fix: Fehlerbehandlung + NFC-Session-Leak (flutter-expert-Review)

- NFC onDiscovered: try/finally um stopSession(), sonst bleibt die native
  NFC-Session bei einer Exception in _extractUid offen und jeder weitere
  Scan schlägt dauerhaft fehl (nur App-Neustart behebt es).
- Alle Login-/Stamp-Handler fangen jetzt auch generische Exceptions (nicht
  nur DioException) – sonst wirkt der Kiosk bei unerwarteter Serverantwort
  nur "eingefroren" ohne Hinweis.
- _runAction: expliziter Re-Entrancy-Guard gegen Doppel-Tap, Status-Refresh
  nach fehlgeschlagener Stempel-Aktion (kein veralteter Button-Zustand).
- ApiClient.init(): Secure-Storage-Reads einzeln try/catch, dio wird immer
  konstruiert auch wenn Keystore beim Kaltstart noch nicht bereit ist.
- main(): ApiClient.init() in try/catch – Fehler dort ließ die App vorher
  komplett schwarz bleiben (runApp() nie erreicht).

v0.1.2+3. Review von flutter-expert-Subagent, Diffs manuell gegengeprüft.

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 11:55:02 +02:00
1 parent c407da2cee
commit cea8ba8f08
6 files changed
+79 -9

No files matched your search

+1 -1
View File
@@ -1,3 +1,3 @@
/// Manuell mit pubspec.yaml synchron halten (siehe timemaster-mobile, /// Manuell mit pubspec.yaml synchron halten (siehe timemaster-mobile,
/// gleiche Konvention – kein package_info_plus während aktiver Feldtests). /// gleiche Konvention – kein package_info_plus während aktiver Feldtests).
const String kAppVersion = '0.1.1+2'; const String kAppVersion = '0.1.2+3';
+11 -1
View File
@@ -33,7 +33,17 @@ Future<void> main() async {
runZonedGuarded(() async { runZonedGuarded(() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
await ApiClient.instance.init(); try {
await ApiClient.instance.init();
} catch (e, st) {
// Scheitert z.B. flutter_secure_storage/Keystore beim Kaltstart, blieb
// die App sonst komplett schwarz stehen (runApp() nie erreicht,
// ErrorWidget.builder greift nur für Fehler im Widget-Tree, nicht
// hier) – auf einem unbeaufsichtigten Kiosk ohne angeschlossene
// Konsole nicht von einem simplen Hänger zu unterscheiden. Lieber
// sichtbar machen und mit leerem Enrollment-Status weiterstarten.
debugPrint('ApiClient.init() fehlgeschlagen (Kiosk $kAppVersion): $e\n$st');
}
runApp(const TimeMasterKioskApp()); runApp(const TimeMasterKioskApp());
}, (error, stack) { }, (error, stack) {
debugPrint('Unbehandelter Fehler (Kiosk $kAppVersion): $error\n$stack'); debugPrint('Unbehandelter Fehler (Kiosk $kAppVersion): $error\n$stack');
+33 -3
View File
@@ -61,6 +61,11 @@ class _LoginScreenState extends State<LoginScreen> {
_goToStamp(session); _goToStamp(session);
} on DioException catch (e) { } on DioException catch (e) {
setState(() => _error = _dioMessage(e)); setState(() => _error = _dioMessage(e));
} catch (_) {
// Unerwartete Antwortform (z.B. fehlendes Pflichtfeld in fromJson) darf
// nicht stillschweigend verschluckt werden – sonst wirkt der Kiosk nur
// "eingefroren" (Spinner stoppt, nichts passiert, kein Hinweis).
if (mounted) setState(() => _error = 'Unerwartete Antwort vom Server.');
} finally { } finally {
if (mounted) setState(() => _loading = false); if (mounted) setState(() => _loading = false);
} }
@@ -81,13 +86,24 @@ class _LoginScreenState extends State<LoginScreen> {
await NfcManager.instance.startSession( await NfcManager.instance.startSession(
pollingOptions: {NfcPollingOption.iso14443, NfcPollingOption.iso15693, NfcPollingOption.iso18092}, pollingOptions: {NfcPollingOption.iso14443, NfcPollingOption.iso15693, NfcPollingOption.iso18092},
onDiscovered: (NfcTag tag) async { onDiscovered: (NfcTag tag) async {
final uid = _extractUid(tag); // try/finally: stopSession() muss auch bei einer Exception in
await NfcManager.instance.stopSession(); // _extractUid laufen, sonst bleibt die native NFC-Session offen
// und jeder weitere Scan-Versuch schlägt dauerhaft fehl (nur durch
// App-Neustart behebbar).
String? uid;
String? err;
try {
uid = _extractUid(tag);
} catch (_) {
err = 'Karte konnte nicht gelesen werden.';
} finally {
await NfcManager.instance.stopSession().catchError((_) {});
}
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
_nfcScanning = false; _nfcScanning = false;
_scannedUid = uid; _scannedUid = uid;
if (uid == null) _error = 'Karte konnte nicht gelesen werden.'; _error = uid == null ? (err ?? 'Karte konnte nicht gelesen werden.') : null;
}); });
}, },
); );
@@ -121,6 +137,8 @@ class _LoginScreenState extends State<LoginScreen> {
_goToStamp(session); _goToStamp(session);
} on DioException catch (e) { } on DioException catch (e) {
setState(() => _error = _dioMessage(e)); setState(() => _error = _dioMessage(e));
} catch (_) {
if (mounted) setState(() => _error = 'Unerwartete Antwort vom Server.');
} finally { } finally {
if (mounted) setState(() => _loading = false); if (mounted) setState(() => _loading = false);
} }
@@ -137,6 +155,8 @@ class _LoginScreenState extends State<LoginScreen> {
setState(() => _employees = list); setState(() => _employees = list);
} on DioException catch (e) { } on DioException catch (e) {
setState(() => _error = _dioMessage(e)); setState(() => _error = _dioMessage(e));
} catch (_) {
if (mounted) setState(() => _error = 'Unerwartete Antwort vom Server.');
} finally { } finally {
if (mounted) setState(() => _loading = false); if (mounted) setState(() => _loading = false);
} }
@@ -153,6 +173,8 @@ class _LoginScreenState extends State<LoginScreen> {
_goToStamp(session); _goToStamp(session);
} on DioException catch (e) { } on DioException catch (e) {
setState(() => _error = _dioMessage(e)); setState(() => _error = _dioMessage(e));
} catch (_) {
if (mounted) setState(() => _error = 'Unerwartete Antwort vom Server.');
} finally { } finally {
if (mounted) setState(() => _loading = false); if (mounted) setState(() => _loading = false);
} }
@@ -209,10 +231,18 @@ class _LoginScreenState extends State<LoginScreen> {
], ],
selected: {_mode}, selected: {_mode},
onSelectionChanged: (s) { onSelectionChanged: (s) {
// Modus-Wechsel mitten im NFC-Scan: laufende native
// Session beenden, sonst bleibt sie verwaist offen und
// der nächste Scan-Versuch (auch für andere Mitarbeiter)
// schlägt dauerhaft fehl.
if (_nfcScanning) {
NfcManager.instance.stopSession().catchError((_) {});
}
setState(() { setState(() {
_mode = s.first; _mode = s.first;
_error = null; _error = null;
_scannedUid = null; _scannedUid = null;
_nfcScanning = false;
}); });
if (_mode == _LoginMode.list && _employees == null) _loadEmployees(); if (_mode == _LoginMode.list && _employees == null) _loadEmployees();
}, },
+22
View File
@@ -65,10 +65,21 @@ class _StampScreenState extends State<StampScreen> {
_error = _dioMessage(e); _error = _dioMessage(e);
_loading = false; _loading = false;
}); });
} catch (_) {
if (mounted) {
setState(() {
_error = 'Unerwartete Antwort vom Server.';
_loading = false;
});
}
} }
} }
Future<void> _runAction(Future<StampResult> Function() action) async { Future<void> _runAction(Future<StampResult> Function() action) async {
// Defensiver Re-Entrancy-Guard: die Buttons sind zwar per `disabled`
// gesperrt, aber das hängt an einem setState-Rebuild. Dieser Check kostet
// nichts und macht Doppel-Taps unabhängig vom Rendering sicher.
if (_actionInProgress) return;
setState(() { setState(() {
_actionInProgress = true; _actionInProgress = true;
_error = null; _error = null;
@@ -86,6 +97,17 @@ class _StampScreenState extends State<StampScreen> {
_error = _dioMessage(e); _error = _dioMessage(e);
_actionInProgress = false; _actionInProgress = false;
}); });
// Status kann sich serverseitig trotz Fehler geändert haben (z.B.
// Konflikt durch parallelen Stempel-Vorgang) – neu laden, damit die
// Buttons nicht einen veralteten, falschen Zustand anzeigen.
unawaited(_refresh());
} catch (_) {
if (mounted) {
setState(() {
_error = 'Unerwartete Antwort vom Server.';
_actionInProgress = false;
});
}
} }
} }
+11 -3
View File
@@ -20,9 +20,17 @@ class ApiClient {
String? _deviceToken; String? _deviceToken;
Future<void> init() async { Future<void> init() async {
final savedUrl = await _storage.read(key: _keyBaseUrl); // Storage-Reads bewusst einzeln try/catch – wenn z.B. der Android-
if (savedUrl != null && savedUrl.isNotEmpty) _baseUrl = savedUrl; // Keystore beim Kaltstart noch nicht bereit ist, soll `dio` trotzdem
_deviceToken = await _storage.read(key: _keyDeviceToken); // konstruiert werden (sonst LateInitializationError beim ersten Zugriff,
// z.B. in enroll() oder KioskService).
try {
final savedUrl = await _storage.read(key: _keyBaseUrl);
if (savedUrl != null && savedUrl.isNotEmpty) _baseUrl = savedUrl;
} catch (_) {}
try {
_deviceToken = await _storage.read(key: _keyDeviceToken);
} catch (_) {}
dio = Dio(BaseOptions( dio = Dio(BaseOptions(
baseUrl: _baseUrl, baseUrl: _baseUrl,
+1 -1
View File
@@ -1,7 +1,7 @@
name: timemaster_kiosk name: timemaster_kiosk
description: TimeMaster Kiosk – Stempeluhr für Android-Tablets hinter VPN description: TimeMaster Kiosk – Stempeluhr für Android-Tablets hinter VPN
publish_to: 'none' publish_to: 'none'
version: 0.1.1+2 version: 0.1.2+3
environment: environment:
sdk: '>=3.3.0 <4.0.0' sdk: '>=3.3.0 <4.0.0'