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:
1 parent
3ed374dd68
commit
046f7f4359
6 files changed
+109
-18
No files matched your search
@@ -1,6 +1,7 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<uses-permission android:name="android.permission.INTERNET"/>
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
<uses-permission android:name="android.permission.USE_BIOMETRIC"/>
|
<uses-permission android:name="android.permission.USE_BIOMETRIC"/>
|
||||||
|
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
|
||||||
<application
|
<application
|
||||||
android:label="timemaster_mobile"
|
android:label="timemaster_mobile"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
@@ -32,6 +33,18 @@
|
|||||||
<meta-data
|
<meta-data
|
||||||
android:name="flutterEmbedding"
|
android:name="flutterEmbedding"
|
||||||
android:value="2" />
|
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>
|
</application>
|
||||||
<!-- Required to query activities that can process text, see:
|
<!-- Required to query activities that can process text, see:
|
||||||
https://developer.android.com/training/package-visibility and
|
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,4 +1,4 @@
|
|||||||
/// Manuell mit `pubspec.yaml` synchron halten (kein package_info_plus, um
|
/// Manuell mit `pubspec.yaml` synchron halten (kein package_info_plus, um
|
||||||
/// keine zusätzliche Native-Plugin-Fläche in dieser Debugging-Phase
|
/// keine zusätzliche Native-Plugin-Fläche in dieser Debugging-Phase
|
||||||
/// einzuführen). Bei jedem Release beide Stellen bumpen.
|
/// einzuführen). Bei jedem Release beide Stellen bumpen.
|
||||||
const String kAppVersion = '0.1.6+7';
|
const String kAppVersion = '0.1.7+8';
|
||||||
@@ -26,6 +26,9 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
String? _error;
|
String? _error;
|
||||||
Timer? _ticker;
|
Timer? _ticker;
|
||||||
String? _newerVersion;
|
String? _newerVersion;
|
||||||
|
final _updateService = UpdateCheckService();
|
||||||
|
bool _updating = false;
|
||||||
|
double? _updateProgress;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -34,11 +37,37 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
_ticker = Timer.periodic(const Duration(seconds: 1), (_) {
|
_ticker = Timer.periodic(const Duration(seconds: 1), (_) {
|
||||||
if (_openEntry != null) setState(() {});
|
if (_openEntry != null) setState(() {});
|
||||||
});
|
});
|
||||||
UpdateCheckService().checkForNewerVersion().then((v) {
|
_updateService.checkForNewerVersion().then((v) {
|
||||||
if (mounted && v != null) setState(() => _newerVersion = 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
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_ticker?.cancel();
|
_ticker?.cancel();
|
||||||
@@ -185,17 +214,33 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
color: Colors.blue[50],
|
color: Colors.blue[50],
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.all(12),
|
||||||
child: Row(
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
const Icon(Icons.system_update, color: Colors.blue),
|
Row(
|
||||||
const SizedBox(width: 12),
|
children: [
|
||||||
Expanded(
|
const Icon(Icons.system_update, color: Colors.blue),
|
||||||
child: Text(
|
const SizedBox(width: 12),
|
||||||
'Neue Version $_newerVersion verfügbar (installiert: $kAppVersion). '
|
Expanded(
|
||||||
'Im Portal unter Profil herunterladen.',
|
child: Text(
|
||||||
style: const TextStyle(color: Colors.blue),
|
'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'),
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,18 +1,24 @@
|
|||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:open_filex/open_filex.dart';
|
||||||
|
import 'package:path_provider/path_provider.dart';
|
||||||
import '../app_version.dart';
|
import '../app_version.dart';
|
||||||
import 'api_client.dart';
|
import 'api_client.dart';
|
||||||
|
|
||||||
/// Prüft `/static/downloads/version.txt` auf dem konfigurierten Server gegen
|
/// Prüft `/static/downloads/version.txt` auf dem konfigurierten Server gegen
|
||||||
/// die eigene kAppVersion. Rein informativ – kein Erzwingen, kein Auto-Update
|
/// die eigene kAppVersion und kann das neue APK direkt herunterladen +
|
||||||
/// (APK-Sideloading, kein Play Store). Fehler beim Check sind egal (offline,
|
/// den System-Installer öffnen (REQUEST_INSTALL_PACKAGES + FileProvider,
|
||||||
/// Server down) – dann einfach kein Hinweis.
|
/// siehe AndroidManifest.xml). Rein informativ/manuell – kein automatischer
|
||||||
|
/// Silent-Install (kein Play Store, App ist nicht Geräteeigentümer/MDM).
|
||||||
class UpdateCheckService {
|
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 {
|
Future<String?> checkForNewerVersion() async {
|
||||||
try {
|
try {
|
||||||
final apiBase = ApiClient.instance.baseUrl; // endet auf .../api/v1
|
|
||||||
final origin = apiBase.replaceFirst(RegExp(r'/api/v1/?$'), '');
|
|
||||||
final response = await Dio(BaseOptions(
|
final response = await Dio(BaseOptions(
|
||||||
baseUrl: origin,
|
baseUrl: _origin,
|
||||||
connectTimeout: const Duration(seconds: 5),
|
connectTimeout: const Duration(seconds: 5),
|
||||||
receiveTimeout: const Duration(seconds: 5),
|
receiveTimeout: const Duration(seconds: 5),
|
||||||
)).get<String>('/static/downloads/version.txt');
|
)).get<String>('/static/downloads/version.txt');
|
||||||
@@ -26,4 +32,24 @@ class UpdateCheckService {
|
|||||||
return null;
|
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
@@ -1,7 +1,7 @@
|
|||||||
name: timemaster_mobile
|
name: timemaster_mobile
|
||||||
description: TimeMaster Zeiterfassung – Employee App
|
description: TimeMaster Zeiterfassung – Employee App
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
version: 0.1.6+7
|
version: 0.1.7+8
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: '>=3.3.0 <4.0.0'
|
sdk: '>=3.3.0 <4.0.0'
|
||||||
@@ -16,6 +16,7 @@ dependencies:
|
|||||||
path_provider: ^2.1.4
|
path_provider: ^2.1.4
|
||||||
flutter_secure_storage: ^9.2.2
|
flutter_secure_storage: ^9.2.2
|
||||||
local_auth: ^2.3.0
|
local_auth: ^2.3.0
|
||||||
|
open_filex: ^4.5.0
|
||||||
provider: ^6.1.2
|
provider: ^6.1.2
|
||||||
intl: ^0.19.0
|
intl: ^0.19.0
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user