/** * Auto-Update Service — prueft und installiert App-Updates via RVS * * Flow: * 1. App sendet "update_check" mit aktueller Version an RVS * 2. RVS vergleicht → sendet "update_available" mit Download-URL * 3. App zeigt Benachrichtigung → User bestaetigt → Download + Install */ import { Alert, Linking, Platform, NativeModules } from 'react-native'; import RNFS from 'react-native-fs'; import rvs, { RVSMessage } from './rvs'; // Version aus package.json (wird beim Build eingebettet) const packageJson = require('../../package.json'); const APP_VERSION = packageJson.version || '0.0.0.0'; type UpdateCallback = (info: UpdateInfo) => void; /** Fortschritt fuer die Anzeige (UpdateProgress-Komponente) */ export interface UpdateProgress { active: boolean; version: string; /** 0..1, -1 = unbekannt */ fraction: number; receivedMB: number; totalMB: number; /** z.B. "Lade herunter …", "Neuer Versuch (2/3) …", "Installiere …" */ status: string; } type ProgressCallback = (p: UpdateProgress) => void; const HTTP_ATTEMPTS = 3; // Abbruch nur, wenn der Download HAENGT (so lange kommt kein Byte) — nicht // nach einer festen Gesamtzeit wie frueher (60 s fuer 65 MB war zu knapp). const READ_STALL_MS = 30_000; const WS_TIMEOUT_MS = 5 * 60_000; export interface UpdateInfo { version: string; downloadUrl: string; size: number; } class UpdateService { private listeners: UpdateCallback[] = []; private checking = false; private downloading = false; private progressListeners: ProgressCallback[] = []; private progress: UpdateProgress = { active: false, version: '', fraction: -1, receivedMB: 0, totalMB: 0, status: '' }; private jobId: number | null = null; /** Laeuft gerade eine Pruefung per Knopf? Dann geht die Antwort NUR dorthin * (sonst zeigt der ChatScreen-Listener denselben Dialog ein zweites Mal). */ private manualWaiter: ((r: { type: 'available'; info: UpdateInfo } | { type: 'none'; latest: string }) => void) | null = null; private cancelled = false; constructor() { // Beim Start alte APK-Reste aus dem Cache wegraeumen — wenn diese App // laeuft, sind frueher heruntergeladene APKs entweder schon installiert // oder unvollstaendig gewesen. Spart sonst pro Update 20-30MB auf dem Handy. this.cleanupOldApks().catch(() => {}); // Auf update_available Nachrichten lauschen rvs.onMessage((msg: RVSMessage) => { if (msg.type === 'update_available' as any) { const info: UpdateInfo = { version: (msg.payload.version as string) || '', downloadUrl: (msg.payload.downloadUrl as string) || '', size: (msg.payload.size as number) || 0, }; if (this.manualWaiter) { const w = this.manualWaiter; this.manualWaiter = null; if (info.version && this.isNewer(info.version)) w({ type: 'available', info }); else w({ type: 'none', latest: info.version }); return; } if (info.version && this.isNewer(info.version)) { console.log(`[Update] Neue Version verfuegbar: ${info.version} (aktuell: ${APP_VERSION})`); this.listeners.forEach(cb => cb(info)); } } else if ((msg.type as string) === 'update_none' && this.manualWaiter) { const w = this.manualWaiter; this.manualWaiter = null; w({ type: 'none', latest: String(msg.payload.latest || '') }); } }); } /** Sucht ueberall wo .apk-Dateien herumliegen koennten. */ private async _apkSearchDirs(): Promise { const dirs = [RNFS.CachesDirectoryPath, RNFS.DocumentDirectoryPath]; if ((RNFS as any).ExternalCachesDirectoryPath) { dirs.push((RNFS as any).ExternalCachesDirectoryPath); } if (RNFS.ExternalDirectoryPath) { dirs.push(RNFS.ExternalDirectoryPath); } return dirs; } /** Raeumt alte heruntergeladene APK-Dateien aus den App-Verzeichnissen auf. * Public damit Settings den Button "Update-Cache leeren" benutzen kann. */ async cleanupOldApks(keepCurrentName?: string): Promise<{ removed: number; freedMB: number }> { const dirs = await this._apkSearchDirs(); let removed = 0; let freed = 0; for (const dir of dirs) { try { if (!(await RNFS.exists(dir))) continue; const files = await RNFS.readDir(dir); const apks = files.filter(f => /\.apk$/i.test(f.name)); for (const f of apks) { if (keepCurrentName && f.name === keepCurrentName) continue; try { const size = parseInt(f.size as any, 10) || 0; await RNFS.unlink(f.path); removed += 1; freed += size; console.log(`[Update] APK geloescht: ${f.path} (${(size / 1024 / 1024).toFixed(1)}MB)`); } catch (err: any) { console.warn(`[Update] APK-Loeschen fehlgeschlagen: ${f.path} (${err?.message || err})`); } } } catch (err: any) { console.warn(`[Update] Cleanup-Fehler in ${dir}: ${err?.message || err}`); } } const freedMB = freed / 1024 / 1024; if (removed > 0) { console.log(`[Update] Cleanup fertig: ${removed} APK${removed === 1 ? '' : 's'} entfernt, ${freedMB.toFixed(1)}MB freigegeben`); } return { removed, freedMB }; } /** Aktuelle Groesse aller APK-Dateien in den App-Verzeichnissen (in MB). */ async getApkCacheSize(): Promise<{ count: number; totalMB: number }> { const dirs = await this._apkSearchDirs(); let count = 0; let total = 0; for (const dir of dirs) { try { if (!(await RNFS.exists(dir))) continue; const files = await RNFS.readDir(dir); for (const f of files) { if (!f.isFile() || !/\.apk$/i.test(f.name)) continue; count += 1; total += parseInt(f.size as any, 10) || 0; } } catch {} } return { count, totalMB: total / 1024 / 1024 }; } /** Bei App-Start Update pruefen */ checkForUpdate(): void { if (this.checking) return; this.checking = true; console.log(`[Update] Pruefe auf Updates (aktuell: ${APP_VERSION})`); rvs.send('update_check' as any, { version: APP_VERSION }); setTimeout(() => { this.checking = false; }, 10000); } /** * Pruefung per Knopf (Einstellungen → Ueber): sagt IMMER etwas — * neue Version (Update-Dialog), "keine neue Version" mit der Version auf dem * Server, oder warum der Update-Server nicht erreichbar ist (mit Details). */ async checkForUpdateManual(): Promise { const cfg = rvs.getConfig(); const where = cfg ? `${cfg.useTLS ? 'wss' : 'ws'}://${cfg.host}:${cfg.port}` : '(nicht eingerichtet)'; if (!cfg) { Alert.alert('Update-Server nicht erreichbar', 'Es ist noch kein Server eingerichtet (Einstellungen → Verbindung).'); return; } if (!rvs.isSocketOpen()) { const lastErr = [...rvs.getConnectionLog()].reverse().find(e => e.level === 'error'); Alert.alert('Update-Server nicht erreichbar', `Keine Verbindung zu ${where}.\n\nZustand: ${rvs.getState()}` + (lastErr ? `\nLetzter Fehler (${new Date(lastErr.timestamp).toLocaleTimeString()}):\n${lastErr.message}` : '')); return; } const result = await new Promise<{ type: 'available'; info: UpdateInfo } | { type: 'none'; latest: string } | { type: 'timeout' }>(resolve => { const t = setTimeout(() => { this.manualWaiter = null; resolve({ type: 'timeout' }); }, 10_000); this.manualWaiter = (r) => { clearTimeout(t); resolve(r); }; rvs.send('update_check' as any, { version: APP_VERSION }); }); if (result.type === 'available') { this.promptUpdate(result.info); return; } if (result.type === 'timeout') { Alert.alert('Update-Server antwortet nicht', `Verbunden mit ${where}, aber nach 10 s keine Antwort auf die Update-Anfrage.\n\n` + 'Moeglicherweise laeuft auf dem Server eine alte RVS-Version.'); return; } // Keine neue Version — zusaetzlich pruefen, ob der Download-Weg funktioniert let dl = ''; try { const ctrl = new AbortController(); const t = setTimeout(() => ctrl.abort(), 10_000); const r = await fetch(`${cfg.useTLS ? 'https' : 'http'}://${cfg.host}:${cfg.port}/update/latest.apk`, { method: 'HEAD', headers: { 'X-RVS-Token': cfg.token }, signal: ctrl.signal }); clearTimeout(t); dl = r.ok ? 'Download-Weg: erreichbar ✓' : r.status === 404 && result.latest ? 'Download-Weg: vom Server noch nicht unterstuetzt (alter RVS)' : r.status === 404 ? 'Download-Weg: erreichbar, aber auf dem Server liegt keine APK' : `Download-Weg: Fehler HTTP ${r.status}`; } catch (e: any) { dl = `Download-Weg: nicht erreichbar (${e?.name === 'AbortError' ? 'Zeitueberschreitung' : (e?.message || e)})`; } Alert.alert('Keine neue Version', `Installiert: ${APP_VERSION}\n` + `Neueste auf dem Server: ${result.latest || '(keine APK hinterlegt)'}\n\n` + `Update-Server: ${where} ✓\n${dl}`); } /** Callback registrieren */ onUpdateAvailable(callback: UpdateCallback): () => void { this.listeners.push(callback); return () => { this.listeners = this.listeners.filter(cb => cb !== callback); }; } /** Update-Dialog anzeigen */ promptUpdate(info: UpdateInfo): void { const sizeMB = (info.size / 1024 / 1024).toFixed(1); Alert.alert( 'ARIA Update verfuegbar', `Version ${info.version} (${sizeMB} MB)\n\nAktuell: ${APP_VERSION}\n\nJetzt herunterladen und installieren?`, [ { text: 'Spaeter', style: 'cancel' }, { text: 'Installieren', onPress: () => this.downloadAndInstall(info), }, ], ); } onProgress(callback: ProgressCallback): () => void { this.progressListeners.push(callback); callback(this.progress); return () => { this.progressListeners = this.progressListeners.filter(cb => cb !== callback); }; } private setProgress(p: Partial): void { this.progress = { ...this.progress, ...p }; this.progressListeners.forEach(cb => cb(this.progress)); } /** Laufenden Download abbrechen (Knopf in der Fortschrittsanzeige) */ cancelDownload(): void { this.cancelled = true; if (this.jobId !== null) { try { RNFS.stopDownload(this.jobId); } catch (_) {} } } /** * APK per HTTP vom RVS laden (/update/latest.apk, Token im Header). * Gibt false zurueck, wenn der RVS den Weg noch nicht kennt (alter RVS) — * dann nimmt downloadAndInstall den alten WebSocket-Weg. */ private async downloadViaHttp(info: UpdateInfo, destPath: string): Promise { const cfg = rvs.getConfig(); if (!cfg) return false; const url = `${cfg.useTLS ? 'https' : 'http'}://${cfg.host}:${cfg.port}/update/latest.apk`; let lastErr = ''; for (let attempt = 1; attempt <= HTTP_ATTEMPTS; attempt++) { if (this.cancelled) throw new Error('Abgebrochen'); this.setProgress({ status: attempt === 1 ? 'Lade herunter …' : `Neuer Versuch (${attempt}/${HTTP_ATTEMPTS}) …`, fraction: -1, receivedMB: 0 }); let expected = 0; try { const job = RNFS.downloadFile({ fromUrl: url, toFile: destPath, headers: { 'X-RVS-Token': cfg.token }, connectionTimeout: 15_000, readTimeout: READ_STALL_MS, progressInterval: 300, begin: (r) => { expected = r.contentLength > 0 ? r.contentLength : 0; }, progress: (r) => { const total = r.contentLength > 0 ? r.contentLength : info.size; this.setProgress({ fraction: total > 0 ? Math.min(1, r.bytesWritten / total) : -1, receivedMB: r.bytesWritten / 1048576, totalMB: total / 1048576, }); }, }); this.jobId = job.jobId; const res = await job.promise; this.jobId = null; if (res.statusCode === 403 || res.statusCode === 404) { // Alter RVS ohne HTTP-Download (404) bzw. Token unbekannt (403) console.warn(`[Update] HTTP-Download nicht verfuegbar (${res.statusCode}) — nehme WebSocket`); return false; } if (res.statusCode !== 200) throw new Error(`HTTP ${res.statusCode}`); const st = await RNFS.stat(destPath); if (expected && Number(st.size) !== expected) { throw new Error(`Datei unvollstaendig (${st.size} von ${expected} Bytes)`); } return true; } catch (err: any) { this.jobId = null; if (this.cancelled) throw new Error('Abgebrochen'); lastErr = err?.message || String(err); console.warn(`[Update] HTTP-Download Versuch ${attempt} fehlgeschlagen: ${lastErr}`); await new Promise(r => setTimeout(r, 2000 * attempt)); } } throw new Error(`Download fehlgeschlagen: ${lastErr}`); } /** Alter Weg: APK als ein grosser Block ueber die WebSocket-Verbindung */ private async downloadViaWebSocket(info: UpdateInfo, destPath: string): Promise { this.setProgress({ status: 'Lade herunter (ohne Fortschritt) …', fraction: -1 }); rvs.send('update_download' as any, {}); const base64 = await new Promise((resolve, reject) => { const timeout = setTimeout(() => { unsub(); reject(new Error('Download-Timeout (5 min)')); }, WS_TIMEOUT_MS); const unsub = rvs.onMessage((msg: RVSMessage) => { if ((msg.type as string) === 'update_data') { clearTimeout(timeout); unsub(); if (msg.payload.error) reject(new Error(msg.payload.error as string)); else resolve(msg.payload.base64 as string); } }); }); if (this.cancelled) throw new Error('Abgebrochen'); await RNFS.writeFile(destPath, base64, 'base64'); } /** APK herunterladen (HTTP mit Fortschritt, sonst WebSocket) und installieren */ async downloadAndInstall(info: UpdateInfo): Promise { if (this.downloading) return; this.downloading = true; this.cancelled = false; this.setProgress({ active: true, version: info.version, fraction: -1, receivedMB: 0, totalMB: info.size / 1048576, status: 'Starte …' }); const fileName = `ARIA-v${info.version}.apk`; const destPath = `${RNFS.CachesDirectoryPath}/${fileName}`; try { console.log(`[Update] Lade APK v${info.version} …`); // Vor dem Download alte APKs im Cache wegraeumen await this.cleanupOldApks(); const viaHttp = await this.downloadViaHttp(info, destPath); if (!viaHttp) await this.downloadViaWebSocket(info, destPath); const fileSize = await RNFS.stat(destPath); console.log(`[Update] APK gespeichert: ${destPath} (${(Number(fileSize.size) / 1024 / 1024).toFixed(1)}MB, ${viaHttp ? 'HTTP' : 'WebSocket'})`); this.setProgress({ fraction: 1, status: 'Installiere …' }); // APK installieren via natives ApkInstaller Module (FileProvider + Intent) if (Platform.OS === 'android') { try { const { ApkInstaller } = NativeModules; await ApkInstaller.install(destPath); } catch (installErr: any) { Alert.alert( 'APK heruntergeladen', `Version ${info.version} gespeichert.\n\nBitte manuell installieren:\nDateimanager → ${fileName} antippen.\n\n(${installErr.message})`, ); } } } catch (err: any) { console.error(`[Update] Fehler: ${err.message}`); try { await RNFS.unlink(destPath); } catch (_) {} if (!this.cancelled) Alert.alert('Update fehlgeschlagen', err.message); } finally { this.downloading = false; this.setProgress({ active: false }); } } /** Versionsvergleich */ private isNewer(remote: string): boolean { const r = remote.split('.').map(Number); const l = APP_VERSION.split('.').map(Number); for (let i = 0; i < Math.max(r.length, l.length); i++) { const diff = (r[i] || 0) - (l[i] || 0); if (diff > 0) return true; if (diff < 0) return false; } return false; } getCurrentVersion(): string { return APP_VERSION; } } const updateService = new UpdateService(); export default updateService;