feat(update): in-app self-update — check Forgejo latest release, download APK with progress, launch installer via FileProvider; REQUEST_INSTALL_PACKAGES + unknown-sources redirect; version-compare unit tests

This commit is contained in:
avi 2026-09-30 12:10:22 -05:00
commit b18f2df1f4
6 changed files with 262 additions and 0 deletions

View file

@ -54,6 +54,7 @@ android {
buildFeatures {
compose = true
buildConfig = true
}
}

View file

@ -8,6 +8,8 @@
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.CAMERA" />
<!-- In-app self-update: launching the package installer for a downloaded APK. -->
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-feature android:name="android.hardware.camera" android:required="false" />
<!-- NIP-55 external signer discovery (Amber, etc.) -->
@ -48,6 +50,17 @@
</intent-filter>
</service>
<!-- Shares update APKs from cacheDir/apks to the package installer. -->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>

View file

@ -1,5 +1,8 @@
package com.rada.ui.theme
import android.content.Intent
import android.net.Uri
import android.provider.Settings
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
@ -18,10 +21,17 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@ -30,6 +40,9 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.rada.BuildConfig
import com.rada.update.UpdateChecker
import kotlinx.coroutines.launch
/** Static swatch preview colors for the picker (independent of active theme). */
internal val AccentSwatches = mapOf(
@ -52,6 +65,46 @@ fun AppearanceDialog(
val context = LocalContext.current
val tokens = LocalTokens.current
val lightMode = !MaterialTheme.colorScheme.background.luminanceIsDark()
val scope = rememberCoroutineScope()
// Update flow state: idle | checking | downloading(progress) | ready | error | upToDate
var updateStatus by remember { mutableStateOf<String?>(null) }
var updateProgress by remember { mutableFloatStateOf(-1f) }
var updateInfo by remember { mutableStateOf<UpdateChecker.UpdateInfo?>(null) }
fun startUpdate() {
val pm = context.packageManager
if (!pm.canRequestPackageInstalls()) {
updateStatus = "needs_permission"
val intent = Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES).apply {
data = Uri.parse("package:${context.packageName}")
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
runCatching { context.startActivity(intent) }
.onFailure { updateStatus = "Can't open install settings on this device." }
return
}
updateStatus = "checking"
scope.launch {
try {
val info = UpdateChecker.check(BuildConfig.VERSION_NAME)
if (info == null) {
updateStatus = "up_to_date"
} else {
updateInfo = info
updateStatus = "downloading"
updateProgress = -1f
val apk = UpdateChecker.download(context, info.downloadUrl) { f ->
updateProgress = f
}
updateStatus = "ready"
UpdateChecker.launchInstall(context, apk)
}
} catch (e: Exception) {
updateStatus = "error: ${e.message ?: "download failed"}"
}
}
}
AlertDialog(
onDismissRequest = onDismiss,
@ -141,6 +194,52 @@ fun AppearanceDialog(
fontSize = 12.sp,
color = tokens.textMuted,
)
Spacer(Modifier.height(22.dp))
Text(
"UPDATE",
fontSize = 12.sp,
letterSpacing = 0.4.sp,
fontWeight = FontWeight.SemiBold,
color = tokens.textMuted,
)
Spacer(Modifier.height(10.dp))
val busy = updateStatus == "checking" || updateStatus == "downloading"
Box(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(SatsAmpGlass.rInput))
.background(tokens.accentSoft)
.border(1.dp, tokens.borderInteractive, RoundedCornerShape(SatsAmpGlass.rInput))
.clickable(enabled = !busy) { startUpdate() }
.padding(vertical = 12.dp, horizontal = 14.dp),
) {
Column {
Text(
when (updateStatus) {
"checking" -> "Checking for updates…"
"downloading" -> if (updateProgress >= 0f)
"Downloading v${updateInfo?.version}… ${(updateProgress * 100).toInt()}%"
else
"Downloading v${updateInfo?.version}…"
"ready" -> "Update downloaded — install again if the dialog was dismissed"
"up_to_date" -> "You're up to date (v${BuildConfig.VERSION_NAME})"
"needs_permission" -> "Allow \"Install unknown apps\" for SatsAmp, then tap again"
null -> "Check for updates (current: v${BuildConfig.VERSION_NAME})"
else -> updateStatus ?: ""
},
fontSize = 13.sp,
fontWeight = FontWeight.Medium,
color = tokens.text,
)
if (updateStatus == "downloading" && updateProgress >= 0f) {
Spacer(Modifier.height(8.dp))
LinearProgressIndicator(
progress = { updateProgress },
modifier = Modifier.fillMaxWidth(),
)
}
}
}
}
},
confirmButton = {

View file

@ -0,0 +1,121 @@
package com.rada.update
import android.content.Context
import android.content.Intent
import androidx.core.content.FileProvider
import java.io.File
import java.util.concurrent.TimeUnit
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import okhttp3.OkHttpClient
import okhttp3.Request
/**
* Self-update over the Forgejo release API (git.atitlan.io/avi/SatsAmp).
* Reads the latest public release anonymously, downloads the APK asset into
* the app cache, then hands it to the system installer via FileProvider —
* the user only needs to confirm the install dialog (and allow
* "install unknown apps" once).
*/
object UpdateChecker {
const val RELEASE_PAGE_URL = "https://git.atitlan.io/avi/SatsAmp/releases"
private const val LATEST_API =
"https://git.atitlan.io/api/v1/repos/avi/SatsAmp/releases/latest"
private val json = Json { ignoreUnknownKeys = true }
private val client = OkHttpClient.Builder()
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(120, TimeUnit.SECONDS)
.build()
@Serializable
private data class ReleaseJson(
val tag_name: String = "",
val assets: List<AssetJson> = emptyList(),
)
@Serializable
private data class AssetJson(
val name: String = "",
val size: Long = 0,
val browser_download_url: String = "",
)
data class UpdateInfo(val version: String, val downloadUrl: String, val sizeBytes: Long)
/** Returns UpdateInfo when the latest release is newer than this build, else null. */
suspend fun check(currentVersion: String): UpdateInfo? = withContext(Dispatchers.IO) {
val req = Request.Builder().url(LATEST_API)
.header("Accept", "application/json").build()
client.newCall(req).execute().use { resp ->
if (!resp.isSuccessful) return@withContext null // no releases yet / offline
val body = resp.body?.string() ?: return@withContext null
val rel = json.decodeFromString(ReleaseJson.serializer(), body)
val apk = rel.assets.firstOrNull { it.name.endsWith(".apk", ignoreCase = true) }
?: return@withContext null
val v = normalize(rel.tag_name)
if (v.isNotEmpty() && compareVersions(v, normalize(currentVersion)) > 0)
UpdateInfo(v, apk.browser_download_url, apk.size)
else null
}
}
/** Downloads the APK; [onProgress] gets fraction 0..1 (or -1 if size unknown). */
suspend fun download(
context: Context,
url: String,
onProgress: (Float) -> Unit,
): File = withContext(Dispatchers.IO) {
val dir = File(context.cacheDir, "apks").apply { mkdirs() }
// Clear stale downloads so a partial/old file can't shadow the new one.
dir.listFiles()?.forEach { it.delete() }
val out = File(dir, "update.apk")
val req = Request.Builder().url(url).build()
client.newCall(req).execute().use { resp ->
if (!resp.isSuccessful) throw java.io.IOException("Download failed: HTTP ${resp.code}")
val stream = resp.body?.byteStream() ?: throw java.io.IOException("Empty body")
val total = resp.body?.contentLength() ?: -1L
stream.use { input ->
out.outputStream().use { os ->
val buf = ByteArray(64 * 1024)
var done = 0L
while (true) {
val n = input.read(buf)
if (n < 0) break
os.write(buf, 0, n)
done += n
onProgress(if (total > 0) done.toFloat() / total else -1f)
}
}
}
}
out
}
/** Launches the system package installer for the downloaded APK. */
fun launchInstall(context: Context, apk: File) {
val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", apk)
val intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, "application/vnd.android.package-archive")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intent)
}
/** "v0.1.2" / "0.1.2" -> "0.1.2" */
private fun normalize(v: String): String =
v.trim().removePrefix("v").removePrefix("V").substringBefore('-')
/** Component-wise numeric compare; missing components count as 0. */
internal fun compareVersions(a: String, b: String): Int {
val pa = a.split('.').map { it.toIntOrNull() ?: 0 }
val pb = b.split('.').map { it.toIntOrNull() ?: 0 }
for (i in 0 until maxOf(pa.size, pb.size)) {
val d = (pa.getOrNull(i) ?: 0) - (pb.getOrNull(i) ?: 0)
if (d != 0) return if (d > 0) 1 else -1
}
return 0
}
}

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<!-- Update APKs downloaded into context.cacheDir/apks -->
<cache-path name="apk" path="apks/" />
</paths>

View file

@ -0,0 +1,23 @@
package com.rada.update
import org.junit.Assert.assertEquals
import org.junit.Test
class UpdateCheckerTest {
@Test fun `newer release detected`() {
assertEquals(1, UpdateChecker.compareVersions("0.1.1", "0.1.0"))
assertEquals(1, UpdateChecker.compareVersions("0.2.0", "0.1.9"))
assertEquals(1, UpdateChecker.compareVersions("1.0.0", "0.9.9"))
}
@Test fun `same and older are not updates`() {
assertEquals(0, UpdateChecker.compareVersions("0.1.0", "0.1.0"))
assertEquals(-1, UpdateChecker.compareVersions("0.1.0", "0.1.1"))
assertEquals(-1, UpdateChecker.compareVersions("0.9", "0.10"))
}
@Test fun `missing components count as zero`() {
assertEquals(0, UpdateChecker.compareVersions("0.1", "0.1.0"))
assertEquals(1, UpdateChecker.compareVersions("0.1.1", "0.1"))
}
}