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