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
+75 -5

No files matched your search

+1 -1
View File
@@ -1,3 +1,3 @@
/// Manuell mit pubspec.yaml synchron halten (siehe timemaster-mobile,
/// gleiche Konvention – kein package_info_plus während aktiver Feldtests).
const String kAppVersion = '0.1.1+2';
const String kAppVersion = '0.1.2+3';
+10
View File
@@ -33,7 +33,17 @@ Future<void> main() async {
runZonedGuarded(() async {
WidgetsFlutterBinding.ensureInitialized();
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());
}, (error, stack) {
debugPrint('Unbehandelter Fehler (Kiosk $kAppVersion): $error\n$stack');
+33 -3
View File
@@ -61,6 +61,11 @@ class _LoginScreenState extends State<LoginScreen> {
_goToStamp(session);
} on DioException catch (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 {
if (mounted) setState(() => _loading = false);
}
@@ -81,13 +86,24 @@ class _LoginScreenState extends State<LoginScreen> {
await NfcManager.instance.startSession(
pollingOptions: {NfcPollingOption.iso14443, NfcPollingOption.iso15693, NfcPollingOption.iso18092},
onDiscovered: (NfcTag tag) async {
final uid = _extractUid(tag);
await NfcManager.instance.stopSession();
// try/finally: stopSession() muss auch bei einer Exception in
// _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;
setState(() {
_nfcScanning = false;
_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);
} on DioException catch (e) {
setState(() => _error = _dioMessage(e));
} catch (_) {
if (mounted) setState(() => _error = 'Unerwartete Antwort vom Server.');
} finally {
if (mounted) setState(() => _loading = false);
}
@@ -137,6 +155,8 @@ class _LoginScreenState extends State<LoginScreen> {
setState(() => _employees = list);
} on DioException catch (e) {
setState(() => _error = _dioMessage(e));
} catch (_) {
if (mounted) setState(() => _error = 'Unerwartete Antwort vom Server.');
} finally {
if (mounted) setState(() => _loading = false);
}
@@ -153,6 +173,8 @@ class _LoginScreenState extends State<LoginScreen> {
_goToStamp(session);
} on DioException catch (e) {
setState(() => _error = _dioMessage(e));
} catch (_) {
if (mounted) setState(() => _error = 'Unerwartete Antwort vom Server.');
} finally {
if (mounted) setState(() => _loading = false);
}
@@ -209,10 +231,18 @@ class _LoginScreenState extends State<LoginScreen> {
],
selected: {_mode},
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(() {
_mode = s.first;
_error = null;
_scannedUid = null;
_nfcScanning = false;
});
if (_mode == _LoginMode.list && _employees == null) _loadEmployees();
},
+22
View File
@@ -65,10 +65,21 @@ class _StampScreenState extends State<StampScreen> {
_error = _dioMessage(e);
_loading = false;
});
} catch (_) {
if (mounted) {
setState(() {
_error = 'Unerwartete Antwort vom Server.';
_loading = false;
});
}
}
}
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(() {
_actionInProgress = true;
_error = null;
@@ -86,6 +97,17 @@ class _StampScreenState extends State<StampScreen> {
_error = _dioMessage(e);
_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;
});
}
}
}
+8
View File
@@ -20,9 +20,17 @@ class ApiClient {
String? _deviceToken;
Future<void> init() async {
// Storage-Reads bewusst einzeln try/catch – wenn z.B. der Android-
// Keystore beim Kaltstart noch nicht bereit ist, soll `dio` trotzdem
// 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(
baseUrl: _baseUrl,
+1 -1
View File
@@ -1,7 +1,7 @@
name: timemaster_kiosk
description: TimeMaster Kiosk – Stempeluhr für Android-Tablets hinter VPN
publish_to: 'none'
version: 0.1.1+2
version: 0.1.2+3
environment:
sdk: '>=3.3.0 <4.0.0'