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:
1 parent
c407da2cee
commit
cea8ba8f08
6 files changed
+79
-9
No files matched your search
@@ -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
@@ -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');
|
||||||
|
|||||||
@@ -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();
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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;
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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'
|
||||||
|
|||||||
Reference in new issue
Block a user