diff --git a/auth/lib/core/configuration.dart b/auth/lib/core/configuration.dart index 096fe91b2f..aed7516b9f 100644 --- a/auth/lib/core/configuration.dart +++ b/auth/lib/core/configuration.dart @@ -13,6 +13,7 @@ import 'package:ente_auth/models/key_attributes.dart'; import 'package:ente_auth/models/key_gen_result.dart'; import 'package:ente_auth/models/private_key_attributes.dart'; import 'package:ente_auth/store/authenticator_db.dart'; +import 'package:ente_auth/utils/lock_screen_settings.dart'; import 'package:ente_crypto_dart/ente_crypto_dart.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:logging/logging.dart'; @@ -469,7 +470,13 @@ class Configuration { await _preferences.setBool(hasOptedForOfflineModeKey, true); } - bool shouldShowLockScreen() { + Future shouldShowLockScreen() async { + final bool isPin = await LockScreenSettings.instance.isPinSet(); + final bool isPass = await LockScreenSettings.instance.isPasswordSet(); + return isPin || isPass || shouldShowSystemLockScreen(); + } + + bool shouldShowSystemLockScreen() { if (_preferences.containsKey(keyShouldShowLockScreen)) { return _preferences.getBool(keyShouldShowLockScreen)!; } else { @@ -477,7 +484,7 @@ class Configuration { } } - Future setShouldShowLockScreen(bool value) { + Future setSystemLockScreen(bool value) { return _preferences.setBool(keyShouldShowLockScreen, value); } diff --git a/auth/lib/main.dart b/auth/lib/main.dart index 9f6e611b3f..de0257805d 100644 --- a/auth/lib/main.dart +++ b/auth/lib/main.dart @@ -22,6 +22,7 @@ import 'package:ente_auth/store/code_store.dart'; import 'package:ente_auth/ui/tools/app_lock.dart'; import 'package:ente_auth/ui/tools/lock_screen.dart'; import 'package:ente_auth/ui/utils/icon_utils.dart'; +import 'package:ente_auth/utils/lock_screen_settings.dart'; import 'package:ente_auth/utils/platform_util.dart'; import 'package:ente_auth/utils/window_protocol_handler.dart'; import 'package:ente_crypto_dart/ente_crypto_dart.dart'; @@ -114,7 +115,7 @@ Future _runInForeground() async { AppLock( builder: (args) => App(locale: locale), lockScreen: const LockScreen(), - enabled: Configuration.instance.shouldShowLockScreen(), + enabled: await Configuration.instance.shouldShowLockScreen(), locale: locale, lightTheme: lightThemeData, darkTheme: darkThemeData, @@ -173,6 +174,7 @@ Future _init(bool bool, {String? via}) async { await NotificationService.instance.init(); await UpdateService.instance.init(); await IconUtils.instance.init(); + await LockScreenSettings.instance.init(); } Future _setupPrivacyScreen() async { diff --git a/auth/lib/services/local_authentication_service.dart b/auth/lib/services/local_authentication_service.dart index 44c2a758a7..a1f016b7a9 100644 --- a/auth/lib/services/local_authentication_service.dart +++ b/auth/lib/services/local_authentication_service.dart @@ -1,6 +1,8 @@ import 'dart:io'; import 'package:ente_auth/core/configuration.dart'; +import 'package:ente_auth/ui/settings/lock_screen/lock_screen_password.dart'; +import 'package:ente_auth/ui/settings/lock_screen/lock_screen_pin.dart'; import 'package:ente_auth/ui/tools/app_lock.dart'; import 'package:ente_auth/utils/auth_util.dart'; import 'package:ente_auth/utils/dialog_util.dart'; @@ -25,7 +27,7 @@ class LocalAuthenticationService { AppLock.of(context)!.setEnabled(false); final result = await requestAuthentication(context, infoMessage); AppLock.of(context)!.setEnabled( - Configuration.instance.shouldShowLockScreen(), + await Configuration.instance.shouldShowLockScreen(), ); if (!result) { showToast(context, infoMessage); @@ -37,6 +39,47 @@ class LocalAuthenticationService { return true; } + Future requestEnteAuthForLockScreen( + BuildContext context, + String? savedPin, + String? savedPassword, { + bool isOnOpeningApp = false, + }) async { + if (savedPassword != null) { + final result = await Navigator.of(context).push( + MaterialPageRoute( + builder: (BuildContext context) { + return LockScreenPassword( + isAuthenticating: true, + isOnOpeningApp: isOnOpeningApp, + authPass: savedPassword, + ); + }, + ), + ); + if (result) { + return true; + } + } + if (savedPin != null) { + final result = await Navigator.of(context).push( + MaterialPageRoute( + builder: (BuildContext context) { + return LockScreenPin( + isAuthenticating: true, + isOnOpeningApp: isOnOpeningApp, + authPin: savedPin, + ); + }, + ), + ); + if (result) { + return true; + } + } + return false; + } + Future requestLocalAuthForLockScreen( BuildContext context, bool shouldEnableLockScreen, @@ -53,11 +96,11 @@ class LocalAuthenticationService { if (result) { AppLock.of(context)!.setEnabled(shouldEnableLockScreen); await Configuration.instance - .setShouldShowLockScreen(shouldEnableLockScreen); + .setSystemLockScreen(shouldEnableLockScreen); return true; } else { AppLock.of(context)! - .setEnabled(Configuration.instance.shouldShowLockScreen()); + .setEnabled(await Configuration.instance.shouldShowLockScreen()); } } else { // ignore: unawaited_futures diff --git a/auth/lib/ui/components/text_input_widget.dart b/auth/lib/ui/components/text_input_widget.dart index 7737978329..2a06d3b71a 100644 --- a/auth/lib/ui/components/text_input_widget.dart +++ b/auth/lib/ui/components/text_input_widget.dart @@ -6,6 +6,7 @@ import 'package:ente_auth/ui/components/separators.dart'; import 'package:ente_auth/utils/debouncer.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:logging/logging.dart'; class TextInputWidget extends StatefulWidget { final String? label; @@ -58,6 +59,7 @@ class TextInputWidget extends StatefulWidget { } class _TextInputWidgetState extends State { + final _logger = Logger("TextInputWidget"); ExecutionState executionState = ExecutionState.idle; final _textController = TextEditingController(); final _debouncer = Debouncer(const Duration(milliseconds: 300)); @@ -66,7 +68,7 @@ class _TextInputWidgetState extends State { ///This is to pass if the TextInputWidget is in a dialog and an error is ///thrown in executing onSubmit by passing it as arg in Navigator.pop() Exception? _exception; - + bool _incorrectPassword = false; @override void initState() { widget.submitNotifier?.addListener(_onSubmit); @@ -138,7 +140,11 @@ class _TextInputWidgetState extends State { borderSide: BorderSide.none, ), focusedBorder: OutlineInputBorder( - borderSide: BorderSide(color: colorScheme.strokeFaint), + borderSide: BorderSide( + color: _incorrectPassword + ? const Color.fromRGBO(245, 42, 42, 1) + : colorScheme.strokeFaint, + ), borderRadius: BorderRadius.circular(8), ), suffixIcon: Padding( @@ -233,6 +239,10 @@ class _TextInputWidgetState extends State { executionState = ExecutionState.error; _debouncer.cancelDebounce(); _exception = e as Exception; + if (e.toString().contains("Incorrect password")) { + _logger.warning("Incorrect password"); + _surfaceWrongPasswordState(); + } if (!widget.popNavAfterSubmission) { rethrow; } @@ -306,6 +316,20 @@ class _TextInputWidgetState extends State { void _popNavigatorStack(BuildContext context, {Exception? e}) { Navigator.of(context).canPop() ? Navigator.of(context).pop(e) : null; } + + void _surfaceWrongPasswordState() { + setState(() { + _incorrectPassword = true; + HapticFeedback.vibrate(); + Future.delayed(const Duration(seconds: 1), () { + if (mounted) { + setState(() { + _incorrectPassword = false; + }); + } + }); + }); + } } //todo: Add clear and custom icon for suffic icon diff --git a/auth/lib/ui/settings/lock_screen/custom_pin_keypad.dart b/auth/lib/ui/settings/lock_screen/custom_pin_keypad.dart new file mode 100644 index 0000000000..e2a8c2b485 --- /dev/null +++ b/auth/lib/ui/settings/lock_screen/custom_pin_keypad.dart @@ -0,0 +1,196 @@ +import "package:ente_auth/theme/ente_theme.dart"; +import "package:flutter/material.dart"; + +class CustomPinKeypad extends StatelessWidget { + final TextEditingController controller; + const CustomPinKeypad({required this.controller, super.key}); + + @override + Widget build(BuildContext context) { + return SafeArea( + child: Container( + padding: const EdgeInsets.all(2), + color: getEnteColorScheme(context).strokeFainter, + child: Column( + children: [ + Row( + children: [ + _Button( + text: '', + number: '1', + onTap: () { + _onKeyTap('1'); + }, + ), + _Button( + text: "ABC", + number: '2', + onTap: () { + _onKeyTap('2'); + }, + ), + _Button( + text: "DEF", + number: '3', + onTap: () { + _onKeyTap('3'); + }, + ), + ], + ), + Row( + children: [ + _Button( + number: '4', + text: "GHI", + onTap: () { + _onKeyTap('4'); + }, + ), + _Button( + number: '5', + text: 'JKL', + onTap: () { + _onKeyTap('5'); + }, + ), + _Button( + number: '6', + text: 'MNO', + onTap: () { + _onKeyTap('6'); + }, + ), + ], + ), + Row( + children: [ + _Button( + number: '7', + text: 'PQRS', + onTap: () { + _onKeyTap('7'); + }, + ), + _Button( + number: '8', + text: 'TUV', + onTap: () { + _onKeyTap('8'); + }, + ), + _Button( + number: '9', + text: 'WXYZ', + onTap: () { + _onKeyTap('9'); + }, + ), + ], + ), + Row( + children: [ + const _Button( + number: '', + text: '', + muteButton: true, + onTap: null, + ), + _Button( + number: '0', + text: '', + onTap: () { + _onKeyTap('0'); + }, + ), + _Button( + number: '', + text: '', + icon: const Icon(Icons.backspace_outlined), + onTap: () { + _onBackspace(); + }, + ), + ], + ), + ], + ), + ), + ); + } + + void _onKeyTap(String number) { + controller.text += number; + return; + } + + void _onBackspace() { + if (controller.text.isNotEmpty) { + controller.text = + controller.text.substring(0, controller.text.length - 1); + } + return; + } +} + +class _Button extends StatelessWidget { + final String number; + final String text; + final VoidCallback? onTap; + final bool muteButton; + final Widget? icon; + const _Button({ + required this.number, + required this.text, + this.muteButton = false, + required this.onTap, + this.icon, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = getEnteColorScheme(context); + final textTheme = getEnteTextTheme(context); + return Expanded( + child: GestureDetector( + onTap: onTap, + child: Container( + margin: const EdgeInsets.all(4), + decoration: BoxDecoration( + shape: BoxShape.rectangle, + borderRadius: BorderRadius.circular(6), + color: muteButton + ? colorScheme.fillFaintPressed + : icon == null + ? colorScheme.backgroundElevated2 + : null, + ), + child: Center( + child: muteButton + ? const SizedBox.shrink() + : icon != null + ? Container( + child: icon, + ) + : Container( + padding: const EdgeInsets.all(4), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + number, + style: textTheme.h3, + ), + Text( + text, + style: textTheme.tinyBold, + ), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/auth/lib/ui/settings/lock_screen/lock_screen_confirm_password.dart b/auth/lib/ui/settings/lock_screen/lock_screen_confirm_password.dart new file mode 100644 index 0000000000..ebb7c3112b --- /dev/null +++ b/auth/lib/ui/settings/lock_screen/lock_screen_confirm_password.dart @@ -0,0 +1,185 @@ +import "package:ente_auth/theme/ente_theme.dart"; +import "package:ente_auth/ui/common/dynamic_fab.dart"; +import "package:ente_auth/ui/components/buttons/icon_button_widget.dart"; +import "package:ente_auth/ui/components/text_input_widget.dart"; +import "package:ente_auth/utils/lock_screen_settings.dart"; +import "package:flutter/material.dart"; +import "package:flutter/services.dart"; + +class LockScreenConfirmPassword extends StatefulWidget { + const LockScreenConfirmPassword({ + super.key, + required this.password, + }); + final String password; + + @override + State createState() => + _LockScreenConfirmPasswordState(); +} + +class _LockScreenConfirmPasswordState extends State { + final _confirmPasswordController = TextEditingController(text: null); + final LockScreenSettings _lockscreenSetting = LockScreenSettings.instance; + final _focusNode = FocusNode(); + final _isFormValid = ValueNotifier(false); + final _submitNotifier = ValueNotifier(false); + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) async { + _focusNode.requestFocus(); + }); + } + + @override + void dispose() { + _submitNotifier.dispose(); + _focusNode.dispose(); + _isFormValid.dispose(); + _confirmPasswordController.dispose(); + super.dispose(); + } + + Future _confirmPasswordMatch() async { + if (widget.password == _confirmPasswordController.text) { + await _lockscreenSetting.setPassword(_confirmPasswordController.text); + + Navigator.of(context).pop(true); + Navigator.of(context).pop(true); + return; + } + await HapticFeedback.vibrate(); + throw Exception("Incorrect password"); + } + + @override + Widget build(BuildContext context) { + final colorTheme = getEnteColorScheme(context); + final textTheme = getEnteTextTheme(context); + final isKeypadOpen = MediaQuery.viewInsetsOf(context).bottom > 100; + + FloatingActionButtonLocation? fabLocation() { + if (isKeypadOpen) { + return null; + } else { + return FloatingActionButtonLocation.centerFloat; + } + } + + return Scaffold( + resizeToAvoidBottomInset: isKeypadOpen, + appBar: AppBar( + elevation: 0, + leading: IconButton( + onPressed: () { + FocusScope.of(context).unfocus(); + Navigator.of(context).pop(); + }, + icon: Icon( + Icons.arrow_back, + color: colorTheme.textBase, + ), + ), + ), + floatingActionButton: ValueListenableBuilder( + valueListenable: _isFormValid, + builder: (context, isFormValid, child) { + return DynamicFAB( + isKeypadOpen: isKeypadOpen, + buttonText: "Confirm", + isFormValid: isFormValid, + onPressedFunction: () async { + _submitNotifier.value = !_submitNotifier.value; + }, + ); + }, + ), + floatingActionButtonLocation: fabLocation(), + floatingActionButtonAnimator: NoScalingAnimation(), + body: SingleChildScrollView( + child: Center( + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + SizedBox( + height: 120, + width: 120, + child: Stack( + alignment: Alignment.center, + children: [ + Container( + width: 82, + height: 82, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: LinearGradient( + colors: [ + Colors.grey.shade500.withOpacity(0.2), + Colors.grey.shade50.withOpacity(0.1), + Colors.grey.shade400.withOpacity(0.2), + Colors.grey.shade300.withOpacity(0.4), + ], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + ), + ), + child: Padding( + padding: const EdgeInsets.all(1.0), + child: Container( + decoration: BoxDecoration( + shape: BoxShape.circle, + color: colorTheme.backgroundBase, + ), + ), + ), + ), + SizedBox( + height: 75, + width: 75, + child: CircularProgressIndicator( + backgroundColor: colorTheme.fillFaintPressed, + value: 1, + strokeWidth: 1.5, + ), + ), + IconButtonWidget( + icon: Icons.lock, + iconButtonType: IconButtonType.primary, + iconColor: colorTheme.textBase, + ), + ], + ), + ), + Text( + "Re-enter Password", + style: textTheme.bodyBold, + ), + const Padding(padding: EdgeInsets.all(12)), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: TextInputWidget( + hintText: "Confirm Password", + autoFocus: true, + textCapitalization: TextCapitalization.none, + isPasswordInput: true, + shouldSurfaceExecutionStates: false, + onChange: (p0) { + _confirmPasswordController.text = p0; + _isFormValid.value = + _confirmPasswordController.text.isNotEmpty; + }, + onSubmit: (p0) { + return _confirmPasswordMatch(); + }, + submitNotifier: _submitNotifier, + ), + ), + const Padding(padding: EdgeInsets.all(12)), + ], + ), + ), + ), + ); + } +} diff --git a/auth/lib/ui/settings/lock_screen/lock_screen_confirm_pin.dart b/auth/lib/ui/settings/lock_screen/lock_screen_confirm_pin.dart new file mode 100644 index 0000000000..da14aa4f88 --- /dev/null +++ b/auth/lib/ui/settings/lock_screen/lock_screen_confirm_pin.dart @@ -0,0 +1,206 @@ +import "package:ente_auth/theme/ente_theme.dart"; +import "package:ente_auth/ui/settings/lock_screen/custom_pin_keypad.dart"; +import "package:ente_auth/utils/lock_screen_settings.dart"; +import "package:flutter/material.dart"; +import "package:flutter/services.dart"; +import "package:pinput/pinput.dart"; + +class LockScreenConfirmPin extends StatefulWidget { + const LockScreenConfirmPin({super.key, required this.pin}); + final String pin; + @override + State createState() => _LockScreenConfirmPinState(); +} + +class _LockScreenConfirmPinState extends State { + final _confirmPinController = TextEditingController(text: null); + bool isConfirmPinValid = false; + + final LockScreenSettings _lockscreenSetting = LockScreenSettings.instance; + final _pinPutDecoration = PinTheme( + height: 48, + width: 48, + padding: const EdgeInsets.only(top: 6.0), + decoration: BoxDecoration( + border: Border.all(color: const Color.fromRGBO(45, 194, 98, 1.0)), + borderRadius: BorderRadius.circular(15.0), + ), + ); + + @override + void dispose() { + super.dispose(); + _confirmPinController.dispose(); + } + + Future _confirmPinMatch() async { + if (widget.pin == _confirmPinController.text) { + await _lockscreenSetting.setPin(_confirmPinController.text); + + Navigator.of(context).pop(true); + Navigator.of(context).pop(true); + return; + } + setState(() { + isConfirmPinValid = true; + }); + await HapticFeedback.vibrate(); + await Future.delayed(const Duration(milliseconds: 75)); + _confirmPinController.clear(); + setState(() { + isConfirmPinValid = false; + }); + } + + @override + Widget build(BuildContext context) { + final colorTheme = getEnteColorScheme(context); + final textTheme = getEnteTextTheme(context); + return Scaffold( + appBar: AppBar( + elevation: 0, + leading: IconButton( + onPressed: () { + Navigator.of(context).pop(false); + }, + icon: Icon( + Icons.arrow_back, + color: colorTheme.textBase, + ), + ), + ), + body: OrientationBuilder( + builder: (context, orientation) { + return orientation == Orientation.portrait + ? _getBody(colorTheme, textTheme, isPortrait: true) + : SingleChildScrollView( + child: _getBody(colorTheme, textTheme, isPortrait: false), + ); + }, + ), + ); + } + + Widget _getBody(colorTheme, textTheme, {required bool isPortrait}) { + return Center( + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + SizedBox( + height: 120, + width: 120, + child: Stack( + alignment: Alignment.center, + children: [ + Container( + width: 82, + height: 82, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: LinearGradient( + colors: [ + Colors.grey.shade500.withOpacity(0.2), + Colors.grey.shade50.withOpacity(0.1), + Colors.grey.shade400.withOpacity(0.2), + Colors.grey.shade300.withOpacity(0.4), + ], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + ), + ), + child: Padding( + padding: const EdgeInsets.all(1.0), + child: Container( + decoration: BoxDecoration( + shape: BoxShape.circle, + color: colorTheme.backgroundBase, + ), + ), + ), + ), + SizedBox( + height: 75, + width: 75, + child: ValueListenableBuilder( + valueListenable: _confirmPinController, + builder: (context, value, child) { + return TweenAnimationBuilder( + tween: Tween( + begin: 0, + end: _confirmPinController.text.length / 4, + ), + curve: Curves.ease, + duration: const Duration(milliseconds: 250), + builder: (context, value, _) => + CircularProgressIndicator( + backgroundColor: colorTheme.fillFaintPressed, + value: value, + color: colorTheme.primary400, + strokeWidth: 1.5, + ), + ); + }, + ), + ), + Icon( + Icons.lock, + color: colorTheme.textBase, + size: 30, + ), + ], + ), + ), + Text( + "Re-enter PIN", + style: textTheme.bodyBold, + ), + const Padding(padding: EdgeInsets.all(12)), + Pinput( + length: 4, + showCursor: false, + useNativeKeyboard: false, + controller: _confirmPinController, + defaultPinTheme: _pinPutDecoration, + submittedPinTheme: _pinPutDecoration.copyWith( + textStyle: textTheme.h3Bold, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10.0), + border: Border.all( + color: colorTheme.fillBase, + ), + ), + ), + followingPinTheme: _pinPutDecoration.copyWith( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10.0), + border: Border.all( + color: colorTheme.fillMuted, + ), + ), + ), + focusedPinTheme: _pinPutDecoration, + errorPinTheme: _pinPutDecoration.copyWith( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10.0), + border: Border.all( + color: colorTheme.warning400, + ), + ), + ), + errorText: '', + obscureText: true, + obscuringCharacter: '*', + forceErrorState: isConfirmPinValid, + onCompleted: (value) async { + await _confirmPinMatch(); + }, + ), + isPortrait + ? const Spacer() + : const Padding(padding: EdgeInsets.all(12)), + CustomPinKeypad(controller: _confirmPinController), + ], + ), + ); + } +} diff --git a/auth/lib/ui/settings/lock_screen/lock_screen_options.dart b/auth/lib/ui/settings/lock_screen/lock_screen_options.dart new file mode 100644 index 0000000000..da98826e66 --- /dev/null +++ b/auth/lib/ui/settings/lock_screen/lock_screen_options.dart @@ -0,0 +1,224 @@ +import "package:ente_auth/core/configuration.dart"; +import "package:ente_auth/theme/ente_theme.dart"; +import "package:ente_auth/ui/components/captioned_text_widget.dart"; +import "package:ente_auth/ui/components/divider_widget.dart"; +import "package:ente_auth/ui/components/menu_item_widget.dart"; +import "package:ente_auth/ui/components/title_bar_title_widget.dart"; +import "package:ente_auth/ui/components/title_bar_widget.dart"; +import "package:ente_auth/ui/components/toggle_switch_widget.dart"; +import "package:ente_auth/ui/settings/lock_screen/lock_screen_password.dart"; +import "package:ente_auth/ui/settings/lock_screen/lock_screen_pin.dart"; +import "package:ente_auth/ui/tools/app_lock.dart"; +import "package:ente_auth/utils/lock_screen_settings.dart"; +import "package:flutter/material.dart"; + +class LockScreenOptions extends StatefulWidget { + const LockScreenOptions({super.key}); + + @override + State createState() => _LockScreenOptionsState(); +} + +class _LockScreenOptionsState extends State { + final Configuration _configuration = Configuration.instance; + final LockScreenSettings _lockscreenSetting = LockScreenSettings.instance; + late bool appLock; + bool isPinEnabled = false; + bool isPasswordEnabled = false; + + @override + void initState() { + super.initState(); + _initializeSettings(); + appLock = isPinEnabled || + isPasswordEnabled || + _configuration.shouldShowSystemLockScreen(); + } + + Future _initializeSettings() async { + final bool passwordEnabled = await _lockscreenSetting.isPasswordSet(); + final bool pinEnabled = await _lockscreenSetting.isPinSet(); + setState(() { + isPasswordEnabled = passwordEnabled; + isPinEnabled = pinEnabled; + }); + } + + Future _deviceLock() async { + await _lockscreenSetting.removePinAndPassword(); + await _initializeSettings(); + } + + Future _pinLock() async { + final bool result = await Navigator.of(context).push( + MaterialPageRoute( + builder: (BuildContext context) { + return const LockScreenPin(); + }, + ), + ); + setState(() { + _initializeSettings(); + if (result) { + appLock = isPinEnabled || + isPasswordEnabled || + _configuration.shouldShowSystemLockScreen(); + } + }); + } + + Future _passwordLock() async { + final bool result = await Navigator.of(context).push( + MaterialPageRoute( + builder: (BuildContext context) { + return const LockScreenPassword(); + }, + ), + ); + setState(() { + _initializeSettings(); + if (result) { + appLock = isPinEnabled || + isPasswordEnabled || + _configuration.shouldShowSystemLockScreen(); + } + }); + } + + Future _onToggleSwitch() async { + AppLock.of(context)!.setEnabled(!appLock); + await _configuration.setSystemLockScreen(!appLock); + await _lockscreenSetting.removePinAndPassword(); + setState(() { + _initializeSettings(); + appLock = !appLock; + }); + } + + @override + Widget build(BuildContext context) { + final colorTheme = getEnteColorScheme(context); + final textTheme = getEnteTextTheme(context); + return Scaffold( + body: CustomScrollView( + primary: false, + slivers: [ + const TitleBarWidget( + flexibleSpaceTitle: TitleBarTitleWidget( + title: 'App lock', + ), + ), + SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 20), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Column( + children: [ + MenuItemWidget( + captionedTextWidget: const CaptionedTextWidget( + title: 'App lock', + ), + alignCaptionedTextToLeft: true, + singleBorderRadius: 8, + menuItemColor: colorTheme.fillFaint, + trailingWidget: ToggleSwitchWidget( + value: () => appLock, + onChanged: () => _onToggleSwitch(), + ), + ), + !appLock + ? Padding( + padding: const EdgeInsets.only( + top: 14, + left: 14, + right: 12, + ), + child: Text( + 'Choose between your device\'s default lock screen and a custom lock screen with a PIN or password.', + style: textTheme.miniFaint, + textAlign: TextAlign.left, + ), + ) + : const SizedBox(), + const Padding( + padding: EdgeInsets.only(top: 24), + ), + ], + ), + appLock + ? Column( + children: [ + MenuItemWidget( + captionedTextWidget: + const CaptionedTextWidget( + title: "Device lock", + ), + alignCaptionedTextToLeft: true, + isTopBorderRadiusRemoved: false, + isBottomBorderRadiusRemoved: true, + menuItemColor: colorTheme.fillFaint, + trailingIcon: + !(isPasswordEnabled || isPinEnabled) + ? Icons.check + : null, + trailingIconColor: colorTheme.textBase, + onTap: () => _deviceLock(), + ), + DividerWidget( + dividerType: DividerType.menuNoIcon, + bgColor: colorTheme.fillFaint, + ), + MenuItemWidget( + captionedTextWidget: + const CaptionedTextWidget( + title: "Pin lock", + ), + alignCaptionedTextToLeft: true, + isTopBorderRadiusRemoved: true, + isBottomBorderRadiusRemoved: true, + menuItemColor: colorTheme.fillFaint, + trailingIcon: + isPinEnabled ? Icons.check : null, + trailingIconColor: colorTheme.textBase, + onTap: () => _pinLock(), + ), + DividerWidget( + dividerType: DividerType.menuNoIcon, + bgColor: colorTheme.fillFaint, + ), + MenuItemWidget( + captionedTextWidget: + const CaptionedTextWidget( + title: "Password", + ), + alignCaptionedTextToLeft: true, + isTopBorderRadiusRemoved: true, + isBottomBorderRadiusRemoved: false, + menuItemColor: colorTheme.fillFaint, + trailingIcon: + isPasswordEnabled ? Icons.check : null, + trailingIconColor: colorTheme.textBase, + onTap: () => _passwordLock(), + ), + ], + ) + : Container(), + ], + ), + ), + ); + }, + childCount: 1, + ), + ), + ], + ), + ); + } +} diff --git a/auth/lib/ui/settings/lock_screen/lock_screen_password.dart b/auth/lib/ui/settings/lock_screen/lock_screen_password.dart new file mode 100644 index 0000000000..504ff506b4 --- /dev/null +++ b/auth/lib/ui/settings/lock_screen/lock_screen_password.dart @@ -0,0 +1,231 @@ +import "package:ente_auth/theme/ente_theme.dart"; +import "package:ente_auth/ui/common/dynamic_fab.dart"; +import "package:ente_auth/ui/components/buttons/icon_button_widget.dart"; +import "package:ente_auth/ui/components/text_input_widget.dart"; +import "package:ente_auth/ui/settings/lock_screen/lock_screen_confirm_password.dart"; +import "package:ente_auth/ui/settings/lock_screen/lock_screen_options.dart"; +import "package:ente_auth/utils/lock_screen_settings.dart"; +import "package:flutter/material.dart"; +import "package:flutter/services.dart"; + +class LockScreenPassword extends StatefulWidget { + const LockScreenPassword({ + super.key, + this.isAuthenticating = false, + this.isOnOpeningApp = false, + this.authPass, + }); + + //Is false when setting a new password + final bool isAuthenticating; + final bool isOnOpeningApp; + final String? authPass; + @override + State createState() => _LockScreenPasswordState(); +} + +class _LockScreenPasswordState extends State { + final _passwordController = TextEditingController(text: null); + final _focusNode = FocusNode(); + final _isFormValid = ValueNotifier(false); + final _submitNotifier = ValueNotifier(false); + int invalidAttemptsCount = 0; + + final _lockscreenSetting = LockScreenSettings.instance; + @override + void initState() { + super.initState(); + invalidAttemptsCount = _lockscreenSetting.getInvalidAttemptCount(); + WidgetsBinding.instance.addPostFrameCallback((_) async { + _focusNode.requestFocus(); + }); + } + + @override + void dispose() { + super.dispose(); + _submitNotifier.dispose(); + _focusNode.dispose(); + _isFormValid.dispose(); + _passwordController.dispose(); + } + + @override + Widget build(BuildContext context) { + final colorTheme = getEnteColorScheme(context); + final textTheme = getEnteTextTheme(context); + final isKeypadOpen = MediaQuery.viewInsetsOf(context).bottom > 100; + + FloatingActionButtonLocation? fabLocation() { + if (isKeypadOpen) { + return null; + } else { + return FloatingActionButtonLocation.centerFloat; + } + } + + return Scaffold( + resizeToAvoidBottomInset: isKeypadOpen, + appBar: AppBar( + elevation: 0, + leading: IconButton( + onPressed: () { + FocusScope.of(context).unfocus(); + Navigator.of(context).pop(false); + }, + icon: Icon( + Icons.arrow_back, + color: colorTheme.textBase, + ), + ), + ), + floatingActionButton: ValueListenableBuilder( + valueListenable: _isFormValid, + builder: (context, isFormValid, child) { + return DynamicFAB( + isKeypadOpen: isKeypadOpen, + buttonText: "Next", + isFormValid: isFormValid, + onPressedFunction: () async { + _submitNotifier.value = !_submitNotifier.value; + }, + ); + }, + ), + floatingActionButtonLocation: fabLocation(), + floatingActionButtonAnimator: NoScalingAnimation(), + body: SingleChildScrollView( + child: Center( + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + SizedBox( + height: 120, + width: 120, + child: Stack( + alignment: Alignment.center, + children: [ + Container( + width: 82, + height: 82, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: LinearGradient( + colors: [ + Colors.grey.shade500.withOpacity(0.2), + Colors.grey.shade50.withOpacity(0.1), + Colors.grey.shade400.withOpacity(0.2), + Colors.grey.shade300.withOpacity(0.4), + ], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + ), + ), + child: Padding( + padding: const EdgeInsets.all(1.0), + child: Container( + decoration: BoxDecoration( + shape: BoxShape.circle, + color: colorTheme.backgroundBase, + ), + ), + ), + ), + SizedBox( + height: 75, + width: 75, + child: CircularProgressIndicator( + backgroundColor: colorTheme.fillFaintPressed, + value: 1, + strokeWidth: 1.5, + ), + ), + IconButtonWidget( + icon: Icons.lock, + iconButtonType: IconButtonType.primary, + iconColor: colorTheme.textBase, + ), + ], + ), + ), + Text( + widget.isAuthenticating ? "Enter Password" : "Set new Password", + textAlign: TextAlign.center, + style: textTheme.bodyBold, + ), + const Padding(padding: EdgeInsets.all(12)), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: TextInputWidget( + hintText: "Password", + autoFocus: true, + textCapitalization: TextCapitalization.none, + isPasswordInput: true, + shouldSurfaceExecutionStates: false, + onChange: (p0) { + _passwordController.text = p0; + _isFormValid.value = _passwordController.text.isNotEmpty; + }, + onSubmit: (p0) { + return _confirmPassword(); + }, + submitNotifier: _submitNotifier, + ), + ), + const Padding(padding: EdgeInsets.all(12)), + ], + ), + ), + ), + ); + } + + Future _confirmPasswordAuth(String inputtedPassword) async { + // final Uint8List? salt = await _lockscreenSetting.getSalt(); + // final hash = cryptoPwHash({ + // "password": utf8.encode(inputtedPassword), + // "salt": salt, + // "opsLimit": Sodium.cryptoPwhashOpslimitInteractive, + // "memLimit": Sodium.cryptoPwhashMemlimitInteractive, + // }); + if (widget.authPass == inputtedPassword) { + await _lockscreenSetting.setInvalidAttemptCount(0); + + widget.isOnOpeningApp + ? Navigator.of(context).pop(true) + : Navigator.of(context).pushReplacement( + MaterialPageRoute( + builder: (context) => const LockScreenOptions(), + ), + ); + return true; + } else { + if (widget.isOnOpeningApp) { + invalidAttemptsCount++; + if (invalidAttemptsCount > 4) { + await _lockscreenSetting.setInvalidAttemptCount(invalidAttemptsCount); + Navigator.of(context).pop(false); + } + } + + await HapticFeedback.vibrate(); + throw Exception("Incorrect password"); + } + } + + Future _confirmPassword() async { + if (widget.isAuthenticating) { + await _confirmPasswordAuth(_passwordController.text); + return; + } else { + await Navigator.of(context).push( + MaterialPageRoute( + builder: (BuildContext context) => LockScreenConfirmPassword( + password: _passwordController.text, + ), + ), + ); + _passwordController.clear(); + } + } +} diff --git a/auth/lib/ui/settings/lock_screen/lock_screen_pin.dart b/auth/lib/ui/settings/lock_screen/lock_screen_pin.dart new file mode 100644 index 0000000000..cc4948b45d --- /dev/null +++ b/auth/lib/ui/settings/lock_screen/lock_screen_pin.dart @@ -0,0 +1,269 @@ +import "package:ente_auth/theme/colors.dart"; +import "package:ente_auth/theme/ente_theme.dart"; +import "package:ente_auth/theme/text_style.dart"; +import "package:ente_auth/ui/settings/lock_screen/custom_pin_keypad.dart"; +import "package:ente_auth/ui/settings/lock_screen/lock_screen_confirm_pin.dart"; +import "package:ente_auth/ui/settings/lock_screen/lock_screen_options.dart"; +import "package:ente_auth/utils/lock_screen_settings.dart"; +import "package:flutter/material.dart"; +import "package:flutter/services.dart"; +import 'package:pinput/pinput.dart'; + +class LockScreenPin extends StatefulWidget { + const LockScreenPin({ + super.key, + this.isAuthenticating = false, + this.isOnOpeningApp = false, + this.authPin, + }); + + //Is false when setting a new password + final bool isAuthenticating; + final bool isOnOpeningApp; + final String? authPin; + @override + State createState() => _LockScreenPinState(); +} + +class _LockScreenPinState extends State { + final _pinController = TextEditingController(text: null); + + final LockScreenSettings _lockscreenSetting = LockScreenSettings.instance; + bool isPinValid = false; + int invalidAttemptsCount = 0; + + @override + void initState() { + super.initState(); + invalidAttemptsCount = _lockscreenSetting.getInvalidAttemptCount(); + } + + @override + void dispose() { + super.dispose(); + _pinController.dispose(); + } + + Future confirmPinAuth(String inputtedPin) async { + // final Uint8List? salt = await _lockscreenSetting.getSalt(); + // final hash = cryptoPwHash({ + // "password": utf8.encode(code), + // "salt": salt, + // "opsLimit": Sodium.cryptoPwhashOpslimitInteractive, + // "memLimit": Sodium.cryptoPwhashMemlimitInteractive, + // }); + // final String hashedPin = base64Encode(hash); + if (widget.authPin == inputtedPin) { + invalidAttemptsCount = 0; + await _lockscreenSetting.setInvalidAttemptCount(0); + widget.isOnOpeningApp + ? Navigator.of(context).pop(true) + : Navigator.of(context).pushReplacement( + MaterialPageRoute( + builder: (context) => const LockScreenOptions(), + ), + ); + return true; + } else { + setState(() { + isPinValid = true; + }); + await HapticFeedback.vibrate(); + await Future.delayed(const Duration(milliseconds: 75)); + _pinController.clear(); + setState(() { + isPinValid = false; + }); + + if (widget.isOnOpeningApp) { + invalidAttemptsCount++; + if (invalidAttemptsCount > 4) { + await _lockscreenSetting.setInvalidAttemptCount(invalidAttemptsCount); + Navigator.of(context).pop(false); + } + } + return false; + } + } + + Future _confirmPin(String inputtedPin) async { + if (widget.isAuthenticating) { + await confirmPinAuth(inputtedPin); + return; + } else { + await Navigator.of(context).push( + MaterialPageRoute( + builder: (BuildContext context) => + LockScreenConfirmPin(pin: inputtedPin), + ), + ); + _pinController.clear(); + } + } + + final _pinPutDecoration = PinTheme( + height: 48, + width: 48, + padding: const EdgeInsets.only(top: 6.0), + decoration: BoxDecoration( + border: Border.all(color: const Color.fromRGBO(45, 194, 98, 1.0)), + borderRadius: BorderRadius.circular(15.0), + ), + ); + + @override + Widget build(BuildContext context) { + final colorTheme = getEnteColorScheme(context); + final textTheme = getEnteTextTheme(context); + return Scaffold( + appBar: AppBar( + elevation: 0, + leading: IconButton( + onPressed: () { + Navigator.of(context).pop(false); + }, + icon: Icon( + Icons.arrow_back, + color: colorTheme.textBase, + ), + ), + ), + body: OrientationBuilder( + builder: (context, orientation) { + return orientation == Orientation.portrait + ? _getBody(colorTheme, textTheme, isPortrait: true) + : SingleChildScrollView( + child: _getBody(colorTheme, textTheme, isPortrait: false), + ); + }, + ), + ); + } + + Widget _getBody( + EnteColorScheme colorTheme, + EnteTextTheme textTheme, { + required bool isPortrait, + }) { + return Center( + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + SizedBox( + height: 120, + width: 120, + child: Stack( + alignment: Alignment.center, + children: [ + Container( + width: 82, + height: 82, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: LinearGradient( + colors: [ + Colors.grey.shade500.withOpacity(0.2), + Colors.grey.shade50.withOpacity(0.1), + Colors.grey.shade400.withOpacity(0.2), + Colors.grey.shade300.withOpacity(0.4), + ], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + ), + ), + child: Padding( + padding: const EdgeInsets.all(1.0), + child: Container( + decoration: BoxDecoration( + shape: BoxShape.circle, + color: colorTheme.backgroundBase, + ), + ), + ), + ), + SizedBox( + height: 75, + width: 75, + child: ValueListenableBuilder( + valueListenable: _pinController, + builder: (context, value, child) { + return TweenAnimationBuilder( + tween: Tween( + begin: 0, + end: _pinController.text.length / 4, + ), + curve: Curves.ease, + duration: const Duration(milliseconds: 250), + builder: (context, value, _) => + CircularProgressIndicator( + backgroundColor: colorTheme.fillFaintPressed, + value: value, + color: colorTheme.primary400, + strokeWidth: 1.5, + ), + ); + }, + ), + ), + Icon( + Icons.lock, + color: colorTheme.textBase, + size: 30, + ), + ], + ), + ), + Text( + widget.isAuthenticating ? "Enter PIN" : "Set new PIN", + style: textTheme.bodyBold, + ), + const Padding(padding: EdgeInsets.all(12)), + Pinput( + length: 4, + showCursor: false, + useNativeKeyboard: false, + controller: _pinController, + defaultPinTheme: _pinPutDecoration, + submittedPinTheme: _pinPutDecoration.copyWith( + textStyle: textTheme.h3Bold, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10.0), + border: Border.all( + color: colorTheme.fillBase, + ), + ), + ), + followingPinTheme: _pinPutDecoration.copyWith( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10.0), + border: Border.all( + color: colorTheme.fillMuted, + ), + ), + ), + focusedPinTheme: _pinPutDecoration, + errorPinTheme: _pinPutDecoration.copyWith( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10.0), + border: Border.all( + color: colorTheme.warning400, + ), + ), + ), + forceErrorState: isPinValid, + obscureText: true, + obscuringCharacter: '*', + errorText: '', + onCompleted: (value) async { + await _confirmPin(_pinController.text); + }, + ), + isPortrait + ? const Spacer() + : const Padding(padding: EdgeInsets.all(12)), + CustomPinKeypad(controller: _pinController), + ], + ), + ); + } +} diff --git a/auth/lib/ui/settings/security_section_widget.dart b/auth/lib/ui/settings/security_section_widget.dart index 678a4ddfa1..8fa3249bf5 100644 --- a/auth/lib/ui/settings/security_section_widget.dart +++ b/auth/lib/ui/settings/security_section_widget.dart @@ -15,12 +15,15 @@ import 'package:ente_auth/ui/components/expandable_menu_item_widget.dart'; import 'package:ente_auth/ui/components/menu_item_widget.dart'; import 'package:ente_auth/ui/components/toggle_switch_widget.dart'; import 'package:ente_auth/ui/settings/common_settings.dart'; +import 'package:ente_auth/ui/settings/lock_screen/lock_screen_options.dart'; +import 'package:ente_auth/utils/auth_util.dart'; import 'package:ente_auth/utils/dialog_util.dart'; import 'package:ente_auth/utils/navigation_util.dart'; import 'package:ente_auth/utils/platform_util.dart'; import 'package:ente_auth/utils/toast_util.dart'; import 'package:ente_crypto_dart/ente_crypto_dart.dart'; import 'package:flutter/material.dart'; +import 'package:local_auth/local_auth.dart'; import 'package:logging/logging.dart'; class SecuritySectionWidget extends StatefulWidget { @@ -134,25 +137,34 @@ class _SecuritySectionWidgetState extends State { } children.addAll([ MenuItemWidget( - captionedTextWidget: CaptionedTextWidget( - title: l10n.lockscreen, + captionedTextWidget: const CaptionedTextWidget( + title: "App lock", ), - trailingWidget: ToggleSwitchWidget( - value: () => _config.shouldShowLockScreen(), - onChanged: () async { - final hasAuthenticated = await LocalAuthenticationService.instance - .requestLocalAuthForLockScreen( + trailingIcon: Icons.chevron_right_outlined, + trailingIconIsMuted: true, + onTap: () async { + if (await LocalAuthentication().isDeviceSupported()) { + final bool result = await requestAuthentication( context, - !_config.shouldShowLockScreen(), - context.l10n.authToChangeLockscreenSetting, - context.l10n.lockScreenEnablePreSteps, + "Please authenticate to change lockscreen setting", ); - if (hasAuthenticated) { - FocusScope.of(context).requestFocus(); - setState(() {}); + if (result) { + await Navigator.of(context).push( + MaterialPageRoute( + builder: (BuildContext context) { + return const LockScreenOptions(); + }, + ), + ); } - }, - ), + } else { + await showErrorDialog( + context, + "No system lock found", + "To enable app lock, please setup device passcode or screen lock in your system settings.", + ); + } + }, ), sectionOptionSpacing, ]); diff --git a/auth/lib/ui/tools/lock_screen.dart b/auth/lib/ui/tools/lock_screen.dart index b6e2126e1d..d28eefefe3 100644 --- a/auth/lib/ui/tools/lock_screen.dart +++ b/auth/lib/ui/tools/lock_screen.dart @@ -1,10 +1,16 @@ import 'dart:io'; +import 'dart:math'; +import 'package:ente_auth/core/configuration.dart'; import 'package:ente_auth/l10n/l10n.dart'; -import 'package:ente_auth/ui/common/gradient_button.dart'; +import 'package:ente_auth/theme/ente_theme.dart'; import 'package:ente_auth/ui/tools/app_lock.dart'; import 'package:ente_auth/utils/auth_util.dart'; +import 'package:ente_auth/utils/dialog_util.dart'; +import 'package:ente_auth/utils/lock_screen_settings.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:flutter_animate/flutter_animate.dart'; import 'package:logging/logging.dart'; class LockScreen extends StatefulWidget { @@ -20,11 +26,17 @@ class _LockScreenState extends State with WidgetsBindingObserver { bool _hasPlacedAppInBackground = false; bool _hasAuthenticationFailed = false; int? lastAuthenticatingTime; - + bool isTimerRunning = false; + int lockedTimeInSeconds = 0; + int invalidAttemptCount = 0; + int remainingTimeInSeconds = 0; + final _lockscreenSetting = LockScreenSettings.instance; + late Brightness _platformBrightness; @override void initState() { _logger.info("initiatingState"); super.initState(); + invalidAttemptCount = _lockscreenSetting.getInvalidAttemptCount(); WidgetsBinding.instance.addObserver(this); WidgetsBinding.instance.addPostFrameCallback((timeStamp) { if (isNonMobileIOSDevice()) { @@ -33,37 +45,133 @@ class _LockScreenState extends State with WidgetsBindingObserver { } _showLockScreen(source: "postFrameInit"); }); + _platformBrightness = + SchedulerBinding.instance.platformDispatcher.platformBrightness; } @override Widget build(BuildContext context) { + final colorTheme = getEnteColorScheme(context); + final textTheme = getEnteTextTheme(context); return Scaffold( - body: Center( - child: Column( - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - Stack( - alignment: Alignment.center, + body: GestureDetector( + onTap: () { + isTimerRunning ? null : _showLockScreen(source: "tap"); + }, + child: Container( + decoration: BoxDecoration( + image: DecorationImage( + opacity: _platformBrightness == Brightness.light ? 0.08 : 0.12, + image: const ExactAssetImage( + 'assets/loading_photos_background.png', + ), + fit: BoxFit.cover, + ), + ), + child: Center( + child: Column( children: [ - Opacity( - opacity: 0.2, - child: Image.asset('assets/loading_photos_background.png'), - ), + const Spacer(), SizedBox( - width: 180, - child: GradientButton( - text: context.l10n.unlock, - iconData: Icons.lock_open_outlined, - onTap: () async { - // ignore: unawaited_futures - _showLockScreen(source: "tapUnlock"); - }, + height: 120, + width: 120, + child: Stack( + alignment: Alignment.center, + children: [ + Container( + width: 82, + height: 82, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: LinearGradient( + colors: [ + Colors.grey.shade500.withOpacity(0.2), + Colors.grey.shade50.withOpacity(0.1), + Colors.grey.shade400.withOpacity(0.2), + Colors.grey.shade300.withOpacity(0.4), + ], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + ), + ), + child: Padding( + padding: const EdgeInsets.all(1.0), + child: Container( + decoration: BoxDecoration( + shape: BoxShape.circle, + color: colorTheme.backgroundBase, + ), + ), + ), + ), + SizedBox( + height: 75, + width: 75, + child: TweenAnimationBuilder( + tween: Tween( + begin: 0, + end: _getFractionOfTimeElapsed(), + ), + duration: const Duration(seconds: 1), + builder: (context, value, _) => + CircularProgressIndicator( + backgroundColor: colorTheme.fillFaintPressed, + value: value, + color: colorTheme.primary400, + strokeWidth: 1.5, + ), + ), + ), + Icon( + Icons.lock, + size: 30, + color: colorTheme.textBase, + ), + ], ), ), + const Spacer(), + isTimerRunning + ? Stack( + alignment: Alignment.center, + children: [ + Text( + "Too many incorrect attempts", + style: textTheme.small, + ) + .animate( + delay: const Duration(milliseconds: 2000), + ) + .fadeOut( + duration: 400.ms, + curve: Curves.easeInOutCirc, + ), + Text( + _formatTime(remainingTimeInSeconds), + style: textTheme.small, + ) + .animate( + delay: const Duration(milliseconds: 2250), + ) + .fadeIn( + duration: 400.ms, + curve: Curves.easeInOutCirc, + ), + ], + ) + : GestureDetector( + onTap: () => _showLockScreen(source: "tap"), + child: Text( + "Tap to unlock", + style: textTheme.small, + ), + ), + const Padding( + padding: EdgeInsets.only(bottom: 24), + ), ], ), - ], + ), ), ), ); @@ -90,10 +198,17 @@ class _LockScreenState extends State with WidgetsBindingObserver { if (!_hasAuthenticationFailed && !didAuthInLast5Seconds) { // Show the lock screen again only if the app is resuming from the // background, and not when the lock screen was explicitly dismissed - Future.delayed( - Duration.zero, - () => _showLockScreen(source: "lifeCycle"), - ); + if (_lockscreenSetting.getlastInvalidAttemptTime() > + DateTime.now().millisecondsSinceEpoch && + !_isShowingLockScreen) { + final int time = (_lockscreenSetting.getlastInvalidAttemptTime() - + DateTime.now().millisecondsSinceEpoch) ~/ + 1000; + Future.delayed(Duration.zero, () { + startLockTimer(time); + _showLockScreen(source: "lifeCycle"); + }); + } } else { _hasAuthenticationFailed = false; // Reset failure state } @@ -115,24 +230,119 @@ class _LockScreenState extends State with WidgetsBindingObserver { super.dispose(); } + Future startLockTimer(int timeInSeconds) async { + if (isTimerRunning) { + return; + } + + setState(() { + isTimerRunning = true; + remainingTimeInSeconds = timeInSeconds; + }); + + while (remainingTimeInSeconds > 0) { + await Future.delayed(const Duration(seconds: 1)); + setState(() { + remainingTimeInSeconds--; + }); + } + + setState(() { + isTimerRunning = false; + }); + } + + double _getFractionOfTimeElapsed() { + final int totalLockedTime = + lockedTimeInSeconds = pow(2, invalidAttemptCount - 5).toInt() * 30; + if (remainingTimeInSeconds == 0) return 1; + + return 1 - remainingTimeInSeconds / totalLockedTime; + } + + String _formatTime(int seconds) { + final int hours = seconds ~/ 3600; + final int minutes = (seconds % 3600) ~/ 60; + final int remainingSeconds = seconds % 60; + + if (hours > 0) { + return "${hours}h ${minutes}m"; + } else if (minutes > 0) { + return "${minutes}m ${remainingSeconds}s"; + } else { + return "${remainingSeconds}s"; + } + } + + Future _autoLogoutOnMaxInvalidAttempts() async { + _logger.info("Auto logout on max invalid attempts"); + await _lockscreenSetting.setInvalidAttemptCount(0); + await showErrorDialog( + context, + "Too many incorrect attempts", + "Please login again", + isDismissable: false, + ); + Navigator.of(context, rootNavigator: true).pop('dialog'); + Navigator.of(context).popUntil((route) => route.isFirst); + final dialog = createProgressDialog(context, "Logging out ..."); + await dialog.show(); + await Configuration.instance.logout(); + await dialog.hide(); + } + Future _showLockScreen({String source = ''}) async { - final int id = DateTime.now().millisecondsSinceEpoch; - _logger.info("Showing lock screen $source $id"); + final int currentTimestamp = DateTime.now().millisecondsSinceEpoch; + _logger.info("Showing lock screen $source $currentTimestamp"); try { + if (currentTimestamp < _lockscreenSetting.getlastInvalidAttemptTime() && + !_isShowingLockScreen) { + final int remainingTime = + (_lockscreenSetting.getlastInvalidAttemptTime() - + currentTimestamp) ~/ + 1000; + + await startLockTimer(remainingTime); + } _isShowingLockScreen = true; - final result = await requestAuthentication( - context, - context.l10n.authToViewSecrets, - ); - _logger.finest("LockScreen Result $result $id"); + final result = isTimerRunning + ? false + : await requestAuthentication( + context, + context.l10n.authToViewSecrets, + isOpeningApp: true, + ); + _logger.finest("LockScreen Result $result $currentTimestamp"); _isShowingLockScreen = false; if (result) { lastAuthenticatingTime = DateTime.now().millisecondsSinceEpoch; AppLock.of(context)!.didUnlock(); + await _lockscreenSetting.setInvalidAttemptCount(0); + setState(() { + lockedTimeInSeconds = 15; + isTimerRunning = false; + }); } else { if (!_hasPlacedAppInBackground) { // Treat this as a failure only if user did not explicitly // put the app in background + if (_lockscreenSetting.getInvalidAttemptCount() > 4 && + invalidAttemptCount != + _lockscreenSetting.getInvalidAttemptCount()) { + invalidAttemptCount = _lockscreenSetting.getInvalidAttemptCount(); + + if (invalidAttemptCount > 9) { + await _autoLogoutOnMaxInvalidAttempts(); + return; + } + + lockedTimeInSeconds = pow(2, invalidAttemptCount - 5).toInt() * 30; + await _lockscreenSetting.setLastInvalidAttemptTime( + DateTime.now().millisecondsSinceEpoch + + lockedTimeInSeconds * 1000, + ); + await startLockTimer(lockedTimeInSeconds); + } _hasAuthenticationFailed = true; _logger.info("Authentication failed"); } diff --git a/auth/lib/ui/two_factor_authentication_page.dart b/auth/lib/ui/two_factor_authentication_page.dart index 068f4255d0..14b352a95f 100644 --- a/auth/lib/ui/two_factor_authentication_page.dart +++ b/auth/lib/ui/two_factor_authentication_page.dart @@ -4,7 +4,7 @@ import 'package:ente_auth/services/user_service.dart'; import 'package:ente_auth/ui/lifecycle_event_handler.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:pinput/pin_put/pin_put.dart'; +import 'package:pinput/pinput.dart'; class TwoFactorAuthenticationPage extends StatefulWidget { final String sessionID; @@ -19,9 +19,13 @@ class TwoFactorAuthenticationPage extends StatefulWidget { class _TwoFactorAuthenticationPageState extends State { final _pinController = TextEditingController(); - final _pinPutDecoration = BoxDecoration( - border: Border.all(color: const Color.fromRGBO(45, 194, 98, 1.0)), - borderRadius: BorderRadius.circular(15.0), + final _pinPutDecoration = PinTheme( + height: 45, + width: 45, + decoration: BoxDecoration( + border: Border.all(color: const Color.fromRGBO(45, 194, 98, 1.0)), + borderRadius: BorderRadius.circular(15.0), + ), ); String _code = ""; late LifecycleEventHandler _lifecycleEventHandler; @@ -79,9 +83,9 @@ class _TwoFactorAuthenticationPageState const Padding(padding: EdgeInsets.all(32)), Padding( padding: const EdgeInsets.fromLTRB(40, 0, 40, 0), - child: PinPut( - fieldsCount: 6, - onSubmit: (String code) { + child: Pinput( + length: 6, + onCompleted: (String code) { _verifyTwoFactorCode(code); }, onChanged: (String pin) { @@ -90,21 +94,34 @@ class _TwoFactorAuthenticationPageState }); }, controller: _pinController, - submittedFieldDecoration: _pinPutDecoration.copyWith( - borderRadius: BorderRadius.circular(20.0), - ), - selectedFieldDecoration: _pinPutDecoration, - followingFieldDecoration: _pinPutDecoration.copyWith( - borderRadius: BorderRadius.circular(5.0), - border: Border.all( - color: const Color.fromRGBO(45, 194, 98, 0.5), + submittedPinTheme: _pinPutDecoration.copyWith( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20.0), + border: Border.all( + color: const Color.fromRGBO(45, 194, 98, 0.5), + ), ), ), - inputDecoration: const InputDecoration( - focusedBorder: InputBorder.none, - border: InputBorder.none, - counterText: '', + // submittedFieldDecoration: _pinPutDecoration.copyWith( + // borderRadius: BorderRadius.circular(20.0), + // ), + // selectedFieldDecoration: _pinPutDecoration, + defaultPinTheme: _pinPutDecoration, + followingPinTheme: _pinPutDecoration.copyWith( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10.0), + border: Border.all( + color: const Color.fromRGBO(45, 194, 98, 0.5), + ), + ), ), + // followingFieldDecoration: _pinPutDecoration.copyWith( + // borderRadius: BorderRadius.circular(5.0), + // border: Border.all( + // color: const Color.fromRGBO(45, 194, 98, 0.5), + // ), + // ), + autofocus: true, ), ), diff --git a/auth/lib/utils/auth_util.dart b/auth/lib/utils/auth_util.dart index c2d2f5afa0..97c4bb2da3 100644 --- a/auth/lib/utils/auth_util.dart +++ b/auth/lib/utils/auth_util.dart @@ -1,6 +1,8 @@ import 'dart:io'; import 'package:ente_auth/l10n/l10n.dart'; +import 'package:ente_auth/services/local_authentication_service.dart'; +import 'package:ente_auth/utils/lock_screen_settings.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter_local_authentication/flutter_local_authentication.dart'; import 'package:local_auth/local_auth.dart'; @@ -8,8 +10,24 @@ import 'package:local_auth_android/local_auth_android.dart'; import 'package:local_auth_darwin/types/auth_messages_ios.dart'; import 'package:logging/logging.dart'; -Future requestAuthentication(BuildContext context, String reason) async { +Future requestAuthentication( + BuildContext context, + String reason, { + bool isOpeningApp = false, +}) async { Logger("AuthUtil").info("Requesting authentication"); + + final String? savedPin = await LockScreenSettings.instance.getPin(); + final String? savedPassword = await LockScreenSettings.instance.getPassword(); + if (savedPassword != null || savedPin != null) { + return await LocalAuthenticationService.instance + .requestEnteAuthForLockScreen( + context, + savedPin, + savedPassword, + isOnOpeningApp: isOpeningApp, + ); + } if (Platform.isMacOS || Platform.isLinux) { return await FlutterLocalAuthentication().authenticate(); } else { diff --git a/auth/lib/utils/lock_screen_settings.dart b/auth/lib/utils/lock_screen_settings.dart new file mode 100644 index 0000000000..5d2aff80ea --- /dev/null +++ b/auth/lib/utils/lock_screen_settings.dart @@ -0,0 +1,118 @@ +import "package:shared_preferences/shared_preferences.dart"; + +class LockScreenSettings { + LockScreenSettings._privateConstructor(); + + static final LockScreenSettings instance = + LockScreenSettings._privateConstructor(); + static const password = "ls_password"; + static const pin = "ls_pin"; + static const saltKey = "ls_salt"; + static const keyInvalidAttempts = "ls_invalid_attempts"; + static const lastInvalidAttemptTime = "ls_last_invalid_attempt_time"; + + late SharedPreferences _preferences; + + Future init() async { + _preferences = await SharedPreferences.getInstance(); + } + + Future setLastInvalidAttemptTime(int time) async { + await _preferences.setInt(lastInvalidAttemptTime, time); + } + + int getlastInvalidAttemptTime() { + return _preferences.getInt(lastInvalidAttemptTime) ?? 0; + } + + int getInvalidAttemptCount() { + return _preferences.getInt(keyInvalidAttempts) ?? 0; + } + + Future setInvalidAttemptCount(int count) async { + await _preferences.setInt(keyInvalidAttempts, count); + } + + // static Uint8List _generateSalt() { + // return Sodium.randombytesBuf(Sodium.cryptoPwhashSaltbytes); + // } + + Future setPin(String userPin) async { + //await _secureStorage.delete(key: saltKey); + await _preferences.setString(pin, userPin); + await _preferences.remove(password); + // final salt = _generateSalt(); + // final hash = cryptoPwHash({ + // "password": utf8.encode(userPin), + // "salt": salt, + // "opsLimit": Sodium.cryptoPwhashOpslimitInteractive, + // "memLimit": Sodium.cryptoPwhashMemlimitInteractive, + // }); + + // final String saltPin = base64Encode(salt); + // final String hashedPin = base64Encode(hash); + + // await _secureStorage.write(key: saltKey, value: saltPin); + // await _secureStorage.write(key: pin, value: hashedPin); + // await _secureStorage.delete(key: password); + + return; + } + + // Future getSalt() async { + // final String? salt = await _secureStorage.read(key: saltKey); + // if (salt == null) return null; + // return base64Decode(salt); + // } + + Future getPin() async { + return _preferences.getString(pin); + // return _secureStorage.read(key: pin); + } + + Future setPassword(String pass) async { + await _preferences.setString(password, pass); + await _preferences.remove(pin); + // await _secureStorage.delete(key: saltKey); + + // final salt = _generateSalt(); + // final hash = cryptoPwHash({ + // "password": utf8.encode(pass), + // "salt": salt, + // "opsLimit": Sodium.cryptoPwhashOpslimitInteractive, + // "memLimit": Sodium.cryptoPwhashMemlimitInteractive, + // }); + + // final String saltPassword = base64Encode(salt); + // final String hashPassword = base64Encode(hash); + + // await _secureStorage.write(key: saltKey, value: saltPassword); + // await _secureStorage.write(key: password, value: hashPassword); + // await _secureStorage.delete(key: pin); + + return; + } + + Future getPassword() async { + return _preferences.getString(password); + // return _secureStorage.read(key: password); + } + + Future removePinAndPassword() async { + await _preferences.remove(pin); + await _preferences.remove(password); + // await _secureStorage.delete(key: saltKey); + // await _secureStorage.delete(key: pin); + // await _secureStorage.delete(key: password); + } + + Future isPinSet() async { + return _preferences.containsKey(pin); + // return await _secureStorage.containsKey(key: pin); + } + + Future isPasswordSet() async { + return _preferences.containsKey(password); + // return await _secureStorage.containsKey(key: password); + } +} diff --git a/auth/pubspec.yaml b/auth/pubspec.yaml index d1881bf54d..839f6be877 100644 --- a/auth/pubspec.yaml +++ b/auth/pubspec.yaml @@ -41,6 +41,7 @@ dependencies: fk_user_agent: ^2.1.0 flutter: sdk: flutter + flutter_animate: ^4.1.0 flutter_bloc: ^8.0.1 flutter_context_menu: ^0.1.3 flutter_displaymode: ^0.6.0 @@ -77,7 +78,7 @@ dependencies: password_strength: ^0.2.0 path: ^1.8.3 path_provider: ^2.0.11 - pinput: ^1.2.2 + pinput: ^2.0.2 pointycastle: ^3.7.3 privacy_screen: ^0.0.6 protobuf: ^3.0.0