From 8aa3a4da02566048fd22ac19aaa18404982e3157 Mon Sep 17 00:00:00 2001 From: be-nj Date: Wed, 26 Aug 2026 03:31:06 +0200 Subject: [PATCH] Ship the real club crests in the APK, and fix the CI licence step MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolving crests from Wikipedia at runtime never produced a visible badge on the TV and left nothing in the log to debug, so the shield fallback was all anyone ever saw. They are now downloaded once at build time by tools/fetch-crests.py into assets/crests (git-ignored, so no trademarked artwork is committed) and loaded from there — instant, and independent of the network. CI: accepting SDK licences by piping "yes" into sdkmanager died of SIGPIPE (exit 141) under pipefail; the licence hashes are written directly now. Co-Authored-By: Claude Fable 5 --- .github/workflows/build.yml | 9 +++- .gitignore | 1 + app/build.gradle.kts | 4 +- .../main/java/dev/castarr/tv/data/Crests.kt | 21 +++++--- .../java/dev/castarr/tv/data/TeamFilters.kt | 4 +- .../main/java/dev/castarr/tv/ui/LiveScreen.kt | 29 ++++------ .../java/dev/castarr/tv/ui/SettingsScreen.kt | 11 +++- tools/fetch-crests.py | 54 +++++++++++++++++++ 8 files changed, 103 insertions(+), 30 deletions(-) create mode 100644 tools/fetch-crests.py diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ec96131..69c9d76 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -34,11 +34,18 @@ jobs: curl -sSL -o /tmp/tools.zip "$CMDLINE_TOOLS" unzip -q /tmp/tools.zip -d "$ANDROID_SDK_ROOT/cmdline-tools" mv "$ANDROID_SDK_ROOT/cmdline-tools/cmdline-tools" "$ANDROID_SDK_ROOT/cmdline-tools/latest" - yes | "$ANDROID_SDK_ROOT/cmdline-tools/latest/bin/sdkmanager" --licenses > /dev/null + # Accept licences by writing the hashes: piping "yes" into + # sdkmanager dies of SIGPIPE (exit 141) under pipefail. + mkdir -p "$ANDROID_SDK_ROOT/licenses" + echo "24333f8a63b6825ea9c5514f83c2829b004d1fee" > "$ANDROID_SDK_ROOT/licenses/android-sdk-license" + echo "84831b9409646a918e30573bab4c9c91346d8abd" > "$ANDROID_SDK_ROOT/licenses/android-sdk-preview-license" "$ANDROID_SDK_ROOT/cmdline-tools/latest/bin/sdkmanager" \ "platforms;android-35" "build-tools;35.0.0" "platform-tools" > /dev/null echo "sdk.dir=$ANDROID_SDK_ROOT" > local.properties + - name: Fetch club crests + run: python3 tools/fetch-crests.py || true + - name: Unit tests run: ./gradlew testDebugUnitTest --no-daemon --stacktrace diff --git a/.gitignore b/.gitignore index 2338f9d..014a4d7 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ local.properties *.iml .kotlin/ tests/runs/ +app/src/main/assets/crests/ diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 82e5bdb..62b09d3 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -12,8 +12,8 @@ android { applicationId = "dev.castarr.tv" minSdk = 26 targetSdk = 35 - versionCode = 29 - versionName = "0.10.3" + versionCode = 31 + versionName = "0.10.5" } // Release signing from environment (see ~/.keys/castarr-release.env on the diff --git a/app/src/main/java/dev/castarr/tv/data/Crests.kt b/app/src/main/java/dev/castarr/tv/data/Crests.kt index 52e39d3..a17ec39 100644 --- a/app/src/main/java/dev/castarr/tv/data/Crests.kt +++ b/app/src/main/java/dev/castarr/tv/data/Crests.kt @@ -18,15 +18,22 @@ class Crests(context: Context) { private val prefs = context.getSharedPreferences("crests", Context.MODE_PRIVATE) - /** Cached crest URL for a club, fetching it once per article. */ + /** Cached crest URL for a club, fetched once and then reused. */ suspend fun urlFor(article: String): String? = withContext(Dispatchers.IO) { - prefs.getString(article, null)?.let { return@withContext it.ifEmpty { null } } + prefs.getString(article, null)?.takeIf { it.isNotEmpty() }?.let { + return@withContext it + } val resolved = runCatching { fetch(article) } - .onFailure { Log.w(TAG, "crest lookup failed: ${it.javaClass.simpleName}") } + .onFailure { Log.w(TAG, "crest lookup failed for $article: $it") } .getOrNull() - // Remember misses too, so a club without a crest is not looked up - // again on every screen. - prefs.edit().putString(article, resolved.orEmpty()).apply() + if (resolved != null) { + Log.i(TAG, "crest for $article: $resolved") + prefs.edit().putString(article, resolved).apply() + } else { + // Deliberately not cached: a single failed lookup (no network + // yet at start-up) must not hide the crest forever. + Log.w(TAG, "no crest for $article") + } resolved } @@ -41,6 +48,8 @@ class Crests(context: Context) { val body = connection.inputStream.bufferedReader().use { it.readText() } JSONObject(body).optJSONObject("thumbnail")?.optString("source") ?.takeIf { it.isNotEmpty() } + // Drop the analytics query the API appends. + ?.substringBefore("?") } finally { connection.disconnect() } diff --git a/app/src/main/java/dev/castarr/tv/data/TeamFilters.kt b/app/src/main/java/dev/castarr/tv/data/TeamFilters.kt index 64402fa..4a4841e 100644 --- a/app/src/main/java/dev/castarr/tv/data/TeamFilters.kt +++ b/app/src/main/java/dev/castarr/tv/data/TeamFilters.kt @@ -97,7 +97,7 @@ object TeamFilters { club("duisburg", "MSV", "MSV Duisburg", listOf("msv duisburg", "duisburg"), 0xFF004E9E, WHITE), club("mannheim", "SVWM", "SV Waldhof Mannheim", listOf("waldhof mannheim", "waldhof"), 0xFF0B4EA2, WHITE), club("wehen", "SVWW", "SV Wehen Wiesbaden", listOf("wehen wiesbaden", "wehen"), 0xFFE1000F, BLACK), - club("ulm", "SSV", "SSV Ulm 1846", listOf("ssv ulm"), 0xFFE1000F, WHITE), + club("ulm", "SSV", "SSV Ulm 1846", listOf("ssv ulm"), 0xFFE1000F, WHITE, article = "SSV Ulm 1846 Fußball"), club("regensburg", "SSVJ", "SSV Jahn Regensburg", listOf("jahn regensburg", "regensburg"), 0xFFE1000F, WHITE), club("verl", "SCV", "SC Verl", listOf("sc verl"), 0xFF00703C, WHITE), club("viktoria", "VKÖ", "Viktoria Köln", listOf("viktoria köln"), 0xFFE1000F, WHITE), @@ -108,7 +108,7 @@ object TeamFilters { club("ingolstadt", "FCI", "FC Ingolstadt 04", listOf("ingolstadt"), 0xFFE1000F, BLACK), club("wuppertal", "WSV", "Wuppertaler SV", listOf("wuppertaler sv"), 0xFFE1000F, 0xFF0B4EA2), club("stuttgart-ii", "VfB2", "VfB Stuttgart II", listOf("vfb stuttgart ii"), 0xFFE32219, BLACK), - club("hoffenheim-ii", "TSG2", "TSG Hoffenheim II", listOf("hoffenheim ii"), 0xFF1C63B7, BLACK), + club("hoffenheim-ii", "TSG2", "TSG Hoffenheim II", listOf("hoffenheim ii"), 0xFF1C63B7, BLACK, article = "TSG 1899 Hoffenheim"), ) /** Clubs switched on for a viewer before they touch the settings. */ diff --git a/app/src/main/java/dev/castarr/tv/ui/LiveScreen.kt b/app/src/main/java/dev/castarr/tv/ui/LiveScreen.kt index 95bb6c5..7d1b7bc 100644 --- a/app/src/main/java/dev/castarr/tv/ui/LiveScreen.kt +++ b/app/src/main/java/dev/castarr/tv/ui/LiveScreen.kt @@ -433,28 +433,21 @@ private fun ChannelRow( } /** - * The club badge, resolved from Wikipedia and cached on the device. Until - * it arrives (or if it never does) a shield in the club colours stands in. + * The club badge. The images ship in the APK (fetched at build time by + * tools/fetch-crests.py, never committed), so nothing has to load over the + * network; the coloured shield stands in if one is ever missing. */ @Composable private fun Crest(team: dev.castarr.tv.data.TeamFilter, state: AppState) { - val url by androidx.compose.runtime.produceState(null, team.key) { - value = state.crests.urlFor(team.article) - } Box(Modifier.size(20.dp), contentAlignment = Alignment.Center) { - val crestUrl = url - if (crestUrl != null) { - SubcomposeAsyncImage( - model = crestUrl, - contentDescription = null, - contentScale = ContentScale.Fit, - modifier = Modifier.fillMaxSize(), - loading = { ShieldFallback(team) }, - error = { ShieldFallback(team) }, - ) - } else { - ShieldFallback(team) - } + SubcomposeAsyncImage( + model = "file:///android_asset/crests/${team.key}.png", + contentDescription = null, + contentScale = ContentScale.Fit, + modifier = Modifier.fillMaxSize(), + loading = { ShieldFallback(team) }, + error = { ShieldFallback(team) }, + ) } } diff --git a/app/src/main/java/dev/castarr/tv/ui/SettingsScreen.kt b/app/src/main/java/dev/castarr/tv/ui/SettingsScreen.kt index 6105190..1c44f7f 100644 --- a/app/src/main/java/dev/castarr/tv/ui/SettingsScreen.kt +++ b/app/src/main/java/dev/castarr/tv/ui/SettingsScreen.kt @@ -6,6 +6,8 @@ import androidx.compose.foundation.Canvas import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.border +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -84,7 +86,14 @@ fun SettingsScreen(state: AppState) { .fillMaxSize() .padding(horizontal = 40.dp, vertical = 12.dp) ) { - Column(Modifier.weight(1.25f), verticalArrangement = Arrangement.spacedBy(16.dp)) { + // The cards outgrew one screen once club menus arrived; focus + // movement scrolls this column along. + Column( + Modifier + .weight(1.25f) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { SettingsCard("Konto") { if (state.auth.isLoggedIn) { Row( diff --git a/tools/fetch-crests.py b/tools/fetch-crests.py new file mode 100644 index 0000000..a4274d7 --- /dev/null +++ b/tools/fetch-crests.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python3 +"""Downloads club crests into the APK's assets at build time. + +The badges are trademarks: they are fetched into a generated, git-ignored +folder so the repository stays free of them while the app ships with them +and needs no network at runtime. +""" +import json +import os +import re +import sys +import time +import urllib.parse +import urllib.request + +OUT = sys.argv[1] if len(sys.argv) > 1 else "app/src/main/assets/crests" +SRC = "app/src/main/java/dev/castarr/tv/data/TeamFilters.kt" +SUMMARY = "https://de.wikipedia.org/api/rest_v1/page/summary/" +UA = {"User-Agent": "Castarr build script (private use)"} + +os.makedirs(OUT, exist_ok=True) +kotlin = open(SRC, encoding="utf-8").read() +entries = re.findall(r'club\((.*?)\)\s*,\s*(?://.*)?$', kotlin, re.M | re.S) +clubs = [] +for raw in re.findall(r'club\(\s*"([^"]+)",\s*"[^"]*",\s*"([^"]+)"[^\n]*', kotlin): + clubs.append(raw) +# an explicit article = "..." wins over the club name +overrides = dict(re.findall(r'club\(\s*"([^"]+)"[^\n]*article = "([^"]+)"', kotlin)) +clubs = [(k, overrides.get(k, n)) for k, n in clubs] + +fetched = skipped = failed = 0 +for key, full_name in clubs: + target = os.path.join(OUT, f"{key}.png") + if os.path.exists(target) and os.path.getsize(target) > 0: + skipped += 1 + continue + try: + url = SUMMARY + urllib.parse.quote(full_name) + with urllib.request.urlopen(urllib.request.Request(url, headers=UA), timeout=20) as r: + thumb = json.load(r).get("thumbnail", {}).get("source") + if not thumb: + raise ValueError("no thumbnail") + with urllib.request.urlopen(urllib.request.Request(thumb, headers=UA), timeout=20) as r: + data = r.read() + with open(target, "wb") as f: + f.write(data) + fetched += 1 + except Exception as exc: # noqa: BLE001 - best effort, shield is the fallback + print(f" {key}: {exc}", file=sys.stderr) + failed += 1 + # Wikipedia rate-limits bursts; this runs rarely and caches. + time.sleep(1.2) + +print(f"crests: {fetched} geladen, {skipped} vorhanden, {failed} fehlgeschlagen")