diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 96d2a59..28ccb3c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -13,7 +13,7 @@ android { targetSdk = 34 versionCode = 1 versionName = "1.0" - buildConfigField("String", "WEBSITE_URL", "\"https://kwaba.whodovoodoo.pasteaf.de/tv\"") + buildConfigField("String", "WEBSITE_URL", "\"https://kwaba-dev.whodovoodoo.pasteaf.de/tv\"") } buildTypes { @@ -35,3 +35,11 @@ android { 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") +} diff --git a/app/src/main/java/de/ggb/kwabatv/UpdateChecker.kt b/app/src/main/java/de/ggb/kwabatv/UpdateChecker.kt new file mode 100644 index 0000000..71bb713 --- /dev/null +++ b/app/src/main/java/de/ggb/kwabatv/UpdateChecker.kt @@ -0,0 +1,68 @@ +package de.ggb.kwabatv + +import org.json.JSONArray + +/** A qualifying update found on Gitea: display version and the APK's direct download URL. */ +data class UpdateInfo(val version: String, val apkDownloadUrl: String) + +/** + * Checks the public KwabaTV Gitea repo for a newer release. Fully self-contained - no + * kwaba-sync server involvement, since GGB/KwabaTV is a public repo and its releases API + * needs no authentication. See docs/superpowers/specs/2026-09-27-in-app-updates-design.md. + */ +object UpdateChecker { + private const val RELEASES_API_URL = "https://git.whodovoodoo.de/api/v1/repos/GGB/KwabaTV/releases" + private const val DOWNLOAD_BASE_URL = "https://git.whodovoodoo.de/GGB/KwabaTV/releases/download" + private const val ASSET_NAME = "KwabaTV.apk" + private val VERSION_RE = Regex("""^(\d+)\.(\d+)\.(\d+)$""") + + /** Parses a bare "X.Y.Z" version string (no "v" prefix) into [major, minor, patch], or null if malformed. */ + fun parseVersion(version: String): List? { + val m = VERSION_RE.matchEntire(version) ?: return null + return m.groupValues.drop(1).map { it.toInt() } + } + + /** Positive if [a] is newer than [b], negative if older, 0 if equal. Both must be 3-element lists. */ + fun compareVersions(a: List, b: List): Int { + for (i in 0..2) { + val diff = a[i] - b[i] + if (diff != 0) return diff + } + return 0 + } + + /** + * Picks the newest qualifying release from a Gitea releases API JSON array: not a draft, + * not a prerelease, tag matches "vX.Y.Z", newer than [currentVersion], and has a + * "KwabaTV.apk" asset. Returns null if none qualify or the JSON is malformed. The download + * URL is built manually from tag + asset name (same approach as kwaba-sync's own + * src/desktop-release.js) rather than trusting the API response's browser_download_url. + */ + fun pickUpdate(releasesJson: String, currentVersion: String): UpdateInfo? { + val current = parseVersion(currentVersion) ?: return null + val releases = try { JSONArray(releasesJson) } catch (e: Exception) { return null } + + var best: Pair, UpdateInfo>? = null + for (i in 0 until releases.length()) { + val release = releases.optJSONObject(i) ?: continue + if (release.optBoolean("draft", false) || release.optBoolean("prerelease", false)) continue + + val tag = release.optString("tag_name", "") + if (!tag.startsWith("v")) continue + val version = parseVersion(tag.substring(1)) ?: continue + if (compareVersions(version, current) <= 0) continue + + val assets = release.optJSONArray("assets") ?: continue + var hasApk = false + for (j in 0 until assets.length()) { + if (assets.optJSONObject(j)?.optString("name") == ASSET_NAME) { hasApk = true; break } + } + if (!hasApk) continue + + if (best == null || compareVersions(version, best!!.first) > 0) { + best = version to UpdateInfo(version.joinToString("."), "$DOWNLOAD_BASE_URL/$tag/$ASSET_NAME") + } + } + return best?.second + } +} diff --git a/app/src/test/java/de/ggb/kwabatv/UpdateCheckerTest.kt b/app/src/test/java/de/ggb/kwabatv/UpdateCheckerTest.kt new file mode 100644 index 0000000..f5b89a6 --- /dev/null +++ b/app/src/test/java/de/ggb/kwabatv/UpdateCheckerTest.kt @@ -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) + } +}