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