From 9c199a6dcd414a0e8fcf5238d96ed47023ee693b Mon Sep 17 00:00:00 2001 From: Tristan Katana <50181095+felixmuinde@users.noreply.github.com> Date: Wed, 15 Apr 2026 20:48:13 +0300 Subject: [PATCH] feat(mobile): Add biometric lock for app resume (#1474) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Feature: Biometric lock for app resume User enables "Biometric Lock" in Settings → prompted to verify fingerprint/face first. User backgrounds the app → _isLocked = true. User returns → lock screen appears, auto-triggers biometric prompt. Success → app unlocks, state preserved underneath. Can retry or log out as fallback. Add USE_BIOMETRIC permission to AndroidManifest. * Fix: Remove duplicate local_auth entry in pubspec.lock and add NSFaceIDUsageDescription to iOS Info.plist * fix(mobile) : Remove duplicate local auth files first * fix(mobile): keep MainNavigationScreen in the Stack, let the lock screen float above, no unmounting * Updtae: Swap out Flutter Activity for FlutterFragmentActivity that extends the lock scan feature * fix(mobile): address biometric lock PR review feedback * fix(mobile): only require biometric auth when enabling lock, not disabling Prevents users from getting locked out if biometrics start failing — they can now disable the lock without needing to pass biometric auth. * fix(mobile): add missing closing brace in setBiometricEnabled --------- Signed-off-by: Tristan Katana <50181095+felixmuinde@users.noreply.github.com> --- .../android/app/src/main/AndroidManifest.xml | 3 +- .../kotlin/am/sure/mobile/MainActivity.kt | 4 +- mobile/ios/Runner/Info.plist | 2 + mobile/lib/main.dart | 53 +++++++++- mobile/lib/screens/biometric_lock_screen.dart | 99 +++++++++++++++++++ mobile/lib/screens/settings_screen.dart | 66 +++++++++++++ mobile/lib/services/biometric_service.dart | 47 +++++++++ mobile/lib/services/preferences_service.dart | 11 +++ mobile/pubspec.lock | 64 ++++++++++-- mobile/pubspec.yaml | 1 + 10 files changed, 337 insertions(+), 13 deletions(-) create mode 100644 mobile/lib/screens/biometric_lock_screen.dart create mode 100644 mobile/lib/services/biometric_service.dart diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml index 353c34933..bd068e898 100644 --- a/mobile/android/app/src/main/AndroidManifest.xml +++ b/mobile/android/app/src/main/AndroidManifest.xml @@ -2,7 +2,8 @@ - + + UIApplicationSupportsIndirectInputEvents + NSFaceIDUsageDescription + Authenticate with Face ID to unlock the app CFBundleURLTypes diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index 0d7db3b2d..8bb8457a0 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -10,11 +10,13 @@ import 'providers/chat_provider.dart'; import 'providers/theme_provider.dart'; import 'screens/backend_config_screen.dart'; import 'screens/login_screen.dart'; +import 'screens/biometric_lock_screen.dart'; import 'screens/main_navigation_screen.dart'; import 'screens/sso_onboarding_screen.dart'; import 'services/api_config.dart'; import 'services/connectivity_service.dart'; import 'services/log_service.dart'; +import 'services/preferences_service.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -163,25 +165,56 @@ class AppWrapper extends StatefulWidget { State createState() => _AppWrapperState(); } -class _AppWrapperState extends State { +class _AppWrapperState extends State with WidgetsBindingObserver { bool _isCheckingConfig = true; bool _hasBackendUrl = false; + bool _isLocked = false; late final AppLinks _appLinks; StreamSubscription? _linkSubscription; @override void initState() { super.initState(); + WidgetsBinding.instance.addObserver(this); _checkBackendConfig(); _initDeepLinks(); } @override void dispose() { + WidgetsBinding.instance.removeObserver(this); _linkSubscription?.cancel(); super.dispose(); } + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.paused) { + _markLockedIfEnabled(); + } else if (state == AppLifecycleState.resumed && _isLocked) { + // Lock screen is already showing via build(); biometric auto-triggers there. + } + } + + Future _markLockedIfEnabled() async { + final authProvider = Provider.of(context, listen: false); + if (!authProvider.isAuthenticated) return; + final enabled = await PreferencesService.instance.getBiometricEnabled(); + if (enabled && mounted) { + setState(() => _isLocked = true); + } + } + + void _onUnlocked() { + if (mounted) setState(() => _isLocked = false); + } + + Future _onLockLogout() async { + final authProvider = Provider.of(context, listen: false); + await authProvider.logout(); + if (mounted) setState(() => _isLocked = false); + } + void _initDeepLinks() { _appLinks = AppLinks(); @@ -258,7 +291,23 @@ class _AppWrapperState extends State { } if (authProvider.isAuthenticated) { - return const MainNavigationScreen(); + return Stack( + children: [ + const MainNavigationScreen(), + if (_isLocked) + BiometricLockScreen( + onUnlocked: _onUnlocked, + onLogout: _onLockLogout, + ), + ], + ); + } + + // Clear stale lock state so it doesn't flash on the next login. + if (_isLocked) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) setState(() => _isLocked = false); + }); } if (authProvider.ssoOnboardingPending) { diff --git a/mobile/lib/screens/biometric_lock_screen.dart b/mobile/lib/screens/biometric_lock_screen.dart new file mode 100644 index 000000000..536e0a9c0 --- /dev/null +++ b/mobile/lib/screens/biometric_lock_screen.dart @@ -0,0 +1,99 @@ +import 'package:flutter/material.dart'; +import '../services/biometric_service.dart'; + +class BiometricLockScreen extends StatefulWidget { + const BiometricLockScreen({ + super.key, + required this.onUnlocked, + this.onLogout, + }); + + final VoidCallback onUnlocked; + final VoidCallback? onLogout; + + @override + State createState() => _BiometricLockScreenState(); +} + +class _BiometricLockScreenState extends State { + bool _isAuthenticating = false; + + @override + void initState() { + super.initState(); + // Auto-trigger biometric prompt on first show. + WidgetsBinding.instance.addPostFrameCallback((_) => _authenticate()); + } + + Future _authenticate() async { + if (!mounted || _isAuthenticating) return; + setState(() => _isAuthenticating = true); + + final success = await BiometricService.instance.authenticate(); + + if (!mounted) return; + setState(() => _isAuthenticating = false); + + if (success) { + widget.onUnlocked(); + } else { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Authentication failed. Tap Unlock to try again.')), + ); + } + } + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return Scaffold( + body: Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.lock_outline, + size: 72, + color: colorScheme.primary, + ), + const SizedBox(height: 24), + Text( + 'App Locked', + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + Text( + 'Authenticate to continue', + style: TextStyle(color: colorScheme.onSurfaceVariant), + ), + const SizedBox(height: 40), + FilledButton.icon( + onPressed: _isAuthenticating ? null : _authenticate, + icon: const Icon(Icons.fingerprint), + label: Text(_isAuthenticating ? 'Authenticating…' : 'Unlock'), + style: FilledButton.styleFrom( + minimumSize: const Size(200, 50), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), + if (widget.onLogout != null) ...[ + const SizedBox(height: 16), + TextButton( + onPressed: widget.onLogout, + child: const Text('Log out'), + ), + ], + ], + ), + ), + ), + ); + } +} diff --git a/mobile/lib/screens/settings_screen.dart b/mobile/lib/screens/settings_screen.dart index 725041cc1..1806d7b5e 100644 --- a/mobile/lib/screens/settings_screen.dart +++ b/mobile/lib/screens/settings_screen.dart @@ -7,6 +7,7 @@ import '../providers/categories_provider.dart'; import '../providers/theme_provider.dart'; import '../services/offline_storage_service.dart'; import '../services/log_service.dart'; +import '../services/biometric_service.dart'; import '../services/preferences_service.dart'; import '../services/user_service.dart'; import 'log_viewer_screen.dart'; @@ -23,12 +24,53 @@ class _SettingsScreenState extends State { String? _appVersion; bool _isResettingAccount = false; bool _isDeletingAccount = false; + bool _biometricSupported = false; + bool _biometricEnabled = false; + bool _isTogglingBiometric = false; @override void initState() { super.initState(); _loadPreferences(); _loadAppVersion(); + _loadBiometricState(); + } + + Future _loadBiometricState() async { + final supported = await BiometricService.instance.isDeviceSupported(); + final enabled = await PreferencesService.instance.getBiometricEnabled(); + if (!supported && enabled) { + await PreferencesService.instance.setBiometricEnabled(false); + } + if (mounted) { + setState(() { + _biometricSupported = supported; + _biometricEnabled = supported && enabled; + }); + } + } + + Future _toggleBiometric(bool value) async { + if (_isTogglingBiometric) return; + setState(() => _isTogglingBiometric = true); + try { + if (value) { + final success = await BiometricService.instance.authenticate( + reason: 'Verify biometric to enable app lock', + ); + if (!mounted) return; + if (!success) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Biometric authentication failed.')), + ); + return; + } + } + await PreferencesService.instance.setBiometricEnabled(value); + if (mounted) setState(() => _biometricEnabled = value); + } finally { + if (mounted) setState(() => _isTogglingBiometric = false); + } } Future _loadAppVersion() async { @@ -461,6 +503,30 @@ class _SettingsScreenState extends State { onTap: () => _handleClearLocalData(context), ), + if (_biometricSupported) ...[ + const Divider(), + + const Padding( + padding: EdgeInsets.fromLTRB(16, 16, 16, 8), + child: Text( + 'Security', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + color: Colors.grey, + ), + ), + ), + + SwitchListTile( + secondary: const Icon(Icons.fingerprint), + title: const Text('Biometric Lock'), + subtitle: const Text('Require biometric authentication when resuming the app'), + value: _biometricEnabled, + onChanged: _isTogglingBiometric ? null : _toggleBiometric, + ), + ], + const Divider(), // Danger Zone Section diff --git a/mobile/lib/services/biometric_service.dart b/mobile/lib/services/biometric_service.dart new file mode 100644 index 000000000..b6daa17a4 --- /dev/null +++ b/mobile/lib/services/biometric_service.dart @@ -0,0 +1,47 @@ +import 'package:local_auth/local_auth.dart'; +import 'log_service.dart'; + +class BiometricService { + static final BiometricService instance = BiometricService._(); + BiometricService._(); + + final LocalAuthentication _auth = LocalAuthentication(); + + /// Returns true when the platform supports biometrics AND at least one + /// biometric is enrolled on the device. + Future isDeviceSupported() async { + try { + final isSupported = await _auth.isDeviceSupported(); + if (!isSupported) return false; + final enrolled = await _auth.getAvailableBiometrics(); + return enrolled.isNotEmpty; + } catch (_) { + return false; + } + } + + /// Returns the list of enrolled biometric types (fingerprint, face, etc.). + Future> getAvailableBiometrics() async { + try { + return await _auth.getAvailableBiometrics(); + } catch (_) { + return []; + } + } + + /// Triggers the OS biometric prompt. Returns true if authentication succeeds. + Future authenticate({String reason = 'Unlock Sure to continue'}) async { + try { + return await _auth.authenticate( + localizedReason: reason, + options: const AuthenticationOptions( + stickyAuth: true, + biometricOnly: false, + ), + ); + } catch (e, stack) { + LogService.instance.error('BiometricService', 'authenticate() failed: $e\n$stack'); + return false; + } + } +} diff --git a/mobile/lib/services/preferences_service.dart b/mobile/lib/services/preferences_service.dart index 045f60a02..05b2b46c7 100644 --- a/mobile/lib/services/preferences_service.dart +++ b/mobile/lib/services/preferences_service.dart @@ -2,6 +2,7 @@ import 'package:shared_preferences/shared_preferences.dart'; class PreferencesService { static const _groupByTypeKey = 'dashboard_group_by_type'; + static const _biometricEnabledKey = 'biometric_enabled'; static const _showCategoryFilterKey = 'dashboard_show_category_filter'; static const _themeModeKey = 'theme_mode'; @@ -30,6 +31,16 @@ class PreferencesService { await prefs.setBool(_groupByTypeKey, value); } + Future getBiometricEnabled() async { + final prefs = await _preferences; + return prefs.getBool(_biometricEnabledKey) ?? false; + } + + Future setBiometricEnabled(bool value) async { + final prefs = await _preferences; + await prefs.setBool(_biometricEnabledKey, value); + } + Future getShowCategoryFilter() async { final prefs = await _preferences; return prefs.getBool(_showCategoryFilterKey) ?? false; diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 2756225b2..a9d66b802 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -69,10 +69,10 @@ packages: dependency: transitive description: name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.4.1" checked_yaml: dependency: transitive description: @@ -198,6 +198,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.0" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: ee8068e0e1cd16c4a82714119918efdeed33b3ba7772c54b5d094ab53f9b7fd1 + url: "https://pub.dev" + source: hosted + version: "2.0.33" flutter_markdown: dependency: "direct main" description: @@ -352,6 +360,46 @@ packages: url: "https://pub.dev" source: hosted version: "6.1.0" + local_auth: + dependency: "direct main" + description: + name: local_auth + sha256: "434d854cf478f17f12ab29a76a02b3067f86a63a6d6c4eb8fbfdcfe4879c1b7b" + url: "https://pub.dev" + source: hosted + version: "2.3.0" + local_auth_android: + dependency: transitive + description: + name: local_auth_android + sha256: a0bdfcc0607050a26ef5b31d6b4b254581c3d3ce3c1816ab4d4f4a9173e84467 + url: "https://pub.dev" + source: hosted + version: "1.0.56" + local_auth_darwin: + dependency: transitive + description: + name: local_auth_darwin + sha256: "699873970067a40ef2f2c09b4c72eb1cfef64224ef041b3df9fdc5c4c1f91f49" + url: "https://pub.dev" + source: hosted + version: "1.6.1" + local_auth_platform_interface: + dependency: transitive + description: + name: local_auth_platform_interface + sha256: f98b8e388588583d3f781f6806e4f4c9f9e189d898d27f0c249b93a1973dd122 + url: "https://pub.dev" + source: hosted + version: "1.1.0" + local_auth_windows: + dependency: transitive + description: + name: local_auth_windows + sha256: bc4e66a29b0fdf751aafbec923b5bed7ad6ed3614875d8151afe2578520b2ab5 + url: "https://pub.dev" + source: hosted + version: "1.0.11" markdown: dependency: transitive description: @@ -364,18 +412,18 @@ packages: dependency: transitive description: name: matcher - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" url: "https://pub.dev" source: hosted - version: "0.12.17" + version: "0.12.18" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" url: "https://pub.dev" source: hosted - version: "0.11.1" + version: "0.13.0" meta: dependency: transitive description: @@ -673,10 +721,10 @@ packages: dependency: transitive description: name: test_api - sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 + sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636" url: "https://pub.dev" source: hosted - version: "0.7.7" + version: "0.7.9" typed_data: dependency: transitive description: diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index d65c19585..f5af60d82 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -24,6 +24,7 @@ dependencies: url_launcher: ^6.2.5 flutter_svg: ^2.2.0 package_info_plus: ^8.0.0 + local_auth: ^2.3.0 flutter_markdown: ^0.7.2 dev_dependencies: