Compare commits
13 Commits
v0.10.5
...
e35500fa2f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e35500fa2f | ||
|
|
b52ef79c01 | ||
|
|
f1a6600e42 | ||
|
|
637679fd90 | ||
|
|
cbdac7ddb2 | ||
|
|
8bf1ed3128 | ||
|
|
3b6a8d7d0a | ||
|
|
e491f8e50f | ||
|
|
56b5d3f0b0 | ||
|
|
b0b8f8801d | ||
|
|
9a0fa37e55 | ||
|
|
73f6dbba75 | ||
|
|
f6f6a2499f |
65
.github/workflows/build.yml
vendored
65
.github/workflows/build.yml
vendored
@@ -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,38 +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: Fetch club crests
|
||||
run: python3 tools/fetch-crests.py || true
|
||||
|
||||
- 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
2
.gitignore
vendored
@@ -6,3 +6,5 @@ local.properties
|
||||
.kotlin/
|
||||
tests/runs/
|
||||
app/src/main/assets/crests/
|
||||
tests/demo/playlist.m3u
|
||||
tests/demo/epg.xml
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
18
README.md
18
README.md
@@ -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/).
|
||||
|
||||
@@ -12,8 +12,8 @@ android {
|
||||
applicationId = "dev.castarr.tv"
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
versionCode = 31
|
||||
versionName = "0.10.5"
|
||||
versionCode = 38
|
||||
versionName = "0.11.5"
|
||||
}
|
||||
|
||||
// Release signing from environment (see ~/.keys/castarr-release.env on the
|
||||
|
||||
@@ -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('|');
|
||||
|
||||
@@ -170,22 +170,55 @@ class AppState(
|
||||
SourceMode.DISPATCHARR -> dispatcharr.upcoming(channel, windowMs)
|
||||
}
|
||||
|
||||
/** One channel's club broadcasts: the next one plus how many follow. */
|
||||
data class TeamHit(
|
||||
val programme: dev.castarr.tv.data.Programme,
|
||||
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
|
||||
* is running right now sits on top.
|
||||
*/
|
||||
fun teamMatches(key: String): List<Pair<Channel, dev.castarr.tv.data.Programme>> {
|
||||
fun teamMatches(key: String): List<Pair<Channel, TeamHit>> {
|
||||
val filter = dev.castarr.tv.data.TeamFilters.byKey(key) ?: return emptyList()
|
||||
val window = dev.castarr.tv.data.TeamFilters.WINDOW_MS
|
||||
val now = System.currentTimeMillis()
|
||||
val window = dev.castarr.tv.data.TeamFilters.windowEnd(now) - now
|
||||
return activeChannels()
|
||||
.filter { dev.castarr.tv.data.TeamFilters.scansGroup(it.group) }
|
||||
.mapNotNull { channel ->
|
||||
upcoming(channel, window)
|
||||
.firstOrNull { !isEpgPlaceholder(it.title) && filter.matches(it.title) }
|
||||
?.let { channel to it }
|
||||
// A channel can carry several of the club's broadcasts in one
|
||||
// evening; the row names the next and counts the rest.
|
||||
val hits = upcoming(channel, window)
|
||||
.filter { !isEpgPlaceholder(it.title) && filter.matches(it.title) }
|
||||
hits.firstOrNull()?.let { channel to TeamHit(it, hits.size - 1) }
|
||||
}
|
||||
.sortedBy { it.second.start }
|
||||
.sortedBy { it.second.programme.start }
|
||||
}
|
||||
|
||||
fun refreshActive() {
|
||||
@@ -200,8 +233,14 @@ class AppState(
|
||||
result.fold(
|
||||
onSuccess = { appError = AppError.NONE },
|
||||
onFailure = { throwable ->
|
||||
// Cached channels keep the app usable; only surface a
|
||||
// fullscreen state when there is nothing to show.
|
||||
// An expired login must always surface: cached channels
|
||||
// would otherwise sit there without EPG or favourites and
|
||||
// nothing would say why.
|
||||
if (throwable.message == "not logged in") {
|
||||
appError = AppError.RELOGIN
|
||||
return
|
||||
}
|
||||
// Otherwise cached channels keep the app usable.
|
||||
if (activeChannels().isNotEmpty()) return
|
||||
appError = when {
|
||||
throwable.message == "not logged in" -> AppError.RELOGIN
|
||||
@@ -215,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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -49,13 +49,20 @@ class DispatcharrRepository(context: Context, private val auth: DeviceAuth) {
|
||||
scope.launch {
|
||||
val result = runCatching {
|
||||
status.value = "loading_channels"
|
||||
Log.i(TAG, "refresh start (loggedIn=${auth.isLoggedIn})")
|
||||
val token = auth.accessToken() ?: error("not logged in")
|
||||
val groups = fetchGroups(token)
|
||||
val list = fetchChannels(token, groups)
|
||||
channels.value = list
|
||||
prefs.edit().putString("channels_cache", Channel.listToJson(list).toString()).apply()
|
||||
launch { runCatching { refreshFavorites(token) } }
|
||||
launch { runCatching { refreshProfiles(token) } }
|
||||
launch {
|
||||
runCatching { refreshFavorites(token) }
|
||||
.onFailure { Log.w(TAG, "favorites failed: ${it.javaClass.simpleName}: ${it.message?.take(120)}") }
|
||||
}
|
||||
launch {
|
||||
runCatching { refreshProfiles(token) }
|
||||
.onFailure { Log.w(TAG, "profiles failed: ${it.javaClass.simpleName}: ${it.message?.take(120)}") }
|
||||
}
|
||||
launch {
|
||||
runCatching { refreshEpg(token) }
|
||||
.onFailure { Log.w(TAG, "epg failed: ${it.javaClass.simpleName}: ${it.message?.take(160)}") }
|
||||
|
||||
@@ -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()
|
||||
@@ -29,8 +31,25 @@ data class TeamFilter(
|
||||
|
||||
object TeamFilters {
|
||||
|
||||
/** Window scanned ahead of now. */
|
||||
const val WINDOW_MS = 3 * 60 * 60 * 1000L
|
||||
/** Minimum window scanned ahead of now. */
|
||||
const val MIN_WINDOW_MS = 3 * 60 * 60 * 1000L
|
||||
|
||||
/**
|
||||
* Scans until midnight, but never less than [MIN_WINDOW_MS] — asking in
|
||||
* the morning should already answer "does my club play tonight", and
|
||||
* asking late at night must not cut the evening short.
|
||||
*/
|
||||
fun windowEnd(now: Long): Long {
|
||||
val midnight = java.util.Calendar.getInstance().apply {
|
||||
timeInMillis = now
|
||||
add(java.util.Calendar.DAY_OF_YEAR, 1)
|
||||
set(java.util.Calendar.HOUR_OF_DAY, 0)
|
||||
set(java.util.Calendar.MINUTE, 0)
|
||||
set(java.util.Calendar.SECOND, 0)
|
||||
set(java.util.Calendar.MILLISECOND, 0)
|
||||
}.timeInMillis
|
||||
return maxOf(midnight, now + MIN_WINDOW_MS)
|
||||
}
|
||||
|
||||
/** At most this many club groups sit in the rail at once. */
|
||||
const val MAX_ACTIVE = 3
|
||||
@@ -46,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. */
|
||||
@@ -138,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()
|
||||
}
|
||||
|
||||
78
app/src/main/java/dev/castarr/tv/server/AttemptBudget.kt
Normal file
78
app/src/main/java/dev/castarr/tv/server/AttemptBudget.kt
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -246,7 +262,7 @@ fun LiveScreen(state: AppState) {
|
||||
Text(
|
||||
when {
|
||||
activeTeam != null ->
|
||||
"In den nächsten 3 Stunden läuft nichts mit ${activeTeam.fullName}."
|
||||
"Heute läuft nichts mehr mit ${activeTeam.fullName}."
|
||||
state.favoritesOnly ->
|
||||
"Noch keine Favoriten — halte OK auf einem Sender gedrückt."
|
||||
else -> "Diese Gruppe ist leer."
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -351,7 +394,7 @@ private fun ChannelRow(
|
||||
number: Int,
|
||||
modifier: Modifier = Modifier,
|
||||
nowNext: NowNext,
|
||||
highlight: dev.castarr.tv.data.Programme? = null,
|
||||
highlight: AppState.TeamHit? = null,
|
||||
playing: Boolean,
|
||||
favorite: Boolean,
|
||||
epgStamp: Long,
|
||||
@@ -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) },
|
||||
)
|
||||
@@ -510,9 +587,10 @@ private fun LogoInitials(initials: String) {
|
||||
}
|
||||
}
|
||||
|
||||
/** The programme that matched the club filter, with its start time. */
|
||||
/** The next club broadcast on this channel, and how many follow it. */
|
||||
@Composable
|
||||
private fun HighlightCell(programme: dev.castarr.tv.data.Programme, modifier: Modifier = Modifier) {
|
||||
private fun HighlightCell(hit: AppState.TeamHit, modifier: Modifier = Modifier) {
|
||||
val programme = hit.programme
|
||||
val running = System.currentTimeMillis() in programme.start until programme.stop
|
||||
Column(modifier) {
|
||||
Row(verticalAlignment = Alignment.Bottom) {
|
||||
@@ -536,7 +614,8 @@ private fun HighlightCell(programme: dev.castarr.tv.data.Programme, modifier: Mo
|
||||
}
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text(
|
||||
"${formatClock(programme.start)}–${formatClock(programme.stop)}",
|
||||
"${formatClock(programme.start)}–${formatClock(programme.stop)}" +
|
||||
if (hit.further > 0) " · +${hit.further} weitere" else "",
|
||||
color = CastarrColors.faint,
|
||||
fontFamily = AppFont,
|
||||
fontSize = 12.sp,
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -178,8 +181,8 @@ fun SettingsScreen(state: AppState) {
|
||||
|
||||
SettingsCard("Vereinsmenüs") {
|
||||
Text(
|
||||
"Eigene Gruppe mit allen Sendern, auf denen der Verein in den " +
|
||||
"nächsten 3 Stunden läuft. Bis zu " +
|
||||
"Eigene Gruppe mit allen Sendern, auf denen der Verein " +
|
||||
"heute noch läuft. Bis zu " +
|
||||
"${dev.castarr.tv.data.TeamFilters.MAX_ACTIVE} Vereine.",
|
||||
color = CastarrColors.faint, fontFamily = AppFont, fontSize = 11.sp,
|
||||
modifier = Modifier.padding(start = 14.dp, end = 14.dp, bottom = 6.dp),
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -251,6 +245,10 @@ fun SettingsScreen(state: AppState) {
|
||||
}
|
||||
}
|
||||
}
|
||||
SettingRow(
|
||||
"Senderliste neu laden",
|
||||
subtitle = "Holt Sender, Programm und Favoriten erneut",
|
||||
) { state.refreshActive() }
|
||||
SettingRow(
|
||||
"Erweitert",
|
||||
subtitle = "M3U/EPG-Adressen von Hand eintragen",
|
||||
@@ -315,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. */
|
||||
@@ -448,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(
|
||||
@@ -496,6 +654,7 @@ private fun SettingRow(
|
||||
label: String,
|
||||
subtitle: String? = null,
|
||||
danger: Boolean = false,
|
||||
leading: (@Composable () -> Unit)? = null,
|
||||
trailing: (@Composable () -> Unit)? = null,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
@@ -521,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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
45
app/src/main/java/dev/castarr/tv/update/UpdateRules.kt
Normal file
45
app/src/main/java/dev/castarr/tv/update/UpdateRules.kt
Normal 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
107
tests/demo/make-demo-data.py
Executable 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("&", "&").replace("<", "<").replace(">", ">")
|
||||
|
||||
|
||||
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
70
tests/helpers/emulator.sh
Executable 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
38
tests/helpers/jdk.sh
Executable 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
115
tests/smoke.sh
Executable 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
|
||||
101
tests/unit/AttemptBudgetTest.kt
Normal file
101
tests/unit/AttemptBudgetTest.kt
Normal 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())
|
||||
}
|
||||
}
|
||||
@@ -51,6 +51,25 @@ class TeamFiltersTest {
|
||||
assertTrue(TeamFilters.all.all { c -> c.needles.all { it == it.lowercase() } })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `window reaches midnight but never falls below three hours`() {
|
||||
val cal = java.util.Calendar.getInstance()
|
||||
// Morning: the window must reach into the evening.
|
||||
cal.set(java.util.Calendar.HOUR_OF_DAY, 9)
|
||||
cal.set(java.util.Calendar.MINUTE, 0)
|
||||
val morning = cal.timeInMillis
|
||||
val eveningKickoff = morning + 12 * 60 * 60 * 1000L
|
||||
assertTrue(TeamFilters.windowEnd(morning) > eveningKickoff)
|
||||
|
||||
// Late at night: still at least three hours ahead.
|
||||
cal.set(java.util.Calendar.HOUR_OF_DAY, 23)
|
||||
cal.set(java.util.Calendar.MINUTE, 30)
|
||||
val lateNight = cal.timeInMillis
|
||||
assertTrue(
|
||||
TeamFilters.windowEnd(lateNight) >= lateNight + TeamFilters.MIN_WINDOW_MS
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `covers all three divisions`() {
|
||||
assertTrue(TeamFilters.all.size > 50)
|
||||
|
||||
76
tests/unit/UpdateRulesTest.kt
Normal file
76
tests/unit/UpdateRulesTest.kt
Normal 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))
|
||||
}
|
||||
}
|
||||
58
tools/crest-sources.json
Normal file
58
tools/crest-sources.json
Normal file
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"aachen": "https://upload.wikimedia.org/wikipedia/commons/thumb/9/94/Alemannia_Aachen_2010.svg/langde-330px-Alemannia_Aachen_2010.svg.png",
|
||||
"aue": "https://thumb.wikimedia.org/wikipedia/de/thumb/1/13/Fc_erzgebirge_aue.svg/langde-330px-Fc_erzgebirge_aue.svg.png",
|
||||
"augsburg": "https://upload.wikimedia.org/wikipedia/de/thumb/b/b5/Logo_FC_Augsburg.svg/langde-330px-Logo_FC_Augsburg.svg.png",
|
||||
"bayern": "https://upload.wikimedia.org/wikipedia/commons/thumb/8/8d/FC_Bayern_M%C3%BCnchen_logo_%282024%29.svg/langde-330px-FC_Bayern_M%C3%BCnchen_logo_%282024%29.svg.png",
|
||||
"bielefeld": "https://thumb.wikimedia.org/wikipedia/commons/thumb/f/fd/Arminia_Bielefeld_Logo_2021%E2%80%93.svg/langde-330px-Arminia_Bielefeld_Logo_2021%E2%80%93.svg.png",
|
||||
"bochum": "https://thumb.wikimedia.org/wikipedia/commons/thumb/7/72/VfL_Bochum_logo.svg/langde-330px-VfL_Bochum_logo.svg.png",
|
||||
"braunschweig": "https://thumb.wikimedia.org/wikipedia/de/thumb/4/45/Logo_Eintracht_Braunschweig.svg/langde-330px-Logo_Eintracht_Braunschweig.svg.png",
|
||||
"bremen": "https://upload.wikimedia.org/wikipedia/commons/thumb/b/be/SV-Werder-Bremen-Logo.svg/langde-330px-SV-Werder-Bremen-Logo.svg.png",
|
||||
"bvb": "https://upload.wikimedia.org/wikipedia/commons/thumb/6/67/Borussia_Dortmund_logo.svg/langde-330px-Borussia_Dortmund_logo.svg.png",
|
||||
"cottbus": "https://upload.wikimedia.org/wikipedia/commons/thumb/5/55/Logo_Energie_Cottbus.svg/langde-330px-Logo_Energie_Cottbus.svg.png",
|
||||
"darmstadt": "https://upload.wikimedia.org/wikipedia/commons/thumb/e/e5/SV_Darmstadt_98_Logo.svg/langde-330px-SV_Darmstadt_98_Logo.svg.png",
|
||||
"dresden": "https://upload.wikimedia.org/wikipedia/commons/thumb/e/e1/Logo_SG_Dynamo_Dresden_neu.svg/langde-330px-Logo_SG_Dynamo_Dresden_neu.svg.png",
|
||||
"duesseldorf": "https://thumb.wikimedia.org/wikipedia/commons/thumb/9/94/Fortuna_D%C3%BCsseldorf.svg/langde-330px-Fortuna_D%C3%BCsseldorf.svg.png",
|
||||
"duisburg": "https://upload.wikimedia.org/wikipedia/commons/thumb/0/02/Msv_duisburg_%282017%29.svg/langde-330px-Msv_duisburg_%282017%29.svg.png",
|
||||
"elversberg": "https://upload.wikimedia.org/wikipedia/commons/thumb/d/d4/SV_Elversberg_Logo_2021.svg/langde-330px-SV_Elversberg_Logo_2021.svg.png",
|
||||
"essen": "https://upload.wikimedia.org/wikipedia/de/thumb/8/8a/Logo_Rot-Weiss_Essen.svg/langde-330px-Logo_Rot-Weiss_Essen.svg.png",
|
||||
"frankfurt": "https://upload.wikimedia.org/wikipedia/de/thumb/3/32/Logo_Eintracht_Frankfurt_1998.svg/langde-330px-Logo_Eintracht_Frankfurt_1998.svg.png",
|
||||
"freiburg": "https://upload.wikimedia.org/wikipedia/de/thumb/b/bf/SC_Freiburg_Logo.svg/langde-330px-SC_Freiburg_Logo.svg.png",
|
||||
"fuerth": "https://upload.wikimedia.org/wikipedia/commons/thumb/b/b1/SpVgg_Greuther_F%C3%BCrth_2017.svg/langde-330px-SpVgg_Greuther_F%C3%BCrth_2017.svg.png",
|
||||
"gladbach": "https://thumb.wikimedia.org/wikipedia/commons/thumb/8/81/Borussia_M%C3%B6nchengladbach_logo.svg/langde-330px-Borussia_M%C3%B6nchengladbach_logo.svg.png",
|
||||
"hannover": "https://upload.wikimedia.org/wikipedia/commons/thumb/c/cd/Hannover_96_Logo.svg/langde-330px-Hannover_96_Logo.svg.png",
|
||||
"hansa": "https://thumb.wikimedia.org/wikipedia/commons/thumb/8/8f/F.C._Hansa_Rostock_Logo.svg/langde-330px-F.C._Hansa_Rostock_Logo.svg.png",
|
||||
"havelse": "https://thumb.wikimedia.org/wikipedia/commons/thumb/8/89/TSV_Havelse_logo.svg/langde-330px-TSV_Havelse_logo.svg.png",
|
||||
"heidenheim": "https://thumb.wikimedia.org/wikipedia/commons/thumb/9/9d/1._FC_Heidenheim_1846.svg/langde-330px-1._FC_Heidenheim_1846.svg.png",
|
||||
"hertha": "https://upload.wikimedia.org/wikipedia/commons/thumb/8/81/Hertha_BSC_Logo_2012.svg/langde-330px-Hertha_BSC_Logo_2012.svg.png",
|
||||
"hoffenheim": "https://upload.wikimedia.org/wikipedia/commons/thumb/e/e7/Logo_TSG_Hoffenheim.svg/langde-330px-Logo_TSG_Hoffenheim.svg.png",
|
||||
"hoffenheim-ii": "https://upload.wikimedia.org/wikipedia/commons/thumb/e/e7/Logo_TSG_Hoffenheim.svg/langde-330px-Logo_TSG_Hoffenheim.svg.png",
|
||||
"holstein": "https://upload.wikimedia.org/wikipedia/commons/thumb/3/30/Holstein_Kiel_Logo.svg/langde-330px-Holstein_Kiel_Logo.svg.png",
|
||||
"hsv": "https://thumb.wikimedia.org/wikipedia/commons/thumb/f/f7/Hamburger_SV_logo.svg/langde-330px-Hamburger_SV_logo.svg.png",
|
||||
"ingolstadt": "https://thumb.wikimedia.org/wikipedia/de/thumb/5/55/FC-Ingolstadt_logo.svg/langde-330px-FC-Ingolstadt_logo.svg.png",
|
||||
"kaiserslautern": "https://upload.wikimedia.org/wikipedia/commons/thumb/d/d3/Logo_1_FC_Kaiserslautern.svg/langde-330px-Logo_1_FC_Kaiserslautern.svg.png",
|
||||
"karlsruhe": "https://upload.wikimedia.org/wikipedia/commons/thumb/c/c8/Karlsruher_SC_Logo_2.svg/langde-330px-Karlsruher_SC_Logo_2.svg.png",
|
||||
"koeln": "https://thumb.wikimedia.org/wikipedia/commons/thumb/0/01/1._FC_Koeln_Logo_2014%E2%80%93.svg/langde-330px-1._FC_Koeln_Logo_2014%E2%80%93.svg.png",
|
||||
"leipzig": "https://upload.wikimedia.org/wikipedia/commons/thumb/a/ab/2019-07-12_Fu%C3%9Fball%3B_Freundschaftsspiel_RB_Leipzig_-_FC_Z%C3%BCrich_1DX_0881_by_Stepro_2.png/330px-2019-07-12_Fu%C3%9Fball%3B_Freundschaftsspiel_RB_Leipzig_-_FC_Z%C3%BCrich_1DX_0881_by_Stepro_2.png",
|
||||
"leverkusen": "https://upload.wikimedia.org/wikipedia/de/thumb/f/f7/Bayer_Leverkusen_Logo.svg/langde-330px-Bayer_Leverkusen_Logo.svg.png",
|
||||
"magdeburg": "https://upload.wikimedia.org/wikipedia/commons/thumb/8/84/1._FC_Magdeburg.svg/langde-330px-1._FC_Magdeburg.svg.png",
|
||||
"mainz": "https://thumb.wikimedia.org/wikipedia/commons/thumb/9/9e/Logo_Mainz_05.svg/langde-330px-Logo_Mainz_05.svg.png",
|
||||
"mannheim": "https://upload.wikimedia.org/wikipedia/commons/thumb/1/17/Svwaldhof.svg/langde-330px-Svwaldhof.svg.png",
|
||||
"muenster": "https://thumb.wikimedia.org/wikipedia/de/thumb/7/7e/SC_Preussen_Muenster_Logo_2018.svg/langde-330px-SC_Preussen_Muenster_Logo_2018.svg.png",
|
||||
"nuernberg": "https://upload.wikimedia.org/wikipedia/commons/thumb/f/fa/1._FC_N%C3%BCrnberg_logo.svg/langde-330px-1._FC_N%C3%BCrnberg_logo.svg.png",
|
||||
"osnabrueck": "https://thumb.wikimedia.org/wikipedia/commons/thumb/4/4e/VfL_Osnabrueck_Logo_2021%E2%80%93.svg/langde-330px-VfL_Osnabrueck_Logo_2021%E2%80%93.svg.png",
|
||||
"paderborn": "https://upload.wikimedia.org/wikipedia/commons/thumb/6/67/SC_Paderborn_07_Logo_new.svg/langde-330px-SC_Paderborn_07_Logo_new.svg.png",
|
||||
"regensburg": "https://thumb.wikimedia.org/wikipedia/commons/thumb/3/3d/Jahn_Regensburg_logo2014.svg/langde-330px-Jahn_Regensburg_logo2014.svg.png",
|
||||
"saarbruecken": "https://thumb.wikimedia.org/wikipedia/de/thumb/f/ff/1._FC_Saarbr%C3%BCcken.svg/langde-330px-1._FC_Saarbr%C3%BCcken.svg.png",
|
||||
"schalke": "https://upload.wikimedia.org/wikipedia/commons/thumb/6/6d/FC_Schalke_04_Logo.svg/langde-330px-FC_Schalke_04_Logo.svg.png",
|
||||
"schweinfurt": "https://thumb.wikimedia.org/wikipedia/de/thumb/7/7c/1._FC_Schweinfurt_05.svg/langde-330px-1._FC_Schweinfurt_05.svg.png",
|
||||
"st-pauli": "https://upload.wikimedia.org/wikipedia/commons/thumb/b/b3/Fc_st_pauli_logo.svg/langde-330px-Fc_st_pauli_logo.svg.png",
|
||||
"stuttgart": "https://thumb.wikimedia.org/wikipedia/commons/thumb/e/eb/VfB_Stuttgart_1893_Logo.svg/langde-330px-VfB_Stuttgart_1893_Logo.svg.png",
|
||||
"stuttgart-ii": "https://upload.wikimedia.org/wikipedia/commons/thumb/e/eb/VfB_Stuttgart_1893_Logo.svg/langde-330px-VfB_Stuttgart_1893_Logo.svg.png",
|
||||
"ulm": "https://thumb.wikimedia.org/wikipedia/commons/thumb/6/6c/SSV_Ulm_1846_Fussball.svg/langde-330px-SSV_Ulm_1846_Fussball.svg.png",
|
||||
"union": "https://upload.wikimedia.org/wikipedia/commons/thumb/4/44/1._FC_Union_Berlin_Logo.svg/langde-330px-1._FC_Union_Berlin_Logo.svg.png",
|
||||
"verl": "https://thumb.wikimedia.org/wikipedia/commons/thumb/c/ce/SC_Verl_Logo.svg/langde-330px-SC_Verl_Logo.svg.png",
|
||||
"viktoria": "https://thumb.wikimedia.org/wikipedia/commons/thumb/d/dc/FC_Viktoria_K%C3%B6ln_1904_Logo.svg/langde-330px-FC_Viktoria_K%C3%B6ln_1904_Logo.svg.png",
|
||||
"wehen": "https://upload.wikimedia.org/wikipedia/de/e/e0/SV_Wehen_Logo.png",
|
||||
"wolfsburg": "https://upload.wikimedia.org/wikipedia/commons/thumb/c/c5/VfL_Wolfsburg_logo_2026.svg/langde-330px-VfL_Wolfsburg_logo_2026.svg.png",
|
||||
"wuppertal": "https://thumb.wikimedia.org/wikipedia/de/thumb/9/9b/WuppertalerSVLogo.svg/langde-330px-WuppertalerSVLogo.svg.png"
|
||||
}
|
||||
@@ -14,14 +14,43 @@ import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
OUT = sys.argv[1] if len(sys.argv) > 1 else "app/src/main/assets/crests"
|
||||
URLS = "tools/crest-sources.json"
|
||||
SRC = "app/src/main/java/dev/castarr/tv/data/TeamFilters.kt"
|
||||
SUMMARY = "https://de.wikipedia.org/api/rest_v1/page/summary/"
|
||||
UA = {"User-Agent": "Castarr build script (private use)"}
|
||||
|
||||
os.makedirs(OUT, exist_ok=True)
|
||||
# Image URLs are plain text and safe to commit; the artwork itself is not.
|
||||
sources = {}
|
||||
if os.path.exists(URLS):
|
||||
sources = json.load(open(URLS, encoding="utf-8"))
|
||||
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
|
||||
@@ -35,15 +64,19 @@ for key, full_name in clubs:
|
||||
skipped += 1
|
||||
continue
|
||||
try:
|
||||
url = SUMMARY + urllib.parse.quote(full_name)
|
||||
with urllib.request.urlopen(urllib.request.Request(url, headers=UA), timeout=20) as r:
|
||||
thumb = json.load(r).get("thumbnail", {}).get("source")
|
||||
thumb = sources.get(key)
|
||||
if not thumb:
|
||||
raise ValueError("no thumbnail")
|
||||
url = SUMMARY + urllib.parse.quote(full_name)
|
||||
with urllib.request.urlopen(urllib.request.Request(url, headers=UA), timeout=20) as r:
|
||||
thumb = json.load(r).get("thumbnail", {}).get("source")
|
||||
if not thumb:
|
||||
raise ValueError("no thumbnail")
|
||||
sources[key] = thumb.split("?")[0]
|
||||
thumb = sources[key]
|
||||
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)
|
||||
@@ -51,4 +84,8 @@ for key, full_name in clubs:
|
||||
# Wikipedia rate-limits bursts; this runs rarely and caches.
|
||||
time.sleep(1.2)
|
||||
|
||||
with open(URLS, "w", encoding="utf-8") as f:
|
||||
json.dump(dict(sorted(sources.items())), f, indent=2, ensure_ascii=False)
|
||||
f.write("\n")
|
||||
|
||||
print(f"crests: {fetched} geladen, {skipped} vorhanden, {failed} fehlgeschlagen")
|
||||
|
||||
171
tools/release.sh
Executable file
171
tools/release.sh
Executable 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."
|
||||
Reference in New Issue
Block a user