10 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
be-nj
e491f8e50f Highlight a club menu while its match is on or about to start
All checks were successful
Build TV app / build (push) Successful in 3m6s
The rail entry showed a plain hit count, which says nothing about whether
that hit is happening now or six hours from now. It now turns into a red
dot with "läuft" while a match is running, and an accent dot with "gleich"
within half an hour of kick-off — the only two moments where the menu is
worth interrupting for. A one-minute ticker re-evaluates it so the state
appears on its own.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 10:45:15 +02:00
be-nj
56b5d3f0b0 Stop the socket timeout from killing the remote mid-session
All checks were successful
Build TV app / build (push) Successful in 2m55s
Issue #1's fix gave accepted sockets a read deadline so idle connections
could no longer pin threads — but NanoHTTPD's 5 s default also applies to
the long-lived WebSocket, and pings only ran every 8 s. The remote was
therefore dropped roughly five seconds into every session, right after a
command or two, with no close frame.

The deadline stays (that was the point) but is now 40 s, comfortably above
the ping interval, so pongs keep an active session alive while a truly idle
socket still gets reaped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 10:33:39 +02:00
be-nj
b0b8f8801d Stop throwing out honest remotes, and mirror club menus to the phone
All checks were successful
Build TV app / build (push) Successful in 4m39s
The rate limit charged every hello, not just failed ones. The phone remote
reconnects on any network hiccup, so five reconnects in a minute — entirely
normal — got it kicked with "zu viele Versuche". Only failed authentication
costs budget now, and a success clears the address.

The club menus existed only on the TV. The phone now receives them and
shows a chip per club next to Alle/Favoriten, listing the matching channels
with kick-off time instead of the usual now/next.

Settings: the club row showed an "An" pill although it can only ever
remove, which read like a toggle. It is a remove icon now.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 10:25:22 +02:00
23 changed files with 1390 additions and 184 deletions

View File

@@ -11,8 +11,19 @@ on:
jobs:
build:
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:
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
steps:
- name: Checkout
@@ -22,35 +33,42 @@ jobs:
git fetch -q --depth 1 origin "${GITHUB_SHA}"
git checkout -q FETCH_HEAD
- name: Install JDK
- name: JDK 17
run: |
apt-get update -qq
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq openjdk-17-jdk-headless > /dev/null
java -version
if [ ! -x "$JAVA_HOME/bin/java" ]; then
echo "JDK nicht im Cache — einmalig herunterladen"
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: |
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
if [ ! -d "$ANDROID_SDK_ROOT/platforms/android-35" ]; then
echo "SDK nicht im Cache — einmalig einrichten"
mkdir -p "$ANDROID_SDK_ROOT/cmdline-tools"
curl -sSL -o /tmp/tools.zip "$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"
# 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"
JAVA_HOME="$JAVA_HOME" "$ANDROID_SDK_ROOT/cmdline-tools/latest/bin/sdkmanager" \
"platforms;android-35" "build-tools;35.0.0" "platform-tools" > /dev/null
fi
echo "sdk.dir=$ANDROID_SDK_ROOT" > local.properties
- name: Unit tests
run: ./gradlew testDebugUnitTest --no-daemon --stacktrace
- name: Build debug APK
run: ./gradlew assembleDebug --no-daemon --stacktrace
# One invocation, not two: a second --no-daemon run pays for another JVM
# start and another configuration phase to redo work it just did.
- name: Tests und Debug-APK
run: ./gradlew testDebugUnitTest assembleDebug --no-daemon --stacktrace
- name: Summary
if: always()
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)"
echo "Cache:"; du -sh "$CACHE"/* 2>/dev/null || echo " (leer)"

2
.gitignore vendored
View File

@@ -6,3 +6,5 @@ local.properties
.kotlin/
tests/runs/
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
> **Dev:** "Does the **Remote** need Backend credentials?"
> **Domain expert:** "No — the user enters the Xtream credentials of a
> **Quelle** once via the Remote, the **TV-App** stores them and is the only
> one talking to the **Backend**."
> **Domain expert:** "No — the Remote is where **Onboarding** happens, not
> where credentials live: the user types the server URL there and finishes the
> 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

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
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/).

View File

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

View File

@@ -267,7 +267,7 @@
ws: null, connected: false, authorized: false,
status: { state: 'idle', channel: '', group: '', live: false, volume: 0.5 },
channels: [], playlistUrl: '', extras: { nowNext: [], favorites: [], favoritesSupported: false }, favOnly: false,
retryDelay: 1000, volumeDragging: false, searchTerm: '',
retryDelay: 1000, volumeDragging: false, searchTerm: '', teamKey: null,
};
// --- pairing credentials ---
@@ -450,16 +450,31 @@
list.innerHTML = '';
const favSet = new Set(state.extras.favorites || []);
const favOn = state.extras.favoritesSupported && state.favOnly;
const shown = favOn ? filtered.filter((c) => favSet.has(c.backendId)) : filtered;
if (state.extras.favoritesSupported) {
const teams = state.extras.teams || [];
const club = teams.find((t) => t.key === state.teamKey);
// Club view: the TV's club menu, mirrored onto the phone.
const clubHits = club ? new Map(club.entries.map((e) => [e.url, e])) : null;
const shown = club
? filtered.filter((c) => clubHits.has(c.url))
: favOn ? filtered.filter((c) => favSet.has(c.backendId)) : filtered;
if (state.extras.favoritesSupported || teams.length) {
const bar = document.createElement('div');
bar.style.cssText = 'display:flex;gap:8px;padding:2px 12px 10px';
[['Alle', false], ['★ Favoriten', true]].forEach(([label, val]) => {
bar.style.cssText = 'display:flex;gap:8px;padding:2px 12px 10px;overflow-x:auto';
const views = [['Alle', null, false]];
if (state.extras.favoritesSupported) views.push(['★ Favoriten', null, true]);
teams.forEach((t) => views.push([t.label + ' ' + t.entries.length, t.key, false]));
views.forEach(([label, key, fav]) => {
const active = key ? state.teamKey === key : (!state.teamKey && state.favOnly === fav);
const chip = document.createElement('button');
chip.textContent = label;
chip.style.cssText = 'padding:7px 14px;border-radius:999px;font-size:12px;background:' +
(state.favOnly === val ? 'rgba(95,212,196,.12);color:#5fd4c4' : '#121418;color:#9aa0a8');
chip.addEventListener('click', () => { state.favOnly = val; renderChannels(); });
chip.style.cssText = 'flex:none;padding:7px 14px;border-radius:999px;font-size:12px;background:' +
(active ? 'rgba(95,212,196,.12);color:#5fd4c4' : '#121418;color:#9aa0a8');
chip.addEventListener('click', () => {
state.teamKey = key;
state.favOnly = fav;
listSignature = '';
renderChannels();
});
bar.appendChild(chip);
});
list.appendChild(bar);
@@ -467,8 +482,9 @@
if (!shown.length) {
const empty = document.createElement('div');
empty.className = 'empty';
empty.textContent = favOn
? 'Keine Favoriten — Stern auf einem Sender antippen.'
empty.textContent = club
? ('Heute läuft nichts mehr mit ' + club.name + '.')
: favOn ? 'Keine Favoriten — Stern auf einem Sender antippen.'
: 'Keine Sender gefunden.';
list.appendChild(empty);
return;
@@ -493,6 +509,18 @@
const name = document.createElement('span');
name.className = 'name'; name.textContent = c.name;
meta.appendChild(name);
const hit = clubHits ? clubHits.get(c.url) : null;
if (hit) {
const line = document.createElement('span');
line.className = 'grp';
const t = new Date(hit.start);
const hh = String(t.getHours()).padStart(2, '0') + ':' + String(t.getMinutes()).padStart(2, '0');
const running = Date.now() >= hit.start && Date.now() < hit.stop;
line.textContent = (running ? 'läuft · ' : 'ab ' + hh + ' · ') + hit.title +
(hit.further > 0 ? ' · +' + hit.further + ' weitere' : '');
line.style.color = running ? '#5fd4c4' : '#9aa0a8';
meta.appendChild(line);
} else {
const info = (state.extras.nowNext || [])[idx];
if (info && info.now) {
const now = document.createElement('span');
@@ -513,6 +541,7 @@
grp.className = 'grp'; grp.textContent = c.group;
meta.appendChild(grp);
}
}
btn.appendChild(num); btn.appendChild(meta);
if (state.extras.favoritesSupported && c.backendId) {
const star = document.createElement('span');
@@ -539,7 +568,8 @@
let listSignature = '';
function renderChannelsIfChanged() {
const sig = [
state.channels.length, state.searchTerm, state.favOnly,
state.channels.length, state.searchTerm, state.favOnly, state.teamKey,
JSON.stringify((state.extras.teams || []).map((t) => [t.key, t.entries.length])),
(state.extras.favorites || []).join(','),
state.status.channel || '',
].join('|');

View File

@@ -176,6 +176,30 @@ class AppState(
val further: Int,
)
/** How urgent a club menu is right now — drives the rail highlight. */
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 —
* the window in which someone actually wants to be nudged.
*/
fun teamSignal(matches: List<Pair<Channel, TeamHit>>): TeamSignal {
val now = System.currentTimeMillis()
var soonAt = 0L
matches.forEach { (_, hit) ->
val p = hit.programme
if (now in p.start until p.stop) return TeamSignal(TeamUrgency.LIVE, p.start)
if (p.start in now..(now + SOON_WINDOW_MS) && (soonAt == 0L || p.start < soonAt)) {
soonAt = p.start
}
}
return if (soonAt > 0) TeamSignal(TeamUrgency.SOON, soonAt)
else TeamSignal(TeamUrgency.NONE, 0L)
}
/**
* Channels showing the viewer's club within the scanned window, paired
* with the programme that matched — earliest kick-off first, so whatever
@@ -230,6 +254,9 @@ class AppState(
private companion object {
/** Grace period before re-opening the channel just closed. */
const val REENTRY_GRACE_MS = 2_500L
/** How far ahead a kick-off counts as "about to start". */
const val SOON_WINDOW_MS = 30 * 60 * 1000L
}
fun isOnline(): Boolean {

View File

@@ -147,9 +147,36 @@ class MainActivity : ComponentActivity(), ControlServer.Listener {
}
val favorites = org.json.JSONArray()
state.dispatcharr.favorites.value.forEach { favorites.put(it) }
// The club menus exist on the TV; the phone should offer them too.
val teams = org.json.JSONArray()
dev.castarr.tv.data.TeamFilters.all
.filter { it.key in state.enabledTeams }
.forEach { club ->
val entries = org.json.JSONArray()
state.teamMatches(club.key).forEach { (channel, hit) ->
entries.put(
JSONObject()
.put("channel", channel.name)
.put("url", channel.url)
.put("group", channel.group)
.put("title", hit.programme.title)
.put("start", hit.programme.start)
.put("stop", hit.programme.stop)
.put("further", hit.further)
)
}
teams.put(
JSONObject()
.put("key", club.key)
.put("label", club.label)
.put("name", club.fullName)
.put("entries", entries)
)
}
return JSONObject()
.put("nowNext", nowNext)
.put("favorites", favorites)
.put("teams", teams)
.put("favoritesSupported", state.sourceMode == AppState.SourceMode.DISPATCHARR)
}

View File

@@ -20,6 +20,8 @@ data class TeamFilter(
val secondary: Color,
/** German Wikipedia article the crest is resolved from. */
val article: String,
/** 1, 2 or 3 — groups the picker by division. */
val league: Int,
) {
fun matches(title: String): Boolean {
val haystack = title.lowercase()
@@ -63,69 +65,70 @@ object TeamFilters {
primary: Long,
secondary: Long,
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. */
val all: List<TeamFilter> = listOf(
// --- Bundesliga ---
club("bayern", "FCB", "FC Bayern München", listOf("bayern münchen", "fc bayern"), 0xFFDC052D, WHITE),
club("bvb", "BVB", "Borussia Dortmund", listOf("borussia dortmund", "bvb"), 0xFFFDE100, BLACK),
club("leipzig", "RBL", "RB Leipzig", listOf("rb leipzig"), 0xFFDD0741, WHITE),
club("leverkusen", "B04", "Bayer 04 Leverkusen", listOf("leverkusen"), 0xFFE32219, BLACK),
club("frankfurt", "SGE", "Eintracht Frankfurt", listOf("eintracht frankfurt"), 0xFF1A1A1A, 0xFFE1000F),
club("stuttgart", "1893", "VfB Stuttgart", listOf("vfb stuttgart"), 0xFFE32219, WHITE),
club("gladbach", "BMG", "Borussia Mönchengladbach", listOf("mönchengladbach", "gladbach"), 0xFF1A1A1A, 0xFF00A94F),
club("wolfsburg", "WOB", "VfL Wolfsburg", listOf("wolfsburg"), 0xFF65B32E, WHITE),
club("bremen", "SVW", "SV Werder Bremen", listOf("werder bremen", "werder"), 0xFF1D9053, WHITE),
club("freiburg", "SCF", "SC Freiburg", listOf("sc freiburg", "freiburg"), 0xFFE2001A, WHITE),
club("hoffenheim", "TSG", "TSG Hoffenheim", listOf("hoffenheim"), 0xFF1C63B7, WHITE),
club("mainz", "M05", "1. FSV Mainz 05", listOf("mainz 05", "mainz"), 0xFFE1000F, WHITE),
club("augsburg", "FCA", "FC Augsburg", listOf("augsburg"), 0xFF00693F, 0xFFE1000F),
club("union", "FCU", "1. FC Union Berlin", listOf("union berlin"), 0xFFE1000F, 0xFFFDE100),
club("koeln", "EFC", "1. FC Köln", listOf("1. fc köln", "fc köln"), 0xFFE1000F, WHITE),
club("hsv", "HSV", "Hamburger SV", listOf("hamburger sv", "hsv"), 0xFF0E5EA6, BLACK),
club("heidenheim", "FCH1", "1. FC Heidenheim", listOf("heidenheim"), 0xFFE1000F, 0xFF1656A4),
club("st-pauli", "FCSP", "FC St. Pauli", listOf("st. pauli", "st pauli"), 0xFF6B4423, 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, league = 1),
club("leipzig", "RBL", "RB Leipzig", listOf("rb leipzig"), 0xFFDD0741, WHITE, league = 1),
club("leverkusen", "B04", "Bayer 04 Leverkusen", listOf("leverkusen"), 0xFFE32219, BLACK, league = 1),
club("frankfurt", "SGE", "Eintracht Frankfurt", listOf("eintracht frankfurt"), 0xFF1A1A1A, 0xFFE1000F, league = 1),
club("stuttgart", "1893", "VfB Stuttgart", listOf("vfb stuttgart"), 0xFFE32219, WHITE, league = 1),
club("gladbach", "BMG", "Borussia Mönchengladbach", listOf("mönchengladbach", "gladbach"), 0xFF1A1A1A, 0xFF00A94F, league = 1),
club("wolfsburg", "WOB", "VfL Wolfsburg", listOf("wolfsburg"), 0xFF65B32E, WHITE, league = 1),
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, league = 1),
club("hoffenheim", "TSG", "TSG Hoffenheim", listOf("hoffenheim"), 0xFF1C63B7, WHITE, league = 1),
club("mainz", "M05", "1. FSV Mainz 05", listOf("mainz 05", "mainz"), 0xFFE1000F, WHITE, league = 1),
club("augsburg", "FCA", "FC Augsburg", listOf("augsburg"), 0xFF00693F, 0xFFE1000F, league = 1),
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, league = 1),
club("hsv", "HSV", "Hamburger SV", listOf("hamburger sv", "hsv"), 0xFF0E5EA6, BLACK, league = 1),
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, league = 1),
// --- 2. Bundesliga ---
club("schalke", "S04", "FC Schalke 04", listOf("schalke"), 0xFF004D9D, WHITE),
club("hertha", "BSC", "Hertha BSC", listOf("hertha"), 0xFF004D9D, WHITE),
club("duesseldorf", "F95", "Fortuna Düsseldorf", listOf("fortuna düsseldorf"), 0xFFE1000F, WHITE),
club("nuernberg", "FCN", "1. FC Nürnberg", listOf("nürnberg"), 0xFF8B1A1A, WHITE),
club("kaiserslautern", "FCK", "1. FC Kaiserslautern", listOf("kaiserslautern"), 0xFFE1000F, WHITE),
club("karlsruhe", "KSC", "Karlsruher SC", listOf("karlsruher sc", "ksc"), 0xFF0055A5, WHITE),
club("hannover", "H96", "Hannover 96", listOf("hannover 96"), 0xFF00A94F, WHITE),
club("paderborn", "SCP", "SC Paderborn 07", listOf("paderborn"), 0xFF004D9D, WHITE),
club("magdeburg", "FCM", "1. FC Magdeburg", listOf("magdeburg"), 0xFF0B7A3E, WHITE),
club("elversberg", "SVE", "SV Elversberg", listOf("elversberg"), 0xFF1A1A1A, 0xFFE1000F),
club("darmstadt", "SV98", "SV Darmstadt 98", listOf("darmstadt"), 0xFF004E9E, WHITE),
club("braunschweig", "BTSV", "Eintracht Braunschweig", listOf("braunschweig"), 0xFFFDE100, 0xFF004E9E),
club("bochum", "BOC", "VfL Bochum", listOf("bochum"), 0xFF005CA9, WHITE),
club("muenster", "SCPM", "Preußen Münster", listOf("preußen münster", "münster"), 0xFF00703C, WHITE),
club("fuerth", "SGF", "SpVgg Greuther Fürth", listOf("greuther fürth", "fürth"), 0xFF00A94F, WHITE),
club("holstein", "KSV", "Holstein Kiel", listOf("holstein kiel"), 0xFF004E9E, 0xFFE1000F),
club("dresden", "SGD", "Dynamo Dresden", listOf("dynamo dresden"), 0xFFFDE100, BLACK),
club("bielefeld", "DSC", "Arminia Bielefeld", listOf("arminia bielefeld", "bielefeld"), 0xFF00539F, WHITE),
club("schalke", "S04", "FC Schalke 04", listOf("schalke"), 0xFF004D9D, WHITE, league = 2),
club("hertha", "BSC", "Hertha BSC", listOf("hertha"), 0xFF004D9D, WHITE, league = 2),
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, league = 2),
club("kaiserslautern", "FCK", "1. FC Kaiserslautern", listOf("kaiserslautern"), 0xFFE1000F, WHITE, league = 2),
club("karlsruhe", "KSC", "Karlsruher SC", listOf("karlsruher sc", "ksc"), 0xFF0055A5, WHITE, league = 2),
club("hannover", "H96", "Hannover 96", listOf("hannover 96"), 0xFF00A94F, WHITE, league = 2),
club("paderborn", "SCP", "SC Paderborn 07", listOf("paderborn"), 0xFF004D9D, WHITE, league = 2),
club("magdeburg", "FCM", "1. FC Magdeburg", listOf("magdeburg"), 0xFF0B7A3E, WHITE, league = 2),
club("elversberg", "SVE", "SV Elversberg", listOf("elversberg"), 0xFF1A1A1A, 0xFFE1000F, league = 2),
club("darmstadt", "SV98", "SV Darmstadt 98", listOf("darmstadt"), 0xFF004E9E, WHITE, league = 2),
club("braunschweig", "BTSV", "Eintracht Braunschweig", listOf("braunschweig"), 0xFFFDE100, 0xFF004E9E, league = 2),
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, league = 2),
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, league = 2),
club("dresden", "SGD", "Dynamo Dresden", listOf("dynamo dresden"), 0xFFFDE100, BLACK, league = 2),
club("bielefeld", "DSC", "Arminia Bielefeld", listOf("arminia bielefeld", "bielefeld"), 0xFF00539F, WHITE, league = 2),
// --- 3. Liga ---
club("hansa", "FCH", "FC Hansa Rostock", listOf("hansa"), 0xFF0B4EA2, WHITE),
club("saarbruecken", "FCS", "1. FC Saarbrücken", listOf("saarbrücken"), 0xFF0B4EA2, BLACK),
club("aue", "FCE", "Erzgebirge Aue", listOf("erzgebirge aue"), 0xFF7C0A02, WHITE),
club("cottbus", "FCEC", "Energie Cottbus", listOf("energie cottbus", "cottbus"), 0xFFE1000F, WHITE),
club("essen", "RWE", "Rot-Weiss Essen", listOf("rot-weiss essen", "rot weiss essen"), 0xFFE1000F, WHITE),
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, 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),
club("havelse", "TSVH", "TSV Havelse", listOf("havelse"), 0xFF004E9E, WHITE),
club("schweinfurt", "FC05", "1. FC Schweinfurt 05", listOf("schweinfurt"), 0xFF00A94F, WHITE),
club("osnabrueck", "VfLO", "VfL Osnabrück", listOf("osnabrück"), 0xFF6A2C8F, WHITE),
club("aachen", "ALE", "Alemannia Aachen", listOf("alemannia aachen", "aachen"), 0xFFFDE100, BLACK),
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, article = "TSG 1899 Hoffenheim"),
club("hansa", "FCH", "FC Hansa Rostock", listOf("hansa"), 0xFF0B4EA2, WHITE, league = 3),
club("saarbruecken", "FCS", "1. FC Saarbrücken", listOf("saarbrücken"), 0xFF0B4EA2, BLACK, league = 3),
club("aue", "FCE", "Erzgebirge Aue", listOf("erzgebirge aue"), 0xFF7C0A02, WHITE, league = 3),
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, league = 3),
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, league = 3),
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", league = 3),
club("regensburg", "SSVJ", "SSV Jahn Regensburg", listOf("jahn regensburg", "regensburg"), 0xFFE1000F, WHITE, league = 3),
club("verl", "SCV", "SC Verl", listOf("sc verl"), 0xFF00703C, WHITE, league = 3),
club("viktoria", "VKÖ", "Viktoria Köln", listOf("viktoria köln"), 0xFFE1000F, WHITE, league = 3),
club("havelse", "TSVH", "TSV Havelse", listOf("havelse"), 0xFF004E9E, WHITE, league = 3),
club("schweinfurt", "FC05", "1. FC Schweinfurt 05", listOf("schweinfurt"), 0xFF00A94F, WHITE, league = 3),
club("osnabrueck", "VfLO", "VfL Osnabrück", listOf("osnabrück"), 0xFF6A2C8F, WHITE, league = 3),
club("aachen", "ALE", "Alemannia Aachen", listOf("alemannia aachen", "aachen"), 0xFFFDE100, BLACK, league = 3),
club("ingolstadt", "FCI", "FC Ingolstadt 04", listOf("ingolstadt"), 0xFFE1000F, BLACK, league = 3),
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, league = 3),
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. */
@@ -155,6 +158,14 @@ object TeamFilters {
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> =
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,30 +59,19 @@ class ControlServer(
var running = false
private set
// Failed authentication attempts per remote address. Counting every
// failure (not just the ones carrying a code) keeps a hostile client
// from spending someone else's budget.
private val attempts = HashMap<String, ArrayDeque<Long>>()
@Synchronized
private fun attemptAllowed(address: String): Boolean {
val now = System.currentTimeMillis()
val queue = attempts.getOrPut(address) { ArrayDeque() }
while (queue.isNotEmpty() && now - queue.first() > ATTEMPT_WINDOW_MS) {
queue.removeFirst()
}
if (attempts.size > MAX_TRACKED_ADDRESSES) {
attempts.entries.removeAll { it.value.isEmpty() }
}
if (queue.size >= ATTEMPT_MAX) return false
queue.addLast(now)
return true
}
// Failed authentication attempts per remote address. The rule itself
// lives in AttemptBudget, where it is unit-tested.
private val attempts = AttemptBudget()
fun startServer() {
// A busy port must not take the whole app down — the remote is
// optional, everything else keeps working.
running = runCatching { start(NanoHTTPD.SOCKET_READ_TIMEOUT, true) }
// NanoHTTPD's 5 s default also applies to the long-lived WebSocket:
// with pings only every 8 s the socket timed out mid-session and the
// remote was thrown out after a few seconds. The timeout still has to
// exist (idle connections must not pin threads), it just has to be
// comfortably longer than the ping interval.
running = runCatching { start(SOCKET_TIMEOUT_MS, true) }
.onFailure { Log.w(TAG, "control server unavailable: ${it.javaClass.simpleName}") }
.isSuccess
if (!running) return
@@ -292,9 +281,7 @@ class ControlServer(
}
private fun handleHello(msg: JSONObject) {
// Every failed attempt counts against this address, whether it
// carried a token or a code.
if (!attemptAllowed(remoteAddress)) {
if (!attempts.allows(remoteAddress)) {
trySend(JSONObject().put("type", "error").put("error", "rate_limited").toString())
runCatching { close(WebSocketFrame.CloseCode.PolicyViolation, "rate limited", false) }
return
@@ -302,10 +289,12 @@ class ControlServer(
val tokenOk = Pairing.isValidToken(context, msg.optString("token"))
val codeOk = !tokenOk && Pairing.isValidCode(context, msg.optString("code"))
if (!tokenOk && !codeOk) {
attempts.recordFailure(remoteAddress)
trySend(JSONObject().put("type", "error").put("error", "bad_code").toString())
runCatching { close(WebSocketFrame.CloseCode.PolicyViolation, "bad code", false) }
return
}
attempts.clear(remoteAddress)
authorized = true
deviceName = msg.optString("name").ifEmpty { "Handy" }
// A code-authenticated client gets its own revocable token, never
@@ -339,9 +328,7 @@ class ControlServer(
private companion object {
const val TAG = "ControlServer"
const val PING_INTERVAL_MS = 8_000L
const val ATTEMPT_WINDOW_MS = 60_000L
const val ATTEMPT_MAX = 5
const val MAX_TRACKED_ADDRESSES = 64
const val SOCKET_TIMEOUT_MS = 40_000
const val MAX_CLIENTS = 8
const val HANDSHAKE_TIMEOUT_MS = 10_000L
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.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.animation.core.animateFloat
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -32,6 +33,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.clipPath
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
@@ -87,6 +89,15 @@ fun LiveScreen(state: AppState) {
val matchesByTeam = remember(teams, allChannels, epgStamp) {
teams.associate { it.key to state.teamMatches(it.key) }
}
// Re-evaluates the highlight every minute; without it "gleich" would
// only appear when something else happened to recompose.
var urgencyTick by remember { mutableStateOf(0) }
LaunchedEffect(teams) {
while (true) {
kotlinx.coroutines.delay(60_000)
urgencyTick++
}
}
val activeTeam = teams.firstOrNull { it.key == state.activeTeam }
val activeMatches = activeTeam?.let { matchesByTeam[it.key] }.orEmpty()
val teamHits = remember(activeMatches) { activeMatches.associate { it.first.url to it.second } }
@@ -189,11 +200,16 @@ fun LiveScreen(state: AppState) {
}
}
items(teams, key = { it.key }) { club ->
val clubMatches = matchesByTeam[club.key].orEmpty()
val clubSignal = remember(clubMatches, urgencyTick) {
state.teamSignal(clubMatches)
}
GroupItem(
label = club.label,
count = matchesByTeam[club.key]?.size ?: 0,
count = clubMatches.size,
selected = state.activeTeam == club.key,
leading = { Crest(club, state) },
leading = { Crest(club, state, clubSignal.urgency) },
signal = clubSignal,
modifier = intoList.then(
if (state.activeTeam == club.key) Modifier.focusRequester(railFocus)
else Modifier
@@ -299,6 +315,8 @@ private fun GroupItem(
selected: Boolean,
modifier: Modifier = Modifier,
leading: (@Composable () -> Unit)? = null,
/** Replaces the count when a match is on or about to start. */
signal: AppState.TeamSignal? = null,
suppressAutoSelect: () -> Boolean = { false },
onSelect: () -> Unit,
) {
@@ -336,11 +354,36 @@ private fun GroupItem(
modifier = Modifier.weight(1f),
)
Spacer(Modifier.width(8.dp))
Text(
"$count",
fontFamily = AppFont,
fontSize = 12.sp,
)
// The signal lives in the value column instead of adding a
// badge — the count is worth less than "now" or "16:00" is.
val urgent = signal?.urgency ?: AppState.TeamUrgency.NONE
if (urgent == AppState.TeamUrgency.NONE) {
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(
Modifier
.clip(RoundedCornerShape(999.dp))
.background(CastarrColors.bgDeep.copy(alpha = 0.92f))
.padding(horizontal = 8.dp, vertical = 3.dp)
) {
Text(
if (urgent == AppState.TeamUrgency.LIVE) "läuft"
else formatClock(signal!!.kickOff),
color = if (urgent == AppState.TeamUrgency.LIVE) CastarrColors.live
else CastarrColors.accent,
fontFamily = AppFont,
fontSize = 12.sp,
fontWeight = FontWeight.SemiBold,
)
}
}
}
}
}
@@ -438,13 +481,47 @@ private fun ChannelRow(
* network; the coloured shield stands in if one is ever missing.
*/
@Composable
private fun Crest(team: dev.castarr.tv.data.TeamFilter, state: AppState) {
Box(Modifier.size(20.dp), contentAlignment = Alignment.Center) {
private fun Crest(
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(
model = "file:///android_asset/crests/${team.key}.png",
contentDescription = null,
contentScale = ContentScale.Fit,
modifier = Modifier.fillMaxSize(),
filterQuality = androidx.compose.ui.graphics.FilterQuality.High,
modifier = Modifier.size(20.dp),
loading = { 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.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
@@ -38,6 +39,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.platform.LocalContext
@@ -78,6 +80,7 @@ fun SettingsScreen(state: AppState) {
val profiles by state.dispatcharr.profiles.collectAsState()
var updateStatus by remember { mutableStateOf("") }
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.
var pairingEpoch by remember { mutableStateOf(0) }
@@ -189,25 +192,16 @@ fun SettingsScreen(state: AppState) {
active.forEach { club ->
SettingRow(
club.fullName,
subtitle = "Erscheint als ${club.label} in der Senderliste",
trailing = { TogglePill(on = true) },
subtitle = "Erscheint als ${club.label} in der Senderliste · Entfernen",
leading = { ClubCrest(club, state) },
trailing = { RemoveIcon() },
) { state.toggleTeam(club.key) }
}
if (active.size < dev.castarr.tv.data.TeamFilters.MAX_ACTIVE) {
SettingRow(
"Verein hinzufügen",
subtitle = "1. bis 3. Liga",
) {
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) }
}
}
) { clubPicker = true }
}
}
@@ -319,6 +313,155 @@ fun SettingsScreen(state: AppState) {
picker?.let { current ->
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. */
@@ -452,6 +595,17 @@ private fun ValueWithCaret(value: String) {
}
}
/** Drawn "x": the row only ever removes, so a toggle would mislead. */
@Composable
private fun RemoveIcon() {
val color = CastarrColors.muted
Canvas(Modifier.size(14.dp)) {
val stroke = 2.dp.toPx()
drawLine(color, Offset(0f, 0f), Offset(size.width, size.height), stroke)
drawLine(color, Offset(size.width, 0f), Offset(0f, size.height), stroke)
}
}
@Composable
private fun TogglePill(on: Boolean) {
Box(
@@ -500,6 +654,7 @@ private fun SettingRow(
label: String,
subtitle: String? = null,
danger: Boolean = false,
leading: (@Composable () -> Unit)? = null,
trailing: (@Composable () -> Unit)? = null,
onClick: () -> Unit,
) {
@@ -525,6 +680,10 @@ private fun SettingRow(
modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (leading != null) {
leading()
Spacer(Modifier.width(12.dp))
}
Column(Modifier.weight(1f)) {
Text(
label,

View File

@@ -27,6 +27,9 @@ object UpdateChecker {
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. */
suspend fun check(state: AppState): String? = withContext(Dispatchers.IO) {
runCatching {
@@ -42,7 +45,7 @@ object UpdateChecker {
}
}
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
val version = "v$tag"
withContext(Dispatchers.Main) { state.updateAvailable = version }
@@ -54,50 +57,71 @@ object UpdateChecker {
}.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? =
withContext(Dispatchers.IO) {
runCatching {
if (apkUrl.isEmpty()) check(state)
require(apkUrl.isNotEmpty()) { "no update available" }
val dir = File(context.cacheDir, "updates").apply { mkdirs() }
val file = File(dir, "castarr-update.apk")
(URL(apkUrl).openConnection() as HttpURLConnection).run {
connectTimeout = 15_000
readTimeout = 120_000
instanceFollowRedirects = true
inputStream.use { input -> file.outputStream().use { input.copyTo(it) } }
disconnect()
// 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 {
if (apkUrl.isEmpty()) check(state)
require(apkUrl.isNotEmpty()) { "no update available" }
val dir = File(context.cacheDir, "updates").apply { mkdirs() }
val file = File(dir, "castarr-update.apk")
val part = File(dir, "castarr-update.apk.part")
part.delete()
val connection = URL(apkUrl).openConnection() as HttpURLConnection
val expected = try {
connection.connectTimeout = 15_000
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(
context, "${BuildConfig.APPLICATION_ID}.fileprovider", file,
)
val intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, "application/vnd.android.package-archive")
addFlags(
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK
)
}
// Launching from the IO dispatcher stalled the installer on
// the first attempt — activities start from the main thread.
withContext(Dispatchers.Main) { context.startActivity(intent) }
null
}.getOrElse {
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"
}
val uri = FileProvider.getUriForFile(
context, "${BuildConfig.APPLICATION_ID}.fileprovider", file,
)
val intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, "application/vnd.android.package-archive")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
}
// Launching from the IO dispatcher stalled the installer on
// the first attempt — activities start from the main thread.
withContext(Dispatchers.Main) { context.startActivity(intent) }
null
}.getOrElse {
Log.w(TAG, "install failed: ${it.javaClass.simpleName}")
"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 {
val connection = URL(url).openConnection() as HttpURLConnection
return try {

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()
entries = re.findall(r'club\((.*?)\)\s*,\s*(?://.*)?$', kotlin, re.M | re.S)
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):
clubs.append(raw)
# 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:
data = r.read()
with open(target, "wb") as f:
f.write(data)
f.write(downscale(data))
fetched += 1
except Exception as exc: # noqa: BLE001 - best effort, shield is the fallback
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."