diff --git a/devtools_options.yaml b/devtools_options.yaml new file mode 100644 index 00000000..fa0b357c --- /dev/null +++ b/devtools_options.yaml @@ -0,0 +1,3 @@ +description: This file stores settings for Dart & Flutter DevTools. +documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states +extensions: diff --git a/lib/Database/database_manager.dart b/lib/Database/database_manager.dart index abcdff4c..66f6aa95 100644 --- a/lib/Database/database_manager.dart +++ b/lib/Database/database_manager.dart @@ -45,6 +45,11 @@ class DatabaseManager { return _database!; } + static resetDatabase() async { + await _database?.close(); + _database = null; + } + static Future initDataBase(String password) async { if (_database == null) { appProvider.currentDatabasePassword = password; diff --git a/lib/Screens/Lock/database_decrypt_screen.dart b/lib/Screens/Lock/database_decrypt_screen.dart index 9a6fe89e..cbc75a42 100644 --- a/lib/Screens/Lock/database_decrypt_screen.dart +++ b/lib/Screens/Lock/database_decrypt_screen.dart @@ -1,15 +1,17 @@ import 'dart:math'; +import 'package:biometric_storage/biometric_storage.dart'; import 'package:cloudotp/Resources/theme.dart'; +import 'package:cloudotp/Utils/itoast.dart'; import 'package:cloudotp/Utils/route_util.dart'; import 'package:cloudotp/Widgets/Dialog/custom_dialog.dart'; import 'package:cloudotp/Widgets/Item/item_builder.dart'; import 'package:cloudotp/Widgets/Scaffold/my_scaffold.dart'; import 'package:flutter/material.dart'; -import 'package:local_auth/local_auth.dart'; import 'package:window_manager/window_manager.dart'; import '../../Database/database_manager.dart'; +import '../../Utils/app_provider.dart'; import '../../Utils/biometric_util.dart'; import '../../Utils/constant.dart'; import '../../Utils/hive_util.dart'; @@ -34,27 +36,69 @@ class DatabaseDecryptScreenState extends State GlobalKey formKey = GlobalKey(); bool _isMaximized = false; bool _isStayOnTop = false; - bool _biometricAvailable = false; - final bool _enableDatabaseBiometric = HiveUtil.getBool( - HiveUtil.enableDatabaseBiometricKey, - defaultValue: false); + bool _isValidated = true; + final bool _allowDatabaseBiometric = + HiveUtil.getBool(HiveUtil.allowDatabaseBiometricKey, defaultValue: false); + String? canAuthenticateResponseString; + CanAuthenticateResponse? canAuthenticateResponse; + + bool get _biometricAvailable => canAuthenticateResponse?.isSuccess ?? false; auth() async { - String? password = await BiometricUtil.getDatabasePassword(); - if (password != null && password.isNotEmpty) { - validateAsyncController.controller.text = password; - onSubmit(); + try { + canAuthenticateResponse = await BiometricUtil.canAuthenticate(); + canAuthenticateResponseString = + await BiometricUtil.getCanAuthenticateResponseString(); + if (canAuthenticateResponse == CanAuthenticateResponse.success) { + String? password = await BiometricUtil.getDatabasePassword(); + if (password == null) { + setState(() { + _isValidated = false; + HiveUtil.put(HiveUtil.allowDatabaseBiometricKey, false); + }); + IToast.showTop(S.current.biometricChanged); + FocusScope.of(context).requestFocus(_focusNode); + } + if (password != null && password.isNotEmpty) { + validateAsyncController.controller.text = password; + onSubmit(); + } + } else { + IToast.showTop(canAuthenticateResponseString ?? ""); + } + } catch (e, t) { + ILogger.error("Failed to authenticate with biometric", e, t); + if (e is AuthException) { + switch (e.code) { + case AuthExceptionCode.userCanceled: + IToast.showTop(S.current.biometricUserCanceled); + break; + case AuthExceptionCode.timeout: + IToast.showTop(S.current.biometricTimeout); + break; + case AuthExceptionCode.unknown: + IToast.showTop(S.current.biometricLockout); + break; + case AuthExceptionCode.canceled: + default: + IToast.showTop(S.current.biometricError); + break; + } + } else { + IToast.showTop(S.current.biometricError); + } } } initBiometricAuthentication() async { - LocalAuthentication localAuth = LocalAuthentication(); - bool available = await localAuth.canCheckBiometrics; - setState(() { - _biometricAvailable = available; - }); - if (_biometricAvailable && _enableDatabaseBiometric) { + canAuthenticateResponse = await BiometricUtil.canAuthenticate(); + canAuthenticateResponseString = + await BiometricUtil.getCanAuthenticateResponseString(); + setState(() {}); + if (_biometricAvailable && _allowDatabaseBiometric) { auth(); + } else { + FocusScope.of(context).requestFocus(_focusNode); } } @@ -95,9 +139,6 @@ class DatabaseDecryptScreenState extends State super.initState(); initBiometricAuthentication(); windowManager.addListener(this); - Future.delayed(const Duration(milliseconds: 200), () { - FocusScope.of(context).requestFocus(_focusNode); - }); validateAsyncController = InputValidateAsyncController( listen: false, validator: (text) async { @@ -164,7 +205,8 @@ class DatabaseDecryptScreenState extends State if (isValidAsync) { if (DatabaseManager.initialized) { Navigator.of(context).pushReplacement(RouteUtil.getFadeRoute( - ItemBuilder.buildContextMenuOverlay(const MainScreen()))); + ItemBuilder.buildContextMenuOverlay( + MainScreen(key: mainScreenKey)))); } } else { _focusNode.requestFocus(); @@ -215,33 +257,31 @@ class DatabaseDecryptScreenState extends State ), ), const SizedBox(height: 30), - ItemBuilder.buildRoundButton( - context, - text: S.current.confirm, - fontSizeDelta: 2, - background: Theme.of(context).primaryColor, - padding: const EdgeInsets.symmetric(horizontal: 80, vertical: 12), - onTap: onSubmit, - ), - const Spacer(), - ItemBuilder.buildClickItem( - clickable: _biometricAvailable && _enableDatabaseBiometric, - GestureDetector( - onTap: _biometricAvailable && _enableDatabaseBiometric - ? () { - auth(); - } - : null, - child: Text( - _biometricAvailable && _enableDatabaseBiometric - ? S.current.biometric - : "", - style: Theme.of(context).textTheme.bodySmall?.copyWith( - fontSize: Theme.of(context).textTheme.titleSmall!.fontSize, - ), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (_biometricAvailable) + ItemBuilder.buildRoundButton( + context, + text: S.current.biometric, + fontSizeDelta: 2, + disabled: !(_allowDatabaseBiometric && _isValidated), + padding: + const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + onTap: () => auth(), + ), + if (_biometricAvailable) const SizedBox(width: 10), + ItemBuilder.buildRoundButton( + context, + text: S.current.confirm, + fontSizeDelta: 2, + background: Theme.of(context).primaryColor, + padding: const EdgeInsets.symmetric(horizontal: 80, vertical: 12), + onTap: onSubmit, ), - ), + ], ), + const Spacer(), ], ); } diff --git a/lib/Screens/Lock/pin_change_screen.dart b/lib/Screens/Lock/pin_change_screen.dart index 91cf1bd3..6dda24db 100644 --- a/lib/Screens/Lock/pin_change_screen.dart +++ b/lib/Screens/Lock/pin_change_screen.dart @@ -1,5 +1,6 @@ import 'dart:math'; +import 'package:biometric_storage/biometric_storage.dart'; import 'package:cloudotp/Utils/itoast.dart'; import 'package:cloudotp/Utils/responsive_util.dart'; import 'package:cloudotp/Widgets/General/Unlock/gesture_notifier.dart'; @@ -8,6 +9,7 @@ import 'package:cloudotp/Widgets/General/Unlock/gesture_unlock_view.dart'; import 'package:cloudotp/Widgets/Item/item_builder.dart'; import 'package:flutter/material.dart'; +import '../../Utils/biometric_util.dart'; import '../../Utils/hive_util.dart'; import '../../Utils/utils.dart'; import '../../generated/l10n.dart'; @@ -26,8 +28,8 @@ class PinChangeScreenState extends State { final String? _oldPassword = HiveUtil.getString(HiveUtil.guesturePasswdKey); bool _isEditMode = HiveUtil.getString(HiveUtil.guesturePasswdKey) != null && HiveUtil.getString(HiveUtil.guesturePasswdKey)!.isNotEmpty; - late final bool _isUseBiometric = - _isEditMode && HiveUtil.getBool(HiveUtil.enableBiometricKey); + late final bool _enableBiometric = + HiveUtil.getBool(HiveUtil.enableBiometricKey); late final GestureNotifier _notifier = _isEditMode ? GestureNotifier( status: GestureStatus.verify, @@ -38,10 +40,22 @@ class PinChangeScreenState extends State { final GlobalKey _gestureUnlockView = GlobalKey(); final GlobalKey _indicator = GlobalKey(); + String? canAuthenticateResponseString; + CanAuthenticateResponse? canAuthenticateResponse; + + bool get _biometricAvailable => canAuthenticateResponse?.isSuccess ?? false; + @override void initState() { super.initState(); - if (_isUseBiometric) { + initBiometricAuthentication(); + } + + initBiometricAuthentication() async { + canAuthenticateResponse = await BiometricUtil.canAuthenticate(); + canAuthenticateResponseString = + await BiometricUtil.getCanAuthenticateResponseString(); + if (_biometricAvailable && _enableBiometric && _isEditMode) { auth(); } } @@ -100,22 +114,16 @@ class PinChangeScreenState extends State { onCompleted: _gestureComplete, ), ), - GestureDetector( - onTap: () { - if (_isEditMode && _isUseBiometric) { + Visibility( + visible: _isEditMode && _biometricAvailable && _enableBiometric, + child: ItemBuilder.buildRoundButton( + context, + text: ResponsiveUtil.isWindows() + ? S.current.biometricVerifyPin + : S.current.biometric, + onTap: () { auth(); - } - }, - child: ItemBuilder.buildClickItem( - clickable: _isEditMode && _isUseBiometric, - Text( - _isEditMode && _isUseBiometric - ? (ResponsiveUtil.isWindows() - ? S.current.biometricVerifyPin - : S.current.biometricVerifyFingerprint) - : "", - style: Theme.of(context).textTheme.titleSmall, - ), + }, ), ), const SizedBox(height: 50), diff --git a/lib/Screens/Lock/pin_verify_screen.dart b/lib/Screens/Lock/pin_verify_screen.dart index 549fb2df..ca2df239 100644 --- a/lib/Screens/Lock/pin_verify_screen.dart +++ b/lib/Screens/Lock/pin_verify_screen.dart @@ -1,5 +1,7 @@ import 'dart:math'; +import 'package:biometric_storage/biometric_storage.dart'; +import 'package:cloudotp/Utils/app_provider.dart'; import 'package:cloudotp/Utils/route_util.dart'; import 'package:cloudotp/Utils/utils.dart'; import 'package:cloudotp/Widgets/General/Unlock/gesture_notifier.dart'; @@ -8,6 +10,7 @@ import 'package:flutter/material.dart'; import 'package:window_manager/window_manager.dart'; import '../../Resources/theme.dart'; +import '../../Utils/biometric_util.dart'; import '../../Utils/hive_util.dart'; import '../../Utils/responsive_util.dart'; import '../../Widgets/Item/item_builder.dart'; @@ -37,13 +40,17 @@ class PinVerifyScreen extends StatefulWidget { class PinVerifyScreenState extends State with WindowListener { final String? _password = HiveUtil.getString(HiveUtil.guesturePasswdKey); - late final bool _isUseBiometric = - HiveUtil.getBool(HiveUtil.enableBiometricKey); + late final bool _enableBiometric = + HiveUtil.getBool(HiveUtil.enableBiometricKey); late final GestureNotifier _notifier = GestureNotifier( status: GestureStatus.verify, gestureText: S.current.verifyGestureLock); final GlobalKey _gestureUnlockView = GlobalKey(); bool _isMaximized = false; bool _isStayOnTop = false; + String? canAuthenticateResponseString; + CanAuthenticateResponse? canAuthenticateResponse; + + bool get _biometricAvailable => canAuthenticateResponse?.isSuccess ?? false; @override void onWindowMaximize() { @@ -81,7 +88,15 @@ class PinVerifyScreenState extends State with WindowListener { void initState() { windowManager.addListener(this); super.initState(); - if (_isUseBiometric && widget.autoAuth) { + initBiometricAuthentication(); + } + + initBiometricAuthentication() async { + canAuthenticateResponse = await BiometricUtil.canAuthenticate(); + canAuthenticateResponseString = + await BiometricUtil.getCanAuthenticateResponseString(); + setState(() {}); + if (_biometricAvailable && _enableBiometric && widget.autoAuth) { auth(); } } @@ -92,7 +107,8 @@ class PinVerifyScreenState extends State with WindowListener { if (widget.onSuccess != null) widget.onSuccess!(); if (widget.jumpToMain) { Navigator.of(context).pushReplacement(RouteUtil.getFadeRoute( - ItemBuilder.buildContextMenuOverlay(const MainScreen()))); + ItemBuilder.buildContextMenuOverlay( + MainScreen(key: mainScreenKey)))); } else { Navigator.of(context).pop(); } @@ -107,26 +123,28 @@ class PinVerifyScreenState extends State with WindowListener { backgroundColor: MyTheme.getBackground(context), appBar: ResponsiveUtil.isDesktop() && widget.showWindowTitle ? PreferredSize( - preferredSize: const Size(0, 86), - child: ItemBuilder.buildWindowTitle( - context, - forceClose: true, - backgroundColor: MyTheme.getBackground(context), - isStayOnTop: _isStayOnTop, - isMaximized: _isMaximized, - onStayOnTopTap: () { - setState(() { - _isStayOnTop = !_isStayOnTop; - windowManager.setAlwaysOnTop(_isStayOnTop); - }); - }, - ), - ) + preferredSize: const Size(0, 86), + child: ItemBuilder.buildWindowTitle( + context, + forceClose: true, + backgroundColor: MyTheme.getBackground(context), + isStayOnTop: _isStayOnTop, + isMaximized: _isMaximized, + onStayOnTopTap: () { + setState(() { + _isStayOnTop = !_isStayOnTop; + windowManager.setAlwaysOnTop(_isStayOnTop); + }); + }, + ), + ) + : null, + bottomNavigationBar: widget.showWindowTitle + ? Container( + height: 86, + color: MyTheme.getBackground(context), + ) : null, - bottomNavigationBar: widget.showWindowTitle ? Container( - height: 86, - color: MyTheme.getBackground(context), - ) : null, body: SafeArea( right: false, child: Center( @@ -140,24 +158,17 @@ class PinVerifyScreenState extends State with WindowListener { const SizedBox(height: 50), Text( _notifier.gestureText, - style: Theme - .of(context) - .textTheme - .titleMedium, + style: Theme.of(context).textTheme.titleMedium, ), const SizedBox(height: 30), Flexible( child: GestureUnlockView( key: _gestureUnlockView, - size: min(MediaQuery - .sizeOf(context) - .width, 400), + size: min(MediaQuery.sizeOf(context).width, 400), padding: 60, roundSpace: 40, defaultColor: Colors.grey.withOpacity(0.5), - selectedColor: Theme - .of(context) - .primaryColor, + selectedColor: Theme.of(context).primaryColor, failedColor: Colors.redAccent, disableColor: Colors.grey, solidRadiusRatio: 0.3, @@ -167,22 +178,15 @@ class PinVerifyScreenState extends State with WindowListener { ), ), Visibility( - visible: _isUseBiometric, - child: GestureDetector( + visible: _biometricAvailable && _enableBiometric, + child: ItemBuilder.buildRoundButton( + context, + text: ResponsiveUtil.isWindows() + ? S.current.biometricVerifyPin + : S.current.biometric, onTap: () { auth(); }, - child: ItemBuilder.buildClickItem( - Text( - ResponsiveUtil.isWindows() - ? S.current.biometricVerifyPin - : S.current.biometricVerifyFingerprint, - style: Theme - .of(context) - .textTheme - .titleSmall, - ), - ), ), ), const SizedBox(height: 50), @@ -203,7 +207,8 @@ class PinVerifyScreenState extends State with WindowListener { if (widget.onSuccess != null) widget.onSuccess!(); if (widget.jumpToMain) { Navigator.of(context).pushReplacement(RouteUtil.getFadeRoute( - ItemBuilder.buildContextMenuOverlay(const MainScreen()))); + ItemBuilder.buildContextMenuOverlay( + MainScreen(key: mainScreenKey)))); } else { Navigator.of(context).pop(); } diff --git a/lib/Screens/Setting/backup_log_screen.dart b/lib/Screens/Setting/backup_log_screen.dart index f5eed5e4..acd4b0c9 100644 --- a/lib/Screens/Setting/backup_log_screen.dart +++ b/lib/Screens/Setting/backup_log_screen.dart @@ -16,7 +16,12 @@ import '../../Utils/utils.dart'; import '../../generated/l10n.dart'; class BackupLogScreen extends StatefulWidget { - const BackupLogScreen({super.key}); + const BackupLogScreen({ + super.key, + this.isOverlay = false, + }); + + final bool isOverlay; @override BackupLogScreenState createState() => BackupLogScreenState(); @@ -44,7 +49,7 @@ class BackupLogScreenState extends State { @override Widget build(BuildContext context) { - return ResponsiveUtil.isLandscape() + return widget.isOverlay ? _buildDesktopBody() : Scaffold( appBar: ItemBuilder.buildAppBar( @@ -57,7 +62,7 @@ class BackupLogScreenState extends State { .titleMedium! .apply(fontWeightDelta: 2), ), - center: true, + center: !ResponsiveUtil.isLandscape(), leading: Icons.arrow_back_rounded, onLeadingTap: () { Navigator.pop(context); @@ -76,6 +81,11 @@ class BackupLogScreenState extends State { ) : ItemBuilder.buildBlankIconButton(context), const SizedBox(width: 5), + if (ResponsiveUtil.isLandscape()) + Container( + margin: const EdgeInsets.only(right: 5), + child: ItemBuilder.buildBlankIconButton(context), + ), ], ), body: _buildBody(), @@ -111,7 +121,7 @@ class BackupLogScreenState extends State { clear() { appProvider.clearAutoBackupLogs(); appProvider.autoBackupLoadingStatus = LoadingStatus.none; - if (ResponsiveUtil.isLandscape()) { + if (widget.isOverlay) { context.contextMenuOverlay.hide(); } } @@ -119,13 +129,13 @@ class BackupLogScreenState extends State { _buildBody() { return ListView( padding: EdgeInsets.symmetric( - horizontal: 10, vertical: ResponsiveUtil.isLandscape() ? 10 : 0), - physics: ResponsiveUtil.isLandscape() + horizontal: 10, vertical: widget.isOverlay ? 10 : 0), + physics: widget.isOverlay ? null : const BouncingScrollPhysics( parent: AlwaysScrollableScrollPhysics()), children: [ - if (ResponsiveUtil.isLandscape()) + if (widget.isOverlay) Row( children: [ const SizedBox(width: 5), @@ -151,12 +161,13 @@ class BackupLogScreenState extends State { ), ], ), - if (ResponsiveUtil.isLandscape()) const SizedBox(height: 10), + if (widget.isOverlay) const SizedBox(height: 10), ...List.generate( appProvider.autoBackupLogs.length, (index) { return BackupLogItem( log: appProvider.autoBackupLogs[index], + isOverlay: widget.isOverlay, ); }, ), @@ -175,7 +186,7 @@ class BackupLogScreenState extends State { text: S.current.goToSetBackupPassword, background: Theme.of(context).primaryColor, onTap: () { - if (ResponsiveUtil.isLandscape()) { + if (widget.isOverlay) { context.contextMenuOverlay.hide(); RouteUtil.pushDialogRoute( context, @@ -208,7 +219,9 @@ class BackupLogScreenState extends State { class BackupLogItem extends StatefulWidget { final AutoBackupLog log; - const BackupLogItem({super.key, required this.log}); + final bool isOverlay; + + const BackupLogItem({super.key, required this.log, required this.isOverlay}); @override BackupLogItemState createState() => BackupLogItemState(); @@ -222,7 +235,7 @@ class BackupLogItemState extends State { return Container( margin: const EdgeInsets.only(bottom: 8), child: Material( - color: ResponsiveUtil.isLandscape() + color: widget.isOverlay ? MyTheme.getCardBackground(context) : Theme.of(context).canvasColor, borderRadius: BorderRadius.circular(10), @@ -236,7 +249,7 @@ class BackupLogItemState extends State { } : null, child: Container( - padding: EdgeInsets.all(ResponsiveUtil.isLandscape() ? 8 : 12), + padding: EdgeInsets.all(widget.isOverlay ? 8 : 12), decoration: BoxDecoration( borderRadius: BorderRadius.circular(10), ), @@ -249,7 +262,7 @@ class BackupLogItemState extends State { Text( widget.log.triggerType.label, style: Theme.of(context).textTheme.bodyMedium?.apply( - fontSizeDelta: ResponsiveUtil.isLandscape() ? 0 : 1, + fontSizeDelta: widget.isOverlay ? 0 : 1, ), ), const Spacer(), @@ -261,7 +274,7 @@ class BackupLogItemState extends State { text: widget.log.lastStatusItem.labelShort, textStyle: Theme.of(context).textTheme.labelSmall?.apply( color: Colors.white, - fontSizeDelta: ResponsiveUtil.isLandscape() ? 0 : 1), + fontSizeDelta: widget.isOverlay ? 0 : 1), background: widget.log.lastStatus.color, ), const SizedBox(width: 5), @@ -298,7 +311,7 @@ class BackupLogItemState extends State { textStyle: Theme.of(context) .textTheme .labelSmall - ?.apply(fontSizeDelta: ResponsiveUtil.isLandscape() ? 0 : 1), + ?.apply(fontSizeDelta: widget.isOverlay ? 0 : 1), List.generate( widget.log.status.length, (i) { diff --git a/lib/Screens/Setting/setting_safe_screen.dart b/lib/Screens/Setting/setting_safe_screen.dart index 7dc551a7..cfaafa01 100644 --- a/lib/Screens/Setting/setting_safe_screen.dart +++ b/lib/Screens/Setting/setting_safe_screen.dart @@ -1,20 +1,20 @@ import 'package:biometric_storage/biometric_storage.dart'; import 'package:cloudotp/Utils/biometric_util.dart'; -import 'package:cloudotp/Utils/ilogger.dart'; import 'package:cloudotp/Widgets/BottomSheet/input_password_bottom_sheet.dart'; import 'package:cloudotp/Widgets/Dialog/dialog_builder.dart'; import 'package:cloudotp/Widgets/General/EasyRefresh/easy_refresh.dart'; import 'package:flutter/material.dart'; import 'package:flutter_windowmanager/flutter_windowmanager.dart'; -import 'package:local_auth/local_auth.dart'; import 'package:provider/provider.dart'; import '../../Database/database_manager.dart'; import '../../Utils/app_provider.dart'; +import '../../Utils/constant.dart'; import '../../Utils/hive_util.dart'; import '../../Utils/itoast.dart'; import '../../Utils/responsive_util.dart'; import '../../Utils/route_util.dart'; +import '../../Utils/utils.dart'; import '../../Widgets/BottomSheet/bottom_sheet_builder.dart'; import '../../Widgets/BottomSheet/list_bottom_sheet.dart'; import '../../Widgets/Item/item_builder.dart'; @@ -34,56 +34,42 @@ class SafeSettingScreen extends StatefulWidget { class _SafeSettingScreenState extends State with TickerProviderStateMixin { bool _enableGuesturePasswd = - HiveUtil.getBool(HiveUtil.enableGuesturePasswdKey); + HiveUtil.getBool(HiveUtil.enableGuesturePasswdKey); bool _hasGuesturePasswd = - HiveUtil.getString(HiveUtil.guesturePasswdKey) != null && - HiveUtil.getString(HiveUtil.guesturePasswdKey)!.isNotEmpty; + Utils.isNotEmpty(HiveUtil.getString(HiveUtil.guesturePasswdKey)); bool _autoLock = HiveUtil.getBool(HiveUtil.autoLockKey); - bool _enableSafeMode = HiveUtil.getBool(HiveUtil.enableSafeModeKey); - bool _enableBiometric = HiveUtil.getBool(HiveUtil.enableBiometricKey); - bool _enableDatabaseBiometric = HiveUtil.getBool( - HiveUtil.enableDatabaseBiometricKey, - defaultValue: false); - bool _biometricHwAvailable = false; + bool _enableSafeMode = HiveUtil.getBool(HiveUtil.enableSafeModeKey, + defaultValue: defaultEnableSafeMode); + bool _allowGuestureBiometric = HiveUtil.getBool(HiveUtil.enableBiometricKey); + bool _allowDatabaseBiometric = + HiveUtil.getBool(HiveUtil.allowDatabaseBiometricKey, defaultValue: false); EncryptDatabaseStatus _encryptDatabaseStatus = - HiveUtil.getEncryptDatabaseStatus(); + HiveUtil.getEncryptDatabaseStatus(); String? canAuthenticateResponseString; CanAuthenticateResponse? canAuthenticateResponse; - bool get _biometricAvailable => - _biometricHwAvailable&& - (canAuthenticateResponse != CanAuthenticateResponse.unsupported && - canAuthenticateResponse != CanAuthenticateResponse.statusUnknown); + bool get _biometricAvailable => canAuthenticateResponse?.isAvailable ?? false; + + bool get _geusturePasswdAvailable => + _enableGuesturePasswd && !_encryptedAndCustomPassword; + + bool get _gesturePasswdAvailableAndSet => + _geusturePasswdAvailable && _hasGuesturePasswd; + + bool get _encryptedAndCustomPassword => + DatabaseManager.isDatabaseEncrypted && + _encryptDatabaseStatus == EncryptDatabaseStatus.customPassword; + + bool get _encryptedAndDefaultPassword => + DatabaseManager.isDatabaseEncrypted && + _encryptDatabaseStatus == EncryptDatabaseStatus.defaultPassword; + + bool get _autoLockAvailable => + _gesturePasswdAvailableAndSet || _encryptedAndCustomPassword; @override void initState() { super.initState(); - BiometricUtil.canAuthenticate().then((value) { - canAuthenticateResponse = value; - switch (value) { - case CanAuthenticateResponse.errorHwUnavailable: - canAuthenticateResponseString = S.current.biometricErrorHwUnavailable; - break; - case CanAuthenticateResponse.errorNoBiometricEnrolled: - canAuthenticateResponseString = - S.current.biometricErrorNoBiometricEnrolled; - break; - case CanAuthenticateResponse.errorNoHardware: - canAuthenticateResponseString = S.current.biometricErrorNoHardware; - break; - case CanAuthenticateResponse.errorPasscodeNotSet: - canAuthenticateResponseString = - S.current.biometricErrorPasscodeNotSet; - break; - case CanAuthenticateResponse.success: - canAuthenticateResponseString = S.current.biometricTip; - break; - default: - canAuthenticateResponseString = S.current.biometricErrorUnkown; - break; - } - setState(() {}); - }); initBiometricAuthentication(); } @@ -95,38 +81,38 @@ class _SafeSettingScreenState extends State child: Scaffold( appBar: ResponsiveUtil.isLandscape() ? ItemBuilder.buildSimpleAppBar( - title: S.current.safeSetting, - context: context, - transparent: true, - ) + title: S.current.safeSetting, + context: context, + transparent: true, + ) : ItemBuilder.buildAppBar( - context: context, - backgroundColor: Theme - .of(context) - .scaffoldBackgroundColor, - leading: Icons.arrow_back_rounded, - onLeadingTap: () { - Navigator.pop(context); - }, - title: Text( - S.current.safeSetting, - style: Theme - .of(context) - .textTheme - .titleMedium - ?.apply(fontWeightDelta: 2), - ), - actions: [ - ItemBuilder.buildBlankIconButton(context), - const SizedBox(width: 5), - ], - ), + context: context, + backgroundColor: Theme.of(context).scaffoldBackgroundColor, + leading: Icons.arrow_back_rounded, + onLeadingTap: () { + Navigator.pop(context); + }, + title: Text( + S.current.safeSetting, + style: Theme.of(context) + .textTheme + .titleMedium + ?.apply(fontWeightDelta: 2), + ), + actions: [ + ItemBuilder.buildBlankIconButton(context), + const SizedBox(width: 5), + ], + ), body: EasyRefresh( child: ListView( physics: const BouncingScrollPhysics(), padding: const EdgeInsets.symmetric(horizontal: 10), children: [ - ..._privacySettings(), + ..._databaseSettings(), + ..._gestureSettings(), + if (_autoLockAvailable) ..._autoLockSettings(), + ..._safeModeSettings(), const SizedBox(height: 30), ], ), @@ -135,101 +121,56 @@ class _SafeSettingScreenState extends State ); } - _privacySettings() { + _gestureSettings() { return [ + const SizedBox(height: 10), ItemBuilder.buildRadioItem( context: context, topRadius: true, + disabled: _encryptedAndCustomPassword, value: _enableGuesturePasswd, title: S.current.enableGestureLock, - bottomRadius: !_enableGuesturePasswd, + bottomRadius: !_geusturePasswdAvailable, description: S.current.enableGestureLockTip, onTap: onEnablePinTapped, ), Visibility( - visible: _enableGuesturePasswd, + visible: _geusturePasswdAvailable, child: ItemBuilder.buildEntryItem( context: context, - bottomRadius: !_hasGuesturePasswd, + bottomRadius: !_gesturePasswdAvailableAndSet || !_biometricAvailable, title: _hasGuesturePasswd ? S.current.changeGestureLock : S.current.setGestureLock, description: - _hasGuesturePasswd ? "" : S.current.haveToSetGestureLockTip, + _hasGuesturePasswd ? "" : S.current.haveToSetGestureLockTip, onTap: onChangePinTapped, ), ), Visibility( - visible: - _enableGuesturePasswd && _hasGuesturePasswd && _biometricAvailable, + visible: _gesturePasswdAvailableAndSet && _biometricAvailable, child: ItemBuilder.buildRadioItem( context: context, - value: _enableBiometric, - title: S.current.biometric, - disabled: canAuthenticateResponse != CanAuthenticateResponse.success, - description: canAuthenticateResponseString ?? "", + value: _allowGuestureBiometric, + title: S.current.biometricUnlock, + bottomRadius: true, + disabled: canAuthenticateResponse?.isSuccess != true, + description: + canAuthenticateResponseString ?? S.current.biometricUnlockTip, onTap: onBiometricTapped, ), ), - Visibility( - visible: _enableGuesturePasswd && _hasGuesturePasswd, - child: ItemBuilder.buildRadioItem( - bottomRadius: - !(_enableGuesturePasswd && _hasGuesturePasswd && _autoLock), - context: context, - value: _autoLock, - title: S.current.autoLock, - description: S.current.autoLockTip, - onTap: onEnableAutoLockTapped, - ), - ), - Visibility( - visible: _enableGuesturePasswd && _hasGuesturePasswd && _autoLock, - child: Selector( - selector: (context, appProvider) => appProvider.autoLockTime, - builder: (context, autoLockTime, child) => - ItemBuilder.buildEntryItem( - context: context, - title: S.current.autoLockDelay, - bottomRadius: true, - tip: AppProvider.getAutoLockOptionLabel(autoLockTime), - onTap: () { - BottomSheetBuilder.showListBottomSheet( - context, - (context) => - TileList.fromOptions( - AppProvider.getAutoLockOptions(), - (item2) { - appProvider.autoLockTime = item2; - Navigator.pop(context); - }, - selected: autoLockTime, - context: context, - title: S.current.chooseAutoLockDelay, - onCloseTap: () => Navigator.pop(context), - ), - ); - }, - ), - ), - ), - const SizedBox(height: 10), - ItemBuilder.buildRadioItem( - context: context, - value: _enableSafeMode, - topRadius: true, - bottomRadius: !DatabaseManager.isDatabaseEncrypted, - title: S.current.safeMode, - disabled: ResponsiveUtil.isDesktop(), - description: S.current.safeModeTip, - onTap: onSafeModeTapped, - ), + ]; + } + + _databaseSettings() { + return [ Visibility( visible: DatabaseManager.isDatabaseEncrypted, child: ItemBuilder.buildEntryItem( context: context, - bottomRadius: - _encryptDatabaseStatus == EncryptDatabaseStatus.defaultPassword, + topRadius: true, + bottomRadius: _encryptedAndDefaultPassword, title: S.current.editEncryptDatabasePassword, description: S.current.encryptDatabaseTip, tip: _encryptDatabaseStatus == EncryptDatabaseStatus.defaultPassword @@ -240,77 +181,44 @@ class _SafeSettingScreenState extends State context, responsive: true, useWideLandscape: true, - (context) => - InputPasswordBottomSheet( - title: S.current.editEncryptDatabasePassword, - message: S.current.editEncryptDatabasePasswordTip, - onConfirm: (passord, confirmPassword) async {}, - onValidConfirm: (passord, confirmPassword) async { - bool res = await DatabaseManager.changePassword(passord); - if (res) { - IToast.showTop(S.current.editSuccess); - HiveUtil.setEncryptDatabaseStatus( - EncryptDatabaseStatus.customPassword); - setState(() { - _encryptDatabaseStatus = - EncryptDatabaseStatus.customPassword; - }); - if (_enableDatabaseBiometric) { - _enableDatabaseBiometric = + (context) => InputPasswordBottomSheet( + title: S.current.editEncryptDatabasePassword, + message: S.current.editEncryptDatabasePasswordTip, + onConfirm: (passord, confirmPassword) async {}, + onValidConfirm: (passord, confirmPassword) async { + bool res = await DatabaseManager.changePassword(passord); + if (res) { + IToast.showTop(S.current.editSuccess); + HiveUtil.setEncryptDatabaseStatus( + EncryptDatabaseStatus.customPassword); + setState(() { + _encryptDatabaseStatus = + EncryptDatabaseStatus.customPassword; + }); + if (_biometricAvailable && _allowDatabaseBiometric) { + _allowDatabaseBiometric = await BiometricUtil.setDatabasePassword( appProvider.currentDatabasePassword); - setState(() {}); - } - HiveUtil.put(HiveUtil.enableDatabaseBiometricKey, - _enableDatabaseBiometric); - } else { - IToast.showTop(S.current.editFailed); - } - }, - ), + setState(() {}); + HiveUtil.put(HiveUtil.allowDatabaseBiometricKey, + _allowDatabaseBiometric); + } + } else { + IToast.showTop(S.current.editFailed); + } + }, + ), ); }, ), ), Visibility( - visible: DatabaseManager.isDatabaseEncrypted && - _encryptDatabaseStatus == EncryptDatabaseStatus.customPassword && - _biometricAvailable, - child: ItemBuilder.buildRadioItem( - context: context, - value: _enableDatabaseBiometric, - disabled: canAuthenticateResponse != CanAuthenticateResponse.success, - description: canAuthenticateResponseString ?? "", - title: S.current.biometric, - onTap: () async { - if (canAuthenticateResponse != CanAuthenticateResponse.success) { - return; - } - if (!_enableDatabaseBiometric) { - _enableDatabaseBiometric = - await BiometricUtil.setDatabasePassword( - appProvider.currentDatabasePassword); - if (_enableDatabaseBiometric) { - IToast.showTop(S.current.enableBiometricSuccess); - } - setState(() {}); - } else { - _enableDatabaseBiometric = false; - setState(() {}); - } - HiveUtil.put( - HiveUtil.enableDatabaseBiometricKey, _enableDatabaseBiometric); - }, - ), - ), - Visibility( - visible: DatabaseManager.isDatabaseEncrypted && - _encryptDatabaseStatus == EncryptDatabaseStatus.customPassword, + visible: _encryptedAndCustomPassword, child: ItemBuilder.buildEntryItem( context: context, title: S.current.clearEncryptDatabasePassword, description: S.current.clearEncryptDatabasePasswordTip, - bottomRadius: true, + bottomRadius: !_biometricAvailable, onTap: () { DialogBuilder.showConfirmDialog( context, @@ -326,6 +234,9 @@ class _SafeSettingScreenState extends State _encryptDatabaseStatus = EncryptDatabaseStatus.defaultPassword; }); + if (_biometricAvailable && _allowDatabaseBiometric) { + await BiometricUtil.clearDatabasePassword(); + } IToast.showTop(S.current.clearEncryptDatabasePasswordSuccess); } else { IToast.showTop(S.current.clearEncryptDatabasePasswordFailed); @@ -336,15 +247,107 @@ class _SafeSettingScreenState extends State }, ), ), + Visibility( + visible: _encryptedAndCustomPassword && _biometricAvailable, + child: ItemBuilder.buildRadioItem( + context: context, + value: _allowDatabaseBiometric, + disabled: canAuthenticateResponse?.isSuccess != true, + bottomRadius: true, + description: canAuthenticateResponseString ?? + S.current.biometricDecryptDatabaseTip, + title: S.current.biometricDecryptDatabase, + onTap: () async { + if (canAuthenticateResponse != CanAuthenticateResponse.success) { + return; + } + if (!_allowDatabaseBiometric) { + _allowDatabaseBiometric = await BiometricUtil.setDatabasePassword( + appProvider.currentDatabasePassword); + if (_allowDatabaseBiometric) { + IToast.showTop(S.current.enableBiometricSuccess); + } + setState(() {}); + } else { + _allowDatabaseBiometric = false; + setState(() {}); + } + HiveUtil.put( + HiveUtil.allowDatabaseBiometricKey, _allowDatabaseBiometric); + }, + ), + ), + ]; + } + + _autoLockSettings() { + return [ + const SizedBox(height: 10), + ItemBuilder.buildRadioItem( + bottomRadius: !_autoLock, + topRadius: true, + context: context, + value: _autoLock, + title: S.current.autoLock, + description: S.current.autoLockTip, + onTap: onEnableAutoLockTapped, + ), + Visibility( + visible: _autoLock, + child: Selector( + selector: (context, appProvider) => appProvider.autoLockTime, + builder: (context, autoLockTime, child) => ItemBuilder.buildEntryItem( + context: context, + title: S.current.autoLockDelay, + bottomRadius: true, + tip: autoLockTime.label, + onTap: () { + BottomSheetBuilder.showListBottomSheet( + context, + (context) => TileList.fromOptions( + AutoLockTime.options(), + (item2) { + appProvider.autoLockTime = item2; + Navigator.pop(context); + }, + selected: autoLockTime, + context: context, + title: S.current.chooseAutoLockDelay, + onCloseTap: () => Navigator.pop(context), + ), + ); + }, + ), + ), + ), + ]; + } + + _safeModeSettings() { + return [ + const SizedBox(height: 10), + ItemBuilder.buildRadioItem( + context: context, + value: _enableSafeMode, + topRadius: true, + bottomRadius: true, + title: S.current.safeMode, + disabled: ResponsiveUtil.isDesktop(), + description: S.current.safeModeTip, + onTap: onSafeModeTapped, + ), ]; } initBiometricAuthentication() async { - LocalAuthentication localAuth = LocalAuthentication(); - bool available = await localAuth.canCheckBiometrics; - setState(() { - _biometricHwAvailable = available; - }); + canAuthenticateResponse = await BiometricUtil.canAuthenticate(); + canAuthenticateResponseString = + await BiometricUtil.getCanAuthenticateResponseString(); + bool exist = await BiometricUtil.exists(); + if (!exist) { + _allowDatabaseBiometric = false; + HiveUtil.put(HiveUtil.allowDatabaseBiometricKey, _allowDatabaseBiometric); + } } onEnablePinTapped() { @@ -373,15 +376,16 @@ class _SafeSettingScreenState extends State } onBiometricTapped() { - if (!_enableBiometric) { + if (!_allowGuestureBiometric) { RouteUtil.pushCupertinoRoute( context, PinVerifyScreen( onSuccess: () { IToast.showTop(S.current.enableBiometricSuccess); setState(() { - _enableBiometric = !_enableBiometric; - HiveUtil.put(HiveUtil.enableBiometricKey, _enableBiometric); + _allowGuestureBiometric = !_allowGuestureBiometric; + HiveUtil.put( + HiveUtil.enableBiometricKey, _allowGuestureBiometric); }); }, isModal: false, @@ -389,8 +393,8 @@ class _SafeSettingScreenState extends State ); } else { setState(() { - _enableBiometric = !_enableBiometric; - HiveUtil.put(HiveUtil.enableBiometricKey, _enableBiometric); + _allowGuestureBiometric = !_allowGuestureBiometric; + HiveUtil.put(HiveUtil.enableBiometricKey, _allowGuestureBiometric); }); } } diff --git a/lib/Screens/Setting/update_screen.dart b/lib/Screens/Setting/update_screen.dart index 346c0576..c5ae2d88 100644 --- a/lib/Screens/Setting/update_screen.dart +++ b/lib/Screens/Setting/update_screen.dart @@ -35,7 +35,13 @@ class UpdateScreen extends StatefulWidget { State createState() => _UpdateScreenState(); } -enum DownloadState { normal, downloading, toInstall, installing } +enum DownloadState { + normal, + downloading, + toInstallPortable, + toInstall, + installing +} class _UpdateScreenState extends State with TickerProviderStateMixin { @@ -144,8 +150,9 @@ class _UpdateScreenState extends State try { late ReleaseAsset asset; if (ResponsiveUtil.isWindows()) { - asset = FileUtil.getWindowsInstallerAsset( + asset = FileUtil.getWindowsAsset( latestVersion, latestReleaseItem); + ILogger.info("Windows asset: $asset"); } String url = asset.pkgsDownloadUrl; var appDocDir = await getDownloadsDirectory(); @@ -153,6 +160,8 @@ class _UpdateScreenState extends State "${appDocDir?.path}/${FileUtil.getFileNameWithExtension(url)}"; if (downloadState == DownloadState.downloading) { return; + } else if (downloadState == DownloadState.toInstallPortable) { + IToast.showTop(S.current.installPortableTip); } else if (downloadState == DownloadState.toInstall) { setState(() { buttonText = S.current.installing; @@ -212,7 +221,7 @@ class _UpdateScreenState extends State IToast.showTop(S.current.downloadSuccess); setState(() { buttonText = S.current.immediatelyInstall; - downloadState = DownloadState.toInstall; + downloadState = DownloadState.toInstallPortable; }); } else { IToast.showTop(S.current.downloadFailed); diff --git a/lib/Screens/Token/add_token_screen.dart b/lib/Screens/Token/add_token_screen.dart index f534abb5..32cf2889 100644 --- a/lib/Screens/Token/add_token_screen.dart +++ b/lib/Screens/Token/add_token_screen.dart @@ -366,6 +366,7 @@ class _AddTokenScreenState extends State leadingType: InputItemLeadingType.text, leadingText: S.current.tokenSecret, tailingType: InputItemTailingType.password, + obscureText: _isEditing, hint: S.current.tokenSecretHint, inputFormatters: [ RegexInputFormatter.onlyNumberAndLetter, @@ -389,6 +390,7 @@ class _AddTokenScreenState extends State leadingText: S.current.tokenPin, leadingType: InputItemLeadingType.text, tailingType: InputItemTailingType.password, + obscureText: _isEditing, hint: S.current.tokenPinHint, maxLength: _otpToken.tokenType.maxPinLength, bottomRadius: true, diff --git a/lib/Screens/home_screen.dart b/lib/Screens/home_screen.dart index b5712e6d..85990078 100644 --- a/lib/Screens/home_screen.dart +++ b/lib/Screens/home_screen.dart @@ -79,6 +79,8 @@ class HomeScreenState extends State with TickerProviderStateMixin { GridItemsNotifier gridItemsNotifier = GridItemsNotifier(); final ValueNotifier _shownSearchbarNotifier = ValueNotifier(false); + bool get hasSearchFocus => _searchFocusNode.hasFocus; + String get currentCategoryUid { if (_currentTabIndex == 0) { return ""; @@ -761,7 +763,7 @@ class HomeScreenState extends State with TickerProviderStateMixin { ], ) : gridView; - return SlidableAutoCloseBehavior(child:body); + return SlidableAutoCloseBehavior(child: body); } _buildTabBar([EdgeInsetsGeometry? padding]) { diff --git a/lib/Screens/main_screen.dart b/lib/Screens/main_screen.dart index 63de3003..65aac166 100644 --- a/lib/Screens/main_screen.dart +++ b/lib/Screens/main_screen.dart @@ -1,9 +1,10 @@ import 'dart:async'; import 'dart:io'; -import 'dart:math'; +import 'package:cloudotp/Database/database_manager.dart'; import 'package:cloudotp/Database/token_dao.dart'; import 'package:cloudotp/Models/opt_token.dart'; +import 'package:cloudotp/Screens/Lock/database_decrypt_screen.dart'; import 'package:cloudotp/Screens/Setting/about_setting_screen.dart'; import 'package:cloudotp/Screens/Setting/setting_navigation_screen.dart'; import 'package:cloudotp/Screens/Token/add_token_screen.dart'; @@ -81,7 +82,6 @@ class MainScreenState extends State Widget? darkModeWidget; bool _isMaximized = false; bool _isStayOnTop = false; - bool _hasJumpedToPinVerify = false; Orientation _oldOrientation = Orientation.portrait; TextEditingController searchController = TextEditingController(); FocusNode searchFocusNode = FocusNode(); @@ -138,6 +138,11 @@ class MainScreenState extends State }); } + void pushRootPage(Widget page) { + Navigator.pushAndRemoveUntil( + context, RouteUtil.getFadeRoute(page), (route) => false); + } + @override void onProtocolUrlReceived(String url) { ILogger.info("Protocol url received", url); @@ -216,7 +221,8 @@ class MainScreenState extends State indicator: LottieUtil.load(LottieUtil.getLoadingPath(context)), ); if (ResponsiveUtil.isMobile()) { - if (HiveUtil.getBool(HiveUtil.enableSafeModeKey)) { + if (HiveUtil.getBool(HiveUtil.enableSafeModeKey, + defaultValue: defaultEnableSafeMode)) { FlutterWindowManager.addFlags(FlutterWindowManager.FLAG_SECURE); } else { FlutterWindowManager.clearFlags(FlutterWindowManager.FLAG_SECURE); @@ -224,20 +230,25 @@ class MainScreenState extends State } } - void jumpToPinVerify({ + Future jumpToLock({ bool autoAuth = false, - }) { - _hasJumpedToPinVerify = true; - RouteUtil.pushCupertinoRoute( - context, + }) async { + if (DatabaseManager.isDatabaseEncrypted && + HiveUtil.getEncryptDatabaseStatus() == + EncryptDatabaseStatus.customPassword) { + await DatabaseManager.resetDatabase(); + pushRootPage(const DatabaseDecryptScreen()); + } else { + pushRootPage( PinVerifyScreen( onSuccess: () {}, showWindowTitle: true, isModal: true, autoAuth: autoAuth, - ), onThen: (_) { - _hasJumpedToPinVerify = false; - }); + jumpToMain: true, + ), + ); + } } @override @@ -304,34 +315,6 @@ class MainScreenState extends State ), ], ); - var rightPosWidget = Column( - children: [ - _titleBar(), - Expanded( - child: Row( - children: [ - Expanded( - child: _desktopMainContent(leftMargin: 5), - ), - _sideBar(leftPadding: 8, rightPadding: 8, topPadding: 8), - ], - ), - ), - ], - ); - var bottomPosWidget = Column( - children: [ - _titleBar(), - Expanded( - child: _desktopMainContent(leftMargin: 5, rightMargin: 5), - ), - RotatedBox( - quarterTurns: 3, - child: _sideBar( - quarterTurns: 1, leftPadding: 8, rightPadding: 8, topPadding: 5), - ), - ], - ); return MyScaffold( resizeToAvoidBottomInset: false, body: leftPosWidget, @@ -836,7 +819,7 @@ class MainScreenState extends State ), onPressed: () { context.contextMenuOverlay - .show(const BackupLogScreen()); + .show(const BackupLogScreen(isOverlay: true)); }, ) : const SizedBox.shrink(), @@ -875,16 +858,14 @@ class MainScreenState extends State } void setTimer() { - if (!_hasJumpedToPinVerify) { - _timer = Timer( - Duration(minutes: appProvider.autoLockTime), - () { - if (HiveUtil.shouldAutoLock()) { - jumpToPinVerify(); - } - }, - ); - } + _timer = Timer( + Duration(seconds: appProvider.autoLockTime.seconds), + () { + if (!appProvider.hasJumpToFilePicker && HiveUtil.shouldAutoLock()) { + jumpToLock(); + } + }, + ); } @override @@ -924,7 +905,7 @@ class MainScreenState extends State @override void onTrayIconRightMouseDown() { - if (!_hasJumpedToPinVerify) trayManager.popUpContextMenu(); + trayManager.popUpContextMenu(); } @override @@ -936,7 +917,7 @@ class MainScreenState extends State Utils.displayApp(); } else if (menuItem.key == TrayKey.lockApp.key) { if (HiveUtil.canLock()) { - jumpToPinVerify(); + jumpToLock(); } else { IToast.showDesktopNotification( S.current.noGestureLock, diff --git a/lib/TokenUtils/export_token_util.dart b/lib/TokenUtils/export_token_util.dart index 31846aa2..4b593417 100644 --- a/lib/TokenUtils/export_token_util.dart +++ b/lib/TokenUtils/export_token_util.dart @@ -98,7 +98,6 @@ class ExportTokenUtil { static Future getUint8List({ String? password, }) async { - if (!await HiveUtil.canBackup()) return null; try { String tmpPassword = password ?? await ConfigDao.getBackupPassword(); List tokens = await TokenDao.listTokens(); diff --git a/lib/Utils/app_provider.dart b/lib/Utils/app_provider.dart index c2ab0da6..a20ca9e6 100644 --- a/lib/Utils/app_provider.dart +++ b/lib/Utils/app_provider.dart @@ -66,11 +66,69 @@ Queue autoBackupQueue = Queue(); AppProvider appProvider = AppProvider(); +enum AutoLockTime { + immediately, + after30Seconds, + after1Minute, + after3Minutes, + after5Minutes, + after10Minutes; + + int get seconds { + switch (this) { + case AutoLockTime.immediately: + return 0; + case AutoLockTime.after30Seconds: + return 30; + case AutoLockTime.after1Minute: + return 60; + case AutoLockTime.after3Minutes: + return 60 * 3; + case AutoLockTime.after5Minutes: + return 60 * 5; + case AutoLockTime.after10Minutes: + return 60 * 10; + } + } + + String get label { + switch (this) { + case AutoLockTime.immediately: + return S.current.immediatelyLock; + case AutoLockTime.after30Seconds: + return S.current.after30SecondsLock; + case AutoLockTime.after1Minute: + return S.current.after1MinuteLock; + case AutoLockTime.after3Minutes: + return S.current.after3MinutesLock; + case AutoLockTime.after5Minutes: + return S.current.after5MinutesLock; + case AutoLockTime.after10Minutes: + return S.current.after10MinutesLock; + default: + return ""; + } + } + + static List> options() { + return [ + Tuple2(S.current.immediatelyLock, AutoLockTime.immediately), + Tuple2(S.current.after30SecondsLock, AutoLockTime.after30Seconds), + Tuple2(S.current.after1MinuteLock, AutoLockTime.after1Minute), + Tuple2(S.current.after3MinutesLock, AutoLockTime.after3Minutes), + Tuple2(S.current.after5MinutesLock, AutoLockTime.after5Minutes), + Tuple2(S.current.after10MinutesLock, AutoLockTime.after10Minutes), + ]; + } +} + class AppProvider with ChangeNotifier { String currentDatabasePassword = ""; String latestVersion = ""; + bool hasJumpToFilePicker = false; + final List _autoBackupLogs = []; List get autoBackupLogs => _autoBackupLogs; @@ -335,42 +393,18 @@ class AppProvider with ChangeNotifier { ]; } - int _autoLockTime = HiveUtil.getInt(HiveUtil.autoLockTimeKey); + AutoLockTime _autoLockTime = HiveUtil.getAutoLockTime(); - int get autoLockTime => _autoLockTime; + AutoLockTime get autoLockTime => _autoLockTime; - set autoLockTime(int value) { + set autoLockTime(AutoLockTime value) { if (value != _autoLockTime) { _autoLockTime = value; notifyListeners(); - HiveUtil.put(HiveUtil.autoLockTimeKey, value); + HiveUtil.setAutoLockTime(value); } } - static String getAutoLockOptionLabel(int time) { - switch (time) { - case 0: - return S.current.immediatelyLock; - case 1: - return S.current.after1MinuteLock; - case 5: - return S.current.after5MinutesLock; - case 10: - return S.current.after10MinutesLock; - default: - return ""; - } - } - - static List> getAutoLockOptions() { - return [ - Tuple2(S.current.immediatelyLock, 0), - Tuple2(S.current.after1MinuteLock, 1), - Tuple2(S.current.after5MinutesLock, 5), - Tuple2(S.current.after10MinutesLock, 10), - ]; - } - Brightness? getBrightness() { if (_themeMode == ActiveThemeMode.system) { return null; diff --git a/lib/Utils/biometric_util.dart b/lib/Utils/biometric_util.dart index e04d565e..dfb768c5 100644 --- a/lib/Utils/biometric_util.dart +++ b/lib/Utils/biometric_util.dart @@ -2,6 +2,15 @@ import 'package:biometric_storage/biometric_storage.dart'; import 'package:cloudotp/Utils/ilogger.dart'; import '../generated/l10n.dart'; +import 'itoast.dart'; + +extension AvailableBiometric on CanAuthenticateResponse { + bool get isAvailable => + this != CanAuthenticateResponse.unsupported && + this != CanAuthenticateResponse.statusUnknown; + + bool get isSuccess => this == CanAuthenticateResponse.success; +} class BiometricUtil { static const String databasePassswordStorageKey = "CloudOTP-DatabasePassword"; @@ -11,6 +20,26 @@ class BiometricUtil { return await BiometricStorage().canAuthenticate(); } + static Future getCanAuthenticateResponseString() async { + CanAuthenticateResponse response = await canAuthenticate(); + switch (response) { + case CanAuthenticateResponse.success: + return null; + case CanAuthenticateResponse.errorHwUnavailable: + return S.current.biometricErrorHwUnavailable; + case CanAuthenticateResponse.errorNoBiometricEnrolled: + return S.current.biometricErrorNoBiometricEnrolled; + case CanAuthenticateResponse.errorNoHardware: + return S.current.biometricErrorNoHardware; + case CanAuthenticateResponse.errorPasscodeNotSet: + return S.current.biometricErrorPasscodeNotSet; + case CanAuthenticateResponse.unsupported: + return S.current.biometricErrorUnsupported; + default: + return S.current.biometricErrorUnkown; + } + } + static Future isBiometricAvailable() async { final response = await BiometricStorage().canAuthenticate(); if (response != CanAuthenticateResponse.success) { @@ -35,16 +64,26 @@ class BiometricUtil { return response == CanAuthenticateResponse.success; } - static Future initStorage() async { + static Future initStorage({ + bool forceInit = false, + }) async { bool isAvailable = await isBiometricAvailable(); - if (!isAvailable) { - return; - } + if (!isAvailable) return; databasePassswordStorage = await BiometricStorage().getStorage( databasePassswordStorageKey, + forceInit: forceInit, ); } + static Future exists() async { + try { + await initStorage(forceInit: true); + return false; + } catch (e) { + return true; + } + } + static Future getDatabasePassword() async { if (databasePassswordStorage == null) { await initStorage(); @@ -84,7 +123,35 @@ class BiometricUtil { return true; } catch (e, t) { ILogger.error("Failed to save database password: $e\n$t"); + if (e is AuthException) { + switch (e.code) { + case AuthExceptionCode.userCanceled: + IToast.showTop(S.current.biometricUserCanceled); + break; + case AuthExceptionCode.timeout: + IToast.showTop(S.current.biometricTimeout); + break; + case AuthExceptionCode.unknown: + IToast.showTop(S.current.biometricLockout); + break; + case AuthExceptionCode.canceled: + default: + IToast.showTop(S.current.biometricError); + break; + } + } return false; } } + + static Future clearDatabasePassword() async { + if (databasePassswordStorage == null) { + return; + } + try { + await databasePassswordStorage!.delete(); + } catch (e, t) { + ILogger.error("Failed to delete database password: $e\n$t"); + } + } } diff --git a/lib/Utils/constant.dart b/lib/Utils/constant.dart index e14ba296..4817d600 100644 --- a/lib/Utils/constant.dart +++ b/lib/Utils/constant.dart @@ -1,3 +1,4 @@ +import 'package:cloudotp/Utils/responsive_util.dart'; import 'package:flutter/cupertino.dart'; import 'package:local_auth_android/local_auth_android.dart'; @@ -26,6 +27,10 @@ const int defaultHOTPPeriod = 15; const String placeholderText = "*"; const String hotpPlaceholderText = "*"; +const bool defaultEnableSafeMode = true; + +const windowsKeyPath = r'SOFTWARE\Cloudchewie\CloudOTP'; + String shareAppText = S.current.shareAppText(officialWebsite); const String feedbackEmail = "2014027378@qq.com"; String feedbackSubject = S.current.feedbackSubject; @@ -48,7 +53,9 @@ AndroidAuthMessages androidAuthMessages = AndroidAuthMessages( goToSettingsButton: S.current.biometricGoToSettingsButton, biometricNotRecognized: S.current.biometricNotRecognized, goToSettingsDescription: S.current.biometricGoToSettingsDescription, - biometricHint: S.current.biometricHint, + biometricHint: ResponsiveUtil.isWindows() + ? S.current.biometricReasonWindows("CloudOTP") + : S.current.biometricReason("CloudOTP"), biometricSuccess: S.current.biometricSuccess, signInTitle: S.current.biometricSignInTitle, deviceCredentialsRequiredTitle: diff --git a/lib/Utils/file_util.dart b/lib/Utils/file_util.dart index aebfe735..9801d775 100644 --- a/lib/Utils/file_util.dart +++ b/lib/Utils/file_util.dart @@ -1,12 +1,16 @@ +import 'dart:ffi'; import 'dart:io'; import 'package:cloudotp/Models/github_response.dart'; +import 'package:cloudotp/Utils/app_provider.dart'; +import 'package:cloudotp/Utils/constant.dart'; import 'package:cloudotp/Utils/responsive_util.dart'; import 'package:cloudotp/Utils/uri_util.dart'; import 'package:cloudotp/Utils/utils.dart'; import 'package:cloudotp/Widgets/Dialog/dialog_builder.dart'; import 'package:device_info_plus/device_info_plus.dart'; import 'package:dio/dio.dart'; +import 'package:ffi/ffi.dart'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/foundation.dart'; @@ -16,6 +20,7 @@ import 'package:path/path.dart'; import 'package:path_provider/path_provider.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:saf/saf.dart'; +import 'package:win32/win32.dart'; import '../../Utils/ilogger.dart'; import '../Widgets/Dialog/custom_dialog.dart'; @@ -23,6 +28,8 @@ import '../generated/l10n.dart'; import 'itoast.dart'; import 'notification_util.dart'; +enum WindowsVersion { installed, portable } + class FileUtil { static Future getDirectoryBySAF() async { Saf saf = Saf("/Documents"); @@ -57,6 +64,7 @@ class FileUtil { }) async { FilePickerResult? result; try { + appProvider.hasJumpToFilePicker = true; result = await FilePicker.platform.pickFiles( dialogTitle: dialogTitle, initialDirectory: initialDirectory, @@ -74,6 +82,8 @@ class FileUtil { } catch (e, t) { ILogger.error("Failed to pick files", e, t); IToast.showTop(S.current.pleaseGrantFilePermission); + } finally { + appProvider.hasJumpToFilePicker = false; } return result; } @@ -89,6 +99,7 @@ class FileUtil { }) async { String? result; try { + appProvider.hasJumpToFilePicker = true; result = await FilePicker.platform.saveFile( dialogTitle: dialogTitle, initialDirectory: initialDirectory, @@ -101,6 +112,8 @@ class FileUtil { } catch (e, t) { ILogger.error("Failed to save file", e, t); IToast.showTop(S.current.pleaseGrantFilePermission); + } finally { + appProvider.hasJumpToFilePicker = false; } return result; } @@ -112,6 +125,7 @@ class FileUtil { }) async { String? result; try { + appProvider.hasJumpToFilePicker = true; if (Platform.isAndroid) { DeviceInfoPlugin deviceInfo = DeviceInfoPlugin(); AndroidDeviceInfo androidInfo = await deviceInfo.androidInfo; @@ -128,6 +142,8 @@ class FileUtil { } catch (e, t) { ILogger.error("Failed to get directory path", e, t); IToast.showTop(S.current.pleaseGrantFilePermission); + } finally { + appProvider.hasJumpToFilePicker = false; } return result; } @@ -409,6 +425,50 @@ class FileUtil { return resAsset; } + WindowsVersion checkWindowsVersion() { + final key = calloc(); + final result = RegOpenKeyEx(HKEY_LOCAL_MACHINE, TEXT(windowsKeyPath), 0, + REG_SAM_FLAGS.KEY_READ, key); + if (result == WIN32_ERROR.ERROR_SUCCESS) { + + WindowsVersion tmp = WindowsVersion.installed; + + // final installPathPtr = calloc(260); + // final dataSize = calloc(); + // dataSize.value = 260 * 2; + // final queryResult = RegQueryValueEx(key.value, TEXT('InstallPath'), + // nullptr, nullptr, installPathPtr.cast(), dataSize); + // if (queryResult == WIN32_ERROR.ERROR_SUCCESS) { + // final currentPath = Platform.resolvedExecutable; + // final installPath = installPathPtr.toDartString(); + // print("currentPath: $currentPath installPath: $installPath"); + // tmp = installPath == currentPath + // ? WindowsVersion.installed + // : WindowsVersion.portable; + // } else { + // tmp = WindowsVersion.portable; + // } + + RegCloseKey(key.value); + calloc.free(key); + // calloc.free(installPathPtr); + // calloc.free(dataSize); + return tmp; + } else { + calloc.free(key); + return WindowsVersion.portable; + } + } + + static ReleaseAsset getWindowsAsset(String latestVersion, ReleaseItem item) { + final windowsVersion = FileUtil().checkWindowsVersion(); + if (windowsVersion == WindowsVersion.installed) { + return getWindowsInstallerAsset(latestVersion, item); + } else { + return getWindowsPortableAsset(latestVersion, item); + } + } + static ReleaseAsset getWindowsPortableAsset( String latestVersion, ReleaseItem item) { var asset = item.assets.firstWhere((element) => diff --git a/lib/Utils/hive_util.dart b/lib/Utils/hive_util.dart index 045fcff0..e1902ef5 100644 --- a/lib/Utils/hive_util.dart +++ b/lib/Utils/hive_util.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'package:cloudotp/Database/config_dao.dart'; import 'package:cloudotp/Resources/theme_color_data.dart'; import 'package:cloudotp/Screens/home_screen.dart'; +import 'package:cloudotp/Utils/app_provider.dart'; import 'package:cloudotp/Utils/enums.dart'; import 'package:cloudotp/Utils/file_util.dart'; import 'package:cloudotp/Utils/responsive_util.dart'; @@ -98,7 +99,7 @@ class HiveUtil { static const String enableGuesturePasswdKey = "enableGuesturePasswd"; static const String guesturePasswdKey = "guesturePasswd"; static const String enableBiometricKey = "enableBiometric"; - static const String enableDatabaseBiometricKey = "enableDatabaseBiometric"; + static const String allowDatabaseBiometricKey = "allowDatabaseBiometric"; static const String autoLockKey = "autoLock"; static const String autoLockTimeKey = "autoLockTime"; static const String enableSafeModeKey = "enableSafeMode"; @@ -109,7 +110,7 @@ class HiveUtil { static initConfig() async { await HiveUtil.put(HiveUtil.inappWebviewKey, true); await HiveUtil.put(HiveUtil.layoutTypeKey, LayoutType.Compact.index); - await HiveUtil.put(HiveUtil.enableSafeModeKey, true); + await HiveUtil.put(HiveUtil.enableSafeModeKey, defaultEnableSafeMode); await HiveUtil.put(HiveUtil.autoFocusSearchBarKey, false); await HiveUtil.put(HiveUtil.maxBackupsCountKey, defaultMaxBackupCount); await HiveUtil.put(HiveUtil.backupPathKey, await FileUtil.getBackupDir()); @@ -123,6 +124,15 @@ class HiveUtil { return enableCloudBackup && autoBackupPassword.isNotEmpty; } + static AutoLockTime getAutoLockTime() { + return AutoLockTime.values[HiveUtil.getInt(HiveUtil.autoLockTimeKey) + .clamp(0, AutoLockTime.values.length - 1)]; + } + + static Future setAutoLockTime(AutoLockTime time) async { + await HiveUtil.put(HiveUtil.autoLockTimeKey, time.index); + } + static int getMaxBackupsCount() { return getInt(HiveUtil.maxBackupsCountKey, defaultValue: defaultMaxBackupCount); @@ -385,10 +395,16 @@ class HiveUtil { static bool shouldAutoLock() => canLock() && HiveUtil.getBool(HiveUtil.autoLockKey); - static bool canLock() => + static bool canLock() => canGuestureLock() || canDatabaseLock(); + + static bool canGuestureLock() => HiveUtil.getBool(HiveUtil.enableGuesturePasswdKey) && - HiveUtil.getString(HiveUtil.guesturePasswdKey) != null && - HiveUtil.getString(HiveUtil.guesturePasswdKey)!.isNotEmpty; + Utils.isNotEmpty(HiveUtil.getString(HiveUtil.guesturePasswdKey)); + + static bool canDatabaseLock() => + HiveUtil.getEncryptDatabaseStatus() == + EncryptDatabaseStatus.customPassword && + DatabaseManager.isDatabaseEncrypted; static Map getCookie() { Map map = {}; diff --git a/lib/Utils/responsive_util.dart b/lib/Utils/responsive_util.dart index 95b1364a..30d60cb7 100644 --- a/lib/Utils/responsive_util.dart +++ b/lib/Utils/responsive_util.dart @@ -42,21 +42,6 @@ class ResponsiveUtil { } } - static Future returnToMainScreen(BuildContext context) async { - if (ResponsiveUtil.isDesktop()) { - desktopNavigatorKey = GlobalKey(); - globalNavigatorState?.pushAndRemoveUntil( - RouteUtil.getFadeRoute(const MainScreen(), duration: Duration.zero), - (route) => false, - ); - } else { - Navigator.pushAndRemoveUntil( - context, - CupertinoPageRoute(builder: (context) => const MainScreen()), - (route) => false); - } - } - static Future maximizeOrRestore() async { if (await windowManager.isMaximized()) { windowManager.restore(); diff --git a/lib/Utils/utils.dart b/lib/Utils/utils.dart index f833f389..eb34d02a 100644 --- a/lib/Utils/utils.dart +++ b/lib/Utils/utils.dart @@ -361,7 +361,7 @@ class Utils { if (showLoading) { CustomLoadingDialog.showLoading(title: S.current.checkingUpdates); } - String currentVersion = (await PackageInfo.fromPlatform()).version; + String currentVersion ="0.0.0"?? (await PackageInfo.fromPlatform()).version; onGetCurrentVersion?.call(currentVersion); String latestVersion = "0.0.0"; await GithubApi.getReleases("Robert-Stackflow", "CloudOTP") @@ -395,11 +395,11 @@ class Utils { DialogBuilder.showConfirmDialog( context, renderHtml: true, + messageTextAlign: TextAlign.start, title: S.current.getNewVersion(latestVersion), message: S.current.doesImmediateUpdate + S.current.updateLogAsFollow( "
${Utils.replaceLineBreak(latestReleaseItem.body ?? "")}"), - messageTextAlign: TextAlign.start, confirmButtonText: S.current.immediatelyDownload, cancelButtonText: S.current.updateLater, onTapConfirm: () async { @@ -473,14 +473,10 @@ class Utils { } static localAuth({Function()? onAuthed}) async { + if (ResponsiveUtil.isDesktop()) return; LocalAuthentication localAuth = LocalAuthentication(); try { - String appName = (await PackageInfo.fromPlatform()).appName; - await localAuth - .authenticate( - localizedReason: ResponsiveUtil.isWindows() - ? S.current.biometricReasonWindows(appName) - : S.current.biometricReason(appName), + await localAuth.authenticate( authMessages: [ androidAuthMessages, androidAuthMessages, @@ -489,9 +485,10 @@ class Utils { options: const AuthenticationOptions( useErrorDialogs: false, stickyAuth: true, + biometricOnly: true, ), - ) - .then((value) { + localizedReason: ' ', + ).then((value) { if (value) { onAuthed?.call(); } diff --git a/lib/Widgets/BottomSheet/add_bottom_sheet.dart b/lib/Widgets/BottomSheet/add_bottom_sheet.dart index a08aeb76..af1788cf 100644 --- a/lib/Widgets/BottomSheet/add_bottom_sheet.dart +++ b/lib/Widgets/BottomSheet/add_bottom_sheet.dart @@ -137,7 +137,7 @@ class AddBottomSheetState extends State ItemBuilder.buildDivider(context, horizontal: 10, vertical: 0), if (!widget.onlyShowScanner) _buildOptions(), - if (!widget.onlyShowScanner) const SizedBox(height: 20), + // if (!widget.onlyShowScanner) const SizedBox(height: 20), ], ), ), diff --git a/lib/Widgets/BottomSheet/list_bottom_sheet.dart b/lib/Widgets/BottomSheet/list_bottom_sheet.dart index f0a67bce..2efd8b89 100644 --- a/lib/Widgets/BottomSheet/list_bottom_sheet.dart +++ b/lib/Widgets/BottomSheet/list_bottom_sheet.dart @@ -86,7 +86,7 @@ class TileList extends StatelessWidget { if (showCancel) ItemBuilder.buildEntryItem( title: S.current.cancel, - backgroundColor: Theme.of(context).cardColor.withAlpha(127), + backgroundColor: Colors.grey.withOpacity(0.1), showTrailing: false, onTap: onCloseTap, context: context, diff --git a/lib/Widgets/Custom/keyboard_handler.dart b/lib/Widgets/Custom/keyboard_handler.dart index e8a651ef..e5aa8595 100644 --- a/lib/Widgets/Custom/keyboard_handler.dart +++ b/lib/Widgets/Custom/keyboard_handler.dart @@ -66,7 +66,7 @@ class KeyboardHandlerState extends State { onInvoke: (_) { mainScreenState?.goHome(); if (HiveUtil.canLock()) { - mainScreenState?.jumpToPinVerify(); + mainScreenState?.jumpToLock(); } else { IToast.showTop(S.current.noGestureLock); } diff --git a/lib/Widgets/Dialog/custom_dialog.dart b/lib/Widgets/Dialog/custom_dialog.dart index 54cc2c05..73960650 100644 --- a/lib/Widgets/Dialog/custom_dialog.dart +++ b/lib/Widgets/Dialog/custom_dialog.dart @@ -27,7 +27,7 @@ class CustomInfoDialog { EdgeInsets? margin, EdgeInsets? padding, bool barrierDismissible = true, - bool renderHtml = true, + bool renderHtml = false, Alignment align = Alignment.bottomCenter, bool topRadius = true, bool bottomRadius = true, @@ -70,7 +70,7 @@ class CustomInfoDialog { EdgeInsets? margin, EdgeInsets? padding, bool barrierDismissible = true, - bool renderHtml = true, + bool renderHtml = false, Alignment align = Alignment.bottomCenter, }) => showGeneralDialog( @@ -120,7 +120,7 @@ class CustomInfoDialog { EdgeInsets? margin, EdgeInsets? padding, bool barrierDismissible = true, - bool renderHtml = true, + bool renderHtml = false, Alignment align = Alignment.bottomCenter, }) => showGeneralDialog( @@ -170,7 +170,7 @@ class CustomInfoDialog { EdgeInsets? margin, EdgeInsets? padding, bool barrierDismissible = true, - bool renderHtml = true, + bool renderHtml = false, Alignment align = Alignment.bottomCenter, }) => showGeneralDialog( @@ -220,7 +220,7 @@ class CustomInfoDialog { EdgeInsets? margin, EdgeInsets? padding, bool barrierDismissible = true, - bool renderHtml = true, + bool renderHtml = false, Alignment align = Alignment.bottomCenter, bool topRadius = true, bool bottomRadius = true, @@ -274,7 +274,7 @@ class CustomInfoDialog { EdgeInsets? margin, EdgeInsets? padding, bool barrierDismissible = true, - bool renderHtml = true, + bool renderHtml = false, Alignment align = Alignment.bottomCenter, }) => showGeneralDialog( @@ -324,7 +324,7 @@ class CustomInfoDialog { EdgeInsets? margin, EdgeInsets? padding, bool barrierDismissible = true, - bool renderHtml = true, + bool renderHtml = false, Alignment align = Alignment.bottomCenter, }) => showGeneralDialog( @@ -378,7 +378,7 @@ class CustomConfirmDialog { EdgeInsets? margin, EdgeInsets? padding, bool barrierDismissible = true, - bool renderHtml = true, + bool renderHtml = false, Alignment align = Alignment.bottomCenter, }) => showDialog( @@ -421,7 +421,7 @@ class CustomConfirmDialog { EdgeInsets? margin, EdgeInsets? padding, bool barrierDismissible = true, - bool renderHtml = true, + bool renderHtml = false, Alignment align = Alignment.bottomCenter, }) => showGeneralDialog( @@ -475,7 +475,7 @@ class CustomConfirmDialog { EdgeInsets? margin, EdgeInsets? padding, bool barrierDismissible = true, - bool renderHtml = true, + bool renderHtml = false, Alignment align = Alignment.bottomCenter, }) => showGeneralDialog( @@ -529,7 +529,7 @@ class CustomConfirmDialog { EdgeInsets? margin, EdgeInsets? padding, bool barrierDismissible = true, - bool renderHtml = true, + bool renderHtml = false, Alignment align = Alignment.bottomCenter, }) => showGeneralDialog( @@ -583,7 +583,7 @@ class CustomConfirmDialog { EdgeInsets? margin, EdgeInsets? padding, bool barrierDismissible = true, - bool renderHtml = true, + bool renderHtml = false, Alignment align = Alignment.bottomCenter, }) => showGeneralDialog( @@ -637,7 +637,7 @@ class CustomConfirmDialog { EdgeInsets? margin, EdgeInsets? padding, bool barrierDismissible = true, - bool renderHtml = true, + bool renderHtml = false, Alignment align = Alignment.bottomCenter, }) => showGeneralDialog( @@ -691,7 +691,7 @@ class CustomConfirmDialog { EdgeInsets? margin, EdgeInsets? padding, bool barrierDismissible = true, - bool renderHtml = true, + bool renderHtml = false, Alignment align = Alignment.bottomCenter, }) => showGeneralDialog( diff --git a/lib/Widgets/Dialog/dialog_builder.dart b/lib/Widgets/Dialog/dialog_builder.dart index d1a4790c..665844b3 100644 --- a/lib/Widgets/Dialog/dialog_builder.dart +++ b/lib/Widgets/Dialog/dialog_builder.dart @@ -25,7 +25,7 @@ class DialogBuilder { EdgeInsets? margin, EdgeInsets? padding, bool barrierDismissible = true, - bool renderHtml = true, + bool renderHtml = false, Alignment align = Alignment.bottomCenter, bool responsive = true, }) { @@ -89,7 +89,7 @@ class DialogBuilder { EdgeInsets? margin, EdgeInsets? padding, bool barrierDismissible = true, - bool renderHtml = true, + bool renderHtml = false, Alignment align = Alignment.bottomCenter, bool responsive = true, bool topRadius = true, diff --git a/lib/Widgets/Dialog/widgets/qrcodes_dialog_widget.dart b/lib/Widgets/Dialog/widgets/qrcodes_dialog_widget.dart index abf0abd3..4e5c51e8 100644 --- a/lib/Widgets/Dialog/widgets/qrcodes_dialog_widget.dart +++ b/lib/Widgets/Dialog/widgets/qrcodes_dialog_widget.dart @@ -58,8 +58,12 @@ class QrcodesDialogWidgetState extends State { ? const EdgeInsets.all(24) : EdgeInsets.zero, decoration: BoxDecoration( - color: MyTheme.getCardBackground(context), - borderRadius: BorderRadius.circular(15), + color: Theme.of(context).canvasColor, + borderRadius: BorderRadius.vertical( + top: const Radius.circular(20), + bottom: ResponsiveUtil.isWideLandscape() + ? const Radius.circular(20) + : Radius.zero), ), child: ListView( padding: const EdgeInsets.symmetric(vertical: 24), diff --git a/lib/Widgets/Item/item_builder.dart b/lib/Widgets/Item/item_builder.dart index 15471c7d..3a4907cc 100644 --- a/lib/Widgets/Item/item_builder.dart +++ b/lib/Widgets/Item/item_builder.dart @@ -1673,6 +1673,7 @@ class ItemBuilder { TextStyle? textStyle, double? width, bool align = false, + bool disabled = false, bool feedback = false, bool reversePosition = false, }) { @@ -1683,7 +1684,9 @@ class ItemBuilder { color: color ?? (background != null ? Colors.white - : Theme.of(context).textTheme.titleSmall?.color), + : disabled + ? Colors.grey + : Theme.of(context).textTheme.titleSmall?.color), fontWeightDelta: 2, fontSizeDelta: fontSizeDelta, ), @@ -1694,7 +1697,7 @@ class ItemBuilder { color: background ?? Theme.of(context).cardColor, borderRadius: BorderRadius.circular(radius), child: InkWell( - onTap: onTap != null + onTap: onTap != null && !disabled ? () { onTap(); if (feedback) HapticFeedback.lightImpact(); diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index 20e2decb..e7d3097c 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -52,6 +52,7 @@ "goToUpdate": "Go to Update", "updateLater": "Do not update for now", "immediatelyInstall": "Install immediately", + "installPortableTip": "You are currently using the portable version, please manually decompress the installation package and overwrite the original files", "installing": "Installing...", "installCanceled": "Installation canceled", "installFileNotFound": "Installation file {filepath} not found", @@ -314,42 +315,52 @@ "setGestureLockSuccess": "Gesture password set successfully", "gestureLockWrong": "Password error, please redraw", "verifyGestureLock": "Verify gesture password", - "biometric": "Biometric recognition", - "biometricErrorHwUnavailable": "The current device does not support biometrics", + "enableBiometricSuccess": "Biometrics enabled successfully", + "biometric": "biometric", + "biometricUnlock": "Unlock using biometrics", + "biometricUnlockTip": "Authentication only through biometrics; only supports Android, IOS, Windows devices; only PIN code verification is supported on Windows devices", + "biometricDecryptDatabase": "Use biometric decrypt database", + "biometricDecryptDatabaseTip": "Use biometric technology to encrypt and store your database password, and use biometrics to decrypt the database when you start the application; when the biometric information of the device is changed (such as adding a fingerprint), you need to re-verify the biometric before you can use it.", + "biometricChanged": "The device biometric information has changed, please re-verify before enabling", + "biometricErrorHwUnavailable": "The current device's biometric hardware is unavailable", "biometricErrorNoBiometricEnrolled": "The current device has not entered biometrics", "biometricErrorNoHardware": "The current device does not support biometrics", "biometricErrorPasscodeNotSet": "The current device does not have a lock screen password set", "biometricErrorUnkown": "Unknown error", - "enableBiometricSuccess": "Biometric recognition is enabled successfully", - "biometricTip": "Only supports Android, IOS, and Windows devices; only PIN is supported on Windows devices", + "biometricErrorUnsupported": "The current platform does not support biometrics", "biometricVerifyPin": "Verify PIN", - "biometricVerifyFingerprint": "Fingerprint recognition", "biometricVerifySuccess": "Verification successful", "biometricToSaveDatabasePassword": "Perform fingerprint verification to save database password", "biometricToDecryptDatabase": "Perform fingerprint verification to decrypt database", - "biometricReason": "Perform fingerprint verification to use {appName}", + "biometricReason": "Fingerprint verification to use {appName}", "biometricReasonWindows": "Verify PIN to use {appName}", "biometricCancelButton": "Cancel", "biometricGoToSettingsButton": "Go to settings", "biometricNotRecognized": "Fingerprint recognition failed", "biometricGoToSettingsDescription": "Please set fingerprint", "biometricHint": "", - "biometricSuccess": "Fingerprint recognition is successful", - "biometricSignInTitle": "Fingerprint verification", - "biometricDeviceCredentialsRequiredTitle": "Please enroll your fingerprint first!", + "biometricUserCanceled": "User canceled operation", + "biometricTimeout": "Operation timeout", + "biometricUnknown": "Verification failed, maybe too many attempts", + "biometricError": "Verification failed", + "biometricSuccess": "Fingerprint recognition successful", + "biometricSignInTitle": "Fingerprint Verification", + "biometricDeviceCredentialsRequiredTitle": "Please enter your fingerprint first!", "biometricNotAvailable": "Your device does not support biometrics", - "biometricNotEnrolled": "Your device has not enrolled your fingerprint", - "biometricLockout": "The biometric function has been locked. Please try again later", - "biometricLockoutPermanent": "The biometric function has been permanently locked. Please use other methods to unlock", + "biometricNotEnrolled": "Your device did not register fingerprints", + "biometricLockout": "The biometric function has been locked, please try again later", + "biometricLockoutPermanent": "The biometric function has been permanently locked, please use other methods to unlock", "biometricOtherReason": "Unknown reason: {reason}", "drawOldGestureLock": "Please draw the old gesture password", "drawNewGestureLock": "Draw a new gesture password", "autoLock": "Auto-lock in the background", - "autoLockTip": "On Windows, Linux, and MacOS devices, when the window is minimized or minimized to the tray, it means it is in the background", + "autoLockTip": "When gesture password or custom database password is enabled, the automatic lock function is supported; On Windows, Linux, and MacOS devices, when the window is minimized or minimized to the tray, it means it is in the background", "autoLockDelay": "Auto-lock timing", "chooseAutoLockDelay": "Choose when to automatically lock", "immediatelyLock": "Lock immediately", + "after30SecondsLock": "Lock after 30 seconds in the background", "after1MinuteLock": "Lock after 1 minute in the background", + "after3MinutesLock": "Lock after 3 minutes in the background", "after5MinutesLock": "Lock after 5 minutes in the background", "after10MinutesLock": "Lock after 10 minutes in the background", "safeMode": "Safe Mode", diff --git a/lib/l10n/intl_zh_CN.arb b/lib/l10n/intl_zh_CN.arb index 3d18ecf3..3566d847 100644 --- a/lib/l10n/intl_zh_CN.arb +++ b/lib/l10n/intl_zh_CN.arb @@ -47,6 +47,7 @@ "goToUpdate": "前往更新", "updateLater": "暂不更新", "immediatelyInstall": "立即安装", + "installPortableTip": "您当前正在使用便携版,请手动解压缩安装包并覆盖原有文件", "installing": "安装中...", "installCanceled": "安装已取消", "installFileNotFound": "安装包{filepath}不存在", @@ -311,16 +312,20 @@ "setGestureLockSuccess": "手势密码设置成功", "gestureLockWrong": "密码错误, 请重新绘制", "verifyGestureLock": "验证手势密码", + "enableBiometricSuccess": "生物识别开启成功", "biometric": "生物识别", - "biometricErrorHwUnavailable": "当前设备不支持生物识别", + "biometricUnlock": "使用生物识别解锁", + "biometricUnlockTip": "仅通过生物识别进行身份验证;仅支持Android、IOS、Windows设备;Windows设备上仅支持PIN码验证", + "biometricDecryptDatabase": "使用生物识别解密数据库", + "biometricChanged": "设备生物识别信息已更改,请重新验证后启用", + "biometricDecryptDatabaseTip": "使用生物识别技术加密存储您的数据库密码,当启动应用时使用生物识别解密数据库;当舍设备的生物识别信息更改(如添加指纹)后,需要重新验证生物识别后才能使用", + "biometricErrorHwUnavailable": "当前设备的生物识别硬件不可用", "biometricErrorNoBiometricEnrolled": "当前设备未录入生物识别", "biometricErrorNoHardware": "当前设备不支持生物识别", "biometricErrorPasscodeNotSet": "当前设备未设置锁屏密码", "biometricErrorUnkown": "未知错误", - "enableBiometricSuccess": "生物识别开启成功", - "biometricTip": "仅支持Android、IOS、Windows设备;Windows设备上仅支持PIN", + "biometricErrorUnsupported": "当前平台不支持生物识别", "biometricVerifyPin": "验证PIN", - "biometricVerifyFingerprint": "指纹识别", "biometricVerifySuccess": "验证成功", "biometricToSaveDatabasePassword": "进行指纹验证以保存数据库密码", "biometricToDecryptDatabase": "进行指纹验证以解密数据库", @@ -331,22 +336,28 @@ "biometricNotRecognized": "指纹识别失败", "biometricGoToSettingsDescription": "请设置指纹", "biometricHint": "", + "biometricUserCanceled": "用户取消操作", + "biometricTimeout": "操作超时", + "biometricUnknown": "验证失败,可能是尝试次数过多", + "biometricError": "验证失败", "biometricSuccess": "指纹识别成功", "biometricSignInTitle": "指纹验证", "biometricDeviceCredentialsRequiredTitle": "请先录入指纹!", "biometricNotAvailable": "您的设备不支持生物识别", - "biometricNotEnrolled": "您的设备未录入指纹", + "biometricNotEnrolled": "您的设备未录入生物识别", "biometricLockout": "生物识别功能已被锁定,请稍后再试", "biometricLockoutPermanent": "生物识别功能已被永久锁定,请使用其他方式解锁", "biometricOtherReason": "未知原因:{reason}", "drawOldGestureLock": "请绘制旧手势密码", "drawNewGestureLock": "绘制新手势密码", "autoLock": "处于后台自动锁定", - "autoLockTip": "在Windows、Linux、MacOS设备中,窗口最小化或最小化至托盘时即表示处于后台", + "autoLockTip": "当启用手势密码或自定义数据库密码后,支持自动锁定功能;在Windows、Linux、MacOS设备中,窗口最小化或最小化至托盘时即表示处于后台", "autoLockDelay": "自动锁定时机", "chooseAutoLockDelay": "选择自动锁定时机", "immediatelyLock": "立即锁定", + "after30SecondsLock": "处于后台30秒后锁定", "after1MinuteLock": "处于后台1分钟后锁定", + "after3MinutesLock": "处于后台3分钟后锁定", "after5MinutesLock": "处于后台5分钟后锁定", "after10MinutesLock": "处于后台10分钟后锁定", "safeMode": "安全模式", diff --git a/lib/main.dart b/lib/main.dart index a2bced91..0ab06717 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -62,7 +62,7 @@ Future runMyApp(List args) async { late Widget home; if (!DatabaseManager.initialized) { home = const DatabaseDecryptScreen(); - } else if (HiveUtil.canLock()) { + } else if (HiveUtil.canGuestureLock()) { home = const PinVerifyScreen( isModal: true, autoAuth: true, @@ -170,7 +170,7 @@ class MyApp extends StatelessWidget { const MyApp({ super.key, - this.home = const MainScreen(), + required this.home, this.title = 'CloudOTP', }); @@ -240,7 +240,8 @@ class MyApp extends StatelessWidget { .copyWith(textScaler: TextScaler.noScaling), child: Listener( onPointerDown: (_) { - if (!ResponsiveUtil.isDesktop()) { + if (!ResponsiveUtil.isDesktop() && + homeScreenState?.hasSearchFocus == true) { FocusManager.instance.primaryFocus?.unfocus(); } }, diff --git a/pubspec.lock b/pubspec.lock index f17a5d6c..baacb2a8 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -84,11 +84,10 @@ packages: biometric_storage: dependency: "direct main" description: - name: biometric_storage - sha256: "2cc569f2dbab39950812a6ae7fefa28c7d1c1eb07d2035c88f5e27da09022f5f" - url: "https://pub.flutter-io.cn" - source: hosted - version: "5.0.1" + path: "third-party/biometric_storage" + relative: true + source: path + version: "5.1.0-rc.5" boolean_selector: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 786ee217..09bf086d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,5 +1,5 @@ name: cloudotp -version: 2.4.0+240 +version: 2.4.1+241 description: An awesome two-factor authenticator which supports cloud storage and multiple platforms. publish_to: none @@ -78,7 +78,8 @@ dependencies: share_plus: ^9.0.0 # 分享 protocol_handler: ^0.2.0 # 协议处理 url_launcher: ^6.3.0 # URL跳转 - biometric_storage: ^5.0.1 # 生物识别 + biometric_storage: + path: third-party/biometric_storage # 平台适配 saf: path: third-party/saf diff --git a/third-party/biometric_storage/CHANGELOG.md b/third-party/biometric_storage/CHANGELOG.md new file mode 100644 index 00000000..3ff45f8f --- /dev/null +++ b/third-party/biometric_storage/CHANGELOG.md @@ -0,0 +1,263 @@ +## 5.1.0-rc.5 + +* upgrade dependency to web 1.0 + +## 5.1.0-rc.4 + +* enable building on jdk 17 and up https://github.com/authpass/biometric_storage/issues/117 thanks @connyduck + +## 5.1.0-rc.3 + +* Split Split authenticationValidityDurationSeconds between android and iOS + * `darwinTouchIDAuthenticationForceReuseContextDuration`: Basically the equivalent to `androidAuthenticationValidityDuration` + * `darwinTouchIDAuthenticationAllowableReuseDuration` +* android: return correct code if no biometric is enrolled #115 @ThomasLamprecht +* web: migrate from dart:html to package:web (for wasm support). + +## 5.0.1 + +* Add option for iOS/MacOS to allow non-biometric authentication (`darwinBiometricOnly`) #101 + * Improve [canAuthenticate] to differentiate between no available biometry and no available + user code. +* Bump dart sdk requirement to `3.2`. + +## 5.0.0+4 + +* Add topics to pubspec.yaml + +## 5.0.0+3 + +* Android: Upgrade AGP, fix building with AGP 8 +* Android: Depend on slf4j-api. + +## 5.0.0+1 + +* MacOS: fix building on MacOS + +## 5.0.0 + +* Allow overriding of `promptInfo` during `read`/`write` thanks @luckyrat +* Android: (POTENTIALLY BREAKING): Completely removed deprecated old file backend + based on `androidx.security`. This was deprecated since version 3.0.0 and users + should have been migrated on every read or write. (this is only internally, does not change + anything of the API). +* Update dependencies. + +## 4.1.3 + +* iOS/MacOS: Reuse LAContext to make `touchIDAuthenticationAllowableReuseDuration` work. + thanks @radvansky-tomas + +## 4.1.2 + +* Android: Move File I/O and encryption to background thread. (Previously used UI Thread) + https://github.com/authpass/biometric_storage/pull/64 + +## 4.1.1 + +* Fix building on all platforms, add github actions to test building. + +## 4.1.0 + +* Android: Remove Moshi dependency altogether. #53 + +## 4.0.1 + +* Update to Moshi 1.13 for Kotlin 1.6.0 compatibility. #53 + +## 4.0.0 + +* Fixed compile errors with Flutter >= 2.8.0 (Compatible with Flutter 2.5). #47 fix #42 + +## 3.0.1 + +* Android: Validate options on `int` + When `authenticationValidityDurationSeconds == -1`, then `androidBiometricOnly` must be `true` +* Android: if `authenticationValidityDurationSeconds` is `> 0` only show authentication prompt when + necessary. (It will simply try to use the key, and show the auth prompt only when a + `UserNotAuthenticatedException` is thrown). +* Android: When biometric key is invalidated (e.g. because biometric security is changed on the + device), we simply delete the old key and data! (KeyPermanentlyInvalidatedException) + +## 3.0.0 + +* Stable Release 🥳 +* **Please check below for breaking changes in the `-rc` releases. + +## 3.0.0-rc.12 + +* Android: Fix a few bugs with `authenticationValidityDurationSeconds` == -1 +* iOS/MacOS: Don't set timeout for `authenticationValidityDurationSeconds` == -1 +* iOS/MacOS: Don't raise an error on `delete` if item was not found. +* Android: Fix user cancel code. + (Previously an `unknown` exception was thrown instead of `userCanceled`) +* Android: Ignore `androidBiometricOnly` prior to Android R (30). +* Introduce `AuthExceptionCode.canceled` + +## 3.0.0-rc.7 + +* **Breaking Change**: `authenticationValidityDurationSeconds` is now `-1` by default, which was + not supported before hand. If you need backward compatibility, make sure to override this value + to the previous value of `10`. +* **Breaking Change**: No more support for Android v1 Plugin registration. +* **Breaking Change**: No longer using androidx.security, but instead handle encryption + directly. Temporarily there is a fallback to read old content. This requires either reencrypting + everything, or old data will no longer be readable. + + 1. This should fix a lot of errors. + 2. This now finally also allows using `authenticationValidityDurationSeconds` = -1. + 3. `BIOMETRIC_WEAK` is no longer used, only `BIOMETRIC_STRONG`. +* Don't ask for authentication for delete. + +## 3.0.0-rc.5 + +* **Breaking Change**: due to the introduction of iOS prompt info there is now a wrapper object + `PromptInfo` which contains `AndroidPromptInfo` and `IosPromptInfo`. +* Android: Add support for local (non-biometric) storage (#28, thanks @killalad) +* Android: Update all gradle dependencies, removed gradle-wrapper from plugin folder. +* iOS: Add support for customizing prompt strings. +* MacOS: Add support for customizing prompt strings. + +## 2.0.3 + +* Android + * compatibility with kotlin 1.5.20 + * Remove jcenter() references. + * androidx.core:core:1.3.2 to 1.6.0 + * moshi from 1.11.0 to 1.12.0 (this is the kotlin 1.5.20 compatibility problem) + +## 2.0.2 + +* Android upgrade dependencies: + * androidx.security:security-crypto from 1.1.0-alpha02 to 1.1.0-alpha03 + * androidx.biometric:biometric from 1.1.0-beta01 to 1.2.0-alpha03 + * Update README to clarify minSdkVersion and kotlin version + +## 2.0.1 + +* Handle android `BIOMETRIC_STATUS_UNKNOWN` response on older devices + (Android 9/API 28(?)) + +## 2.0.0 + +* Null safety stable release. + +## 2.0.0-nullsafety.1 + +* Null safety migration. + +## 1.1.0+1 + +* upgrade android moshi dependency. + +## 1.1.0 + +* Upgrade to latest Android dependencies (gradle plugin, androidx.*, gradle plugin) + * [androidx.security:security-crypto](https://developer.android.com/jetpack/androidx/releases/security) 1.0.0-rc02 to 1.1.0-alpha02 + * [androidx.biometric:biometric](https://developer.android.com/jetpack/androidx/releases/biometric) 1.0.1 to 1.1.0-beta01 + +## 1.0.1+5 + +* Workaround to not load win32 when compiling for web. + +## 1.0.1+4 + +* Fix windows plugin config. + +## 1.0.1+1 + +* Support for web support: **Warning**: Unencrypted - stores into local storage on web! +* Updated README to add details about windows. + +## 1.0.0 + +* Windows: Initial support for windows. only unauthenticated storage in Credential Manager. + +## 0.4.1 + +* Linux: Improve snap compatibility by detecting AppArmor error to prompt users to connect + to password-manager-service. + +## 0.4.0 + +* Linux: Initial support for Linux - only unauthenticated storage in Keyring. + +## 0.3.4+6 + +* Android: androidx.security 1.0.0-rc02 needs another proguard rule. + https://github.com/google/tink/issues/361 + +## 0.3.4+5 + +* Android: Upgrade to androidx.security 1.0.0-rc02 which should fix protobuf incompatibilities + #6 https://developer.android.com/jetpack/androidx/releases/security#security-crypto-1.0.0-rc02 + +## 0.3.4+4 + +* Android: fix PromptInfo deserialization with minification. +* Android: add proguard setting to fix protobuf exceptions. + +## 0.3.4+2 + +* Android: updated dependencies to androidx.security, biometric, gradle tools. + +## 0.3.4+1 + +* Android: on error send stack trace to flutter. also fixed a couple of warnings. + +## 0.3.4 + +* Android: allow customization of the PromptInfo (labels, buttons, etc). + @patrickhammond + +## 0.3.3 + +* ios: added swift 5 dependency to podspec to fix compile errors + https://github.com/authpass/biometric_storage/issues/3 + +## 0.3.2 + +* android: fingerprint failures don't cancel the dialog, so don't trigger error callback. #2 + (fixes crash) + +## 0.3.1 + +* Use android v2 plugin API. + +## 0.3.0-beta.2 + +* Use new plugin format for Mac OS format. Not compatible with flutter 1.9.x + +## 0.2.2+2 + +* Use legacy plugin platforms structure to be compatible with flutter stable. + +## 0.2.2+1 + +* fixed home page link, updated example README. + +## 0.2.2 + +* Android: Use codegen instead of reflection for json serialization. + (Fixes bug that options aren't assed in correctly due to minification) + +## 0.2.1 + +* Android: Fix for having multiple files with different configurations. +* Correctly handle UserCanceled events. +* Define correct default values on dart side (10 seconds validity timeout). + +## 0.2.0 + +* MacOS Support + +## 0.1.0 + +* iOS Support +* Support for non-authenticated storage (ie. secure/encrypted storage, + without extra biometric authenticatiton prompts) +* delete()'ing files. + +## 0.0.1 - Initial release + +* Android Support. diff --git a/third-party/biometric_storage/LICENSE b/third-party/biometric_storage/LICENSE new file mode 100644 index 00000000..c8b048f3 --- /dev/null +++ b/third-party/biometric_storage/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2019 Herbert Poul (@hpoul) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/third-party/biometric_storage/README.md b/third-party/biometric_storage/README.md new file mode 100644 index 00000000..d41886c2 --- /dev/null +++ b/third-party/biometric_storage/README.md @@ -0,0 +1,117 @@ +# biometric_storage + +[![Pub](https://img.shields.io/pub/v/biometric_storage?color=green)](https://pub.dev/packages/biometric_storage/) + +Encrypted file store, **optionally** secured by biometric lock +for Android, iOS, MacOS and partial support for Linux, Windows and Web. + +Meant as a way to store small data in a hardware encrypted fashion. E.g. to +store passwords, secret keys, etc. but not massive amounts +of data. + +* Android: Uses androidx with KeyStore. +* iOS and MacOS: LocalAuthentication with KeyChain. +* Linux: Stores values in Keyring using libsecret. (No biometric authentication support). +* Windows: Uses [wincreds.h to store into read/write into credential store](https://docs.microsoft.com/en-us/windows/win32/api/wincred/). +* Web: **Warning** Uses unauthenticated, **unencrypted** storage in localStorage. + If you have a better idea for secure storage on web platform, [please open an Issue](https://github.com/authpass/biometric_storage/issues). + +Check out [AuthPass Password Manager](https://authpass.app/) for a app which +makes heavy use of this plugin. + +## Getting Started + +### Installation + +#### Android +* Requirements: + * Android: API Level >= 23 (android/app/build.gradle `minSdkVersion 23`) + * Make sure to use the latest kotlin version: + * `android/build.gradle`: `ext.kotlin_version = '1.4.31'` + * MainActivity must extend FlutterFragmentActivity + * Theme for the main activity must use `Theme.AppCompat` thme. + (Otherwise there will be crashes on Android < 29) + For example: + + **android/app/src/main/AndroidManifest.xml**: + ```xml + + [...] + + + ``` + + **android/app/src/main/res/values/styles.xml**: + ```xml + + + + + ``` + +##### Resources + +* https://developer.android.com/topic/security/data +* https://developer.android.com/topic/security/best-practices + +#### iOS + +https://developer.apple.com/documentation/localauthentication/logging_a_user_into_your_app_with_face_id_or_touch_id + +* include the NSFaceIDUsageDescription key in your app’s Info.plist file +* Supports all iOS versions supported by Flutter. (ie. iOS 12) + +**Known Issue**: since iOS 15 the simulator seem to no longer support local authentication: + https://developer.apple.com/forums/thread/685773 + +#### Mac OS + +* include the NSFaceIDUsageDescription key in your app’s Info.plist file +* enable keychain sharing and signing. (not sure why this is required. but without it + You will probably see an error like: + > SecurityError, Error while writing data: -34018: A required entitlement isn't present. +* Supports all MacOS Versions supported by Flutter (ie. >= MacOS 10.14) + +### Usage + +> You basically only need 4 methods. + +1. Check whether biometric authentication is supported by the device + +```dart + final response = await BiometricStorage().canAuthenticate() + if (response != CanAuthenticateResponse.success) { + // panic.. + } +``` + +2. Create the access object + +```dart + final store = BiometricStorage().getStorage('mystorage'); +``` + +3. Read data + +```dart + final data = await storageFile.read(); +``` + +4. Write data + +```dart + final myNewData = 'Hello World'; + await storageFile.write(myNewData); +``` + +See also the API documentation: https://pub.dev/documentation/biometric_storage/latest/biometric_storage/BiometricStorageFile-class.html#instance-methods diff --git a/third-party/biometric_storage/analysis_options.yaml b/third-party/biometric_storage/analysis_options.yaml new file mode 100644 index 00000000..0ba12552 --- /dev/null +++ b/third-party/biometric_storage/analysis_options.yaml @@ -0,0 +1,19 @@ +include: package:flutter_lints/flutter.yaml + +analyzer: + errors: + # treat missing required parameters as a warning (not a hint) + missing_required_param: warning + # treat missing returns as a warning (not a hint) + missing_return: warning + # allow having TODOs in the code + todo: ignore + exclude: + - lib/generated_plugin_registrant.dart + - example/lib/generated_plugin_registrant.dart + language: + strict-casts: true + strict-raw-types: true + +linter: + rules: diff --git a/third-party/biometric_storage/android/build.gradle b/third-party/biometric_storage/android/build.gradle new file mode 100644 index 00000000..761a58ec --- /dev/null +++ b/third-party/biometric_storage/android/build.gradle @@ -0,0 +1,63 @@ +group 'design.codeux.biometric_storage' +version '1.0-SNAPSHOT' + +buildscript { + ext.kotlin_version = '1.8.10' + repositories { + google() + mavenCentral() + } + + dependencies { + classpath 'com.android.tools.build:gradle:8.1.0' + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" + } +} + +rootProject.allprojects { + repositories { + google() + mavenCentral() + } +} + +apply plugin: 'com.android.library' +apply plugin: 'kotlin-android' +apply plugin: 'kotlin-kapt' + +android { + namespace "design.codeux.biometric_storage" + compileSdk 31 + + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = '17' + } + + sourceSets { + main.java.srcDirs += 'src/main/kotlin' + } + defaultConfig { + minSdkVersion 23 + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles 'proguard.pro' + } + lintOptions { + disable 'InvalidPackage' + } +} + +dependencies { + def biometric_version = "1.2.0-alpha05" + + api "androidx.core:core-ktx:1.10.1" + api "androidx.fragment:fragment-ktx:1.6.1" + + implementation "org.slf4j:slf4j-api:2.0.7" + implementation "androidx.biometric:biometric:$biometric_version" + implementation "io.github.oshai:kotlin-logging-jvm:5.0.1" +} diff --git a/third-party/biometric_storage/android/gradle.properties b/third-party/biometric_storage/android/gradle.properties new file mode 100644 index 00000000..8bd86f68 --- /dev/null +++ b/third-party/biometric_storage/android/gradle.properties @@ -0,0 +1 @@ +org.gradle.jvmargs=-Xmx1536M diff --git a/third-party/biometric_storage/android/proguard.pro b/third-party/biometric_storage/android/proguard.pro new file mode 100644 index 00000000..e69de29b diff --git a/third-party/biometric_storage/android/settings.gradle b/third-party/biometric_storage/android/settings.gradle new file mode 100644 index 00000000..7da7f9af --- /dev/null +++ b/third-party/biometric_storage/android/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'biometric_storage' diff --git a/third-party/biometric_storage/android/src/main/AndroidManifest.xml b/third-party/biometric_storage/android/src/main/AndroidManifest.xml new file mode 100644 index 00000000..4f4e4ed6 --- /dev/null +++ b/third-party/biometric_storage/android/src/main/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/third-party/biometric_storage/android/src/main/kotlin/design/codeux/biometric_storage/BiometricStorageFile.kt b/third-party/biometric_storage/android/src/main/kotlin/design/codeux/biometric_storage/BiometricStorageFile.kt new file mode 100644 index 00000000..0ed6e6cb --- /dev/null +++ b/third-party/biometric_storage/android/src/main/kotlin/design/codeux/biometric_storage/BiometricStorageFile.kt @@ -0,0 +1,147 @@ +package design.codeux.biometric_storage + +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import android.security.keystore.KeyProperties +import io.github.oshai.kotlinlogging.KotlinLogging +import java.io.File +import java.io.IOException +import javax.crypto.Cipher +import kotlin.time.Duration + +private val logger = KotlinLogging.logger {} + +data class InitOptions( + val androidAuthenticationValidityDuration: Duration? = null, + val authenticationRequired: Boolean = true, + val androidBiometricOnly: Boolean = true +) + +class BiometricStorageFile( + context: Context, + baseName: String, + val options: InitOptions +) { + + companion object { + /** + * Name of directory inside private storage where all encrypted files are stored. + */ + private const val DIRECTORY_NAME = "biometric_storage" + private const val FILE_SUFFIX_V2 = ".v2.txt" + } + + private val masterKeyName = "${baseName}_master_key" + private val fileNameV2 = "$baseName$FILE_SUFFIX_V2" + private val fileV2: File + + private val cryptographyManager = CryptographyManager { + setUserAuthenticationRequired(options.authenticationRequired) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + val useStrongBox = context.packageManager.hasSystemFeature( + PackageManager.FEATURE_STRONGBOX_KEYSTORE + ) + setIsStrongBoxBacked(useStrongBox) + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + if (options.androidAuthenticationValidityDuration == null) { + setUserAuthenticationParameters( + 0, + KeyProperties.AUTH_BIOMETRIC_STRONG + ) + } else { + setUserAuthenticationParameters( + options.androidAuthenticationValidityDuration.inWholeSeconds.toInt(), + KeyProperties.AUTH_DEVICE_CREDENTIAL or KeyProperties.AUTH_BIOMETRIC_STRONG + ) + } + } else { + @Suppress("DEPRECATION") + setUserAuthenticationValidityDurationSeconds( + options.androidAuthenticationValidityDuration?.inWholeSeconds?.toInt() ?: -1 + ) + } + } + + init { + val baseDir = File(context.filesDir, DIRECTORY_NAME) + if (!baseDir.exists()) { + baseDir.mkdirs() + } + fileV2 = File(baseDir, fileNameV2) + + logger.trace { "Initialized $this with $options" } + + validateOptions() + } + + private fun validateOptions() { + if (options.androidAuthenticationValidityDuration == null && !options.androidBiometricOnly) { + throw IllegalArgumentException("when androidAuthenticationValidityDuration is null, androidBiometricOnly must be true") + } + } + + fun cipherForEncrypt() = cryptographyManager.getInitializedCipherForEncryption(masterKeyName) + fun cipherForDecrypt(): Cipher? { + if (fileV2.exists()) { + return cryptographyManager.getInitializedCipherForDecryption(masterKeyName, fileV2) + } + logger.debug { "No file exists, no IV found. null cipher." } + return null + } + + fun exists() = fileV2.exists() + + @Synchronized + fun writeFile(cipher: Cipher?, content: String) { + // cipher will be null if user does not need authentication or valid period is > -1 + val useCipher = cipher ?: cipherForEncrypt() + try { + val encrypted = cryptographyManager.encryptData(content, useCipher) + fileV2.writeBytes(encrypted.encryptedPayload) + logger.debug { "Successfully written ${encrypted.encryptedPayload.size} bytes." } + + return + } catch (ex: IOException) { + // Error occurred opening file for writing. + logger.error(ex) { "Error while writing encrypted file $fileV2" } + throw ex + } + } + + @Synchronized + fun readFile(cipher: Cipher?): String? { + val useCipher = cipher ?: cipherForDecrypt() + // if the file exists, there should *always* be a decryption key. + if (useCipher != null && fileV2.exists()) { + return try { + val bytes = fileV2.readBytes() + logger.debug { "read ${bytes.size}" } + cryptographyManager.decryptData(bytes, useCipher) + } catch (ex: IOException) { + logger.error(ex) { "Error while writing encrypted file $fileV2" } + null + } + } + + logger.debug { "File $fileV2 does not exist. returning null." } + return null + + } + + @Synchronized + fun deleteFile(): Boolean { + cryptographyManager.deleteKey(masterKeyName) + return fileV2.delete() + } + + override fun toString(): String { + return "BiometricStorageFile(masterKeyName='$masterKeyName', fileName='$fileNameV2', file=$fileV2)" + } + + fun dispose() { + logger.trace { "dispose" } + } + +} diff --git a/third-party/biometric_storage/android/src/main/kotlin/design/codeux/biometric_storage/BiometricStoragePlugin.kt b/third-party/biometric_storage/android/src/main/kotlin/design/codeux/biometric_storage/BiometricStoragePlugin.kt new file mode 100644 index 00000000..cae56731 --- /dev/null +++ b/third-party/biometric_storage/android/src/main/kotlin/design/codeux/biometric_storage/BiometricStoragePlugin.kt @@ -0,0 +1,450 @@ +package design.codeux.biometric_storage + +import android.app.Activity +import android.content.Context +import android.os.* +import android.security.keystore.KeyPermanentlyInvalidatedException +import android.security.keystore.UserNotAuthenticatedException +import androidx.annotation.AnyThread +import androidx.annotation.UiThread +import androidx.annotation.WorkerThread +import androidx.biometric.* +import androidx.biometric.BiometricManager.Authenticators.* +import androidx.fragment.app.FragmentActivity +import io.flutter.embedding.engine.plugins.FlutterPlugin +import io.flutter.embedding.engine.plugins.activity.* +import io.flutter.plugin.common.* +import io.flutter.plugin.common.MethodChannel.MethodCallHandler +import io.flutter.plugin.common.MethodChannel.Result +import io.github.oshai.kotlinlogging.KotlinLogging +import java.io.PrintWriter +import java.io.StringWriter +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors +import javax.crypto.Cipher +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +private val logger = KotlinLogging.logger {} + +enum class CipherMode { + Encrypt, + Decrypt, +} + +typealias ErrorCallback = (errorInfo: AuthenticationErrorInfo) -> Unit + +class MethodCallException( + val errorCode: String, + val errorMessage: String?, + val errorDetails: Any? = null +) : Exception(errorMessage ?: errorCode) + +@Suppress("unused") +enum class CanAuthenticateResponse(val code: Int) { + Success(BiometricManager.BIOMETRIC_SUCCESS), + ErrorHwUnavailable(BiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE), + ErrorNoBiometricEnrolled(BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED), + ErrorNoHardware(BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE), + ErrorStatusUnknown(BiometricManager.BIOMETRIC_STATUS_UNKNOWN), + ErrorPasscodeNotSet(-99), + ; + + override fun toString(): String { + return "CanAuthenticateResponse.${name}: $code" + } +} + +@Suppress("unused") +enum class AuthenticationError(vararg val code: Int) { + Canceled(BiometricPrompt.ERROR_CANCELED), + Timeout(BiometricPrompt.ERROR_TIMEOUT), + UserCanceled(BiometricPrompt.ERROR_USER_CANCELED, BiometricPrompt.ERROR_NEGATIVE_BUTTON), + Unknown(-1), + + /** Authentication valid, but unknown */ + Failed(-2), + ; + + companion object { + fun forCode(code: Int) = + values().firstOrNull { it.code.contains(code) } ?: Unknown + } +} + +data class AuthenticationErrorInfo( + val error: AuthenticationError, + val message: CharSequence, + val errorDetails: String? = null +) { + constructor( + error: AuthenticationError, + message: CharSequence, + e: Throwable + ) : this(error, message, e.toCompleteString()) +} + +private fun Throwable.toCompleteString(): String { + val out = StringWriter().let { out -> + printStackTrace(PrintWriter(out)) + out.toString() + } + return "$this\n$out" +} + +class BiometricStoragePlugin : FlutterPlugin, ActivityAware, MethodCallHandler { + + companion object { + const val PARAM_NAME = "name" + const val PARAM_WRITE_CONTENT = "content" + const val PARAM_ANDROID_PROMPT_INFO = "androidPromptInfo" + + } + + private val executor: ExecutorService by lazy { Executors.newSingleThreadExecutor() } + private val handler: Handler by lazy { Handler(Looper.getMainLooper()) } + + + private var attachedActivity: FragmentActivity? = null + + private val storageFiles = mutableMapOf() + + private val biometricManager by lazy { BiometricManager.from(applicationContext) } + + private lateinit var applicationContext: Context + + override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) { + this.applicationContext = binding.applicationContext + val channel = MethodChannel(binding.binaryMessenger, "biometric_storage") + channel.setMethodCallHandler(this) + } + + override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { + executor.shutdown() + } + + override fun onMethodCall(call: MethodCall, result: Result) { + logger.trace { "onMethodCall(${call.method})" } + try { + fun requiredArgument(name: String) = + call.argument(name) ?: throw MethodCallException( + "MissingArgument", + "Missing required argument '$name'" + ) + + // every method call requires the name of the stored file. + val getName = { requiredArgument(PARAM_NAME) } + val getAndroidPromptInfo = { + requiredArgument>(PARAM_ANDROID_PROMPT_INFO).let { + AndroidPromptInfo( + title = it["title"] as String, + subtitle = it["subtitle"] as String?, + description = it["description"] as String?, + negativeButton = it["negativeButton"] as String, + confirmationRequired = it["confirmationRequired"] as Boolean, + ) + } + } + + fun withStorage(cb: BiometricStorageFile.() -> Unit) { + val name = getName() + storageFiles[name]?.apply(cb) ?: run { + logger.warn { "User tried to access storage '$name', before initialization" } + result.error("Storage $name was not initialized.", null, null) + return + } + } + + val resultError: ErrorCallback = { errorInfo -> + result.error( + "AuthError:${errorInfo.error}", + errorInfo.message.toString(), + errorInfo.errorDetails + ) + logger.error { "AuthError: $errorInfo" } + + } + + @UiThread + fun BiometricStorageFile.withAuth( + mode: CipherMode, + @WorkerThread cb: BiometricStorageFile.(cipher: Cipher?) -> Unit + ) { + if (!options.authenticationRequired) { + return cb(null) + } + + fun cipherForMode() = when (mode) { + CipherMode.Encrypt -> cipherForEncrypt() + CipherMode.Decrypt -> cipherForDecrypt() + } + + val cipher = if (options.androidAuthenticationValidityDuration != null) { + null + } else try { + cipherForMode() + } catch (e: KeyPermanentlyInvalidatedException) { + // TODO should we communicate this to the caller? + logger.warn(e) { "Key was invalidated. removing previous storage and recreating." } + deleteFile() + // if deleting fails, simply throw the second time around. + cipherForMode() + } + + if (cipher == null) { + // if we have no cipher, just try the callback and see if the + // user requires authentication. + try { + return cb(null) + } catch (e: UserNotAuthenticatedException) { + logger.debug(e) { "User requires (re)authentication. showing prompt ..." } + } + } + + val promptInfo = getAndroidPromptInfo() + authenticate(cipher, promptInfo, options, { + cb(cipher) + }, onError = resultError) + } + + when (call.method) { + "canAuthenticate" -> result.success(canAuthenticate().name) + "init" -> { + val name = getName() + if (storageFiles.containsKey(name)) { + if (call.argument("forceInit") == true) { + throw MethodCallException( + "AlreadyInitialized", + "A storage file with the name '$name' was already initialized." + ) + } else { + result.success(false) + return + } + } + + val options = call.argument>("options")?.let { + InitOptions( + androidAuthenticationValidityDuration = (it["androidAuthenticationValidityDurationSeconds"] as Int?)?.seconds, + authenticationRequired = it["authenticationRequired"] as Boolean, + androidBiometricOnly = it["androidBiometricOnly"] as Boolean, + ) + } ?: InitOptions() +// val options = moshi.adapter(InitOptions::class.java) +// .fromJsonValue(call.argument("options") ?: emptyMap()) +// ?: InitOptions() + storageFiles[name] = BiometricStorageFile(applicationContext, name, options) + result.success(true) + } + "dispose" -> storageFiles.remove(getName())?.apply { + dispose() + result.success(true) + } ?: throw MethodCallException( + "NoSuchStorage", + "Tried to dispose non existing storage.", + null + ) + "read" -> withStorage { + if (exists()) { + withAuth(CipherMode.Decrypt) { + val ret = readFile( + it, + ) + ui(resultError) { result.success(ret) } + } + } else { + result.success(null) + } + } + "delete" -> withStorage { + if (exists()) { + result.success(deleteFile()) + } else { + result.success(false) + } + } + "write" -> withStorage { + withAuth(CipherMode.Encrypt) { + writeFile(it, requiredArgument(PARAM_WRITE_CONTENT)) + ui(resultError) { result.success(true) } + } + } + else -> result.notImplemented() + } + } catch (e: MethodCallException) { + logger.error(e) { "Error while processing method call ${call.method}" } + result.error(e.errorCode, e.errorMessage, e.errorDetails) + } catch (e: Exception) { + logger.error(e) { "Error while processing method call '${call.method}'" } + result.error("Unexpected Error", e.message, e.toCompleteString()) + } + } + + @AnyThread + private inline fun ui( + @UiThread crossinline onError: ErrorCallback, + @UiThread crossinline cb: () -> Unit + ) = handler.post { + try { + cb() + } catch (e: Throwable) { + logger.error(e) { "Error while calling UI callback. This must not happen." } + onError( + AuthenticationErrorInfo( + AuthenticationError.Unknown, + "Unexpected authentication error. ${e.localizedMessage}", + e + ) + ) + } + } + + private inline fun worker( + @UiThread crossinline onError: ErrorCallback, + @WorkerThread crossinline cb: () -> Unit + ) = executor.submit { + try { + cb() + } catch (e: Throwable) { + logger.error(e) { "Error while calling worker callback. This must not happen." } + handler.post { + onError( + AuthenticationErrorInfo( + AuthenticationError.Unknown, + "Unexpected authentication error. ${e.localizedMessage}", + e + ) + ) + } + } + } + + private fun canAuthenticate(): CanAuthenticateResponse { + val credentialsResponse = biometricManager.canAuthenticate(DEVICE_CREDENTIAL) + logger.debug { "canAuthenticate for DEVICE_CREDENTIAL: $credentialsResponse" } + if (credentialsResponse == BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED) { + return CanAuthenticateResponse.ErrorNoBiometricEnrolled + } + + val response = biometricManager.canAuthenticate( + BIOMETRIC_STRONG or BIOMETRIC_WEAK + ) + return CanAuthenticateResponse.values().firstOrNull { it.code == response } + ?: throw Exception( + "Unknown response code {$response} (available: ${ + CanAuthenticateResponse + .values() + .contentToString() + }" + ) + } + + @UiThread + private fun authenticate( + cipher: Cipher?, + promptInfo: AndroidPromptInfo, + options: InitOptions, + @WorkerThread onSuccess: (cipher: Cipher?) -> Unit, + onError: ErrorCallback + ) { + logger.trace {"authenticate()" } + val activity = attachedActivity ?: return run { + logger.error { "We are not attached to an activity." } + onError( + AuthenticationErrorInfo( + AuthenticationError.Failed, + "Plugin not attached to any activity." + ) + ) + } + val prompt = + BiometricPrompt(activity, executor, object : BiometricPrompt.AuthenticationCallback() { + override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { + logger.trace { "onAuthenticationError($errorCode, $errString)" } + ui(onError) { + onError( + AuthenticationErrorInfo( + AuthenticationError.forCode( + errorCode + ), errString + ) + ) + } + } + + @WorkerThread + override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { + logger.trace { "onAuthenticationSucceeded($result)" } + worker(onError) { onSuccess(result.cryptoObject?.cipher) } + } + + override fun onAuthenticationFailed() { + logger.trace { "onAuthenticationFailed()" } + // this just means the user was not recognised, but the O/S will handle feedback so we don't have to + } + }) + + val promptBuilder = BiometricPrompt.PromptInfo.Builder() + .setTitle(promptInfo.title) + .setSubtitle(promptInfo.subtitle) + .setDescription(promptInfo.description) + .setConfirmationRequired(promptInfo.confirmationRequired) + + val biometricOnly = + options.androidBiometricOnly || Build.VERSION.SDK_INT < Build.VERSION_CODES.R + + if (biometricOnly) { + if (!options.androidBiometricOnly) { + logger.debug { + "androidBiometricOnly was false, but prior " + + "to ${Build.VERSION_CODES.R} this was not supported. ignoring." + } + } + promptBuilder + .setAllowedAuthenticators(BIOMETRIC_STRONG) + .setNegativeButtonText(promptInfo.negativeButton) + } else { + promptBuilder.setAllowedAuthenticators(DEVICE_CREDENTIAL or BIOMETRIC_STRONG) + } + + if (cipher == null || options.androidAuthenticationValidityDuration != null) { + // if androidAuthenticationValidityDuration is not null we can't use a CryptoObject + logger.debug { "Authenticating without cipher. ${options.androidAuthenticationValidityDuration}" } + prompt.authenticate(promptBuilder.build()) + } else { + prompt.authenticate(promptBuilder.build(), BiometricPrompt.CryptoObject(cipher)) + } + } + + override fun onDetachedFromActivity() { + logger.trace { "onDetachedFromActivity" } + attachedActivity = null + } + + override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) { + } + + override fun onAttachedToActivity(binding: ActivityPluginBinding) { + logger.debug { "Attached to new activity." } + updateAttachedActivity(binding.activity) + } + + private fun updateAttachedActivity(activity: Activity) { + if (activity !is FragmentActivity) { + logger.error { "Got attached to activity which is not a FragmentActivity: $activity" } + return + } + attachedActivity = activity + } + + override fun onDetachedFromActivityForConfigChanges() { + } +} + +data class AndroidPromptInfo( + val title: String, + val subtitle: String?, + val description: String?, + val negativeButton: String, + val confirmationRequired: Boolean +) diff --git a/third-party/biometric_storage/android/src/main/kotlin/design/codeux/biometric_storage/CryptographyManager.kt b/third-party/biometric_storage/android/src/main/kotlin/design/codeux/biometric_storage/CryptographyManager.kt new file mode 100644 index 00000000..2a3ff11b --- /dev/null +++ b/third-party/biometric_storage/android/src/main/kotlin/design/codeux/biometric_storage/CryptographyManager.kt @@ -0,0 +1,183 @@ +// based on https://github.com/isaidamier/blogs.biometrics.cryptoBlog/blob/cryptoObject/app/src/main/java/com/example/android/biometricauth/CryptographyManager.kt + +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ + +package design.codeux.biometric_storage + +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import io.github.oshai.kotlinlogging.KotlinLogging +import java.io.File +import java.nio.charset.Charset +import java.security.KeyStore +import java.security.KeyStoreException +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec + +private val logger = KotlinLogging.logger {} + +interface CryptographyManager { + + /** + * This method first gets or generates an instance of SecretKey and then initializes the Cipher + * with the key. The secret key uses [ENCRYPT_MODE][Cipher.ENCRYPT_MODE] is used. + */ + fun getInitializedCipherForEncryption(keyName: String): Cipher + + /** + * This method first gets or generates an instance of SecretKey and then initializes the Cipher + * with the key. The secret key uses [DECRYPT_MODE][Cipher.DECRYPT_MODE] is used. + */ + fun getInitializedCipherForDecryption(keyName: String, initializationVector: ByteArray): Cipher + fun getInitializedCipherForDecryption(keyName: String, encryptedDataFile: File): Cipher + + /** + * The Cipher created with [getInitializedCipherForEncryption] is used here + */ + fun encryptData(plaintext: String, cipher: Cipher): EncryptedData + + /** + * The Cipher created with [getInitializedCipherForDecryption] is used here + */ + fun decryptData(ciphertext: ByteArray, cipher: Cipher): String + +} + +fun CryptographyManager(configure: KeyGenParameterSpec.Builder.() -> Unit): CryptographyManagerImpl = CryptographyManagerImpl(configure) + +@Suppress("ArrayInDataClass") +data class EncryptedData(val encryptedPayload: ByteArray) + +class CryptographyManagerImpl( + private val configure: KeyGenParameterSpec.Builder.() -> Unit +) : + CryptographyManager { + + companion object { + + private const val KEY_SIZE: Int = 256 + + /** + * Prefix for the key name, to distinguish it from previously written key. + * kind of namespacing it. + */ + private const val KEY_PREFIX = "_CM_" + const val ANDROID_KEYSTORE = "AndroidKeyStore" + private const val ENCRYPTION_BLOCK_MODE = KeyProperties.BLOCK_MODE_GCM + private const val ENCRYPTION_PADDING = KeyProperties.ENCRYPTION_PADDING_NONE + private const val ENCRYPTION_ALGORITHM = KeyProperties.KEY_ALGORITHM_AES + + private const val IV_SIZE_IN_BYTES = 12 + private const val TAG_SIZE_IN_BYTES = 16 + + } + + override fun getInitializedCipherForEncryption(keyName: String): Cipher { + val cipher = getCipher() + val secretKey = getOrCreateSecretKey(keyName) + cipher.init(Cipher.ENCRYPT_MODE, secretKey) + return cipher + } + + override fun getInitializedCipherForDecryption( + keyName: String, + initializationVector: ByteArray + ): Cipher { + val cipher = getCipher() + val secretKey = getOrCreateSecretKey(keyName) + cipher.init(Cipher.DECRYPT_MODE, secretKey, GCMParameterSpec(TAG_SIZE_IN_BYTES * 8, initializationVector)) + return cipher + } + override fun getInitializedCipherForDecryption( + keyName: String, + encryptedDataFile: File, + ): Cipher { + val iv = ByteArray(IV_SIZE_IN_BYTES) + val count = encryptedDataFile.inputStream().read(iv) + assert(count == IV_SIZE_IN_BYTES) + return getInitializedCipherForDecryption(keyName, iv) + } + + override fun encryptData(plaintext: String, cipher: Cipher): EncryptedData { + val input = plaintext.toByteArray(Charsets.UTF_8) + val ciphertext = ByteArray(IV_SIZE_IN_BYTES + input.size + TAG_SIZE_IN_BYTES) + val bytesWritten = cipher.doFinal(input, 0, input.size, ciphertext, IV_SIZE_IN_BYTES) + cipher.iv.copyInto(ciphertext) + assert(bytesWritten == input.size + TAG_SIZE_IN_BYTES) + assert(cipher.iv.size == IV_SIZE_IN_BYTES) + logger.debug { "encrypted ${input.size} (${ciphertext.size} output)" } +// val ciphertext = cipher.doFinal(plaintext.toByteArray(Charsets.UTF_8)) + return EncryptedData(ciphertext) + } + + override fun decryptData(ciphertext: ByteArray, cipher: Cipher): String { + logger.debug { "decrypting ${ciphertext.size} bytes (iv: ${IV_SIZE_IN_BYTES}, tag: ${TAG_SIZE_IN_BYTES})" } + val iv = ciphertext.sliceArray(IntRange(0, IV_SIZE_IN_BYTES - 1)) + if (!iv.contentEquals(cipher.iv)) { + throw IllegalStateException("expected first bytes of ciphertext to equal cipher iv.") + } + val plaintext = cipher.doFinal(ciphertext, IV_SIZE_IN_BYTES, ciphertext.size - IV_SIZE_IN_BYTES) + return String(plaintext, Charset.forName("UTF-8")) + } + + private fun getCipher(): Cipher { + val transformation = "$ENCRYPTION_ALGORITHM/$ENCRYPTION_BLOCK_MODE/$ENCRYPTION_PADDING" + return Cipher.getInstance(transformation) + } + + fun deleteKey(keyName: String) { + val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE) + keyStore.load(null) // Keystore must be loaded before it can be accessed + try { + keyStore.deleteEntry(KEY_PREFIX + keyName) + } catch (e: KeyStoreException) { + logger.warn(e) { "Unable to delete key from KeyStore $KEY_PREFIX$keyName" } + } + } + + private fun getOrCreateSecretKey(keyName: String): SecretKey { + val realKeyName = KEY_PREFIX + keyName + // If Secretkey was previously created for that keyName, then grab and return it. + val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE) + keyStore.load(null) // Keystore must be loaded before it can be accessed + keyStore.getKey(realKeyName, null)?.let { return it as SecretKey } + + // if you reach here, then a new SecretKey must be generated for that keyName + val paramsBuilder = KeyGenParameterSpec.Builder( + realKeyName, + KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT + ) + paramsBuilder.apply { + setBlockModes(ENCRYPTION_BLOCK_MODE) + setEncryptionPaddings(ENCRYPTION_PADDING) + setKeySize(KEY_SIZE) + setUserAuthenticationRequired(true) + configure() + } + + val keyGenParams = paramsBuilder.build() + val keyGenerator = KeyGenerator.getInstance( + KeyProperties.KEY_ALGORITHM_AES, + ANDROID_KEYSTORE + ) + keyGenerator.init(keyGenParams) + return keyGenerator.generateKey() + } + +} diff --git a/third-party/biometric_storage/doc/screenshot_ios.png b/third-party/biometric_storage/doc/screenshot_ios.png new file mode 100644 index 00000000..08fee0c4 Binary files /dev/null and b/third-party/biometric_storage/doc/screenshot_ios.png differ diff --git a/third-party/biometric_storage/ios/Classes/BiometricStorageImpl.swift b/third-party/biometric_storage/ios/Classes/BiometricStorageImpl.swift new file mode 100644 index 00000000..1cc18696 --- /dev/null +++ b/third-party/biometric_storage/ios/Classes/BiometricStorageImpl.swift @@ -0,0 +1,349 @@ +// Shared file between iOS and Mac OS +// make sure they stay in sync. + +import Foundation +import LocalAuthentication + +typealias StorageCallback = (Any?) -> Void +typealias StorageError = (String, String?, Any?) -> Any + +struct StorageMethodCall { + let method: String + let arguments: Any? +} + +class InitOptions { + init(params: [String: Any]) { + darwinTouchIDAuthenticationAllowableReuseDuration = params["drawinTouchIDAuthenticationAllowableReuseDurationSeconds"] as? Int + darwinTouchIDAuthenticationForceReuseContextDuration = params["darwinTouchIDAuthenticationForceReuseContextDurationSeconds"] as? Int + authenticationRequired = params["authenticationRequired"] as? Bool + darwinBiometricOnly = params["darwinBiometricOnly"] as? Bool + } + let darwinTouchIDAuthenticationAllowableReuseDuration: Int? + let darwinTouchIDAuthenticationForceReuseContextDuration: Int? + let authenticationRequired: Bool! + let darwinBiometricOnly: Bool! +} + +class IOSPromptInfo { + init(params: [String: Any]) { + saveTitle = params["saveTitle"] as? String + accessTitle = params["accessTitle"] as? String + } + let saveTitle: String! + let accessTitle: String! +} + +private func hpdebug(_ message: String) { + print(message); +} + +class BiometricStorageImpl { + + init(storageError: @escaping StorageError, storageMethodNotImplemented: Any) { + self.storageError = storageError + self.storageMethodNotImplemented = storageMethodNotImplemented + } + + private var stores: [String: BiometricStorageFile] = [:] + private let storageError: StorageError + private let storageMethodNotImplemented: Any + + private func storageError(code: String, message: String?, details: Any?) -> Any { + return storageError(code, message, details) + } + + public func handle(_ call: StorageMethodCall, result: @escaping StorageCallback) { + + func requiredArg(_ name: String, _ cb: (T) -> Void) { + guard let args = call.arguments as? Dictionary else { + result(storageError(code: "InvalidArguments", message: "Invalid arguments \(String(describing: call.arguments))", details: nil)) + return + } + guard let value = args[name] else { + result(storageError(code: "InvalidArguments", message: "Missing argument \(name)", details: nil)) + return + } + guard let valueTyped = value as? T else { + result(storageError(code: "InvalidArguments", message: "Invalid argument for \(name): expected \(T.self) got \(value)", details: nil)) + return + } + cb(valueTyped) + return + } + func requireStorage(_ name: String, _ cb: (BiometricStorageFile) -> Void) { + guard let file = stores[name] else { + result(storageError(code: "InvalidArguments", message: "Storage was not initialized \(name)", details: nil)) + return + } + cb(file) + } + + if ("canAuthenticate" == call.method) { + canAuthenticate(result: result) + } else if ("init" == call.method) { + requiredArg("name") { name in + requiredArg("options") { options in + stores[name] = BiometricStorageFile(name: name, initOptions: InitOptions(params: options), storageError: storageError) + } + } + result(true) + } else if ("dispose" == call.method) { + // nothing to dispose + result(true) + } else if ("read" == call.method) { + requiredArg("name") { name in + requiredArg("iosPromptInfo") { promptInfo in + requireStorage(name) { file in + file.read(result, IOSPromptInfo(params: promptInfo)) + } + } + } + } else if ("write" == call.method) { + requiredArg("name") { name in + requiredArg("content") { content in + requiredArg("iosPromptInfo") { promptInfo in + requireStorage(name) { file in + file.write(content, result, IOSPromptInfo(params: promptInfo)) + } + } + } + } + } else if ("delete" == call.method) { + requiredArg("name") { name in + requiredArg("iosPromptInfo") { promptInfo in + requireStorage(name) { file in + file.delete(result, IOSPromptInfo(params: promptInfo)) + } + } + } + } else { + result(storageMethodNotImplemented) + } + } + + + private func canAuthenticate(result: @escaping StorageCallback) { + var error: NSError? + let context = LAContext() + if context.canEvaluatePolicy(.deviceOwnerAuthentication, error: &error) { + result("Success") + return + } + guard let err = error else { + result("ErrorUnknown") + return + } + let laError = LAError(_nsError: err) + NSLog("LAError: \(laError)"); + switch laError.code { + case .touchIDNotAvailable: + result("ErrorHwUnavailable") + break; + case .passcodeNotSet: + result("ErrorPasscodeNotSet") + break; + case .touchIDNotEnrolled: + result("ErrorNoBiometricEnrolled") + break; + case .invalidContext: fallthrough + default: + result("ErrorUnknown") + break; + } + } +} + +typealias StoredContext = (context: LAContext, expireAt: Date) + +class BiometricStorageFile { + private let name: String + private let initOptions: InitOptions + private var _context: StoredContext? + private var context: LAContext { + get { + if let context = _context { + if context.expireAt.timeIntervalSinceNow < 0 { + // already expired. + _context = nil + } else { + return context.context + } + } + + let context = LAContext() + if (initOptions.authenticationRequired) { + if let duration = initOptions.darwinTouchIDAuthenticationAllowableReuseDuration { + if #available(OSX 10.12, *) { + context.touchIDAuthenticationAllowableReuseDuration = Double(duration) + } else { + // Fallback on earlier versions + hpdebug("Pre OSX 10.12 no touchIDAuthenticationAllowableReuseDuration available. ignoring.") + } + } + + if let duration = initOptions.darwinTouchIDAuthenticationForceReuseContextDuration { + _context = (context: context, expireAt: Date(timeIntervalSinceNow: Double(duration))) + } + } + return context + } + } + private let storageError: StorageError + + init(name: String, initOptions: InitOptions, storageError: @escaping StorageError) { + self.name = name + self.initOptions = initOptions + self.storageError = storageError + } + + private func baseQuery(_ result: @escaping StorageCallback) -> [String: Any]? { + var query = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: "flutter_biometric_storage", + kSecAttrAccount as String: name, + ] as [String : Any] + if initOptions.authenticationRequired { + guard let access = accessControl(result) else { + return nil + } + if #available(iOS 13.0, macOS 10.15, *) { + query[kSecUseDataProtectionKeychain as String] = true + } + query[kSecAttrAccessControl as String] = access + } + return query + } + + private func accessControl(_ result: @escaping StorageCallback) -> SecAccessControl? { + let accessControlFlags: SecAccessControlCreateFlags + + if initOptions.darwinBiometricOnly { + if #available(iOS 11.3, *) { + accessControlFlags = .biometryCurrentSet + } else { + accessControlFlags = .touchIDCurrentSet + } + } else { + accessControlFlags = .userPresence + } + +// access = SecAccessControlCreateWithFlags(nil, +// kSecAttrAccessibleWhenUnlockedThisDeviceOnly, +// accessControlFlags, +// &error) + var error: Unmanaged? + guard let access = SecAccessControlCreateWithFlags( + nil, // Use the default allocator. + kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly, + accessControlFlags, + &error) else { + hpdebug("Error while creating access control flags. \(String(describing: error))") + result(storageError("writing data", "error writing data", "\(String(describing: error))")); + return nil + } + + return access + } + + func read(_ result: @escaping StorageCallback, _ promptInfo: IOSPromptInfo) { + + guard var query = baseQuery(result) else { + return; + } + query[kSecMatchLimit as String] = kSecMatchLimitOne + query[kSecUseOperationPrompt as String] = promptInfo.accessTitle + query[kSecReturnAttributes as String] = true + query[kSecReturnData as String] = true + query[kSecUseAuthenticationContext as String] = context + + var item: CFTypeRef? + + let status = SecItemCopyMatching(query as CFDictionary, &item) + guard status != errSecItemNotFound else { + result(nil) + return + } + guard status == errSecSuccess else { + handleOSStatusError(status, result, "Error retrieving item. \(status)") + return + } + guard let existingItem = item as? [String : Any], + let data = existingItem[kSecValueData as String] as? Data, + let dataString = String(data: data, encoding: String.Encoding.utf8) + else { + result(storageError("RetrieveError", "Unexpected data.", nil)) + return + } + result(dataString) + } + + func delete(_ result: @escaping StorageCallback, _ promptInfo: IOSPromptInfo) { + guard let query = baseQuery(result) else { + return; + } + // query[kSecMatchLimit as String] = kSecMatchLimitOne + // query[kSecReturnData as String] = true + let status = SecItemDelete(query as CFDictionary) + if status == errSecSuccess { + result(true) + return + } + if status == errSecItemNotFound { + hpdebug("Item not in keychain. Nothing to delete.") + result(true) + return + } + handleOSStatusError(status, result, "writing data") + } + + func write(_ content: String, _ result: @escaping StorageCallback, _ promptInfo: IOSPromptInfo) { + guard var query = baseQuery(result) else { + return; + } + + if (initOptions.authenticationRequired) { + query.merge([ + kSecUseAuthenticationContext as String: context, + ]) { (_, new) in new } + if let operationPrompt = promptInfo.saveTitle { + query[kSecUseOperationPrompt as String] = operationPrompt + } + } else { + hpdebug("No authentication required for \(name)") + } + query.merge([ + // kSecMatchLimit as String: kSecMatchLimitOne, + kSecValueData as String: content.data(using: String.Encoding.utf8) as Any, + ]) { (_, new) in new } + var status = SecItemAdd(query as CFDictionary, nil) + if (status == errSecDuplicateItem) { + hpdebug("Value already exists. updating.") + let update = [kSecValueData as String: query[kSecValueData as String]] + query.removeValue(forKey: kSecValueData as String) + status = SecItemUpdate(query as CFDictionary, update as CFDictionary) + } + guard status == errSecSuccess else { + handleOSStatusError(status, result, "writing data") + return + } + result(nil) + } + + private func handleOSStatusError(_ status: OSStatus, _ result: @escaping StorageCallback, _ message: String) { + var errorMessage: String? = nil + if #available(iOS 11.3, OSX 10.12, *) { + errorMessage = SecCopyErrorMessageString(status, nil) as String? + } + let code: String + switch status { + case errSecUserCanceled: + code = "AuthError:UserCanceled" + default: + code = "SecurityError" + } + + result(storageError(code, "Error while \(message): \(status): \(errorMessage ?? "Unknown")", nil)) + } + +} diff --git a/third-party/biometric_storage/ios/Classes/BiometricStoragePlugin.h b/third-party/biometric_storage/ios/Classes/BiometricStoragePlugin.h new file mode 100644 index 00000000..a23a5ef4 --- /dev/null +++ b/third-party/biometric_storage/ios/Classes/BiometricStoragePlugin.h @@ -0,0 +1,4 @@ +#import + +@interface BiometricStoragePlugin : NSObject +@end diff --git a/third-party/biometric_storage/ios/Classes/BiometricStoragePlugin.m b/third-party/biometric_storage/ios/Classes/BiometricStoragePlugin.m new file mode 100644 index 00000000..21c1bc31 --- /dev/null +++ b/third-party/biometric_storage/ios/Classes/BiometricStoragePlugin.m @@ -0,0 +1,8 @@ +#import "BiometricStoragePlugin.h" +#import + +@implementation BiometricStoragePlugin ++ (void)registerWithRegistrar:(NSObject*)registrar { + [SwiftBiometricStoragePlugin registerWithRegistrar:registrar]; +} +@end diff --git a/third-party/biometric_storage/ios/Classes/SwiftBiometricStoragePlugin.swift b/third-party/biometric_storage/ios/Classes/SwiftBiometricStoragePlugin.swift new file mode 100644 index 00000000..0d6be911 --- /dev/null +++ b/third-party/biometric_storage/ios/Classes/SwiftBiometricStoragePlugin.swift @@ -0,0 +1,18 @@ +import Flutter +//import UIKit + +public class SwiftBiometricStoragePlugin: NSObject, FlutterPlugin { + private let impl = BiometricStorageImpl(storageError: { (code, message, details) -> Any in + FlutterError(code: code, message: message, details: details) + }, storageMethodNotImplemented: FlutterMethodNotImplemented) + + public static func register(with registrar: FlutterPluginRegistrar) { + let channel = FlutterMethodChannel(name: "biometric_storage", binaryMessenger: registrar.messenger()) + let instance = SwiftBiometricStoragePlugin() + registrar.addMethodCallDelegate(instance, channel: channel) + } + + public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + impl.handle(StorageMethodCall(method: call.method, arguments: call.arguments), result: result) + } +} diff --git a/third-party/biometric_storage/ios/biometric_storage.podspec b/third-party/biometric_storage/ios/biometric_storage.podspec new file mode 100644 index 00000000..00167be6 --- /dev/null +++ b/third-party/biometric_storage/ios/biometric_storage.podspec @@ -0,0 +1,25 @@ +# +# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html. +# Run `pod lib lint biometric_storage.podspec' to validate before publishing. +# +Pod::Spec.new do |s| + s.name = 'biometric_storage' + s.version = '0.0.1' + s.summary = 'A new flutter plugin project.' + s.description = <<-DESC +A new flutter plugin project. + DESC + s.homepage = 'http://example.com' + s.license = { :file => '../LICENSE' } + s.author = { 'Your Company' => 'email@example.com' } + s.source = { :path => '.' } + s.source_files = 'Classes/**/*' #, '../macos/Classes/BiometricStorageImpl.swift' + s.public_header_files = 'Classes/**/*.h' + s.dependency 'Flutter' + s.platform = :ios, '9.0' + + # Flutter.framework does not contain a i386 slice. Only x86_64 simulators are supported. + s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'VALID_ARCHS[sdk=iphonesimulator*]' => 'x86_64' } + s.swift_version = '5.0' +end + diff --git a/third-party/biometric_storage/lib/biometric_storage.dart b/third-party/biometric_storage/lib/biometric_storage.dart new file mode 100644 index 00000000..d57d0b7a --- /dev/null +++ b/third-party/biometric_storage/lib/biometric_storage.dart @@ -0,0 +1,3 @@ +export 'src/biometric_storage.dart'; +export 'src/biometric_storage_win32_fake.dart' + if (dart.library.io) 'src/biometric_storage_win32.dart'; diff --git a/third-party/biometric_storage/lib/src/biometric_storage.dart b/third-party/biometric_storage/lib/src/biometric_storage.dart new file mode 100644 index 00000000..f07bf0c3 --- /dev/null +++ b/third-party/biometric_storage/lib/src/biometric_storage.dart @@ -0,0 +1,499 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:logging/logging.dart'; +import 'package:plugin_platform_interface/plugin_platform_interface.dart'; + +final _logger = Logger('biometric_storage'); + +/// Reason for not supporting authentication. +/// **As long as this is NOT [unsupported] you can still use the secure +/// storage without biometric storage** (By setting +/// [StorageFileInitOptions.authenticationRequired] to `false`). +enum CanAuthenticateResponse { + success, + errorHwUnavailable, + errorNoBiometricEnrolled, + errorNoHardware, + + /// Passcode is not set (iOS/MacOS) or no user credentials (on macos). + errorPasscodeNotSet, + + /// Used on android if the status is unknown. + /// https://developer.android.com/reference/androidx/biometric/BiometricManager#BIOMETRIC_STATUS_UNKNOWN + statusUnknown, + + /// Plugin does not support platform. This should no longer be the case. + unsupported, +} + +const _canAuthenticateMapping = { + 'Success': CanAuthenticateResponse.success, + 'ErrorHwUnavailable': CanAuthenticateResponse.errorHwUnavailable, + 'ErrorNoBiometricEnrolled': CanAuthenticateResponse.errorNoBiometricEnrolled, + 'ErrorNoHardware': CanAuthenticateResponse.errorNoHardware, + 'ErrorPasscodeNotSet': CanAuthenticateResponse.errorPasscodeNotSet, + 'ErrorUnknown': CanAuthenticateResponse.unsupported, + 'ErrorStatusUnknown': CanAuthenticateResponse.statusUnknown, +}; + +enum AuthExceptionCode { + /// User taps the cancel/negative button or presses `back`. + userCanceled, + + /// Authentication prompt is canceled due to another reason + /// (like when biometric sensor becamse unavailable like when + /// user switches between apps, logsout, etc). + canceled, + unknown, + timeout, + linuxAppArmorDenied, +} + +const _authErrorCodeMapping = { + 'AuthError:UserCanceled': AuthExceptionCode.userCanceled, + 'AuthError:Canceled': AuthExceptionCode.canceled, + 'AuthError:Timeout': AuthExceptionCode.timeout, +}; + +class BiometricStorageException implements Exception { + BiometricStorageException(this.message); + final String message; + + @override + String toString() { + return 'BiometricStorageException{message: $message}'; + } +} + +/// Exceptions during authentication operations. +/// See [AuthExceptionCode] for details. +class AuthException implements Exception { + AuthException(this.code, this.message); + + final AuthExceptionCode code; + final String message; + + @override + String toString() { + return 'AuthException{code: $code, message: $message}'; + } +} + +class StorageFileInitOptions { + StorageFileInitOptions({ + Duration? androidAuthenticationValidityDuration, + Duration? darwinTouchIDAuthenticationAllowableReuseDuration, + this.darwinTouchIDAuthenticationForceReuseContextDuration, + @Deprecated( + 'use use androidAuthenticationValidityDuration, iosTouchIDAuthenticationAllowableReuseDuration or iosTouchIDAuthenticationForceReuseContextDuration instead') + this.authenticationValidityDurationSeconds = -1, + this.authenticationRequired = true, + this.androidBiometricOnly = true, + this.darwinBiometricOnly = true, + }) : androidAuthenticationValidityDuration = + androidAuthenticationValidityDuration ?? + (authenticationValidityDurationSeconds <= 0 + ? null + : Duration(seconds: authenticationValidityDurationSeconds)), + darwinTouchIDAuthenticationAllowableReuseDuration = + darwinTouchIDAuthenticationAllowableReuseDuration ?? + (authenticationValidityDurationSeconds <= 0 + ? null + : Duration(seconds: authenticationValidityDurationSeconds)); + + @Deprecated( + 'use use androidAuthenticationValidityDuration, iosTouchIDAuthenticationAllowableReuseDuration or iosTouchIDAuthenticationForceReuseContextDuration instead') + final int authenticationValidityDurationSeconds; + + /// see https://developer.android.com/reference/android/security/keystore/KeyGenParameterSpec.Builder#setUserAuthenticationParameters(int,%20int) + final Duration? androidAuthenticationValidityDuration; + + /// see https://developer.apple.com/documentation/localauthentication/lacontext/1622329-touchidauthenticationallowablere + /// > If the user unlocks the device using Touch ID within the specified time interval, then authentication for the receiver succeeds automatically, without prompting the user for Touch ID. This bypasses a scenario where the user unlocks the device and then is almost immediately prompted for another fingerprint. + /// and https://developer.apple.com/documentation/localauthentication/accessing_keychain_items_with_face_id_or_touch_id + /// > Note that this grace period applies specifically to device unlock with Touch ID, not keychain retrieval authentications + /// + /// If you want to avoid requiring authentication after a successful + /// keychain retrieval see [darwinTouchIDAuthenticationForceReuseContextDuration] + final Duration? darwinTouchIDAuthenticationAllowableReuseDuration; + + /// To prevent forcing the user to authenticate again after unlocking once + /// we can reuse the `LAContext` object for the given amount of time. + /// see https://github.com/authpass/biometric_storage/pull/73 + /// This is pretty much undocumented behavior, but works similar to + /// `androidAuthenticationValidityDuration`. + /// + /// See also [darwinTouchIDAuthenticationAllowableReuseDuration] + final Duration? darwinTouchIDAuthenticationForceReuseContextDuration; + + /// Whether an authentication is required. if this is + /// false NO BIOMETRIC CHECK WILL BE PERFORMED! and the value + /// will simply be save encrypted. (default: true) + final bool authenticationRequired; + + /// Only makes difference on Android, where if set true, you can't use + /// PIN/pattern/password to get the file. + /// On Android < 30 this will always be ignored. (always `true`) + /// https://github.com/authpass/biometric_storage/issues/12#issuecomment-900358154 + /// + /// Also: this **must** be `true` if [androidAuthenticationValidityDuration] + /// is null. + /// https://github.com/authpass/biometric_storage/issues/12#issuecomment-902508609 + final bool androidBiometricOnly; + + /// Only for iOS and macOS: + /// Uses `.biometryCurrentSet` if true, `.userPresence` otherwise. + /// https://developer.apple.com/documentation/security/secaccesscontrolcreateflags/1392879-userpresence + final bool darwinBiometricOnly; + + Map toJson() => { + 'androidAuthenticationValidityDurationSeconds': + androidAuthenticationValidityDuration?.inSeconds, + 'darwinTouchIDAuthenticationAllowableReuseDurationSeconds': + darwinTouchIDAuthenticationAllowableReuseDuration?.inSeconds, + 'darwinTouchIDAuthenticationForceReuseContextDurationSeconds': + darwinTouchIDAuthenticationForceReuseContextDuration?.inSeconds, + 'authenticationRequired': authenticationRequired, + 'androidBiometricOnly': androidBiometricOnly, + 'darwinBiometricOnly': darwinBiometricOnly, + }; +} + +/// Android specific configuration of the prompt displayed for biometry. +class AndroidPromptInfo { + const AndroidPromptInfo({ + this.title = 'Authenticate to unlock data', + this.subtitle, + this.description, + this.negativeButton = 'Cancel', + this.confirmationRequired = true, + }); + + final String title; + final String? subtitle; + final String? description; + final String negativeButton; + final bool confirmationRequired; + + static const defaultValues = AndroidPromptInfo(); + + Map _toJson() => { + 'title': title, + 'subtitle': subtitle, + 'description': description, + 'negativeButton': negativeButton, + 'confirmationRequired': confirmationRequired, + }; +} + +/// iOS **and MacOS** specific configuration of the prompt displayed for biometry. +class IosPromptInfo { + const IosPromptInfo({ + this.saveTitle = 'Unlock to save data', + this.accessTitle = 'Unlock to access data', + }); + + final String saveTitle; + final String accessTitle; + + static const defaultValues = IosPromptInfo(); + + Map _toJson() => { + 'saveTitle': saveTitle, + 'accessTitle': accessTitle, + }; +} + +/// Wrapper for platform specific prompt infos. +class PromptInfo { + const PromptInfo({ + this.androidPromptInfo = AndroidPromptInfo.defaultValues, + this.iosPromptInfo = IosPromptInfo.defaultValues, + this.macOsPromptInfo = IosPromptInfo.defaultValues, + }); + static const defaultValues = PromptInfo(); + + final AndroidPromptInfo androidPromptInfo; + final IosPromptInfo iosPromptInfo; + final IosPromptInfo macOsPromptInfo; +} + +/// Main plugin class to interact with. Is always a singleton right now, +/// factory constructor will always return the same instance. +/// +/// * call [canAuthenticate] to check support on the platform/device. +/// * call [getStorage] to initialize a storage. +abstract class BiometricStorage extends PlatformInterface { + // Returns singleton instance. + factory BiometricStorage() => _instance; + + BiometricStorage.create() : super(token: _token); + + static BiometricStorage _instance = MethodChannelBiometricStorage(); + + /// Platform-specific plugins should set this with their own platform-specific + /// class that extends [UrlLauncherPlatform] when they register themselves. + static set instance(BiometricStorage instance) { + PlatformInterface.verifyToken(instance, _token); + _instance = instance; + } + + static const Object _token = Object(); + + /// Returns whether this device supports biometric/secure storage or + /// the reason [CanAuthenticateResponse] why it is not supported. + Future canAuthenticate(); + + /// Returns true when there is an AppArmor error when trying to read a value. + /// + /// When used inside a snap, there might be app armor limitations + /// which lead to an error like: + /// org.freedesktop.DBus.Error.AccessDenied: An AppArmor policy prevents + /// this sender from sending this message to this recipient; + /// type="method_call", sender=":1.140" (uid=1000 pid=94358 + /// comm="/snap/biometric-storage-example/x1/biometric_stora" + /// label="snap.biometric-storage-example.biometric (enforce)") + /// interface="org.freedesktop.Secret.Service" member="OpenSession" + /// error name="(unset)" requested_reply="0" destination=":1.30" + /// (uid=1000 pid=1153 comm="/usr/bin/gnome-keyring-daemon + /// --daemonize --login " label="unconfined") + Future linuxCheckAppArmorError(); + + /// Retrieves the given biometric storage file. + /// Each store is completely separated, and has it's own encryption and + /// biometric lock. + /// if [forceInit] is true, will throw an exception if the store was already + /// created in this runtime. + Future getStorage( + String name, { + StorageFileInitOptions? options, + bool forceInit = false, + PromptInfo promptInfo = PromptInfo.defaultValues, + }); + + @protected + Future read( + String name, + PromptInfo promptInfo, + ); + + @protected + Future delete( + String name, + PromptInfo promptInfo, + ); + + @protected + Future write( + String name, + String content, + PromptInfo promptInfo, + ); +} + +class MethodChannelBiometricStorage extends BiometricStorage { + MethodChannelBiometricStorage() : super.create(); + + static const MethodChannel _channel = MethodChannel('biometric_storage'); + + @override + Future canAuthenticate() async { + if (kIsWeb) { + return CanAuthenticateResponse.unsupported; + } + if (Platform.isAndroid || + Platform.isIOS || + Platform.isMacOS || + Platform.isLinux) { + final response = await _channel.invokeMethod('canAuthenticate'); + final ret = _canAuthenticateMapping[response]; + if (ret == null) { + throw StateError('Invalid response from native platform. {$response}'); + } + return ret; + } + return CanAuthenticateResponse.unsupported; + } + + /// Returns true when there is an AppArmor error when trying to read a value. + /// + /// When used inside a snap, there might be app armor limitations + /// which lead to an error like: + /// org.freedesktop.DBus.Error.AccessDenied: An AppArmor policy prevents + /// this sender from sending this message to this recipient; + /// type="method_call", sender=":1.140" (uid=1000 pid=94358 + /// comm="/snap/biometric-storage-example/x1/biometric_stora" + /// label="snap.biometric-storage-example.biometric (enforce)") + /// interface="org.freedesktop.Secret.Service" member="OpenSession" + /// error name="(unset)" requested_reply="0" destination=":1.30" + /// (uid=1000 pid=1153 comm="/usr/bin/gnome-keyring-daemon + /// --daemonize --login " label="unconfined") + @override + Future linuxCheckAppArmorError() async { + if (!Platform.isLinux) { + return false; + } + final tmpStorage = await getStorage('appArmorCheck', + options: StorageFileInitOptions(authenticationRequired: false)); + _logger.finer('Checking app armor'); + try { + await tmpStorage.read(); + _logger.finer('Everything okay.'); + return false; + } on AuthException catch (e, stackTrace) { + if (e.code == AuthExceptionCode.linuxAppArmorDenied) { + return true; + } + _logger.warning( + 'Unknown error while checking for app armor.', e, stackTrace); + // some other weird error? + rethrow; + } + } + + /// Retrieves the given biometric storage file. + /// Each store is completely separated, and has it's own encryption and + /// biometric lock. + /// if [forceInit] is true, will throw an exception if the store was already + /// created in this runtime. + @override + Future getStorage( + String name, { + StorageFileInitOptions? options, + bool forceInit = false, + PromptInfo promptInfo = PromptInfo.defaultValues, + }) async { + try { + final result = await _channel.invokeMethod( + 'init', + { + 'name': name, + 'options': options?.toJson() ?? StorageFileInitOptions().toJson(), + 'forceInit': forceInit, + }, + ); + _logger.finest('getting storage. was created: $result'); + return BiometricStorageFile( + this, + name, + promptInfo, + ); + } catch (e, stackTrace) { + _logger.warning( + 'Error while initializing biometric storage.', e, stackTrace); + rethrow; + } + } + + @override + Future read( + String name, + PromptInfo promptInfo, + ) => + _transformErrors(_channel.invokeMethod('read', { + 'name': name, + ..._promptInfoForCurrentPlatform(promptInfo), + })); + + @override + Future delete( + String name, + PromptInfo promptInfo, + ) => + _transformErrors(_channel.invokeMethod('delete', { + 'name': name, + ..._promptInfoForCurrentPlatform(promptInfo), + })); + + @override + Future write( + String name, + String content, + PromptInfo promptInfo, + ) => + _transformErrors(_channel.invokeMethod('write', { + 'name': name, + 'content': content, + ..._promptInfoForCurrentPlatform(promptInfo), + })); + + Map _promptInfoForCurrentPlatform(PromptInfo promptInfo) { + // Don't expose Android configurations to other platforms + if (Platform.isAndroid) { + return { + 'androidPromptInfo': promptInfo.androidPromptInfo._toJson() + }; + } else if (Platform.isIOS) { + return { + 'iosPromptInfo': promptInfo.iosPromptInfo._toJson() + }; + } else if (Platform.isMacOS) { + return { + // This is no typo, we use the same implementation on iOS and MacOS, + // so we use the same parameter. + 'iosPromptInfo': promptInfo.macOsPromptInfo._toJson() + }; + } else if (Platform.isLinux) { + return {}; + } else { + // Windows has no method channel implementation + // Web has a Noop implementation. + throw StateError('Unsupported Platform ${Platform.operatingSystem}'); + } + } + + Future _transformErrors(Future future) => + future.catchError((Object error, StackTrace stackTrace) { + if (error is PlatformException) { + _logger.finest( + 'Error during plugin operation (details: ${error.details})', + error, + stackTrace); + if (error.code.startsWith('AuthError:')) { + return Future.error( + AuthException( + _authErrorCodeMapping[error.code] ?? AuthExceptionCode.unknown, + error.message ?? 'Unknown error', + ), + stackTrace, + ); + } + if (error.details is Map) { + final message = error.details['message'] as String; + if (message.contains('org.freedesktop.DBus.Error.AccessDenied') || + message.contains('AppArmor')) { + _logger.fine('Got app armor error.'); + return Future.error( + AuthException( + AuthExceptionCode.linuxAppArmorDenied, error.message!), + stackTrace); + } + } + } + return Future.error(error, stackTrace); + }); +} + +class BiometricStorageFile { + BiometricStorageFile(this._plugin, this.name, this.defaultPromptInfo); + + final BiometricStorage _plugin; + final String name; + final PromptInfo defaultPromptInfo; + + /// read from the secure file and returns the content. + /// Will return `null` if file does not exist. + Future read({PromptInfo? promptInfo}) => + _plugin.read(name, promptInfo ?? defaultPromptInfo); + + /// Write content of this file. Previous value will be overwritten. + Future write(String content, {PromptInfo? promptInfo}) => + _plugin.write(name, content, promptInfo ?? defaultPromptInfo); + + /// Delete the content of this storage. + Future delete({PromptInfo? promptInfo}) => + _plugin.delete(name, promptInfo ?? defaultPromptInfo); +} diff --git a/third-party/biometric_storage/lib/src/biometric_storage_web.dart b/third-party/biometric_storage/lib/src/biometric_storage_web.dart new file mode 100644 index 00000000..607fa89d --- /dev/null +++ b/third-party/biometric_storage/lib/src/biometric_storage_web.dart @@ -0,0 +1,60 @@ +import 'dart:async'; +import 'package:web/web.dart' as web show window; + +import 'package:biometric_storage/src/biometric_storage.dart'; +import 'package:flutter_web_plugins/flutter_web_plugins.dart'; + +/// A web implementation of the BiometricStorage plugin. +class BiometricStoragePluginWeb extends BiometricStorage { + BiometricStoragePluginWeb() : super.create(); + + static const namePrefix = 'design.codeux.authpass.'; + + static void registerWith(Registrar registrar) { + BiometricStorage.instance = BiometricStoragePluginWeb(); + } + + @override + Future canAuthenticate() async => + CanAuthenticateResponse.errorHwUnavailable; + + @override + Future getStorage( + String name, { + StorageFileInitOptions? options, + bool forceInit = false, + PromptInfo promptInfo = PromptInfo.defaultValues, + }) async { + return BiometricStorageFile(this, namePrefix + name, promptInfo); + } + + @override + Future delete( + String name, + PromptInfo promptInfo, + ) async { + final oldValue = web.window.localStorage.getItem(name); + web.window.localStorage.removeItem(name); + return oldValue != null; + } + + @override + Future linuxCheckAppArmorError() async => false; + + @override + Future read( + String name, + PromptInfo promptInfo, + ) async { + return web.window.localStorage.getItem(name); + } + + @override + Future write( + String name, + String content, + PromptInfo promptInfo, + ) async { + web.window.localStorage.setItem(name, content); + } +} diff --git a/third-party/biometric_storage/lib/src/biometric_storage_win32.dart b/third-party/biometric_storage/lib/src/biometric_storage_win32.dart new file mode 100644 index 00000000..84d807ad --- /dev/null +++ b/third-party/biometric_storage/lib/src/biometric_storage_win32.dart @@ -0,0 +1,133 @@ +import 'dart:convert'; +import 'dart:ffi'; + +import 'package:ffi/ffi.dart'; +import 'package:logging/logging.dart'; +import 'package:win32/win32.dart'; + +import './biometric_storage.dart'; + +final _logger = Logger('biometric_storage_win32'); + +class Win32BiometricStoragePlugin extends BiometricStorage { + Win32BiometricStoragePlugin() : super.create(); + + static const namePrefix = 'design.codeux.authpass.'; + + /// Registers this class as the default instance of [PathProviderPlatform] + static void registerWith() { + BiometricStorage.instance = Win32BiometricStoragePlugin(); + } + + @override + Future canAuthenticate() async { + return CanAuthenticateResponse.errorHwUnavailable; + } + + @override + Future getStorage( + String name, { + StorageFileInitOptions? options, + bool forceInit = false, + PromptInfo promptInfo = PromptInfo.defaultValues, + }) async { + return BiometricStorageFile(this, namePrefix + name, promptInfo); + } + + @override + Future linuxCheckAppArmorError() async => false; + + @override + Future delete( + String name, + PromptInfo promptInfo, + ) async { + final namePointer = TEXT(name); + try { + final result = CredDelete(namePointer, CRED_TYPE_GENERIC, 0); + if (result != TRUE) { + final errorCode = GetLastError(); + if (errorCode == ERROR_NOT_FOUND) { + _logger.fine('Unable to find credential of name $name'); + } else { + _logger.warning('Error ($result): $errorCode'); + } + return false; + } + } finally { + calloc.free(namePointer); + } + return true; + } + + @override + Future read( + String name, + PromptInfo promptInfo, + ) async { + _logger.finer('read($name)'); + final credPointer = calloc>(); + final namePointer = TEXT(name); + try { + if (CredRead(namePointer, CRED_TYPE_GENERIC, 0, credPointer) != TRUE) { + final errorCode = GetLastError(); + if (errorCode == ERROR_NOT_FOUND) { + _logger.fine('Unable to find credential of name $name'); + } else { + _logger.warning('Error: $errorCode ', + WindowsException(HRESULT_FROM_WIN32(errorCode))); + } + return null; + } + final cred = credPointer.value.ref; + final blob = cred.CredentialBlob.asTypedList(cred.CredentialBlobSize); + + _logger.fine('CredFree()'); + CredFree(credPointer.value); + + return utf8.decode(blob); + } finally { + _logger.fine('free(credPointer)'); + calloc.free(credPointer); + _logger.fine('free(namePointer)'); + calloc.free(namePointer); + _logger.fine('read($name) done.'); + } + } + + @override + Future write( + String name, + String content, + PromptInfo promptInfo, + ) async { + _logger.fine('write()'); + final examplePassword = utf8.encode(content); + final blob = examplePassword.allocatePointer(); + final namePointer = TEXT(name); + final userNamePointer = TEXT('flutter.biometric_storage'); + + final credential = calloc() + ..ref.Type = CRED_TYPE_GENERIC + ..ref.TargetName = namePointer + ..ref.Persist = CRED_PERSIST_LOCAL_MACHINE + ..ref.UserName = userNamePointer + ..ref.CredentialBlob = blob + ..ref.CredentialBlobSize = examplePassword.length; + try { + final result = CredWrite(credential, 0); + if (result != TRUE) { + final errorCode = GetLastError(); + throw BiometricStorageException( + 'Error writing credential $name ($result): $errorCode'); + } + } finally { + _logger.fine('free'); + calloc.free(blob); + calloc.free(credential); + calloc.free(namePointer); + calloc.free(userNamePointer); + _logger.fine('free done'); + } + } +} diff --git a/third-party/biometric_storage/lib/src/biometric_storage_win32_fake.dart b/third-party/biometric_storage/lib/src/biometric_storage_win32_fake.dart new file mode 100644 index 00000000..437b51d9 --- /dev/null +++ b/third-party/biometric_storage/lib/src/biometric_storage_win32_fake.dart @@ -0,0 +1 @@ +class Win32BiometricStoragePlugin {} diff --git a/third-party/biometric_storage/linux/CMakeLists.txt b/third-party/biometric_storage/linux/CMakeLists.txt new file mode 100644 index 00000000..10319250 --- /dev/null +++ b/third-party/biometric_storage/linux/CMakeLists.txt @@ -0,0 +1,29 @@ +cmake_minimum_required(VERSION 3.10) +set(PROJECT_NAME "biometric_storage") +project(${PROJECT_NAME} LANGUAGES CXX) + +set(PLUGIN_NAME "${PROJECT_NAME}_plugin") + +add_library(${PLUGIN_NAME} SHARED + "${PLUGIN_NAME}.cc" +) + +find_package(PkgConfig REQUIRED) +pkg_check_modules (LIBSECRET REQUIRED IMPORTED_TARGET libsecret-1>=0.18) + +apply_standard_settings(${PLUGIN_NAME}) +set_target_properties(${PLUGIN_NAME} PROPERTIES + CXX_VISIBILITY_PRESET hidden) +target_include_directories(${PLUGIN_NAME} PRIVATE ${LIBSECRET_INCLUDE_DIRS}) +target_compile_definitions(${PLUGIN_NAME} PRIVATE FLUTTER_PLUGIN_IMPL) +target_include_directories(${PLUGIN_NAME} INTERFACE + "${CMAKE_CURRENT_SOURCE_DIR}/include") +target_link_libraries(${PLUGIN_NAME} PRIVATE flutter) +target_link_libraries(${PLUGIN_NAME} PRIVATE PkgConfig::GTK) +target_link_libraries(${PLUGIN_NAME} PRIVATE PkgConfig::LIBSECRET) + +# List of absolute paths to libraries that should be bundled with the plugin +set(biometric_storage_bundled_libraries + "" + PARENT_SCOPE +) diff --git a/third-party/biometric_storage/linux/biometric_storage_plugin.cc b/third-party/biometric_storage/linux/biometric_storage_plugin.cc new file mode 100644 index 00000000..1c1e65a8 --- /dev/null +++ b/third-party/biometric_storage/linux/biometric_storage_plugin.cc @@ -0,0 +1,217 @@ +#include "include/biometric_storage/biometric_storage_plugin.h" + +#include +#include +#include +#include + +#define BIOMETRIC_SCHEMA biometric_get_schema () + +const char kBadArgumentsError[] = "Bad Arguments"; +const char kSecurityAccessError[] = "Security Access Error"; +const char kMethodRead[] = "read"; +const char kMethodWrite[] = "write"; +const char kMethodDelete[] = "delete"; +const char kNamePrefix[] = "design.codeux.authpass"; + +#define METHOD_PARAM_NAME(varName, args) \ + g_autofree gchar * varName = g_strdup_printf("%s.%s", kNamePrefix, fl_value_get_string(fl_value_lookup_string(args, "name"))) + + +#define BIOMETRIC_STORAGE_PLUGIN(obj) \ + (G_TYPE_CHECK_INSTANCE_CAST((obj), biometric_storage_plugin_get_type(), \ + BiometricStoragePlugin)) + +#define IS_METHOD(name, equals) \ + strcmp(method, equals) == 0 + +struct _BiometricStoragePlugin { + GObject parent_instance; +}; + +G_DEFINE_TYPE(BiometricStoragePlugin, biometric_storage_plugin, g_object_get_type()) + + + +static FlMethodResponse* _handle_error(const gchar* message, GError *error) { + const gchar* domain = g_quark_to_string(error->domain); + g_autofree gchar *error_message = g_strdup_printf("%s: %s (%d) (%s)", message, error->message, error->code, domain); + g_warning("%s", error_message); + g_autoptr(FlValue) error_details = fl_value_new_map(); + fl_value_set_string_take(error_details, "domain", fl_value_new_string(domain)); + fl_value_set_string_take(error_details, "code", fl_value_new_int(error->code)); + fl_value_set_string_take(error_details, "message", fl_value_new_string(error->message)); + return FL_METHOD_RESPONSE(fl_method_error_response_new( + kSecurityAccessError, error_message, error_details)); +} + +static FlMethodResponse *handleInit(FlValue *args) { + FlValue* options = fl_value_lookup_string(args, "options"); + if (fl_value_get_type(options) != FL_VALUE_TYPE_MAP) { + return FL_METHOD_RESPONSE(fl_method_error_response_new( + kBadArgumentsError, "Argument map missing or malformed", nullptr)); + } + FlValue* authRequired = fl_value_lookup_string(options, "authenticationRequired"); + if (fl_value_get_bool(authRequired)) { + return FL_METHOD_RESPONSE(fl_method_error_response_new( + kBadArgumentsError, "Linux plugin only supports non-authenticated secure storage", nullptr)); + } + return FL_METHOD_RESPONSE(fl_method_success_response_new(fl_value_new_bool(true))); +} + +const SecretSchema * +biometric_get_schema (void) +{ + static const SecretSchema the_schema = { + "design.codeux.BiometricStorage", SECRET_SCHEMA_NONE, + { + { "name", SECRET_SCHEMA_ATTRIBUTE_STRING }, + // { "NULL", 0 }, + } + }; + return &the_schema; +} + +static void on_password_stored(GObject *source, GAsyncResult *result, + gpointer user_data) { + GError *error = NULL; + FlMethodCall *method_call = (FlMethodCall *)user_data; + g_autoptr(FlMethodResponse) response = nullptr; + + secret_password_store_finish(result, &error); + if (error != NULL) { + /* ... handle the failure here */ + response = _handle_error("Failed to store secret", error); + g_error_free(error); + } else { + response = FL_METHOD_RESPONSE(fl_method_success_response_new(fl_value_new_bool(true))); + } + + fl_method_call_respond(method_call, response, nullptr); + g_object_unref(method_call); +} + +static void on_password_cleared(GObject *source, GAsyncResult *result, + gpointer user_data) { + GError *error = NULL; + FlMethodCall *method_call = (FlMethodCall *)user_data; + g_autoptr(FlMethodResponse) response = nullptr; + + gboolean removed = secret_password_clear_finish(result, &error); + + if (error != NULL) { + /* ... handle the failure here */ + response = _handle_error("Failed to delete secret", error); + g_error_free(error); + + } else { + response = FL_METHOD_RESPONSE(fl_method_success_response_new(fl_value_new_bool(removed))); + } + fl_method_call_respond(method_call, response, nullptr); + g_object_unref(method_call); +} + +static void on_password_lookup(GObject *source, GAsyncResult *result, + gpointer user_data) { + GError *error = NULL; + FlMethodCall *method_call = (FlMethodCall *)user_data; + g_autoptr(FlMethodResponse) response = nullptr; + + gchar *password = secret_password_lookup_finish(result, &error); + + if (error != NULL) { + /* ... handle the failure here */ + response = _handle_error("Failed to lookup secret", error); + g_error_free(error); + } else if (password == NULL) { + /* password will be null, if no matching password found */ + g_warning("Failed to lookup password (not found)."); + response = FL_METHOD_RESPONSE(fl_method_success_response_new(fl_value_new_null())); + } else { + /* ... do something with the password */ + response = FL_METHOD_RESPONSE(fl_method_success_response_new(fl_value_new_string(password))); + secret_password_free(password); + } + fl_method_call_respond(method_call, response, nullptr); + g_object_unref(method_call); +} + +// Called when a method call is received from Flutter. +static void +biometric_storage_plugin_handle_method_call(BiometricStoragePlugin *self, + FlMethodCall *method_call) { + g_autoptr(FlMethodResponse) response = nullptr; + + const gchar *method = fl_method_call_get_name(method_call); + FlValue *args = fl_method_call_get_args(method_call); + + if (strcmp(method, "canAuthenticate") == 0) { + g_autoptr(FlValue) result = fl_value_new_string("ErrorHwUnavailable"); + response = FL_METHOD_RESPONSE(fl_method_success_response_new(result)); + } else if (strcmp(method, "init") == 0) { + response = handleInit(args); + } else if (IS_METHOD(method, kMethodWrite)) { + METHOD_PARAM_NAME(name, args); + // const gchar *name = + // fl_value_get_string(fl_value_lookup_string(args, "name")); + const gchar *content = + fl_value_get_string(fl_value_lookup_string(args, "content")); + g_object_ref(method_call); + secret_password_store(BIOMETRIC_SCHEMA, SECRET_COLLECTION_DEFAULT, name, + content, NULL, on_password_stored, method_call, + "name", name, NULL); + return; + } else if (IS_METHOD(method, kMethodRead)) { + METHOD_PARAM_NAME(name, args); + // const gchar *name = + // fl_value_get_string(fl_value_lookup_string(args, "name")); + g_object_ref(method_call); + secret_password_lookup(BIOMETRIC_SCHEMA, NULL, on_password_lookup, + method_call, "name", name, NULL); + return; + } else if (IS_METHOD(method, kMethodDelete)) { + METHOD_PARAM_NAME(name, args); + // const gchar *name = + // fl_value_get_string(fl_value_lookup_string(args, "name")); + g_object_ref(method_call); + secret_password_clear(BIOMETRIC_SCHEMA, NULL, on_password_cleared, + method_call, "name", name, NULL); + return; + } else { + response = FL_METHOD_RESPONSE(fl_method_not_implemented_response_new()); + } + + fl_method_call_respond(method_call, response, nullptr); +} + +static void biometric_storage_plugin_dispose(GObject* object) { + G_OBJECT_CLASS(biometric_storage_plugin_parent_class)->dispose(object); +} + +static void biometric_storage_plugin_class_init(BiometricStoragePluginClass* klass) { + G_OBJECT_CLASS(klass)->dispose = biometric_storage_plugin_dispose; +} + +static void biometric_storage_plugin_init(BiometricStoragePlugin* self) {} + +static void method_call_cb(FlMethodChannel* channel, FlMethodCall* method_call, + gpointer user_data) { + BiometricStoragePlugin* plugin = BIOMETRIC_STORAGE_PLUGIN(user_data); + biometric_storage_plugin_handle_method_call(plugin, method_call); +} + +void biometric_storage_plugin_register_with_registrar(FlPluginRegistrar* registrar) { + BiometricStoragePlugin* plugin = BIOMETRIC_STORAGE_PLUGIN( + g_object_new(biometric_storage_plugin_get_type(), nullptr)); + + g_autoptr(FlStandardMethodCodec) codec = fl_standard_method_codec_new(); + g_autoptr(FlMethodChannel) channel = + fl_method_channel_new(fl_plugin_registrar_get_messenger(registrar), + "biometric_storage", + FL_METHOD_CODEC(codec)); + fl_method_channel_set_method_call_handler(channel, method_call_cb, + g_object_ref(plugin), + g_object_unref); + + g_object_unref(plugin); +} diff --git a/third-party/biometric_storage/linux/include/biometric_storage/biometric_storage_plugin.h b/third-party/biometric_storage/linux/include/biometric_storage/biometric_storage_plugin.h new file mode 100644 index 00000000..f3e7b643 --- /dev/null +++ b/third-party/biometric_storage/linux/include/biometric_storage/biometric_storage_plugin.h @@ -0,0 +1,27 @@ +#ifndef FLUTTER_PLUGIN_BIOMETRIC_STORAGE_PLUGIN_H_ +#define FLUTTER_PLUGIN_BIOMETRIC_STORAGE_PLUGIN_H_ + +#include + +G_BEGIN_DECLS + +#ifdef FLUTTER_PLUGIN_IMPL +#define FLUTTER_PLUGIN_EXPORT __attribute__((visibility("default"))) +#else +#define FLUTTER_PLUGIN_EXPORT +#endif + +typedef struct _BiometricStoragePlugin BiometricStoragePlugin; +typedef struct { + GObjectClass parent_class; +} BiometricStoragePluginClass; + +FLUTTER_PLUGIN_EXPORT GType biometric_storage_plugin_get_type(); + +FLUTTER_PLUGIN_EXPORT void biometric_storage_plugin_register_with_registrar( + FlPluginRegistrar* registrar); + + +G_END_DECLS + +#endif // FLUTTER_PLUGIN_BIOMETRIC_STORAGE_PLUGIN_H_ diff --git a/third-party/biometric_storage/macos/Classes/BiometricStorageImpl.swift b/third-party/biometric_storage/macos/Classes/BiometricStorageImpl.swift new file mode 100644 index 00000000..1cc18696 --- /dev/null +++ b/third-party/biometric_storage/macos/Classes/BiometricStorageImpl.swift @@ -0,0 +1,349 @@ +// Shared file between iOS and Mac OS +// make sure they stay in sync. + +import Foundation +import LocalAuthentication + +typealias StorageCallback = (Any?) -> Void +typealias StorageError = (String, String?, Any?) -> Any + +struct StorageMethodCall { + let method: String + let arguments: Any? +} + +class InitOptions { + init(params: [String: Any]) { + darwinTouchIDAuthenticationAllowableReuseDuration = params["drawinTouchIDAuthenticationAllowableReuseDurationSeconds"] as? Int + darwinTouchIDAuthenticationForceReuseContextDuration = params["darwinTouchIDAuthenticationForceReuseContextDurationSeconds"] as? Int + authenticationRequired = params["authenticationRequired"] as? Bool + darwinBiometricOnly = params["darwinBiometricOnly"] as? Bool + } + let darwinTouchIDAuthenticationAllowableReuseDuration: Int? + let darwinTouchIDAuthenticationForceReuseContextDuration: Int? + let authenticationRequired: Bool! + let darwinBiometricOnly: Bool! +} + +class IOSPromptInfo { + init(params: [String: Any]) { + saveTitle = params["saveTitle"] as? String + accessTitle = params["accessTitle"] as? String + } + let saveTitle: String! + let accessTitle: String! +} + +private func hpdebug(_ message: String) { + print(message); +} + +class BiometricStorageImpl { + + init(storageError: @escaping StorageError, storageMethodNotImplemented: Any) { + self.storageError = storageError + self.storageMethodNotImplemented = storageMethodNotImplemented + } + + private var stores: [String: BiometricStorageFile] = [:] + private let storageError: StorageError + private let storageMethodNotImplemented: Any + + private func storageError(code: String, message: String?, details: Any?) -> Any { + return storageError(code, message, details) + } + + public func handle(_ call: StorageMethodCall, result: @escaping StorageCallback) { + + func requiredArg(_ name: String, _ cb: (T) -> Void) { + guard let args = call.arguments as? Dictionary else { + result(storageError(code: "InvalidArguments", message: "Invalid arguments \(String(describing: call.arguments))", details: nil)) + return + } + guard let value = args[name] else { + result(storageError(code: "InvalidArguments", message: "Missing argument \(name)", details: nil)) + return + } + guard let valueTyped = value as? T else { + result(storageError(code: "InvalidArguments", message: "Invalid argument for \(name): expected \(T.self) got \(value)", details: nil)) + return + } + cb(valueTyped) + return + } + func requireStorage(_ name: String, _ cb: (BiometricStorageFile) -> Void) { + guard let file = stores[name] else { + result(storageError(code: "InvalidArguments", message: "Storage was not initialized \(name)", details: nil)) + return + } + cb(file) + } + + if ("canAuthenticate" == call.method) { + canAuthenticate(result: result) + } else if ("init" == call.method) { + requiredArg("name") { name in + requiredArg("options") { options in + stores[name] = BiometricStorageFile(name: name, initOptions: InitOptions(params: options), storageError: storageError) + } + } + result(true) + } else if ("dispose" == call.method) { + // nothing to dispose + result(true) + } else if ("read" == call.method) { + requiredArg("name") { name in + requiredArg("iosPromptInfo") { promptInfo in + requireStorage(name) { file in + file.read(result, IOSPromptInfo(params: promptInfo)) + } + } + } + } else if ("write" == call.method) { + requiredArg("name") { name in + requiredArg("content") { content in + requiredArg("iosPromptInfo") { promptInfo in + requireStorage(name) { file in + file.write(content, result, IOSPromptInfo(params: promptInfo)) + } + } + } + } + } else if ("delete" == call.method) { + requiredArg("name") { name in + requiredArg("iosPromptInfo") { promptInfo in + requireStorage(name) { file in + file.delete(result, IOSPromptInfo(params: promptInfo)) + } + } + } + } else { + result(storageMethodNotImplemented) + } + } + + + private func canAuthenticate(result: @escaping StorageCallback) { + var error: NSError? + let context = LAContext() + if context.canEvaluatePolicy(.deviceOwnerAuthentication, error: &error) { + result("Success") + return + } + guard let err = error else { + result("ErrorUnknown") + return + } + let laError = LAError(_nsError: err) + NSLog("LAError: \(laError)"); + switch laError.code { + case .touchIDNotAvailable: + result("ErrorHwUnavailable") + break; + case .passcodeNotSet: + result("ErrorPasscodeNotSet") + break; + case .touchIDNotEnrolled: + result("ErrorNoBiometricEnrolled") + break; + case .invalidContext: fallthrough + default: + result("ErrorUnknown") + break; + } + } +} + +typealias StoredContext = (context: LAContext, expireAt: Date) + +class BiometricStorageFile { + private let name: String + private let initOptions: InitOptions + private var _context: StoredContext? + private var context: LAContext { + get { + if let context = _context { + if context.expireAt.timeIntervalSinceNow < 0 { + // already expired. + _context = nil + } else { + return context.context + } + } + + let context = LAContext() + if (initOptions.authenticationRequired) { + if let duration = initOptions.darwinTouchIDAuthenticationAllowableReuseDuration { + if #available(OSX 10.12, *) { + context.touchIDAuthenticationAllowableReuseDuration = Double(duration) + } else { + // Fallback on earlier versions + hpdebug("Pre OSX 10.12 no touchIDAuthenticationAllowableReuseDuration available. ignoring.") + } + } + + if let duration = initOptions.darwinTouchIDAuthenticationForceReuseContextDuration { + _context = (context: context, expireAt: Date(timeIntervalSinceNow: Double(duration))) + } + } + return context + } + } + private let storageError: StorageError + + init(name: String, initOptions: InitOptions, storageError: @escaping StorageError) { + self.name = name + self.initOptions = initOptions + self.storageError = storageError + } + + private func baseQuery(_ result: @escaping StorageCallback) -> [String: Any]? { + var query = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: "flutter_biometric_storage", + kSecAttrAccount as String: name, + ] as [String : Any] + if initOptions.authenticationRequired { + guard let access = accessControl(result) else { + return nil + } + if #available(iOS 13.0, macOS 10.15, *) { + query[kSecUseDataProtectionKeychain as String] = true + } + query[kSecAttrAccessControl as String] = access + } + return query + } + + private func accessControl(_ result: @escaping StorageCallback) -> SecAccessControl? { + let accessControlFlags: SecAccessControlCreateFlags + + if initOptions.darwinBiometricOnly { + if #available(iOS 11.3, *) { + accessControlFlags = .biometryCurrentSet + } else { + accessControlFlags = .touchIDCurrentSet + } + } else { + accessControlFlags = .userPresence + } + +// access = SecAccessControlCreateWithFlags(nil, +// kSecAttrAccessibleWhenUnlockedThisDeviceOnly, +// accessControlFlags, +// &error) + var error: Unmanaged? + guard let access = SecAccessControlCreateWithFlags( + nil, // Use the default allocator. + kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly, + accessControlFlags, + &error) else { + hpdebug("Error while creating access control flags. \(String(describing: error))") + result(storageError("writing data", "error writing data", "\(String(describing: error))")); + return nil + } + + return access + } + + func read(_ result: @escaping StorageCallback, _ promptInfo: IOSPromptInfo) { + + guard var query = baseQuery(result) else { + return; + } + query[kSecMatchLimit as String] = kSecMatchLimitOne + query[kSecUseOperationPrompt as String] = promptInfo.accessTitle + query[kSecReturnAttributes as String] = true + query[kSecReturnData as String] = true + query[kSecUseAuthenticationContext as String] = context + + var item: CFTypeRef? + + let status = SecItemCopyMatching(query as CFDictionary, &item) + guard status != errSecItemNotFound else { + result(nil) + return + } + guard status == errSecSuccess else { + handleOSStatusError(status, result, "Error retrieving item. \(status)") + return + } + guard let existingItem = item as? [String : Any], + let data = existingItem[kSecValueData as String] as? Data, + let dataString = String(data: data, encoding: String.Encoding.utf8) + else { + result(storageError("RetrieveError", "Unexpected data.", nil)) + return + } + result(dataString) + } + + func delete(_ result: @escaping StorageCallback, _ promptInfo: IOSPromptInfo) { + guard let query = baseQuery(result) else { + return; + } + // query[kSecMatchLimit as String] = kSecMatchLimitOne + // query[kSecReturnData as String] = true + let status = SecItemDelete(query as CFDictionary) + if status == errSecSuccess { + result(true) + return + } + if status == errSecItemNotFound { + hpdebug("Item not in keychain. Nothing to delete.") + result(true) + return + } + handleOSStatusError(status, result, "writing data") + } + + func write(_ content: String, _ result: @escaping StorageCallback, _ promptInfo: IOSPromptInfo) { + guard var query = baseQuery(result) else { + return; + } + + if (initOptions.authenticationRequired) { + query.merge([ + kSecUseAuthenticationContext as String: context, + ]) { (_, new) in new } + if let operationPrompt = promptInfo.saveTitle { + query[kSecUseOperationPrompt as String] = operationPrompt + } + } else { + hpdebug("No authentication required for \(name)") + } + query.merge([ + // kSecMatchLimit as String: kSecMatchLimitOne, + kSecValueData as String: content.data(using: String.Encoding.utf8) as Any, + ]) { (_, new) in new } + var status = SecItemAdd(query as CFDictionary, nil) + if (status == errSecDuplicateItem) { + hpdebug("Value already exists. updating.") + let update = [kSecValueData as String: query[kSecValueData as String]] + query.removeValue(forKey: kSecValueData as String) + status = SecItemUpdate(query as CFDictionary, update as CFDictionary) + } + guard status == errSecSuccess else { + handleOSStatusError(status, result, "writing data") + return + } + result(nil) + } + + private func handleOSStatusError(_ status: OSStatus, _ result: @escaping StorageCallback, _ message: String) { + var errorMessage: String? = nil + if #available(iOS 11.3, OSX 10.12, *) { + errorMessage = SecCopyErrorMessageString(status, nil) as String? + } + let code: String + switch status { + case errSecUserCanceled: + code = "AuthError:UserCanceled" + default: + code = "SecurityError" + } + + result(storageError(code, "Error while \(message): \(status): \(errorMessage ?? "Unknown")", nil)) + } + +} diff --git a/third-party/biometric_storage/macos/Classes/BiometricStorageMacOSPlugin.swift b/third-party/biometric_storage/macos/Classes/BiometricStorageMacOSPlugin.swift new file mode 100644 index 00000000..58c30a45 --- /dev/null +++ b/third-party/biometric_storage/macos/Classes/BiometricStorageMacOSPlugin.swift @@ -0,0 +1,20 @@ +import FlutterMacOS +import Cocoa + +public class BiometricStorageMacOSPlugin: NSObject, FlutterPlugin { + + private let impl = BiometricStorageImpl(storageError: { (code, message, details) -> Any in + FlutterError(code: code, message: message, details: details) + }, storageMethodNotImplemented: FlutterMethodNotImplemented) + + public static func register(with registrar: FlutterPluginRegistrar) { + let channel = FlutterMethodChannel(name: "biometric_storage", binaryMessenger: registrar.messenger) + let instance = BiometricStorageMacOSPlugin() + registrar.addMethodCallDelegate(instance, channel: channel) + } + + public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + impl.handle(StorageMethodCall(method: call.method, arguments: call.arguments), result: result) + } + +} diff --git a/third-party/biometric_storage/macos/Classes/BiometricStoragePlugin.swift b/third-party/biometric_storage/macos/Classes/BiometricStoragePlugin.swift new file mode 100644 index 00000000..65d25878 --- /dev/null +++ b/third-party/biometric_storage/macos/Classes/BiometricStoragePlugin.swift @@ -0,0 +1,19 @@ +import Cocoa +import FlutterMacOS + +public class BiometricStoragePlugin: NSObject, FlutterPlugin { + public static func register(with registrar: FlutterPluginRegistrar) { + let channel = FlutterMethodChannel(name: "biometric_storage", binaryMessenger: registrar.messenger) + let instance = BiometricStoragePlugin() + registrar.addMethodCallDelegate(instance, channel: channel) + } + + public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + switch call.method { + case "getPlatformVersion": + result("macOS " + ProcessInfo.processInfo.operatingSystemVersionString) + default: + result(FlutterMethodNotImplemented) + } + } +} diff --git a/third-party/biometric_storage/macos/biometric_storage.podspec b/third-party/biometric_storage/macos/biometric_storage.podspec new file mode 100644 index 00000000..7bc528d6 --- /dev/null +++ b/third-party/biometric_storage/macos/biometric_storage.podspec @@ -0,0 +1,22 @@ +# +# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html. +# Run `pod lib lint biometric_storage.podspec' to validate before publishing. +# +Pod::Spec.new do |s| + s.name = 'biometric_storage' + s.version = '0.0.1' + s.summary = 'A new flutter plugin project.' + s.description = <<-DESC +A new flutter plugin project. + DESC + s.homepage = 'http://example.com' + s.license = { :file => '../LICENSE' } + s.author = { 'Your Company' => 'email@example.com' } + s.source = { :path => '.' } + s.source_files = 'Classes/**/*' + s.dependency 'FlutterMacOS' + + s.platform = :osx, '10.11' + s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES' } + s.swift_version = '5.0' +end diff --git a/third-party/biometric_storage/pubspec.yaml b/third-party/biometric_storage/pubspec.yaml new file mode 100644 index 00000000..61c22044 --- /dev/null +++ b/third-party/biometric_storage/pubspec.yaml @@ -0,0 +1,64 @@ +name: biometric_storage +description: | + Secure Storage: Encrypted data store optionally secured by biometric lock with support + for iOS, Android, MacOS. Partial support for Linux, Windows and web (localStorage). +version: 5.1.0-rc.5 +homepage: https://github.com/authpass/biometric_storage/ + +environment: + sdk: '>=3.2.0 <4.0.0' + flutter: ">=2.8.0" + +dependencies: + flutter: + sdk: flutter + flutter_web_plugins: + sdk: flutter + logging: ">=1.0.0 <2.0.0" + plugin_platform_interface: ">=2.0.0 <3.0.0" + + ffi: '>=1.0.0 <3.0.0' + win32: '>=2.0.0 <6.0.0' + web: ">=0.5.0 <2.0.0" + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^4.0.0 + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter. +flutter: + # This section identifies this Flutter project as a plugin project. + # The androidPackage and pluginClass identifiers should not ordinarily + # be modified. They are used by the tooling to maintain consistency when + # adding or updating assets for this project. + plugin: + platforms: + android: + package: design.codeux.biometric_storage + pluginClass: BiometricStoragePlugin + ios: + pluginClass: BiometricStoragePlugin + macos: + pluginClass: BiometricStorageMacOSPlugin + linux: + pluginClass: BiometricStoragePlugin + windows: + dartPluginClass: Win32BiometricStoragePlugin + fileName: src/biometric_storage_win32.dart + web: + pluginClass: BiometricStoragePluginWeb + fileName: src/biometric_storage_web.dart + +topics: + - biometrics + - encryption + - storage + - security + - secure-storage +screenshots: + - description: 'Face ID on iPhone' + path: doc/screenshot_ios.png diff --git a/third-party/biometric_storage/test/biometric_storage_test.dart b/third-party/biometric_storage/test/biometric_storage_test.dart new file mode 100644 index 00000000..085e8d08 --- /dev/null +++ b/third-party/biometric_storage/test/biometric_storage_test.dart @@ -0,0 +1,29 @@ +import 'package:biometric_storage/src/biometric_storage.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + const channel = MethodChannel('biometric_storage'); + + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (MethodCall methodCall) async { + if (methodCall.method == 'canAuthenticate') { + return 'ErrorUnknown'; + } + throw PlatformException(code: 'NotImplemented'); + }); + }); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + + test('canAuthenticate', () async { + final result = await BiometricStorage().canAuthenticate(); + expect(result, CanAuthenticateResponse.unsupported); + }); +} diff --git a/tools/CloudOTP.iss b/tools/CloudOTP.iss index bd8402f8..89798852 100644 --- a/tools/CloudOTP.iss +++ b/tools/CloudOTP.iss @@ -2,7 +2,7 @@ ; SEE THE DOCUMENTATION FOR DETAILS ON CREATING INNO SETUP SCRIPT FILES! #define MyAppName "CloudOTP" -#define MyAppVersion "2.4.0" +#define MyAppVersion "2.4.1" #define MyAppPublisher "Cloudchewie" #define MyAppURL "https://apps.cloudchewie.com/cloudotp" #define MyAppExeName "CloudOTP.exe" @@ -56,6 +56,11 @@ Source: "D:\Repositories\CloudOTP\build\windows\x64\runner\Release\*"; DestDir: Name: "{autoprograms}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}" Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon +[Registry] +; Registry +Root: HKLM; Subkey: "SOFTWARE\{#MyAppPublisher}\{#MyAppName}"; ValueType: string; ValueName: "InstallPath"; ValueData: "{app}"; Flags: uninsdeletekey +Root: HKLM; Subkey: "SOFTWARE\{#MyAppPublisher}\{#MyAppName}"; ValueType: string; ValueName: "Version"; ValueData: "{#MyAppVersion}"; Flags: uninsdeletevalue + [Run] Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}"; Flags: nowait postinstall skipifsilent