7 Commits

Author SHA1 Message Date
be-nj
e35500fa2f CI: cache the JDK, the SDK and Gradle on the runner host
All checks were successful
Build TV app / build (push) Successful in 2m42s
Every push re-installed a JDK, re-downloaded the Android SDK and let Gradle
fetch its distribution and every dependency again — roughly two of the three
minutes a run took, spent on bytes that never change.

The job now mounts a directory from the runner host at /cache and keeps the
JDK, the SDK and GRADLE_USER_HOME there; both setup steps are skipped when
what they install is already present. Tests and the APK build share one
Gradle invocation instead of paying twice for JVM startup and configuration.

Needs the mount whitelisted in the runner's valid_volumes
(/etc/komodo/stacks/gitea/runner-cache/** on diana).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 14:15:18 +02:00
be-nj
b52ef79c01 Test the three bugs that reached the living room, and script the release
The rate limit, the version compare and the download check were all wrapped
in Android — a Context, a socket, a file — so none of them had a test, and
all three shipped broken: the remote thrown out after five reconnects
(0.11.1), a truncated APK handed to the installer (0.11.5), and a version
compare that would read 0.11.10 as older than 0.11.9 the moment we get
there.

They are now plain Kotlin in AttemptBudget and UpdateRules, with the clock
injected, and 20 tests covering the failures themselves. AttemptBudget also
prunes expired entries before evicting: dropping only empty queues let the
map grow without bound while every tracked address held one fresh failure.

tests/smoke.sh installs the debug APK on the headless emulator, walks the
first screen with the D-pad and fails on a crash or ANR — the class of bug
unit tests cannot see, and the reason 0.11.0 crashed on startup. Forty
seconds end to end.

tools/release.sh does the bump, tests, signed build, tag, apk branch and
Gitea release in one command. It refuses an unsigned build and never writes
the release notes itself; commit subjects are offered as a draft.

tests/helpers/jdk.sh picks a JDK Gradle can run on: Ubuntu moved
default-java to 25, which fails the build with a bare
"IllegalArgumentException: 25.0.4".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 14:12:51 +02:00
be-nj
f1a6600e42 Emulator helper so tests start in seconds, not minutes
All checks were successful
Build TV app / build (push) Successful in 2m54s
The Google TV emulator was launched by hand with -no-snapshot, which forces
a cold boot every time. tests/helpers/emulator.sh boots from the AVD's
default_boot snapshot (~7s) and refreshes that snapshot on stop, so the next
run stays fast. It also wraps the two things a test run needs afterwards:
installing an APK and pulling a screenshot into tests/runs/.

CONTEXT.md's example dialogue still described Xtream credentials entered
through the Remote, which ADR-0005 and ADR-0007 replaced with device flow and
phone-side onboarding.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 13:29:33 +02:00
be-nj
637679fd90 Release 0.11.5
All checks were successful
Build TV app / build (push) Successful in 3m13s
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 12:07:24 +02:00
be-nj
cbdac7ddb2 Make the club highlight visible and the updater honest
Three fixes.

The pulse ring never showed. It was drawn on a 30dp canvas inside a box
sized to the 20dp crest, so the parent's constraints shrank the canvas
to 20dp and the crest image covered what was left. The box is now wider
than the crest and the ring has room.

"läuft" was drawn straight onto the row, and a focused row is filled
with the accent colour — red on turquoise. The signal now sits on a
dark chip, which reads on an unfocused row, a selected one and a
focused one alike.

The updater handed the installer whatever had been written so far. It
now checks the response code, downloads to a .part file, compares the
byte count against Content-Length before renaming, and refuses a second
concurrent attempt instead of letting two writers share one file. A
half-written APK leaves the installer spinning with nothing to report,
which is what a hang looks like from the sofa.

Crests are also resampled to 128px with a proper filter when fetched;
Android's 8x downscale from the 330px originals left ragged edges.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 12:04:58 +02:00
be-nj
8bf1ed3128 Give Heidenheim its own short label
Two clubs mapped to FCH; Hansa Rostock keeps it, Heidenheim becomes
HDH, which is what its own supporters use anyway.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 11:49:37 +02:00
be-nj
3b6a8d7d0a Show crests and league sections when picking a club
All checks were successful
Build TV app / build (push) Successful in 2m53s
The club chooser was a flat list of 56 names in a generic picker
dialog, which is unreadable from a couch. It now has its own dialog
with a crest per row, the short label on the right, and section
headers per division. Enabled clubs in the settings card show their
crest too.

A search field would have been the obvious alternative, but text
entry on a remote is exactly what the Ten-Foot rule rules out, so
grouping plus crests carries the recognition instead.

Also adds tests/demo, a generator for neutral playlist and EPG data
so screenshots never carry real channel names.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 11:42:18 +02:00
21 changed files with 1267 additions and 208 deletions

View File

@@ -11,8 +11,19 @@ on:
jobs: jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
container:
image: docker.gitea.com/runner-images:ubuntu-latest
# A directory on the runner host, whitelisted in the runner's
# valid_volumes. Everything the job would otherwise download on every
# push — JDK, Android SDK, Gradle distribution and dependencies —
# lives here and survives the container.
options: -v /etc/komodo/stacks/gitea/runner-cache/castarr:/cache
env: env:
ANDROID_SDK_ROOT: /opt/android-sdk CACHE: /cache
JAVA_HOME: /cache/jdk17
ANDROID_SDK_ROOT: /cache/android-sdk
GRADLE_USER_HOME: /cache/gradle
JDK_URL: https://api.adoptium.net/v3/binary/latest/17/ga/linux/x64/jdk/hotspot/normal/eclipse
CMDLINE_TOOLS: https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip CMDLINE_TOOLS: https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip
steps: steps:
- name: Checkout - name: Checkout
@@ -22,35 +33,42 @@ jobs:
git fetch -q --depth 1 origin "${GITHUB_SHA}" git fetch -q --depth 1 origin "${GITHUB_SHA}"
git checkout -q FETCH_HEAD git checkout -q FETCH_HEAD
- name: Install JDK - name: JDK 17
run: | run: |
apt-get update -qq if [ ! -x "$JAVA_HOME/bin/java" ]; then
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq openjdk-17-jdk-headless > /dev/null echo "JDK nicht im Cache — einmalig herunterladen"
java -version mkdir -p "$JAVA_HOME"
curl -sSL "$JDK_URL" | tar -xz -C "$JAVA_HOME" --strip-components=1
fi
"$JAVA_HOME/bin/java" -version
- name: Install Android SDK - name: Android SDK
run: | run: |
if [ ! -d "$ANDROID_SDK_ROOT/platforms/android-35" ]; then
echo "SDK nicht im Cache — einmalig einrichten"
mkdir -p "$ANDROID_SDK_ROOT/cmdline-tools" mkdir -p "$ANDROID_SDK_ROOT/cmdline-tools"
curl -sSL -o /tmp/tools.zip "$CMDLINE_TOOLS" curl -sSL -o /tmp/tools.zip "$CMDLINE_TOOLS"
unzip -q /tmp/tools.zip -d "$ANDROID_SDK_ROOT/cmdline-tools" unzip -q -o /tmp/tools.zip -d "$ANDROID_SDK_ROOT/cmdline-tools"
rm -rf "$ANDROID_SDK_ROOT/cmdline-tools/latest"
mv "$ANDROID_SDK_ROOT/cmdline-tools/cmdline-tools" "$ANDROID_SDK_ROOT/cmdline-tools/latest" mv "$ANDROID_SDK_ROOT/cmdline-tools/cmdline-tools" "$ANDROID_SDK_ROOT/cmdline-tools/latest"
# Accept licences by writing the hashes: piping "yes" into # Accept licences by writing the hashes: piping "yes" into
# sdkmanager dies of SIGPIPE (exit 141) under pipefail. # sdkmanager dies of SIGPIPE (exit 141) under pipefail.
mkdir -p "$ANDROID_SDK_ROOT/licenses" mkdir -p "$ANDROID_SDK_ROOT/licenses"
echo "24333f8a63b6825ea9c5514f83c2829b004d1fee" > "$ANDROID_SDK_ROOT/licenses/android-sdk-license" echo "24333f8a63b6825ea9c5514f83c2829b004d1fee" > "$ANDROID_SDK_ROOT/licenses/android-sdk-license"
echo "84831b9409646a918e30573bab4c9c91346d8abd" > "$ANDROID_SDK_ROOT/licenses/android-sdk-preview-license" echo "84831b9409646a918e30573bab4c9c91346d8abd" > "$ANDROID_SDK_ROOT/licenses/android-sdk-preview-license"
"$ANDROID_SDK_ROOT/cmdline-tools/latest/bin/sdkmanager" \ JAVA_HOME="$JAVA_HOME" "$ANDROID_SDK_ROOT/cmdline-tools/latest/bin/sdkmanager" \
"platforms;android-35" "build-tools;35.0.0" "platform-tools" > /dev/null "platforms;android-35" "build-tools;35.0.0" "platform-tools" > /dev/null
fi
echo "sdk.dir=$ANDROID_SDK_ROOT" > local.properties echo "sdk.dir=$ANDROID_SDK_ROOT" > local.properties
- name: Unit tests # One invocation, not two: a second --no-daemon run pays for another JVM
run: ./gradlew testDebugUnitTest --no-daemon --stacktrace # start and another configuration phase to redo work it just did.
- name: Tests und Debug-APK
- name: Build debug APK run: ./gradlew testDebugUnitTest assembleDebug --no-daemon --stacktrace
run: ./gradlew assembleDebug --no-daemon --stacktrace
- name: Summary - name: Summary
if: always() if: always()
run: | run: |
echo "APK:"; ls -la app/build/outputs/apk/debug/ 2>/dev/null || echo " (kein Build)" 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)" echo "Tests:"; ls tests/runs/junit/ 2>/dev/null || echo " (keine Reports)"
echo "Cache:"; du -sh "$CACHE"/* 2>/dev/null || echo " (leer)"

2
.gitignore vendored
View File

@@ -6,3 +6,5 @@ local.properties
.kotlin/ .kotlin/
tests/runs/ tests/runs/
app/src/main/assets/crests/ app/src/main/assets/crests/
tests/demo/playlist.m3u
tests/demo/epg.xml

View File

@@ -73,9 +73,10 @@ Channel, no full guide timeline.
## Example dialogue ## Example dialogue
> **Dev:** "Does the **Remote** need Backend credentials?" > **Dev:** "Does the **Remote** need Backend credentials?"
> **Domain expert:** "No — the user enters the Xtream credentials of a > **Domain expert:** "No — the Remote is where **Onboarding** happens, not
> **Quelle** once via the Remote, the **TV-App** stores them and is the only > where credentials live: the user types the server URL there and finishes the
> one talking to the **Backend**." > IdP login on the phone. The **TV-App** holds the tokens and is the only one
> talking to the **Backend**. A generic M3U **Quelle** needs no login at all."
## Flagged ambiguities ## Flagged ambiguities

View File

@@ -79,4 +79,22 @@ sind signiert; über einem Debug-Build muss einmal deinstalliert werden
(Signaturwechsel). Updates bezieht die App über die Release-API dieses (Signaturwechsel). Updates bezieht die App über die Release-API dieses
Repos, die APK selbst liegt auf dem Branch `apk`. Repos, die APK selbst liegt auf dem Branch `apk`.
### Tests
```
./gradlew test # Unit-Tests, Reports unter tests/runs/
tests/helpers/emulator.sh start # headloser Google-TV-Emulator
tests/helpers/emulator.sh install app/build/outputs/apk/debug/app-debug.apk
tests/helpers/emulator.sh shot name # Screenshot nach tests/runs/screenshots/
tests/helpers/emulator.sh stop
```
Der Emulator (AVD `castarr-googletv`, API 34) startet aus einem Snapshot und
ist in wenigen Sekunden oben; `stop` schreibt den Snapshot vorher neu. Ein
Kaltstart entsteht nur, wenn der Snapshot fehlt.
Demo-Daten für einen Lauf ohne echtes Backend: `tests/demo/make-demo-data.py`
erzeugt Playlist und EPG, die ein lokaler HTTP-Server als generische Quelle
ausliefert.
Architektur-Notizen: [CONTEXT.md](CONTEXT.md) und [docs/adr/](docs/adr/). Architektur-Notizen: [CONTEXT.md](CONTEXT.md) und [docs/adr/](docs/adr/).

View File

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

View File

@@ -179,19 +179,25 @@ class AppState(
/** How urgent a club menu is right now — drives the rail highlight. */ /** How urgent a club menu is right now — drives the rail highlight. */
enum class TeamUrgency { NONE, SOON, LIVE } enum class TeamUrgency { NONE, SOON, LIVE }
/** The urgent state plus the kick-off it refers to, for the rail label. */
data class TeamSignal(val urgency: TeamUrgency, val kickOff: Long)
/** /**
* LIVE while a match is running, SOON within half an hour of kick-off — * LIVE while a match is running, SOON within half an hour of kick-off —
* the window in which someone actually wants to be nudged. * the window in which someone actually wants to be nudged.
*/ */
fun teamUrgency(matches: List<Pair<Channel, TeamHit>>): TeamUrgency { fun teamSignal(matches: List<Pair<Channel, TeamHit>>): TeamSignal {
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
var soon = false var soonAt = 0L
matches.forEach { (_, hit) -> matches.forEach { (_, hit) ->
val p = hit.programme val p = hit.programme
if (now in p.start until p.stop) return TeamUrgency.LIVE if (now in p.start until p.stop) return TeamSignal(TeamUrgency.LIVE, p.start)
if (p.start in now..(now + SOON_WINDOW_MS)) soon = true if (p.start in now..(now + SOON_WINDOW_MS) && (soonAt == 0L || p.start < soonAt)) {
soonAt = p.start
} }
return if (soon) TeamUrgency.SOON else TeamUrgency.NONE }
return if (soonAt > 0) TeamSignal(TeamUrgency.SOON, soonAt)
else TeamSignal(TeamUrgency.NONE, 0L)
} }
/** /**

View File

@@ -20,6 +20,8 @@ data class TeamFilter(
val secondary: Color, val secondary: Color,
/** German Wikipedia article the crest is resolved from. */ /** German Wikipedia article the crest is resolved from. */
val article: String, val article: String,
/** 1, 2 or 3 — groups the picker by division. */
val league: Int,
) { ) {
fun matches(title: String): Boolean { fun matches(title: String): Boolean {
val haystack = title.lowercase() val haystack = title.lowercase()
@@ -63,69 +65,70 @@ object TeamFilters {
primary: Long, primary: Long,
secondary: Long, secondary: Long,
article: String = fullName, article: String = fullName,
) = TeamFilter(key, label, fullName, needles, Color(primary), Color(secondary), article) league: Int = 1,
) = TeamFilter(key, label, fullName, needles, Color(primary), Color(secondary), article, league)
/** Clubs of the top three German divisions. */ /** Clubs of the top three German divisions. */
val all: List<TeamFilter> = listOf( val all: List<TeamFilter> = listOf(
// --- Bundesliga --- // --- Bundesliga ---
club("bayern", "FCB", "FC Bayern München", listOf("bayern münchen", "fc bayern"), 0xFFDC052D, WHITE), club("bayern", "FCB", "FC Bayern München", listOf("bayern münchen", "fc bayern"), 0xFFDC052D, WHITE, league = 1),
club("bvb", "BVB", "Borussia Dortmund", listOf("borussia dortmund", "bvb"), 0xFFFDE100, BLACK), club("bvb", "BVB", "Borussia Dortmund", listOf("borussia dortmund", "bvb"), 0xFFFDE100, BLACK, league = 1),
club("leipzig", "RBL", "RB Leipzig", listOf("rb leipzig"), 0xFFDD0741, WHITE), club("leipzig", "RBL", "RB Leipzig", listOf("rb leipzig"), 0xFFDD0741, WHITE, league = 1),
club("leverkusen", "B04", "Bayer 04 Leverkusen", listOf("leverkusen"), 0xFFE32219, BLACK), club("leverkusen", "B04", "Bayer 04 Leverkusen", listOf("leverkusen"), 0xFFE32219, BLACK, league = 1),
club("frankfurt", "SGE", "Eintracht Frankfurt", listOf("eintracht frankfurt"), 0xFF1A1A1A, 0xFFE1000F), club("frankfurt", "SGE", "Eintracht Frankfurt", listOf("eintracht frankfurt"), 0xFF1A1A1A, 0xFFE1000F, league = 1),
club("stuttgart", "1893", "VfB Stuttgart", listOf("vfb stuttgart"), 0xFFE32219, WHITE), club("stuttgart", "1893", "VfB Stuttgart", listOf("vfb stuttgart"), 0xFFE32219, WHITE, league = 1),
club("gladbach", "BMG", "Borussia Mönchengladbach", listOf("mönchengladbach", "gladbach"), 0xFF1A1A1A, 0xFF00A94F), club("gladbach", "BMG", "Borussia Mönchengladbach", listOf("mönchengladbach", "gladbach"), 0xFF1A1A1A, 0xFF00A94F, league = 1),
club("wolfsburg", "WOB", "VfL Wolfsburg", listOf("wolfsburg"), 0xFF65B32E, WHITE), club("wolfsburg", "WOB", "VfL Wolfsburg", listOf("wolfsburg"), 0xFF65B32E, WHITE, league = 1),
club("bremen", "SVW", "SV Werder Bremen", listOf("werder bremen", "werder"), 0xFF1D9053, WHITE), club("bremen", "SVW", "SV Werder Bremen", listOf("werder bremen", "werder"), 0xFF1D9053, WHITE, league = 1),
club("freiburg", "SCF", "SC Freiburg", listOf("sc freiburg", "freiburg"), 0xFFE2001A, WHITE), club("freiburg", "SCF", "SC Freiburg", listOf("sc freiburg", "freiburg"), 0xFFE2001A, WHITE, league = 1),
club("hoffenheim", "TSG", "TSG Hoffenheim", listOf("hoffenheim"), 0xFF1C63B7, WHITE), club("hoffenheim", "TSG", "TSG Hoffenheim", listOf("hoffenheim"), 0xFF1C63B7, WHITE, league = 1),
club("mainz", "M05", "1. FSV Mainz 05", listOf("mainz 05", "mainz"), 0xFFE1000F, WHITE), club("mainz", "M05", "1. FSV Mainz 05", listOf("mainz 05", "mainz"), 0xFFE1000F, WHITE, league = 1),
club("augsburg", "FCA", "FC Augsburg", listOf("augsburg"), 0xFF00693F, 0xFFE1000F), club("augsburg", "FCA", "FC Augsburg", listOf("augsburg"), 0xFF00693F, 0xFFE1000F, league = 1),
club("union", "FCU", "1. FC Union Berlin", listOf("union berlin"), 0xFFE1000F, 0xFFFDE100), club("union", "FCU", "1. FC Union Berlin", listOf("union berlin"), 0xFFE1000F, 0xFFFDE100, league = 1),
club("koeln", "EFC", "1. FC Köln", listOf("1. fc köln", "fc köln"), 0xFFE1000F, WHITE), club("koeln", "EFC", "1. FC Köln", listOf("1. fc köln", "fc köln"), 0xFFE1000F, WHITE, league = 1),
club("hsv", "HSV", "Hamburger SV", listOf("hamburger sv", "hsv"), 0xFF0E5EA6, BLACK), club("hsv", "HSV", "Hamburger SV", listOf("hamburger sv", "hsv"), 0xFF0E5EA6, BLACK, league = 1),
club("heidenheim", "FCH1", "1. FC Heidenheim", listOf("heidenheim"), 0xFFE1000F, 0xFF1656A4), club("heidenheim", "HDH", "1. FC Heidenheim", listOf("heidenheim"), 0xFFE1000F, 0xFF1656A4, league = 1),
club("st-pauli", "FCSP", "FC St. Pauli", listOf("st. pauli", "st pauli"), 0xFF6B4423, WHITE), club("st-pauli", "FCSP", "FC St. Pauli", listOf("st. pauli", "st pauli"), 0xFF6B4423, WHITE, league = 1),
// --- 2. Bundesliga --- // --- 2. Bundesliga ---
club("schalke", "S04", "FC Schalke 04", listOf("schalke"), 0xFF004D9D, WHITE), club("schalke", "S04", "FC Schalke 04", listOf("schalke"), 0xFF004D9D, WHITE, league = 2),
club("hertha", "BSC", "Hertha BSC", listOf("hertha"), 0xFF004D9D, WHITE), club("hertha", "BSC", "Hertha BSC", listOf("hertha"), 0xFF004D9D, WHITE, league = 2),
club("duesseldorf", "F95", "Fortuna Düsseldorf", listOf("fortuna düsseldorf"), 0xFFE1000F, WHITE), club("duesseldorf", "F95", "Fortuna Düsseldorf", listOf("fortuna düsseldorf"), 0xFFE1000F, WHITE, league = 2),
club("nuernberg", "FCN", "1. FC Nürnberg", listOf("nürnberg"), 0xFF8B1A1A, WHITE), club("nuernberg", "FCN", "1. FC Nürnberg", listOf("nürnberg"), 0xFF8B1A1A, WHITE, league = 2),
club("kaiserslautern", "FCK", "1. FC Kaiserslautern", listOf("kaiserslautern"), 0xFFE1000F, WHITE), club("kaiserslautern", "FCK", "1. FC Kaiserslautern", listOf("kaiserslautern"), 0xFFE1000F, WHITE, league = 2),
club("karlsruhe", "KSC", "Karlsruher SC", listOf("karlsruher sc", "ksc"), 0xFF0055A5, WHITE), club("karlsruhe", "KSC", "Karlsruher SC", listOf("karlsruher sc", "ksc"), 0xFF0055A5, WHITE, league = 2),
club("hannover", "H96", "Hannover 96", listOf("hannover 96"), 0xFF00A94F, WHITE), club("hannover", "H96", "Hannover 96", listOf("hannover 96"), 0xFF00A94F, WHITE, league = 2),
club("paderborn", "SCP", "SC Paderborn 07", listOf("paderborn"), 0xFF004D9D, WHITE), club("paderborn", "SCP", "SC Paderborn 07", listOf("paderborn"), 0xFF004D9D, WHITE, league = 2),
club("magdeburg", "FCM", "1. FC Magdeburg", listOf("magdeburg"), 0xFF0B7A3E, WHITE), club("magdeburg", "FCM", "1. FC Magdeburg", listOf("magdeburg"), 0xFF0B7A3E, WHITE, league = 2),
club("elversberg", "SVE", "SV Elversberg", listOf("elversberg"), 0xFF1A1A1A, 0xFFE1000F), club("elversberg", "SVE", "SV Elversberg", listOf("elversberg"), 0xFF1A1A1A, 0xFFE1000F, league = 2),
club("darmstadt", "SV98", "SV Darmstadt 98", listOf("darmstadt"), 0xFF004E9E, WHITE), club("darmstadt", "SV98", "SV Darmstadt 98", listOf("darmstadt"), 0xFF004E9E, WHITE, league = 2),
club("braunschweig", "BTSV", "Eintracht Braunschweig", listOf("braunschweig"), 0xFFFDE100, 0xFF004E9E), club("braunschweig", "BTSV", "Eintracht Braunschweig", listOf("braunschweig"), 0xFFFDE100, 0xFF004E9E, league = 2),
club("bochum", "BOC", "VfL Bochum", listOf("bochum"), 0xFF005CA9, WHITE), club("bochum", "BOC", "VfL Bochum", listOf("bochum"), 0xFF005CA9, WHITE, league = 2),
club("muenster", "SCPM", "Preußen Münster", listOf("preußen münster", "münster"), 0xFF00703C, WHITE), club("muenster", "SCPM", "Preußen Münster", listOf("preußen münster", "münster"), 0xFF00703C, WHITE, league = 2),
club("fuerth", "SGF", "SpVgg Greuther Fürth", listOf("greuther fürth", "fürth"), 0xFF00A94F, WHITE), club("fuerth", "SGF", "SpVgg Greuther Fürth", listOf("greuther fürth", "fürth"), 0xFF00A94F, WHITE, league = 2),
club("holstein", "KSV", "Holstein Kiel", listOf("holstein kiel"), 0xFF004E9E, 0xFFE1000F), club("holstein", "KSV", "Holstein Kiel", listOf("holstein kiel"), 0xFF004E9E, 0xFFE1000F, league = 2),
club("dresden", "SGD", "Dynamo Dresden", listOf("dynamo dresden"), 0xFFFDE100, BLACK), club("dresden", "SGD", "Dynamo Dresden", listOf("dynamo dresden"), 0xFFFDE100, BLACK, league = 2),
club("bielefeld", "DSC", "Arminia Bielefeld", listOf("arminia bielefeld", "bielefeld"), 0xFF00539F, WHITE), club("bielefeld", "DSC", "Arminia Bielefeld", listOf("arminia bielefeld", "bielefeld"), 0xFF00539F, WHITE, league = 2),
// --- 3. Liga --- // --- 3. Liga ---
club("hansa", "FCH", "FC Hansa Rostock", listOf("hansa"), 0xFF0B4EA2, WHITE), club("hansa", "FCH", "FC Hansa Rostock", listOf("hansa"), 0xFF0B4EA2, WHITE, league = 3),
club("saarbruecken", "FCS", "1. FC Saarbrücken", listOf("saarbrücken"), 0xFF0B4EA2, BLACK), club("saarbruecken", "FCS", "1. FC Saarbrücken", listOf("saarbrücken"), 0xFF0B4EA2, BLACK, league = 3),
club("aue", "FCE", "Erzgebirge Aue", listOf("erzgebirge aue"), 0xFF7C0A02, WHITE), club("aue", "FCE", "Erzgebirge Aue", listOf("erzgebirge aue"), 0xFF7C0A02, WHITE, league = 3),
club("cottbus", "FCEC", "Energie Cottbus", listOf("energie cottbus", "cottbus"), 0xFFE1000F, WHITE), club("cottbus", "FCEC", "Energie Cottbus", listOf("energie cottbus", "cottbus"), 0xFFE1000F, WHITE, league = 3),
club("essen", "RWE", "Rot-Weiss Essen", listOf("rot-weiss essen", "rot weiss essen"), 0xFFE1000F, WHITE), club("essen", "RWE", "Rot-Weiss Essen", listOf("rot-weiss essen", "rot weiss essen"), 0xFFE1000F, WHITE, league = 3),
club("duisburg", "MSV", "MSV Duisburg", listOf("msv duisburg", "duisburg"), 0xFF004E9E, WHITE), club("duisburg", "MSV", "MSV Duisburg", listOf("msv duisburg", "duisburg"), 0xFF004E9E, WHITE, league = 3),
club("mannheim", "SVWM", "SV Waldhof Mannheim", listOf("waldhof mannheim", "waldhof"), 0xFF0B4EA2, WHITE), club("mannheim", "SVWM", "SV Waldhof Mannheim", listOf("waldhof mannheim", "waldhof"), 0xFF0B4EA2, WHITE, league = 3),
club("wehen", "SVWW", "SV Wehen Wiesbaden", listOf("wehen wiesbaden", "wehen"), 0xFFE1000F, BLACK), club("wehen", "SVWW", "SV Wehen Wiesbaden", listOf("wehen wiesbaden", "wehen"), 0xFFE1000F, BLACK, league = 3),
club("ulm", "SSV", "SSV Ulm 1846", listOf("ssv ulm"), 0xFFE1000F, WHITE, article = "SSV Ulm 1846 Fußball"), club("ulm", "SSV", "SSV Ulm 1846", listOf("ssv ulm"), 0xFFE1000F, WHITE, article = "SSV Ulm 1846 Fußball", league = 3),
club("regensburg", "SSVJ", "SSV Jahn Regensburg", listOf("jahn regensburg", "regensburg"), 0xFFE1000F, WHITE), club("regensburg", "SSVJ", "SSV Jahn Regensburg", listOf("jahn regensburg", "regensburg"), 0xFFE1000F, WHITE, league = 3),
club("verl", "SCV", "SC Verl", listOf("sc verl"), 0xFF00703C, WHITE), club("verl", "SCV", "SC Verl", listOf("sc verl"), 0xFF00703C, WHITE, league = 3),
club("viktoria", "VKÖ", "Viktoria Köln", listOf("viktoria köln"), 0xFFE1000F, WHITE), club("viktoria", "VKÖ", "Viktoria Köln", listOf("viktoria köln"), 0xFFE1000F, WHITE, league = 3),
club("havelse", "TSVH", "TSV Havelse", listOf("havelse"), 0xFF004E9E, WHITE), club("havelse", "TSVH", "TSV Havelse", listOf("havelse"), 0xFF004E9E, WHITE, league = 3),
club("schweinfurt", "FC05", "1. FC Schweinfurt 05", listOf("schweinfurt"), 0xFF00A94F, WHITE), club("schweinfurt", "FC05", "1. FC Schweinfurt 05", listOf("schweinfurt"), 0xFF00A94F, WHITE, league = 3),
club("osnabrueck", "VfLO", "VfL Osnabrück", listOf("osnabrück"), 0xFF6A2C8F, WHITE), club("osnabrueck", "VfLO", "VfL Osnabrück", listOf("osnabrück"), 0xFF6A2C8F, WHITE, league = 3),
club("aachen", "ALE", "Alemannia Aachen", listOf("alemannia aachen", "aachen"), 0xFFFDE100, BLACK), club("aachen", "ALE", "Alemannia Aachen", listOf("alemannia aachen", "aachen"), 0xFFFDE100, BLACK, league = 3),
club("ingolstadt", "FCI", "FC Ingolstadt 04", listOf("ingolstadt"), 0xFFE1000F, BLACK), club("ingolstadt", "FCI", "FC Ingolstadt 04", listOf("ingolstadt"), 0xFFE1000F, BLACK, league = 3),
club("wuppertal", "WSV", "Wuppertaler SV", listOf("wuppertaler sv"), 0xFFE1000F, 0xFF0B4EA2), club("wuppertal", "WSV", "Wuppertaler SV", listOf("wuppertaler sv"), 0xFFE1000F, 0xFF0B4EA2, league = 3),
club("stuttgart-ii", "VfB2", "VfB Stuttgart II", listOf("vfb stuttgart ii"), 0xFFE32219, BLACK), club("stuttgart-ii", "VfB2", "VfB Stuttgart II", listOf("vfb stuttgart ii"), 0xFFE32219, BLACK, league = 3),
club("hoffenheim-ii", "TSG2", "TSG Hoffenheim II", listOf("hoffenheim ii"), 0xFF1C63B7, BLACK, article = "TSG 1899 Hoffenheim"), club("hoffenheim-ii", "TSG2", "TSG Hoffenheim II", listOf("hoffenheim ii"), 0xFF1C63B7, BLACK, article = "TSG 1899 Hoffenheim", league = 3),
) )
/** Clubs switched on for a viewer before they touch the settings. */ /** Clubs switched on for a viewer before they touch the settings. */
@@ -155,6 +158,14 @@ object TeamFilters {
fun byKey(key: String): TeamFilter? = all.firstOrNull { it.key == key } fun byKey(key: String): TeamFilter? = all.firstOrNull { it.key == key }
/** Clubs grouped by division, in the order the picker shows them. */
fun byLeague(clubs: List<TeamFilter> = all): List<Pair<String, List<TeamFilter>>> =
listOf(
"1. Bundesliga" to clubs.filter { it.league == 1 },
"2. Bundesliga" to clubs.filter { it.league == 2 },
"3. Liga" to clubs.filter { it.league == 3 },
).filter { it.second.isNotEmpty() }
fun defaultKeysFor(username: String): List<String> = fun defaultKeysFor(username: String): List<String> =
defaultForUser[username.trim().lowercase()].orEmpty() defaultForUser[username.trim().lowercase()].orEmpty()
} }

View File

@@ -0,0 +1,78 @@
package dev.castarr.tv.server
/**
* Per-address budget for failed authentication attempts.
*
* Kept free of Android and of the clock so the rule itself can be tested:
* this is where the phone remote was thrown out in 0.11.0, because every
* connection cost budget instead of only the failed ones. A remote
* reconnects on every network hiccup, and five reconnects a minute are
* normal traffic, not an attack.
*/
class AttemptBudget(
private val windowMs: Long = WINDOW_MS,
private val maxFailures: Int = MAX_FAILURES,
private val maxTrackedAddresses: Int = MAX_TRACKED_ADDRESSES,
private val now: () -> Long = System::currentTimeMillis,
) {
private val failures = HashMap<String, ArrayDeque<Long>>()
/** True while this address may still try. Never consumes budget. */
@Synchronized
fun allows(address: String): Boolean {
val queue = failures[address] ?: return true
prune(queue, now())
if (queue.isEmpty()) failures.remove(address)
return queue.size < maxFailures
}
/** Only a *failed* authentication costs budget. */
@Synchronized
fun recordFailure(address: String) {
val timestamp = now()
val queue = failures.getOrPut(address) { ArrayDeque() }
queue.addLast(timestamp)
if (failures.size > maxTrackedAddresses) evict(timestamp)
}
/** A successful authentication wipes the address clean. */
@Synchronized
fun clear(address: String) {
failures.remove(address)
}
@Synchronized
fun trackedAddresses(): Int = failures.size
private fun prune(queue: ArrayDeque<Long>, timestamp: Long) {
while (queue.isNotEmpty() && timestamp - queue.first() > windowMs) {
queue.removeFirst()
}
}
/**
* Expired entries first, and only then the oldest addresses. Dropping
* just the empty queues left the map growing without bound as long as
* every tracked address still held one fresh failure.
*/
private fun evict(timestamp: Long) {
val iterator = failures.entries.iterator()
while (iterator.hasNext()) {
val entry = iterator.next()
prune(entry.value, timestamp)
if (entry.value.isEmpty()) iterator.remove()
}
if (failures.size <= maxTrackedAddresses) return
failures.entries
.sortedBy { it.value.firstOrNull() ?: 0L }
.take(failures.size - maxTrackedAddresses)
.forEach { failures.remove(it.key) }
}
companion object {
const val WINDOW_MS = 60_000L
const val MAX_FAILURES = 5
const val MAX_TRACKED_ADDRESSES = 64
}
}

View File

@@ -59,40 +59,9 @@ class ControlServer(
var running = false var running = false
private set private set
// Failed authentication attempts per remote address. Counting every // Failed authentication attempts per remote address. The rule itself
// failure (not just the ones carrying a code) keeps a hostile client // lives in AttemptBudget, where it is unit-tested.
// from spending someone else's budget. private val attempts = AttemptBudget()
private val attempts = HashMap<String, ArrayDeque<Long>>()
/** True while this address may still try; does not consume budget. */
@Synchronized
private fun attemptAllowed(address: String): Boolean {
val now = System.currentTimeMillis()
val queue = attempts[address] ?: return true
while (queue.isNotEmpty() && now - queue.first() > ATTEMPT_WINDOW_MS) {
queue.removeFirst()
}
return queue.size < ATTEMPT_MAX
}
/**
* Only *failed* authentication costs budget. Counting successes too
* threw out honest clients: the phone remote reconnects on every
* network hiccup, and five reconnects a minute are normal.
*/
@Synchronized
private fun recordFailure(address: String) {
val queue = attempts.getOrPut(address) { ArrayDeque() }
queue.addLast(System.currentTimeMillis())
if (attempts.size > MAX_TRACKED_ADDRESSES) {
attempts.entries.removeAll { it.value.isEmpty() }
}
}
@Synchronized
private fun clearFailures(address: String) {
attempts.remove(address)
}
fun startServer() { fun startServer() {
// A busy port must not take the whole app down — the remote is // A busy port must not take the whole app down — the remote is
@@ -312,7 +281,7 @@ class ControlServer(
} }
private fun handleHello(msg: JSONObject) { private fun handleHello(msg: JSONObject) {
if (!attemptAllowed(remoteAddress)) { if (!attempts.allows(remoteAddress)) {
trySend(JSONObject().put("type", "error").put("error", "rate_limited").toString()) trySend(JSONObject().put("type", "error").put("error", "rate_limited").toString())
runCatching { close(WebSocketFrame.CloseCode.PolicyViolation, "rate limited", false) } runCatching { close(WebSocketFrame.CloseCode.PolicyViolation, "rate limited", false) }
return return
@@ -320,12 +289,12 @@ class ControlServer(
val tokenOk = Pairing.isValidToken(context, msg.optString("token")) val tokenOk = Pairing.isValidToken(context, msg.optString("token"))
val codeOk = !tokenOk && Pairing.isValidCode(context, msg.optString("code")) val codeOk = !tokenOk && Pairing.isValidCode(context, msg.optString("code"))
if (!tokenOk && !codeOk) { if (!tokenOk && !codeOk) {
recordFailure(remoteAddress) attempts.recordFailure(remoteAddress)
trySend(JSONObject().put("type", "error").put("error", "bad_code").toString()) trySend(JSONObject().put("type", "error").put("error", "bad_code").toString())
runCatching { close(WebSocketFrame.CloseCode.PolicyViolation, "bad code", false) } runCatching { close(WebSocketFrame.CloseCode.PolicyViolation, "bad code", false) }
return return
} }
clearFailures(remoteAddress) attempts.clear(remoteAddress)
authorized = true authorized = true
deviceName = msg.optString("name").ifEmpty { "Handy" } deviceName = msg.optString("name").ifEmpty { "Handy" }
// A code-authenticated client gets its own revocable token, never // A code-authenticated client gets its own revocable token, never
@@ -360,9 +329,6 @@ class ControlServer(
const val TAG = "ControlServer" const val TAG = "ControlServer"
const val PING_INTERVAL_MS = 8_000L const val PING_INTERVAL_MS = 8_000L
const val SOCKET_TIMEOUT_MS = 40_000 const val SOCKET_TIMEOUT_MS = 40_000
const val ATTEMPT_WINDOW_MS = 60_000L
const val ATTEMPT_MAX = 5
const val MAX_TRACKED_ADDRESSES = 64
const val MAX_CLIENTS = 8 const val MAX_CLIENTS = 8
const val HANDSHAKE_TIMEOUT_MS = 10_000L const val HANDSHAKE_TIMEOUT_MS = 10_000L
val PING_PAYLOAD = byteArrayOf(0x6e, 0x63) val PING_PAYLOAD = byteArrayOf(0x6e, 0x63)

View File

@@ -21,6 +21,7 @@ import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.animation.core.animateFloat
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -32,6 +33,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.clipPath import androidx.compose.ui.graphics.drawscope.clipPath
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
@@ -199,14 +201,15 @@ fun LiveScreen(state: AppState) {
} }
items(teams, key = { it.key }) { club -> items(teams, key = { it.key }) { club ->
val clubMatches = matchesByTeam[club.key].orEmpty() val clubMatches = matchesByTeam[club.key].orEmpty()
val clubSignal = remember(clubMatches, urgencyTick) {
state.teamSignal(clubMatches)
}
GroupItem( GroupItem(
label = club.label, label = club.label,
count = clubMatches.size, count = clubMatches.size,
selected = state.activeTeam == club.key, selected = state.activeTeam == club.key,
leading = { Crest(club, state) }, leading = { Crest(club, state, clubSignal.urgency) },
urgency = remember(clubMatches, urgencyTick) { signal = clubSignal,
state.teamUrgency(clubMatches)
},
modifier = intoList.then( modifier = intoList.then(
if (state.activeTeam == club.key) Modifier.focusRequester(railFocus) if (state.activeTeam == club.key) Modifier.focusRequester(railFocus)
else Modifier else Modifier
@@ -312,8 +315,8 @@ private fun GroupItem(
selected: Boolean, selected: Boolean,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
leading: (@Composable () -> Unit)? = null, leading: (@Composable () -> Unit)? = null,
/** Replaces the count and adds a dot when a match is on or imminent. */ /** Replaces the count when a match is on or about to start. */
urgency: AppState.TeamUrgency = AppState.TeamUrgency.NONE, signal: AppState.TeamSignal? = null,
suppressAutoSelect: () -> Boolean = { false }, suppressAutoSelect: () -> Boolean = { false },
onSelect: () -> Unit, onSelect: () -> Unit,
) { ) {
@@ -351,31 +354,35 @@ private fun GroupItem(
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
Spacer(Modifier.width(8.dp)) Spacer(Modifier.width(8.dp))
when (urgency) { // The signal lives in the value column instead of adding a
AppState.TeamUrgency.LIVE, AppState.TeamUrgency.SOON -> { // badge — the count is worth less than "now" or "16:00" is.
val accent = val urgent = signal?.urgency ?: AppState.TeamUrgency.NONE
if (urgency == AppState.TeamUrgency.LIVE) CastarrColors.live if (urgent == AppState.TeamUrgency.NONE) {
else CastarrColors.accent Text(
"$count",
fontFamily = AppFont,
fontSize = 12.sp,
)
} else {
// A focused row is filled with the accent colour, so signal
// colour straight on the row was red on turquoise. The chip
// gives it a dark ground that holds on every row state.
Box( Box(
Modifier Modifier
.size(6.dp) .clip(RoundedCornerShape(999.dp))
.clip(CircleShape) .background(CastarrColors.bgDeep.copy(alpha = 0.92f))
.background(accent) .padding(horizontal = 8.dp, vertical = 3.dp)
) ) {
Spacer(Modifier.width(6.dp))
Text( Text(
if (urgency == AppState.TeamUrgency.LIVE) "läuft" else "gleich", if (urgent == AppState.TeamUrgency.LIVE) "läuft"
color = accent, else formatClock(signal!!.kickOff),
color = if (urgent == AppState.TeamUrgency.LIVE) CastarrColors.live
else CastarrColors.accent,
fontFamily = AppFont, fontFamily = AppFont,
fontSize = 12.sp, fontSize = 12.sp,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
) )
} }
AppState.TeamUrgency.NONE -> Text(
"$count",
fontFamily = AppFont,
fontSize = 12.sp,
)
} }
} }
} }
@@ -474,13 +481,47 @@ private fun ChannelRow(
* network; the coloured shield stands in if one is ever missing. * network; the coloured shield stands in if one is ever missing.
*/ */
@Composable @Composable
private fun Crest(team: dev.castarr.tv.data.TeamFilter, state: AppState) { private fun Crest(
Box(Modifier.size(20.dp), contentAlignment = Alignment.Center) { team: dev.castarr.tv.data.TeamFilter,
state: AppState,
urgency: AppState.TeamUrgency = AppState.TeamUrgency.NONE,
) {
// The box is wider than the crest on purpose: the pulse ring is drawn
// around it, and a box sized to the crest would clip the ring away.
Box(Modifier.size(30.dp), contentAlignment = Alignment.Center) {
if (urgency != AppState.TeamUrgency.NONE) {
// A slow pulse is what actually catches the eye from the sofa;
// colour alone does not at that distance.
val transition = androidx.compose.animation.core.rememberInfiniteTransition(
label = "crest-pulse",
)
val phase by transition.animateFloat(
initialValue = 0f,
targetValue = 1f,
animationSpec = androidx.compose.animation.core.infiniteRepeatable(
androidx.compose.animation.core.tween(2000, easing = androidx.compose.animation.core.LinearEasing),
androidx.compose.animation.core.RepeatMode.Restart,
),
label = "phase",
)
val ringColor =
if (urgency == AppState.TeamUrgency.LIVE) CastarrColors.live
else CastarrColors.accent
Canvas(Modifier.fillMaxSize()) {
val grow = phase.coerceAtMost(0.75f) / 0.75f
drawCircle(
color = ringColor.copy(alpha = (1f - grow) * 0.85f),
radius = size.minDimension * (0.34f + grow * 0.15f),
style = Stroke(width = 2.dp.toPx()),
)
}
}
SubcomposeAsyncImage( SubcomposeAsyncImage(
model = "file:///android_asset/crests/${team.key}.png", model = "file:///android_asset/crests/${team.key}.png",
contentDescription = null, contentDescription = null,
contentScale = ContentScale.Fit, contentScale = ContentScale.Fit,
modifier = Modifier.fillMaxSize(), filterQuality = androidx.compose.ui.graphics.FilterQuality.High,
modifier = Modifier.size(20.dp),
loading = { ShieldFallback(team) }, loading = { ShieldFallback(team) },
error = { ShieldFallback(team) }, error = { ShieldFallback(team) },
) )

View File

@@ -21,6 +21,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
@@ -79,6 +80,7 @@ fun SettingsScreen(state: AppState) {
val profiles by state.dispatcharr.profiles.collectAsState() val profiles by state.dispatcharr.profiles.collectAsState()
var updateStatus by remember { mutableStateOf("") } var updateStatus by remember { mutableStateOf("") }
var picker by remember { mutableStateOf<Picker?>(null) } var picker by remember { mutableStateOf<Picker?>(null) }
var clubPicker by remember { mutableStateOf(false) }
// Bumped on reset so the QR code and the four-digit code redraw. // Bumped on reset so the QR code and the four-digit code redraw.
var pairingEpoch by remember { mutableStateOf(0) } var pairingEpoch by remember { mutableStateOf(0) }
@@ -191,6 +193,7 @@ fun SettingsScreen(state: AppState) {
SettingRow( SettingRow(
club.fullName, club.fullName,
subtitle = "Erscheint als ${club.label} in der Senderliste · Entfernen", subtitle = "Erscheint als ${club.label} in der Senderliste · Entfernen",
leading = { ClubCrest(club, state) },
trailing = { RemoveIcon() }, trailing = { RemoveIcon() },
) { state.toggleTeam(club.key) } ) { state.toggleTeam(club.key) }
} }
@@ -198,17 +201,7 @@ fun SettingsScreen(state: AppState) {
SettingRow( SettingRow(
"Verein hinzufügen", "Verein hinzufügen",
subtitle = "1. bis 3. Liga", subtitle = "1. bis 3. Liga",
) { ) { clubPicker = true }
val choices = dev.castarr.tv.data.TeamFilters.all
.filter { it.key !in state.enabledTeams }
picker = Picker(
"Verein hinzufügen",
choices.map { it.fullName },
-1,
) { index ->
choices.getOrNull(index)?.let { state.toggleTeam(it.key) }
}
}
} }
} }
@@ -320,6 +313,155 @@ fun SettingsScreen(state: AppState) {
picker?.let { current -> picker?.let { current ->
PickerDialog(current) { picker = null } PickerDialog(current) { picker = null }
} }
if (clubPicker) {
ClubPickerDialog(
state = state,
onPick = { club -> state.toggleTeam(club.key); clubPicker = false },
onClose = { clubPicker = false },
)
}
}
/** Club crest from the bundled assets; initials while it loads. */
@Composable
private fun ClubCrest(team: dev.castarr.tv.data.TeamFilter, state: AppState, size: Int = 26) {
Box(Modifier.size(size.dp), contentAlignment = Alignment.Center) {
coil.compose.SubcomposeAsyncImage(
model = "file:///android_asset/crests/${team.key}.png",
contentDescription = null,
contentScale = androidx.compose.ui.layout.ContentScale.Fit,
modifier = Modifier.fillMaxSize(),
loading = { CrestInitials(team) },
error = { CrestInitials(team) },
)
}
}
@Composable
private fun CrestInitials(team: dev.castarr.tv.data.TeamFilter) {
Box(
Modifier
.fillMaxSize()
.clip(CircleShape)
.background(team.primary),
contentAlignment = Alignment.Center,
) {
Text(
team.label.take(3),
color = team.secondary,
fontFamily = AppFont,
fontSize = 8.sp,
fontWeight = FontWeight.Bold,
)
}
}
/**
* Club chooser: crests plus division headers, because a flat list of 56
* names is unreadable on a remote.
*/
@Composable
private fun ClubPickerDialog(
state: AppState,
onPick: (dev.castarr.tv.data.TeamFilter) -> Unit,
onClose: () -> Unit,
) {
val firstFocus = remember { FocusRequester() }
LaunchedEffect(Unit) { runCatching { firstFocus.requestFocus() } }
val sections = remember(state.enabledTeams) {
dev.castarr.tv.data.TeamFilters.byLeague(
dev.castarr.tv.data.TeamFilters.all.filter { it.key !in state.enabledTeams }
)
}
val firstKey = sections.firstOrNull()?.second?.firstOrNull()?.key
Dialog(onDismissRequest = onClose, properties = DialogProperties(usePlatformDefaultWidth = false)) {
Box(
Modifier
.fillMaxSize()
.background(CastarrColors.bgDeep.copy(alpha = 0.88f)),
contentAlignment = Alignment.Center,
) {
Column(
Modifier
.width(420.dp)
.clip(RoundedCornerShape(16.dp))
.background(CastarrColors.surface)
.padding(horizontal = 10.dp, vertical = 14.dp)
) {
Text(
"VEREIN HINZUFÜGEN",
color = CastarrColors.faint,
fontFamily = AppFont,
fontSize = 11.sp,
fontWeight = FontWeight.SemiBold,
letterSpacing = 2.sp,
modifier = Modifier.padding(start = 14.dp, bottom = 10.dp),
)
LazyColumn(Modifier.heightIn(max = 460.dp)) {
sections.forEach { (league, clubs) ->
item(key = "h-$league") {
Text(
league,
color = CastarrColors.accent,
fontFamily = AppFont,
fontSize = 11.sp,
fontWeight = FontWeight.SemiBold,
letterSpacing = 1.5.sp,
modifier = Modifier.padding(start = 14.dp, top = 12.dp, bottom = 6.dp),
)
}
items(clubs, key = { it.key }) { club ->
val focusMod =
if (club.key == firstKey) Modifier.focusRequester(firstFocus)
else Modifier
Surface(
onClick = { onPick(club) },
modifier = Modifier
.fillMaxWidth()
.then(focusMod),
shape = ClickableSurfaceDefaults.shape(rowShape),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1f),
colors = ClickableSurfaceDefaults.colors(
containerColor = androidx.compose.ui.graphics.Color.Transparent,
contentColor = CastarrColors.fg,
focusedContainerColor = CastarrColors.surfaceFocused,
focusedContentColor = CastarrColors.fg,
),
border = ClickableSurfaceDefaults.border(
focusedBorder = Border(
border = BorderStroke(2.dp, CastarrColors.accent),
shape = rowShape,
),
),
) {
Row(
modifier = Modifier.padding(horizontal = 14.dp, vertical = 9.dp),
verticalAlignment = Alignment.CenterVertically,
) {
ClubCrest(club, state, size = 24)
Spacer(Modifier.width(12.dp))
Text(
club.fullName,
fontFamily = AppFont,
fontSize = 14.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
Text(
club.label,
color = CastarrColors.faint,
fontFamily = AppFont,
fontSize = 12.sp,
)
}
}
}
}
}
}
}
}
} }
/** TV-friendly dropdown replacement: fullscreen scrim, options centered. */ /** TV-friendly dropdown replacement: fullscreen scrim, options centered. */
@@ -512,6 +654,7 @@ private fun SettingRow(
label: String, label: String,
subtitle: String? = null, subtitle: String? = null,
danger: Boolean = false, danger: Boolean = false,
leading: (@Composable () -> Unit)? = null,
trailing: (@Composable () -> Unit)? = null, trailing: (@Composable () -> Unit)? = null,
onClick: () -> Unit, onClick: () -> Unit,
) { ) {
@@ -537,6 +680,10 @@ private fun SettingRow(
modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp), modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
if (leading != null) {
leading()
Spacer(Modifier.width(12.dp))
}
Column(Modifier.weight(1f)) { Column(Modifier.weight(1f)) {
Text( Text(
label, label,

View File

@@ -27,6 +27,9 @@ object UpdateChecker {
private var apkUrl: String = "" private var apkUrl: String = ""
/** Guards against a second click landing on the same download. */
private val busy = java.util.concurrent.atomic.AtomicBoolean(false)
/** Returns the newer version tag, or null when current. Never throws. */ /** Returns the newer version tag, or null when current. Never throws. */
suspend fun check(state: AppState): String? = withContext(Dispatchers.IO) { suspend fun check(state: AppState): String? = withContext(Dispatchers.IO) {
runCatching { runCatching {
@@ -42,7 +45,7 @@ object UpdateChecker {
} }
} }
if (url.isEmpty() && tag.isNotEmpty()) url = APK_FALLBACK if (url.isEmpty() && tag.isNotEmpty()) url = APK_FALLBACK
if (url.isNotEmpty() && isNewer(tag, BuildConfig.VERSION_NAME)) { if (url.isNotEmpty() && UpdateRules.isNewer(tag, BuildConfig.VERSION_NAME)) {
apkUrl = url apkUrl = url
val version = "v$tag" val version = "v$tag"
withContext(Dispatchers.Main) { state.updateAvailable = version } withContext(Dispatchers.Main) { state.updateAvailable = version }
@@ -54,48 +57,69 @@ object UpdateChecker {
}.onFailure { Log.w(TAG, "check failed: ${it.javaClass.simpleName}") }.getOrNull() }.onFailure { Log.w(TAG, "check failed: ${it.javaClass.simpleName}") }.getOrNull()
} }
/** Downloads the APK and hands it to the package installer. */ /**
* Downloads the APK and hands it to the package installer.
*
* The download lands in a .part file that is only renamed once the byte
* count matches what the server announced. Handing a half-written APK to
* the installer leaves it sitting on a spinner with nothing to report,
* which is indistinguishable from a hang.
*/
suspend fun downloadAndInstall(context: Context, state: AppState): String? = suspend fun downloadAndInstall(context: Context, state: AppState): String? =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
// A second click while the first download runs would have two
// writers on one file, and the installer reads whichever half won.
if (!busy.compareAndSet(false, true)) return@withContext "Update läuft bereits"
try {
runCatching { runCatching {
if (apkUrl.isEmpty()) check(state) if (apkUrl.isEmpty()) check(state)
require(apkUrl.isNotEmpty()) { "no update available" } require(apkUrl.isNotEmpty()) { "no update available" }
val dir = File(context.cacheDir, "updates").apply { mkdirs() } val dir = File(context.cacheDir, "updates").apply { mkdirs() }
val file = File(dir, "castarr-update.apk") val file = File(dir, "castarr-update.apk")
(URL(apkUrl).openConnection() as HttpURLConnection).run { val part = File(dir, "castarr-update.apk.part")
connectTimeout = 15_000 part.delete()
readTimeout = 120_000 val connection = URL(apkUrl).openConnection() as HttpURLConnection
instanceFollowRedirects = true val expected = try {
inputStream.use { input -> file.outputStream().use { input.copyTo(it) } } connection.connectTimeout = 15_000
disconnect() connection.readTimeout = 120_000
connection.instanceFollowRedirects = true
val code = connection.responseCode
require(code == HttpURLConnection.HTTP_OK) { "http $code" }
val announced = connection.contentLengthLong
connection.inputStream.use { input ->
part.outputStream().use { output -> input.copyTo(output) }
} }
announced
} finally {
connection.disconnect()
}
require(UpdateRules.isComplete(part.length(), expected)) {
"truncated: ${part.length()} of $expected"
}
file.delete()
require(part.renameTo(file)) { "rename failed" }
val uri = FileProvider.getUriForFile( val uri = FileProvider.getUriForFile(
context, "${BuildConfig.APPLICATION_ID}.fileprovider", file, context, "${BuildConfig.APPLICATION_ID}.fileprovider", file,
) )
val intent = Intent(Intent.ACTION_VIEW).apply { val intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, "application/vnd.android.package-archive") setDataAndType(uri, "application/vnd.android.package-archive")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK) addFlags(
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK
)
} }
// Launching from the IO dispatcher stalled the installer on // Launching from the IO dispatcher stalled the installer on
// the first attempt — activities start from the main thread. // the first attempt — activities start from the main thread.
withContext(Dispatchers.Main) { context.startActivity(intent) } withContext(Dispatchers.Main) { context.startActivity(intent) }
null null
}.getOrElse { }.getOrElse {
Log.w(TAG, "install failed: ${it.javaClass.simpleName}") Log.w(TAG, "install failed: ${it.javaClass.simpleName}: ${it.message}")
File(context.cacheDir, "updates/castarr-update.apk.part").delete()
"Update fehlgeschlagen — später erneut versuchen" "Update fehlgeschlagen — später erneut versuchen"
} }
} finally {
busy.set(false)
} }
private fun isNewer(remote: String, local: String): Boolean {
fun parts(v: String) = v.split(".").mapNotNull { it.toIntOrNull() }
val r = parts(remote)
val l = parts(local)
for (i in 0 until maxOf(r.size, l.size)) {
val a = r.getOrElse(i) { 0 }
val b = l.getOrElse(i) { 0 }
if (a != b) return a > b
}
return false
} }
private fun get(url: String): String { private fun get(url: String): String {

View File

@@ -0,0 +1,45 @@
package dev.castarr.tv.update
/**
* The two decisions the updater makes, without Android or a network in the
* way: is the offered release newer, and did the download arrive whole.
*
* Both shipped as bugs once — a version compare that reads "0.11.10" as
* older than "0.11.9", and a half-written APK handed to the package
* installer, which then sits on a spinner with nothing to report.
*/
object UpdateRules {
/**
* Compares dotted numeric versions segment by segment, missing segments
* counting as zero ("0.12" == "0.12.0"). A leading "v" is tolerated on
* either side; anything non-numeric is ignored rather than throwing,
* because a release tag is user input.
*/
fun isNewer(remote: String, local: String): Boolean {
val r = segments(remote)
val l = segments(local)
for (i in 0 until maxOf(r.size, l.size)) {
val a = r.getOrElse(i) { 0 }
val b = l.getOrElse(i) { 0 }
if (a != b) return a > b
}
return false
}
/**
* True when the bytes on disk match what the server announced.
*
* A server that announces nothing (chunked transfer, `announced <= 0`)
* cannot be checked against — then any non-empty file has to pass, which
* is the honest answer rather than a guess.
*/
fun isComplete(actualBytes: Long, announcedBytes: Long): Boolean {
if (actualBytes <= 0) return false
if (announcedBytes <= 0) return true
return actualBytes == announcedBytes
}
private fun segments(version: String) =
version.removePrefix("v").split(".").mapNotNull { it.toIntOrNull() }
}

107
tests/demo/make-demo-data.py Executable file
View File

@@ -0,0 +1,107 @@
#!/usr/bin/env python3
"""Neutral demo line-up for screenshots and manual testing.
Invented channels and programmes only — no real provider data ever ends up
in a screenshot. One club match is placed a few minutes ahead so the club
menu shows its "kick-off imminent" state.
"""
import argparse
import datetime
import os
p = argparse.ArgumentParser()
p.add_argument("--out", default=os.path.dirname(os.path.abspath(__file__)))
p.add_argument("--kickoff-in", type=int, default=18,
help="minutes until the club match starts")
args = p.parse_args()
now = datetime.datetime.now().astimezone()
def fmt(dt):
# Local offset, so the emulator's clock agrees with ours (CEST vs CET).
return dt.strftime("%Y%m%d%H%M%S %z")
CHANNELS = [
("Unterhaltung", "Blau TV HD", "blau"),
("Unterhaltung", "Nordlicht HD", "nordlicht"),
("Unterhaltung", "Kanal Sieben", "sieben"),
("Filme", "Kinohaus HD", "kinohaus"),
("Filme", "Filmwerk Classic", "filmwerk"),
("Filme", "Nachtkino HD", "nachtkino"),
("Serien", "Serienbox HD", "serienbox"),
("Serien", "Staffel Eins", "staffel"),
("Sport", "Arena Sport 1 HD", "arena1"),
("Sport", "Arena Sport 2 HD", "arena2"),
("Sport", "Liga Live UHD", "ligalive"),
("Sport", "Stadionkanal HD", "stadion"),
("Nachrichten", "Tagesblick HD", "tagesblick"),
("Nachrichten", "Weltbericht", "weltbericht"),
("Doku & Wissen", "Terra Doku HD", "terra"),
("Doku & Wissen", "Wissenswelt", "wissen"),
("Kinder", "Krümelkiste", "kruemel"),
("Musik", "Tonspur HD", "tonspur"),
("Regional", "Hafenkanal HD", "hafen"),
("International", "Globus TV", "globus"),
]
PROGRAMMES = {
"blau": [("Morgenmagazin", -40, 80), ("Kochduell", 40, 60)],
"nordlicht": [("Küstenrevier", -20, 70), ("Nordlicht Talk", 50, 45)],
"sieben": [("Quiz um Sieben", -10, 60), ("Sieben Reportage", 50, 45)],
"kinohaus": [("Der weite Weg", -55, 125), ("Sturmhöhe", 70, 110)],
"filmwerk": [("Schwarzweiß", -30, 95), ("Die Erbschaft", 65, 100)],
"nachtkino": [("Mitternachtszug", -15, 105), ("Letzte Runde", 90, 95)],
"serienbox": [("Praxis Ostwind (3/12)", -25, 45), ("Praxis Ostwind (4/12)", 20, 45)],
"staffel": [("Hafenwache (7/10)", -35, 50), ("Hafenwache (8/10)", 15, 50)],
"arena1": [("Handball: Pokalrunde", -45, 105), ("Sport am Abend", 60, 60)],
"arena2": [("Radsport: Etappe 4", -50, 130), ("Motorsport kompakt", 80, 55)],
"ligalive": [("Sport aktuell", -25, 43), ("__CLUB__", None, 120)],
"stadion": [("Stadionmagazin", -20, 60), ("FC St. Pauli: Der Rückblick", 40, 45)],
"tagesblick": [("Tagesblick am Mittag", -12, 30), ("Wetter und Verkehr", 18, 15)],
"weltbericht": [("Weltbericht Spezial", -35, 75), ("Auslandsjournal", 40, 45)],
"terra": [("Wale der Arktis", -40, 90), ("Vulkane Islands", 50, 45)],
"wissen": [("Wie Brücken halten", -20, 45), ("Chemie des Alltags", 25, 45)],
"kruemel": [("Bärenbande", -15, 25), ("Malstunde", 10, 25)],
"tonspur": [("Charts der Woche", -60, 120), ("Akustik-Session", 60, 60)],
"hafen": [("Hafenrundfahrt", -30, 60), ("Regional um sechs", 30, 30)],
"globus": [("Globus Reportage", -45, 90), ("Fernweh", 45, 60)],
}
CLUB_TITLE = "Pokal: Nordstadt - FC St. Pauli, 1. Runde"
os.makedirs(args.out, exist_ok=True)
m3u = os.path.join(args.out, "playlist.m3u")
xml = os.path.join(args.out, "epg.xml")
with open(m3u, "w", encoding="utf-8") as f:
f.write("#EXTM3U\n")
for group, name, cid in CHANNELS:
f.write(f'#EXTINF:-1 tvg-id="{cid}" group-title="{group}",{name}\n')
f.write(f"http://10.0.2.2:8099/stream/{cid}.ts\n")
def escape(text):
return text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
with open(xml, "w", encoding="utf-8") as f:
f.write('<?xml version="1.0" encoding="UTF-8"?>\n<tv>\n')
for _, name, cid in CHANNELS:
f.write(f' <channel id="{cid}"><display-name>{escape(name)}</display-name></channel>\n')
for cid, items in PROGRAMMES.items():
for title, offset, length in items:
if title == "__CLUB__":
title, offset = CLUB_TITLE, args.kickoff_in
start = now + datetime.timedelta(minutes=offset)
stop = start + datetime.timedelta(minutes=length)
f.write(
f' <programme start="{fmt(start)}" stop="{fmt(stop)}" channel="{cid}">'
f"<title>{escape(title)}</title></programme>\n"
)
f.write("</tv>\n")
print(f"{len(CHANNELS)} Sender, {sum(len(v) for v in PROGRAMMES.values())} Sendungen")
print(f"Anstoß in {args.kickoff_in} Min: {CLUB_TITLE}")
print(f"-> {m3u}\n-> {xml}")

70
tests/helpers/emulator.sh Executable file
View File

@@ -0,0 +1,70 @@
#!/usr/bin/env bash
# Headless Google-TV emulator for test runs.
#
# The AVD keeps a "default_boot" snapshot, so a start restores a booted
# system in seconds instead of cold-booting for minutes. "stop" refreshes
# that snapshot before killing, so the next start stays fast.
#
# tests/helpers/emulator.sh start # boot (or restore) and wait
# tests/helpers/emulator.sh stop # snapshot, then kill
# tests/helpers/emulator.sh status
# tests/helpers/emulator.sh install <apk>
# tests/helpers/emulator.sh shot <name> # screenshot into tests/runs/screenshots
set -euo pipefail
AVD="${CASTARR_AVD:-castarr-googletv}"
SERIAL="${CASTARR_SERIAL:-emulator-5554}"
SDK="${ANDROID_SDK_ROOT:-$HOME/Android/Sdk}"
ADB="$SDK/platform-tools/adb"
EMULATOR="$SDK/emulator/emulator"
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
RUNS="$ROOT/tests/runs"
LOG="$RUNS/emulator.log"
booted() { [ "$("$ADB" -s "$SERIAL" shell getprop sys.boot_completed 2>/dev/null | tr -d '\r')" = "1" ]; }
start() {
if booted; then echo "already up: $SERIAL"; return 0; fi
mkdir -p "$RUNS"
# No -no-snapshot here: that is what makes a start slow.
nohup "$EMULATOR" -avd "$AVD" \
-no-window -no-audio -no-boot-anim \
-gpu swiftshader_indirect \
>"$LOG" 2>&1 &
local start_ts=$SECONDS
"$ADB" -s "$SERIAL" wait-for-device
for _ in $(seq 1 180); do
booted && { echo "up after $((SECONDS - start_ts))s"; return 0; }
sleep 1
done
echo "timeout waiting for boot; see $LOG" >&2
return 1
}
stop() {
if ! pgrep -f "qemu-system-.*-avd $AVD" >/dev/null 2>&1 && ! booted; then
echo "not running"; return 0
fi
# Refresh the snapshot so the next start is a restore, not a cold boot.
"$ADB" -s "$SERIAL" emu avd snapshot save default_boot
"$ADB" -s "$SERIAL" emu kill
echo "stopped, snapshot saved"
}
case "${1:-status}" in
start) start ;;
stop) stop ;;
status) booted && echo "up: $SERIAL" || echo "down" ;;
install)
[ $# -ge 2 ] || { echo "usage: $0 install <apk>" >&2; exit 2; }
"$ADB" -s "$SERIAL" install -r "$2"
;;
shot)
name="${2:-shot}"
mkdir -p "$RUNS/screenshots"
"$ADB" -s "$SERIAL" exec-out screencap -p > "$RUNS/screenshots/$name.png"
echo "$RUNS/screenshots/$name.png"
;;
*) echo "usage: $0 {start|stop|status|install <apk>|shot <name>}" >&2; exit 2 ;;
esac

38
tests/helpers/jdk.sh Executable file
View File

@@ -0,0 +1,38 @@
#!/usr/bin/env bash
# Picks a JDK that Gradle can actually run on and exports JAVA_HOME.
#
# Ubuntu moved default-java to 25, which the Gradle version in this repo
# refuses with a bare "IllegalArgumentException: 25.0.4" — an unhelpful
# message for a build that worked yesterday. Sourced by the scripts under
# tests/ and tools/; harmless when JAVA_HOME is already a supported JDK.
#
# . tests/helpers/jdk.sh
set -u
_jdk_major() {
"$1/bin/java" -version 2>&1 | head -1 |
sed -E 's/.*version "([0-9]+).*/\1/'
}
_jdk_pick() {
local candidate major
if [ -n "${JAVA_HOME:-}" ] && [ -x "${JAVA_HOME}/bin/java" ]; then
major="$(_jdk_major "$JAVA_HOME")"
if [ "$major" -ge 17 ] 2>/dev/null && [ "$major" -le 21 ] 2>/dev/null; then
return 0
fi
fi
for candidate in \
/usr/lib/jvm/java-21-openjdk-amd64 \
/usr/lib/jvm/java-17-openjdk-amd64 \
"$HOME"/jdk-21* \
"$HOME"/jdk-17*; do
[ -x "$candidate/bin/java" ] || continue
export JAVA_HOME="$candidate"
return 0
done
echo "no JDK 17-21 found; Gradle cannot run on $(java -version 2>&1 | head -1)" >&2
return 1
}
_jdk_pick

115
tests/smoke.sh Executable file
View File

@@ -0,0 +1,115 @@
#!/usr/bin/env bash
# Smoke test on the headless Google TV emulator.
#
# Builds the debug APK, installs it, walks the first screen with the D-pad
# and fails on anything the unit tests cannot see: a crash on startup, a
# crash while navigating, an ANR. The 0.11.0 startup crash and the rail
# crash on "Verein hinzufügen" would both have been caught here.
#
# tests/smoke.sh # build, run, leave the emulator up
# tests/smoke.sh --apk <path> # skip the build, test this APK
# tests/smoke.sh --stop # stop the emulator when done
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
HELPERS="$ROOT/tests/helpers"
RUNS="$ROOT/tests/runs"
SERIAL="${CASTARR_SERIAL:-emulator-5554}"
ADB="${ANDROID_SDK_ROOT:-$HOME/Android/Sdk}/platform-tools/adb"
PKG="dev.castarr.tv"
ACTIVITY="$PKG/.MainActivity"
APK=""
STOP_AFTER=0
while [ $# -gt 0 ]; do
case "$1" in
--apk) APK="$2"; shift 2 ;;
--stop) STOP_AFTER=1; shift ;;
*) echo "usage: $0 [--apk <path>] [--stop]" >&2; exit 2 ;;
esac
done
mkdir -p "$RUNS/screenshots"
LOG="$RUNS/smoke.log"
: > "$LOG"
step() { printf '\n== %s\n' "$1" | tee -a "$LOG"; }
fail() { printf '\nFAIL: %s\n' "$1" | tee -a "$LOG" >&2; exit 1; }
if [ -z "$APK" ]; then
step "Debug-APK bauen"
# shellcheck source=tests/helpers/jdk.sh
. "$HELPERS/jdk.sh"
(cd "$ROOT" && ./gradlew assembleDebug --no-daemon -q) >>"$LOG" 2>&1 ||
fail "Build fehlgeschlagen, siehe $LOG"
APK="$ROOT/app/build/outputs/apk/debug/app-debug.apk"
fi
[ -f "$APK" ] || fail "APK nicht gefunden: $APK"
step "Emulator starten"
"$HELPERS/emulator.sh" start | tee -a "$LOG"
step "Installieren"
# A debug build over a signed release needs the old one gone first.
"$ADB" -s "$SERIAL" uninstall "$PKG" >/dev/null 2>&1 || true
"$ADB" -s "$SERIAL" install -r "$APK" >>"$LOG" 2>&1 || fail "Installation fehlgeschlagen"
step "Starten"
"$ADB" -s "$SERIAL" logcat -c
"$ADB" -s "$SERIAL" shell am start -W -n "$ACTIVITY" >>"$LOG" 2>&1 ||
fail "am start fehlgeschlagen"
# The first frame is not the point — the crash usually lands a moment later,
# when state loads.
sleep 6
running() { [ -n "$("$ADB" -s "$SERIAL" shell pidof "$PKG" | tr -d '\r')" ]; }
crashed() {
"$ADB" -s "$SERIAL" logcat -d -b crash,main 2>/dev/null |
grep -E "FATAL EXCEPTION|ANR in $PKG|Process $PKG .* has died" | head -20
}
check() {
local where="$1" trace
trace="$(crashed || true)"
if [ -n "$trace" ]; then
printf '%s\n' "$trace" >>"$LOG"
printf '%s\n' "$trace" | head -5
fail "Absturz $where — vollständig in $LOG"
fi
running || fail "Prozess weg $where (kein Stacktrace im Log)"
}
check "beim Start"
"$HELPERS/emulator.sh" shot smoke-start >/dev/null
step "D-Pad-Navigation"
# Down/right walks the rail and opens whatever has focus; back returns.
for key in DPAD_DOWN DPAD_RIGHT DPAD_RIGHT DPAD_DOWN DPAD_CENTER BACK DPAD_UP; do
"$ADB" -s "$SERIAL" shell input keyevent "$key"
sleep 1
done
sleep 2
check "bei der Navigation"
"$HELPERS/emulator.sh" shot smoke-nav >/dev/null
step "Einstellungen öffnen"
"$ADB" -s "$SERIAL" shell am start -n "$ACTIVITY" >/dev/null 2>&1
sleep 2
check "nach dem Wiedereintritt"
if [ "$STOP_AFTER" = 1 ]; then
step "Emulator stoppen"
"$HELPERS/emulator.sh" stop | tee -a "$LOG"
fi
cat <<EOF
OK — kein Absturz, App läuft.
Screenshots: $RUNS/screenshots/smoke-start.png, smoke-nav.png
Log: $LOG
Der Test deckt Start, D-Pad und Wiedereintritt ab. Alles hinter dem
Onboarding (echte Quelle, Wiedergabe, Kopplung) braucht ein Backend und
bleibt Handarbeit.
EOF

View File

@@ -0,0 +1,101 @@
package dev.castarr.tv.server
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class AttemptBudgetTest {
private var clock = 0L
private fun budget(
windowMs: Long = 60_000L,
maxFailures: Int = 5,
maxTracked: Int = 64,
) = AttemptBudget(windowMs, maxFailures, maxTracked) { clock }
/**
* The 0.11.0 bug: the remote reconnects on every network hiccup, and
* counting those successful handshakes threw the phone out after five.
*/
@Test
fun `successful connections never cost budget`() {
val budget = budget()
repeat(50) {
assertTrue(budget.allows("10.0.0.5"))
budget.clear("10.0.0.5")
}
assertTrue(budget.allows("10.0.0.5"))
}
@Test
fun `five failures in the window lock the address out`() {
val budget = budget()
repeat(5) {
assertTrue(budget.allows("10.0.0.5"))
budget.recordFailure("10.0.0.5")
}
assertFalse(budget.allows("10.0.0.5"))
}
@Test
fun `a success after failures wipes the slate`() {
val budget = budget()
repeat(4) { budget.recordFailure("10.0.0.5") }
budget.clear("10.0.0.5")
repeat(4) {
assertTrue(budget.allows("10.0.0.5"))
budget.recordFailure("10.0.0.5")
}
assertTrue(budget.allows("10.0.0.5"))
}
@Test
fun `failures expire once the window has passed`() {
val budget = budget()
repeat(5) { budget.recordFailure("10.0.0.5") }
assertFalse(budget.allows("10.0.0.5"))
clock += 60_001
assertTrue(budget.allows("10.0.0.5"))
}
@Test
fun `the window slides, it does not reset in blocks`() {
val budget = budget()
repeat(4) {
budget.recordFailure("10.0.0.5")
clock += 20_000
}
// Two of the four are older than a minute by now, so there is room.
assertTrue(budget.allows("10.0.0.5"))
}
@Test
fun `one address cannot spend another's budget`() {
val budget = budget()
repeat(5) { budget.recordFailure("10.0.0.9") }
assertFalse(budget.allows("10.0.0.9"))
assertTrue(budget.allows("10.0.0.5"))
}
/** An attacker cycling source addresses must not grow the map forever. */
@Test
fun `tracked addresses stay bounded under a flood of fresh ones`() {
val budget = budget(maxTracked = 8)
repeat(500) { i ->
budget.recordFailure("10.0.0.$i")
clock += 10
}
assertTrue(budget.trackedAddresses() <= 8)
}
@Test
fun `eviction drops the stale addresses, not the active one`() {
val budget = budget(maxTracked = 4)
repeat(4) { i -> budget.recordFailure("10.0.1.$i") }
clock += 60_001
repeat(5) { budget.recordFailure("10.0.0.5") }
assertFalse(budget.allows("10.0.0.5"))
assertEquals(1, budget.trackedAddresses())
}
}

View File

@@ -0,0 +1,76 @@
package dev.castarr.tv.update
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class UpdateRulesTest {
@Test
fun `a higher patch level is newer`() {
assertTrue(UpdateRules.isNewer("0.11.5", "0.11.4"))
}
@Test
fun `the same version is not newer`() {
assertFalse(UpdateRules.isNewer("0.11.5", "0.11.5"))
}
@Test
fun `an older release never offers itself as an update`() {
assertFalse(UpdateRules.isNewer("0.11.4", "0.11.5"))
}
/** Segments are numbers, not text — "10" beats "9". */
@Test
fun `double digit segments compare numerically`() {
assertTrue(UpdateRules.isNewer("0.11.10", "0.11.9"))
assertFalse(UpdateRules.isNewer("0.11.9", "0.11.10"))
assertTrue(UpdateRules.isNewer("0.12.0", "0.9.99"))
}
@Test
fun `a missing segment counts as zero`() {
assertFalse(UpdateRules.isNewer("0.12", "0.12.0"))
assertTrue(UpdateRules.isNewer("0.12.1", "0.12"))
}
@Test
fun `a leading v on the tag makes no difference`() {
assertTrue(UpdateRules.isNewer("v0.11.5", "0.11.4"))
assertFalse(UpdateRules.isNewer("v0.11.4", "v0.11.4"))
}
@Test
fun `garbage in a tag does not offer an update`() {
assertFalse(UpdateRules.isNewer("", "0.11.5"))
assertFalse(UpdateRules.isNewer("nightly", "0.11.5"))
}
@Test
fun `a complete download passes`() {
assertTrue(UpdateRules.isComplete(actualBytes = 6_515_429, announcedBytes = 6_515_429))
}
/** The 0.11.5 bug: a truncated APK left the installer hanging. */
@Test
fun `a truncated download is rejected`() {
assertFalse(UpdateRules.isComplete(actualBytes = 3_000_000, announcedBytes = 6_515_429))
}
@Test
fun `an empty download is rejected even when nothing was announced`() {
assertFalse(UpdateRules.isComplete(actualBytes = 0, announcedBytes = -1))
assertFalse(UpdateRules.isComplete(actualBytes = 0, announcedBytes = 0))
}
@Test
fun `an unannounced length cannot be checked, so any content passes`() {
assertTrue(UpdateRules.isComplete(actualBytes = 6_515_429, announcedBytes = -1))
}
@Test
fun `more bytes than announced is rejected too`() {
assertFalse(UpdateRules.isComplete(actualBytes = 7_000_000, announcedBytes = 6_515_429))
}
}

View File

@@ -27,6 +27,30 @@ if os.path.exists(URLS):
kotlin = open(SRC, encoding="utf-8").read() kotlin = open(SRC, encoding="utf-8").read()
entries = re.findall(r'club\((.*?)\)\s*,\s*(?://.*)?$', kotlin, re.M | re.S) entries = re.findall(r'club\((.*?)\)\s*,\s*(?://.*)?$', kotlin, re.M | re.S)
clubs = [] clubs = []
# Wikipedia serves ~330px thumbnails. The rail draws them at 20dp, which on a
# 1080p TV is 40px -- an 8x downscale that Android does with a cheap filter,
# and the edges come out ragged. Resampling once here with a proper filter
# fixes that and shrinks the APK.
CREST_PX = 128
def downscale(data: bytes) -> bytes:
try:
from PIL import Image
except ImportError:
return data
import io
image = Image.open(io.BytesIO(data)).convert("RGBA")
if max(image.size) <= CREST_PX:
return data
scale = CREST_PX / max(image.size)
size = (max(1, round(image.width * scale)), max(1, round(image.height * scale)))
out = io.BytesIO()
image.resize(size, Image.LANCZOS).save(out, format="PNG", optimize=True)
return out.getvalue()
for raw in re.findall(r'club\(\s*"([^"]+)",\s*"[^"]*",\s*"([^"]+)"[^\n]*', kotlin): for raw in re.findall(r'club\(\s*"([^"]+)",\s*"[^"]*",\s*"([^"]+)"[^\n]*', kotlin):
clubs.append(raw) clubs.append(raw)
# an explicit article = "..." wins over the club name # an explicit article = "..." wins over the club name
@@ -52,7 +76,7 @@ for key, full_name in clubs:
with urllib.request.urlopen(urllib.request.Request(thumb, headers=UA), timeout=20) as r: with urllib.request.urlopen(urllib.request.Request(thumb, headers=UA), timeout=20) as r:
data = r.read() data = r.read()
with open(target, "wb") as f: with open(target, "wb") as f:
f.write(data) f.write(downscale(data))
fetched += 1 fetched += 1
except Exception as exc: # noqa: BLE001 - best effort, shield is the fallback except Exception as exc: # noqa: BLE001 - best effort, shield is the fallback
print(f" {key}: {exc}", file=sys.stderr) print(f" {key}: {exc}", file=sys.stderr)

171
tools/release.sh Executable file
View File

@@ -0,0 +1,171 @@
#!/usr/bin/env bash
# Cuts a release: version bump, tests, signed build, tag, apk branch, Gitea
# release. Every step was done by hand before, fifteen times in one day.
#
# tools/release.sh 0.11.6 --notes notes.md
# tools/release.sh 0.11.6 --dry-run # show what would happen
# tools/release.sh 0.11.6 --no-smoke # skip the emulator run
#
# Release notes are never generated: the commit subjects since the last tag
# are only a starting point, written to a file for you to edit. Pass --notes
# to supply them directly.
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT"
REMOTE="${CASTARR_REMOTE:-gitea}"
REPO="${CASTARR_REPO:-be-nj/castarr}"
API="${CASTARR_API:-https://git.beckm4nn.net/api/v1}"
SIGNING_ENV="${CASTARR_SIGNING_ENV:-$HOME/.keys/castarr-release.env}"
GRADLE="./gradlew --no-daemon -q"
VERSION=""
NOTES_FILE=""
DRY=0
SMOKE=1
while [ $# -gt 0 ]; do
case "$1" in
--notes) NOTES_FILE="$2"; shift 2 ;;
--dry-run) DRY=1; shift ;;
--no-smoke) SMOKE=0; shift ;;
-*) echo "unbekannte Option: $1" >&2; exit 2 ;;
*) VERSION="$1"; shift ;;
esac
done
[ -n "$VERSION" ] || { echo "usage: $0 <version> [--notes <file>] [--dry-run] [--no-smoke]" >&2; exit 2; }
[[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo "Version muss X.Y.Z sein, nicht '$VERSION'" >&2; exit 2; }
say() { printf '\n== %s\n' "$1"; }
run() { if [ "$DRY" = 1 ]; then printf ' would run: %s\n' "$*"; else "$@"; fi; }
die() { printf 'ABBRUCH: %s\n' "$1" >&2; exit 1; }
# --- Vorbedingungen ---------------------------------------------------------
say "Prüfen"
[ -z "$(git status --porcelain)" ] || die "Arbeitsbaum nicht sauber"
[ "$(git rev-parse --abbrev-ref HEAD)" = "main" ] || die "nicht auf main"
git fetch -q "$REMOTE" main
[ "$(git rev-parse HEAD)" = "$(git rev-parse "$REMOTE/main")" ] ||
die "main weicht von $REMOTE/main ab — erst pushen oder pullen"
git rev-parse -q --verify "refs/tags/v$VERSION" >/dev/null &&
die "Tag v$VERSION existiert schon"
CURRENT="$(sed -nE 's/.*versionName = "(.*)".*/\1/p' app/build.gradle.kts)"
CODE="$(sed -nE 's/.*versionCode = ([0-9]+).*/\1/p' app/build.gradle.kts)"
[ -n "$CURRENT" ] && [ -n "$CODE" ] || die "Version aus app/build.gradle.kts nicht lesbar"
NEXT_CODE=$((CODE + 1))
echo " $CURRENT (code $CODE) -> $VERSION (code $NEXT_CODE)"
# shellcheck source=tests/helpers/jdk.sh
. tests/helpers/jdk.sh
echo " JDK: $(basename "${JAVA_HOME:-system}")"
# --- Notizen ----------------------------------------------------------------
LAST_TAG="$(git describe --tags --abbrev=0 2>/dev/null || true)"
if [ -z "$NOTES_FILE" ]; then
NOTES_FILE="$(mktemp -t castarr-notes-XXXX.md)"
{
echo "# Notizen für $VERSION — diese Zeilen sind ein Entwurf, keine Release-Notes."
echo "#"
[ -n "$LAST_TAG" ] && echo "# Commits seit $LAST_TAG:" || echo "# Commits:"
if [ -n "$LAST_TAG" ]; then
git log --format='# %s' "$LAST_TAG..HEAD"
else
git log --format='# %s' -10
fi
} > "$NOTES_FILE"
if [ -n "${EDITOR:-}" ] && [ -t 0 ]; then
"$EDITOR" "$NOTES_FILE"
else
say "Notizen"
echo " Entwurf liegt in $NOTES_FILE."
echo " Schreib die Notes dort hinein und ruf erneut auf:"
echo " $0 $VERSION --notes $NOTES_FILE"
exit 0
fi
fi
[ -f "$NOTES_FILE" ] || die "Notes-Datei fehlt: $NOTES_FILE"
NOTES="$(grep -v '^#' "$NOTES_FILE" | sed -e '/./,$!d')"
[ -n "${NOTES//[[:space:]]/}" ] || die "Notes sind leer"
# --- Tests ------------------------------------------------------------------
say "Unit-Tests"
run $GRADLE testDebugUnitTest
if [ "$SMOKE" = 1 ]; then
say "Smoke-Test auf dem Emulator"
run tests/smoke.sh
fi
# --- Version bumpen ---------------------------------------------------------
say "Version setzen"
if [ "$DRY" = 0 ]; then
sed -i -E "s/versionCode = $CODE/versionCode = $NEXT_CODE/; s/versionName = \"$CURRENT\"/versionName = \"$VERSION\"/" \
app/build.gradle.kts
grep -q "versionName = \"$VERSION\"" app/build.gradle.kts || die "Bump hat nicht gegriffen"
else
echo " would set versionCode=$NEXT_CODE versionName=$VERSION"
fi
# --- Signierter Build -------------------------------------------------------
say "Release-APK bauen"
if [ -f "$SIGNING_ENV" ]; then
# Signing secrets stay in the file; only this shell sees them.
set -a; . "$SIGNING_ENV"; set +a
echo " signiert (Konfiguration aus $SIGNING_ENV)"
else
echo " WARNUNG: $SIGNING_ENV fehlt — der Build wäre unsigniert"
[ "$DRY" = 1 ] || die "ohne Signatur kein Release (Updater lehnt Signaturwechsel ab)"
fi
run $GRADLE assembleRelease
APK="app/build/outputs/apk/release/app-release.apk"
[ "$DRY" = 1 ] || [ -f "$APK" ] || die "APK nicht gebaut: $APK"
# --- Commit, Tag, Push ------------------------------------------------------
say "Commit und Tag"
run git add app/build.gradle.kts
run git commit -q -m "Release $VERSION"
run git tag "v$VERSION"
run git push -q "$REMOTE" main "v$VERSION"
# --- APK-Branch -------------------------------------------------------------
say "APK auf den Branch apk"
if [ "$DRY" = 0 ]; then
git fetch -q "$REMOTE" apk
WORKTREE="$(mktemp -d -t castarr-apk-XXXX)"
trap 'git worktree remove --force "$WORKTREE" 2>/dev/null || true' EXIT
git worktree add -q -B apk "$WORKTREE" "$REMOTE/apk"
cp "$APK" "$WORKTREE/castarr.apk"
echo "$VERSION" > "$WORKTREE/VERSION"
git -C "$WORKTREE" add castarr.apk VERSION
git -C "$WORKTREE" commit -q -m "castarr $VERSION"
git -C "$WORKTREE" push -q "$REMOTE" apk
git worktree remove --force "$WORKTREE"
trap - EXIT
else
echo " would push $APK as castarr.apk"
fi
# --- Gitea-Release ----------------------------------------------------------
say "Gitea-Release"
TOKEN="${GITEA_TOKEN:-${CASTARR_GITEA_TOKEN:-}}"
if [ -z "$TOKEN" ]; then
echo " Kein GITEA_TOKEN gesetzt — Release bitte in der Weboberfläche anlegen:"
echo " ${API%/api/v1}/$REPO/releases/new?tag=v$VERSION"
echo " Notes liegen in $NOTES_FILE"
elif [ "$DRY" = 1 ]; then
echo " would create release v$VERSION"
else
BODY="$(NOTES="$NOTES" python3 -c 'import json,os; print(json.dumps({
"tag_name": "v" + os.environ["V"], "name": os.environ["V"],
"body": os.environ["NOTES"], "target_commitish": "main"}))' V="$VERSION")"
curl -sS -X POST "$API/repos/$REPO/releases" \
-H "Authorization: token $TOKEN" \
-H "Content-Type: application/json" \
-d "$BODY" > /dev/null || die "Release-API fehlgeschlagen"
echo " angelegt: ${API%/api/v1}/$REPO/releases/tag/v$VERSION"
fi
say "Fertig: $VERSION"
echo " Die App findet das Update über die Release-API, die APK über den Branch apk."