From 7f1dff5661bf6b85d0c6d24f8b626ff538c7c78f Mon Sep 17 00:00:00 2001 From: be-nj Date: Wed, 26 Aug 2026 02:59:23 +0200 Subject: [PATCH] =?UTF-8?q?Real=20club=20crests,=20and=20no=20more=20crash?= =?UTF-8?q?=20on=20"Verein=20hinzuf=C3=BCgen"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Crests are resolved from the German Wikipedia page summary at runtime and cached per device, so nothing copyrighted is stored in the repository. The coloured shield remains as the fallback while the badge loads or when a club has none. The add dialog crashed for the same reason the rail did: it focused a row that only exists when something is preselected, and adding a club has no preselection. It now also scrolls, which 56 clubs rather need. CI: this Gitea resolves bare action names against itself, so the workflow now uses full GitHub URLs, and it runs the unit tests too. Co-Authored-By: Claude Fable 5 --- .github/workflows/build.yml | 20 +++++-- app/build.gradle.kts | 4 +- app/src/main/java/dev/castarr/tv/AppState.kt | 1 + .../main/java/dev/castarr/tv/data/Crests.kt | 54 +++++++++++++++++++ .../java/dev/castarr/tv/data/TeamFilters.kt | 9 ++-- .../main/java/dev/castarr/tv/ui/LiveScreen.kt | 30 +++++++++-- .../java/dev/castarr/tv/ui/SettingsScreen.kt | 25 +++++++-- 7 files changed, 127 insertions(+), 16 deletions(-) create mode 100644 app/src/main/java/dev/castarr/tv/data/Crests.kt diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 2b745f8..d228a39 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -8,20 +8,32 @@ jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + # Full URLs: this Gitea resolves bare action names against itself, + # where these actions do not exist. + - uses: https://github.com/actions/checkout@v4 - - uses: actions/setup-java@v4 + - uses: https://github.com/actions/setup-java@v4 with: distribution: temurin java-version: "17" - - uses: gradle/actions/setup-gradle@v4 + - uses: https://github.com/gradle/actions/setup-gradle@v4 + + - name: Unit tests + run: ./gradlew testDebugUnitTest --stacktrace - name: Build debug APK run: ./gradlew assembleDebug --stacktrace - name: Upload APK - uses: actions/upload-artifact@v4 + uses: https://github.com/actions/upload-artifact@v4 with: name: castarr-debug-apk path: app/build/outputs/apk/debug/app-debug.apk + + - name: Upload test report + if: always() + uses: https://github.com/actions/upload-artifact@v4 + with: + name: castarr-test-report + path: tests/runs/ diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 46e841f..bf23e27 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 = 27 - versionName = "0.10.1" + versionCode = 28 + versionName = "0.10.2" } // Release signing from environment (see ~/.keys/castarr-release.env on the diff --git a/app/src/main/java/dev/castarr/tv/AppState.kt b/app/src/main/java/dev/castarr/tv/AppState.kt index e2b078b..44fcc8d 100644 --- a/app/src/main/java/dev/castarr/tv/AppState.kt +++ b/app/src/main/java/dev/castarr/tv/AppState.kt @@ -27,6 +27,7 @@ class AppState( val dispatcharr: DispatcharrRepository, ) { private val prefs = context.getSharedPreferences("app", Context.MODE_PRIVATE) + val crests = dev.castarr.tv.data.Crests(context) var screen by mutableStateOf(Screen.LIVE) var playerVisible by mutableStateOf(false) diff --git a/app/src/main/java/dev/castarr/tv/data/Crests.kt b/app/src/main/java/dev/castarr/tv/data/Crests.kt new file mode 100644 index 0000000..52e39d3 --- /dev/null +++ b/app/src/main/java/dev/castarr/tv/data/Crests.kt @@ -0,0 +1,54 @@ +package dev.castarr.tv.data + +import android.content.Context +import android.util.Log +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.json.JSONObject +import java.net.HttpURLConnection +import java.net.URL +import java.net.URLEncoder + +/** + * Resolves club crests at runtime from Wikipedia's page summary. The badges + * are trademarks, so they are fetched and cached on the device rather than + * shipped with the app — nothing copyrighted lives in the repository. + */ +class Crests(context: Context) { + + private val prefs = context.getSharedPreferences("crests", Context.MODE_PRIVATE) + + /** Cached crest URL for a club, fetching it once per article. */ + suspend fun urlFor(article: String): String? = withContext(Dispatchers.IO) { + prefs.getString(article, null)?.let { return@withContext it.ifEmpty { null } } + val resolved = runCatching { fetch(article) } + .onFailure { Log.w(TAG, "crest lookup failed: ${it.javaClass.simpleName}") } + .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() + resolved + } + + private fun fetch(article: String): String? { + val encoded = URLEncoder.encode(article, "UTF-8").replace("+", "%20") + val connection = URL("$SUMMARY$encoded").openConnection() as HttpURLConnection + return try { + connection.connectTimeout = 10_000 + connection.readTimeout = 10_000 + connection.setRequestProperty("User-Agent", USER_AGENT) + connection.setRequestProperty("Accept", "application/json") + val body = connection.inputStream.bufferedReader().use { it.readText() } + JSONObject(body).optJSONObject("thumbnail")?.optString("source") + ?.takeIf { it.isNotEmpty() } + } finally { + connection.disconnect() + } + } + + private companion object { + const val TAG = "Crests" + const val SUMMARY = "https://de.wikipedia.org/api/rest_v1/page/summary/" + const val USER_AGENT = "Castarr TV (private use)" + } +} 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 7598d58..64402fa 100644 --- a/app/src/main/java/dev/castarr/tv/data/TeamFilters.kt +++ b/app/src/main/java/dev/castarr/tv/data/TeamFilters.kt @@ -5,8 +5,8 @@ import androidx.compose.ui.graphics.Color /** * Per-club shortcut to "where does my club play tonight": a rail entry that * lists every channel whose EPG mentions the club within the next few hours. - * Club crests are trademarks, so the icon is a plain shield in the club - * colours rather than the real badge. + * Crests are fetched from Wikipedia at runtime and cached on the device; + * the club colours are the fallback while that is pending or unavailable. */ data class TeamFilter( val key: String, @@ -18,6 +18,8 @@ data class TeamFilter( val needles: List, val primary: Color, val secondary: Color, + /** German Wikipedia article the crest is resolved from. */ + val article: String, ) { fun matches(title: String): Boolean { val haystack = title.lowercase() @@ -43,7 +45,8 @@ object TeamFilters { needles: List, primary: Long, secondary: Long, - ) = TeamFilter(key, label, fullName, needles, Color(primary), Color(secondary)) + article: String = fullName, + ) = TeamFilter(key, label, fullName, needles, Color(primary), Color(secondary), article) /** Clubs of the top three German divisions. */ val all: List = listOf( 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 b9eb78d..95bb6c5 100644 --- a/app/src/main/java/dev/castarr/tv/ui/LiveScreen.kt +++ b/app/src/main/java/dev/castarr/tv/ui/LiveScreen.kt @@ -193,7 +193,7 @@ fun LiveScreen(state: AppState) { label = club.label, count = matchesByTeam[club.key]?.size ?: 0, selected = state.activeTeam == club.key, - leading = { Crest(club) }, + leading = { Crest(club, state) }, modifier = intoList.then( if (state.activeTeam == club.key) Modifier.focusRequester(railFocus) else Modifier @@ -433,11 +433,33 @@ private fun ChannelRow( } /** - * Stand-in for the club badge: real crests are trademarks, so this draws a - * simple shield in the club colours instead. + * 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. */ @Composable -private fun Crest(team: dev.castarr.tv.data.TeamFilter) { +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) + } + } +} + +@Composable +private fun ShieldFallback(team: dev.castarr.tv.data.TeamFilter) { Canvas(Modifier.size(18.dp)) { val w = size.width val h = size.height 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 f86af0a..6105190 100644 --- a/app/src/main/java/dev/castarr/tv/ui/SettingsScreen.kt +++ b/app/src/main/java/dev/castarr/tv/ui/SettingsScreen.kt @@ -14,9 +14,13 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable @@ -308,7 +312,13 @@ fun SettingsScreen(state: AppState) { @Composable private fun PickerDialog(picker: Picker, onClose: () -> Unit) { val selectedFocus = remember { FocusRequester() } - LaunchedEffect(Unit) { selectedFocus.requestFocus() } + // A requester bound to no composed row throws; with no preselection + // (adding something new) there is deliberately no such row. + LaunchedEffect(Unit) { runCatching { selectedFocus.requestFocus() } } + val listState = rememberLazyListState() + LaunchedEffect(picker.selected) { + if (picker.selected > 0) listState.scrollToItem(picker.selected) + } Dialog( onDismissRequest = onClose, properties = DialogProperties(usePlatformDefaultWidth = false), @@ -337,7 +347,11 @@ private fun PickerDialog(picker: Picker, onClose: () -> Unit) { letterSpacing = 2.sp, modifier = Modifier.padding(start = 14.dp, bottom = 10.dp), ) - picker.options.forEachIndexed { index, option -> + LazyColumn( + state = listState, + modifier = Modifier.heightIn(max = 420.dp), + ) { + itemsIndexed(picker.options) { index, option -> val selected = index == picker.selected Surface( onClick = { @@ -346,7 +360,11 @@ private fun PickerDialog(picker: Picker, onClose: () -> Unit) { }, modifier = Modifier .fillMaxWidth() - .then(if (selected) Modifier.focusRequester(selectedFocus) else Modifier), + .then( + if (selected || (picker.selected < 0 && index == 0)) + Modifier.focusRequester(selectedFocus) + else Modifier + ), shape = ClickableSurfaceDefaults.shape(rowShape), scale = ClickableSurfaceDefaults.scale(focusedScale = 1f), colors = ClickableSurfaceDefaults.colors( @@ -387,6 +405,7 @@ private fun PickerDialog(picker: Picker, onClose: () -> Unit) { } } } + } } } }