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) { } } } + } } } }