Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
647cbf6a9a | ||
|
|
49061f3d32 | ||
|
|
29065f8c29 | ||
|
|
d67c2a54de | ||
|
|
286872c972 | ||
|
|
5cc18d760d | ||
|
|
ef3648ad14 |
@@ -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")
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Before Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 89 KiB |
|
After Width: | Height: | Size: 9.4 KiB |
|
After Width: | Height: | Size: 192 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 284 KiB |
|
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.
|
||||||