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:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user