7 Commits
Author SHA1 Message Date
GGBandClaude Sonnet 5 647cbf6a9a Point WEBSITE_URL back at production
Was pointed at kwaba-dev for testing while chasing the caching/icon
issues earlier this session. Production is confirmed caught up to the
same code already verified against kwaba-dev, and in-app updates now
mean future fixes won't require a manual reinstall anyway.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LHgWjmEJXsJEi1EVAoTp4K
2026-09-27 20:03:18 +02:00
GGBandClaude Sonnet 5 49061f3d32 Add redesigned icon/banner as proper density-bucketed mipmap resources
New icon combines the site's sigil mark (same SVG as common.js's
sigil(), same brand colors) with the "KWABA" wordmark in Cinzel Black,
composed to survive worst-case circular/squircle masking. Banner gets
the same density-bucket treatment.

Moved both from a single flat drawable/ to proper mipmap-mdpi through
mipmap-xxxhdpi buckets - a launcher icon/banner without density
buckets can get upscaled and cropped oddly in some launcher UI
surfaces (confirmed via the standard installed-apps list, which
rendered correctly once bucketed; a scan of every other icon-showing
UI surface on this specific device found no other issues, aside from
one Amazon Appstore-owned "My Apps" screen that renders its own
independently cached thumbnail outside of anything android:icon or
android:banner control).

The AndroidManifest.xml references to @mipmap/ic_launcher_art and
@mipmap/banner_tv_art were already committed in 29065f8 without these
backing files - this commit completes that (the repo was in a state
with a dangling resource reference between those two pushes).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LHgWjmEJXsJEi1EVAoTp4K
2026-09-27 19:55:28 +02:00
GGBandClaude Sonnet 5 29065f8c29 Wire update check into MainActivity: dialog, download, install
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LHgWjmEJXsJEi1EVAoTp4K
2026-09-27 19:22:34 +02:00
GGBandClaude Sonnet 5 d67c2a54de Add real Gitea release fetch; bump version to 1.1.0
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LHgWjmEJXsJEi1EVAoTp4K
2026-09-27 19:21:02 +02:00
GGBandClaude Sonnet 5 286872c972 Add unit-tested update-version comparison logic
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LHgWjmEJXsJEi1EVAoTp4K
2026-09-27 19:17:22 +02:00
GGBandClaude Sonnet 5 5cc18d760d Add in-app updates implementation plan
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LHgWjmEJXsJEi1EVAoTp4K
2026-09-27 19:12:39 +02:00
GGBandClaude Sonnet 5 ef3648ad14 Add in-app updates design spec
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LHgWjmEJXsJEi1EVAoTp4K
2026-09-27 19:02:31 +02:00
19 changed files with 1139 additions and 4 deletions
+10 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId = "de.ggb.kwabatv" applicationId = "de.ggb.kwabatv"
minSdk = 21 minSdk = 21
targetSdk = 34 targetSdk = 34
versionCode = 1 versionCode = 2
versionName = "1.0" versionName = "1.1.0"
buildConfigField("String", "WEBSITE_URL", "\"https://kwaba.whodovoodoo.pasteaf.de/tv\"") buildConfigField("String", "WEBSITE_URL", "\"https://kwaba.whodovoodoo.pasteaf.de/tv\"")
} }
@@ -35,3 +35,11 @@ android {
jvmTarget = "1.8" 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")
}
+3 -2
View File
@@ -2,13 +2,14 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-feature android:name="android.software.leanback" android:required="false" /> <uses-feature android:name="android.software.leanback" android:required="false" />
<uses-feature android:name="android.hardware.touchscreen" android:required="false" /> <uses-feature android:name="android.hardware.touchscreen" android:required="false" />
<application <application
android:allowBackup="true" android:allowBackup="true"
android:icon="@drawable/ic_launcher_art" android:icon="@mipmap/ic_launcher_art"
android:banner="@drawable/banner_tv_art" android:banner="@mipmap/banner_tv_art"
android:label="@string/app_name" android:label="@string/app_name"
android:networkSecurityConfig="@xml/network_security_config" android:networkSecurityConfig="@xml/network_security_config"
android:theme="@android:style/Theme.NoTitleBar.Fullscreen"> android:theme="@android:style/Theme.NoTitleBar.Fullscreen">
@@ -1,9 +1,17 @@
package de.ggb.kwabatv package de.ggb.kwabatv
import android.app.Activity 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.net.Uri
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.os.SystemClock import android.os.SystemClock
import android.provider.Settings
import android.view.KeyEvent import android.view.KeyEvent
import android.view.View import android.view.View
import android.webkit.PermissionRequest import android.webkit.PermissionRequest
@@ -49,6 +57,8 @@ class MainActivity : Activity() {
private var fullscreenView: View? = null private var fullscreenView: View? = null
private var currentPath: String = "/tv" private var currentPath: String = "/tv"
private var lastThrottledSeekAt = 0L private var lastThrottledSeekAt = 0L
private var pendingDownloadId: Long = -1
private var receiverRegistered = false
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@@ -84,13 +94,92 @@ class MainActivity : Activity() {
} }
webView.loadUrl(BuildConfig.WEBSITE_URL) webView.loadUrl(BuildConfig.WEBSITE_URL)
checkForUpdateInBackground()
} }
override fun onDestroy() { override fun onDestroy() {
webView.destroy() webView.destroy()
if (receiverRegistered) { unregisterReceiver(downloadReceiver); receiverRegistered = false }
super.onDestroy() super.onDestroy()
} }
/* ---------------------------------------------------------- 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)
}
private fun isRoomList() = currentPath == "/tv" private fun isRoomList() = currentPath == "/tv"
private fun isInRoom() = currentPath.startsWith("/tv/") private fun isInRoom() = currentPath.startsWith("/tv/")
@@ -0,0 +1,90 @@
package de.ggb.kwabatv
import org.json.JSONArray
import java.net.HttpURLConnection
import java.net.URL
/** 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
}
/**
* 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
}
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 284 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

@@ -0,0 +1,137 @@
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)
}
}
@@ -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>`.
---
### 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:
```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<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`:
```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<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**
```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
<uses-permission android:name="android.permission.INTERNET" />
```
Add directly after it:
```xml
<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:
```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)`).
@@ -0,0 +1,158 @@
# In-App Updates — Design
Date: 2026-09-27
Status: approved, pending implementation plan
## Background
KwabaTV has no update mechanism today — every new build requires a manual
`adb install -r` sideload from a development machine. That's fine for this
session's iteration pace, but not sustainable once the app is "done" and
just sitting on a Fire TV Stick: any future fix requires physically
reconnecting ADB.
Kwaba Sync's own desktop Electron app (`desktop/`, in the `kwaba-sync`
repo) already solved an equivalent problem: the server proxies the latest
GitHub-style release from Gitea (`src/desktop-release.js`), gated behind a
login because that repository is private, and the Electron app polls it,
shows an in-window update banner, and never installs without an explicit
click.
KwabaTV's own Gitea repo (`GGB/KwabaTV`) is public — confirmed via its
Gitea API (`visibility":"public"`, releases endpoint returns 200
unauthenticated). That removes the reason the desktop app's design needed a
server-side proxy at all (private-repo auth). This design is intentionally
simpler than desktop's as a result: no `kwaba-sync` involvement, no
server-side changes, no coordination needed with that repo's owner. It
mirrors desktop's *behavior* (check on launch, never install silently,
graceful failure) without mirroring its *architecture*.
## Goal
The app checks Gitea for a newer release on every launch, and — if one
exists — offers to download and install it, entirely on-device.
## Release convention
- `versionName` in `app/build.gradle.kts` moves from the current `"1.0"`
to a proper 3-part semver (e.g. `"1.1.0"`) as part of this work, since
the comparison logic below assumes major/minor/patch on both sides.
- Tag format: `vX.Y.Z` (e.g. `v1.1.0`), matching `versionName`.
- Exactly one APK asset per release, always named `KwabaTV.apk`, so the
app never has to guess which asset to download.
- Releases are published manually for now (no CI in this repo yet).
Drafts and pre-releases are ignored by the version check.
## Components
All new code lives in kwabatv; nothing else changes outside it.
**`UpdateChecker.kt`** — a small, self-contained class with one entry
point, e.g. `checkForUpdate(context, onAvailable: (UpdateInfo) -> Unit)`.
On a background thread, it does a plain HTTP GET to
`https://git.whodovoodoo.de/api/v1/repos/GGB/KwabaTV/releases`, picks the
highest-versioned non-draft, non-prerelease release whose tag matches
`vX.Y.Z`, and compares it numerically (major/minor/patch) against
`BuildConfig.VERSION_NAME`. If the release is newer, it calls back with
`{ version, apkDownloadUrl }` on the main thread. Any failure along the
way (network unreachable, non-200, malformed JSON, no matching release) is
swallowed silently — no error UI, no retry logic beyond "try again next
launch."
**`MainActivity.onCreate()`** calls `UpdateChecker.checkForUpdate(...)`
right after `webView.loadUrl(BuildConfig.WEBSITE_URL)`, so the check never
delays getting to content. If the callback fires, it shows a native
`AlertDialog`:
> **KwabaTV v1.1.0 ist verfügbar**
> [Aktualisieren] [Später]
Android's default dialog buttons are focusable and DPAD-navigable with no
extra work. "Später" just dismisses; the same check runs again next
launch, with no persisted "skip this version" state.
**Download + install**, triggered by "Aktualisieren":
- Uses `android.app.DownloadManager` (a base-SDK system service — no
AndroidX needed, keeping the app's existing zero-dependency footprint)
to download the APK asset directly from Gitea's public release URL.
- Registers a `BroadcastReceiver` for
`DownloadManager.ACTION_DOWNLOAD_COMPLETE` to know when the download
finishes.
- On completion, calls `DownloadManager.getUriForDownloadedFile(id)` — this
returns a ready-to-use `content://` URI with no custom `FileProvider`
setup required (the earlier draft of this design needed one, which
would have pulled in an AndroidX dependency; `DownloadManager` avoids
that entirely).
- Fires `Intent(ACTION_VIEW)` with that URI, MIME type
`application/vnd.android.package-archive`, and
`FLAG_GRANT_READ_URI_PERMISSION`, which opens Android's own system
package installer. The user taps "Install" themselves inside that
system UI — Android does not allow silent unattended installs, by OS
design, regardless of permissions granted.
**First-run permission gate:** before firing the install intent, check
`packageManager.canRequestPackageInstalls()`. If false (first time ever
installing an update), send the user to
`Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES` for this package instead, so
they can grant "install unknown apps" for KwabaTV specifically. They then
press "Aktualisieren" again to retry.
**New manifest entries:**
- `<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />`
- No new `<provider>` entry (avoided by using `DownloadManager`, see
above).
## Data flow
```
App launch
→ webView.loadUrl(WEBSITE_URL) [not blocked by the check]
→ UpdateChecker (background thread)
GET git.whodovoodoo.de/.../releases
→ newer release found?
no → nothing happens
yes → AlertDialog on main thread
"Später" → nothing further this launch
"Aktualisieren" → canRequestPackageInstalls()?
no → Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES
yes → DownloadManager.enqueue(apk)
→ ACTION_DOWNLOAD_COMPLETE
→ ACTION_VIEW install intent
→ system installer UI (user taps Install)
```
## Error handling
Every failure mode in the check itself (DNS/network failure, non-200,
malformed JSON, no releases, no tag matching `vX.Y.Z`) results in no
dialog and no visible change — identical to a launch where the app is
already current. This matches desktop's graceful-degradation philosophy:
an update check is a nice-to-have, never something that should be able to
block or degrade normal app use.
Download failures (`DownloadManager` reports a failed status) are also
silent for now — the user can just press "Aktualisieren" again next
launch, or manually sideload as a fallback (this doesn't remove that
option). A visible failure toast is a reasonable future improvement, not
required for the first version of this feature.
## Testing
This feature cannot be meaningfully unit-tested in isolation — the
interesting behavior is entirely about real network calls, real Android
permission state, and the real system package installer. It will be
verified end-to-end on the real Fire TV Stick used throughout this
project: publish an actual test release on the `GGB/KwabaTV` Gitea repo,
then run the full check → dialog → download → permission-gate →
install flow on-device, the same hands-on verification approach used for
every other feature this session.
## Out of scope (for this pass)
- Any `kwaba-sync` server changes (Option B from the design discussion —
hosting the APK for discovery from the Kwaba website's lobby — is a
separate, later concern, not required for in-app updates to work).
- CI/automated release publishing.
- Periodic re-checks while the app stays open (check is launch-only).
- "Skip this version" / persisted dismissal state.