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 #1659 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.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 io.oxygenhub.android.api.HubApi
18
import io.oxygenhub.android.api.Release
19
import io.oxygenhub.android.ui.common.Panel
20
import io.oxygenhub.android.ui.theme.Tones
21
22
/** Where the app's own releases are (#7), built and signed by OxygenFlow. */
23
const val APP_HUB = "https://oxygenhub.io"
24
const val APP_OWNER = "andymccall"
25
const val APP_REPO = "oxygenhub-android"
26
27
/** `v1.2.3` or `1.2.3` as numbers; null for anything else (a pre-release such as `1.2.3-rc1`). */
28
fun versionOf(text: String): List<Int>? {
29
    val parts = text.trim().removePrefix("v").split(".")
30
    if (parts.size != 3) return null
31
    return parts.map { p -> p.toIntOrNull()?.takeIf { it >= 0 } ?: return null }
32
}
33
34
/** Whether [candidate] is a later version than [installed]. */
35
fun isNewer(candidate: String, installed: String): Boolean {
36
    val a = versionOf(candidate) ?: return false
37
    val b = versionOf(installed) ?: return true
38
    return a.zip(b).firstOrNull { (x, y) -> x != y }?.let { (x, y) -> x > y } ?: false
39
}
40
41
/** An update: the newest full release later than [installed], with its APK's address. */
42
data class Update(val version: String, val apkUrl: String, val pageUrl: String)
43
44
/** The newest full release of [releases] later than [installed] that carries an APK, if any. */
45
fun updateFrom(releases: List<Release>, installed: String, hub: String = APP_HUB): Update? {
46
    val newest = releases
47
        .filter { !it.prerelease && versionOf(it.tag) != null }
48
        .maxWithOrNull { a, b -> if (isNewer(a.tag, b.tag)) 1 else if (isNewer(b.tag, a.tag)) -1 else 0 }
49
        ?: return null
50
    if (!isNewer(newest.tag, installed)) return null
51
    val apk = newest.assets.firstOrNull { it.name.endsWith(".apk") } ?: return null
52
    fun absolute(u: String) = if (u.startsWith("http")) u else hub.trimEnd('/') + "/" + u.trimStart('/')
53
    val page = newest.url.ifEmpty { "/$APP_OWNER/$APP_REPO/releases" }
54
    return Update(newest.tag.removePrefix("v"), absolute(apk.url), absolute(page))
55
}
56
57
/**
58
 * The app's version and, when the hub signed in to is [APP_HUB] and it has
59
 * a later release, a link to its APK: Android installs it from the
60
 * download, as the same key signs every release.
61
 */
62
@Composable
63
fun UpdateCard(api: HubApi, hub: String) {
64
    val context = LocalContext.current
65
    val installed = remember(context) { context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: "" }
66
    val update by produceState<Update?>(null, hub) {
67
        if (hub.trimEnd('/') == APP_HUB) {
68
            value = runCatching { updateFrom(api.releases(APP_OWNER, APP_REPO), installed) }.getOrNull()
69
        }
70
    }
71
    val uri = LocalUriHandler.current
72
    Panel(Modifier.fillMaxWidth()) {
73
        ListItem(
74
            leadingContent = { Icon(Icons.Outlined.SystemUpdate, contentDescription = null, tint = Tones.info) },
75
            headlineContent = { Text(update?.let { "Version ${it.version} is out" } ?: "OxygenHub $installed") },
76
            supportingContent = {
77
                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.")
78
            },
79
            trailingContent = update?.let { u -> { TextButton(onClick = { uri.openUri(u.apkUrl) }) { Text("Download") } } },
80
        )
81
    }
82
}