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.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 (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 */
/* --------------------------------------------------------------------- */

View file

@ -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 (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 */
/* --------------------------------------------------------------------- */

View file

@ -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 (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.
*
@ -37,16 +73,36 @@ export async function checkForUpdate(): Promise<UpdateInfo | null> {
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<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 { 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<UpdateInfo | null>(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 @@
<span>NetDiag startet …</span>
</div>
{:else}
{#if updateInfo}
<button
class="bg-sky-700 px-4 pb-2 text-sm text-white safe-top"
onclick={() => updateInfo && openUpdate(updateInfo)}
>
Neue Version {updateInfo.version} verfügbar — tippen zum Aktualisieren
</button>
{#if updateInfo && !updateDismissed}
<div class="bg-sky-700 text-sm text-white safe-top">
{#if updateBusy}
<div class="px-4 pb-2">
<div class="flex justify-between">
<span>Update wird geladen …</span>
<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}
{@render children()}
{/if}

View file

@ -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<UpdateInfo | null>(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 @@
<button
class="mt-3 w-full rounded bg-zinc-800 px-3 py-2 text-sm active:bg-zinc-700 disabled:opacity-50"
onclick={checkUpdate}
disabled={checking}
disabled={checking || installing}
>
{checking ? 'Prüfe …' : 'Auf Update prüfen'}
</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>
<!-- Synchronisierung -->