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:
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user