From 34356f25ef798c9ee3c4b8a36e207301dd7391e9 Mon Sep 17 00:00:00 2001 From: Eduard Wisch Date: Tue, 19 May 2026 21:31:42 +0200 Subject: [PATCH] Updater: APK direkt in App herunterladen und installieren [apk] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ersetzt den Browser-Umweg (window.open) durch einen echten In-App-Installer: das native Plugin lädt die APK streamend herunter (Fortschritts-Events updateProgress 0–100 %), prüft die Installationsberechtigung (Android 8+) und öffnet den Paketinstaller über den vorhandenen FileProvider. Versionsvergleich jetzt numerisch (YYYYMMDD-HHMM) statt lexikografisch. Banner ist schließbar; Einstellungsseite zeigt separaten Fortschrittsbalken. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../netdiag/NetDiagScannerPlugin.kt | 95 +++++++++++++++++++ native-plugin/NetDiagScannerPlugin.kt | 95 +++++++++++++++++++ src/lib/updater.ts | 68 +++++++++++-- src/routes/+layout.svelte | 56 +++++++++-- src/routes/einstellungen/+page.svelte | 49 +++++++++- 5 files changed, 344 insertions(+), 19 deletions(-) diff --git a/android/app/src/main/java/de/data_it_solution/netdiag/NetDiagScannerPlugin.kt b/android/app/src/main/java/de/data_it_solution/netdiag/NetDiagScannerPlugin.kt index 8a75408..7a59c76 100644 --- a/android/app/src/main/java/de/data_it_solution/netdiag/NetDiagScannerPlugin.kt +++ b/android/app/src/main/java/de/data_it_solution/netdiag/NetDiagScannerPlugin.kt @@ -2,7 +2,12 @@ package de.data_it_solution.netdiag import android.Manifest import android.content.Context +import android.content.Intent +import android.net.Uri import android.net.wifi.WifiManager +import android.os.Build +import android.provider.Settings +import androidx.core.content.FileProvider import com.getcapacitor.JSArray import com.getcapacitor.JSObject import com.getcapacitor.Plugin @@ -19,12 +24,15 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.io.BufferedReader import java.io.File +import java.io.FileOutputStream import java.io.FileReader import java.net.DatagramPacket import java.net.DatagramSocket +import java.net.HttpURLConnection import java.net.InetAddress import java.net.InetSocketAddress import java.net.Socket +import java.net.URL import java.util.concurrent.ConcurrentHashMap /** @@ -475,6 +483,93 @@ class NetDiagScannerPlugin : Plugin() { var maxMs = 0.0 } + /* --------------------------------------------------------------------- */ + /* App-Update: APK herunterladen und Paketinstaller öffnen */ + /* --------------------------------------------------------------------- */ + + /** + * Lädt die neue APK vom (authentifizierten) Update-Proxy des netdiag-Moduls + * herunter und öffnet den Android-Paketinstaller. Der Download-Fortschritt + * wird laufend als `updateProgress`-Event (0–100 %) gemeldet. + * + * Vor Android 8 genügt die globale Einstellung „Unbekannte Quellen". Ab + * Android 8 muss die App einzeln berechtigt sein — fehlt das Recht, wird + * der passende Einstellungs-Dialog geöffnet und der Aufruf abgewiesen. + */ + @PluginMethod + fun installUpdate(call: PluginCall) { + val url = call.getString("url") ?: return call.reject("url fehlt") + io.launch { + try { + // Ab Android 8: App braucht das Recht, Pakete zu installieren + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && + !context.packageManager.canRequestPackageInstalls() + ) { + val perm = Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES) + .setData(Uri.parse("package:${context.packageName}")) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + context.startActivity(perm) + call.reject("Bitte erlauben, dass NetDiag Apps installieren darf, dann erneut tippen") + return@launch + } + + val apk = File(context.cacheDir, "NetDiag-update.apk") + downloadApk(url, apk) + + val uri = FileProvider.getUriForFile( + context, "${context.packageName}.fileprovider", apk + ) + val install = Intent(Intent.ACTION_VIEW) + .setDataAndType(uri, "application/vnd.android.package-archive") + .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + context.startActivity(install) + resolve(call, JSObject().put("started", true)) + } catch (e: Exception) { + call.reject("installUpdate: ${e.message}") + } + } + } + + /** APK streamend in `target` laden und dabei `updateProgress`-Events senden. */ + private fun downloadApk(url: String, target: File) { + val conn = (URL(url).openConnection() as HttpURLConnection).apply { + connectTimeout = 15_000 + readTimeout = 120_000 + instanceFollowRedirects = true + } + try { + val code = conn.responseCode + if (code != 200) throw Exception("Download fehlgeschlagen (HTTP $code)") + val total = conn.contentLength.toLong() // -1 wenn unbekannt + var read = 0L + var lastPct = -1 + conn.inputStream.use { input -> + FileOutputStream(target).use { out -> + val buf = ByteArray(64 * 1024) + while (true) { + val n = input.read(buf) + if (n < 0) break + out.write(buf, 0, n) + read += n + if (total > 0) { + val pct = (read * 100 / total).toInt() + if (pct != lastPct) { + lastPct = pct + notifyListeners( + "updateProgress", JSObject().put("percent", pct) + ) + } + } + } + } + } + if (target.length() < 1024) throw Exception("APK unvollständig empfangen") + } finally { + conn.disconnect() + } + } + /* --------------------------------------------------------------------- */ /* Hilfsfunktionen */ /* --------------------------------------------------------------------- */ diff --git a/native-plugin/NetDiagScannerPlugin.kt b/native-plugin/NetDiagScannerPlugin.kt index 272a6ab..e7cc5cc 100644 --- a/native-plugin/NetDiagScannerPlugin.kt +++ b/native-plugin/NetDiagScannerPlugin.kt @@ -2,7 +2,12 @@ package de.data_it_solution.netdiag import android.Manifest import android.content.Context +import android.content.Intent +import android.net.Uri import android.net.wifi.WifiManager +import android.os.Build +import android.provider.Settings +import androidx.core.content.FileProvider import com.getcapacitor.JSArray import com.getcapacitor.JSObject import com.getcapacitor.Plugin @@ -19,12 +24,15 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.io.BufferedReader import java.io.File +import java.io.FileOutputStream import java.io.FileReader import java.net.DatagramPacket import java.net.DatagramSocket +import java.net.HttpURLConnection import java.net.InetAddress import java.net.InetSocketAddress import java.net.Socket +import java.net.URL import java.util.concurrent.ConcurrentHashMap /** @@ -426,6 +434,93 @@ class NetDiagScannerPlugin : Plugin() { var maxMs = 0.0 } + /* --------------------------------------------------------------------- */ + /* App-Update: APK herunterladen und Paketinstaller öffnen */ + /* --------------------------------------------------------------------- */ + + /** + * Lädt die neue APK vom (authentifizierten) Update-Proxy des netdiag-Moduls + * herunter und öffnet den Android-Paketinstaller. Der Download-Fortschritt + * wird laufend als `updateProgress`-Event (0–100 %) gemeldet. + * + * Vor Android 8 genügt die globale Einstellung „Unbekannte Quellen". Ab + * Android 8 muss die App einzeln berechtigt sein — fehlt das Recht, wird + * der passende Einstellungs-Dialog geöffnet und der Aufruf abgewiesen. + */ + @PluginMethod + fun installUpdate(call: PluginCall) { + val url = call.getString("url") ?: return call.reject("url fehlt") + io.launch { + try { + // Ab Android 8: App braucht das Recht, Pakete zu installieren + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && + !context.packageManager.canRequestPackageInstalls() + ) { + val perm = Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES) + .setData(Uri.parse("package:${context.packageName}")) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + context.startActivity(perm) + call.reject("Bitte erlauben, dass NetDiag Apps installieren darf, dann erneut tippen") + return@launch + } + + val apk = File(context.cacheDir, "NetDiag-update.apk") + downloadApk(url, apk) + + val uri = FileProvider.getUriForFile( + context, "${context.packageName}.fileprovider", apk + ) + val install = Intent(Intent.ACTION_VIEW) + .setDataAndType(uri, "application/vnd.android.package-archive") + .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + context.startActivity(install) + resolve(call, JSObject().put("started", true)) + } catch (e: Exception) { + call.reject("installUpdate: ${e.message}") + } + } + } + + /** APK streamend in `target` laden und dabei `updateProgress`-Events senden. */ + private fun downloadApk(url: String, target: File) { + val conn = (URL(url).openConnection() as HttpURLConnection).apply { + connectTimeout = 15_000 + readTimeout = 120_000 + instanceFollowRedirects = true + } + try { + val code = conn.responseCode + if (code != 200) throw Exception("Download fehlgeschlagen (HTTP $code)") + val total = conn.contentLength.toLong() // -1 wenn unbekannt + var read = 0L + var lastPct = -1 + conn.inputStream.use { input -> + FileOutputStream(target).use { out -> + val buf = ByteArray(64 * 1024) + while (true) { + val n = input.read(buf) + if (n < 0) break + out.write(buf, 0, n) + read += n + if (total > 0) { + val pct = (read * 100 / total).toInt() + if (pct != lastPct) { + lastPct = pct + notifyListeners( + "updateProgress", JSObject().put("percent", pct) + ) + } + } + } + } + } + if (target.length() < 1024) throw Exception("APK unvollständig empfangen") + } finally { + conn.disconnect() + } + } + /* --------------------------------------------------------------------- */ /* Hilfsfunktionen */ /* --------------------------------------------------------------------- */ diff --git a/src/lib/updater.ts b/src/lib/updater.ts index 0407bed..ec53f6a 100644 --- a/src/lib/updater.ts +++ b/src/lib/updater.ts @@ -6,12 +6,15 @@ * (update.php): der Server prüft die Registry, die App spricht nur ihren * eigenen, authentifizierten Endpunkt an. * + * Installiert wird direkt in der App: das native Plugin lädt die APK (mit + * Fortschritt) und öffnet den Android-Paketinstaller — kein Browser-Umweg. + * * checkForUpdate() verschluckt Fehler NICHT — bei einem Problem wirft es mit * Klartext-Grund. So kann der Aufrufer „wirklich aktuell" von „Prüfung * fehlgeschlagen" unterscheiden und eine echte Meldung zeigen. */ -import { Capacitor } from '@capacitor/core'; +import { Capacitor, registerPlugin, type PluginListenerHandle } from '@capacitor/core'; import { checkAppUpdate, updateDownloadUrl } from './api'; /** Aktuelle Build-Version (von der CI injiziert, im Dev 'dev') */ @@ -22,6 +25,39 @@ export interface UpdateInfo { downloadUrl: string; } +/** Fortschritts-Callback für den APK-Download (0–100 %) */ +export type UpdateProgressCb = (percent: number) => void; + +/** + * Natives Plugin (Kotlin-Klasse `NetDiagScanner`). `installUpdate` lädt die + * APK herunter und feuert den Paketinstaller; der Download-Fortschritt kommt + * als `updateProgress`-Event. + */ +interface NetDiagUpdaterPlugin { + installUpdate(opts: { url: string }): Promise<{ started: boolean }>; + addListener( + eventName: 'updateProgress', + cb: (data: { percent: number }) => void, + ): Promise; +} +const updaterPlugin = registerPlugin('NetDiagScanner'); + +/** + * Versionsvergleich für das Build-Format `YYYYMMDD-HHMM`. Verglichen wird + * numerisch (alle Ziffern aneinandergereiht) statt lexikografisch — robust + * gegen unterschiedliche Längen. Liefert false, sobald eine Seite keine + * Ziffern enthält (z. B. der Dev-Build `dev`). + */ +export function isNewer(remote: string, local: string): boolean { + const digits = (v: string) => Number(v.replace(/\D/g, '')); + const r = digits(remote); + const l = digits(local); + if (!Number.isSafeInteger(r) || !Number.isSafeInteger(l) || r === 0 || l === 0) { + return false; + } + return r > l; +} + /** * Prüfen, ob eine neuere APK verfügbar ist. * @@ -37,16 +73,36 @@ export async function checkForUpdate(): Promise { if (!version) { throw new Error('Server lieferte keine Versionsinfo'); } - if (version > APP_VERSION) { + if (isNewer(version, APP_VERSION)) { return { version, downloadUrl: updateDownloadUrl() }; } return null; // aktuell — nachweislich geprüft } /** - * Update-APK im System öffnen. Android lädt die Datei herunter; der Nutzer - * bestätigt anschließend die Installation. + * Update direkt in der App installieren: APK herunterladen (mit Fortschritt) + * und den Android-Paketinstaller öffnen. Im Browser-Dev gibt es keinen + * nativen Installer — dort wird die APK ersatzweise im Browser geöffnet. + * + * @throws Error mit Klartext-Grund, wenn Download oder Installation scheitern + * (z. B. fehlendes Recht „Apps installieren"). */ -export function openUpdate(info: UpdateInfo): void { - window.open(info.downloadUrl, '_system'); +export async function installUpdate( + info: UpdateInfo, + onProgress?: UpdateProgressCb, +): Promise { + if (!Capacitor.isNativePlatform()) { + window.open(info.downloadUrl, '_system'); + return; + } + + let handle: PluginListenerHandle | undefined; + if (onProgress) { + handle = await updaterPlugin.addListener('updateProgress', (d) => onProgress(d.percent)); + } + try { + await updaterPlugin.installUpdate({ url: info.downloadUrl }); + } finally { + await handle?.remove(); + } } diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index c056f4e..120e3f0 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -8,7 +8,7 @@ import { toast } from '$lib/toast.svelte'; import { initDb } from '$lib/db'; import { registerBackListener, removeBackListener } from '$lib/backButton.svelte'; - import { checkForUpdate, openUpdate, type UpdateInfo } from '$lib/updater'; + import { checkForUpdate, installUpdate, type UpdateInfo } from '$lib/updater'; import { initDebugLog } from '$lib/debuglog.svelte'; import Toast from '$lib/components/Toast.svelte'; @@ -16,9 +16,26 @@ let booted = $state(false); let updateInfo = $state(null); + let updateDismissed = $state(false); + let updateBusy = $state(false); + let updatePercent = $state(0); const HOME = '/auftraege/'; + // Update herunterladen und Installer öffnen — Fortschritt im Banner + async function runUpdate() { + if (!updateInfo || updateBusy) return; + updateBusy = true; + updatePercent = 0; + try { + await installUpdate(updateInfo, (p) => (updatePercent = p)); + // Erfolg: Android übernimmt die Installation, Banner bleibt bei 100 % + } catch (e) { + toast.show(e instanceof Error ? e.message : 'Update fehlgeschlagen', 'error', 6000); + updateBusy = false; + } + } + onMount(async () => { initDebugLog(); // zuerst — damit auch Startfehler erfasst werden await auth.init(); @@ -70,13 +87,36 @@ NetDiag startet … {:else} - {#if updateInfo} - + {#if updateInfo && !updateDismissed} +
+ {#if updateBusy} +
+
+ Update wird geladen … + {updatePercent}% +
+
+
+
+
+ {:else} +
+ + +
+ {/if} +
{/if} {@render children()} {/if} diff --git a/src/routes/einstellungen/+page.svelte b/src/routes/einstellungen/+page.svelte index 8291121..7dffee3 100644 --- a/src/routes/einstellungen/+page.svelte +++ b/src/routes/einstellungen/+page.svelte @@ -5,9 +5,12 @@ import { getServerUrl } from '$lib/api'; import { sync } from '$lib/sync.svelte'; import { toast } from '$lib/toast.svelte'; - import { APP_VERSION, checkForUpdate, openUpdate } from '$lib/updater'; + import { APP_VERSION, checkForUpdate, installUpdate, type UpdateInfo } from '$lib/updater'; let checking = $state(false); + let available = $state(null); + let installing = $state(false); + let percent = $state(0); // Sync-Status in Klartext const syncLabel = $derived( @@ -25,9 +28,8 @@ async function checkUpdate() { checking = true; try { - const upd = await checkForUpdate(); - if (upd) openUpdate(upd); - else toast.show('App ist aktuell', 'success'); + available = await checkForUpdate(); + if (!available) toast.show('App ist aktuell', 'success'); } catch (e) { // Echte Fehlermeldung statt stillem „aktuell" toast.show( @@ -42,6 +44,19 @@ } } + async function runInstall() { + if (!available || installing) return; + installing = true; + percent = 0; + try { + await installUpdate(available, (p) => (percent = p)); + // Erfolg: Android übernimmt die Installation + } catch (e) { + toast.show(e instanceof Error ? e.message : 'Update fehlgeschlagen', 'error', 6000); + installing = false; + } + } + async function logout() { await auth.logout(); sync.stop(); @@ -64,10 +79,34 @@ + + {#if available} + {#if installing} +
+
+ Update wird geladen … + {percent}% +
+
+
+
+
+ {:else} + + {/if} + {/if}