diff --git a/.gitea/workflows/android.yml b/.gitea/workflows/android.yml index c531531..7fbadd2 100644 --- a/.gitea/workflows/android.yml +++ b/.gitea/workflows/android.yml @@ -96,7 +96,16 @@ jobs: # Reuse the release if this tag was built before. id=$(curl -fsS -H "$auth" "$api/releases/tags/$REF_NAME" 2>/dev/null | node -p 'JSON.parse(require("fs").readFileSync(0)).id' || true) if [ -z "$id" ]; then - body=$(node -e 'console.log(JSON.stringify({ tag_name: process.argv[1], name: process.argv[1], body: "Android builds. musicshare-*-foss.apk has no closed-source code (no Google ML Kit)." }))' "$REF_NAME") + # The changelog first (the app shows the text above "---" as what's new), then a note for this page. + body=$(node -e ' + const fs = require("fs"); + const code = require("../apps/mobile/app.json").expo.android.versionCode; + const file = `../fastlane/metadata/android/en-US/changelogs/${code}.txt`; + const notes = fs.existsSync(file) ? fs.readFileSync(file, "utf8").trim() : ""; + const note = "musicshare-*-foss.apk has no closed-source code (no Google ML Kit); it does not update itself."; + const tag = process.argv[1]; + console.log(JSON.stringify({ tag_name: tag, name: tag, body: notes ? `${notes}\n\n---\n${note}` : `---\n${note}` })); + ' "$REF_NAME") id=$(curl -fsS -X POST -H "$auth" -H 'Content-Type: application/json' -d "$body" "$api/releases" | node -p 'JSON.parse(require("fs").readFileSync(0)).id') fi for f in *.apk SHA256SUMS; do diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index a3a58ce..ec1b0cb 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -11,6 +11,7 @@ import { loadUser } from './lib/auth'; import { HttpError } from './lib/http'; import { redis } from './lib/redis'; import { adminRoutes } from './routes/admin'; +import { appUpdateRoutes } from './routes/appUpdates'; import { accountRoutes } from './routes/account'; import { authRoutes } from './routes/auth'; import { avatarRoutes } from './routes/avatars'; @@ -81,6 +82,7 @@ export async function buildApp({ logger = true, rateLimits = true }: { logger?: await app.register(connectionRoutes); await app.register(deviceRoutes); await app.register(realtimeRoutes); + await app.register(appUpdateRoutes); // registered as its own plugin so its permissive body parsers stay scoped to webhook routes await app.register(webhookRoutes); diff --git a/apps/api/src/env.ts b/apps/api/src/env.ts index f12771c..b6f2161 100644 --- a/apps/api/src/env.ts +++ b/apps/api/src/env.ts @@ -34,6 +34,13 @@ const schema = z.object({ .string() .default('https://lrclib.net') .transform((v) => v.replace(/\/+$/, '')), + /** + * App updates: the Gitea/Forgejo API URL of the latest Android release, e.g. + * https://gitea.example.com/api/v1/repos/owner/musicshare/releases/latest. Empty turns update checks off. + * The token (read access to the repo) is only needed when the repo is private; downloads go through us. + */ + ANDROID_RELEASES_URL: z.string().default(''), + ANDROID_RELEASES_TOKEN: optional, /** Apple Music (MusicKit): Team ID, MusicKit key ID, and the key's .p8 contents (newlines may be written as \n). */ APPLE_TEAM_ID: optional, APPLE_MUSICKIT_KEY_ID: optional, diff --git a/apps/api/src/routes/appUpdates.ts b/apps/api/src/routes/appUpdates.ts new file mode 100644 index 0000000..482b9a6 --- /dev/null +++ b/apps/api/src/routes/appUpdates.ts @@ -0,0 +1,45 @@ +import { Readable } from 'node:stream'; +import type { ReadableStream as WebReadableStream } from 'node:stream/web'; +import type { FastifyInstance } from 'fastify'; +import type { AppUpdateDTO } from '@musicshare/shared'; +import { HttpError } from '../lib/http'; +import { fetchFromReleaseHost, latestAndroidRelease } from '../services/appUpdates'; + +/** The Android app checks here for a newer version of itself and downloads it through us. */ +export async function appUpdateRoutes(app: FastifyInstance) { + app.get( + '/app/android/latest', + { config: { rateLimit: { max: 30, timeWindow: '1 minute' } } }, + async (req, reply): Promise => { + try { + const release = await latestAndroidRelease(); + return reply.send(release?.update ?? null); + } catch (err) { + req.log.warn(err, 'release lookup failed'); + throw new HttpError(503, "Couldn't check for updates right now"); + } + }, + ); + + /** Only the latest release's APK, so this can't be used to fetch anything else from the release host. */ + app.get<{ Params: { name: string } }>( + '/app/android/apk/:name', + { config: { rateLimit: { max: 10, timeWindow: '1 hour' } } }, + async (req, reply) => { + const release = await latestAndroidRelease().catch(() => null); + if (!release || req.params.name !== release.apkName) throw new HttpError(404, 'Not found'); + const res = await fetchFromReleaseHost(release.assetUrl, { signal: AbortSignal.timeout(10 * 60_000) }); + if (!res.ok || !res.body) { + req.log.warn({ status: res.status }, 'APK download from the release host failed'); + throw new HttpError(502, "Couldn't download the update right now"); + } + reply + .header('content-type', 'application/vnd.android.package-archive') + .header('content-disposition', `attachment; filename="${release.apkName}"`) + .header('cache-control', 'no-store'); + const length = res.headers.get('content-length'); + if (length) reply.header('content-length', length); + return reply.send(Readable.fromWeb(res.body as WebReadableStream)); + }, + ); +} diff --git a/apps/api/src/services/appUpdates.ts b/apps/api/src/services/appUpdates.ts new file mode 100644 index 0000000..419df44 --- /dev/null +++ b/apps/api/src/services/appUpdates.ts @@ -0,0 +1,110 @@ +import type { AppUpdateDTO } from '@musicshare/shared'; +import { parseVersion } from '@musicshare/shared'; +import { env } from '../env'; + +/** The newest release, plus where its APK really lives (served through GET /app/android/apk/:name). */ +export interface AndroidRelease { + update: AppUpdateDTO; + apkName: string; + assetUrl: string; +} + +interface GiteaAsset { + name: string; + size: number; + browser_download_url: string; +} +interface GiteaRelease { + tag_name: string; + body?: string | null; + draft?: boolean; + prerelease?: boolean; + published_at?: string; + created_at: string; + assets?: GiteaAsset[]; +} + +const CACHE_MS = 10 * 60_000; +const FAILURE_CACHE_MS = 60_000; +let cached: { at: number; ttl: number; release: AndroidRelease | null } | null = null; + +export const resetAndroidReleaseCache = () => { + cached = null; +}; + +/** + * Credentials for the release host. Gitea takes an access token as the basic-auth username with this + * password, which works for both its API and the release download links. + */ +function releaseHeaders(url: string): Record { + const token = env.ANDROID_RELEASES_TOKEN; + // Only ever send the token to the host it's for (asset links could point elsewhere). + if (!token || new URL(url).origin !== new URL(env.ANDROID_RELEASES_URL).origin) return {}; + return { authorization: `Basic ${Buffer.from(`${token}:x-oauth-basic`).toString('base64')}` }; +} + +export function fetchFromReleaseHost(url: string, init: RequestInit = {}) { + return fetch(url, { ...init, headers: { ...releaseHeaders(url), ...(init.headers as Record) } }); +} + +/** The release notes shown in the app: the text before a "---" line (what follows is for the releases page). */ +function notesFrom(body: string | null | undefined): string | null { + const notes = (body ?? '').split(/^\s*---\s*$/m)[0]!.trim(); + return notes || null; +} + +async function load(): Promise { + const res = await fetchFromReleaseHost(env.ANDROID_RELEASES_URL, { + headers: { accept: 'application/json' }, + signal: AbortSignal.timeout(10_000), + }); + if (res.status === 404) return null; // no releases yet + if (!res.ok) throw new Error(`release lookup failed: ${res.status}`); + const release = (await res.json()) as GiteaRelease; + if (release.draft || release.prerelease) return null; + + const version = release.tag_name.replace(/^v/, ''); + if (!parseVersion(version)) return null; + const apkName = `musicshare-${version}.apk`; + const apk = release.assets?.find((a) => a.name === apkName); + const sums = release.assets?.find((a) => a.name === 'SHA256SUMS'); + // Without a checksum the phone can't check the download, so don't offer it. + if (!apk || !sums) return null; + + const sumsRes = await fetchFromReleaseHost(sums.browser_download_url, { signal: AbortSignal.timeout(10_000) }); + if (!sumsRes.ok) throw new Error(`SHA256SUMS download failed: ${sumsRes.status}`); + const sha256 = (await sumsRes.text()) + .split('\n') + .map((line) => line.trim().split(/\s+\*?/)) + .find(([, name]) => name === apkName)?.[0] + ?.toLowerCase(); + if (!sha256 || !/^[0-9a-f]{64}$/.test(sha256)) return null; + + return { + apkName, + assetUrl: apk.browser_download_url, + update: { + version, + apkUrl: `${env.API_PUBLIC_URL.replace(/\/+$/, '')}/app/android/apk/${encodeURIComponent(apkName)}`, + sha256, + size: apk.size, + notes: notesFrom(release.body), + publishedAt: release.published_at ?? release.created_at, + }, + }; +} + +/** The newest Android release (cached for a few minutes), or null when there is none or updates are off. */ +export async function latestAndroidRelease(): Promise { + if (!env.ANDROID_RELEASES_URL) return null; + if (cached && Date.now() - cached.at < cached.ttl) return cached.release; + try { + const release = await load(); + cached = { at: Date.now(), ttl: CACHE_MS, release }; + return release; + } catch (err) { + // Keep serving the last good answer for a bit rather than hammering the release host. + cached = { at: Date.now(), ttl: FAILURE_CACHE_MS, release: cached?.release ?? null }; + throw err; + } +} diff --git a/apps/api/test/app-updates.test.ts b/apps/api/test/app-updates.test.ts new file mode 100644 index 0000000..567f4c9 --- /dev/null +++ b/apps/api/test/app-updates.test.ts @@ -0,0 +1,94 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { resetAndroidReleaseCache } from '../src/services/appUpdates'; +import { json, routeFetch, useTestApp } from './helpers'; + +const app = useTestApp(); +beforeEach(resetAndroidReleaseCache); +afterEach(() => vi.unstubAllGlobals()); + +const SHA = 'ab'.repeat(32); +const release = (over: object = {}) => ({ + tag_name: 'v1.2.0', + body: 'Lyrics and notifications.\n\n---\nmusicshare-*-foss.apk has no closed-source code.', + draft: false, + prerelease: false, + published_at: '2026-10-15T15:33:31Z', + created_at: '2026-10-15T15:33:00Z', + assets: [ + { name: 'musicshare-1.2.0.apk', size: 77_000_000, browser_download_url: 'https://git.test/me/musicshare/releases/download/v1.2.0/musicshare-1.2.0.apk' }, + { name: 'musicshare-1.2.0-foss.apk', size: 67_000_000, browser_download_url: 'https://git.test/me/musicshare/releases/download/v1.2.0/musicshare-1.2.0-foss.apk' }, + { name: 'SHA256SUMS', size: 200, browser_download_url: 'https://git.test/me/musicshare/releases/download/v1.2.0/SHA256SUMS' }, + ], + ...over, +}); +const sums = `${'cd'.repeat(32)} musicshare-1.2.0-foss.apk\n${SHA} musicshare-1.2.0.apk\n`; + +describe('app updates', () => { + it('reports the latest release with its checksum and notes, asking the release host only once', async () => { + const auth: (string | null)[] = []; + const seen = (init?: RequestInit) => auth.push(new Headers(init?.headers).get('authorization')); + vi.stubGlobal( + 'fetch', + routeFetch([ + [/releases\/latest$/, (_url, init) => (seen(init), json(release()))], + [/SHA256SUMS$/, (_url, init) => (seen(init), new Response(sums))], + ]), + ); + const res = await app().inject({ url: '/app/android/latest' }); + expect(res.statusCode).toBe(200); + expect(res.json()).toEqual({ + version: '1.2.0', + apkUrl: 'https://api.test/app/android/apk/musicshare-1.2.0.apk', + sha256: SHA, + size: 77_000_000, + notes: 'Lyrics and notifications.', + publishedAt: '2026-10-15T15:33:31Z', + }); + expect(auth).toEqual(Array(2).fill(`Basic ${Buffer.from('test-release-token:x-oauth-basic').toString('base64')}`)); + + await app().inject({ url: '/app/android/latest' }); + expect(auth).toHaveLength(2); + }); + + it('offers nothing for drafts, pre-releases or releases without a checksum', async () => { + for (const over of [{ prerelease: true }, { draft: true }, { assets: release().assets.slice(0, 2) }]) { + resetAndroidReleaseCache(); + vi.stubGlobal('fetch', routeFetch([[/releases\/latest$/, () => json(release(over))]])); + expect((await app().inject({ url: '/app/android/latest' })).json()).toBeNull(); + } + }); + + it('streams only the latest APK, never sending the token to another host', async () => { + let apkAuth: string | null = 'unset'; + vi.stubGlobal( + 'fetch', + routeFetch([ + [ + /releases\/latest$/, + () => + json( + release({ + assets: [ + { name: 'musicshare-1.2.0.apk', size: 4, browser_download_url: 'https://cdn.example/musicshare-1.2.0.apk' }, + { name: 'SHA256SUMS', size: 200, browser_download_url: 'https://git.test/SHA256SUMS' }, + ], + }), + ), + ], + [/SHA256SUMS$/, () => new Response(sums)], + [/cdn\.example/, (_url, init) => ((apkAuth = new Headers(init?.headers).get('authorization')), new Response('APK!'))], + ]), + ); + expect((await app().inject({ url: '/app/android/apk/musicshare-1.1.0.apk' })).statusCode).toBe(404); + const res = await app().inject({ url: '/app/android/apk/musicshare-1.2.0.apk' }); + expect(res.statusCode).toBe(200); + expect(res.headers['content-type']).toBe('application/vnd.android.package-archive'); + expect(res.body).toBe('APK!'); + expect(apkAuth).toBeNull(); + }); + + it('says 503 when the release host is down', async () => { + vi.stubGlobal('fetch', routeFetch([[/releases\/latest$/, () => json({ message: 'oops' }, 500)]])); + expect((await app().inject({ url: '/app/android/latest' })).statusCode).toBe(503); + }); +}); diff --git a/apps/api/vitest.config.ts b/apps/api/vitest.config.ts index 3f9298c..e6be385 100644 --- a/apps/api/vitest.config.ts +++ b/apps/api/vitest.config.ts @@ -22,6 +22,8 @@ const testEnv = { SPOTIFY_CLIENT_ID: 'test-spotify-id', SPOTIFY_CLIENT_SECRET: 'test-spotify-secret', ODESLI_API_KEY: '', + ANDROID_RELEASES_URL: 'https://git.test/api/v1/repos/me/musicshare/releases/latest', + ANDROID_RELEASES_TOKEN: 'test-release-token', // Never send real email from tests (apps/api/.env may hold real SMTP settings). SMTP_HOST: '', APPLE_TEAM_ID: 'TEAMID1234', diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index 53b1156..595a8d6 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -21,6 +21,11 @@ export default ({ config }: ConfigContext): ExpoConfig => { return { ...(config as ExpoConfig), extra: { ...config.extra, foss }, - plugins: [...plugins, ['expo-build-properties', { android: { usesCleartextTraffic: localHttp } }] satisfies Plugin], + plugins: [ + ...plugins, + ['expo-build-properties', { android: { usesCleartextTraffic: localHttp } }] satisfies Plugin, + // F-Droid delivers updates itself; only our own builds install them (modules/updater). + ...(foss ? ['./plugins/withoutSelfUpdate'] : []), + ], }; }; diff --git a/apps/mobile/modules/updater/android/build.gradle b/apps/mobile/modules/updater/android/build.gradle new file mode 100644 index 0000000..90eaa41 --- /dev/null +++ b/apps/mobile/modules/updater/android/build.gradle @@ -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 + } +} diff --git a/apps/mobile/modules/updater/android/src/main/AndroidManifest.xml b/apps/mobile/modules/updater/android/src/main/AndroidManifest.xml new file mode 100644 index 0000000..0b0b603 --- /dev/null +++ b/apps/mobile/modules/updater/android/src/main/AndroidManifest.xml @@ -0,0 +1,11 @@ + + + + + + + + + + diff --git a/apps/mobile/modules/updater/android/src/main/java/tech/chrisedwards/musicshare/updater/InstallResultReceiver.kt b/apps/mobile/modules/updater/android/src/main/java/tech/chrisedwards/musicshare/updater/InstallResultReceiver.kt new file mode 100644 index 0000000..17e09ce --- /dev/null +++ b/apps/mobile/modules/updater/android/src/main/java/tech/chrisedwards/musicshare/updater/InstallResultReceiver.kt @@ -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" + } +} diff --git a/apps/mobile/modules/updater/android/src/main/java/tech/chrisedwards/musicshare/updater/UpdaterBus.kt b/apps/mobile/modules/updater/android/src/main/java/tech/chrisedwards/musicshare/updater/UpdaterBus.kt new file mode 100644 index 0000000..ee7e369 --- /dev/null +++ b/apps/mobile/modules/updater/android/src/main/java/tech/chrisedwards/musicshare/updater/UpdaterBus.kt @@ -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) + } +} diff --git a/apps/mobile/modules/updater/android/src/main/java/tech/chrisedwards/musicshare/updater/UpdaterModule.kt b/apps/mobile/modules/updater/android/src/main/java/tech/chrisedwards/musicshare/updater/UpdaterModule.kt new file mode 100644 index 0000000..54ca97a --- /dev/null +++ b/apps/mobile/modules/updater/android/src/main/java/tech/chrisedwards/musicshare/updater/UpdaterModule.kt @@ -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) diff --git a/apps/mobile/modules/updater/expo-module.config.json b/apps/mobile/modules/updater/expo-module.config.json new file mode 100644 index 0000000..fe5f071 --- /dev/null +++ b/apps/mobile/modules/updater/expo-module.config.json @@ -0,0 +1,6 @@ +{ + "platforms": ["android"], + "android": { + "modules": ["tech.chrisedwards.musicshare.updater.UpdaterModule"] + } +} diff --git a/apps/mobile/modules/updater/index.ts b/apps/mobile/modules/updater/index.ts new file mode 100644 index 0000000..8746bb0 --- /dev/null +++ b/apps/mobile/modules/updater/index.ts @@ -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'; diff --git a/apps/mobile/modules/updater/src/UpdaterModule.ts b/apps/mobile/modules/updater/src/UpdaterModule.ts new file mode 100644 index 0000000..2661975 --- /dev/null +++ b/apps/mobile/modules/updater/src/UpdaterModule.ts @@ -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 { + /** 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; + /** + * 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; +} + +/** Android only; null elsewhere and in Expo Go. */ +export default requireOptionalNativeModule('MusicshareUpdater'); diff --git a/apps/mobile/plugins/withoutSelfUpdate.js b/apps/mobile/plugins/withoutSelfUpdate.js new file mode 100644 index 0000000..cd0e69b --- /dev/null +++ b/apps/mobile/plugins/withoutSelfUpdate.js @@ -0,0 +1,17 @@ +// F-Droid builds (MUSICSHARE_FOSS=1) get their updates from F-Droid, so they mustn't be able to +// install packages themselves: remove the permissions the updater module (modules/updater) adds. +const { AndroidConfig, withAndroidManifest } = require('expo/config-plugins'); + +const PERMISSIONS = ['android.permission.REQUEST_INSTALL_PACKAGES', 'android.permission.UPDATE_PACKAGES_WITHOUT_USER_ACTION']; + +module.exports = function withoutSelfUpdate(config) { + return withAndroidManifest(config, (cfg) => { + const manifest = AndroidConfig.Manifest.ensureToolsAvailable(cfg.modResults).manifest; + const existing = manifest['uses-permission'] ?? []; + manifest['uses-permission'] = [ + ...existing.filter((p) => !PERMISSIONS.includes(p.$['android:name'])), + ...PERMISSIONS.map((name) => ({ $: { 'android:name': name, 'tools:node': 'remove' } })), + ]; + return cfg; + }); +}; diff --git a/apps/mobile/src/app/(tabs)/index.tsx b/apps/mobile/src/app/(tabs)/index.tsx index 70780fb..0cf410b 100644 --- a/apps/mobile/src/app/(tabs)/index.tsx +++ b/apps/mobile/src/app/(tabs)/index.tsx @@ -1,7 +1,7 @@ import { router } from 'expo-router'; import { useEffect, useState } from 'react'; import { Pressable, RefreshControl, ScrollView, StyleSheet, Text, View } from 'react-native'; -import Animated, { FadeInDown, LinearTransition } from 'react-native-reanimated'; +import Animated, { FadeIn, LinearTransition } from 'react-native-reanimated'; import { SafeAreaView } from 'react-native-safe-area-context'; import useSWR, { useSWRConfig } from 'swr'; import { sortFeed, type ConnectionDTO, type FeedItemDTO, type FollowListDTO, type ProfileDTO } from '@musicshare/shared'; @@ -10,6 +10,7 @@ import { ActivityBell } from '@/components/ActivityBell'; import { Avatar } from '@/components/Avatar'; import { NowPlayingCard } from '@/components/NowPlayingCard'; import { RelinkReminder } from '@/components/RelinkReminder'; +import { UpdateBanner } from '@/components/UpdateBanner'; import { SharingControls } from '@/components/SharingControls'; import { Body, Button, Card, Title } from '@/components/ui'; import { api } from '@/lib/api'; @@ -84,9 +85,9 @@ export default function FeedScreen() { return ( - {/* Cards rise in on load and glide to new positions when someone pins or starts playing. */} + {/* Cards fade in and slide to new positions when someone pins or starts playing. */} item.user.id} contentContainerStyle={styles.list} @@ -97,6 +98,7 @@ export default function FeedScreen() { musicshare + {offerAppleMusic && ( @@ -160,8 +162,8 @@ export default function FeedScreen() { ) : null } - renderItem={({ item, index }) => ( - + renderItem={({ item }) => ( + + ); @@ -289,6 +291,7 @@ function Connections() { }); const linkSpotifyOnLastfm = () => openSpotifyLinkPage('lastfm').catch((e) => showError(e)); + const linkSpotifyOnListenBrainz = () => openSpotifyLinkPage('listenbrainz').catch((e) => showError(e)); const spotifyLinked = byProvider.has('lastfm') || byProvider.has('listenbrainz'); const disconnect = (provider: NowPlayingProvider) => @@ -382,8 +385,10 @@ function Connections() {