| 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 |
} |