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