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
+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);
});
});