From 5cc18d760d77da3c2b3d1ee281588a6133d53f90 Mon Sep 17 00:00:00 2001 From: "ggb.kevin" Date: Sun, 27 Sep 2026 19:12:39 +0200 Subject: [PATCH] Add in-app updates implementation plan Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01LHgWjmEJXsJEi1EVAoTp4K --- .../plans/2026-09-27-in-app-updates.md | 652 ++++++++++++++++++ 1 file changed, 652 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-27-in-app-updates.md diff --git a/docs/superpowers/plans/2026-09-27-in-app-updates.md b/docs/superpowers/plans/2026-09-27-in-app-updates.md new file mode 100644 index 0000000..2561bcc --- /dev/null +++ b/docs/superpowers/plans/2026-09-27-in-app-updates.md @@ -0,0 +1,652 @@ +# In-App Updates Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** KwabaTV checks its public Gitea repo for a newer release on every launch and, with explicit user confirmation, downloads and installs it via Android's own system package installer. + +**Architecture:** Fully self-contained in the kwabatv repo — no `kwaba-sync` server involvement, since `GGB/KwabaTV` is a public Gitea repo and its releases API needs no authentication. A pure, unit-tested version-comparison core (`UpdateChecker`) is wrapped by a real network fetch, then wired into `MainActivity` via a native `AlertDialog` and Android's `DownloadManager` + system package installer. + +**Tech Stack:** Kotlin, Android base SDK only (`org.json`, `java.net.HttpURLConnection`, `android.app.DownloadManager` — no new runtime dependencies), JUnit4 + `org.json:json` as test-only dependencies for local unit tests. + +## Global Constraints + +- No AndroidX dependencies — matches the project's existing zero-dependency, WebView-only design (see `docs/superpowers/specs/2026-09-27-in-app-updates-design.md`). This is why the plan uses `DownloadManager` instead of a custom `FileProvider`. +- Release tag convention: `vX.Y.Z` (e.g. `v1.1.0`). APK asset name is always exactly `KwabaTV.apk`. +- Never install silently — Android's system package installer always requires an explicit user tap, by OS design. Nothing in this plan attempts to bypass that. +- Update-check failures (network unreachable, malformed JSON, no qualifying release) are always silent — no error dialog, no toast. Just try again next launch. +- `JAVA_HOME` for every Gradle command in this plan: `/c/Users/GGB/jdks/jdk-21.0.12.1+1` (Gradle 8.7 does not run on JDK 25, which is what Android Studio's bundled JBR ships). +- Build/install verification throughout uses the existing project pattern: `cd /c/Projekte/kwabatv && JAVA_HOME="/c/Users/GGB/jdks/jdk-21.0.12.1+1" ./gradlew.bat `. + +--- + +### Task 1: Pure version-comparison logic, fully unit-tested + +**Files:** +- Modify: `app/build.gradle.kts` (add a `dependencies` block — none exists yet) +- Create: `app/src/main/java/de/ggb/kwabatv/UpdateChecker.kt` +- Create: `app/src/test/java/de/ggb/kwabatv/UpdateCheckerTest.kt` (new source set — `src/test/java` doesn't exist yet in this project, Gradle picks it up automatically once it exists alongside a test dependency) + +**Interfaces:** +- Produces: `data class UpdateInfo(val version: String, val apkDownloadUrl: String)` — top-level in `UpdateChecker.kt`, package `de.ggb.kwabatv` (no import needed from `MainActivity.kt`, same package). +- Produces: `object UpdateChecker` with `fun parseVersion(version: String): List?`, `fun compareVersions(a: List, b: List): Int`, `fun pickUpdate(releasesJson: String, currentVersion: String): UpdateInfo?`. Task 2 adds a fourth function, `checkForUpdate`, to this same object. + +- [ ] **Step 1: Add test dependencies to `app/build.gradle.kts`** + +Current file ends with: +```kotlin + kotlinOptions { + jvmTarget = "1.8" + } +} +``` + +Add a `dependencies` block directly after the closing `}` of the `android { ... }` block (i.e. at the very end of the file): + +```kotlin + kotlinOptions { + jvmTarget = "1.8" + } +} + +dependencies { + testImplementation("junit:junit:4.13.2") + // Android's org.json classes are stubbed out (throw at runtime) in plain JVM unit + // tests unless a real implementation is on the test classpath - this is that + // implementation, test-only, never shipped in the APK. + testImplementation("org.json:json:20231013") +} +``` + +- [ ] **Step 2: Create `UpdateChecker.kt` with stub implementations (so the test file below compiles, but fails on assertions)** + +Create `app/src/main/java/de/ggb/kwabatv/UpdateChecker.kt`: + +```kotlin +package de.ggb.kwabatv + +/** A qualifying update found on Gitea: display version and the APK's direct download URL. */ +data class UpdateInfo(val version: String, val apkDownloadUrl: String) + +/** + * Checks the public KwabaTV Gitea repo for a newer release. Fully self-contained - no + * kwaba-sync server involvement, since GGB/KwabaTV is a public repo and its releases API + * needs no authentication. See docs/superpowers/specs/2026-09-27-in-app-updates-design.md. + */ +object UpdateChecker { + + fun parseVersion(version: String): List? = null + + fun compareVersions(a: List, b: List): Int = 0 + + fun pickUpdate(releasesJson: String, currentVersion: String): UpdateInfo? = null +} +``` + +- [ ] **Step 3: Write the test file** + +Create `app/src/test/java/de/ggb/kwabatv/UpdateCheckerTest.kt`: + +```kotlin +package de.ggb.kwabatv + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class UpdateCheckerTest { + + @Test + fun `parseVersion parses a valid three-part version`() { + assertEquals(listOf(1, 2, 3), UpdateChecker.parseVersion("1.2.3")) + } + + @Test + fun `parseVersion rejects a two-part version`() { + assertNull(UpdateChecker.parseVersion("1.0")) + } + + @Test + fun `parseVersion rejects a version with a v prefix`() { + assertNull(UpdateChecker.parseVersion("v1.2.3")) + } + + @Test + fun `parseVersion rejects non-numeric input`() { + assertNull(UpdateChecker.parseVersion("a.b.c")) + } + + @Test + fun `compareVersions is positive when a is newer in the minor position`() { + assertTrue(UpdateChecker.compareVersions(listOf(1, 1, 0), listOf(1, 0, 9)) > 0) + } + + @Test + fun `compareVersions is negative when a is older in the patch position`() { + assertTrue(UpdateChecker.compareVersions(listOf(1, 0, 0), listOf(1, 0, 1)) < 0) + } + + @Test + fun `compareVersions is zero for equal versions`() { + assertEquals(0, UpdateChecker.compareVersions(listOf(2, 3, 4), listOf(2, 3, 4))) + } + + @Test + fun `pickUpdate returns the newer release when one qualifies`() { + val json = """ + [ + { + "tag_name": "v1.1.0", + "draft": false, + "prerelease": false, + "assets": [ { "name": "KwabaTV.apk" } ] + } + ] + """.trimIndent() + val result = UpdateChecker.pickUpdate(json, "1.0.0") + assertEquals( + UpdateInfo("1.1.0", "https://git.whodovoodoo.de/GGB/KwabaTV/releases/download/v1.1.0/KwabaTV.apk"), + result + ) + } + + @Test + fun `pickUpdate returns null when the release is not newer`() { + val json = """ + [ + { "tag_name": "v1.0.0", "draft": false, "prerelease": false, + "assets": [ { "name": "KwabaTV.apk" } ] } + ] + """.trimIndent() + assertNull(UpdateChecker.pickUpdate(json, "1.0.0")) + } + + @Test + fun `pickUpdate skips draft releases`() { + val json = """ + [ + { "tag_name": "v9.9.9", "draft": true, "prerelease": false, + "assets": [ { "name": "KwabaTV.apk" } ] } + ] + """.trimIndent() + assertNull(UpdateChecker.pickUpdate(json, "1.0.0")) + } + + @Test + fun `pickUpdate skips prerelease releases`() { + val json = """ + [ + { "tag_name": "v9.9.9", "draft": false, "prerelease": true, + "assets": [ { "name": "KwabaTV.apk" } ] } + ] + """.trimIndent() + assertNull(UpdateChecker.pickUpdate(json, "1.0.0")) + } + + @Test + fun `pickUpdate skips a release with no matching apk asset`() { + val json = """ + [ + { "tag_name": "v1.1.0", "draft": false, "prerelease": false, + "assets": [ { "name": "checksums.txt" } ] } + ] + """.trimIndent() + assertNull(UpdateChecker.pickUpdate(json, "1.0.0")) + } + + @Test + fun `pickUpdate returns null for malformed json`() { + assertNull(UpdateChecker.pickUpdate("not json", "1.0.0")) + } + + @Test + fun `pickUpdate returns null when currentVersion itself is malformed`() { + val json = """ + [ + { "tag_name": "v1.1.0", "draft": false, "prerelease": false, + "assets": [ { "name": "KwabaTV.apk" } ] } + ] + """.trimIndent() + assertNull(UpdateChecker.pickUpdate(json, "not-a-version")) + } + + @Test + fun `pickUpdate picks the highest of multiple qualifying releases`() { + val json = """ + [ + { "tag_name": "v1.1.0", "draft": false, "prerelease": false, + "assets": [ { "name": "KwabaTV.apk" } ] }, + { "tag_name": "v1.2.0", "draft": false, "prerelease": false, + "assets": [ { "name": "KwabaTV.apk" } ] } + ] + """.trimIndent() + val result = UpdateChecker.pickUpdate(json, "1.0.0") + assertEquals("1.2.0", result?.version) + } +} +``` + +- [ ] **Step 4: Run the tests and verify they fail** + +Run: `cd /c/Projekte/kwabatv && JAVA_HOME="/c/Users/GGB/jdks/jdk-21.0.12.1+1" ./gradlew.bat testDebugUnitTest --tests "de.ggb.kwabatv.UpdateCheckerTest"` + +Expected: build reaches the test task and reports FAILED, with most of the 13 tests failing (the stubs return `null`/`0` unconditionally, so any test expecting a real value fails). This confirms the tests actually exercise `UpdateChecker` rather than trivially passing. + +- [ ] **Step 5: Replace the stub with the real implementation** + +Replace the full contents of `app/src/main/java/de/ggb/kwabatv/UpdateChecker.kt` with: + +```kotlin +package de.ggb.kwabatv + +import org.json.JSONArray + +/** A qualifying update found on Gitea: display version and the APK's direct download URL. */ +data class UpdateInfo(val version: String, val apkDownloadUrl: String) + +/** + * Checks the public KwabaTV Gitea repo for a newer release. Fully self-contained - no + * kwaba-sync server involvement, since GGB/KwabaTV is a public repo and its releases API + * needs no authentication. See docs/superpowers/specs/2026-09-27-in-app-updates-design.md. + */ +object UpdateChecker { + private const val RELEASES_API_URL = "https://git.whodovoodoo.de/api/v1/repos/GGB/KwabaTV/releases" + private const val DOWNLOAD_BASE_URL = "https://git.whodovoodoo.de/GGB/KwabaTV/releases/download" + private const val ASSET_NAME = "KwabaTV.apk" + private val VERSION_RE = Regex("""^(\d+)\.(\d+)\.(\d+)$""") + + /** Parses a bare "X.Y.Z" version string (no "v" prefix) into [major, minor, patch], or null if malformed. */ + fun parseVersion(version: String): List? { + val m = VERSION_RE.matchEntire(version) ?: return null + return m.groupValues.drop(1).map { it.toInt() } + } + + /** Positive if [a] is newer than [b], negative if older, 0 if equal. Both must be 3-element lists. */ + fun compareVersions(a: List, b: List): Int { + for (i in 0..2) { + val diff = a[i] - b[i] + if (diff != 0) return diff + } + return 0 + } + + /** + * Picks the newest qualifying release from a Gitea releases API JSON array: not a draft, + * not a prerelease, tag matches "vX.Y.Z", newer than [currentVersion], and has a + * "KwabaTV.apk" asset. Returns null if none qualify or the JSON is malformed. The download + * URL is built manually from tag + asset name (same approach as kwaba-sync's own + * src/desktop-release.js) rather than trusting the API response's browser_download_url. + */ + fun pickUpdate(releasesJson: String, currentVersion: String): UpdateInfo? { + val current = parseVersion(currentVersion) ?: return null + val releases = try { JSONArray(releasesJson) } catch (e: Exception) { return null } + + var best: Pair, UpdateInfo>? = null + for (i in 0 until releases.length()) { + val release = releases.optJSONObject(i) ?: continue + if (release.optBoolean("draft", false) || release.optBoolean("prerelease", false)) continue + + val tag = release.optString("tag_name", "") + if (!tag.startsWith("v")) continue + val version = parseVersion(tag.substring(1)) ?: continue + if (compareVersions(version, current) <= 0) continue + + val assets = release.optJSONArray("assets") ?: continue + var hasApk = false + for (j in 0 until assets.length()) { + if (assets.optJSONObject(j)?.optString("name") == ASSET_NAME) { hasApk = true; break } + } + if (!hasApk) continue + + if (best == null || compareVersions(version, best!!.first) > 0) { + best = version to UpdateInfo(version.joinToString("."), "$DOWNLOAD_BASE_URL/$tag/$ASSET_NAME") + } + } + return best?.second + } +} +``` + +- [ ] **Step 6: Run the tests and verify they pass** + +Run: `cd /c/Projekte/kwabatv && JAVA_HOME="/c/Users/GGB/jdks/jdk-21.0.12.1+1" ./gradlew.bat testDebugUnitTest --tests "de.ggb.kwabatv.UpdateCheckerTest"` + +Expected: BUILD SUCCESSFUL, all 13 tests pass. + +- [ ] **Step 7: Commit** + +```bash +cd /c/Projekte/kwabatv +git add app/build.gradle.kts app/src/main/java/de/ggb/kwabatv/UpdateChecker.kt app/src/test/java/de/ggb/kwabatv/UpdateCheckerTest.kt +git commit -m "Add unit-tested update-version comparison logic" +``` + +--- + +### Task 2: Real network fetch + version bump + +**Files:** +- Modify: `app/src/main/java/de/ggb/kwabatv/UpdateChecker.kt` (append one function to the `object UpdateChecker` from Task 1) +- Modify: `app/build.gradle.kts:14-15` (version bump) + +**Interfaces:** +- Consumes: `UpdateChecker.pickUpdate(releasesJson: String, currentVersion: String): UpdateInfo?` (Task 1). +- Produces: `UpdateChecker.checkForUpdate(currentVersion: String): UpdateInfo?` — does a real network call, returns null on any failure. Task 3's `MainActivity` calls this directly. + +- [ ] **Step 1: Confirm the real Gitea API response shape matches what `pickUpdate` expects** + +Run: `curl -s "https://git.whodovoodoo.de/api/v1/repos/GGB/KwabaTV/releases" | head -c 1000` + +Expected: a JSON array (possibly empty, `[]`, if no releases are published yet — that's fine, it just means `checkForUpdate` will correctly return null against real data). If any releases exist, confirm each object has `tag_name`, `draft`, `prerelease`, and `assets` (an array of objects with `name`) — these are the exact fields `pickUpdate` reads. No code changes in this step, just verification against the live API before wiring up the caller. + +- [ ] **Step 2: Add `checkForUpdate` to `UpdateChecker.kt`** + +Add these imports to the top of `app/src/main/java/de/ggb/kwabatv/UpdateChecker.kt` (after `import org.json.JSONArray`): + +```kotlin +import java.net.HttpURLConnection +import java.net.URL +``` + +Add this function inside `object UpdateChecker { ... }`, after `pickUpdate`: + +```kotlin + /** + * Fetches the releases list and returns the newest qualifying update, or null - including + * on any failure (network unreachable, non-200 response, malformed body). An update check + * is a nice-to-have; it must never be able to crash or visibly disrupt normal app use. + */ + fun checkForUpdate(currentVersion: String): UpdateInfo? { + return try { + val connection = URL(RELEASES_API_URL).openConnection() as HttpURLConnection + connection.connectTimeout = 8000 + connection.readTimeout = 8000 + connection.requestMethod = "GET" + connection.setRequestProperty("Accept", "application/json") + if (connection.responseCode != 200) return null + val body = connection.inputStream.bufferedReader().use { it.readText() } + pickUpdate(body, currentVersion) + } catch (e: Exception) { + null + } + } +``` + +- [ ] **Step 3: Bump the app version** + +In `app/build.gradle.kts`, current lines 14-15: + +```kotlin + versionCode = 1 + versionName = "1.0" +``` + +Replace with: + +```kotlin + versionCode = 2 + versionName = "1.1.0" +``` + +- [ ] **Step 4: Verify the project still builds** + +Run: `cd /c/Projekte/kwabatv && JAVA_HOME="/c/Users/GGB/jdks/jdk-21.0.12.1+1" ./gradlew.bat assembleDebug` + +Expected: BUILD SUCCESSFUL. This confirms `checkForUpdate` compiles correctly against the real `HttpURLConnection`/`URL` APIs — it isn't unit-tested (real network I/O), so this compile check plus Task 3's real-device test are its verification. + +- [ ] **Step 5: Commit** + +```bash +cd /c/Projekte/kwabatv +git add app/src/main/java/de/ggb/kwabatv/UpdateChecker.kt app/build.gradle.kts +git commit -m "Add real Gitea release fetch; bump version to 1.1.0" +``` + +--- + +### Task 3: Wire into MainActivity — dialog, permission gate, download, install + +**Files:** +- Modify: `app/src/main/AndroidManifest.xml:4` (new permission) +- Modify: `app/src/main/java/de/ggb/kwabatv/MainActivity.kt` (imports, new fields, `onCreate`/`onDestroy` changes, new private methods) + +**Interfaces:** +- Consumes: `UpdateChecker.checkForUpdate(currentVersion: String): UpdateInfo?` (Task 2), `UpdateInfo(version: String, apkDownloadUrl: String)` (Task 1). + +- [ ] **Step 1: Add the install-packages permission** + +In `app/src/main/AndroidManifest.xml`, current line 4: + +```xml + +``` + +Add directly after it: + +```xml + + +``` + +- [ ] **Step 2: Add new imports to `MainActivity.kt`** + +Current imports: + +```kotlin +import android.app.Activity +import android.net.Uri +import android.os.Bundle +import android.os.SystemClock +import android.view.KeyEvent +import android.view.View +import android.webkit.PermissionRequest +import android.webkit.WebChromeClient +import android.webkit.WebView +import android.webkit.WebViewClient +``` + +Replace with: + +```kotlin +import android.app.Activity +import android.app.AlertDialog +import android.app.DownloadManager +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.net.Uri +import android.os.Build +import android.os.Bundle +import android.os.SystemClock +import android.provider.Settings +import android.view.KeyEvent +import android.view.View +import android.webkit.PermissionRequest +import android.webkit.WebChromeClient +import android.webkit.WebView +import android.webkit.WebViewClient +``` + +- [ ] **Step 3: Add new fields** + +Current field declarations: + +```kotlin + private lateinit var webView: WebView + private var fullscreenView: View? = null + private var currentPath: String = "/tv" + private var lastThrottledSeekAt = 0L +``` + +Replace with: + +```kotlin + private lateinit var webView: WebView + private var fullscreenView: View? = null + private var currentPath: String = "/tv" + private var lastThrottledSeekAt = 0L + private var pendingDownloadId: Long = -1 + private var receiverRegistered = false +``` + +- [ ] **Step 4: Trigger the update check from `onCreate`** + +Current end of `onCreate`: + +```kotlin + webView.loadUrl(BuildConfig.WEBSITE_URL) + } +``` + +Replace with: + +```kotlin + webView.loadUrl(BuildConfig.WEBSITE_URL) + checkForUpdateInBackground() + } +``` + +- [ ] **Step 5: Clean up the download receiver in `onDestroy`** + +Current: + +```kotlin + override fun onDestroy() { + webView.destroy() + super.onDestroy() + } +``` + +Replace with: + +```kotlin + override fun onDestroy() { + webView.destroy() + if (receiverRegistered) { unregisterReceiver(downloadReceiver); receiverRegistered = false } + super.onDestroy() + } +``` + +- [ ] **Step 6: Add the update flow methods** + +Add this block right after `onDestroy()` (before `private fun isRoomList()...`): + +```kotlin + /* ---------------------------------------------------------- In-App-Update + Prueft bei jedem Start das oeffentliche KwabaTV-Gitea-Repo auf ein neueres + Release (siehe UpdateChecker.kt) und bietet es ueber Androids eigenen + Paket-Installer an - nie ohne Bestaetigung, das Betriebssystem selbst + erlaubt keine stille Installation. Ein fehlgeschlagener Check ist immer + lautlos: kein Fehlerdialog, einfach beim naechsten Start erneut versuchen. */ + + private fun checkForUpdateInBackground() { + Thread { + val update = UpdateChecker.checkForUpdate(BuildConfig.VERSION_NAME) + if (update != null) runOnUiThread { showUpdateDialog(update) } + }.start() + } + + private fun showUpdateDialog(update: UpdateInfo) { + AlertDialog.Builder(this) + .setTitle("Update verfügbar") + .setMessage("KwabaTV v${update.version} ist verfügbar.") + .setPositiveButton("Aktualisieren") { _, _ -> startUpdate(update) } + .setNegativeButton("Später", null) + .show() + } + + /* "Unbekannte Apps installieren" ist fuer neue Apps ab Android 8 (API 26) + pro App einzeln zu erlauben - ohne das wuerde der Installations-Intent + unten stillschweigend nichts tun. Statt automatisch nach der Rueckkehr + aus den Einstellungen weiterzumachen (mehr Zustand, mehr Fehlerquellen), + muss der Nutzer "Aktualisieren" nach dem Erlauben schlicht erneut + druecken - siehe Design-Spec. */ + private fun startUpdate(update: UpdateInfo) { + val canInstall = Build.VERSION.SDK_INT < Build.VERSION_CODES.O || packageManager.canRequestPackageInstalls() + if (!canInstall) { + startActivity(Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:$packageName"))) + return + } + downloadApk(update.apkDownloadUrl) + } + + private fun downloadApk(url: String) { + val request = DownloadManager.Request(Uri.parse(url)) + .setTitle("KwabaTV Update") + .setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED) + .setDestinationInExternalFilesDir(this, null, "KwabaTV-update.apk") + val downloadManager = getSystemService(DOWNLOAD_SERVICE) as DownloadManager + pendingDownloadId = downloadManager.enqueue(request) + // Ab Android 13 (API 33) muss jeder dynamisch registrierte Receiver + // explizit als (nicht-)exportiert markiert sein, sonst wirft + // registerReceiver() eine SecurityException. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + registerReceiver(downloadReceiver, IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE), Context.RECEIVER_NOT_EXPORTED) + } else { + @Suppress("UnspecifiedRegisterReceiverFlag") + registerReceiver(downloadReceiver, IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE)) + } + receiverRegistered = true + } + + private val downloadReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + if (intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -1) != pendingDownloadId) return + if (receiverRegistered) { unregisterReceiver(this); receiverRegistered = false } + val downloadManager = getSystemService(DOWNLOAD_SERVICE) as DownloadManager + // getUriForDownloadedFile() returns null for a failed download - that failure is + // silent for now too, matching the update check's own error handling (see spec). + val uri = downloadManager.getUriForDownloadedFile(pendingDownloadId) ?: return + installApk(uri) + } + } + + private fun installApk(uri: Uri) { + val intent = Intent(Intent.ACTION_VIEW).apply { + setDataAndType(uri, "application/vnd.android.package-archive") + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + startActivity(intent) + } + +``` + +- [ ] **Step 7: Verify the project builds** + +Run: `cd /c/Projekte/kwabatv && JAVA_HOME="/c/Users/GGB/jdks/jdk-21.0.12.1+1" ./gradlew.bat assembleDebug` + +Expected: BUILD SUCCESSFUL. + +- [ ] **Step 8: Commit** + +```bash +cd /c/Projekte/kwabatv +git add app/src/main/AndroidManifest.xml app/src/main/java/de/ggb/kwabatv/MainActivity.kt +git commit -m "Wire update check into MainActivity: dialog, download, install" +``` + +- [ ] **Step 9: Publish a real test release on the KwabaTV Gitea repo** + +This step requires the current `app-debug.apk` (built in Step 7) to be renamed `KwabaTV.apk` and attached to a real Gitea release tagged one version below what's about to be tested, so the *installed* app is older than the *published* release and the update check has something real to find. + +- Install the just-built APK (still `versionName "1.1.0"`) normally first via `adb install -r`, confirm it runs. +- Temporarily edit `app/build.gradle.kts` to `versionName = "1.0.0"` (simulating "an older version is installed"), rebuild (`assembleDebug`), install that older-numbered build via `adb install -r` so the device's installed version is genuinely older than the release you're about to publish. +- Revert `app/build.gradle.kts` back to `versionName = "1.1.0"` (do not commit the temporary `1.0.0` edit). +- Rebuild (`assembleDebug`), rename the resulting APK to `KwabaTV.apk`, and publish it as a Gitea release on `GGB/KwabaTV` tagged `v1.1.0` (via the Gitea web UI: Releases → New Release → tag `v1.1.0`, attach `KwabaTV.apk`, publish — not a draft, not a prerelease). + +- [ ] **Step 10: Verify the full flow on the real Fire TV Stick** + +- Relaunch the app on-device (already showing `1.0.0` per the previous step). Confirm the `AlertDialog` appears: "KwabaTV v1.1.0 ist verfügbar." +- Press "Aktualisieren". First time through, expect Android to redirect to the "install unknown apps" settings screen for KwabaTV (unless already granted from earlier sideloading in this project) — grant it, return to the app, press "Aktualisieren" again. +- Confirm `DownloadManager`'s notification shows the download progressing, then Android's own package installer screen appears automatically once it completes. +- Tap "Install" in the system installer. Confirm the app updates in place and relaunches showing `versionName 1.1.0`. +- Relaunch the app once more with no newer release published: confirm no dialog appears (already current). + +--- + +## Self-Review Notes + +- **Spec coverage:** release convention (Task 1/2), version bump (Task 2), background check on launch (Task 3 Step 4), native `AlertDialog` with DPAD-navigable buttons (Task 3, uses Android's default focusable dialog buttons — no custom styling needed), `DownloadManager`-based download avoiding AndroidX (Task 3), permission gate + `ACTION_MANAGE_UNKNOWN_APP_SOURCES` redirect (Task 3), silent error handling throughout (Task 1-2's `checkForUpdate`/`pickUpdate`, Task 3's receiver), real-device end-to-end verification (Task 3 Steps 9-10). All covered. +- **Placeholder scan:** none found — every step has complete, concrete code or an exact command. +- **Type consistency:** `UpdateInfo(version, apkDownloadUrl)` used identically in Task 1 (definition + tests), Task 2 (`checkForUpdate` return type), Task 3 (`showUpdateDialog`/`startUpdate` parameter). `UpdateChecker.checkForUpdate(currentVersion: String): UpdateInfo?` signature matches between Task 2's definition and Task 3's call site (`UpdateChecker.checkForUpdate(BuildConfig.VERSION_NAME)`).