Compare commits
6 Commits
v0.11.4
...
e35500fa2f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e35500fa2f | ||
|
|
b52ef79c01 | ||
|
|
f1a6600e42 | ||
|
|
637679fd90 | ||
|
|
cbdac7ddb2 | ||
|
|
8bf1ed3128 |
62
.github/workflows/build.yml
vendored
62
.github/workflows/build.yml
vendored
@@ -11,8 +11,19 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: docker.gitea.com/runner-images:ubuntu-latest
|
||||||
|
# A directory on the runner host, whitelisted in the runner's
|
||||||
|
# valid_volumes. Everything the job would otherwise download on every
|
||||||
|
# push — JDK, Android SDK, Gradle distribution and dependencies —
|
||||||
|
# lives here and survives the container.
|
||||||
|
options: -v /etc/komodo/stacks/gitea/runner-cache/castarr:/cache
|
||||||
env:
|
env:
|
||||||
ANDROID_SDK_ROOT: /opt/android-sdk
|
CACHE: /cache
|
||||||
|
JAVA_HOME: /cache/jdk17
|
||||||
|
ANDROID_SDK_ROOT: /cache/android-sdk
|
||||||
|
GRADLE_USER_HOME: /cache/gradle
|
||||||
|
JDK_URL: https://api.adoptium.net/v3/binary/latest/17/ga/linux/x64/jdk/hotspot/normal/eclipse
|
||||||
CMDLINE_TOOLS: https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip
|
CMDLINE_TOOLS: https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
@@ -22,35 +33,42 @@ jobs:
|
|||||||
git fetch -q --depth 1 origin "${GITHUB_SHA}"
|
git fetch -q --depth 1 origin "${GITHUB_SHA}"
|
||||||
git checkout -q FETCH_HEAD
|
git checkout -q FETCH_HEAD
|
||||||
|
|
||||||
- name: Install JDK
|
- name: JDK 17
|
||||||
run: |
|
run: |
|
||||||
apt-get update -qq
|
if [ ! -x "$JAVA_HOME/bin/java" ]; then
|
||||||
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq openjdk-17-jdk-headless > /dev/null
|
echo "JDK nicht im Cache — einmalig herunterladen"
|
||||||
java -version
|
mkdir -p "$JAVA_HOME"
|
||||||
|
curl -sSL "$JDK_URL" | tar -xz -C "$JAVA_HOME" --strip-components=1
|
||||||
|
fi
|
||||||
|
"$JAVA_HOME/bin/java" -version
|
||||||
|
|
||||||
- name: Install Android SDK
|
- name: Android SDK
|
||||||
run: |
|
run: |
|
||||||
mkdir -p "$ANDROID_SDK_ROOT/cmdline-tools"
|
if [ ! -d "$ANDROID_SDK_ROOT/platforms/android-35" ]; then
|
||||||
curl -sSL -o /tmp/tools.zip "$CMDLINE_TOOLS"
|
echo "SDK nicht im Cache — einmalig einrichten"
|
||||||
unzip -q /tmp/tools.zip -d "$ANDROID_SDK_ROOT/cmdline-tools"
|
mkdir -p "$ANDROID_SDK_ROOT/cmdline-tools"
|
||||||
mv "$ANDROID_SDK_ROOT/cmdline-tools/cmdline-tools" "$ANDROID_SDK_ROOT/cmdline-tools/latest"
|
curl -sSL -o /tmp/tools.zip "$CMDLINE_TOOLS"
|
||||||
# Accept licences by writing the hashes: piping "yes" into
|
unzip -q -o /tmp/tools.zip -d "$ANDROID_SDK_ROOT/cmdline-tools"
|
||||||
# sdkmanager dies of SIGPIPE (exit 141) under pipefail.
|
rm -rf "$ANDROID_SDK_ROOT/cmdline-tools/latest"
|
||||||
mkdir -p "$ANDROID_SDK_ROOT/licenses"
|
mv "$ANDROID_SDK_ROOT/cmdline-tools/cmdline-tools" "$ANDROID_SDK_ROOT/cmdline-tools/latest"
|
||||||
echo "24333f8a63b6825ea9c5514f83c2829b004d1fee" > "$ANDROID_SDK_ROOT/licenses/android-sdk-license"
|
# Accept licences by writing the hashes: piping "yes" into
|
||||||
echo "84831b9409646a918e30573bab4c9c91346d8abd" > "$ANDROID_SDK_ROOT/licenses/android-sdk-preview-license"
|
# sdkmanager dies of SIGPIPE (exit 141) under pipefail.
|
||||||
"$ANDROID_SDK_ROOT/cmdline-tools/latest/bin/sdkmanager" \
|
mkdir -p "$ANDROID_SDK_ROOT/licenses"
|
||||||
"platforms;android-35" "build-tools;35.0.0" "platform-tools" > /dev/null
|
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
|
echo "sdk.dir=$ANDROID_SDK_ROOT" > local.properties
|
||||||
|
|
||||||
- name: Unit tests
|
# One invocation, not two: a second --no-daemon run pays for another JVM
|
||||||
run: ./gradlew testDebugUnitTest --no-daemon --stacktrace
|
# start and another configuration phase to redo work it just did.
|
||||||
|
- name: Tests und Debug-APK
|
||||||
- name: Build debug APK
|
run: ./gradlew testDebugUnitTest assembleDebug --no-daemon --stacktrace
|
||||||
run: ./gradlew assembleDebug --no-daemon --stacktrace
|
|
||||||
|
|
||||||
- name: Summary
|
- name: Summary
|
||||||
if: always()
|
if: always()
|
||||||
run: |
|
run: |
|
||||||
echo "APK:"; ls -la app/build/outputs/apk/debug/ 2>/dev/null || echo " (kein Build)"
|
echo "APK:"; ls -la app/build/outputs/apk/debug/ 2>/dev/null || echo " (kein Build)"
|
||||||
echo "Tests:"; ls tests/runs/junit/ 2>/dev/null || echo " (keine Reports)"
|
echo "Tests:"; ls tests/runs/junit/ 2>/dev/null || echo " (keine Reports)"
|
||||||
|
echo "Cache:"; du -sh "$CACHE"/* 2>/dev/null || echo " (leer)"
|
||||||
|
|||||||
@@ -73,9 +73,10 @@ Channel, no full guide timeline.
|
|||||||
## Example dialogue
|
## Example dialogue
|
||||||
|
|
||||||
> **Dev:** "Does the **Remote** need Backend credentials?"
|
> **Dev:** "Does the **Remote** need Backend credentials?"
|
||||||
> **Domain expert:** "No — the user enters the Xtream credentials of a
|
> **Domain expert:** "No — the Remote is where **Onboarding** happens, not
|
||||||
> **Quelle** once via the Remote, the **TV-App** stores them and is the only
|
> where credentials live: the user types the server URL there and finishes the
|
||||||
> one talking to the **Backend**."
|
> IdP login on the phone. The **TV-App** holds the tokens and is the only one
|
||||||
|
> talking to the **Backend**. A generic M3U **Quelle** needs no login at all."
|
||||||
|
|
||||||
## Flagged ambiguities
|
## Flagged ambiguities
|
||||||
|
|
||||||
|
|||||||
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
|
(Signaturwechsel). Updates bezieht die App über die Release-API dieses
|
||||||
Repos, die APK selbst liegt auf dem Branch `apk`.
|
Repos, die APK selbst liegt auf dem Branch `apk`.
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
```
|
||||||
|
./gradlew test # Unit-Tests, Reports unter tests/runs/
|
||||||
|
tests/helpers/emulator.sh start # headloser Google-TV-Emulator
|
||||||
|
tests/helpers/emulator.sh install app/build/outputs/apk/debug/app-debug.apk
|
||||||
|
tests/helpers/emulator.sh shot name # Screenshot nach tests/runs/screenshots/
|
||||||
|
tests/helpers/emulator.sh stop
|
||||||
|
```
|
||||||
|
|
||||||
|
Der Emulator (AVD `castarr-googletv`, API 34) startet aus einem Snapshot und
|
||||||
|
ist in wenigen Sekunden oben; `stop` schreibt den Snapshot vorher neu. Ein
|
||||||
|
Kaltstart entsteht nur, wenn der Snapshot fehlt.
|
||||||
|
|
||||||
|
Demo-Daten für einen Lauf ohne echtes Backend: `tests/demo/make-demo-data.py`
|
||||||
|
erzeugt Playlist und EPG, die ein lokaler HTTP-Server als generische Quelle
|
||||||
|
ausliefert.
|
||||||
|
|
||||||
Architektur-Notizen: [CONTEXT.md](CONTEXT.md) und [docs/adr/](docs/adr/).
|
Architektur-Notizen: [CONTEXT.md](CONTEXT.md) und [docs/adr/](docs/adr/).
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ android {
|
|||||||
applicationId = "dev.castarr.tv"
|
applicationId = "dev.castarr.tv"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = 37
|
versionCode = 38
|
||||||
versionName = "0.11.4"
|
versionName = "0.11.5"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Release signing from environment (see ~/.keys/castarr-release.env on the
|
// Release signing from environment (see ~/.keys/castarr-release.env on the
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ object TeamFilters {
|
|||||||
club("union", "FCU", "1. FC Union Berlin", listOf("union berlin"), 0xFFE1000F, 0xFFFDE100, 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("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("hsv", "HSV", "Hamburger SV", listOf("hamburger sv", "hsv"), 0xFF0E5EA6, BLACK, league = 1),
|
||||||
club("heidenheim", "FCH1", "1. FC Heidenheim", listOf("heidenheim"), 0xFFE1000F, 0xFF1656A4, 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),
|
club("st-pauli", "FCSP", "FC St. Pauli", listOf("st. pauli", "st pauli"), 0xFF6B4423, WHITE, league = 1),
|
||||||
// --- 2. Bundesliga ---
|
// --- 2. Bundesliga ---
|
||||||
club("schalke", "S04", "FC Schalke 04", listOf("schalke"), 0xFF004D9D, WHITE, league = 2),
|
club("schalke", "S04", "FC Schalke 04", listOf("schalke"), 0xFF004D9D, WHITE, league = 2),
|
||||||
|
|||||||
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,40 +59,9 @@ class ControlServer(
|
|||||||
var running = false
|
var running = false
|
||||||
private set
|
private set
|
||||||
|
|
||||||
// Failed authentication attempts per remote address. Counting every
|
// Failed authentication attempts per remote address. The rule itself
|
||||||
// failure (not just the ones carrying a code) keeps a hostile client
|
// lives in AttemptBudget, where it is unit-tested.
|
||||||
// from spending someone else's budget.
|
private val attempts = AttemptBudget()
|
||||||
private val attempts = HashMap<String, ArrayDeque<Long>>()
|
|
||||||
|
|
||||||
/** True while this address may still try; does not consume budget. */
|
|
||||||
@Synchronized
|
|
||||||
private fun attemptAllowed(address: String): Boolean {
|
|
||||||
val now = System.currentTimeMillis()
|
|
||||||
val queue = attempts[address] ?: return true
|
|
||||||
while (queue.isNotEmpty() && now - queue.first() > ATTEMPT_WINDOW_MS) {
|
|
||||||
queue.removeFirst()
|
|
||||||
}
|
|
||||||
return queue.size < ATTEMPT_MAX
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Only *failed* authentication costs budget. Counting successes too
|
|
||||||
* threw out honest clients: the phone remote reconnects on every
|
|
||||||
* network hiccup, and five reconnects a minute are normal.
|
|
||||||
*/
|
|
||||||
@Synchronized
|
|
||||||
private fun recordFailure(address: String) {
|
|
||||||
val queue = attempts.getOrPut(address) { ArrayDeque() }
|
|
||||||
queue.addLast(System.currentTimeMillis())
|
|
||||||
if (attempts.size > MAX_TRACKED_ADDRESSES) {
|
|
||||||
attempts.entries.removeAll { it.value.isEmpty() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Synchronized
|
|
||||||
private fun clearFailures(address: String) {
|
|
||||||
attempts.remove(address)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun startServer() {
|
fun startServer() {
|
||||||
// A busy port must not take the whole app down — the remote is
|
// A busy port must not take the whole app down — the remote is
|
||||||
@@ -312,7 +281,7 @@ class ControlServer(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun handleHello(msg: JSONObject) {
|
private fun handleHello(msg: JSONObject) {
|
||||||
if (!attemptAllowed(remoteAddress)) {
|
if (!attempts.allows(remoteAddress)) {
|
||||||
trySend(JSONObject().put("type", "error").put("error", "rate_limited").toString())
|
trySend(JSONObject().put("type", "error").put("error", "rate_limited").toString())
|
||||||
runCatching { close(WebSocketFrame.CloseCode.PolicyViolation, "rate limited", false) }
|
runCatching { close(WebSocketFrame.CloseCode.PolicyViolation, "rate limited", false) }
|
||||||
return
|
return
|
||||||
@@ -320,12 +289,12 @@ class ControlServer(
|
|||||||
val tokenOk = Pairing.isValidToken(context, msg.optString("token"))
|
val tokenOk = Pairing.isValidToken(context, msg.optString("token"))
|
||||||
val codeOk = !tokenOk && Pairing.isValidCode(context, msg.optString("code"))
|
val codeOk = !tokenOk && Pairing.isValidCode(context, msg.optString("code"))
|
||||||
if (!tokenOk && !codeOk) {
|
if (!tokenOk && !codeOk) {
|
||||||
recordFailure(remoteAddress)
|
attempts.recordFailure(remoteAddress)
|
||||||
trySend(JSONObject().put("type", "error").put("error", "bad_code").toString())
|
trySend(JSONObject().put("type", "error").put("error", "bad_code").toString())
|
||||||
runCatching { close(WebSocketFrame.CloseCode.PolicyViolation, "bad code", false) }
|
runCatching { close(WebSocketFrame.CloseCode.PolicyViolation, "bad code", false) }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
clearFailures(remoteAddress)
|
attempts.clear(remoteAddress)
|
||||||
authorized = true
|
authorized = true
|
||||||
deviceName = msg.optString("name").ifEmpty { "Handy" }
|
deviceName = msg.optString("name").ifEmpty { "Handy" }
|
||||||
// A code-authenticated client gets its own revocable token, never
|
// A code-authenticated client gets its own revocable token, never
|
||||||
@@ -360,9 +329,6 @@ class ControlServer(
|
|||||||
const val TAG = "ControlServer"
|
const val TAG = "ControlServer"
|
||||||
const val PING_INTERVAL_MS = 8_000L
|
const val PING_INTERVAL_MS = 8_000L
|
||||||
const val SOCKET_TIMEOUT_MS = 40_000
|
const val SOCKET_TIMEOUT_MS = 40_000
|
||||||
const val ATTEMPT_WINDOW_MS = 60_000L
|
|
||||||
const val ATTEMPT_MAX = 5
|
|
||||||
const val MAX_TRACKED_ADDRESSES = 64
|
|
||||||
const val MAX_CLIENTS = 8
|
const val MAX_CLIENTS = 8
|
||||||
const val HANDSHAKE_TIMEOUT_MS = 10_000L
|
const val HANDSHAKE_TIMEOUT_MS = 10_000L
|
||||||
val PING_PAYLOAD = byteArrayOf(0x6e, 0x63)
|
val PING_PAYLOAD = byteArrayOf(0x6e, 0x63)
|
||||||
|
|||||||
@@ -357,22 +357,33 @@ private fun GroupItem(
|
|||||||
// The signal lives in the value column instead of adding a
|
// The signal lives in the value column instead of adding a
|
||||||
// badge — the count is worth less than "now" or "16:00" is.
|
// badge — the count is worth less than "now" or "16:00" is.
|
||||||
val urgent = signal?.urgency ?: AppState.TeamUrgency.NONE
|
val urgent = signal?.urgency ?: AppState.TeamUrgency.NONE
|
||||||
Text(
|
if (urgent == AppState.TeamUrgency.NONE) {
|
||||||
when (urgent) {
|
Text(
|
||||||
AppState.TeamUrgency.LIVE -> "läuft"
|
"$count",
|
||||||
AppState.TeamUrgency.SOON -> formatClock(signal!!.kickOff)
|
fontFamily = AppFont,
|
||||||
AppState.TeamUrgency.NONE -> "$count"
|
fontSize = 12.sp,
|
||||||
},
|
)
|
||||||
color = when (urgent) {
|
} else {
|
||||||
AppState.TeamUrgency.LIVE -> CastarrColors.live
|
// A focused row is filled with the accent colour, so signal
|
||||||
AppState.TeamUrgency.SOON -> CastarrColors.accent
|
// colour straight on the row was red on turquoise. The chip
|
||||||
AppState.TeamUrgency.NONE -> Color.Unspecified
|
// gives it a dark ground that holds on every row state.
|
||||||
},
|
Box(
|
||||||
fontFamily = AppFont,
|
Modifier
|
||||||
fontSize = 12.sp,
|
.clip(RoundedCornerShape(999.dp))
|
||||||
fontWeight = if (urgent == AppState.TeamUrgency.NONE) FontWeight.Normal
|
.background(CastarrColors.bgDeep.copy(alpha = 0.92f))
|
||||||
else FontWeight.SemiBold,
|
.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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -475,7 +486,9 @@ private fun Crest(
|
|||||||
state: AppState,
|
state: AppState,
|
||||||
urgency: AppState.TeamUrgency = AppState.TeamUrgency.NONE,
|
urgency: AppState.TeamUrgency = AppState.TeamUrgency.NONE,
|
||||||
) {
|
) {
|
||||||
Box(Modifier.size(20.dp), contentAlignment = Alignment.Center) {
|
// 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) {
|
if (urgency != AppState.TeamUrgency.NONE) {
|
||||||
// A slow pulse is what actually catches the eye from the sofa;
|
// A slow pulse is what actually catches the eye from the sofa;
|
||||||
// colour alone does not at that distance.
|
// colour alone does not at that distance.
|
||||||
@@ -494,11 +507,11 @@ private fun Crest(
|
|||||||
val ringColor =
|
val ringColor =
|
||||||
if (urgency == AppState.TeamUrgency.LIVE) CastarrColors.live
|
if (urgency == AppState.TeamUrgency.LIVE) CastarrColors.live
|
||||||
else CastarrColors.accent
|
else CastarrColors.accent
|
||||||
Canvas(Modifier.size(30.dp)) {
|
Canvas(Modifier.fillMaxSize()) {
|
||||||
val grow = phase.coerceAtMost(0.7f) / 0.7f
|
val grow = phase.coerceAtMost(0.75f) / 0.75f
|
||||||
drawCircle(
|
drawCircle(
|
||||||
color = ringColor.copy(alpha = (1f - grow) * 0.9f),
|
color = ringColor.copy(alpha = (1f - grow) * 0.85f),
|
||||||
radius = size.minDimension * (0.33f + grow * 0.17f),
|
radius = size.minDimension * (0.34f + grow * 0.15f),
|
||||||
style = Stroke(width = 2.dp.toPx()),
|
style = Stroke(width = 2.dp.toPx()),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -507,7 +520,8 @@ private fun Crest(
|
|||||||
model = "file:///android_asset/crests/${team.key}.png",
|
model = "file:///android_asset/crests/${team.key}.png",
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
contentScale = ContentScale.Fit,
|
contentScale = ContentScale.Fit,
|
||||||
modifier = Modifier.fillMaxSize(),
|
filterQuality = androidx.compose.ui.graphics.FilterQuality.High,
|
||||||
|
modifier = Modifier.size(20.dp),
|
||||||
loading = { ShieldFallback(team) },
|
loading = { ShieldFallback(team) },
|
||||||
error = { ShieldFallback(team) },
|
error = { ShieldFallback(team) },
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ object UpdateChecker {
|
|||||||
|
|
||||||
private var apkUrl: String = ""
|
private var apkUrl: String = ""
|
||||||
|
|
||||||
|
/** Guards against a second click landing on the same download. */
|
||||||
|
private val busy = java.util.concurrent.atomic.AtomicBoolean(false)
|
||||||
|
|
||||||
/** Returns the newer version tag, or null when current. Never throws. */
|
/** Returns the newer version tag, or null when current. Never throws. */
|
||||||
suspend fun check(state: AppState): String? = withContext(Dispatchers.IO) {
|
suspend fun check(state: AppState): String? = withContext(Dispatchers.IO) {
|
||||||
runCatching {
|
runCatching {
|
||||||
@@ -42,7 +45,7 @@ object UpdateChecker {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (url.isEmpty() && tag.isNotEmpty()) url = APK_FALLBACK
|
if (url.isEmpty() && tag.isNotEmpty()) url = APK_FALLBACK
|
||||||
if (url.isNotEmpty() && isNewer(tag, BuildConfig.VERSION_NAME)) {
|
if (url.isNotEmpty() && UpdateRules.isNewer(tag, BuildConfig.VERSION_NAME)) {
|
||||||
apkUrl = url
|
apkUrl = url
|
||||||
val version = "v$tag"
|
val version = "v$tag"
|
||||||
withContext(Dispatchers.Main) { state.updateAvailable = version }
|
withContext(Dispatchers.Main) { state.updateAvailable = version }
|
||||||
@@ -54,50 +57,71 @@ object UpdateChecker {
|
|||||||
}.onFailure { Log.w(TAG, "check failed: ${it.javaClass.simpleName}") }.getOrNull()
|
}.onFailure { Log.w(TAG, "check failed: ${it.javaClass.simpleName}") }.getOrNull()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Downloads the APK and hands it to the package installer. */
|
/**
|
||||||
|
* Downloads the APK and hands it to the package installer.
|
||||||
|
*
|
||||||
|
* The download lands in a .part file that is only renamed once the byte
|
||||||
|
* count matches what the server announced. Handing a half-written APK to
|
||||||
|
* the installer leaves it sitting on a spinner with nothing to report,
|
||||||
|
* which is indistinguishable from a hang.
|
||||||
|
*/
|
||||||
suspend fun downloadAndInstall(context: Context, state: AppState): String? =
|
suspend fun downloadAndInstall(context: Context, state: AppState): String? =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
runCatching {
|
// A second click while the first download runs would have two
|
||||||
if (apkUrl.isEmpty()) check(state)
|
// writers on one file, and the installer reads whichever half won.
|
||||||
require(apkUrl.isNotEmpty()) { "no update available" }
|
if (!busy.compareAndSet(false, true)) return@withContext "Update läuft bereits"
|
||||||
val dir = File(context.cacheDir, "updates").apply { mkdirs() }
|
try {
|
||||||
val file = File(dir, "castarr-update.apk")
|
runCatching {
|
||||||
(URL(apkUrl).openConnection() as HttpURLConnection).run {
|
if (apkUrl.isEmpty()) check(state)
|
||||||
connectTimeout = 15_000
|
require(apkUrl.isNotEmpty()) { "no update available" }
|
||||||
readTimeout = 120_000
|
val dir = File(context.cacheDir, "updates").apply { mkdirs() }
|
||||||
instanceFollowRedirects = true
|
val file = File(dir, "castarr-update.apk")
|
||||||
inputStream.use { input -> file.outputStream().use { input.copyTo(it) } }
|
val part = File(dir, "castarr-update.apk.part")
|
||||||
disconnect()
|
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(
|
} finally {
|
||||||
context, "${BuildConfig.APPLICATION_ID}.fileprovider", file,
|
busy.set(false)
|
||||||
)
|
|
||||||
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"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun isNewer(remote: String, local: String): Boolean {
|
|
||||||
fun parts(v: String) = v.split(".").mapNotNull { it.toIntOrNull() }
|
|
||||||
val r = parts(remote)
|
|
||||||
val l = parts(local)
|
|
||||||
for (i in 0 until maxOf(r.size, l.size)) {
|
|
||||||
val a = r.getOrElse(i) { 0 }
|
|
||||||
val b = l.getOrElse(i) { 0 }
|
|
||||||
if (a != b) return a > b
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun get(url: String): String {
|
private fun get(url: String): String {
|
||||||
val connection = URL(url).openConnection() as HttpURLConnection
|
val connection = URL(url).openConnection() as HttpURLConnection
|
||||||
return try {
|
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() }
|
||||||
|
}
|
||||||
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())
|
||||||
|
}
|
||||||
|
}
|
||||||
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,6 +27,30 @@ if os.path.exists(URLS):
|
|||||||
kotlin = open(SRC, encoding="utf-8").read()
|
kotlin = open(SRC, encoding="utf-8").read()
|
||||||
entries = re.findall(r'club\((.*?)\)\s*,\s*(?://.*)?$', kotlin, re.M | re.S)
|
entries = re.findall(r'club\((.*?)\)\s*,\s*(?://.*)?$', kotlin, re.M | re.S)
|
||||||
clubs = []
|
clubs = []
|
||||||
|
# Wikipedia serves ~330px thumbnails. The rail draws them at 20dp, which on a
|
||||||
|
# 1080p TV is 40px -- an 8x downscale that Android does with a cheap filter,
|
||||||
|
# and the edges come out ragged. Resampling once here with a proper filter
|
||||||
|
# fixes that and shrinks the APK.
|
||||||
|
CREST_PX = 128
|
||||||
|
|
||||||
|
|
||||||
|
def downscale(data: bytes) -> bytes:
|
||||||
|
try:
|
||||||
|
from PIL import Image
|
||||||
|
except ImportError:
|
||||||
|
return data
|
||||||
|
import io
|
||||||
|
|
||||||
|
image = Image.open(io.BytesIO(data)).convert("RGBA")
|
||||||
|
if max(image.size) <= CREST_PX:
|
||||||
|
return data
|
||||||
|
scale = CREST_PX / max(image.size)
|
||||||
|
size = (max(1, round(image.width * scale)), max(1, round(image.height * scale)))
|
||||||
|
out = io.BytesIO()
|
||||||
|
image.resize(size, Image.LANCZOS).save(out, format="PNG", optimize=True)
|
||||||
|
return out.getvalue()
|
||||||
|
|
||||||
|
|
||||||
for raw in re.findall(r'club\(\s*"([^"]+)",\s*"[^"]*",\s*"([^"]+)"[^\n]*', kotlin):
|
for raw in re.findall(r'club\(\s*"([^"]+)",\s*"[^"]*",\s*"([^"]+)"[^\n]*', kotlin):
|
||||||
clubs.append(raw)
|
clubs.append(raw)
|
||||||
# an explicit article = "..." wins over the club name
|
# an explicit article = "..." wins over the club name
|
||||||
@@ -52,7 +76,7 @@ for key, full_name in clubs:
|
|||||||
with urllib.request.urlopen(urllib.request.Request(thumb, headers=UA), timeout=20) as r:
|
with urllib.request.urlopen(urllib.request.Request(thumb, headers=UA), timeout=20) as r:
|
||||||
data = r.read()
|
data = r.read()
|
||||||
with open(target, "wb") as f:
|
with open(target, "wb") as f:
|
||||||
f.write(data)
|
f.write(downscale(data))
|
||||||
fetched += 1
|
fetched += 1
|
||||||
except Exception as exc: # noqa: BLE001 - best effort, shield is the fallback
|
except Exception as exc: # noqa: BLE001 - best effort, shield is the fallback
|
||||||
print(f" {key}: {exc}", file=sys.stderr)
|
print(f" {key}: {exc}", file=sys.stderr)
|
||||||
|
|||||||
171
tools/release.sh
Executable file
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