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:
2026-10-15 12:34:04 -04:00
co-authored by Claude Opus 5.5
parent 804493aee0
commit 5cbde5e7f9
29 changed files with 901 additions and 11 deletions
@@ -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>
@@ -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"
}
}
@@ -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)
}
}
@@ -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)
@@ -0,0 +1,6 @@
{
"platforms": ["android"],
"android": {
"modules": ["tech.chrisedwards.musicshare.updater.UpdaterModule"]
}
}
+3
View File
@@ -0,0 +1,3 @@
// Android-only native module; the default export is null where it is unavailable.
export { default } from './src/UpdaterModule';
export type { InstallStatus } from './src/UpdaterModule';
@@ -0,0 +1,30 @@
import { NativeModule, requireOptionalNativeModule } from 'expo';
/**
* What the system did with an install: `installed` (rarely seen: updating ourselves ends this process),
* `needs_user` (a quiet install wasn't allowed; it has to be started from the app), `waiting` (the system's
* confirmation screen is showing), `cancelled`, or `failed`.
*/
export type InstallStatus = 'installed' | 'needs_user' | 'waiting' | 'cancelled' | 'failed';
type Events = {
onProgress: (event: { received: number; total: number }) => void;
onInstallStatus: (event: { status: InstallStatus; message: string | null }) => void;
};
declare class MusicshareUpdaterModule extends NativeModule<Events> {
/** Delete downloaded updates (once installed, or no longer wanted). */
clearDownloads(): void;
/** This build's versionName, e.g. "1.1.0". */
currentVersion(): string;
/** Download to the app's cache and check the SHA-256; resolves with the file's path. Reuses an earlier complete download. */
download(url: string, sha256: string, fileName: string): Promise<string>;
/**
* Hand the APK to the system installer. `interactive` shows the system's confirmation when it's needed;
* otherwise only a quiet install is tried. The outcome arrives as onInstallStatus.
*/
install(path: string, interactive: boolean): Promise<void>;
}
/** Android only; null elsewhere and in Expo Go. */
export default requireOptionalNativeModule<MusicshareUpdaterModule>('MusicshareUpdater');