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
@@ -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' },
});