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 {
|
buildFeatures {
|
||||||
compose = true
|
compose = true
|
||||||
|
buildConfig = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,8 @@
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||||
<uses-permission android:name="android.permission.CAMERA" />
|
<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" />
|
<uses-feature android:name="android.hardware.camera" android:required="false" />
|
||||||
|
|
||||||
<!-- NIP-55 external signer discovery (Amber, etc.) -->
|
<!-- NIP-55 external signer discovery (Amber, etc.) -->
|
||||||
|
|
@ -48,6 +50,17 @@
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</service>
|
</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>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
package com.rada.ui.theme
|
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.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.clickable
|
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.material.icons.filled.Check
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
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.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
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.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
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). */
|
/** Static swatch preview colors for the picker (independent of active theme). */
|
||||||
internal val AccentSwatches = mapOf(
|
internal val AccentSwatches = mapOf(
|
||||||
|
|
@ -52,6 +65,46 @@ fun AppearanceDialog(
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val tokens = LocalTokens.current
|
val tokens = LocalTokens.current
|
||||||
val lightMode = !MaterialTheme.colorScheme.background.luminanceIsDark()
|
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(
|
AlertDialog(
|
||||||
onDismissRequest = onDismiss,
|
onDismissRequest = onDismiss,
|
||||||
|
|
@ -141,6 +194,52 @@ fun AppearanceDialog(
|
||||||
fontSize = 12.sp,
|
fontSize = 12.sp,
|
||||||
color = tokens.textMuted,
|
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 = {
|
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