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
+10 -1
View File
@@ -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
+2
View File
@@ -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);
+7
View File
@@ -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,
+45
View File
@@ -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<AppUpdateDTO | null> => {
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));
},
);
}
+110
View File
@@ -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<string, string> {
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<string, string>) } });
}
/** 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<AndroidRelease | null> {
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<AndroidRelease | null> {
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;
}
}
+94
View File
@@ -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);
});
});
+2
View File
@@ -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',
+6 -1
View File
@@ -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'] : []),
],
};
};
@@ -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');
+17
View File
@@ -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;
});
};
+7 -5
View File
@@ -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 (
<SafeAreaView style={styles.safe} edges={['top']}>
<AmbientBackground />
{/* 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. */}
<Animated.FlatList
itemLayoutAnimation={LinearTransition.springify().damping(18)}
itemLayoutAnimation={LinearTransition.duration(200)}
data={feed ?? []}
keyExtractor={(item) => item.user.id}
contentContainerStyle={styles.list}
@@ -97,6 +98,7 @@ export default function FeedScreen() {
<Text style={styles.brand}>musicshare</Text>
<ActivityBell />
</View>
<UpdateBanner />
<SharingControls compact />
<RelinkReminder />
{offerAppleMusic && (
@@ -160,8 +162,8 @@ export default function FeedScreen() {
</View>
) : null
}
renderItem={({ item, index }) => (
<Animated.View entering={FadeInDown.delay(Math.min(index, 8) * 60).springify().damping(16)}>
renderItem={({ item }) => (
<Animated.View entering={FadeIn.duration(180)}>
<NowPlayingCard
np={item}
preferred={me?.preferredService}
+74 -2
View File
@@ -26,10 +26,11 @@ import { api } from '@/lib/api';
import { WEB_URL } from '@/lib/config';
import { showError } from '@/lib/errors';
import { AppleMusicPermissionError, appleMusicAvailable, connectAppleMusic } from '@/lib/appleMusic';
import { SignInError, openSpotifyLinkPage, signInWithService } from '@/lib/connect';
import { SignInError, openSignupPage, openSpotifyLinkPage, signInWithService } from '@/lib/connect';
import { openReaderSettings, useReader } from '@/lib/nowPlayingReader';
import { NTFY_URL, disablePush, enablePush, pushAvailable, usePushStatus } from '@/lib/push';
import { useSession } from '@/lib/session';
import { checkForUpdate, currentVersion, installUpdate, updaterAvailable, useUpdateState } from '@/lib/updates';
import { colors, radius, space } from '@/lib/theme';
export default function SettingsScreen() {
@@ -44,6 +45,7 @@ export default function SettingsScreen() {
<ThisPhone />
<Connections />
<FeaturedPicker />
<AppVersion />
<Account />
</Screen>
);
@@ -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() {
<Button small variant="primary" title="Sign in" onPress={() => connectOAuth('lastfm')} />
)}
</View>
{byProvider.has('lastfm') && (
{byProvider.has('lastfm') ? (
<Button small title="Link Spotify to Last.fm" onPress={linkSpotifyOnLastfm} style={{ alignSelf: 'flex-start' }} />
) : (
<Button small title="No account? Create one" onPress={() => openSignupPage('lastfm')} style={{ alignSelf: 'flex-start' }} />
)}
</View>
@@ -393,6 +398,7 @@ function Connections() {
active={byProvider.has('listenbrainz')}
onConnect={(username) => run(() => api.connectUsername('listenbrainz', username))}
onDisconnect={() => disconnect('listenbrainz')}
onLinkSpotify={linkSpotifyOnListenBrainz}
/>
<JellyfinConnection
@@ -417,12 +423,14 @@ function UsernameConnection({
active,
onConnect,
onDisconnect,
onLinkSpotify,
}: {
provider: 'lastfm' | 'listenbrainz';
status: React.ReactNode;
active: boolean;
onConnect: (username: string) => Promise<void>;
onDisconnect: () => void;
onLinkSpotify: () => void;
}) {
const [username, setUsername] = useState('');
const [busy, setBusy] = useState(false);
@@ -454,6 +462,18 @@ function UsernameConnection({
/>
</Row>
)}
{active ? (
<Button small title={`Link Spotify to ${SERVICE_LABELS[provider]}`} onPress={onLinkSpotify} style={{ alignSelf: 'flex-start' }} />
) : (
<>
<Button small title="No account? Create one" onPress={() => openSignupPage(provider)} style={{ alignSelf: 'flex-start' }} />
{provider === 'listenbrainz' && (
<Body muted style={styles.small}>
ListenBrainz uses MusicBrainz accounts: create one there, then sign in to ListenBrainz once.
</Body>
)}
</>
)}
</View>
);
}
@@ -514,6 +534,58 @@ function JellyfinConnection({
);
}
/** This build's version, and checking for a newer one (our own APKs; F-Droid updates its builds itself). */
function AppVersion() {
const state = useUpdateState();
if (!updaterAvailable) return null;
const status = (() => {
switch (state.status) {
case 'checking':
return 'Checking for updates…';
case 'upToDate':
return 'Up to date.';
case 'downloading':
return `Downloading ${state.update.version}… ${Math.round(state.progress * 100)}%`;
case 'ready':
return `Version ${state.update.version} is ready to install.`;
case 'installing':
return `Installing ${state.update.version}…`;
case 'error':
return state.message;
default:
return null;
}
})();
return (
<Card>
<Heading>App version {currentVersion()}</Heading>
{status && (
<Body muted style={styles.small}>
{status}
</Body>
)}
{state.status === 'ready' || state.status === 'installing' ? (
<Button
small
variant="primary"
title="Install update"
disabled={state.status === 'installing'}
onPress={() => installUpdate(true)}
style={{ alignSelf: 'flex-start' }}
/>
) : (
<Button
small
title="Check for updates"
loading={state.status === 'checking' || state.status === 'downloading'}
onPress={() => checkForUpdate({ force: true })}
style={{ alignSelf: 'flex-start' }}
/>
)}
</Card>
);
}
function Account() {
const { me, setMe, signOut } = useSession();
const resend = async () => {
+6
View File
@@ -12,6 +12,7 @@ import { appleMusicAvailable, useAppleMusicLive } from '@/lib/appleMusic';
import { syncDevice } from '@/lib/nowPlayingReader';
import { syncPush } from '@/lib/push';
import { SessionProvider, useSession } from '@/lib/session';
import { startUpdater } from '@/lib/updates';
import { colors, space } from '@/lib/theme';
SplashScreen.preventAutoHideAsync();
@@ -43,6 +44,11 @@ function AppNavigator() {
if (ready) SplashScreen.hideAsync();
}, [ready]);
// Look for a newer version of the app (our own APKs only; see lib/updates.ts).
useEffect(() => {
if (ready) startUpdater();
}, [ready]);
// Keep the Android listener pointed at this account (no-op elsewhere).
useEffect(() => {
if (signedIn) syncDevice().catch(() => {});
@@ -5,7 +5,6 @@ import { useEffect, useRef, useState } from 'react';
import { Linking, Pressable, StyleSheet, Text, View } from 'react-native';
import Animated, {
FadeIn,
FadeInDown,
FadeOut,
useAnimatedStyle,
useSharedValue,
@@ -98,7 +97,7 @@ export function NowPlayingCard({
</View>
</View>
<Animated.View key={np.track.id} entering={FadeInDown.duration(380).springify()} exiting={FadeOut.duration(200)}>
<Animated.View key={np.track.id} entering={FadeIn.duration(250)} exiting={FadeOut.duration(200)}>
<TrackRow track={np.track} preferred={preferred} expanded={expanded} onToggle={toggle} />
</Animated.View>
<ProgressBar np={np} compact={!expanded} />
@@ -0,0 +1,32 @@
import { StyleSheet } from 'react-native';
import { installUpdate, updaterAvailable, useUpdateState } from '@/lib/updates';
import { Body, Button, Card, Heading } from './ui';
/** "Update ready" on the feed, once a newer version has downloaded (lib/updates.ts). */
export function UpdateBanner() {
const state = useUpdateState();
if (!updaterAvailable || (state.status !== 'ready' && state.status !== 'installing')) return null;
return (
<Card highlighted>
<Heading>musicshare {state.update.version} is ready</Heading>
{state.update.notes ? (
<Body muted style={styles.notes} numberOfLines={4}>
{state.update.notes}
</Body>
) : null}
<Button
small
variant="primary"
title={state.status === 'installing' ? 'Installing…' : 'Install update'}
disabled={state.status === 'installing'}
onPress={() => installUpdate(true)}
style={styles.button}
/>
</Card>
);
}
const styles = StyleSheet.create({
notes: { fontSize: 13 },
button: { alignSelf: 'flex-start' },
});
+133
View File
@@ -0,0 +1,133 @@
import { useSyncExternalStore } from 'react';
import { AppState } from 'react-native';
import { isNewerVersion, type AppUpdateDTO } from '@musicshare/shared';
import Updater, { type InstallStatus } from '@/modules/updater';
import { api } from './api';
import { FOSS_BUILD } from './build';
/**
* Self-updating for the APKs we publish (not F-Droid builds, which F-Droid updates, nor dev builds).
*
* When the app opens (at most every few hours) it asks the server for the newest release and, if
* it's newer, downloads it in the background and checks its SHA-256. Then:
* - leaving the app tries a quiet install, once per version. Android 12+ allows that once this app has
* installed an update itself (and "install unknown apps" is allowed for it); the new version runs next
* time it's opened. On phones with Google Play, Play Protect may still ask to scan a new version.
* - otherwise the feed shows "Update ready", and tapping it opens Android's install screen.
*/
export const updaterAvailable = Updater !== null && !FOSS_BUILD && !__DEV__;
export type UpdateState =
| { status: 'idle' | 'checking' | 'upToDate' }
| { status: 'downloading'; update: AppUpdateDTO; progress: number }
| { status: 'ready' | 'installing'; update: AppUpdateDTO; path: string }
| { status: 'error'; message: string; update?: AppUpdateDTO };
const CHECK_EVERY_MS = 6 * 3600_000;
let state: UpdateState = { status: 'idle' };
let lastCheck = 0;
/** Versions we've already tried to install on the way out; after that, only when asked (no repeat prompts). */
const autoTried = new Set<string>();
const listeners = new Set<() => void>();
function set(next: UpdateState) {
// Visible with `adb logcat -s ReactNativeJS`, for checking updates on a real phone.
if (next.status !== state.status) console.log(`[updates] ${next.status}${'message' in next ? `: ${next.message}` : ''}`);
state = next;
listeners.forEach((l) => l());
}
export const currentVersion = () => Updater?.currentVersion() ?? null;
/** Look for an update and download it. `force` skips the every-few-hours limit (the Settings button). */
export async function checkForUpdate({ force = false } = {}): Promise<void> {
if (!Updater || !updaterAvailable) return;
if (['checking', 'downloading', 'installing'].includes(state.status)) return;
if (state.status === 'ready') return; // already have it
if (!force && Date.now() - lastCheck < CHECK_EVERY_MS) return;
lastCheck = Date.now();
set({ status: 'checking' });
let update: AppUpdateDTO | null;
try {
update = await api.androidUpdate();
} catch {
set({ status: 'error', message: "Couldn't check for updates." });
return;
}
if (!update || !isNewerVersion(update.version, Updater.currentVersion())) {
Updater.clearDownloads(); // e.g. the update we downloaded has since been installed
set({ status: 'upToDate' });
return;
}
set({ status: 'downloading', update, progress: 0 });
const progress = Updater.addListener('onProgress', ({ received, total }) => {
if (state.status === 'downloading') set({ ...state, progress: total > 0 ? received / total : 0 });
});
try {
const path = await Updater.download(update.apkUrl, update.sha256, `musicshare-${update.version}.apk`);
set({ status: 'ready', update, path });
} catch (err) {
set({ status: 'error', message: err instanceof Error ? err.message : "Couldn't download the update.", update });
} finally {
progress.remove();
}
}
/** Install the downloaded update. `interactive` shows Android's confirmation screen if it needs one. */
export async function installUpdate(interactive: boolean): Promise<void> {
if (!Updater || state.status !== 'ready') return;
const ready = state;
set({ ...ready, status: 'installing' });
try {
await Updater.install(ready.path, interactive);
} catch (err) {
set({ status: 'error', message: err instanceof Error ? err.message : "Couldn't install the update.", update: ready.update });
lastCheck = 0; // e.g. the download went missing: fetch it again next time
}
}
function onInstallStatus(status: InstallStatus, message: string | null) {
if (state.status !== 'installing') return;
const { update, path } = state;
switch (status) {
case 'waiting':
return; // Android's screen is up; its answer comes next
case 'needs_user':
case 'cancelled':
return set({ status: 'ready', update, path });
case 'installed':
return set({ status: 'upToDate' });
case 'failed':
return set({ status: 'error', message: message ?? "Couldn't install the update.", update });
}
}
let started = false;
/** Call once at startup: checks now, again when the app comes back, and tries a quiet install on the way out. */
export function startUpdater() {
if (started || !Updater || !updaterAvailable) return;
started = true;
Updater.addListener('onInstallStatus', ({ status, message }) => onInstallStatus(status, message));
AppState.addEventListener('change', (appState) => {
if (appState === 'active') checkForUpdate().catch(() => {});
else if (appState === 'background' && state.status === 'ready' && !autoTried.has(state.update.version)) {
autoTried.add(state.update.version);
installUpdate(false).catch(() => {});
}
});
checkForUpdate().catch(() => {});
}
export function useUpdateState(): UpdateState {
return useSyncExternalStore(
(listener) => {
listeners.add(listener);
return () => listeners.delete(listener);
},
() => state,
);
}
+5
View File
@@ -43,3 +43,8 @@ VAPID_PUBLIC_KEY=
VAPID_PRIVATE_KEY=
# Lyrics: LRCLIB (or your own copy of it). Empty turns lyrics off.
LRCLIB_URL=https://lrclib.net
# App updates: the Android app checks the latest Gitea release through the API and updates itself.
# Empty turns it off. The token needs read access to the repo, and only if the repo is private.
ANDROID_RELEASES_URL=https://gitea.chrisedwards.tech/api/v1/repos/chris/musicshare/releases/latest
ANDROID_RELEASES_TOKEN=
+26
View File
@@ -70,6 +70,32 @@ that release's files.
**Test builds:** go to **Actions > Android > Run workflow** and pick a branch. The APKs go to the
repo's **Packages** as `musicshare-android`, versioned `<branch>-<commit>`, instead of to a release.
## App updates
The normal APK updates itself from these releases; the F-Droid build doesn't (F-Droid updates it).
1. The app asks our server (`GET /app/android/latest`) when it opens, at most every 6 hours, or when you
tap **Settings > Check for updates**.
2. The server reads the latest release from Gitea and returns its version, the APK's SHA-256 (from
`SHA256SUMS`) and the release notes. Drafts and pre-releases are skipped.
3. If it's newer, the app downloads the APK through our server and checks the SHA-256.
4. The next time you leave the app, it tries a quiet install (once per version). Android 12+ allows that
after the app has installed one update itself. Otherwise the feed shows **Update ready**, and tapping it
opens Android's install screen; the first time, Android asks you to allow musicshare to install apps.
On phones with Google Play, Play Protect may ask to scan each new version ("App scan recommended")
because the app isn't from the Play Store. Choosing **Scan app** lets the update finish.
Set these on the API server (`deploy/production.env`):
```ini
ANDROID_RELEASES_URL=https://gitea.chrisedwards.tech/api/v1/repos/chris/musicshare/releases/latest
# Only while the repo is private: a token with read:repository (User settings > Applications).
ANDROID_RELEASES_TOKEN=
```
The release notes come from `fastlane/metadata/android/en-US/changelogs/<versionCode>.txt`: the workflow
puts that text above a `---` line in the release, and the app shows only that part.
## Building by hand
```bash
+4
View File
@@ -13,6 +13,7 @@ import type {
import type { DeviceDTO } from './devices';
import type { LyricsDTO } from './lyrics';
import type { NotificationListDTO } from './notifications';
import type { AppUpdateDTO } from './updates';
import type {
ChangeEmailInput,
ChangePasswordInput,
@@ -160,6 +161,9 @@ export function createApiClient({ baseUrl, getToken, credentials }: ApiClientOpt
request<ConnectionDTO>('/connections/jellyfin', { body: input }),
disconnect: (provider: string) => request(`/connections/${q(provider)}`, { method: 'DELETE' }),
/** The newest Android release, or null when the server doesn't offer updates. */
androidUpdate: () => request<AppUpdateDTO | null>('/app/android/latest'),
devices: () => request<DeviceDTO[]>('/me/devices'),
/** The token is returned once; it can only report playback for this user. */
registerDevice: (input: { platform: 'android' | 'ios'; name: string }) =>
+1
View File
@@ -11,3 +11,4 @@ export * from './progress';
export * from './reactions';
export * from './lyrics';
export * from './notifications';
export * from './updates';
+26
View File
@@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest';
import { isNewerVersion, parseVersion } from './updates';
describe('parseVersion', () => {
it('reads dotted versions, with or without a leading v', () => {
expect(parseVersion('1.10.0')).toEqual([1, 10, 0]);
expect(parseVersion('v2.0')).toEqual([2, 0]);
expect(parseVersion('1.2.0-beta')).toBeNull();
expect(parseVersion('')).toBeNull();
});
});
describe('isNewerVersion', () => {
it('compares numerically, part by part', () => {
expect(isNewerVersion('1.10.0', '1.9.3')).toBe(true);
expect(isNewerVersion('1.1.0', '1.1.0')).toBe(false);
expect(isNewerVersion('1.1', '1.1.0')).toBe(false);
expect(isNewerVersion('1.1.1', '1.1')).toBe(true);
expect(isNewerVersion('1.0.9', '1.1.0')).toBe(false);
});
it('never treats something unparseable as newer', () => {
expect(isNewerVersion('nightly', '1.0.0')).toBe(false);
expect(isNewerVersion('2.0.0', 'unknown')).toBe(false);
});
});
+31
View File
@@ -0,0 +1,31 @@
/** The newest Android release, as the server reports it (GET /app/android/latest). */
export interface AppUpdateDTO {
/** e.g. "1.2.0" (the release tag without its "v"). */
version: string;
/** Where to download the APK (through our server, so the release host can stay private). */
apkUrl: string;
/** Hex SHA-256 of the APK; the phone checks it before installing. */
sha256: string;
size: number;
/** What's new, from the release notes. */
notes: string | null;
publishedAt: string;
}
/** "1.10.0" → [1, 10, 0]; null for anything that isn't a plain dotted version (an optional leading "v" is fine). */
export function parseVersion(version: string): number[] | null {
const m = /^v?(\d+(?:\.\d+)*)$/.exec(version.trim());
return m ? m[1]!.split('.').map(Number) : null;
}
/** True when `candidate` is a later version than `current`. Unparseable versions are never newer. */
export function isNewerVersion(candidate: string, current: string): boolean {
const a = parseVersion(candidate);
const b = parseVersion(current);
if (!a || !b) return false;
for (let i = 0; i < Math.max(a.length, b.length); i++) {
const diff = (a[i] ?? 0) - (b[i] ?? 0);
if (diff !== 0) return diff > 0;
}
return false;
}