3 Commits

Author SHA1 Message Date
be-nj
8aa3a4da02 Ship the real club crests in the APK, and fix the CI licence step
All checks were successful
Build TV app / build (push) Successful in 4m15s
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 <noreply@anthropic.com>
2026-08-26 03:31:06 +02:00
be-nj
b7ee0523d1 CI: build without marketplace actions
Some checks failed
Build TV app / build (push) Failing after 21s
The runner resolves bare action names against this Gitea, and even with
full GitHub URLs it fails on annotated tags (unsupported object type).
The image also ships neither a JDK nor an Android SDK, so the job never
had a chance. Everything is plain shell now: shallow checkout, JDK 17,
command-line SDK tools, tests, debug APK.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 03:10:43 +02:00
be-nj
a3105925bb Make a waiting update visible instead of hiding it in the settings
Some checks failed
Build TV app / build (push) Failing after 5s
The start-up check already ran, but its result only showed as a badge for
whoever happened to open the settings. A chip in the top bar now says
"Update <version>" and leads straight there, and the check repeats every
six hours because a TV keeps the same process alive for days.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 03:07:12 +02:00
10 changed files with 176 additions and 51 deletions

View File

@@ -4,36 +4,56 @@ on:
push:
workflow_dispatch:
# No marketplace actions: this Gitea runner resolves bare action names
# against the local instance, and even with full URLs it cannot resolve
# annotated tags ("unsupported object type"). Everything below is plain
# shell, which also keeps the job readable.
jobs:
build:
runs-on: ubuntu-latest
env:
ANDROID_SDK_ROOT: /opt/android-sdk
CMDLINE_TOOLS: https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip
steps:
# Full URLs: this Gitea resolves bare action names against itself,
# where these actions do not exist.
- uses: https://github.com/actions/checkout@v4
- name: Checkout
run: |
git init -q .
git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"
git fetch -q --depth 1 origin "${GITHUB_SHA}"
git checkout -q FETCH_HEAD
- uses: https://github.com/actions/setup-java@v4
with:
distribution: temurin
java-version: "17"
- name: Install JDK
run: |
apt-get update -qq
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq openjdk-17-jdk-headless > /dev/null
java -version
- uses: https://github.com/gradle/actions/setup-gradle@v4
- name: Install Android SDK
run: |
mkdir -p "$ANDROID_SDK_ROOT/cmdline-tools"
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"
# 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 --stacktrace
run: ./gradlew testDebugUnitTest --no-daemon --stacktrace
- name: Build debug APK
run: ./gradlew assembleDebug --stacktrace
run: ./gradlew assembleDebug --no-daemon --stacktrace
- name: Upload APK
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
- name: Summary
if: always()
uses: https://github.com/actions/upload-artifact@v4
with:
name: castarr-test-report
path: tests/runs/
run: |
echo "APK:"; ls -la app/build/outputs/apk/debug/ 2>/dev/null || echo " (kein Build)"
echo "Tests:"; ls tests/runs/junit/ 2>/dev/null || echo " (keine Reports)"

1
.gitignore vendored
View File

@@ -5,3 +5,4 @@ local.properties
*.iml
.kotlin/
tests/runs/
app/src/main/assets/crests/

View File

@@ -12,8 +12,8 @@ android {
applicationId = "dev.castarr.tv"
minSdk = 26
targetSdk = 35
versionCode = 28
versionName = "0.10.2"
versionCode = 31
versionName = "0.10.5"
}
// Release signing from environment (see ~/.keys/castarr-release.env on the

View File

@@ -50,7 +50,14 @@ class MainActivity : ComponentActivity(), ControlServer.Listener {
server.startServer()
state.remoteAvailable = server.running
setContent { CastarrApp(state) }
lifecycleScope.launch { UpdateChecker.check(state) }
lifecycleScope.launch {
// At start-up and then occasionally: a TV often keeps the same
// app process alive for days.
while (true) {
UpdateChecker.check(state)
delay(UPDATE_CHECK_INTERVAL_MS)
}
}
}
private fun onPlaybackChanged() {
@@ -325,5 +332,6 @@ class MainActivity : ComponentActivity(), ControlServer.Listener {
const val TICK_INTERVAL_MS = 2_000L
const val SEEK_STEP_SECONDS = 10L
const val DIGIT_COMMIT_MS = 1_800L
const val UPDATE_CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000L
}
}

View File

@@ -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()
}

View File

@@ -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. */

View File

@@ -132,6 +132,10 @@ private fun TopBar(state: AppState) {
}
Spacer(Modifier.width(18.dp))
}
state.updateAvailable?.let { version ->
UpdateChip(version) { state.screen = AppState.Screen.SETTINGS }
Spacer(Modifier.width(10.dp))
}
GearButton {
state.screen = if (state.screen == AppState.Screen.LIVE) AppState.Screen.SETTINGS
else AppState.Screen.LIVE
@@ -139,6 +143,33 @@ private fun TopBar(state: AppState) {
}
}
/**
* Shown in the top bar as soon as the start-up check finds a newer
* release — otherwise an update would only be noticed by someone who
* happens to open the settings.
*/
@Composable
private fun UpdateChip(version: String, onClick: () -> Unit) {
Surface(
onClick = onClick,
shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(999.dp)),
colors = ClickableSurfaceDefaults.colors(
containerColor = CastarrColors.accentDim,
contentColor = CastarrColors.accent,
focusedContainerColor = CastarrColors.accent,
focusedContentColor = CastarrColors.onAccent,
),
) {
Text(
"Update $version",
fontFamily = AppFont,
fontSize = 13.sp,
fontWeight = FontWeight.Medium,
modifier = Modifier.padding(horizontal = 14.dp, vertical = 8.dp),
)
}
}
/** Round settings button with a drawn gear (glyphs render as emoji). */
@Composable
private fun GearButton(onClick: () -> Unit) {

View File

@@ -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<String?>(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) },
)
}
}

View File

@@ -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(

54
tools/fetch-crests.py Normal file
View File

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