OXYGENHUB

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