- 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>
134 lines
5.4 KiB
TypeScript
134 lines
5.4 KiB
TypeScript
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,
|
|
);
|
|
}
|