Updater: APK direkt in App herunterladen und installieren [apk]
All checks were successful
Build APK / build-apk (push) Successful in 1m42s
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:
parent
8d7353cbff
commit
34356f25ef
5 changed files with 344 additions and 19 deletions
|
|
@ -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 */
|
||||
/* --------------------------------------------------------------------- */
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
/* --------------------------------------------------------------------- */
|
||||
|
|
|
|||
|
|
@ -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<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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 -->
|
||||
|
|
|
|||
Loading…
Reference in a new issue