OXYGENHUB
# cccf06468345 oxygenhub-android / app / src / main / java / io / oxygenhub / android / update / AppUpdate.kt
4.3 KiB · last changed The version card asks for releases each time the app comes to the front (closes #34) 2 days ago source blame history raw
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.Icon
7
import androidx.compose.material3.ListItem
8
import androidx.compose.material3.Text
9
import androidx.compose.material3.TextButton
10
import androidx.compose.runtime.Composable
11
import androidx.compose.runtime.getValue
12
import androidx.compose.runtime.produceState
13
import androidx.compose.runtime.remember
14
import androidx.compose.ui.Modifier
15
import androidx.compose.ui.platform.LocalContext
16
import androidx.compose.ui.platform.LocalUriHandler
17
import androidx.lifecycle.Lifecycle
18
import androidx.lifecycle.compose.LocalLifecycleOwner
19
import androidx.lifecycle.repeatOnLifecycle
20
import io.oxygenhub.android.api.HubApi
21
import io.oxygenhub.android.api.Release
22
import io.oxygenhub.android.ui.common.Panel
23
import io.oxygenhub.android.ui.theme.Tones
24
25
/** Where the app's own releases are (#7), built and signed by OxygenFlow. */
26
const val APP_HUB = "https://oxygenhub.io"
27
const val APP_OWNER = "andymccall"
28
const val APP_REPO = "oxygenhub-android"
29
30
/** `v1.2.3` or `1.2.3` as numbers; null for anything else (a pre-release such as `1.2.3-rc1`). */
31
fun versionOf(text: String): List<Int>? {
32
    val parts = text.trim().removePrefix("v").split(".")
33
    if (parts.size != 3) return null
34
    return parts.map { p -> p.toIntOrNull()?.takeIf { it >= 0 } ?: return null }
35
}
36
37
/** Whether [candidate] is a later version than [installed]. */
38
fun isNewer(candidate: String, installed: String): Boolean {
39
    val a = versionOf(candidate) ?: return false
40
    val b = versionOf(installed) ?: return true
41
    return a.zip(b).firstOrNull { (x, y) -> x != y }?.let { (x, y) -> x > y } ?: false
42
}
43
44
/** An update: the newest full release later than [installed], with its APK's address. */
45
data class Update(val version: String, val apkUrl: String, val pageUrl: String)
46
47
/** The newest full release of [releases] later than [installed] that carries an APK, if any. */
48
fun updateFrom(releases: List<Release>, installed: String, hub: String = APP_HUB): Update? {
49
    val newest = releases
50
        .filter { !it.prerelease && versionOf(it.tag) != null }
51
        .maxWithOrNull { a, b -> if (isNewer(a.tag, b.tag)) 1 else if (isNewer(b.tag, a.tag)) -1 else 0 }
52
        ?: return null
53
    if (!isNewer(newest.tag, installed)) return null
54
    val apk = newest.assets.firstOrNull { it.name.endsWith(".apk") } ?: return null
55
    fun absolute(u: String) = if (u.startsWith("http")) u else hub.trimEnd('/') + "/" + u.trimStart('/')
56
    val page = newest.url.ifEmpty { "/$APP_OWNER/$APP_REPO/releases" }
57
    return Update(newest.tag.removePrefix("v"), absolute(apk.url), absolute(page))
58
}
59
60
/**
61
 * The app's version and, when the hub signed in to is [APP_HUB] and it has
62
 * a later release, a link to its APK: Android installs it from the
63
 * download, as the same key signs every release.
64
 */
65
@Composable
66
fun UpdateCard(api: HubApi, hub: String) {
67
    val context = LocalContext.current
68
    val installed = remember(context) { context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: "" }
69
    // Asked again each time the app comes to the front, so an app left
70
    // open sees a release made since without a restart (#34).
71
    val owner = LocalLifecycleOwner.current
72
    val update by produceState<Update?>(null, hub, owner) {
73
        if (hub.trimEnd('/') == APP_HUB) {
74
            owner.repeatOnLifecycle(Lifecycle.State.RESUMED) {
75
                runCatching { updateFrom(api.releases(APP_OWNER, APP_REPO), installed) }.onSuccess { value = it }
76
            }
77
        }
78
    }
79
    val uri = LocalUriHandler.current
80
    Panel(Modifier.fillMaxWidth()) {
81
        ListItem(
82
            leadingContent = { Icon(Icons.Outlined.SystemUpdate, contentDescription = null, tint = Tones.info) },
83
            headlineContent = { Text(update?.let { "Version ${it.version} is out" } ?: "OxygenHub $installed") },
84
            supportingContent = {
85
                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.")
86
            },
87
            trailingContent = update?.let { u -> { TextButton(onClick = { uri.openUri(u.apkUrl) }) { Text("Download") } } },
88
        )
89
    }
90
}