Test the three bugs that reached the living room, and script the release
The rate limit, the version compare and the download check were all wrapped in Android — a Context, a socket, a file — so none of them had a test, and all three shipped broken: the remote thrown out after five reconnects (0.11.1), a truncated APK handed to the installer (0.11.5), and a version compare that would read 0.11.10 as older than 0.11.9 the moment we get there. They are now plain Kotlin in AttemptBudget and UpdateRules, with the clock injected, and 20 tests covering the failures themselves. AttemptBudget also prunes expired entries before evicting: dropping only empty queues let the map grow without bound while every tracked address held one fresh failure. tests/smoke.sh installs the debug APK on the headless emulator, walks the first screen with the D-pad and fails on a crash or ANR — the class of bug unit tests cannot see, and the reason 0.11.0 crashed on startup. Forty seconds end to end. tools/release.sh does the bump, tests, signed build, tag, apk branch and Gitea release in one command. It refuses an unsigned build and never writes the release notes itself; commit subjects are offered as a draft. tests/helpers/jdk.sh picks a JDK Gradle can run on: Ubuntu moved default-java to 25, which fails the build with a bare "IllegalArgumentException: 25.0.4". Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
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)
|
||||||
|
|||||||
@@ -45,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 }
|
||||||
@@ -93,8 +93,7 @@ object UpdateChecker {
|
|||||||
} finally {
|
} finally {
|
||||||
connection.disconnect()
|
connection.disconnect()
|
||||||
}
|
}
|
||||||
require(part.length() > 0) { "empty download" }
|
require(UpdateRules.isComplete(part.length(), expected)) {
|
||||||
require(expected <= 0 || part.length() == expected) {
|
|
||||||
"truncated: ${part.length()} of $expected"
|
"truncated: ${part.length()} of $expected"
|
||||||
}
|
}
|
||||||
file.delete()
|
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 {
|
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() }
|
||||||
|
}
|
||||||
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
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