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
+7 -5
View File
@@ -1,7 +1,7 @@
import { router } from 'expo-router';
import { useEffect, useState } from 'react';
import { Pressable, RefreshControl, ScrollView, StyleSheet, Text, View } from 'react-native';
import Animated, { FadeInDown, LinearTransition } from 'react-native-reanimated';
import Animated, { FadeIn, LinearTransition } from 'react-native-reanimated';
import { SafeAreaView } from 'react-native-safe-area-context';
import useSWR, { useSWRConfig } from 'swr';
import { sortFeed, type ConnectionDTO, type FeedItemDTO, type FollowListDTO, type ProfileDTO } from '@musicshare/shared';
@@ -10,6 +10,7 @@ import { ActivityBell } from '@/components/ActivityBell';
import { Avatar } from '@/components/Avatar';
import { NowPlayingCard } from '@/components/NowPlayingCard';
import { RelinkReminder } from '@/components/RelinkReminder';
import { UpdateBanner } from '@/components/UpdateBanner';
import { SharingControls } from '@/components/SharingControls';
import { Body, Button, Card, Title } from '@/components/ui';
import { api } from '@/lib/api';
@@ -84,9 +85,9 @@ export default function FeedScreen() {
return (
<SafeAreaView style={styles.safe} edges={['top']}>
<AmbientBackground />
{/* Cards rise in on load and glide to new positions when someone pins or starts playing. */}
{/* Cards fade in and slide to new positions when someone pins or starts playing. */}
<Animated.FlatList
itemLayoutAnimation={LinearTransition.springify().damping(18)}
itemLayoutAnimation={LinearTransition.duration(200)}
data={feed ?? []}
keyExtractor={(item) => item.user.id}
contentContainerStyle={styles.list}
@@ -97,6 +98,7 @@ export default function FeedScreen() {
<Text style={styles.brand}>musicshare</Text>
<ActivityBell />
</View>
<UpdateBanner />
<SharingControls compact />
<RelinkReminder />
{offerAppleMusic && (
@@ -160,8 +162,8 @@ export default function FeedScreen() {
</View>
) : null
}
renderItem={({ item, index }) => (
<Animated.View entering={FadeInDown.delay(Math.min(index, 8) * 60).springify().damping(16)}>
renderItem={({ item }) => (
<Animated.View entering={FadeIn.duration(180)}>
<NowPlayingCard
np={item}
preferred={me?.preferredService}
+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 () => {
+6
View File
@@ -12,6 +12,7 @@ import { appleMusicAvailable, useAppleMusicLive } from '@/lib/appleMusic';
import { syncDevice } from '@/lib/nowPlayingReader';
import { syncPush } from '@/lib/push';
import { SessionProvider, useSession } from '@/lib/session';
import { startUpdater } from '@/lib/updates';
import { colors, space } from '@/lib/theme';
SplashScreen.preventAutoHideAsync();
@@ -43,6 +44,11 @@ function AppNavigator() {
if (ready) SplashScreen.hideAsync();
}, [ready]);
// Look for a newer version of the app (our own APKs only; see lib/updates.ts).
useEffect(() => {
if (ready) startUpdater();
}, [ready]);
// Keep the Android listener pointed at this account (no-op elsewhere).
useEffect(() => {
if (signedIn) syncDevice().catch(() => {});
@@ -5,7 +5,6 @@ import { useEffect, useRef, useState } from 'react';
import { Linking, Pressable, StyleSheet, Text, View } from 'react-native';
import Animated, {
FadeIn,
FadeInDown,
FadeOut,
useAnimatedStyle,
useSharedValue,
@@ -98,7 +97,7 @@ export function NowPlayingCard({
</View>
</View>
<Animated.View key={np.track.id} entering={FadeInDown.duration(380).springify()} exiting={FadeOut.duration(200)}>
<Animated.View key={np.track.id} entering={FadeIn.duration(250)} exiting={FadeOut.duration(200)}>
<TrackRow track={np.track} preferred={preferred} expanded={expanded} onToggle={toggle} />
</Animated.View>
<ProgressBar np={np} compact={!expanded} />
@@ -0,0 +1,32 @@
import { StyleSheet } from 'react-native';
import { installUpdate, updaterAvailable, useUpdateState } from '@/lib/updates';
import { Body, Button, Card, Heading } from './ui';
/** "Update ready" on the feed, once a newer version has downloaded (lib/updates.ts). */
export function UpdateBanner() {
const state = useUpdateState();
if (!updaterAvailable || (state.status !== 'ready' && state.status !== 'installing')) return null;
return (
<Card highlighted>
<Heading>musicshare {state.update.version} is ready</Heading>
{state.update.notes ? (
<Body muted style={styles.notes} numberOfLines={4}>
{state.update.notes}
</Body>
) : null}
<Button
small
variant="primary"
title={state.status === 'installing' ? 'Installing…' : 'Install update'}
disabled={state.status === 'installing'}
onPress={() => installUpdate(true)}
style={styles.button}
/>
</Card>
);
}
const styles = StyleSheet.create({
notes: { fontSize: 13 },
button: { alignSelf: 'flex-start' },
});
+133
View File
@@ -0,0 +1,133 @@
import { useSyncExternalStore } from 'react';
import { AppState } from 'react-native';
import { isNewerVersion, type AppUpdateDTO } from '@musicshare/shared';
import Updater, { type InstallStatus } from '@/modules/updater';
import { api } from './api';
import { FOSS_BUILD } from './build';
/**
* Self-updating for the APKs we publish (not F-Droid builds, which F-Droid updates, nor dev builds).
*
* When the app opens (at most every few hours) it asks the server for the newest release and, if
* it's newer, downloads it in the background and checks its SHA-256. Then:
* - leaving the app tries a quiet install, once per version. Android 12+ allows that once this app has
* installed an update itself (and "install unknown apps" is allowed for it); the new version runs next
* time it's opened. On phones with Google Play, Play Protect may still ask to scan a new version.
* - otherwise the feed shows "Update ready", and tapping it opens Android's install screen.
*/
export const updaterAvailable = Updater !== null && !FOSS_BUILD && !__DEV__;
export type UpdateState =
| { status: 'idle' | 'checking' | 'upToDate' }
| { status: 'downloading'; update: AppUpdateDTO; progress: number }
| { status: 'ready' | 'installing'; update: AppUpdateDTO; path: string }
| { status: 'error'; message: string; update?: AppUpdateDTO };
const CHECK_EVERY_MS = 6 * 3600_000;
let state: UpdateState = { status: 'idle' };
let lastCheck = 0;
/** Versions we've already tried to install on the way out; after that, only when asked (no repeat prompts). */
const autoTried = new Set<string>();
const listeners = new Set<() => void>();
function set(next: UpdateState) {
// Visible with `adb logcat -s ReactNativeJS`, for checking updates on a real phone.
if (next.status !== state.status) console.log(`[updates] ${next.status}${'message' in next ? `: ${next.message}` : ''}`);
state = next;
listeners.forEach((l) => l());
}
export const currentVersion = () => Updater?.currentVersion() ?? null;
/** Look for an update and download it. `force` skips the every-few-hours limit (the Settings button). */
export async function checkForUpdate({ force = false } = {}): Promise<void> {
if (!Updater || !updaterAvailable) return;
if (['checking', 'downloading', 'installing'].includes(state.status)) return;
if (state.status === 'ready') return; // already have it
if (!force && Date.now() - lastCheck < CHECK_EVERY_MS) return;
lastCheck = Date.now();
set({ status: 'checking' });
let update: AppUpdateDTO | null;
try {
update = await api.androidUpdate();
} catch {
set({ status: 'error', message: "Couldn't check for updates." });
return;
}
if (!update || !isNewerVersion(update.version, Updater.currentVersion())) {
Updater.clearDownloads(); // e.g. the update we downloaded has since been installed
set({ status: 'upToDate' });
return;
}
set({ status: 'downloading', update, progress: 0 });
const progress = Updater.addListener('onProgress', ({ received, total }) => {
if (state.status === 'downloading') set({ ...state, progress: total > 0 ? received / total : 0 });
});
try {
const path = await Updater.download(update.apkUrl, update.sha256, `musicshare-${update.version}.apk`);
set({ status: 'ready', update, path });
} catch (err) {
set({ status: 'error', message: err instanceof Error ? err.message : "Couldn't download the update.", update });
} finally {
progress.remove();
}
}
/** Install the downloaded update. `interactive` shows Android's confirmation screen if it needs one. */
export async function installUpdate(interactive: boolean): Promise<void> {
if (!Updater || state.status !== 'ready') return;
const ready = state;
set({ ...ready, status: 'installing' });
try {
await Updater.install(ready.path, interactive);
} catch (err) {
set({ status: 'error', message: err instanceof Error ? err.message : "Couldn't install the update.", update: ready.update });
lastCheck = 0; // e.g. the download went missing: fetch it again next time
}
}
function onInstallStatus(status: InstallStatus, message: string | null) {
if (state.status !== 'installing') return;
const { update, path } = state;
switch (status) {
case 'waiting':
return; // Android's screen is up; its answer comes next
case 'needs_user':
case 'cancelled':
return set({ status: 'ready', update, path });
case 'installed':
return set({ status: 'upToDate' });
case 'failed':
return set({ status: 'error', message: message ?? "Couldn't install the update.", update });
}
}
let started = false;
/** Call once at startup: checks now, again when the app comes back, and tries a quiet install on the way out. */
export function startUpdater() {
if (started || !Updater || !updaterAvailable) return;
started = true;
Updater.addListener('onInstallStatus', ({ status, message }) => onInstallStatus(status, message));
AppState.addEventListener('change', (appState) => {
if (appState === 'active') checkForUpdate().catch(() => {});
else if (appState === 'background' && state.status === 'ready' && !autoTried.has(state.update.version)) {
autoTried.add(state.update.version);
installUpdate(false).catch(() => {});
}
});
checkForUpdate().catch(() => {});
}
export function useUpdateState(): UpdateState {
return useSyncExternalStore(
(listener) => {
listeners.add(listener);
return () => listeners.delete(listener);
},
() => state,
);
}