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