Updater: APK direkt in App herunterladen und installieren [apk]
All checks were successful
Build APK / build-apk (push) Successful in 1m42s

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) <noreply@anthropic.com>
This commit is contained in:
Eduard Wisch 2026-05-19 21:31:42 +02:00
parent 8d7353cbff
commit 34356f25ef
5 changed files with 344 additions and 19 deletions

View file

@ -2,7 +2,12 @@ package de.data_it_solution.netdiag
import android.Manifest import android.Manifest
import android.content.Context import android.content.Context
import android.content.Intent
import android.net.Uri
import android.net.wifi.WifiManager 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.JSArray
import com.getcapacitor.JSObject import com.getcapacitor.JSObject
import com.getcapacitor.Plugin import com.getcapacitor.Plugin
@ -19,12 +24,15 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.io.BufferedReader import java.io.BufferedReader
import java.io.File import java.io.File
import java.io.FileOutputStream
import java.io.FileReader import java.io.FileReader
import java.net.DatagramPacket import java.net.DatagramPacket
import java.net.DatagramSocket import java.net.DatagramSocket
import java.net.HttpURLConnection
import java.net.InetAddress import java.net.InetAddress
import java.net.InetSocketAddress import java.net.InetSocketAddress
import java.net.Socket import java.net.Socket
import java.net.URL
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
/** /**
@ -475,6 +483,93 @@ class NetDiagScannerPlugin : Plugin() {
var maxMs = 0.0 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 (0100 %) 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 */ /* Hilfsfunktionen */
/* --------------------------------------------------------------------- */ /* --------------------------------------------------------------------- */

View file

@ -2,7 +2,12 @@ package de.data_it_solution.netdiag
import android.Manifest import android.Manifest
import android.content.Context import android.content.Context
import android.content.Intent
import android.net.Uri
import android.net.wifi.WifiManager 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.JSArray
import com.getcapacitor.JSObject import com.getcapacitor.JSObject
import com.getcapacitor.Plugin import com.getcapacitor.Plugin
@ -19,12 +24,15 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.io.BufferedReader import java.io.BufferedReader
import java.io.File import java.io.File
import java.io.FileOutputStream
import java.io.FileReader import java.io.FileReader
import java.net.DatagramPacket import java.net.DatagramPacket
import java.net.DatagramSocket import java.net.DatagramSocket
import java.net.HttpURLConnection
import java.net.InetAddress import java.net.InetAddress
import java.net.InetSocketAddress import java.net.InetSocketAddress
import java.net.Socket import java.net.Socket
import java.net.URL
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
/** /**
@ -426,6 +434,93 @@ class NetDiagScannerPlugin : Plugin() {
var maxMs = 0.0 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 (0100 %) 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 */ /* Hilfsfunktionen */
/* --------------------------------------------------------------------- */ /* --------------------------------------------------------------------- */

View file

@ -6,12 +6,15 @@
* (update.php): der Server prüft die Registry, die App spricht nur ihren * (update.php): der Server prüft die Registry, die App spricht nur ihren
* eigenen, authentifizierten Endpunkt an. * 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 * checkForUpdate() verschluckt Fehler NICHT bei einem Problem wirft es mit
* Klartext-Grund. So kann der Aufrufer wirklich aktuell" von Prüfung * Klartext-Grund. So kann der Aufrufer wirklich aktuell" von Prüfung
* fehlgeschlagen" unterscheiden und eine echte Meldung zeigen. * 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'; import { checkAppUpdate, updateDownloadUrl } from './api';
/** Aktuelle Build-Version (von der CI injiziert, im Dev 'dev') */ /** Aktuelle Build-Version (von der CI injiziert, im Dev 'dev') */
@ -22,6 +25,39 @@ export interface UpdateInfo {
downloadUrl: string; downloadUrl: string;
} }
/** Fortschritts-Callback für den APK-Download (0100 %) */
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<PluginListenerHandle>;
}
const updaterPlugin = registerPlugin<NetDiagUpdaterPlugin>('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. * Prüfen, ob eine neuere APK verfügbar ist.
* *
@ -37,16 +73,36 @@ export async function checkForUpdate(): Promise<UpdateInfo | null> {
if (!version) { if (!version) {
throw new Error('Server lieferte keine Versionsinfo'); throw new Error('Server lieferte keine Versionsinfo');
} }
if (version > APP_VERSION) { if (isNewer(version, APP_VERSION)) {
return { version, downloadUrl: updateDownloadUrl() }; return { version, downloadUrl: updateDownloadUrl() };
} }
return null; // aktuell — nachweislich geprüft return null; // aktuell — nachweislich geprüft
} }
/** /**
* Update-APK im System öffnen. Android lädt die Datei herunter; der Nutzer * Update direkt in der App installieren: APK herunterladen (mit Fortschritt)
* bestätigt anschließend die Installation. * 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 { export async function installUpdate(
window.open(info.downloadUrl, '_system'); info: UpdateInfo,
onProgress?: UpdateProgressCb,
): Promise<void> {
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();
}
} }

View file

@ -8,7 +8,7 @@
import { toast } from '$lib/toast.svelte'; import { toast } from '$lib/toast.svelte';
import { initDb } from '$lib/db'; import { initDb } from '$lib/db';
import { registerBackListener, removeBackListener } from '$lib/backButton.svelte'; 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 { initDebugLog } from '$lib/debuglog.svelte';
import Toast from '$lib/components/Toast.svelte'; import Toast from '$lib/components/Toast.svelte';
@ -16,9 +16,26 @@
let booted = $state(false); let booted = $state(false);
let updateInfo = $state<UpdateInfo | null>(null); let updateInfo = $state<UpdateInfo | null>(null);
let updateDismissed = $state(false);
let updateBusy = $state(false);
let updatePercent = $state(0);
const HOME = '/auftraege/'; 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 () => { onMount(async () => {
initDebugLog(); // zuerst — damit auch Startfehler erfasst werden initDebugLog(); // zuerst — damit auch Startfehler erfasst werden
await auth.init(); await auth.init();
@ -70,13 +87,36 @@
<span>NetDiag startet …</span> <span>NetDiag startet …</span>
</div> </div>
{:else} {:else}
{#if updateInfo} {#if updateInfo && !updateDismissed}
<button <div class="bg-sky-700 text-sm text-white safe-top">
class="bg-sky-700 px-4 pb-2 text-sm text-white safe-top" {#if updateBusy}
onclick={() => updateInfo && openUpdate(updateInfo)} <div class="px-4 pb-2">
> <div class="flex justify-between">
Neue Version {updateInfo.version} verfügbar — tippen zum Aktualisieren <span>Update wird geladen …</span>
</button> <span class="font-mono">{updatePercent}%</span>
</div>
<div class="mt-1 h-1.5 overflow-hidden rounded bg-sky-900">
<div
class="h-full rounded bg-white transition-all"
style="width:{updatePercent}%"
></div>
</div>
</div>
{:else}
<div class="flex items-center">
<button class="flex-1 px-4 pb-2 text-left" onclick={runUpdate}>
Neue Version {updateInfo.version} verfügbar — tippen zum Aktualisieren
</button>
<button
class="px-4 pb-2 text-lg leading-none"
aria-label="Hinweis ausblenden"
onclick={() => (updateDismissed = true)}
>
×
</button>
</div>
{/if}
</div>
{/if} {/if}
{@render children()} {@render children()}
{/if} {/if}

View file

@ -5,9 +5,12 @@
import { getServerUrl } from '$lib/api'; import { getServerUrl } from '$lib/api';
import { sync } from '$lib/sync.svelte'; import { sync } from '$lib/sync.svelte';
import { toast } from '$lib/toast.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 checking = $state(false);
let available = $state<UpdateInfo | null>(null);
let installing = $state(false);
let percent = $state(0);
// Sync-Status in Klartext // Sync-Status in Klartext
const syncLabel = $derived( const syncLabel = $derived(
@ -25,9 +28,8 @@
async function checkUpdate() { async function checkUpdate() {
checking = true; checking = true;
try { try {
const upd = await checkForUpdate(); available = await checkForUpdate();
if (upd) openUpdate(upd); if (!available) toast.show('App ist aktuell', 'success');
else toast.show('App ist aktuell', 'success');
} catch (e) { } catch (e) {
// Echte Fehlermeldung statt stillem „aktuell" // Echte Fehlermeldung statt stillem „aktuell"
toast.show( 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() { async function logout() {
await auth.logout(); await auth.logout();
sync.stop(); sync.stop();
@ -64,10 +79,34 @@
<button <button
class="mt-3 w-full rounded bg-zinc-800 px-3 py-2 text-sm active:bg-zinc-700 disabled:opacity-50" class="mt-3 w-full rounded bg-zinc-800 px-3 py-2 text-sm active:bg-zinc-700 disabled:opacity-50"
onclick={checkUpdate} onclick={checkUpdate}
disabled={checking} disabled={checking || installing}
> >
{checking ? 'Prüfe …' : 'Auf Update prüfen'} {checking ? 'Prüfe …' : 'Auf Update prüfen'}
</button> </button>
{#if available}
{#if installing}
<div class="mt-3">
<div class="flex justify-between text-xs text-zinc-400">
<span>Update wird geladen …</span>
<span class="font-mono">{percent}%</span>
</div>
<div class="mt-1 h-1.5 overflow-hidden rounded bg-zinc-800">
<div
class="h-full rounded bg-sky-500 transition-all"
style="width:{percent}%"
></div>
</div>
</div>
{:else}
<button
class="mt-2 w-full rounded bg-sky-700 px-3 py-2 text-sm font-semibold text-white active:bg-sky-800"
onclick={runInstall}
>
Version {available.version} installieren
</button>
{/if}
{/if}
</section> </section>
<!-- Synchronisierung --> <!-- Synchronisierung -->