Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions android/app/src/github/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

<!-- Lets the in-app update checker hand a downloaded release APK to the system installer. -->
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />

</manifest>
1 change: 1 addition & 0 deletions android/app/src/github/res/values/bools.xml
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<bool name="has_bundled_watchapp">true</bool>
<bool name="has_update_checker">true</bool>
</resources>
51 changes: 51 additions & 0 deletions android/app/src/main/java/com/pebblerecorder/app/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<String> {
val permissions = mutableListOf(Manifest.permission.RECORD_AUDIO)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
Expand Down
86 changes: 86 additions & 0 deletions android/app/src/main/java/com/pebblerecorder/app/UpdateChecker.kt
Original file line number Diff line number Diff line change
@@ -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<AvailableUpdate?> =
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<File> =
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
}
}
2 changes: 2 additions & 0 deletions android/app/src/main/res/values/bools.xml
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,6 @@
<resources>
<!-- Overridden per distribution flavor in src/github and src/fdroid - see app/build.gradle.kts. -->
<bool name="has_bundled_watchapp">false</bool>
<!-- Same: only the github flavor self-updates from GitHub Releases; F-Droid handles its own. -->
<bool name="has_update_checker">false</bool>
</resources>
7 changes: 7 additions & 0 deletions android/app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,13 @@
<string name="install_watchapp_button">Install the app on Pebble</string>
<string name="install_watchapp_missing">No bundled watchapp found - rebuild the Android app after building watch/</string>
<string name="install_watchapp_no_handler">No app installed can open a .pbw file - is the Pebble/Core companion app installed?</string>
<string name="update_available_title">Update available</string>
<string name="update_available_message">Version %1$s is out (you have %2$s). Download and install it now?</string>
<string name="update_install_button">Update</string>
<string name="update_later_button">Later</string>
<string name="update_downloading">Downloading update…</string>
<string name="update_download_failed">Update download failed: %1$s</string>
<string name="update_allow_installs">Allow PebbleRecorder to install apps, then reopen the app to update</string>
<string name="reboot_rearm_notification_title">Tap to re-enable the watch trigger</string>
<string name="reboot_rearm_notification_text">Your phone restarted - open PebbleRecorder once to fully re-arm it for the watch button.</string>
<string name="widget_status_idle">Idle</string>
Expand Down
Loading