From 40add471c430757c7ce5eb894c63f8a27221cf48 Mon Sep 17 00:00:00 2001 From: BW Date: Tue, 6 Oct 2026 13:36:38 -0600 Subject: [PATCH] Add in-app update checker for the github flavor On launch, query GitHub's latest release; if its tag is newer than the installed version, offer to download the APK and hand it to the system installer. Gated by bool/has_update_checker (true only in the github flavor) and REQUEST_INSTALL_PACKAGES lives in the github manifest, so the F-Droid build is unchanged. Co-Authored-By: Claude Sonnet 5.5 --- android/app/src/github/AndroidManifest.xml | 7 ++ android/app/src/github/res/values/bools.xml | 1 + .../com/pebblerecorder/app/MainActivity.kt | 51 +++++++++++ .../com/pebblerecorder/app/UpdateChecker.kt | 86 +++++++++++++++++++ android/app/src/main/res/values/bools.xml | 2 + android/app/src/main/res/values/strings.xml | 7 ++ 6 files changed, 154 insertions(+) create mode 100644 android/app/src/github/AndroidManifest.xml create mode 100644 android/app/src/main/java/com/pebblerecorder/app/UpdateChecker.kt diff --git a/android/app/src/github/AndroidManifest.xml b/android/app/src/github/AndroidManifest.xml new file mode 100644 index 0000000..227c118 --- /dev/null +++ b/android/app/src/github/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/android/app/src/github/res/values/bools.xml b/android/app/src/github/res/values/bools.xml index 0993779..5329e38 100644 --- a/android/app/src/github/res/values/bools.xml +++ b/android/app/src/github/res/values/bools.xml @@ -1,4 +1,5 @@ true + true diff --git a/android/app/src/main/java/com/pebblerecorder/app/MainActivity.kt b/android/app/src/main/java/com/pebblerecorder/app/MainActivity.kt index 5ef4b3b..ab9024d 100644 --- a/android/app/src/main/java/com/pebblerecorder/app/MainActivity.kt +++ b/android/app/src/main/java/com/pebblerecorder/app/MainActivity.kt @@ -86,6 +86,11 @@ class MainActivity : AppCompatActivity() { setUpGeminiSettings() + // savedInstanceState == null: only on a fresh launch, so rotating doesn't re-pop the dialog. + if (savedInstanceState == null && resources.getBoolean(R.bool.has_update_checker)) { + checkForUpdate() + } + requestPermissions.launch(requiredPermissions()) lifecycleScope.launch { @@ -195,6 +200,52 @@ class MainActivity : AppCompatActivity() { } } + /** Asks GitHub Releases for a newer version and, if there is one, offers to install it. */ + private fun checkForUpdate() { + lifecycleScope.launch { + val installed = packageManager.getPackageInfo(packageName, 0).versionName.orEmpty() + // A failed check (offline, rate limited) is silent - it'll just run again next launch. + val update = UpdateChecker.findUpdate(installed).getOrNull() ?: return@launch + AlertDialog.Builder(this@MainActivity) + .setTitle(R.string.update_available_title) + .setMessage(getString(R.string.update_available_message, update.version, installed)) + .setPositiveButton(R.string.update_install_button) { _, _ -> downloadAndInstall(update) } + .setNegativeButton(R.string.update_later_button, null) + .show() + } + } + + private fun downloadAndInstall(update: AvailableUpdate) { + // Android gates installs per source app; send the user to grant it before downloading. + if (!packageManager.canRequestPackageInstalls()) { + Toast.makeText(this, R.string.update_allow_installs, Toast.LENGTH_LONG).show() + startActivity( + Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:$packageName")), + ) + return + } + Toast.makeText(this, R.string.update_downloading, Toast.LENGTH_SHORT).show() + lifecycleScope.launch { + UpdateChecker.download(this@MainActivity, update) + .onSuccess { apk -> + val uri = FileProvider.getUriForFile(this@MainActivity, "$packageName.fileprovider", apk) + startActivity( + Intent(Intent.ACTION_VIEW).apply { + setDataAndType(uri, "application/vnd.android.package-archive") + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + }, + ) + } + .onFailure { + Toast.makeText( + this@MainActivity, + getString(R.string.update_download_failed, it.message), + Toast.LENGTH_LONG, + ).show() + } + } + } + private fun requiredPermissions(): Array { val permissions = mutableListOf(Manifest.permission.RECORD_AUDIO) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { diff --git a/android/app/src/main/java/com/pebblerecorder/app/UpdateChecker.kt b/android/app/src/main/java/com/pebblerecorder/app/UpdateChecker.kt new file mode 100644 index 0000000..62ea656 --- /dev/null +++ b/android/app/src/main/java/com/pebblerecorder/app/UpdateChecker.kt @@ -0,0 +1,86 @@ +package com.pebblerecorder.app + +import android.content.Context +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.json.JSONObject +import java.io.File +import java.io.IOException +import java.net.URL +import javax.net.ssl.HttpsURLConnection + +private const val LATEST_RELEASE_URL = "https://api.github.com/repos/BigWebstas/PebbleRecorder/releases/latest" + +/** A published GitHub release that is newer than the running app, with its installable APK. */ +data class AvailableUpdate(val version: String, val apkUrl: String) + +/** + * Checks the GitHub Releases page for a newer version and downloads its APK. Only used by the + * "github" flavor (see bool/has_update_checker) - F-Droid users get updates through F-Droid. + */ +object UpdateChecker { + + /** Returns the latest release if it is newer than [installedVersion], or null if up to date. */ + suspend fun findUpdate(installedVersion: String): Result = + withContext(Dispatchers.IO) { + runCatching { + val release = JSONObject(httpGet(LATEST_RELEASE_URL)) + val version = release.getString("tag_name").removePrefix("v") + if (!isNewer(version, installedVersion)) return@runCatching null + + // Each release carries two APKs: the plain one (github flavor, this app) and a + // "-fdroid" one for the F-Droid reproducible build. Take the former. + val assets = release.getJSONArray("assets") + val apkUrl = (0 until assets.length()) + .map { assets.getJSONObject(it) } + .firstOrNull { it.getString("name").let { n -> n.endsWith(".apk") && "fdroid" !in n } } + ?.getString("browser_download_url") + ?: throw IOException("Release v$version has no APK attached") + AvailableUpdate(version, apkUrl) + } + } + + /** Downloads [update]'s APK into the cache dir (served via FileProvider) and returns it. */ + suspend fun download(context: Context, update: AvailableUpdate): Result = + withContext(Dispatchers.IO) { + runCatching { + val target = File(context.cacheDir, "update.apk") + val connection = open(update.apkUrl) + try { + connection.inputStream.use { input -> + target.outputStream().use { output -> input.copyTo(output) } + } + } finally { + connection.disconnect() + } + target + } + } + + /** True when dotted-number [candidate] (e.g. "0.1.19") is greater than [installed]. */ + internal fun isNewer(candidate: String, installed: String): Boolean { + val a = candidate.split('.').map { it.toIntOrNull() ?: 0 } + val b = installed.split('.').map { it.toIntOrNull() ?: 0 } + for (i in 0 until maxOf(a.size, b.size)) { + val diff = a.getOrElse(i) { 0 } - b.getOrElse(i) { 0 } + if (diff != 0) return diff > 0 + } + return false + } + + private fun httpGet(url: String): String { + val connection = open(url).apply { setRequestProperty("Accept", "application/vnd.github+json") } + try { + return connection.inputStream.bufferedReader().use { it.readText() } + } finally { + connection.disconnect() + } + } + + /** Opens [url] (GitHub's asset URLs redirect to a CDN, which HttpsURLConnection follows). */ + private fun open(url: String): HttpsURLConnection = + (URL(url).openConnection() as HttpsURLConnection).apply { + connectTimeout = 15_000 + readTimeout = 30_000 + } +} diff --git a/android/app/src/main/res/values/bools.xml b/android/app/src/main/res/values/bools.xml index 6510963..10a6a2c 100644 --- a/android/app/src/main/res/values/bools.xml +++ b/android/app/src/main/res/values/bools.xml @@ -2,4 +2,6 @@ false + + false diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 532f1fa..4e5759c 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -28,6 +28,13 @@ Install the app on Pebble No bundled watchapp found - rebuild the Android app after building watch/ No app installed can open a .pbw file - is the Pebble/Core companion app installed? + Update available + Version %1$s is out (you have %2$s). Download and install it now? + Update + Later + Downloading update… + Update download failed: %1$s + Allow PebbleRecorder to install apps, then reopen the app to update Tap to re-enable the watch trigger Your phone restarted - open PebbleRecorder once to fully re-arm it for the watch button. Idle