feat: In-App-Update (Download + Installer direkt öffnen)

"Jetzt aktualisieren"-Button im Update-Banner lädt die neue APK vom Server
herunter (mit Fortschrittsbalken) und öffnet danach direkt den Android-
Paketinstaller (open_filex + FileProvider). Kein Silent-Install (App ist
nicht Geräteeigentümer/MDM) – User bestätigt die Installation weiterhin im
System-Dialog, muss nur nicht mehr manuell ins Portal wechseln.
REQUEST_INSTALL_PACKAGES-Permission + FileProvider-Konfiguration ergänzt.
v0.1.7+8.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dzdm3EWeNdYFUuXFRJryA1
This commit is contained in:
patrickandClaude Sonnet 5 committed 2026-09-10 00:14:53 +02:00
1 parent 3ed374dd68
commit 046f7f4359
6 files changed
+109 -18

No files matched your search

+13
View File
@@ -1,6 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.USE_BIOMETRIC"/>
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
<application
android:label="timemaster_mobile"
android:name="${applicationName}"
@@ -32,6 +33,18 @@
<meta-data
android:name="flutterEmbedding"
android:value="2" />
<!-- FileProvider für open_filex – Android 7+ verlangt content://
statt file:// beim Öffnen heruntergeladener APKs. -->
<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>
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Deckt getExternalFilesDir()/... ab, wo wir das APK zwischenspeichern -->
<external-files-path name="downloads" path="." />
<cache-path name="cache" path="." />
</paths>
+1 -1
View File
@@ -1,4 +1,4 @@
/// Manuell mit `pubspec.yaml` synchron halten (kein package_info_plus, um
/// keine zusätzliche Native-Plugin-Fläche in dieser Debugging-Phase
/// einzuführen). Bei jedem Release beide Stellen bumpen.
const String kAppVersion = '0.1.6+7';
const String kAppVersion = '0.1.7+8';
+55 -10
View File
@@ -26,6 +26,9 @@ class _HomeScreenState extends State<HomeScreen> {
String? _error;
Timer? _ticker;
String? _newerVersion;
final _updateService = UpdateCheckService();
bool _updating = false;
double? _updateProgress;
@override
void initState() {
@@ -34,11 +37,37 @@ class _HomeScreenState extends State<HomeScreen> {
_ticker = Timer.periodic(const Duration(seconds: 1), (_) {
if (_openEntry != null) setState(() {});
});
UpdateCheckService().checkForNewerVersion().then((v) {
_updateService.checkForNewerVersion().then((v) {
if (mounted && v != null) setState(() => _newerVersion = v);
});
}
Future<void> _installUpdate() async {
setState(() {
_updating = true;
_updateProgress = null;
_error = null;
});
try {
await _updateService.downloadAndInstall(
onProgress: (p) {
if (mounted) setState(() => _updateProgress = p);
},
);
} catch (e) {
if (mounted) {
setState(() => _error = 'Update fehlgeschlagen: $e');
}
} finally {
if (mounted) {
setState(() {
_updating = false;
_updateProgress = null;
});
}
}
}
@override
void dispose() {
_ticker?.cancel();
@@ -185,17 +214,33 @@ class _HomeScreenState extends State<HomeScreen> {
color: Colors.blue[50],
child: Padding(
padding: const EdgeInsets.all(12),
child: Row(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(Icons.system_update, color: Colors.blue),
const SizedBox(width: 12),
Expanded(
child: Text(
'Neue Version $_newerVersion verfügbar (installiert: $kAppVersion). '
'Im Portal unter Profil herunterladen.',
style: const TextStyle(color: Colors.blue),
),
Row(
children: [
const Icon(Icons.system_update, color: Colors.blue),
const SizedBox(width: 12),
Expanded(
child: Text(
'Neue Version $_newerVersion verfügbar (installiert: $kAppVersion).',
style: const TextStyle(color: Colors.blue),
),
),
],
),
const SizedBox(height: 8),
if (_updating)
LinearProgressIndicator(value: _updateProgress)
else
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: _installUpdate,
icon: const Icon(Icons.download),
label: const Text('Jetzt aktualisieren'),
),
),
],
),
),
+32 -6
View File
@@ -1,18 +1,24 @@
import 'package:dio/dio.dart';
import 'package:open_filex/open_filex.dart';
import 'package:path_provider/path_provider.dart';
import '../app_version.dart';
import 'api_client.dart';
/// Prüft `/static/downloads/version.txt` auf dem konfigurierten Server gegen
/// die eigene kAppVersion. Rein informativ – kein Erzwingen, kein Auto-Update
/// (APK-Sideloading, kein Play Store). Fehler beim Check sind egal (offline,
/// Server down) – dann einfach kein Hinweis.
/// die eigene kAppVersion und kann das neue APK direkt herunterladen +
/// den System-Installer öffnen (REQUEST_INSTALL_PACKAGES + FileProvider,
/// siehe AndroidManifest.xml). Rein informativ/manuell – kein automatischer
/// Silent-Install (kein Play Store, App ist nicht Geräteeigentümer/MDM).
class UpdateCheckService {
String get _origin {
final apiBase = ApiClient.instance.baseUrl; // endet auf .../api/v1
return apiBase.replaceFirst(RegExp(r'/api/v1/?$'), '');
}
Future<String?> checkForNewerVersion() async {
try {
final apiBase = ApiClient.instance.baseUrl; // endet auf .../api/v1
final origin = apiBase.replaceFirst(RegExp(r'/api/v1/?$'), '');
final response = await Dio(BaseOptions(
baseUrl: origin,
baseUrl: _origin,
connectTimeout: const Duration(seconds: 5),
receiveTimeout: const Duration(seconds: 5),
)).get<String>('/static/downloads/version.txt');
@@ -26,4 +32,24 @@ class UpdateCheckService {
return null;
}
}
/// Lädt die APK herunter und öffnet den Android-Paketinstaller.
/// [onProgress] liefert 0.0–1.0, falls die Antwort ein Content-Length hat.
Future<void> downloadAndInstall({void Function(double)? onProgress}) async {
final dir = await getExternalStorageDirectory() ?? await getTemporaryDirectory();
final filePath = '${dir.path}/timemaster-update.apk';
await Dio(BaseOptions(baseUrl: _origin)).download(
'/static/downloads/timemaster.apk',
filePath,
onReceiveProgress: (received, total) {
if (total > 0 && onProgress != null) onProgress(received / total);
},
);
final result = await OpenFilex.open(filePath, type: 'application/vnd.android.package-archive');
if (result.type != ResultType.done) {
throw Exception('Installer konnte nicht geöffnet werden: ${result.message}');
}
}
}
+2 -1
View File
@@ -1,7 +1,7 @@
name: timemaster_mobile
description: TimeMaster Zeiterfassung – Employee App
publish_to: 'none'
version: 0.1.6+7
version: 0.1.7+8
environment:
sdk: '>=3.3.0 <4.0.0'
@@ -16,6 +16,7 @@ dependencies:
path_provider: ^2.1.4
flutter_secure_storage: ^9.2.2
local_auth: ^2.3.0
open_filex: ^4.5.0
provider: ^6.1.2
intl: ^0.19.0