Keine neue Version: Meldung mit installierter Version, neuester Version auf dem Server und ob Update-Server und Download-Weg erreichbar sind (HEAD auf /update/latest.apk). Keine Verbindung: Meldung mit Server-Adresse, Zustand und letztem Verbindungsfehler. Keine Antwort in 10 s: Hinweis auf alten RVS. Neue Version: wie bisher der Update-Dialog (nur einmal, nicht doppelt ueber den ChatScreen-Listener). RVS antwortet auf update_check jetzt auch ohne neue Version (update_none mit latest/current). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
408 lines
16 KiB
TypeScript
408 lines
16 KiB
TypeScript
/**
|
|
* 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<string[]> {
|
|
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<void> {
|
|
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<UpdateProgress>): 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<boolean> {
|
|
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<void>(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<void> {
|
|
this.setProgress({ status: 'Lade herunter (ohne Fortschritt) …', fraction: -1 });
|
|
rvs.send('update_download' as any, {});
|
|
const base64 = await new Promise<string>((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<void> {
|
|
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;
|