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:
parent
6147e0fde2
commit
b18f2df1f4
6 changed files with 262 additions and 0 deletions
|
|
@ -54,6 +54,7 @@ android {
|
|||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
buildConfig = true
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
121
app/src/main/java/com/rada/update/UpdateChecker.kt
Normal file
121
app/src/main/java/com/rada/update/UpdateChecker.kt
Normal 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
|
||||
}
|
||||
}
|
||||
5
app/src/main/res/xml/file_paths.xml
Normal file
5
app/src/main/res/xml/file_paths.xml
Normal 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>
|
||||
23
app/src/test/java/com/rada/update/UpdateCheckerTest.kt
Normal file
23
app/src/test/java/com/rada/update/UpdateCheckerTest.kt
Normal 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"))
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue