diff --git a/app/src/main/java/dev/castarr/tv/server/AttemptBudget.kt b/app/src/main/java/dev/castarr/tv/server/AttemptBudget.kt new file mode 100644 index 0000000..85431df --- /dev/null +++ b/app/src/main/java/dev/castarr/tv/server/AttemptBudget.kt @@ -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>() + + /** 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, 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 + } +} diff --git a/app/src/main/java/dev/castarr/tv/server/ControlServer.kt b/app/src/main/java/dev/castarr/tv/server/ControlServer.kt index 4c4d99c..3a5d20a 100644 --- a/app/src/main/java/dev/castarr/tv/server/ControlServer.kt +++ b/app/src/main/java/dev/castarr/tv/server/ControlServer.kt @@ -59,40 +59,9 @@ 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>() - - /** 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) - } + // 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 @@ -312,7 +281,7 @@ class ControlServer( } private fun handleHello(msg: JSONObject) { - 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 @@ -320,12 +289,12 @@ class ControlServer( val tokenOk = Pairing.isValidToken(context, msg.optString("token")) val codeOk = !tokenOk && Pairing.isValidCode(context, msg.optString("code")) if (!tokenOk && !codeOk) { - recordFailure(remoteAddress) + attempts.recordFailure(remoteAddress) trySend(JSONObject().put("type", "error").put("error", "bad_code").toString()) runCatching { close(WebSocketFrame.CloseCode.PolicyViolation, "bad code", false) } return } - clearFailures(remoteAddress) + attempts.clear(remoteAddress) authorized = true deviceName = msg.optString("name").ifEmpty { "Handy" } // A code-authenticated client gets its own revocable token, never @@ -360,9 +329,6 @@ class ControlServer( const val TAG = "ControlServer" const val PING_INTERVAL_MS = 8_000L 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 HANDSHAKE_TIMEOUT_MS = 10_000L val PING_PAYLOAD = byteArrayOf(0x6e, 0x63) diff --git a/app/src/main/java/dev/castarr/tv/update/UpdateChecker.kt b/app/src/main/java/dev/castarr/tv/update/UpdateChecker.kt index 4eaab69..5949cc9 100644 --- a/app/src/main/java/dev/castarr/tv/update/UpdateChecker.kt +++ b/app/src/main/java/dev/castarr/tv/update/UpdateChecker.kt @@ -45,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 } @@ -93,8 +93,7 @@ object UpdateChecker { } finally { connection.disconnect() } - require(part.length() > 0) { "empty download" } - require(expected <= 0 || part.length() == expected) { + require(UpdateRules.isComplete(part.length(), expected)) { "truncated: ${part.length()} of $expected" } file.delete() @@ -123,18 +122,6 @@ object UpdateChecker { } } - 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 { diff --git a/app/src/main/java/dev/castarr/tv/update/UpdateRules.kt b/app/src/main/java/dev/castarr/tv/update/UpdateRules.kt new file mode 100644 index 0000000..5d778f8 --- /dev/null +++ b/app/src/main/java/dev/castarr/tv/update/UpdateRules.kt @@ -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() } +} diff --git a/tests/helpers/jdk.sh b/tests/helpers/jdk.sh new file mode 100755 index 0000000..78e9dbd --- /dev/null +++ b/tests/helpers/jdk.sh @@ -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 diff --git a/tests/smoke.sh b/tests/smoke.sh new file mode 100755 index 0000000..318a66f --- /dev/null +++ b/tests/smoke.sh @@ -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 # 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 ] [--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 < + 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()) + } +} diff --git a/tests/unit/UpdateRulesTest.kt b/tests/unit/UpdateRulesTest.kt new file mode 100644 index 0000000..4b01997 --- /dev/null +++ b/tests/unit/UpdateRulesTest.kt @@ -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)) + } +} diff --git a/tools/release.sh b/tools/release.sh new file mode 100755 index 0000000..8a4f9ba --- /dev/null +++ b/tools/release.sh @@ -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 [--notes ] [--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."