import { unwrap, type AuthSession } from '@pipu/core'; import type { components } from '@pipu/api-client'; import Constants from 'expo-constants'; import * as FileSystem from 'expo-file-system'; import * as IntentLauncher from 'expo-intent-launcher'; import * as SecureStore from 'expo-secure-store'; import { sha256 } from 'js-sha256'; import { releaseNeed } from './releaseNeed'; export type AndroidRelease = components['schemas']['AndroidRelease']; /** Состояние самообновления (docs/mobile.md#самообновление). */ export type UpdateState = | { status: 'idle' } | { status: 'checking' } | { status: 'latest' } | { status: 'available'; release: AndroidRelease; required: boolean; dismissed: boolean; } | { status: 'downloading'; release: AndroidRelease; required: boolean; progress: number; } | { status: 'verifying'; release: AndroidRelease; required: boolean } | { status: 'ready'; release: AndroidRelease; required: boolean; file: string; } | { status: 'error'; release: AndroidRelease; required: boolean; error: 'bad_checksum' | 'failed'; }; const CHECK_EVERY_MS = 6 * 60 * 60 * 1000; const CHECKED_KEY = 'pipu.update.checked'; const HASH_CHUNK = 1 << 20; const APK_MIME = 'application/vnd.android.package-archive'; export function installedVersionCode(): number { return Number((Constants.expoConfig?.extra as { versionCode?: number } | undefined)?.versionCode ?? 0); } /** SHA-256 файла кусками по 1 МБ: целиком APK в память JS не читаем. */ async function fileSha256(uri: string, size: number): Promise { const hash = sha256.create(); for (let pos = 0; pos < size; pos += HASH_CHUNK) { const b64 = await FileSystem.readAsStringAsync(uri, { encoding: FileSystem.EncodingType.Base64, position: pos, length: Math.min(HASH_CHUNK, size - pos), }); const bin = atob(b64); const bytes = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); hash.update(bytes); } return hash.hex(); } class Updater { private current: UpdateState = { status: 'idle' }; private readonly listeners = new Set<() => void>(); private download: FileSystem.DownloadResumable | null = null; get state(): UpdateState { return this.current; } subscribe = (fn: () => void) => { this.listeners.add(fn); return () => this.listeners.delete(fn); }; private set(s: UpdateState) { this.current = s; for (const fn of this.listeners) fn(); } /** Проверка: автоматически — не чаще раза в 6 часов, вручную (профиль) — всегда. */ async check(session: AuthSession, manual = false): Promise { if (['checking', 'downloading', 'verifying'].includes(this.current.status)) return; if (!manual) { const last = Number((await SecureStore.getItemAsync(CHECKED_KEY)) ?? 0); if (Date.now() - last < CHECK_EVERY_MS) return; } if (manual) this.set({ status: 'checking' }); try { const release = unwrap(await session.client.GET('/api/v1/app/android/latest')); await SecureStore.setItemAsync(CHECKED_KEY, String(Date.now())); const installed = installedVersionCode(); const need = releaseNeed(installed, release.version_code, release.min_supported); if (need !== 'current') { this.set({ status: 'available', release, required: need === 'required', dismissed: false, }); } else { this.set(manual ? { status: 'latest' } : { status: 'idle' }); } } catch { // Нет релиза или сервер без Gitea — молча; при ручной проверке показываем «последняя версия». this.set(manual ? { status: 'latest' } : { status: 'idle' }); } } dismiss() { const s = this.current; if (s.status === 'available' && !s.required) this.set({ ...s, dismissed: true }); else if (s.status === 'latest') this.set({ status: 'idle' }); } /** Скачивает APK с сервера (с Bearer), сверяет SHA-256 и запускает установщик. */ async downloadAndInstall(session: AuthSession, server: string): Promise { const s = this.current; if (s.status !== 'available' && s.status !== 'error' && s.status !== 'ready') return; const { release, required } = s; if (s.status === 'ready') return this.install(s.file); // Любой запрос через client обновит access-токен, если он истёк. await session.client.GET('/api/v1/app/android/latest').catch(() => undefined); const file = `${FileSystem.cacheDirectory}pipu-music-${release.version}.apk`; this.set({ status: 'downloading', release, required, progress: 0 }); try { await FileSystem.deleteAsync(file, { idempotent: true }); this.download = FileSystem.createDownloadResumable( server + release.download_url, file, { headers: { Authorization: `Bearer ${session.accessToken ?? ''}` } }, (p) => { const total = p.totalBytesExpectedToWrite > 0 ? p.totalBytesExpectedToWrite : release.size; this.set({ status: 'downloading', release, required, progress: total ? p.totalBytesWritten / total : 0, }); }, ); const res = await this.download.downloadAsync(); this.download = null; if (!res || res.status !== 200) throw new Error(`status ${res?.status}`); this.set({ status: 'verifying', release, required }); const info = await FileSystem.getInfoAsync(file); const size = info.exists ? info.size : 0; if ((await fileSha256(file, size)) !== release.sha256.toLowerCase()) { await FileSystem.deleteAsync(file, { idempotent: true }); this.set({ status: 'error', release, required, error: 'bad_checksum' }); return; } this.set({ status: 'ready', release, required, file }); await this.install(file); } catch { this.download = null; this.set({ status: 'error', release, required, error: 'failed' }); } } private async install(file: string) { const uri = await FileSystem.getContentUriAsync(file); await IntentLauncher.startActivityAsync('android.intent.action.VIEW', { data: uri, type: APK_MIME, flags: 1, // FLAG_GRANT_READ_URI_PERMISSION }); } /** Экран «Установка неизвестных приложений» для нашего пакета. */ async openInstallSettings() { const pkg = Constants.expoConfig?.android?.package ?? 'app.pipu.music'; await IntentLauncher.startActivityAsync('android.settings.MANAGE_UNKNOWN_APP_SOURCES', { data: `package:${pkg}` }); } } export const updater = new Updater();