Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
55b7a02b85 | ||
|
|
ae2024ef55 | ||
|
|
27f4fbdd12 | ||
|
|
0acf96860e | ||
|
|
c9988e7244 | ||
|
|
a00199c121 |
@@ -855,7 +855,9 @@ Satelliten verbinden dann nicht mehr). ACME-State persistent in `./data/caddy/`
|
|||||||
- WebSocket Relay (alle Message-Types: chat, audio, file, config, xtts, update, etc.)
|
- WebSocket Relay (alle Message-Types: chat, audio, file, config, xtts, update, etc.)
|
||||||
- OAuth-Callback HTTP: `GET /oauth/callback/{service}?code=...` → broadcastet als
|
- OAuth-Callback HTTP: `GET /oauth/callback/{service}?code=...` → broadcastet als
|
||||||
`oauth_callback`-WS-Message + zeigt dem Browser eine "OAuth erfolgreich"-Seite
|
`oauth_callback`-WS-Message + zeigt dem Browser eine "OAuth erfolgreich"-Seite
|
||||||
- Auto-Update: APK-Verteilung an Apps ueber WebSocket
|
- Auto-Update: App fragt per WebSocket nach der Version, laedt die APK per HTTP
|
||||||
|
(`/update/latest.apk`, nur mit gueltigem Raum-Token im Header `X-RVS-Token`,
|
||||||
|
mit Fortschrittsanzeige + Fortsetzen per Range). Alte Apps: Fallback ueber WebSocket
|
||||||
- Heartbeat + tote Verbindungen aufraeumen
|
- Heartbeat + tote Verbindungen aufraeumen
|
||||||
|
|
||||||
**Auto-Update APK bereitstellen:**
|
**Auto-Update APK bereitstellen:**
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import WorkspaceScreen from './src/workspace/WorkspaceScreen';
|
|||||||
import SettingsScreen from './src/screens/SettingsScreen';
|
import SettingsScreen from './src/screens/SettingsScreen';
|
||||||
import ViewModeToggle from './src/components/ViewModeToggle';
|
import ViewModeToggle from './src/components/ViewModeToggle';
|
||||||
import SecurityLock from './src/components/SecurityLock';
|
import SecurityLock from './src/components/SecurityLock';
|
||||||
|
import UpdateProgress from './src/components/UpdateProgress';
|
||||||
import rvs from './src/services/rvs';
|
import rvs from './src/services/rvs';
|
||||||
import { initLogger, installGlobalCrashReporter } from './src/services/logger';
|
import { initLogger, installGlobalCrashReporter } from './src/services/logger';
|
||||||
import { acquireBackgroundAudio } from './src/services/backgroundAudio';
|
import { acquireBackgroundAudio } from './src/services/backgroundAudio';
|
||||||
@@ -193,6 +194,7 @@ const App: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
</Tab.Navigator>
|
</Tab.Navigator>
|
||||||
</NavigationContainer>
|
</NavigationContainer>
|
||||||
|
<UpdateProgress />
|
||||||
</GestureHandlerRootView>
|
</GestureHandlerRootView>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -79,8 +79,8 @@ android {
|
|||||||
applicationId "com.ariacockpit"
|
applicationId "com.ariacockpit"
|
||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
versionCode 20508
|
versionCode 20601
|
||||||
versionName "0.2.5.8"
|
versionName "0.2.6.1"
|
||||||
// Fallback fuer Libraries mit Product Flavors
|
// Fallback fuer Libraries mit Product Flavors
|
||||||
missingDimensionStrategy 'react-native-camera', 'general'
|
missingDimensionStrategy 'react-native-camera', 'general'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "aria-cockpit",
|
"name": "aria-cockpit",
|
||||||
"version": "0.2.5.8",
|
"version": "0.2.6.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"android": "react-native run-android",
|
"android": "react-native run-android",
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
/**
|
||||||
|
* Fortschrittsanzeige beim App-Update: Balken mit Prozent und MB, Knopf
|
||||||
|
* zum Abbrechen. Erscheint nur, waehrend ein Update laedt/installiert.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { Modal, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||||
|
import updateService, { UpdateProgress as Progress } from '../services/updater';
|
||||||
|
|
||||||
|
const UpdateProgress: React.FC = () => {
|
||||||
|
const [p, setP] = useState<Progress | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => updateService.onProgress(setP), []);
|
||||||
|
|
||||||
|
if (!p || !p.active) return null;
|
||||||
|
const known = p.fraction >= 0;
|
||||||
|
const pct = known ? Math.round(p.fraction * 100) : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal visible transparent animationType="fade" onRequestClose={() => {}}>
|
||||||
|
<View style={styles.overlay}>
|
||||||
|
<View style={styles.box}>
|
||||||
|
<Text style={styles.title}>Update auf Version {p.version}</Text>
|
||||||
|
<Text style={styles.status}>{p.status}</Text>
|
||||||
|
<View style={styles.track}>
|
||||||
|
<View style={[styles.bar, { width: known ? `${pct}%` : '100%' }, !known && styles.barUnknown]} />
|
||||||
|
</View>
|
||||||
|
<Text style={styles.numbers}>
|
||||||
|
{known ? `${pct} % · ` : ''}
|
||||||
|
{p.receivedMB > 0 ? `${p.receivedMB.toFixed(1)} von ${p.totalMB.toFixed(1)} MB` : `${p.totalMB.toFixed(1)} MB`}
|
||||||
|
</Text>
|
||||||
|
{p.status !== 'Installiere …' && (
|
||||||
|
<TouchableOpacity style={styles.btn} onPress={() => updateService.cancelDownload()}>
|
||||||
|
<Text style={styles.btnText}>Abbrechen</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
overlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.6)', justifyContent: 'center', padding: 24 },
|
||||||
|
box: { backgroundColor: '#1A1A2E', borderRadius: 14, padding: 20 },
|
||||||
|
title: { color: '#FFFFFF', fontSize: 17, fontWeight: '700' },
|
||||||
|
status: { color: '#C8C8D8', fontSize: 14, marginTop: 6 },
|
||||||
|
track: { height: 10, borderRadius: 5, backgroundColor: '#2A2A3E', marginTop: 14, overflow: 'hidden' },
|
||||||
|
bar: { height: 10, borderRadius: 5, backgroundColor: '#0096FF' },
|
||||||
|
barUnknown: { opacity: 0.35 },
|
||||||
|
numbers: { color: '#9090A8', fontSize: 13, marginTop: 8 },
|
||||||
|
btn: { alignSelf: 'flex-end', marginTop: 14, paddingVertical: 8, paddingHorizontal: 16,
|
||||||
|
borderRadius: 8, backgroundColor: '#2A2A3E' },
|
||||||
|
btnText: { color: '#FFFFFF', fontWeight: '600' },
|
||||||
|
});
|
||||||
|
|
||||||
|
export default UpdateProgress;
|
||||||
@@ -2539,8 +2539,7 @@ const SettingsScreen: React.FC = () => {
|
|||||||
style={[styles.connectButton, {marginTop: 12}]}
|
style={[styles.connectButton, {marginTop: 12}]}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
const updateService = require('../services/updater').default;
|
const updateService = require('../services/updater').default;
|
||||||
updateService.checkForUpdate();
|
updateService.checkForUpdateManual();
|
||||||
Alert.alert('Update-Check', 'Pruefe auf neue Version...');
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text style={styles.connectButtonText}>Auf Updates pr{'\u00FC'}fen</Text>
|
<Text style={styles.connectButtonText}>Auf Updates pr{'\u00FC'}fen</Text>
|
||||||
|
|||||||
+209
-34
@@ -17,6 +17,26 @@ const APP_VERSION = packageJson.version || '0.0.0.0';
|
|||||||
|
|
||||||
type UpdateCallback = (info: UpdateInfo) => void;
|
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 {
|
export interface UpdateInfo {
|
||||||
version: string;
|
version: string;
|
||||||
downloadUrl: string;
|
downloadUrl: string;
|
||||||
@@ -27,6 +47,13 @@ class UpdateService {
|
|||||||
private listeners: UpdateCallback[] = [];
|
private listeners: UpdateCallback[] = [];
|
||||||
private checking = false;
|
private checking = false;
|
||||||
private downloading = 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() {
|
constructor() {
|
||||||
// Beim Start alte APK-Reste aus dem Cache wegraeumen — wenn diese App
|
// Beim Start alte APK-Reste aus dem Cache wegraeumen — wenn diese App
|
||||||
@@ -42,10 +69,19 @@ class UpdateService {
|
|||||||
downloadUrl: (msg.payload.downloadUrl as string) || '',
|
downloadUrl: (msg.payload.downloadUrl as string) || '',
|
||||||
size: (msg.payload.size as number) || 0,
|
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)) {
|
if (info.version && this.isNewer(info.version)) {
|
||||||
console.log(`[Update] Neue Version verfuegbar: ${info.version} (aktuell: ${APP_VERSION})`);
|
console.log(`[Update] Neue Version verfuegbar: ${info.version} (aktuell: ${APP_VERSION})`);
|
||||||
this.listeners.forEach(cb => cb(info));
|
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 || '') });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -126,6 +162,64 @@ class UpdateService {
|
|||||||
setTimeout(() => { this.checking = false; }, 10000);
|
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 */
|
/** Callback registrieren */
|
||||||
onUpdateAvailable(callback: UpdateCallback): () => void {
|
onUpdateAvailable(callback: UpdateCallback): () => void {
|
||||||
this.listeners.push(callback);
|
this.listeners.push(callback);
|
||||||
@@ -150,46 +244,125 @@ class UpdateService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** APK ueber WebSocket herunterladen und installieren */
|
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> {
|
async downloadAndInstall(info: UpdateInfo): Promise<void> {
|
||||||
if (this.downloading) return;
|
if (this.downloading) return;
|
||||||
this.downloading = true;
|
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 {
|
try {
|
||||||
console.log(`[Update] Fordere APK v${info.version} an...`);
|
console.log(`[Update] Lade APK v${info.version} …`);
|
||||||
Alert.alert('Download gestartet', `Version ${info.version} wird ueber RVS heruntergeladen...`);
|
// Vor dem Download alte APKs im Cache wegraeumen
|
||||||
|
|
||||||
// APK ueber WebSocket anfordern
|
|
||||||
rvs.send('update_download' as any, {});
|
|
||||||
|
|
||||||
// Auf update_data warten (einmalig)
|
|
||||||
const apkData = await new Promise<{base64: string, fileName: string}>((resolve, reject) => {
|
|
||||||
const timeout = setTimeout(() => reject(new Error('Download-Timeout (60s)')), 60000);
|
|
||||||
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({
|
|
||||||
base64: msg.payload.base64 as string,
|
|
||||||
fileName: msg.payload.fileName as string || `ARIA-${info.version}.apk`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Vor dem Schreiben alte APKs im Cache wegraeumen — falls mehrere
|
|
||||||
// Updates in einer Session gezogen werden
|
|
||||||
await this.cleanupOldApks();
|
await this.cleanupOldApks();
|
||||||
|
|
||||||
// Base64 als APK-Datei speichern
|
const viaHttp = await this.downloadViaHttp(info, destPath);
|
||||||
const destPath = `${RNFS.CachesDirectoryPath}/${apkData.fileName}`;
|
if (!viaHttp) await this.downloadViaWebSocket(info, destPath);
|
||||||
await RNFS.writeFile(destPath, apkData.base64, 'base64');
|
|
||||||
const fileSize = await RNFS.stat(destPath);
|
const fileSize = await RNFS.stat(destPath);
|
||||||
console.log(`[Update] APK gespeichert: ${destPath} (${(Number(fileSize.size) / 1024 / 1024).toFixed(1)}MB)`);
|
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)
|
// APK installieren via natives ApkInstaller Module (FileProvider + Intent)
|
||||||
if (Platform.OS === 'android') {
|
if (Platform.OS === 'android') {
|
||||||
@@ -199,15 +372,17 @@ class UpdateService {
|
|||||||
} catch (installErr: any) {
|
} catch (installErr: any) {
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
'APK heruntergeladen',
|
'APK heruntergeladen',
|
||||||
`Version ${info.version} gespeichert.\n\nBitte manuell installieren:\nDateimanager → ${apkData.fileName} antippen.\n\n(${installErr.message})`,
|
`Version ${info.version} gespeichert.\n\nBitte manuell installieren:\nDateimanager → ${fileName} antippen.\n\n(${installErr.message})`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error(`[Update] Fehler: ${err.message}`);
|
console.error(`[Update] Fehler: ${err.message}`);
|
||||||
Alert.alert('Update fehlgeschlagen', err.message);
|
try { await RNFS.unlink(destPath); } catch (_) {}
|
||||||
|
if (!this.cancelled) Alert.alert('Update fehlgeschlagen', err.message);
|
||||||
} finally {
|
} finally {
|
||||||
this.downloading = false;
|
this.downloading = false;
|
||||||
|
this.setProgress({ active: false });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -268,3 +268,19 @@ einer `.env`; das Kennwort wird interaktiv abgefragt. **Binaries landen unter
|
|||||||
> und `ui_tap`/`ui_text`/`ui_swipe`/`ui_key`/`app_launch` (steuern);
|
> und `ui_tap`/`ui_text`/`ui_swipe`/`ui_key`/`app_launch` (steuern);
|
||||||
> `release_agent.sh` vorhanden. Damit läuft der volle Ablauf sehen→steuern
|
> `release_agent.sh` vorhanden. Damit läuft der volle Ablauf sehen→steuern
|
||||||
> (z.B. E-Mail-Konto einrichten). Nächstes: Feinschliff (M4).
|
> (z.B. E-Mail-Konto einrichten). Nächstes: Feinschliff (M4).
|
||||||
|
|
||||||
|
## Auto-Update
|
||||||
|
|
||||||
|
Der Agent fragt beim Verbinden (und danach alle ~6 h) den RVS nach einer neueren
|
||||||
|
Version (`agent_update_check`). Liegt auf dem RVS eine neuere APK in
|
||||||
|
`rvs/updates/agent/`, lädt er sie im Hintergrund per HTTP
|
||||||
|
(`/update/agent/latest.apk`, Token im Header) und zeigt
|
||||||
|
**„ARIA Host-Agent x.y bereit — Tippen zum Installieren"** — zusätzlich einen
|
||||||
|
Knopf in der App. Den Install-Dialog zeigt Android selbst; still installieren
|
||||||
|
darf eine normale App nicht. Beim ersten Mal fragt Android, ob die App
|
||||||
|
„unbekannte Apps installieren" darf.
|
||||||
|
|
||||||
|
`release_agent.sh` kopiert die APK automatisch auf den RVS, wenn `RVS_UPDATE_HOST`
|
||||||
|
gesetzt ist (wie bei `release.sh`). Manuell:
|
||||||
|
`scp aria-android-agent.apk <rvs-host>:~/ARIA-AGENT/rvs/updates/agent/aria-host-agent-v<version>.apk`
|
||||||
|
— die Version muss im Dateinamen stehen.
|
||||||
|
|||||||
@@ -9,6 +9,8 @@
|
|||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
<uses-permission android:name="android.permission.CAMERA" />
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
||||||
|
<!-- Auto-Update: neue Agent-APK vom RVS installieren (Android fragt trotzdem nach) -->
|
||||||
|
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||||
<!-- app_launch: Start-Intents/App-Labels sind ab Android 11 sonst unsichtbar. -->
|
<!-- app_launch: Start-Intents/App-Labels sind ab Android 11 sonst unsichtbar. -->
|
||||||
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES" />
|
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES" />
|
||||||
<!-- Overlay: ARIA blendet Text/Bild UEBER das laufende Programm ein (Fire TV).
|
<!-- Overlay: ARIA blendet Text/Bild UEBER das laufende Programm ein (Fire TV).
|
||||||
@@ -60,6 +62,17 @@
|
|||||||
android:taskAffinity=""
|
android:taskAffinity=""
|
||||||
android:theme="@android:style/Theme.Translucent.NoTitleBar" />
|
android:theme="@android:style/Theme.Translucent.NoTitleBar" />
|
||||||
|
|
||||||
|
<!-- Auto-Update: geladene APK an den Android-Installer uebergeben -->
|
||||||
|
<provider
|
||||||
|
android:name="androidx.core.content.FileProvider"
|
||||||
|
android:authorities="${applicationId}.fileprovider"
|
||||||
|
android:exported="false"
|
||||||
|
android:grantUriPermissions="true">
|
||||||
|
<meta-data
|
||||||
|
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||||
|
android:resource="@xml/file_paths" />
|
||||||
|
</provider>
|
||||||
|
|
||||||
<service
|
<service
|
||||||
android:name=".AgentService"
|
android:name=".AgentService"
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
|
|||||||
@@ -32,6 +32,11 @@ class AgentService : Service() {
|
|||||||
@Volatile var status: String = "gestoppt"
|
@Volatile var status: String = "gestoppt"
|
||||||
@Volatile var connected: Boolean = false
|
@Volatile var connected: Boolean = false
|
||||||
|
|
||||||
|
/** App-Oberflaeche auffrischen (z.B. "Update bereit"-Knopf) */
|
||||||
|
fun broadcastStatus(ctx: Context) {
|
||||||
|
ctx.sendBroadcast(Intent(ACTION_STATUS).setPackage(ctx.packageName))
|
||||||
|
}
|
||||||
|
|
||||||
fun start(ctx: Context) {
|
fun start(ctx: Context) {
|
||||||
val i = Intent(ctx, AgentService::class.java)
|
val i = Intent(ctx, AgentService::class.java)
|
||||||
if (Build.VERSION.SDK_INT >= 26) ctx.startForegroundService(i) else ctx.startService(i)
|
if (Build.VERSION.SDK_INT >= 26) ctx.startForegroundService(i) else ctx.startService(i)
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
package de.hackersoft.ariaagent
|
||||||
|
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
|
import android.provider.Settings
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.core.content.FileProvider
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import java.io.File
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-Update fuer den Host-Agent (Android / Fire TV).
|
||||||
|
*
|
||||||
|
* 1. RvsClient fragt beim Verbinden (und alle paar Stunden) per
|
||||||
|
* agent_update_check nach; der RVS antwortet mit agent_update_available.
|
||||||
|
* 2. Wir laden die APK im Hintergrund per HTTP vom RVS
|
||||||
|
* (/update/agent/latest.apk, Token im Header) — sie ist nur ein paar MB.
|
||||||
|
* 3. Dann eine Benachrichtigung "Tippen zum Installieren" (und ein Knopf in
|
||||||
|
* der App). Den Install-Dialog zeigt Android selbst — still installieren
|
||||||
|
* darf eine normale App nicht, ein Tippen braucht es immer.
|
||||||
|
*/
|
||||||
|
object AgentUpdater {
|
||||||
|
private const val CH = "aria_agent_update"
|
||||||
|
private const val NOTIF_ID = 4711
|
||||||
|
private const val DIR = "updates"
|
||||||
|
|
||||||
|
/** Fertig geladene, noch nicht installierte Version ("" = keine) */
|
||||||
|
@Volatile var readyVersion: String = ""
|
||||||
|
private set
|
||||||
|
@Volatile private var downloading = false
|
||||||
|
|
||||||
|
private val http = OkHttpClient.Builder()
|
||||||
|
.connectTimeout(15, TimeUnit.SECONDS)
|
||||||
|
.readTimeout(30, TimeUnit.SECONDS) // Abbruch nur, wenn der Download haengt
|
||||||
|
.build()
|
||||||
|
|
||||||
|
fun installedVersion(ctx: Context): String = try {
|
||||||
|
ctx.packageManager.getPackageInfo(ctx.packageName, 0).versionName ?: "0"
|
||||||
|
} catch (_: Exception) { "0" }
|
||||||
|
|
||||||
|
/** a > b ? (Versionen wie 0.0.1.6) */
|
||||||
|
fun isNewer(a: String, b: String): Boolean {
|
||||||
|
val pa = a.split('.').map { it.toIntOrNull() ?: 0 }
|
||||||
|
val pb = b.split('.').map { it.toIntOrNull() ?: 0 }
|
||||||
|
for (i in 0 until maxOf(pa.size, pb.size)) {
|
||||||
|
val d = pa.getOrElse(i) { 0 } - pb.getOrElse(i) { 0 }
|
||||||
|
if (d != 0) return d > 0
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun apkFile(ctx: Context, version: String) =
|
||||||
|
File(File(ctx.cacheDir, DIR).apply { mkdirs() }, "aria-host-agent-v$version.apk")
|
||||||
|
|
||||||
|
/** RVS meldet eine neue Version → im Hintergrund laden, dann melden. */
|
||||||
|
fun onAvailable(ctx: Context, config: AgentConfig, version: String, size: Long) {
|
||||||
|
val app = ctx.applicationContext
|
||||||
|
if (!isNewer(version, installedVersion(app))) return
|
||||||
|
val file = apkFile(app, version)
|
||||||
|
if (readyVersion == version && file.exists()) { notifyReady(app, version); return }
|
||||||
|
if (downloading) return
|
||||||
|
downloading = true
|
||||||
|
Thread {
|
||||||
|
try {
|
||||||
|
// Reste frueherer Versionen wegraeumen
|
||||||
|
File(app.cacheDir, DIR).listFiles()?.forEach { if (it != file) it.delete() }
|
||||||
|
val proto = if (config.tls) "https" else "http"
|
||||||
|
val req = Request.Builder()
|
||||||
|
.url("$proto://${config.host}:${config.port}/update/agent/latest.apk")
|
||||||
|
.header("X-RVS-Token", config.token)
|
||||||
|
.build()
|
||||||
|
var lastErr = ""
|
||||||
|
for (attempt in 1..3) {
|
||||||
|
try {
|
||||||
|
http.newCall(req).execute().use { res ->
|
||||||
|
if (!res.isSuccessful) throw IllegalStateException("HTTP ${res.code}")
|
||||||
|
val body = res.body ?: throw IllegalStateException("leere Antwort")
|
||||||
|
val expected = body.contentLength()
|
||||||
|
val tmp = File(file.path + ".part")
|
||||||
|
body.byteStream().use { input -> tmp.outputStream().use { input.copyTo(it) } }
|
||||||
|
if (expected > 0 && tmp.length() != expected) {
|
||||||
|
throw IllegalStateException("unvollstaendig (${tmp.length()} von $expected)")
|
||||||
|
}
|
||||||
|
if (!tmp.renameTo(file)) throw IllegalStateException("Speichern fehlgeschlagen")
|
||||||
|
}
|
||||||
|
readyVersion = version
|
||||||
|
notifyReady(app, version)
|
||||||
|
AgentService.broadcastStatus(app)
|
||||||
|
return@Thread
|
||||||
|
} catch (e: Exception) {
|
||||||
|
lastErr = e.message ?: e.toString()
|
||||||
|
try { Thread.sleep(3000L * attempt) } catch (_: InterruptedException) { return@Thread }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
android.util.Log.w("AgentUpdater", "Update $version nicht geladen: $lastErr")
|
||||||
|
} finally {
|
||||||
|
downloading = false
|
||||||
|
}
|
||||||
|
}.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Install-Dialog von Android oeffnen (aus Benachrichtigung oder App-Knopf). */
|
||||||
|
fun install(ctx: Context): Boolean {
|
||||||
|
val v = readyVersion
|
||||||
|
if (v.isEmpty()) return false
|
||||||
|
val file = apkFile(ctx, v)
|
||||||
|
if (!file.exists()) { readyVersion = ""; return false }
|
||||||
|
// Android 8+: "Unbekannte Apps installieren" muss fuer DIESE App erlaubt sein
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && !ctx.packageManager.canRequestPackageInstalls()) {
|
||||||
|
ctx.startActivity(Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
|
||||||
|
Uri.parse("package:${ctx.packageName}")).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
val uri = FileProvider.getUriForFile(ctx, "${ctx.packageName}.fileprovider", file)
|
||||||
|
ctx.startActivity(Intent(Intent.ACTION_VIEW)
|
||||||
|
.setDataAndType(uri, "application/vnd.android.package-archive")
|
||||||
|
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK))
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun notifyReady(ctx: Context, version: String) {
|
||||||
|
val nm = ctx.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
nm.createNotificationChannel(NotificationChannel(CH, "Updates", NotificationManager.IMPORTANCE_DEFAULT))
|
||||||
|
}
|
||||||
|
val open = PendingIntent.getActivity(ctx, 1,
|
||||||
|
Intent(ctx, MainActivity::class.java).setAction(MainActivity.ACTION_INSTALL_UPDATE)
|
||||||
|
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP),
|
||||||
|
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)
|
||||||
|
nm.notify(NOTIF_ID, NotificationCompat.Builder(ctx, CH)
|
||||||
|
.setSmallIcon(android.R.drawable.stat_sys_download_done)
|
||||||
|
.setContentTitle("ARIA Host-Agent $version bereit")
|
||||||
|
.setContentText("Tippen zum Installieren")
|
||||||
|
.setContentIntent(open)
|
||||||
|
.setAutoCancel(true)
|
||||||
|
.build())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,6 +26,11 @@ import org.json.JSONObject
|
|||||||
|
|
||||||
class MainActivity : AppCompatActivity() {
|
class MainActivity : AppCompatActivity() {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/** Aus der "Update bereit"-Benachrichtigung: gleich den Install-Dialog oeffnen */
|
||||||
|
const val ACTION_INSTALL_UPDATE = "de.hackersoft.ariaagent.INSTALL_UPDATE"
|
||||||
|
}
|
||||||
|
|
||||||
private lateinit var host: EditText
|
private lateinit var host: EditText
|
||||||
private lateinit var port: EditText
|
private lateinit var port: EditText
|
||||||
private lateinit var token: EditText
|
private lateinit var token: EditText
|
||||||
@@ -78,6 +83,10 @@ class MainActivity : AppCompatActivity() {
|
|||||||
tls = findViewById(R.id.tls)
|
tls = findViewById(R.id.tls)
|
||||||
control = findViewById(R.id.control)
|
control = findViewById(R.id.control)
|
||||||
statusView = findViewById(R.id.status)
|
statusView = findViewById(R.id.status)
|
||||||
|
findViewById<Button>(R.id.btnUpdate).setOnClickListener {
|
||||||
|
if (!AgentUpdater.install(this)) toast("Kein Update bereit")
|
||||||
|
}
|
||||||
|
if (intent?.action == ACTION_INSTALL_UPDATE) AgentUpdater.install(this)
|
||||||
|
|
||||||
findViewById<Button>(R.id.btnScan).setOnClickListener {
|
findViewById<Button>(R.id.btnScan).setOnClickListener {
|
||||||
if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA)
|
if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA)
|
||||||
@@ -209,13 +218,19 @@ class MainActivity : AppCompatActivity() {
|
|||||||
|
|
||||||
private fun refreshStatus() {
|
private fun refreshStatus() {
|
||||||
val dot = if (AgentService.connected) "🟢" else "🔴"
|
val dot = if (AgentService.connected) "🟢" else "🔴"
|
||||||
statusView.text = "Status: ${AgentService.status} $dot"
|
statusView.text = "Status: ${AgentService.status} $dot · v${AgentUpdater.installedVersion(this)}"
|
||||||
|
val ready = AgentUpdater.readyVersion
|
||||||
|
findViewById<Button>(R.id.btnUpdate).apply {
|
||||||
|
visibility = if (ready.isNotEmpty()) android.view.View.VISIBLE else android.view.View.GONE
|
||||||
|
text = "Update auf $ready installieren"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onNewIntent(intent: Intent?) {
|
override fun onNewIntent(intent: Intent?) {
|
||||||
super.onNewIntent(intent)
|
super.onNewIntent(intent)
|
||||||
if (intent == null) return
|
if (intent == null) return
|
||||||
setIntent(intent)
|
setIntent(intent)
|
||||||
|
if (intent.action == ACTION_INSTALL_UPDATE) { AgentUpdater.install(this); return }
|
||||||
applyProvisioning(intent)
|
applyProvisioning(intent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ class RvsClient(
|
|||||||
override fun onOpen(webSocket: WebSocket, response: Response) {
|
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||||
onStatus(true, "verbunden")
|
onStatus(true, "verbunden")
|
||||||
sendHello(webSocket)
|
sendHello(webSocket)
|
||||||
|
sendUpdateCheck(webSocket)
|
||||||
startPing(webSocket)
|
startPing(webSocket)
|
||||||
}
|
}
|
||||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||||
@@ -141,9 +142,17 @@ class RvsClient(
|
|||||||
localIp()?.let { p.put("ip", it) }
|
localIp()?.let { p.put("ip", it) }
|
||||||
p.put("caps", JSONArray(caps))
|
p.put("caps", JSONArray(caps))
|
||||||
p.put("control", config.controlEnabled)
|
p.put("control", config.controlEnabled)
|
||||||
|
p.put("version", AgentUpdater.installedVersion(appCtx))
|
||||||
send(webSocket, "host_hello", p)
|
send(webSocket, "host_hello", p)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Beim RVS nach einer neueren Agent-Version fragen (Antwort: agent_update_available) */
|
||||||
|
private fun sendUpdateCheck(webSocket: WebSocket) {
|
||||||
|
send(webSocket, "agent_update_check", JSONObject()
|
||||||
|
.put("version", AgentUpdater.installedVersion(appCtx))
|
||||||
|
.put("platform", "android"))
|
||||||
|
}
|
||||||
|
|
||||||
/** Erste echte IPv4 dieses Geraets im LAN (ohne Loopback). */
|
/** Erste echte IPv4 dieses Geraets im LAN (ohne Loopback). */
|
||||||
private fun localIp(): String? = try {
|
private fun localIp(): String? = try {
|
||||||
java.net.NetworkInterface.getNetworkInterfaces().toList()
|
java.net.NetworkInterface.getNetworkInterfaces().toList()
|
||||||
@@ -153,6 +162,8 @@ class RvsClient(
|
|||||||
?.hostAddress
|
?.hostAddress
|
||||||
} catch (_: Throwable) { null }
|
} catch (_: Throwable) { null }
|
||||||
|
|
||||||
|
private var pingCount = 0
|
||||||
|
|
||||||
private fun startPing(webSocket: WebSocket) {
|
private fun startPing(webSocket: WebSocket) {
|
||||||
pingThread?.interrupt()
|
pingThread?.interrupt()
|
||||||
pingThread = Thread {
|
pingThread = Thread {
|
||||||
@@ -161,6 +172,8 @@ class RvsClient(
|
|||||||
val p = JSONObject().put("hostId", config.hostId())
|
val p = JSONObject().put("hostId", config.hostId())
|
||||||
send(webSocket, "host_ping", p)
|
send(webSocket, "host_ping", p)
|
||||||
sendHello(webSocket) // Re-announce (RVS replayt hellos nicht)
|
sendHello(webSocket) // Re-announce (RVS replayt hellos nicht)
|
||||||
|
// Alle ~6 h nach Updates fragen (25 s × 864)
|
||||||
|
if (++pingCount % 864 == 0) sendUpdateCheck(webSocket)
|
||||||
}
|
}
|
||||||
}.also { it.start() }
|
}.also { it.start() }
|
||||||
}
|
}
|
||||||
@@ -171,6 +184,11 @@ class RvsClient(
|
|||||||
// Sprachdialog: chat/audio_pcm/stt_* laufen ueber dieselbe Verbindung.
|
// Sprachdialog: chat/audio_pcm/stt_* laufen ueber dieselbe Verbindung.
|
||||||
// (Der RVS broadcastet alles im Raum — VoiceSession filtert auf den
|
// (Der RVS broadcastet alles im Raum — VoiceSession filtert auf den
|
||||||
// eigenen Turn, damit nicht auch Handy und Stick gleichzeitig antworten.)
|
// eigenen Turn, damit nicht auch Handy und Stick gleichzeitig antworten.)
|
||||||
|
if (type == "agent_update_available") {
|
||||||
|
val p = msg.optJSONObject("payload") ?: JSONObject()
|
||||||
|
AgentUpdater.onAvailable(appCtx, config, p.optString("version"), p.optLong("size"))
|
||||||
|
return
|
||||||
|
}
|
||||||
if (type != "host_command") {
|
if (type != "host_command") {
|
||||||
if (type in VOICE_TYPES) {
|
if (type in VOICE_TYPES) {
|
||||||
try { voice.handle(type, msg.optJSONObject("payload") ?: JSONObject()) }
|
try { voice.handle(type, msg.optJSONObject("payload") ?: JSONObject()) }
|
||||||
|
|||||||
@@ -25,6 +25,15 @@
|
|||||||
android:text="Status: —"
|
android:text="Status: —"
|
||||||
android:paddingBottom="16dp" />
|
android:paddingBottom="16dp" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/btnUpdate"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:focusable="true"
|
||||||
|
android:focusableInTouchMode="false"
|
||||||
|
android:visibility="gone"
|
||||||
|
android:text="Update installieren" />
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
android:id="@+id/btnScan"
|
android:id="@+id/btnScan"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Auto-Update: nur der Update-Ordner im Cache wird an den Installer freigegeben -->
|
||||||
|
<paths>
|
||||||
|
<cache-path name="updates" path="updates/" />
|
||||||
|
</paths>
|
||||||
@@ -143,6 +143,21 @@ for entry in "${ASSETS[@]}"; do
|
|||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
|
# ── Auto-Update: Android-APK auf den RVS (eigener Ordner updates/agent/) ──
|
||||||
|
RVS_UPDATE_HOST="${RVS_UPDATE_HOST:-}"
|
||||||
|
if [ -n "$RVS_UPDATE_HOST" ]; then
|
||||||
|
echo -e "\n${GREEN}[+] Android-APK auf den RVS kopieren (Auto-Update)...${NC}"
|
||||||
|
if ssh "$RVS_UPDATE_HOST" "mkdir -p ~/ARIA-AGENT/rvs/updates/agent && rm -f ~/ARIA-AGENT/rvs/updates/agent/*.apk" 2>/dev/null \
|
||||||
|
&& scp "$APK" "${RVS_UPDATE_HOST}:~/ARIA-AGENT/rvs/updates/agent/aria-host-agent-v${VERSION}.apk" 2>/dev/null; then
|
||||||
|
echo -e " ${GREEN}✓${NC} Agents bekommen v$VERSION beim naechsten Verbinden angeboten"
|
||||||
|
else
|
||||||
|
echo -e " ${YELLOW}Kopieren fehlgeschlagen (RVS_UPDATE_HOST=$RVS_UPDATE_HOST). Manuell:${NC}"
|
||||||
|
echo -e " ${YELLOW}scp $APK $RVS_UPDATE_HOST:~/ARIA-AGENT/rvs/updates/agent/aria-host-agent-v${VERSION}.apk${NC}"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo -e "\n${YELLOW}Auto-Update uebersprungen (RVS_UPDATE_HOST nicht gesetzt)${NC}"
|
||||||
|
fi
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo -e "${GREEN}╔═══════════════════════════════════════════════════╗${NC}"
|
echo -e "${GREEN}╔═══════════════════════════════════════════════════╗${NC}"
|
||||||
echo -e "${GREEN}║ Host-Agent $TAG ist live!${NC}"
|
echo -e "${GREEN}║ Host-Agent $TAG ist live!${NC}"
|
||||||
|
|||||||
+86
-6
@@ -11,6 +11,9 @@ const path = require("path");
|
|||||||
const PORT = parseInt(process.env.PORT || "3000", 10);
|
const PORT = parseInt(process.env.PORT || "3000", 10);
|
||||||
const MAX_SESSIONS = parseInt(process.env.MAX_SESSIONS || "10", 10);
|
const MAX_SESSIONS = parseInt(process.env.MAX_SESSIONS || "10", 10);
|
||||||
const UPDATES_DIR = process.env.UPDATES_DIR || "/updates";
|
const UPDATES_DIR = process.env.UPDATES_DIR || "/updates";
|
||||||
|
// Host-Agent (Android) hat einen EIGENEN Ordner — sonst bekaeme die Cockpit-App
|
||||||
|
// die Agent-APK angeboten (die Versions-Erkennung schaut nur auf den Dateinamen).
|
||||||
|
const AGENT_UPDATES_DIR = path.join(UPDATES_DIR, "agent");
|
||||||
// HTTPS statt HTTP auf PORT — fuer die Strecke externer Reverse-Proxy → RVS.
|
// HTTPS statt HTTP auf PORT — fuer die Strecke externer Reverse-Proxy → RVS.
|
||||||
// Das oeffentliche Zertifikat hat der Proxy; hier reicht ein selbstsigniertes
|
// Das oeffentliche Zertifikat hat der Proxy; hier reicht ein selbstsigniertes
|
||||||
// (wird beim ersten Start erzeugt). NICHT zusammen mit dem Caddy im Stack
|
// (wird beim ersten Start erzeugt). NICHT zusammen mit dem Caddy im Stack
|
||||||
@@ -24,7 +27,7 @@ const ALLOWED_TYPES = new Set([
|
|||||||
"chat", "audio", "file", "location", "mode", "log", "event", "heartbeat",
|
"chat", "audio", "file", "location", "mode", "log", "event", "heartbeat",
|
||||||
"file_request", "file_response", "file_saved", "stt_result", "config", "tts_request",
|
"file_request", "file_response", "file_saved", "stt_result", "config", "tts_request",
|
||||||
"xtts_request", "xtts_response", "xtts_list_voices", "xtts_voices_list", "voice_upload", "xtts_voice_saved",
|
"xtts_request", "xtts_response", "xtts_list_voices", "xtts_voices_list", "voice_upload", "xtts_voice_saved",
|
||||||
"update_check", "update_available", "update_download", "update_data",
|
"update_check", "update_available", "update_none", "update_download", "update_data",
|
||||||
"agent_activity", "cancel_request", "interject",
|
"agent_activity", "cancel_request", "interject",
|
||||||
"audio_pcm",
|
"audio_pcm",
|
||||||
"file_from_aria",
|
"file_from_aria",
|
||||||
@@ -148,6 +151,8 @@ const ALLOWED_TYPES = new Set([
|
|||||||
// Wer ist im Raum? client_hello: Client stellt sich vor (bleibt beim RVS);
|
// Wer ist im Raum? client_hello: Client stellt sich vor (bleibt beim RVS);
|
||||||
// room_clients_query → room_clients: Liste NUR des eigenen Raums.
|
// room_clients_query → room_clients: Liste NUR des eigenen Raums.
|
||||||
"client_hello", "room_clients_query", "room_clients",
|
"client_hello", "room_clients_query", "room_clients",
|
||||||
|
// Host-Agent (Android): Update-Pruefung, beantwortet der RVS selbst
|
||||||
|
"agent_update_check", "agent_update_available",
|
||||||
"node_stats", "node_stats_stream_start", "node_stats_stream_stop",
|
"node_stats", "node_stats_stream_start", "node_stats_stream_stop",
|
||||||
"node_stats_history_request", "node_stats_history",
|
"node_stats_history_request", "node_stats_history",
|
||||||
"node_stats_reset", "node_stats_reset_done",
|
"node_stats_reset", "node_stats_reset_done",
|
||||||
@@ -403,6 +408,23 @@ function handleHttpRequest(req, res) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Updates als normaler HTTP-Download (statt eines riesigen WS-Blocks):
|
||||||
|
// Cockpit-App unter /update/latest.apk, Host-Agent (Android) unter
|
||||||
|
// /update/agent/latest.apk. Nur mit gueltigem Raum-Token (X-RVS-Token).
|
||||||
|
if ((req.method === "GET" || req.method === "HEAD")
|
||||||
|
&& (pathname === "/update/latest.apk" || pathname === "/update/agent/latest.apk")) {
|
||||||
|
const tok = String(req.headers["x-rvs-token"] || "");
|
||||||
|
if (!tok || !rooms.has(tok)) {
|
||||||
|
res.writeHead(403, { "Content-Type": "text/plain" });
|
||||||
|
res.end("Forbidden\n");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const isAgent = pathname.startsWith("/update/agent/");
|
||||||
|
serveApk(req, res, isAgent ? getLatestAPK(AGENT_UPDATES_DIR) : getLatestAPK(),
|
||||||
|
isAgent ? "aria-host-agent" : "ARIA");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Health-Endpoint
|
// Health-Endpoint
|
||||||
if (req.method === "GET" && pathname === "/health") {
|
if (req.method === "GET" && pathname === "/health") {
|
||||||
res.writeHead(200, { "Content-Type": "application/json" });
|
res.writeHead(200, { "Content-Type": "application/json" });
|
||||||
@@ -592,6 +614,29 @@ function registerClient(ws, token) {
|
|||||||
},
|
},
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
}));
|
}));
|
||||||
|
} else {
|
||||||
|
// Auch "nichts Neues" beantworten — die App zeigt dann, dass der
|
||||||
|
// Update-Server erreichbar ist und welche Version er hat
|
||||||
|
ws.send(JSON.stringify({
|
||||||
|
type: "update_none",
|
||||||
|
payload: { latest: apkInfo ? apkInfo.version : "", current: clientVersion },
|
||||||
|
timestamp: Date.now(),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Host-Agent (Android) fragt nach einem Update — direkt antworten
|
||||||
|
if (msg.type === "agent_update_check") {
|
||||||
|
const have = String(msg.payload?.version || "0.0.0.0");
|
||||||
|
const apk = getLatestAPK(AGENT_UPDATES_DIR);
|
||||||
|
if (apk && compareVersions(apk.version, have) > 0) {
|
||||||
|
ws.send(JSON.stringify({
|
||||||
|
type: "agent_update_available",
|
||||||
|
payload: { version: apk.version, downloadUrl: "/update/agent/latest.apk",
|
||||||
|
size: fs.statSync(apk.path).size },
|
||||||
|
timestamp: Date.now(),
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -736,15 +781,50 @@ wss.on("close", () => {
|
|||||||
|
|
||||||
let latestVersion = null;
|
let latestVersion = null;
|
||||||
|
|
||||||
function getLatestAPK() {
|
/** APK per HTTP ausliefern: Content-Length (Fortschritt), Range (Fortsetzen). */
|
||||||
|
function serveApk(req, res, apk, label) {
|
||||||
|
if (!apk) {
|
||||||
|
res.writeHead(404, { "Content-Type": "text/plain" });
|
||||||
|
res.end("Keine APK verfuegbar\n");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const size = fs.statSync(apk.path).size;
|
||||||
|
const headers = {
|
||||||
|
"Content-Type": "application/vnd.android.package-archive",
|
||||||
|
"Content-Disposition": `attachment; filename="${label}-v${apk.version}.apk"`,
|
||||||
|
"Accept-Ranges": "bytes",
|
||||||
|
"Cache-Control": "no-store",
|
||||||
|
"X-APK-Version": apk.version,
|
||||||
|
};
|
||||||
|
let start = 0, end = size - 1, status = 200;
|
||||||
|
const m = /^bytes=(\d*)-(\d*)$/.exec(String(req.headers.range || ""));
|
||||||
|
if (m && (m[1] || m[2])) {
|
||||||
|
if (m[1]) { start = parseInt(m[1], 10); if (m[2]) end = Math.min(parseInt(m[2], 10), size - 1); }
|
||||||
|
else { start = Math.max(0, size - parseInt(m[2], 10)); }
|
||||||
|
if (start > end || start >= size) {
|
||||||
|
res.writeHead(416, { "Content-Range": `bytes */${size}` });
|
||||||
|
res.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
status = 206;
|
||||||
|
headers["Content-Range"] = `bytes ${start}-${end}/${size}`;
|
||||||
|
}
|
||||||
|
headers["Content-Length"] = end - start + 1;
|
||||||
|
res.writeHead(status, headers);
|
||||||
|
if (req.method === "HEAD") { res.end(); return; }
|
||||||
|
log(`APK-Download (HTTP) ${label} v${apk.version}${status === 206 ? ` ab Byte ${start}` : ""}`);
|
||||||
|
fs.createReadStream(apk.path, { start, end }).pipe(res);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLatestAPK(dir = UPDATES_DIR) {
|
||||||
try {
|
try {
|
||||||
if (!fs.existsSync(UPDATES_DIR)) return null;
|
if (!fs.existsSync(dir)) return null;
|
||||||
const files = fs.readdirSync(UPDATES_DIR)
|
const files = fs.readdirSync(dir)
|
||||||
.filter(f => f.endsWith(".apk"))
|
.filter(f => f.endsWith(".apk"))
|
||||||
.map(f => {
|
.map(f => {
|
||||||
// ARIA-v0.0.2.3.apk oder ARIA-Cockpit-release.apk
|
// ARIA-v0.0.2.3.apk oder ARIA-Cockpit-release.apk
|
||||||
const match = f.match(/(\d+\.\d+\.\d+[\.\d]*)/);
|
const match = f.match(/(\d+(?:\.\d+){2,})/);
|
||||||
return { file: f, path: path.join(UPDATES_DIR, f), version: match ? match[1] : null };
|
return { file: f, path: path.join(dir, f), version: match ? match[1] : null };
|
||||||
})
|
})
|
||||||
.filter(f => f.version)
|
.filter(f => f.version)
|
||||||
.sort((a, b) => compareVersions(b.version, a.version)); // Neueste zuerst
|
.sort((a, b) => compareVersions(b.version, a.version)); // Neueste zuerst
|
||||||
|
|||||||
Reference in New Issue
Block a user