Files
KwabaTV/docs/superpowers/plans/2026-09-27-in-app-updates.md

27 KiB

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>.

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<Int>?, fun compareVersions(a: List<Int>, b: List<Int>): 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:

    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):

    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:

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<Int>? = null

    fun compareVersions(a: List<Int>, b: List<Int>): 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:

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:

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<Int>? {
        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<Int>, b: List<Int>): 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<List<Int>, 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
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):

import java.net.HttpURLConnection
import java.net.URL

Add this function inside object UpdateChecker { ... }, after pickUpdate:

    /**
     * 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:

        versionCode = 1
        versionName = "1.0"

Replace with:

        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
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:

    <uses-permission android:name="android.permission.INTERNET" />

Add directly after it:

    <uses-permission android:name="android.permission.INTERNET" />
    <uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
  • Step 2: Add new imports to MainActivity.kt

Current imports:

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:

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:

    private lateinit var webView: WebView
    private var fullscreenView: View? = null
    private var currentPath: String = "/tv"
    private var lastThrottledSeekAt = 0L

Replace with:

    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:

        webView.loadUrl(BuildConfig.WEBSITE_URL)
    }

Replace with:

        webView.loadUrl(BuildConfig.WEBSITE_URL)
        checkForUpdateInBackground()
    }
  • Step 5: Clean up the download receiver in onDestroy

Current:

    override fun onDestroy() {
        webView.destroy()
        super.onDestroy()
    }

Replace with:

    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()...):

    /* ---------------------------------------------------------- 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
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)).