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")) + } +}