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(); 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 { 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 { 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, ); }