import 'dart:async'; import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:local_auth/local_auth.dart'; import '../services/blob_store.dart'; import '../services/documents.dart'; import '../services/legal.dart'; import '../services/secure_store.dart'; import '../services/sftp_service.dart'; import '../services/ssh_service.dart'; import '../services/vault.dart'; import 'failure.dart'; const idleChoices = [1, 2, 5, 10, 15, 30, 60]; /// Maps a platform error from the biometric key store to a message key. String biometricFailure(PlatformException e) { final text = '${e.code} ${e.message} ${e.details}'.toLowerCase(); if (text.contains('-128') || text.contains('cancel') || text.contains('negative')) { return 'biometricCancelled'; } if (text.contains('lockout') || text.contains('-8')) { return 'biometricLockout'; } if (text.contains('not enrolled') || text.contains('none_enrolled') || text.contains('no_hardware') || text.contains('-7') || text.contains('-6')) { return 'biometricUnavailable'; } if (text.contains('invalidated') || text.contains('permanently')) { return 'biometricInvalid'; } return 'biometricFailed'; } class AppController extends ChangeNotifier with WidgetsBindingObserver { final SecureStore store; final BlobStore blobs; late final Vault vault = Vault(store, blobs: blobs); late final Legal legal = Legal(store); late final SshService ssh = SshService(vault); bool ready = false, accepted = false, initialized = false, foreground = true; bool biometric = false, lockBackground = true, authenticating = false; bool systemDialog = false; String? startupError; Locale? locale; ThemeMode theme = ThemeMode.system; int idleMinutes = 5, monitorSeconds = 10; /// Editor preferences: font size, word wrap, tabs or spaces, indent width. Map editorPrefs = {}; DateTime _lastActivity = DateTime.now(); Timer? _timer; void Function()? onLocked; /// Version of the biometric key storage. Version 2 accepts only strong /// biometrics (no device passcode fallback) and is invalidated by the /// system when the enrolled biometrics change. int biometricVersion = 2; /// Whether the automatic biometric prompt was already shown for the /// current locked period; a failed or cancelled prompt is not repeated /// until the user asks for it or the app returns from the background. bool biometricAttempted = false; /// A localization key explaining why biometric unlock is not available /// right now (shown on the lock screen), or null. String? biometricNotice; SftpService? _sftp; Object? _sftpClient; Future? _opening; /// One SFTP session per SSH connection, shared by the file browser and /// the editor so closing one page never interrupts the other. Future sftp() async { final client = ssh.client; // throws 'disconnected' if (_sftp != null && identical(_sftpClient, client)) return _sftp!; if (_opening != null && identical(_sftpClient, client)) return _opening!; _sftpClient = client; final opening = _opening = client.sftp().then((s) { if (!identical(_sftpClient, client)) { s.close(); throw const Failure('disconnected'); } return _sftp = SftpService(s); }); try { return await opening; } finally { if (identical(_opening, opening)) _opening = null; } } /// An SFTP session to the server of [profileId], or 'connectToSave' when /// the app is connected elsewhere (or not at all). Future sftpFor(String profileId) async { if (!ssh.connected || ssh.profile?.id != profileId) { throw const Failure('connectToSave'); } return await sftp(); } /// Files handed over by other apps, opened once the Vault is unlocked. final pendingIncoming = []; StreamSubscription>? _incoming; /// The directory shown in the SFTP browser, offered for "save as". String? remoteDirectory; /// Files on the device (system document picker); replaceable in tests. DocumentPlatform documents = MethodChannelDocuments(); /// Open editors register here; on lock their unsaved buffers are kept as /// encrypted drafts. final draftSources = {}; /// The store holding the biometric-gated copy of the Vault data key; /// replaceable in tests. late SecureStore Function(String reason) biometricStore = (reason) => DeviceStore.biometric(reason, negative: biometricNegativeText); /// Localized prompt texts, kept current by the lock screen. String biometricReasonText = 'SM Mobile'; String biometricNegativeText = 'Cancel'; Future Function() biometricSupported = () => LocalAuthentication().isDeviceSupported(); AppController(this.store, {BlobStore? blobs}) : blobs = blobs ?? MemoryBlobStore(); Future initialize() async { WidgetsBinding.instance.addObserver(this); try { final prefs = jsonDecode(await store.read('preferences') ?? '{}'); if (['cs', 'de', 'en'].contains(prefs['language'])) { locale = Locale(prefs['language']); } theme = ThemeMode.values.firstWhere( (t) => t.name == prefs['theme'], orElse: () => ThemeMode.system, ); biometric = prefs['biometric'] == true; biometricVersion = prefs['biometricVersion'] is int ? prefs['biometricVersion'] : 1; if (biometric && biometricVersion < 2) { // Version 1 keys also opened with the device passcode. They are not // used any more; biometric unlock is set up again after the next // unlock with the master password. biometricNotice = 'biometricUpgrade'; } lockBackground = prefs['lockBackground'] != false; idleMinutes = idleChoices.contains(prefs['idleMinutes']) ? prefs['idleMinutes'] : 5; monitorSeconds = [5, 10, 30, 60].contains(prefs['monitorSeconds']) ? prefs['monitorSeconds'] : 10; if (prefs['editor'] is Map) { editorPrefs = Map.from(prefs['editor']); } accepted = await legal.load(); initialized = await vault.exists(); } catch (e) { startupError = e is Failure ? e.code : 'storageError'; } try { _incoming = documents.incoming.listen((docs) { pendingIncoming.addAll(docs); notifyListeners(); }); } catch (_) { /* No document support on this platform. */ } ready = true; _timer = Timer.periodic(const Duration(seconds: 1), (_) { if (vault.unlocked && DateTime.now().difference(_lastActivity) >= Duration(minutes: idleMinutes)) { lock(); } }); notifyListeners(); } void activity() { _lastActivity = DateTime.now(); } void changed() { notifyListeners(); } Future withSystemDialog(Future Function() action) async { systemDialog = true; try { return await action(); } finally { systemDialog = false; activity(); } } Future saveSettings() async { await store.write( 'preferences', jsonEncode({ 'language': locale?.languageCode, 'theme': theme.name, 'biometric': biometric, 'biometricVersion': biometricVersion, 'lockBackground': lockBackground, 'idleMinutes': idleMinutes, 'monitorSeconds': monitorSeconds, 'editor': editorPrefs, }), ); notifyListeners(); } Future acceptLicense() async { await legal.accept(guidanceDisplayed: true); accepted = true; notifyListeners(); } Future openVault(String password, {bool create = false}) async { if (!accepted) throw const Failure('licenseError'); if (create) { try { await vault.create(password); } finally { initialized = await vault.exists(); notifyListeners(); } } else { await vault.unlock(password); } activity(); biometricAttempted = false; if (biometric && biometricVersion < 2) await _upgradeBiometric(); notifyListeners(); } /// Moves a version 1 biometric key (which the device passcode could also /// release) to version 2 storage. Writing does not prompt on iOS; Android /// asks for a biometric confirmation, which the user may decline. Future _upgradeBiometric() async { try { await vault.enableBiometric(biometricStore(biometricReasonText)); biometricVersion = 2; biometricNotice = null; } catch (_) { biometric = false; biometricVersion = 2; biometricNotice = 'biometricReenable'; } await _deleteLegacyBiometric(); try { await saveSettings(); } catch (_) { /* Retried with the next settings change. */ } } Future _deleteLegacyBiometric() async { try { await DeviceStore.legacyBiometric().delete('key'); } catch (_) { /* Nothing stored or not accessible; it is never read again. */ } } /// Unlocks with the data key released by biometric authentication. /// /// The key is stored by the system so that only a successful strong /// biometric check releases it; a `true` from a dialog alone opens /// nothing. On failure the lock screen offers the master password. Future biometricUnlock(String reason) async { if (!accepted) throw const Failure('licenseError'); if (!biometric || biometricVersion < 2) { throw const Failure('biometricUnavailable'); } authenticating = true; biometricAttempted = true; notifyListeners(); try { await withSystemDialog( () => vault.unlockBiometric(biometricStore(reason)), ); biometricNotice = null; activity(); } on Failure catch (e) { if (e.code == 'biometricInvalid') { // Removed by the system (biometrics changed) or stale. It can never // open the Vault again; delete it and ask for the password. biometric = false; biometricNotice = 'biometricInvalid'; try { await biometricStore(reason).delete('key'); } catch (_) { /* Already gone. */ } try { await saveSettings(); } catch (_) { /* Biometric stays off in memory. */ } } rethrow; } on PlatformException catch (e) { throw Failure(biometricFailure(e)); } finally { authenticating = false; notifyListeners(); } } Future setBiometric(bool enable, String reason) async { authenticating = true; notifyListeners(); try { if (enable) { if (!await biometricSupported()) { throw const Failure('biometricUnavailable'); } // Writing the key needs no separate yes/no dialog: on Android the // write itself is confirmed with a strong biometric, and on iOS the // first read is. A failed write leaves biometric unlock off. await withSystemDialog( () => vault.enableBiometric(biometricStore(reason)), ); biometricVersion = 2; biometricNotice = null; } else { await biometricStore(reason).delete('key'); } biometric = enable; await saveSettings(); } on PlatformException catch (e) { throw Failure(biometricFailure(e)); } finally { authenticating = false; notifyListeners(); } } /// Changes the master password. The Vault gets a new data key, so a /// biometric key stored before the change can no longer open it; when /// biometric unlock is on, the new key is stored in its place, or /// biometric unlock is turned off if that is not possible. Future changePassword( String current, String next, String reason, ) async { await vault.changePassword(current, next); activity(); if (!biometric) return; try { await withSystemDialog( () => vault.enableBiometric(biometricStore(reason)), ); biometricVersion = 2; } catch (_) { biometric = false; biometricNotice = 'biometricReenable'; try { await biometricStore(reason).delete('key'); } catch (_) { /* The stored key no longer opens the Vault. */ } await saveSettings(); throw const Failure('passwordChangedBiometricOff'); } await saveSettings(); } void lock() { final drafts = []; for (final source in draftSources.toList()) { try { final draft = source(); if (draft != null) drafts.add(draft); } catch (_) { /* A broken editor must not prevent locking. */ } } ssh.disconnect(clear: true); vault.lock(drafts: drafts); biometricAttempted = false; onLocked?.call(); notifyListeners(); } @override void didChangeAppLifecycleState(AppLifecycleState state) { foreground = state == AppLifecycleState.resumed; if ((state == AppLifecycleState.paused || state == AppLifecycleState.hidden) && !systemDialog) { // Returning from the background offers the biometric prompt again. biometricAttempted = false; } if (state == AppLifecycleState.resumed) { if (vault.unlocked && DateTime.now().difference(_lastActivity) >= Duration(minutes: idleMinutes)) { lock(); } } else if ((state == AppLifecycleState.hidden || state == AppLifecycleState.paused || state == AppLifecycleState.detached) && !systemDialog && lockBackground) { lock(); } notifyListeners(); } @override void dispose() { _timer?.cancel(); _incoming?.cancel(); WidgetsBinding.instance.removeObserver(this); ssh.dispose(); vault.lock(); super.dispose(); } }