Point updater at Forgejo releases and Wolfskin assets
Some checks failed
review/code-gpt Request changes: version equivalence and lost positive asset-selection coverage

This commit is contained in:
justinzeus 2026-10-22 14:47:50 +02:00
parent 7d6f041b55
commit 0f7a23faa8
5 changed files with 238 additions and 72 deletions

View file

@ -576,7 +576,7 @@ sealed interface AppPreference<Pref, T> {
val UpdateUrl = val UpdateUrl =
AppStringPreference<AppPreferences>( AppStringPreference<AppPreferences>(
title = R.string.update_url, title = R.string.update_url,
defaultValue = "https://api.github.com/repos/damontecres/Wholphin/releases/latest", defaultValue = "https://git.justinvisser.org/api/v1/repos/justinzeus/wolfskin/releases/latest",
getter = { it.updateUrl }, getter = { it.updateUrl },
setter = { prefs, value -> setter = { prefs, value ->
prefs.update { updateUrl = value } prefs.update { updateUrl = value }

View file

@ -57,7 +57,7 @@ class UpdateChecker
@param:StandardOkHttpClient private val okHttpClient: OkHttpClient, @param:StandardOkHttpClient private val okHttpClient: OkHttpClient,
) { ) {
companion object { companion object {
const val ASSET_NAME = "Wholphin" const val ASSET_NAME = "Wolfskin"
const val APK_NAME = "$ASSET_NAME.apk" const val APK_NAME = "$ASSET_NAME.apk"
private const val APK_MIME_TYPE = "application/vnd.android.package-archive" private const val APK_MIME_TYPE = "application/vnd.android.package-archive"
@ -65,6 +65,52 @@ class UpdateChecker
private val NOTE_REGEX = Regex("<!-- app-note:(.+) -->") private val NOTE_REGEX = Regex("<!-- app-note:(.+) -->")
val ACTIVE = BuildConfig.UPDATING_ENABLED val ACTIVE = BuildConfig.UPDATING_ENABLED
private const val RELEASES_URL = "https://git.justinvisser.org/api/v1/repos/justinzeus/wolfskin/releases?limit=50"
/**
* Find the release in a Forgejo release list whose name parses to the given version
*/
fun findReleaseByVersion(
releases: JsonArray,
version: Version,
): JsonObject? =
releases
.firstOrNull { release ->
Version.tryFromString(release.jsonObject["name"]?.jsonPrimitive?.contentOrNull) == version
}?.jsonObject
/**
* Parse a single release object (Forgejo or GitHub shape) into a [Release], or null if its name is not a version
*/
fun parseRelease(
json: JsonObject,
debug: Boolean,
supportedABIs: List<String> = Build.SUPPORTED_ABIS.toList(),
): Release? {
val name = json["name"]?.jsonPrimitive?.contentOrNull
val version = Version.tryFromString(name)
if (version == null) {
Timber.w("Update version parsing failed. name=$name")
return null
}
val publishedAt = json["published_at"]?.jsonPrimitive?.contentOrNull
val body = json["body"]?.jsonPrimitive?.contentOrNull
val downloadUrl =
json["assets"]?.jsonArray?.let { assets -> getDownloadUrl(assets, debug, supportedABIs) }
Timber.v("version=$version, downloadUrl=$downloadUrl")
val notes =
if (body.isNotNullOrBlank()) {
NOTE_REGEX
.findAll(body)
.map { m ->
m.groupValues[1]
}.toList()
} else {
emptyList()
}
return Release(version, downloadUrl, publishedAt, body, notes)
}
} }
/** /**
@ -127,17 +173,29 @@ class UpdateChecker
return Version.fromString(pkgInfo.versionName!!) return Version.fromString(pkgInfo.versionName!!)
} }
/**
* Get the release whose name parses to the given version.
*
* Wolfskin releases are named by `git describe` (e.g. `v1.0.9-2-g3094fc8c`) rather than tagged `vX.Y.Z`,
* so this searches the release list instead of fetching a tag.
*/
suspend fun getRelease(version: Version): Release? { suspend fun getRelease(version: Version): Release? {
val url = val request =
"https://api.github.com/repos/damontecres/Wholphin/releases/tags/v${version.major}.${version.minor}.${version.patch}" Request
.Builder()
.url(RELEASES_URL)
.get()
.build()
return withContext(WholphinDispatchers.IO) { return withContext(WholphinDispatchers.IO) {
val request = okHttpClient.newCall(request).execute().use {
Request if (it.isSuccessful) {
.Builder() val releases = Json.parseToJsonElement(it.body.string()).jsonArray
.url(url) findReleaseByVersion(releases, version)?.let { json -> parseRelease(json, BuildConfig.DEBUG) }
.get() } else {
.build() Timber.w("Release lookup failed ${it.code}: ${it.message}")
getRelease(request) null
}
}
} }
} }
@ -158,36 +216,11 @@ class UpdateChecker
private fun getRelease(request: Request): Release? { private fun getRelease(request: Request): Release? {
return okHttpClient.newCall(request).execute().use { return okHttpClient.newCall(request).execute().use {
if (it.isSuccessful) { if (it.isSuccessful) {
val result = Json.parseToJsonElement(it.body.string()) parseRelease(Json.parseToJsonElement(it.body.string()).jsonObject, BuildConfig.DEBUG)
val name = result.jsonObject["name"]?.jsonPrimitive?.contentOrNull
val version = Version.tryFromString(name)
val publishedAt =
result.jsonObject["published_at"]?.jsonPrimitive?.contentOrNull
val body = result.jsonObject["body"]?.jsonPrimitive?.contentOrNull
val downloadUrl =
result.jsonObject["assets"]
?.jsonArray
?.let { assets -> getDownloadUrl(assets, BuildConfig.DEBUG) }
Timber.v("version=$version, downloadUrl=$downloadUrl")
if (version != null) {
val notes =
if (body.isNotNullOrBlank()) {
NOTE_REGEX
.findAll(body)
.map { m ->
m.groupValues[1]
}.toList()
} else {
emptyList()
}
return@use Release(version, downloadUrl, publishedAt, body, notes)
} else {
Timber.w("Update version parsing failed. name=$name")
}
} else { } else {
Timber.w("Update check failed ${it.code}: ${it.message}") Timber.w("Update check failed ${it.code}: ${it.message}")
null
} }
return@use null
} }
} }

View file

@ -1,11 +1,15 @@
package com.github.damontecres.wholphin.test package com.github.damontecres.wholphin.test
import com.github.damontecres.wholphin.services.UpdateChecker
import com.github.damontecres.wholphin.services.getDownloadUrl import com.github.damontecres.wholphin.services.getDownloadUrl
import com.github.damontecres.wholphin.util.Version
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import org.junit.Assert import org.junit.Assert
import org.junit.Before import org.junit.Before
import org.junit.Test import org.junit.Test
@ -19,71 +23,104 @@ class TestUpdateChecker {
lateinit var releaseV108Json: JsonObject lateinit var releaseV108Json: JsonObject
val assetsV108Json: JsonArray by lazy { releaseV108Json["assets"]!!.jsonArray } val assetsV108Json: JsonArray by lazy { releaseV108Json["assets"]!!.jsonArray }
private fun read(filename: String): JsonObject { private val forgejoLatest: JsonObject by lazy { read("forgejo_release_latest.json") }
private val forgejoAssets: JsonArray by lazy { forgejoLatest["assets"]!!.jsonArray }
private val forgejoReleases: JsonArray by lazy { readElement("forgejo_releases.json").jsonArray }
private fun readElement(filename: String): JsonElement {
val resource = javaClass.classLoader?.getResource(filename) val resource = javaClass.classLoader?.getResource(filename)
Assert.assertNotNull(resource) Assert.assertNotNull(resource)
val fileContents = Paths.get(resource!!.toURI()).readText() val fileContents = Paths.get(resource!!.toURI()).readText()
return Json.parseToJsonElement(fileContents).jsonObject return Json.parseToJsonElement(fileContents)
} }
private fun read(filename: String): JsonObject = readElement(filename).jsonObject
private fun forgejoDownload(name: String) =
"https://git.justinvisser.org/justinzeus/wolfskin/releases/download/pwolfskin-v1.0.9-2-g3094fc8c/$name"
@Before @Before
fun setup() { fun setup() {
releaseJson = read("release_develop.json") releaseJson = read("release_develop.json")
} }
// The GitHub fixtures are upstream Wholphin releases: their assets are named Wholphin*.apk, which
// the Wolfskin updater must not select.
@Test @Test
fun `Release chooses release`() { fun `Release does not choose upstream Wholphin assets`() {
val url = getDownloadUrl(assetsJson, false, listOf()) val url = getDownloadUrl(assetsJson, false, listOf())
Assert.assertEquals("https://github.com/damontecres/Wholphin/releases/download/develop/Wholphin-release.apk", url) Assert.assertNull(url)
} }
@Test @Test
fun `Choose abi`() { fun `Choose abi does not choose upstream Wholphin assets`() {
val url = getDownloadUrl(assetsJson, false, listOf("arm64-v8a")) val url = getDownloadUrl(assetsJson, false, listOf("arm64-v8a"))
Assert.assertEquals("https://github.com/damontecres/Wholphin/releases/download/develop/Wholphin-release-arm64-v8a.apk", url) Assert.assertNull(url)
} }
@Test @Test
fun `Choose unknown abi`() { fun `Choose unknown abi does not choose upstream Wholphin assets`() {
val url = getDownloadUrl(assetsJson, false, listOf("unknown")) val url = getDownloadUrl(assetsJson, false, listOf("unknown"))
Assert.assertEquals("https://github.com/damontecres/Wholphin/releases/download/develop/Wholphin-release.apk", url) Assert.assertNull(url)
} }
@Test @Test
fun `Debug chooses debug`() { fun `Debug does not choose upstream Wholphin assets`() {
val url = getDownloadUrl(assetsJson, true, listOf()) val url = getDownloadUrl(assetsJson, true, listOf())
Assert.assertEquals("https://github.com/damontecres/Wholphin/releases/download/develop/Wholphin-debug.apk", url) Assert.assertNull(url)
} }
@Test @Test
fun `Choose debug abi`() { fun `Choose debug abi does not choose upstream Wholphin assets`() {
val url = getDownloadUrl(assetsJson, true, listOf("arm64-v8a")) val url = getDownloadUrl(assetsJson, true, listOf("arm64-v8a"))
Assert.assertEquals("https://github.com/damontecres/Wholphin/releases/download/develop/Wholphin-debug-arm64-v8a.apk", url) Assert.assertNull(url)
} }
@Test @Test
fun `Test regular release`() { fun `Test regular release does not choose upstream Wholphin assets`() {
releaseV108Json = read("release_v108.json") releaseV108Json = read("release_v108.json")
getDownloadUrl(assetsV108Json, false, listOf("arm64-v8a")).let { url -> Assert.assertNull(getDownloadUrl(assetsV108Json, false, listOf("arm64-v8a")))
Assert.assertEquals( Assert.assertNull(getDownloadUrl(assetsV108Json, false, listOf("armeabi-v7a")))
"https://github.com/damontecres/Wholphin/releases/download/v1.0.8/Wholphin-arm64-v8a.apk", Assert.assertNull(getDownloadUrl(assetsV108Json, false, emptyList()))
url, Assert.assertNull(getDownloadUrl(assetsV108Json, true, listOf("arm64-v8a")))
) }
}
getDownloadUrl(assetsV108Json, false, listOf("armeabi-v7a")).let { url -> @Test
Assert.assertEquals( fun `Parse Forgejo latest release`() {
"https://github.com/damontecres/Wholphin/releases/download/v1.0.8/Wholphin-armeabi-v7a.apk", val release = UpdateChecker.parseRelease(forgejoLatest, debug = false, supportedABIs = listOf("arm64-v8a"))
url, checkNotNull(release)
) Assert.assertEquals(Version.fromString("v1.0.9-2-g3094fc8c"), release.version)
} Assert.assertEquals(forgejoDownload("Wolfskin-arm64-v8a.apk"), release.downloadUrl)
getDownloadUrl(assetsV108Json, false, emptyList()).let { url -> Assert.assertEquals("2026-10-20T09:05:00Z", release.publishedAt)
Assert.assertEquals( Assert.assertEquals(listOf("Subtitle preference fix"), release.notes)
"https://github.com/damontecres/Wholphin/releases/download/v1.0.8/Wholphin.apk", }
url,
) @Test
} fun `Choose Wolfskin asset for abi`() {
getDownloadUrl(assetsV108Json, true, listOf("arm64-v8a")).let { url -> Assert.assertEquals(forgejoDownload("Wolfskin-arm64-v8a.apk"), getDownloadUrl(forgejoAssets, false, listOf("arm64-v8a")))
Assert.assertNull(url) Assert.assertEquals(forgejoDownload("Wolfskin-armeabi-v7a.apk"), getDownloadUrl(forgejoAssets, false, listOf("armeabi-v7a")))
} }
@Test
fun `Choose universal Wolfskin asset for unknown abi`() {
Assert.assertEquals(forgejoDownload("Wolfskin.apk"), getDownloadUrl(forgejoAssets, false, listOf("unknown")))
Assert.assertEquals(forgejoDownload("Wolfskin.apk"), getDownloadUrl(forgejoAssets, false, emptyList()))
}
@Test
fun `Debug asset is not offered when release has none`() {
Assert.assertNull(getDownloadUrl(forgejoAssets, true, listOf("arm64-v8a")))
}
@Test
fun `Find installed version in release list`() {
val release = UpdateChecker.findReleaseByVersion(forgejoReleases, Version.fromString("1.0.9-2-g3094fc8c"))
Assert.assertEquals("v1.0.9-2-g3094fc8c", release?.get("name")?.jsonPrimitive?.content)
}
@Test
fun `Find no release for version missing from list`() {
Assert.assertNull(UpdateChecker.findReleaseByVersion(forgejoReleases, Version.fromString("1.0.9-3-gffffff")))
} }
} }

View file

@ -0,0 +1,44 @@
{
"id": 42,
"name": "v1.0.9-2-g3094fc8c",
"tag_name": "pwolfskin-v1.0.9-2-g3094fc8c",
"body": "Wolfskin build\n<!-- app-note:Subtitle preference fix -->\n**Full Changelog**: https://git.justinvisser.org/justinzeus/wolfskin/compare/v1.0.8...v1.0.9",
"draft": false,
"prerelease": false,
"created_at": "2026-10-20T09:00:00Z",
"published_at": "2026-10-20T09:05:00Z",
"target_commitish": "3094fc8c",
"html_url": "https://git.justinvisser.org/justinzeus/wolfskin/releases/tag/pwolfskin-v1.0.9-2-g3094fc8c",
"assets": [
{
"id": 101,
"name": "Wolfskin.apk",
"size": 12345678,
"download_count": 3,
"created_at": "2026-10-20T09:05:00Z",
"uuid": "11111111-1111-1111-1111-111111111111",
"type": "attachment",
"browser_download_url": "https://git.justinvisser.org/justinzeus/wolfskin/releases/download/pwolfskin-v1.0.9-2-g3094fc8c/Wolfskin.apk"
},
{
"id": 102,
"name": "Wolfskin-arm64-v8a.apk",
"size": 7654321,
"download_count": 1,
"created_at": "2026-10-20T09:05:00Z",
"uuid": "22222222-2222-2222-2222-222222222222",
"type": "attachment",
"browser_download_url": "https://git.justinvisser.org/justinzeus/wolfskin/releases/download/pwolfskin-v1.0.9-2-g3094fc8c/Wolfskin-arm64-v8a.apk"
},
{
"id": 103,
"name": "Wolfskin-armeabi-v7a.apk",
"size": 6543210,
"download_count": 0,
"created_at": "2026-10-20T09:05:00Z",
"uuid": "33333333-3333-3333-3333-333333333333",
"type": "attachment",
"browser_download_url": "https://git.justinvisser.org/justinzeus/wolfskin/releases/download/pwolfskin-v1.0.9-2-g3094fc8c/Wolfskin-armeabi-v7a.apk"
}
]
}

View file

@ -0,0 +1,52 @@
[
{
"id": 44,
"name": "v1.0.10-0-gfeedbee",
"tag_name": "pwolfskin-v1.0.10-0-gfeedbee",
"body": "",
"draft": false,
"prerelease": false,
"created_at": "2026-10-22T08:00:00Z",
"published_at": "2026-10-22T08:05:00Z",
"target_commitish": "feedbee",
"html_url": "https://git.justinvisser.org/justinzeus/wolfskin/releases/tag/pwolfskin-v1.0.10-0-gfeedbee",
"assets": [
{
"id": 104,
"name": "Wolfskin.apk",
"size": 12345679,
"download_count": 0,
"created_at": "2026-10-22T08:05:00Z",
"uuid": "44444444-4444-4444-4444-444444444444",
"type": "attachment",
"browser_download_url": "https://git.justinvisser.org/justinzeus/wolfskin/releases/download/pwolfskin-v1.0.10-0-gfeedbee/Wolfskin.apk"
}
]
},
{
"id": 42,
"name": "v1.0.9-2-g3094fc8c",
"tag_name": "pwolfskin-v1.0.9-2-g3094fc8c",
"body": "Wolfskin build",
"draft": false,
"prerelease": false,
"created_at": "2026-10-20T09:00:00Z",
"published_at": "2026-10-20T09:05:00Z",
"target_commitish": "3094fc8c",
"html_url": "https://git.justinvisser.org/justinzeus/wolfskin/releases/tag/pwolfskin-v1.0.9-2-g3094fc8c",
"assets": []
},
{
"id": 40,
"name": "nightly",
"tag_name": "nightly",
"body": "",
"draft": false,
"prerelease": true,
"created_at": "2026-10-10T09:00:00Z",
"published_at": "2026-10-10T09:05:00Z",
"target_commitish": "main",
"html_url": "https://git.justinvisser.org/justinzeus/wolfskin/releases/tag/nightly",
"assets": []
}
]