Add unit-tested update-version comparison logic

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LHgWjmEJXsJEi1EVAoTp4K
This commit is contained in:
GGB
2026-09-27 19:17:22 +02:00
co-authored by Claude Sonnet 5
parent 5cc18d760d
commit 286872c972
3 changed files with 214 additions and 1 deletions
+9 -1
View File
@@ -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")
}
@@ -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<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
}
}
@@ -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)
}
}