The app says when a later release is out: Account shows its version and a later release's APK to download (closes #7) e0004d61958c
- Author
- Andy McCall @andymccall <[email protected]>
- via
- agent
agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key cfb5ece97d0d
- When
- · 2026-10-27 18:37 UTC
- Signature
- ◈ sealed by agent:claude@andymccall
- Hash
e0004d61958c245e3d97cd9e4580b3a53621ef38bfb52d5abb80eee6a3559e4e- Depends on
- 144cbf7bf2b5 4aee5293461d 57c0824300db bfa2eff05d35 db00632a61d6
- Depended on by
- 645469f5227b d98e5a147428 840c75813f86 23279b95b5b2 aa21eb5e0e5b
- In bookmarks
- main
- In tags
- v0.1.0 v0.1.1 v0.1.2 v0.1.3 v0.1.4 v0.1.5 v0.1.6 v0.1.7 v0.1.8 v0.1.9
- Edits
- 5 atoms, 4 tree operations, 4 files
Browse the tree at this change
added app/src/main/java/io/oxygenhub/android/update/AppUpdate.kt +81 −0
| 1 | + | package io.oxygenhub.android.update |
|
| 2 | + | ||
| 3 | + | import androidx.compose.foundation.layout.fillMaxWidth |
|
| 4 | + | import androidx.compose.material.icons.Icons |
|
| 5 | + | import androidx.compose.material.icons.outlined.SystemUpdate |
|
| 6 | + | import androidx.compose.material3.Card |
|
| 7 | + | import androidx.compose.material3.Icon |
|
| 8 | + | import androidx.compose.material3.ListItem |
|
| 9 | + | import androidx.compose.material3.Text |
|
| 10 | + | import androidx.compose.material3.TextButton |
|
| 11 | + | import androidx.compose.runtime.Composable |
|
| 12 | + | import androidx.compose.runtime.getValue |
|
| 13 | + | import androidx.compose.runtime.produceState |
|
| 14 | + | import androidx.compose.runtime.remember |
|
| 15 | + | import androidx.compose.ui.Modifier |
|
| 16 | + | import androidx.compose.ui.platform.LocalContext |
|
| 17 | + | import androidx.compose.ui.platform.LocalUriHandler |
|
| 18 | + | import io.oxygenhub.android.api.HubApi |
|
| 19 | + | import io.oxygenhub.android.api.Release |
|
| 20 | + | ||
| 21 | + | /** Where the app's own releases are (#7), built and signed by OxygenFlow. */ |
|
| 22 | + | const val APP_HUB = "https://oxygenhub.io" |
|
| 23 | + | const val APP_OWNER = "andymccall" |
|
| 24 | + | const val APP_REPO = "oxygenhub-android" |
|
| 25 | + | ||
| 26 | + | /** `v1.2.3` or `1.2.3` as numbers; null for anything else (a pre-release such as `1.2.3-rc1`). */ |
|
| 27 | + | fun versionOf(text: String): List<Int>? { |
|
| 28 | + | val parts = text.trim().removePrefix("v").split(".") |
|
| 29 | + | if (parts.size != 3) return null |
|
| 30 | + | return parts.map { p -> p.toIntOrNull()?.takeIf { it >= 0 } ?: return null } |
|
| 31 | + | } |
|
| 32 | + | ||
| 33 | + | /** Whether [candidate] is a later version than [installed]. */ |
|
| 34 | + | fun isNewer(candidate: String, installed: String): Boolean { |
|
| 35 | + | val a = versionOf(candidate) ?: return false |
|
| 36 | + | val b = versionOf(installed) ?: return true |
|
| 37 | + | return a.zip(b).firstOrNull { (x, y) -> x != y }?.let { (x, y) -> x > y } ?: false |
|
| 38 | + | } |
|
| 39 | + | ||
| 40 | + | /** An update: the newest full release later than [installed], with its APK's address. */ |
|
| 41 | + | data class Update(val version: String, val apkUrl: String, val pageUrl: String) |
|
| 42 | + | ||
| 43 | + | /** The newest full release of [releases] later than [installed] that carries an APK, if any. */ |
|
| 44 | + | fun updateFrom(releases: List<Release>, installed: String, hub: String = APP_HUB): Update? { |
|
| 45 | + | val newest = releases |
|
| 46 | + | .filter { !it.prerelease && versionOf(it.tag) != null } |
|
| 47 | + | .maxWithOrNull { a, b -> if (isNewer(a.tag, b.tag)) 1 else if (isNewer(b.tag, a.tag)) -1 else 0 } |
|
| 48 | + | ?: return null |
|
| 49 | + | if (!isNewer(newest.tag, installed)) return null |
|
| 50 | + | val apk = newest.assets.firstOrNull { it.name.endsWith(".apk") } ?: return null |
|
| 51 | + | fun absolute(u: String) = if (u.startsWith("http")) u else hub.trimEnd('/') + "/" + u.trimStart('/') |
|
| 52 | + | val page = newest.url.ifEmpty { "/$APP_OWNER/$APP_REPO/releases" } |
|
| 53 | + | return Update(newest.tag.removePrefix("v"), absolute(apk.url), absolute(page)) |
|
| 54 | + | } |
|
| 55 | + | ||
| 56 | + | /** |
|
| 57 | + | * The app's version and, when the hub signed in to is [APP_HUB] and it has |
|
| 58 | + | * a later release, a link to its APK: Android installs it from the |
|
| 59 | + | * download, as the same key signs every release. |
|
| 60 | + | */ |
|
| 61 | + | @Composable |
|
| 62 | + | fun UpdateCard(api: HubApi, hub: String) { |
|
| 63 | + | val context = LocalContext.current |
|
| 64 | + | val installed = remember(context) { context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: "" } |
|
| 65 | + | val update by produceState<Update?>(null, hub) { |
|
| 66 | + | if (hub.trimEnd('/') == APP_HUB) { |
|
| 67 | + | value = runCatching { updateFrom(api.releases(APP_OWNER, APP_REPO), installed) }.getOrNull() |
|
| 68 | + | } |
|
| 69 | + | } |
|
| 70 | + | val uri = LocalUriHandler.current |
|
| 71 | + | Card(Modifier.fillMaxWidth()) { |
|
| 72 | + | ListItem( |
|
| 73 | + | leadingContent = { Icon(Icons.Outlined.SystemUpdate, contentDescription = null) }, |
|
| 74 | + | headlineContent = { Text(update?.let { "Version ${it.version} is out" } ?: "OxygenHub $installed") }, |
|
| 75 | + | supportingContent = { |
|
| 76 | + | Text(if (update != null) "You have $installed. Download it and open the file to update." else "Releases are at $APP_HUB/$APP_OWNER/$APP_REPO.") |
|
| 77 | + | }, |
|
| 78 | + | trailingContent = update?.let { u -> { TextButton(onClick = { uri.openUri(u.apkUrl) }) { Text("Download") } } }, |
|
| 79 | + | ) |
|
| 80 | + | } |
|
| 81 | + | } |
added app/src/test/java/io/oxygenhub/android/update/AppUpdateTest.kt +41 −0
| 1 | + | package io.oxygenhub.android.update |
|
| 2 | + | ||
| 3 | + | import io.oxygenhub.android.api.Release |
|
| 4 | + | import io.oxygenhub.android.api.ReleaseAsset |
|
| 5 | + | import org.junit.Assert.assertEquals |
|
| 6 | + | import org.junit.Assert.assertFalse |
|
| 7 | + | import org.junit.Assert.assertNull |
|
| 8 | + | import org.junit.Assert.assertTrue |
|
| 9 | + | import org.junit.Test |
|
| 10 | + | ||
| 11 | + | class AppUpdateTest { |
|
| 12 | + | @Test |
|
| 13 | + | fun versionsCompareAsNumbers() { |
|
| 14 | + | assertTrue(isNewer("v0.10.0", "0.9.9")) |
|
| 15 | + | assertTrue(isNewer("1.0.0", "0.99.99")) |
|
| 16 | + | assertFalse(isNewer("v0.1.0", "0.1.0")) |
|
| 17 | + | assertFalse(isNewer("0.1.0", "0.2.0")) |
|
| 18 | + | // A pre-release is never offered; a debug build's odd version is always behind. |
|
| 19 | + | assertFalse(isNewer("0.2.0-rc1", "0.1.0")) |
|
| 20 | + | assertTrue(isNewer("0.1.0", "1.0")) |
|
| 21 | + | assertNull(versionOf("1.2")) |
|
| 22 | + | } |
|
| 23 | + | ||
| 24 | + | private fun release(tag: String, vararg assets: String, pre: Boolean = false) = |
|
| 25 | + | Release(tag = tag, prerelease = pre, url = "/andymccall/oxygenhub-android/release/$tag", assets = assets.map { ReleaseAsset(it, url = "/andymccall/oxygenhub-android/asset/1/$it") }) |
|
| 26 | + | ||
| 27 | + | @Test |
|
| 28 | + | fun theNewestFullReleaseWithAnApkIsOffered() { |
|
| 29 | + | val releases = listOf( |
|
| 30 | + | release("v0.1.0", "oxygenhub-android-0.1.0-universal.apk"), |
|
| 31 | + | release("v0.3.0", "oxygenhub-android-0.3.0-universal.apk", pre = true), |
|
| 32 | + | release("v0.2.0", "oxygenhub-android-0.2.0-universal.apk", "SHA256SUMS"), |
|
| 33 | + | ) |
|
| 34 | + | val u = updateFrom(releases, "0.1.0")!! |
|
| 35 | + | assertEquals("0.2.0", u.version) |
|
| 36 | + | assertEquals("https://oxygenhub.io/andymccall/oxygenhub-android/asset/1/oxygenhub-android-0.2.0-universal.apk", u.apkUrl) |
|
| 37 | + | assertEquals("https://oxygenhub.io/andymccall/oxygenhub-android/release/v0.2.0", u.pageUrl) |
|
| 38 | + | assertNull("up to date", updateFrom(releases, "0.2.0")) |
|
| 39 | + | assertNull("no APK, no offer", updateFrom(listOf(release("v0.4.0", "SHA256SUMS")), "0.1.0")) |
|
| 40 | + | } |
|
| 41 | + | } |
modified CHANGELOG.md +1 −0
| … | 3 unchanged lines |
||
| 4 | 4 | ||
| 5 | 5 | ## 0.1.0 (2026-10-27) |
|
| 6 | 6 | ||
| 7 | + | - **The app says when it is out of date (#7):** Account shows the app's version and, signed in to oxygenhub.io, whether a later full release of andymccall/oxygenhub-android is out, with its APK to download; opening the file updates the app, since every release is signed with the same key. Pre-releases are not offered. |
|
| 7 | 8 | - **Releases built by OxygenFlow (#7):** the app's version lives in `gradle.properties` (`oxygenhub.version`; the version code follows from it), and when it changes the pipeline's `release` job builds a release APK signed with the app's own key (a PKCS12 keystore and its password, the repository's `android-keystore` and `android-keystore-password` secrets, mounted as files), checks it with apksigner and keeps it with its `SHA256SUMS` for a release on oxygenhub.io. `scripts/release.sh` does the same locally with the key's files. The key's SHA-256 certificate fingerprint is `B0:99:57:36:44:42:AF:B2:27:28:B7:EF:93:F8:23:2D:F1:41:17:DA:50:28:6D:45:93:04:0F:9C:9F:A2:2A:48`; a release-signed app replaces a debug build only after uninstalling it. |
|
| 8 | 9 | ||
| 9 | 10 | - **The phone runner on OxygenFlow 0.5.0 (#22):** the app pins OxygenFlow 0.5.0 and oxy 0.8.0, so the phone's runner takes the idle claim that fits its memory (oxygenflow#92), stops a WASM job promptly (#93), offers the images it holds (#94) and reports where a job's time went (#35). Reinstall the app to move the runner from 0.3.1. |
|
| … | 12 unchanged lines |
modified app/src/main/java/io/oxygenhub/android/ui/account/AccountScreen.kt +2 −0
| … | 41 unchanged lines |
||
| 42 | 42 | import io.oxygenhub.android.runner.RunnerCard |
|
| 43 | 43 | import io.oxygenhub.android.ui.common.AppScreen |
|
| 44 | 44 | import io.oxygenhub.android.ui.common.AvatarImage |
|
| 45 | + | import io.oxygenhub.android.update.UpdateCard |
|
| 45 | 46 | import kotlinx.coroutines.launch |
|
| 46 | 47 | ||
| 47 | 48 | /** Who is signed in, to which hub, with what; and signing out. */ |
|
| … | 17 unchanged lines |
||
| 65 | 66 | } |
|
| 66 | 67 | LandAlertsCard() |
|
| 67 | 68 | RunnerCard(api, session.hub, session.canWrite) |
|
| 69 | + | UpdateCard(api, session.hub) |
|
| 68 | 70 | Card(Modifier.fillMaxWidth()) { |
|
| 69 | 71 | ListItem( |
|
| 70 | 72 | leadingContent = { Icon(Icons.Outlined.Dns, contentDescription = null) }, |
|
| … | 49 unchanged lines |