Make the club highlight visible and the updater honest

Three fixes.

The pulse ring never showed. It was drawn on a 30dp canvas inside a box
sized to the 20dp crest, so the parent's constraints shrank the canvas
to 20dp and the crest image covered what was left. The box is now wider
than the crest and the ring has room.

"läuft" was drawn straight onto the row, and a focused row is filled
with the accent colour — red on turquoise. The signal now sits on a
dark chip, which reads on an unfocused row, a selected one and a
focused one alike.

The updater handed the installer whatever had been written so far. It
now checks the response code, downloads to a .part file, compares the
byte count against Content-Length before renaming, and refuses a second
concurrent attempt instead of letting two writers share one file. A
half-written APK leaves the installer spinning with nothing to report,
which is what a hang looks like from the sofa.

Crests are also resampled to 128px with a proper filter when fetched;
Android's 8x downscale from the 330px originals left ragged edges.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
be-nj
2026-08-26 12:04:58 +02:00
parent 8bf1ed3128
commit cbdac7ddb2
3 changed files with 124 additions and 49 deletions

View File

@@ -357,22 +357,33 @@ private fun GroupItem(
// The signal lives in the value column instead of adding a // The signal lives in the value column instead of adding a
// badge — the count is worth less than "now" or "16:00" is. // badge — the count is worth less than "now" or "16:00" is.
val urgent = signal?.urgency ?: AppState.TeamUrgency.NONE val urgent = signal?.urgency ?: AppState.TeamUrgency.NONE
if (urgent == AppState.TeamUrgency.NONE) {
Text( Text(
when (urgent) { "$count",
AppState.TeamUrgency.LIVE -> "läuft"
AppState.TeamUrgency.SOON -> formatClock(signal!!.kickOff)
AppState.TeamUrgency.NONE -> "$count"
},
color = when (urgent) {
AppState.TeamUrgency.LIVE -> CastarrColors.live
AppState.TeamUrgency.SOON -> CastarrColors.accent
AppState.TeamUrgency.NONE -> Color.Unspecified
},
fontFamily = AppFont, fontFamily = AppFont,
fontSize = 12.sp, fontSize = 12.sp,
fontWeight = if (urgent == AppState.TeamUrgency.NONE) FontWeight.Normal
else FontWeight.SemiBold,
) )
} else {
// A focused row is filled with the accent colour, so signal
// colour straight on the row was red on turquoise. The chip
// gives it a dark ground that holds on every row state.
Box(
Modifier
.clip(RoundedCornerShape(999.dp))
.background(CastarrColors.bgDeep.copy(alpha = 0.92f))
.padding(horizontal = 8.dp, vertical = 3.dp)
) {
Text(
if (urgent == AppState.TeamUrgency.LIVE) "läuft"
else formatClock(signal!!.kickOff),
color = if (urgent == AppState.TeamUrgency.LIVE) CastarrColors.live
else CastarrColors.accent,
fontFamily = AppFont,
fontSize = 12.sp,
fontWeight = FontWeight.SemiBold,
)
}
}
} }
} }
} }
@@ -475,7 +486,9 @@ private fun Crest(
state: AppState, state: AppState,
urgency: AppState.TeamUrgency = AppState.TeamUrgency.NONE, urgency: AppState.TeamUrgency = AppState.TeamUrgency.NONE,
) { ) {
Box(Modifier.size(20.dp), contentAlignment = Alignment.Center) { // The box is wider than the crest on purpose: the pulse ring is drawn
// around it, and a box sized to the crest would clip the ring away.
Box(Modifier.size(30.dp), contentAlignment = Alignment.Center) {
if (urgency != AppState.TeamUrgency.NONE) { if (urgency != AppState.TeamUrgency.NONE) {
// A slow pulse is what actually catches the eye from the sofa; // A slow pulse is what actually catches the eye from the sofa;
// colour alone does not at that distance. // colour alone does not at that distance.
@@ -494,11 +507,11 @@ private fun Crest(
val ringColor = val ringColor =
if (urgency == AppState.TeamUrgency.LIVE) CastarrColors.live if (urgency == AppState.TeamUrgency.LIVE) CastarrColors.live
else CastarrColors.accent else CastarrColors.accent
Canvas(Modifier.size(30.dp)) { Canvas(Modifier.fillMaxSize()) {
val grow = phase.coerceAtMost(0.7f) / 0.7f val grow = phase.coerceAtMost(0.75f) / 0.75f
drawCircle( drawCircle(
color = ringColor.copy(alpha = (1f - grow) * 0.9f), color = ringColor.copy(alpha = (1f - grow) * 0.85f),
radius = size.minDimension * (0.33f + grow * 0.17f), radius = size.minDimension * (0.34f + grow * 0.15f),
style = Stroke(width = 2.dp.toPx()), style = Stroke(width = 2.dp.toPx()),
) )
} }
@@ -507,7 +520,8 @@ private fun Crest(
model = "file:///android_asset/crests/${team.key}.png", model = "file:///android_asset/crests/${team.key}.png",
contentDescription = null, contentDescription = null,
contentScale = ContentScale.Fit, contentScale = ContentScale.Fit,
modifier = Modifier.fillMaxSize(), filterQuality = androidx.compose.ui.graphics.FilterQuality.High,
modifier = Modifier.size(20.dp),
loading = { ShieldFallback(team) }, loading = { ShieldFallback(team) },
error = { ShieldFallback(team) }, error = { ShieldFallback(team) },
) )

View File

@@ -27,6 +27,9 @@ object UpdateChecker {
private var apkUrl: String = "" private var apkUrl: String = ""
/** Guards against a second click landing on the same download. */
private val busy = java.util.concurrent.atomic.AtomicBoolean(false)
/** Returns the newer version tag, or null when current. Never throws. */ /** Returns the newer version tag, or null when current. Never throws. */
suspend fun check(state: AppState): String? = withContext(Dispatchers.IO) { suspend fun check(state: AppState): String? = withContext(Dispatchers.IO) {
runCatching { runCatching {
@@ -54,36 +57,70 @@ object UpdateChecker {
}.onFailure { Log.w(TAG, "check failed: ${it.javaClass.simpleName}") }.getOrNull() }.onFailure { Log.w(TAG, "check failed: ${it.javaClass.simpleName}") }.getOrNull()
} }
/** Downloads the APK and hands it to the package installer. */ /**
* Downloads the APK and hands it to the package installer.
*
* The download lands in a .part file that is only renamed once the byte
* count matches what the server announced. Handing a half-written APK to
* the installer leaves it sitting on a spinner with nothing to report,
* which is indistinguishable from a hang.
*/
suspend fun downloadAndInstall(context: Context, state: AppState): String? = suspend fun downloadAndInstall(context: Context, state: AppState): String? =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
// A second click while the first download runs would have two
// writers on one file, and the installer reads whichever half won.
if (!busy.compareAndSet(false, true)) return@withContext "Update läuft bereits"
try {
runCatching { runCatching {
if (apkUrl.isEmpty()) check(state) if (apkUrl.isEmpty()) check(state)
require(apkUrl.isNotEmpty()) { "no update available" } require(apkUrl.isNotEmpty()) { "no update available" }
val dir = File(context.cacheDir, "updates").apply { mkdirs() } val dir = File(context.cacheDir, "updates").apply { mkdirs() }
val file = File(dir, "castarr-update.apk") val file = File(dir, "castarr-update.apk")
(URL(apkUrl).openConnection() as HttpURLConnection).run { val part = File(dir, "castarr-update.apk.part")
connectTimeout = 15_000 part.delete()
readTimeout = 120_000 val connection = URL(apkUrl).openConnection() as HttpURLConnection
instanceFollowRedirects = true val expected = try {
inputStream.use { input -> file.outputStream().use { input.copyTo(it) } } connection.connectTimeout = 15_000
disconnect() connection.readTimeout = 120_000
connection.instanceFollowRedirects = true
val code = connection.responseCode
require(code == HttpURLConnection.HTTP_OK) { "http $code" }
val announced = connection.contentLengthLong
connection.inputStream.use { input ->
part.outputStream().use { output -> input.copyTo(output) }
} }
announced
} finally {
connection.disconnect()
}
require(part.length() > 0) { "empty download" }
require(expected <= 0 || part.length() == expected) {
"truncated: ${part.length()} of $expected"
}
file.delete()
require(part.renameTo(file)) { "rename failed" }
val uri = FileProvider.getUriForFile( val uri = FileProvider.getUriForFile(
context, "${BuildConfig.APPLICATION_ID}.fileprovider", file, context, "${BuildConfig.APPLICATION_ID}.fileprovider", file,
) )
val intent = Intent(Intent.ACTION_VIEW).apply { val intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, "application/vnd.android.package-archive") setDataAndType(uri, "application/vnd.android.package-archive")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK) addFlags(
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK
)
} }
// Launching from the IO dispatcher stalled the installer on // Launching from the IO dispatcher stalled the installer on
// the first attempt — activities start from the main thread. // the first attempt — activities start from the main thread.
withContext(Dispatchers.Main) { context.startActivity(intent) } withContext(Dispatchers.Main) { context.startActivity(intent) }
null null
}.getOrElse { }.getOrElse {
Log.w(TAG, "install failed: ${it.javaClass.simpleName}") Log.w(TAG, "install failed: ${it.javaClass.simpleName}: ${it.message}")
File(context.cacheDir, "updates/castarr-update.apk.part").delete()
"Update fehlgeschlagen — später erneut versuchen" "Update fehlgeschlagen — später erneut versuchen"
} }
} finally {
busy.set(false)
}
} }
private fun isNewer(remote: String, local: String): Boolean { private fun isNewer(remote: String, local: String): Boolean {

View File

@@ -27,6 +27,30 @@ if os.path.exists(URLS):
kotlin = open(SRC, encoding="utf-8").read() kotlin = open(SRC, encoding="utf-8").read()
entries = re.findall(r'club\((.*?)\)\s*,\s*(?://.*)?$', kotlin, re.M | re.S) entries = re.findall(r'club\((.*?)\)\s*,\s*(?://.*)?$', kotlin, re.M | re.S)
clubs = [] clubs = []
# Wikipedia serves ~330px thumbnails. The rail draws them at 20dp, which on a
# 1080p TV is 40px -- an 8x downscale that Android does with a cheap filter,
# and the edges come out ragged. Resampling once here with a proper filter
# fixes that and shrinks the APK.
CREST_PX = 128
def downscale(data: bytes) -> bytes:
try:
from PIL import Image
except ImportError:
return data
import io
image = Image.open(io.BytesIO(data)).convert("RGBA")
if max(image.size) <= CREST_PX:
return data
scale = CREST_PX / max(image.size)
size = (max(1, round(image.width * scale)), max(1, round(image.height * scale)))
out = io.BytesIO()
image.resize(size, Image.LANCZOS).save(out, format="PNG", optimize=True)
return out.getvalue()
for raw in re.findall(r'club\(\s*"([^"]+)",\s*"[^"]*",\s*"([^"]+)"[^\n]*', kotlin): for raw in re.findall(r'club\(\s*"([^"]+)",\s*"[^"]*",\s*"([^"]+)"[^\n]*', kotlin):
clubs.append(raw) clubs.append(raw)
# an explicit article = "..." wins over the club name # an explicit article = "..." wins over the club name
@@ -52,7 +76,7 @@ for key, full_name in clubs:
with urllib.request.urlopen(urllib.request.Request(thumb, headers=UA), timeout=20) as r: with urllib.request.urlopen(urllib.request.Request(thumb, headers=UA), timeout=20) as r:
data = r.read() data = r.read()
with open(target, "wb") as f: with open(target, "wb") as f:
f.write(data) f.write(downscale(data))
fetched += 1 fetched += 1
except Exception as exc: # noqa: BLE001 - best effort, shield is the fallback except Exception as exc: # noqa: BLE001 - best effort, shield is the fallback
print(f" {key}: {exc}", file=sys.stderr) print(f" {key}: {exc}", file=sys.stderr)