diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index ab2502d..957223e 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -54,6 +54,7 @@ android {
buildFeatures {
compose = true
+ buildConfig = true
}
}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index b61c366..b5fe611 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -8,6 +8,8 @@
+
+
@@ -48,6 +50,17 @@
+
+
+
+
+
diff --git a/app/src/main/java/com/rada/ui/theme/AppearanceDialog.kt b/app/src/main/java/com/rada/ui/theme/AppearanceDialog.kt
index c987284..7d44d55 100644
--- a/app/src/main/java/com/rada/ui/theme/AppearanceDialog.kt
+++ b/app/src/main/java/com/rada/ui/theme/AppearanceDialog.kt
@@ -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(null) }
+ var updateProgress by remember { mutableFloatStateOf(-1f) }
+ var updateInfo by remember { mutableStateOf(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 = {
diff --git a/app/src/main/java/com/rada/update/UpdateChecker.kt b/app/src/main/java/com/rada/update/UpdateChecker.kt
new file mode 100644
index 0000000..d62094b
--- /dev/null
+++ b/app/src/main/java/com/rada/update/UpdateChecker.kt
@@ -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 = 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
+ }
+}
diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml
new file mode 100644
index 0000000..caa7643
--- /dev/null
+++ b/app/src/main/res/xml/file_paths.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/test/java/com/rada/update/UpdateCheckerTest.kt b/app/src/test/java/com/rada/update/UpdateCheckerTest.kt
new file mode 100644
index 0000000..0295e72
--- /dev/null
+++ b/app/src/test/java/com/rada/update/UpdateCheckerTest.kt
@@ -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"))
+ }
+}