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
|
targetSdk = 34
|
||||||
versionCode = 1
|
versionCode = 1
|
||||||
versionName = "1.0"
|
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 {
|
buildTypes {
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
|||||||
@@ -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