Use Forgejo releases for Wolfskin updates and match installed versions consistently #20

Merged
justinzeus merged 2 commits from fm/wolfskin-5-updater-gpt into main 2026-10-22 16:13:20 +02:00
7 changed files with 314 additions and 70 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,53 @@ 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 ->
val candidate = Version.tryFromString(release.jsonObject["name"]?.jsonPrimitive?.contentOrNull)
candidate != null && !candidate.isGreaterThan(version) && !version.isGreaterThan(candidate)
}?.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 +174,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 +217,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,16 @@
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.JsonPrimitive
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 +24,151 @@ 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 val suffixedAssets: JsonArray by lazy { read("forgejo_release_suffixed.json")["assets"]!!.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")))
) }
@Test
fun `Parse Forgejo latest release`() {
val release = UpdateChecker.parseRelease(forgejoLatest, debug = false, supportedABIs = listOf("arm64-v8a"))
checkNotNull(release)
Assert.assertEquals(Version.fromString("v1.0.9-2-g3094fc8c"), release.version)
Assert.assertEquals(forgejoDownload("Wolfskin-arm64-v8a.apk"), release.downloadUrl)
Assert.assertEquals("2026-10-20T09:05:00Z", release.publishedAt)
Assert.assertEquals(listOf("Subtitle preference fix"), release.notes)
}
@Test
fun `Choose Wolfskin asset for abi`() {
Assert.assertEquals(forgejoDownload("Wolfskin-arm64-v8a.apk"), getDownloadUrl(forgejoAssets, false, listOf("arm64-v8a")))
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 `Choose debug ABI before universal and fall back for missing ABI`() {
for ((abis, name) in listOf(
listOf("arm64-v8a") to "Wolfskin-debug-arm64-v8a.apk",
listOf("unknown") to "Wolfskin-debug.apk",
emptyList<String>() to "Wolfskin-debug.apk",
)) {
Assert.assertEquals(forgejoDownload(name), getDownloadUrl(suffixedAssets, true, abis))
} }
getDownloadUrl(assetsV108Json, false, listOf("armeabi-v7a")).let { url -> }
Assert.assertEquals(
"https://github.com/damontecres/Wholphin/releases/download/v1.0.8/Wholphin-armeabi-v7a.apk", @Test
url, fun `Choose release suffixed ABI before universal and unsuffixed assets`() {
) val assets = JsonArray(forgejoAssets + suffixedAssets)
} for ((abis, name) in listOf(
getDownloadUrl(assetsV108Json, false, emptyList()).let { url -> listOf("arm64-v8a") to "Wolfskin-release-arm64-v8a.apk",
Assert.assertEquals( listOf("armeabi-v7a") to "Wolfskin-release.apk",
"https://github.com/damontecres/Wholphin/releases/download/v1.0.8/Wholphin.apk", emptyList<String>() to "Wolfskin-release.apk",
url, )) {
) Assert.assertEquals(forgejoDownload(name), getDownloadUrl(assets, false, abis))
}
getDownloadUrl(assetsV108Json, true, listOf("arm64-v8a")).let { url ->
Assert.assertNull(url)
} }
} }
@Test
fun `Release and debug assets do not cross build types`() {
val debugAssets = JsonArray(suffixedAssets.filter { it.jsonObject["name"]!!.jsonPrimitive.content.contains("-debug") })
val releaseAssets = JsonArray(suffixedAssets.filter { it.jsonObject["name"]!!.jsonPrimitive.content.contains("-release") })
Assert.assertNull(getDownloadUrl(debugAssets, false, listOf("arm64-v8a")))
Assert.assertNull(getDownloadUrl(releaseAssets, 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 release with different hash abbreviation length`() {
val release = UpdateChecker.findReleaseByVersion(forgejoReleases, Version.fromString("1.0.9-2-g3094fc8"))
Assert.assertEquals("v1.0.9-2-g3094fc8c", release?.get("name")?.jsonPrimitive?.content)
}
@Test
fun `Find release with absent or zero commit count`() {
val release = UpdateChecker.findReleaseByVersion(forgejoReleases, Version.fromString("1.0.10"))
Assert.assertEquals("v1.0.10-0-gfeedbee", release?.get("name")?.jsonPrimitive?.content)
val plainRelease = JsonObject(forgejoReleases.first().jsonObject + ("name" to JsonPrimitive("v1.0.10")))
val plainReleases = JsonArray(listOf(plainRelease))
Assert.assertEquals(plainReleases.first(), UpdateChecker.findReleaseByVersion(plainReleases, Version.fromString("1.0.10-0-gfeedbee")))
}
@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,24 @@
{
"name": "v1.0.9-2-g3094fc8c",
"tag_name": "pwolfskin-v1.0.9-2-g3094fc8c",
"published_at": "2026-10-20T09:05:00Z",
"body": "Wolfskin release and debug builds",
"assets": [
{
"name": "Wolfskin-debug.apk",
"browser_download_url": "https://git.justinvisser.org/justinzeus/wolfskin/releases/download/pwolfskin-v1.0.9-2-g3094fc8c/Wolfskin-debug.apk"
},
{
"name": "Wolfskin-release.apk",
"browser_download_url": "https://git.justinvisser.org/justinzeus/wolfskin/releases/download/pwolfskin-v1.0.9-2-g3094fc8c/Wolfskin-release.apk"
},
{
"name": "Wolfskin-debug-arm64-v8a.apk",
"browser_download_url": "https://git.justinvisser.org/justinzeus/wolfskin/releases/download/pwolfskin-v1.0.9-2-g3094fc8c/Wolfskin-debug-arm64-v8a.apk"
},
{
"name": "Wolfskin-release-arm64-v8a.apk",
"browser_download_url": "https://git.justinvisser.org/justinzeus/wolfskin/releases/download/pwolfskin-v1.0.9-2-g3094fc8c/Wolfskin-release-arm64-v8a.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": []
}
]

View file

@ -17,6 +17,11 @@ use the catalog's exact Wholphin extension version (currently `0.2.4`). Do not
place local extension AARs in `app/libs`: upstream prefers them over the pinned place local extension AARs in `app/libs`: upstream prefers them over the pinned
GitHub Packages dependencies. GitHub Packages dependencies.
Upload release assets renamed to `Wolfskin.apk` / `Wolfskin-<abi>.apk` for the
in-app updater. Gradle outputs
`Wholphin-default-release-<versionName>-<versionCode>[-<abi>].apk`; strip the
flavor, version, and code as well as replacing the prefix.
Upstream enables JVM unit tests only for debug variants; there is no Upstream enables JVM unit tests only for debug variants; there is no
`testDefaultReleaseUnitTest` task. `make test` uses the closest default-flavor `testDefaultReleaseUnitTest` task. `make test` uses the closest default-flavor
task without changing upstream's test configuration. task without changing upstream's test configuration.