The Android app updates itself from our Gitea releases; feed cards fade in instead of bouncing
- API: GET /app/android/latest reads the latest Gitea release (ANDROID_RELEASES_URL, optional token for a private repo) with the APK's SHA-256 from SHA256SUMS; GET /app/android/apk/:name streams that APK. - App (modules/updater): downloads in the background, checks the SHA-256, and installs through PackageInstaller: quietly once when leaving the app (Android 12+), otherwise from the feed banner or Settings > App version. F-Droid builds leave it out and drop the install permissions. - CI: the release notes come from the fastlane changelog; the app shows the part above ---. - Settings: Create-account and Link Spotify buttons for Last.fm and ListenBrainz. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
plugins {
|
||||
id 'com.android.library'
|
||||
id 'expo-module-gradle-plugin'
|
||||
}
|
||||
|
||||
group = 'tech.chrisedwards.musicshare.updater'
|
||||
version = '0.1.0'
|
||||
|
||||
android {
|
||||
namespace "tech.chrisedwards.musicshare.updater"
|
||||
defaultConfig {
|
||||
versionCode 1
|
||||
versionName "0.1.0"
|
||||
}
|
||||
lintOptions {
|
||||
abortOnError false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- Installing our own updates (downloaded from our server, checked against the release's SHA-256).
|
||||
The F-Droid build removes both: F-Droid delivers its updates itself (plugins/withoutSelfUpdate.js). -->
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
<!-- Android 12+: lets updates after the first one install without a confirmation screen. -->
|
||||
<uses-permission android:name="android.permission.UPDATE_PACKAGES_WITHOUT_USER_ACTION" />
|
||||
|
||||
<application>
|
||||
<receiver android:name=".InstallResultReceiver" android:exported="false" />
|
||||
</application>
|
||||
</manifest>
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
package tech.chrisedwards.musicshare.updater
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageInstaller
|
||||
import android.os.Build
|
||||
|
||||
/** Where the system reports how an install session went. */
|
||||
class InstallResultReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
val message = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE)
|
||||
when (intent.getIntExtra(PackageInstaller.EXTRA_STATUS, PackageInstaller.STATUS_FAILURE)) {
|
||||
PackageInstaller.STATUS_PENDING_USER_ACTION -> {
|
||||
if (intent.getBooleanExtra(EXTRA_INTERACTIVE, false)) {
|
||||
val confirm = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
intent.getParcelableExtra(Intent.EXTRA_INTENT, Intent::class.java)
|
||||
} else {
|
||||
@Suppress("DEPRECATION") intent.getParcelableExtra(Intent.EXTRA_INTENT)
|
||||
}
|
||||
if (confirm != null) {
|
||||
context.startActivity(confirm.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
|
||||
UpdaterBus.emit("waiting", null)
|
||||
return
|
||||
}
|
||||
}
|
||||
// A quiet attempt (app in the background): drop it; the app offers the update next time it's open.
|
||||
val sessionId = intent.getIntExtra(PackageInstaller.EXTRA_SESSION_ID, -1)
|
||||
if (sessionId != -1) runCatching { context.packageManager.packageInstaller.abandonSession(sessionId) }
|
||||
UpdaterBus.emit("needs_user", null)
|
||||
}
|
||||
PackageInstaller.STATUS_SUCCESS -> UpdaterBus.emit("installed", null)
|
||||
PackageInstaller.STATUS_FAILURE_ABORTED -> UpdaterBus.emit("cancelled", message)
|
||||
else -> UpdaterBus.emit("failed", message)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val EXTRA_INTERACTIVE = "tech.chrisedwards.musicshare.updater.INTERACTIVE"
|
||||
}
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package tech.chrisedwards.musicshare.updater
|
||||
|
||||
/** Hands install results from the receiver (no JS) to the module while the app is open. */
|
||||
internal object UpdaterBus {
|
||||
interface Listener {
|
||||
fun onInstallStatus(status: String, message: String?)
|
||||
}
|
||||
|
||||
@Volatile var listener: Listener? = null
|
||||
|
||||
fun emit(status: String, message: String?) {
|
||||
listener?.onInstallStatus(status, message)
|
||||
}
|
||||
}
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
package tech.chrisedwards.musicshare.updater
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageInstaller
|
||||
import android.os.Build
|
||||
import expo.modules.kotlin.exception.CodedException
|
||||
import expo.modules.kotlin.exception.Exceptions
|
||||
import expo.modules.kotlin.modules.Module
|
||||
import expo.modules.kotlin.modules.ModuleDefinition
|
||||
import java.io.File
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.security.MessageDigest
|
||||
|
||||
class UpdaterModule : Module() {
|
||||
private val context: Context get() = appContext.reactContext ?: throw Exceptions.ReactContextLost()
|
||||
|
||||
private val busListener = object : UpdaterBus.Listener {
|
||||
override fun onInstallStatus(status: String, message: String?) {
|
||||
sendEvent("onInstallStatus", mapOf("status" to status, "message" to message))
|
||||
}
|
||||
}
|
||||
|
||||
override fun definition() = ModuleDefinition {
|
||||
Name("MusicshareUpdater")
|
||||
|
||||
Events("onProgress", "onInstallStatus")
|
||||
|
||||
OnStartObserving("onInstallStatus") { UpdaterBus.listener = busListener }
|
||||
OnStopObserving("onInstallStatus") { if (UpdaterBus.listener === busListener) UpdaterBus.listener = null }
|
||||
|
||||
/** Delete downloaded updates (once installed, or no longer wanted). */
|
||||
Function("clearDownloads") {
|
||||
updatesDir().deleteRecursively()
|
||||
}
|
||||
|
||||
Function("currentVersion") {
|
||||
context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: "0"
|
||||
}
|
||||
|
||||
// Runs off the main thread (AsyncFunction), so plain blocking I/O is fine.
|
||||
AsyncFunction("download") { url: String, sha256: String, fileName: String ->
|
||||
if (!fileName.matches(Regex("[A-Za-z0-9._-]+\\.apk"))) throw UpdateException("Bad file name")
|
||||
val dir = updatesDir().apply { mkdirs() }
|
||||
val target = File(dir, fileName)
|
||||
// Only the newest download is worth keeping.
|
||||
dir.listFiles()?.filter { it.name != fileName }?.forEach { it.delete() }
|
||||
if (target.exists() && sha256Of(target) == sha256.lowercase()) return@AsyncFunction target.absolutePath
|
||||
|
||||
val partial = File(dir, "$fileName.part")
|
||||
val connection = (URL(url).openConnection() as HttpURLConnection).apply {
|
||||
connectTimeout = 15_000
|
||||
readTimeout = 30_000
|
||||
}
|
||||
try {
|
||||
if (connection.responseCode != 200) throw UpdateException("Download failed (HTTP ${connection.responseCode})")
|
||||
val total = connection.contentLengthLong
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
var received = 0L
|
||||
var lastReported = 0L
|
||||
connection.inputStream.use { input ->
|
||||
partial.outputStream().use { output ->
|
||||
val buffer = ByteArray(64 * 1024)
|
||||
while (true) {
|
||||
val n = input.read(buffer)
|
||||
if (n < 0) break
|
||||
output.write(buffer, 0, n)
|
||||
digest.update(buffer, 0, n)
|
||||
received += n
|
||||
if (received - lastReported > 1_000_000) {
|
||||
lastReported = received
|
||||
sendEvent("onProgress", mapOf("received" to received.toDouble(), "total" to total.toDouble()))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val actual = digest.digest().joinToString("") { "%02x".format(it) }
|
||||
if (actual != sha256.lowercase()) {
|
||||
partial.delete()
|
||||
throw UpdateException("The download didn't match its checksum")
|
||||
}
|
||||
if (!partial.renameTo(target)) throw UpdateException("Couldn't save the download")
|
||||
target.absolutePath
|
||||
} finally {
|
||||
connection.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
AsyncFunction("install") { path: String, interactive: Boolean ->
|
||||
val apk = File(path)
|
||||
if (!apk.exists() || apk.parentFile?.canonicalPath != updatesDir().canonicalPath) throw UpdateException("No downloaded update")
|
||||
val installer = context.packageManager.packageInstaller
|
||||
val params = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL).apply {
|
||||
setAppPackageName(context.packageName)
|
||||
setSize(apk.length())
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
setRequireUserAction(PackageInstaller.SessionParams.USER_ACTION_NOT_REQUIRED)
|
||||
}
|
||||
}
|
||||
val sessionId = installer.createSession(params)
|
||||
installer.openSession(sessionId).use { session ->
|
||||
apk.inputStream().use { input ->
|
||||
session.openWrite("base.apk", 0, apk.length()).use { output ->
|
||||
input.copyTo(output)
|
||||
session.fsync(output)
|
||||
}
|
||||
}
|
||||
val intent = Intent(context, InstallResultReceiver::class.java)
|
||||
.setPackage(context.packageName)
|
||||
.putExtra(InstallResultReceiver.EXTRA_INTERACTIVE, interactive)
|
||||
// Mutable: the system adds the result to it.
|
||||
val flags = PendingIntent.FLAG_UPDATE_CURRENT or
|
||||
(if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0)
|
||||
val pending = PendingIntent.getBroadcast(context, sessionId, intent, flags)
|
||||
session.commit(pending.intentSender)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Not cacheDir: Android empties caches when storage runs low, which could lose a download before it's installed.
|
||||
private fun updatesDir() = File(context.noBackupFilesDir, "updates")
|
||||
|
||||
private fun sha256Of(file: File): String {
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
file.inputStream().use { input ->
|
||||
val buffer = ByteArray(64 * 1024)
|
||||
while (true) {
|
||||
val n = input.read(buffer)
|
||||
if (n < 0) break
|
||||
digest.update(buffer, 0, n)
|
||||
}
|
||||
}
|
||||
return digest.digest().joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
}
|
||||
|
||||
internal class UpdateException(message: String) : CodedException(message)
|
||||
Reference in New Issue
Block a user