feat: add biometric authentication support and related UI screens
- Updated AndroidManifest.xml to include permissions for biometric authentication. - Changed MainActivity to extend FlutterFragmentActivity for better compatibility. - Modified gradle.properties to optimize memory settings. - Enhanced app.dart to manage new app phases for biometric authentication. - Implemented LocalVaultService methods for handling biometric key protection. - Created BiometricChoiceScreen and BiometricGateScreen for user interaction. - Updated HomeScreen to handle vault invalidation scenarios. - Registered local_auth plugin for biometric functionality on macOS and Windows. - Updated pubspec.yaml and pubspec.lock to include local_auth dependency.
This commit is contained in:
+405
-36
@@ -1,3 +1,4 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
@@ -6,6 +7,8 @@ import 'package:notas/data/local_vault_service.dart';
|
||||
import 'package:notas/data/note_repository.dart';
|
||||
import 'package:notas/platform/app_platform.dart';
|
||||
import 'package:notas/platform/window_state.dart';
|
||||
import 'package:notas/screens/biometric_choice_screen.dart';
|
||||
import 'package:notas/screens/biometric_gate_screen.dart';
|
||||
import 'package:notas/screens/home_screen.dart';
|
||||
import 'package:notas/screens/settings_screen.dart';
|
||||
import 'package:notas/screens/vault_access_screen.dart';
|
||||
@@ -20,6 +23,14 @@ enum _AppSection {
|
||||
settings,
|
||||
}
|
||||
|
||||
enum _AppPhase {
|
||||
loading,
|
||||
access,
|
||||
biometricChoice,
|
||||
biometricGate,
|
||||
notes,
|
||||
}
|
||||
|
||||
class NotesApp extends StatefulWidget {
|
||||
const NotesApp({super.key});
|
||||
|
||||
@@ -27,43 +38,125 @@ class NotesApp extends StatefulWidget {
|
||||
State<NotesApp> createState() => _NotesAppState();
|
||||
}
|
||||
|
||||
class _NotesAppState extends State<NotesApp> with WindowListener {
|
||||
class _NotesAppState extends State<NotesApp>
|
||||
with WindowListener, WidgetsBindingObserver {
|
||||
static const Duration _screenTransitionDuration = Duration(milliseconds: 280);
|
||||
static const Duration _biometricInactivityTimeout = Duration(minutes: 5);
|
||||
|
||||
final LocalVaultService _vaultService = LocalVaultService.instance;
|
||||
final GlobalKey<ScaffoldMessengerState> _scaffoldMessengerKey =
|
||||
GlobalKey<ScaffoldMessengerState>();
|
||||
final GlobalKey<NavigatorState> _navigatorKey = GlobalKey<NavigatorState>();
|
||||
|
||||
AppDatabase? _database;
|
||||
NoteRepository? _repository;
|
||||
String? _pendingEncryptionKey;
|
||||
bool _isBootstrapping = true;
|
||||
bool _isUnlocking = false;
|
||||
bool _biometricGateEnabled = false;
|
||||
int _biometricGateSession = 0;
|
||||
Timer? _biometricLockTimer;
|
||||
bool _isHandlingWindowClose = false;
|
||||
_AppPhase _phase = _AppPhase.loading;
|
||||
_AppSection _currentSection = _AppSection.home;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
if (isDesktop) {
|
||||
windowManager.addListener(this);
|
||||
windowManager.setPreventClose(true);
|
||||
}
|
||||
_bootstrapVault();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
if (isDesktop) {
|
||||
windowManager.removeListener(this);
|
||||
windowManager.setPreventClose(false);
|
||||
}
|
||||
_biometricLockTimer?.cancel();
|
||||
_database?.close();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
if (_isUnlocking) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (state) {
|
||||
case AppLifecycleState.resumed:
|
||||
_cancelBiometricLockTimer();
|
||||
return;
|
||||
case AppLifecycleState.inactive:
|
||||
case AppLifecycleState.paused:
|
||||
_scheduleBiometricLockTimer();
|
||||
return;
|
||||
case AppLifecycleState.detached:
|
||||
case AppLifecycleState.hidden:
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _bootstrapVault() async {
|
||||
try {
|
||||
final String? encryptionKey = await _vaultService.readEncryptionKey();
|
||||
final bool hasEncryptionKey = await _vaultService.hasEncryptionKey();
|
||||
_biometricGateEnabled = hasEncryptionKey && await _vaultService.isBiometricGateEnabled();
|
||||
|
||||
if (!hasEncryptionKey) {
|
||||
_pendingEncryptionKey = null;
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_phase = _AppPhase.access;
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
final bool accessCompleted = await _vaultService.isVaultAccessCompleted();
|
||||
final bool biometricChoicePending = await _vaultService.isBiometricChoicePending();
|
||||
|
||||
if (!accessCompleted) {
|
||||
_pendingEncryptionKey = await _vaultService.readStoredEncryptionKeyRaw();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_phase = _AppPhase.access;
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (biometricChoicePending) {
|
||||
_pendingEncryptionKey = await _vaultService.readStoredEncryptionKeyRaw();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_phase = _AppPhase.biometricChoice;
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (_biometricGateEnabled) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_phase = _AppPhase.biometricGate;
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
final String? encryptionKey = await _vaultService.readStoredEncryptionKeyRaw();
|
||||
if (encryptionKey != null) {
|
||||
await _openVault(encryptionKey);
|
||||
} else if (mounted) {
|
||||
setState(() {
|
||||
_phase = _AppPhase.access;
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
@@ -77,16 +170,190 @@ class _NotesAppState extends State<NotesApp> with WindowListener {
|
||||
Future<void> _openVault(String encryptionKey) async {
|
||||
await _database?.close();
|
||||
|
||||
final AppDatabase database = AppDatabase(encryptionKey: encryptionKey);
|
||||
if (!mounted) {
|
||||
await database.close();
|
||||
try {
|
||||
final AppDatabase database = AppDatabase(encryptionKey: encryptionKey);
|
||||
if (!mounted) {
|
||||
await database.close();
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_database = database;
|
||||
_repository = NoteRepository(database: database);
|
||||
_phase = _AppPhase.notes;
|
||||
});
|
||||
} catch (e) {
|
||||
// If the database file is not a valid SQLite DB (e.g., wrong key or corruption),
|
||||
// reset the local vault so the app doesn't crash. The reset will delete DB files
|
||||
// and clear the stored encryption key.
|
||||
await _resetLocalVaultData();
|
||||
|
||||
if (mounted) {
|
||||
_scaffoldMessengerKey.currentState?.showSnackBar(
|
||||
const SnackBar(content: Text('El vault local estaba corrupto y ha sido reiniciado.')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _beginInitialVaultFlow({String? actionLabel}) async {
|
||||
if (_isUnlocking) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_database = database;
|
||||
_repository = NoteRepository(database: database);
|
||||
_isUnlocking = true;
|
||||
});
|
||||
|
||||
try {
|
||||
if (actionLabel != null) {
|
||||
_showAccountPlaceholder(actionLabel);
|
||||
}
|
||||
|
||||
final String? existingKey = await _vaultService.readStoredEncryptionKeyRaw();
|
||||
final String encryptionKey = existingKey ?? await _vaultService.createEncryptionKey();
|
||||
|
||||
_pendingEncryptionKey = encryptionKey;
|
||||
await _vaultService.setVaultAccessCompleted(true);
|
||||
await _vaultService.setBiometricChoicePending(true);
|
||||
await _vaultService.setBiometricGateEnabled(false);
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_phase = _AppPhase.biometricChoice;
|
||||
_biometricGateEnabled = false;
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
_scaffoldMessengerKey.currentState?.showSnackBar(
|
||||
SnackBar(content: Text('No se pudo preparar el vault local: $error')),
|
||||
);
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isUnlocking = false;
|
||||
_isBootstrapping = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _enterWithoutAccount() {
|
||||
return _beginInitialVaultFlow();
|
||||
}
|
||||
|
||||
Future<void> _completeBiometricChoice({required bool enableBiometrics}) async {
|
||||
if (_isUnlocking) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isUnlocking = true;
|
||||
});
|
||||
|
||||
try {
|
||||
final String? pendingKey = _pendingEncryptionKey ?? await _vaultService.readStoredEncryptionKeyRaw();
|
||||
|
||||
if (pendingKey == null) {
|
||||
throw StateError('No se encontró la llave local.');
|
||||
}
|
||||
|
||||
if (enableBiometrics) {
|
||||
final bool available = await _vaultService.isBiometricAvailable();
|
||||
if (available) {
|
||||
bool activated = await _vaultService.enableBiometricProtection();
|
||||
while (!activated) {
|
||||
// Ask the user to retry or skip
|
||||
final BuildContext? dialogCtx = _navigatorKey.currentContext;
|
||||
if (dialogCtx == null) {
|
||||
break;
|
||||
}
|
||||
|
||||
final NavigatorState navigator = Navigator.of(dialogCtx);
|
||||
|
||||
final bool? retry = await showDialog<bool>(
|
||||
context: dialogCtx,
|
||||
builder: (BuildContext context) => AlertDialog(
|
||||
title: const Text('No se pudo activar la biometría'),
|
||||
content: const Text('No se pudo activar la biometría. ¿Quieres intentarlo de nuevo o entrar sin huella?'),
|
||||
actions: [
|
||||
TextButton(onPressed: () => navigator.pop(false), child: const Text('Entrar sin huella')),
|
||||
FilledButton(onPressed: () => navigator.pop(true), child: const Text('Reintentar')),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (retry != true) {
|
||||
// User chose to skip biometric activation
|
||||
break;
|
||||
}
|
||||
|
||||
activated = await _vaultService.enableBiometricProtection();
|
||||
}
|
||||
|
||||
if (activated) {
|
||||
await _vaultService.setBiometricChoicePending(false);
|
||||
await _vaultService.setVaultAccessCompleted(true);
|
||||
_biometricGateEnabled = true;
|
||||
_pendingEncryptionKey = pendingKey;
|
||||
await _openVault(pendingKey);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
_scaffoldMessengerKey.currentState?.showSnackBar(
|
||||
const SnackBar(content: Text('La biometría no está disponible en este dispositivo.')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await _vaultService.setBiometricGateEnabled(false);
|
||||
await _vaultService.setBiometricChoicePending(false);
|
||||
await _vaultService.setVaultAccessCompleted(true);
|
||||
_biometricGateEnabled = false;
|
||||
_pendingEncryptionKey = pendingKey;
|
||||
await _openVault(pendingKey);
|
||||
} catch (error) {
|
||||
_scaffoldMessengerKey.currentState?.showSnackBar(
|
||||
SnackBar(content: Text('No se pudo finalizar la configuración del vault: $error')),
|
||||
);
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isUnlocking = false;
|
||||
_isBootstrapping = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _unlockBiometricGate() async {
|
||||
if (_isUnlocking) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isUnlocking = true;
|
||||
});
|
||||
|
||||
try {
|
||||
final String? encryptionKey = await _vaultService.readEncryptionKey();
|
||||
|
||||
if (encryptionKey != null) {
|
||||
await _openVault(encryptionKey);
|
||||
}
|
||||
} catch (error) {
|
||||
_scaffoldMessengerKey.currentState?.showSnackBar(
|
||||
SnackBar(content: Text('No se pudo desbloquear el vault: $error')),
|
||||
);
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isUnlocking = false;
|
||||
_isBootstrapping = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _openSettings() {
|
||||
@@ -116,6 +383,7 @@ class _NotesAppState extends State<NotesApp> with WindowListener {
|
||||
_repository = null;
|
||||
_database = null;
|
||||
_isBootstrapping = true;
|
||||
_phase = _AppPhase.loading;
|
||||
});
|
||||
|
||||
await database?.close();
|
||||
@@ -145,33 +413,64 @@ class _NotesAppState extends State<NotesApp> with WindowListener {
|
||||
setState(() {
|
||||
_isBootstrapping = false;
|
||||
_isUnlocking = false;
|
||||
_biometricGateEnabled = false;
|
||||
_pendingEncryptionKey = null;
|
||||
_phase = _AppPhase.access;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _enterWithoutAccount() async {
|
||||
if (_isUnlocking) {
|
||||
Future<void> _lockVault() async {
|
||||
final AppDatabase? database = _database;
|
||||
|
||||
if (database == null && _repository == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
await database?.close();
|
||||
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isUnlocking = true;
|
||||
_database = null;
|
||||
_repository = null;
|
||||
_isBootstrapping = false;
|
||||
_biometricGateSession += 1;
|
||||
_phase = _AppPhase.biometricGate;
|
||||
_currentSection = _AppSection.home;
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
final String encryptionKey = await _vaultService.createEncryptionKey();
|
||||
await _openVault(encryptionKey);
|
||||
} catch (error) {
|
||||
_scaffoldMessengerKey.currentState?.showSnackBar(
|
||||
SnackBar(content: Text('No se pudo crear el vault local: $error')),
|
||||
);
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isUnlocking = false;
|
||||
_isBootstrapping = false;
|
||||
});
|
||||
}
|
||||
bool get _needsBiometricLock => _biometricGateEnabled && _repository != null;
|
||||
|
||||
void _cancelBiometricLockTimer() {
|
||||
_biometricLockTimer?.cancel();
|
||||
_biometricLockTimer = null;
|
||||
}
|
||||
|
||||
void _scheduleBiometricLockTimer() {
|
||||
if (!_needsBiometricLock) {
|
||||
return;
|
||||
}
|
||||
|
||||
_biometricLockTimer?.cancel();
|
||||
_biometricLockTimer = Timer(_biometricInactivityTimeout, () {
|
||||
if (!mounted || !_needsBiometricLock) {
|
||||
return;
|
||||
}
|
||||
|
||||
unawaited(_lockVault());
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _allowWindowClose() async {
|
||||
if (!isDesktop) {
|
||||
return;
|
||||
}
|
||||
|
||||
await windowManager.setPreventClose(false);
|
||||
await windowManager.close();
|
||||
}
|
||||
|
||||
void _showAccountPlaceholder(String actionLabel) {
|
||||
@@ -199,6 +498,7 @@ class _NotesAppState extends State<NotesApp> with WindowListener {
|
||||
|
||||
Widget _buildLoadingScreen() {
|
||||
return MaterialApp(
|
||||
navigatorKey: _navigatorKey,
|
||||
title: 'Mis Notas',
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: AppTheme.theme,
|
||||
@@ -228,6 +528,7 @@ class _NotesAppState extends State<NotesApp> with WindowListener {
|
||||
|
||||
Widget _buildAppShell({required Widget home}) {
|
||||
return MaterialApp(
|
||||
navigatorKey: _navigatorKey,
|
||||
title: 'Mis Notas',
|
||||
debugShowCheckedModeBanner: false,
|
||||
scaffoldMessengerKey: _scaffoldMessengerKey,
|
||||
@@ -242,6 +543,7 @@ class _NotesAppState extends State<NotesApp> with WindowListener {
|
||||
key: const ValueKey<String>('home-screen'),
|
||||
repository: repository,
|
||||
onOpenSettings: _openSettings,
|
||||
onVaultInvalid: _resetLocalVaultData,
|
||||
)
|
||||
: SettingsScreen(
|
||||
key: const ValueKey<String>('settings-screen'),
|
||||
@@ -250,6 +552,7 @@ class _NotesAppState extends State<NotesApp> with WindowListener {
|
||||
);
|
||||
|
||||
return MaterialApp(
|
||||
navigatorKey: _navigatorKey,
|
||||
title: 'Mis Notas',
|
||||
debugShowCheckedModeBanner: false,
|
||||
scaffoldMessengerKey: _scaffoldMessengerKey,
|
||||
@@ -308,6 +611,44 @@ class _NotesAppState extends State<NotesApp> with WindowListener {
|
||||
_saveWindowSize();
|
||||
}
|
||||
|
||||
@override
|
||||
void onWindowFocus() {
|
||||
_cancelBiometricLockTimer();
|
||||
}
|
||||
|
||||
@override
|
||||
void onWindowBlur() {
|
||||
_scheduleBiometricLockTimer();
|
||||
}
|
||||
|
||||
@override
|
||||
void onWindowClose() {
|
||||
if (_isHandlingWindowClose) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_needsBiometricLock) {
|
||||
unawaited(_allowWindowClose());
|
||||
return;
|
||||
}
|
||||
|
||||
_isHandlingWindowClose = true;
|
||||
_cancelBiometricLockTimer();
|
||||
|
||||
unawaited(() async {
|
||||
try {
|
||||
final String? encryptionKey = await _vaultService.readEncryptionKey();
|
||||
if (encryptionKey == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
await _allowWindowClose();
|
||||
} finally {
|
||||
_isHandlingWindowClose = false;
|
||||
}
|
||||
}());
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_isBootstrapping) {
|
||||
@@ -320,17 +661,45 @@ class _NotesAppState extends State<NotesApp> with WindowListener {
|
||||
return _buildMainShell(repository);
|
||||
}
|
||||
|
||||
return _buildAppShell(
|
||||
home: VaultAccessScreen(
|
||||
isBusy: _isUnlocking,
|
||||
onCreateAccountPressed: (String email, String password) async {
|
||||
_showAccountPlaceholder('Crear cuenta');
|
||||
},
|
||||
onSignInPressed: (String email, String password) async {
|
||||
_showAccountPlaceholder('Iniciar sesión');
|
||||
},
|
||||
onContinueWithoutAccount: _enterWithoutAccount,
|
||||
),
|
||||
);
|
||||
switch (_phase) {
|
||||
case _AppPhase.loading:
|
||||
return _buildLoadingScreen();
|
||||
case _AppPhase.access:
|
||||
return _buildAppShell(
|
||||
home: VaultAccessScreen(
|
||||
isBusy: _isUnlocking,
|
||||
onCreateAccountPressed: (String email, String password) async {
|
||||
await _beginInitialVaultFlow(actionLabel: 'Crear cuenta');
|
||||
},
|
||||
onSignInPressed: (String email, String password) async {
|
||||
await _beginInitialVaultFlow(actionLabel: 'Iniciar sesión');
|
||||
},
|
||||
onContinueWithoutAccount: _enterWithoutAccount,
|
||||
),
|
||||
);
|
||||
case _AppPhase.biometricChoice:
|
||||
return _buildAppShell(
|
||||
home: BiometricChoiceScreen(
|
||||
isBusy: _isUnlocking,
|
||||
onEnableBiometrics: () => _completeBiometricChoice(enableBiometrics: true),
|
||||
onSkipBiometrics: () => _completeBiometricChoice(enableBiometrics: false),
|
||||
),
|
||||
);
|
||||
case _AppPhase.biometricGate:
|
||||
return _buildAppShell(
|
||||
home: BiometricGateScreen(
|
||||
key: ValueKey<int>(_biometricGateSession),
|
||||
isBusy: _isUnlocking,
|
||||
onUnlockRequested: _unlockBiometricGate,
|
||||
),
|
||||
);
|
||||
case _AppPhase.notes:
|
||||
if (repository == null) {
|
||||
return _buildLoadingScreen();
|
||||
}
|
||||
|
||||
return _buildMainShell(repository);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user