OXYGENHUB

Coverage / app/src/main/java/io/oxygenhub/android/update/AppUpdate.kt 45.7%

16 of 35 lines run, 11 of 22 functions, 31 of 62 branches · run #1640 on main · run missed

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
}