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")