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
+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 () => {