diff --git a/app/android/app/src/main/kotlin/com/nkming/nc_photos/MainActivity.kt b/app/android/app/src/main/kotlin/com/nkming/nc_photos/MainActivity.kt index fed93a58..ad284356 100644 --- a/app/android/app/src/main/kotlin/com/nkming/nc_photos/MainActivity.kt +++ b/app/android/app/src/main/kotlin/com/nkming/nc_photos/MainActivity.kt @@ -8,14 +8,14 @@ import com.nkming.nc_photos.np_android_core.UriUtil import com.nkming.nc_photos.np_android_core.logE import com.nkming.nc_photos.np_android_core.logI import com.nkming.nc_photos.np_platform_image_processor.NpPlatformImageProcessorPlugin -import io.flutter.embedding.android.FlutterActivity +import io.flutter.embedding.android.FlutterFragmentActivity import io.flutter.embedding.engine.FlutterEngine import io.flutter.plugin.common.EventChannel import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel import java.net.URLEncoder -class MainActivity : FlutterActivity(), MethodChannel.MethodCallHandler { +class MainActivity : FlutterFragmentActivity(), MethodChannel.MethodCallHandler { companion object { private const val METHOD_CHANNEL = "com.nkming.nc_photos/activity" diff --git a/app/lib/animation_util.dart b/app/lib/animation_util.dart new file mode 100644 index 00000000..7fbb477a --- /dev/null +++ b/app/lib/animation_util.dart @@ -0,0 +1,18 @@ +double tremblingTransform(int count, double t) { + final tt = (t * count) % 1; + return _tremblingTransformT(tt); +} + +double _tremblingTransformT(double t) { + if (t <= 0 || t >= 1) { + return 0; + } + final x = 4 * t; + if (x < 1) { + return -x; + } else if (x < 3) { + return x - 1; + } else { + return 4 - x; + } +} diff --git a/app/lib/bloc_util.dart b/app/lib/bloc_util.dart index 0522cc5a..e4bb2a20 100644 --- a/app/lib/bloc_util.dart +++ b/app/lib/bloc_util.dart @@ -12,6 +12,7 @@ class BlocListenerT, S, T> extends SingleChildStatelessWidget { const BlocListenerT({ super.key, + super.child, required this.selector, required this.listener, }); diff --git a/app/lib/controller/pref_controller.dart b/app/lib/controller/pref_controller.dart index 09c3306e..bf09b499 100644 --- a/app/lib/controller/pref_controller.dart +++ b/app/lib/controller/pref_controller.dart @@ -239,9 +239,36 @@ class PrefController { _c.pref.getSecondarySeedColor()?.run(Color.new)); } +@npSubjectAccessor class SecurePrefController { SecurePrefController(this._c); + Future setProtectedPageAuthType(ProtectedPageAuthType? value) => + _setOrRemove( + controller: _protectedPageAuthTypeController, + setter: (pref, value) => pref.setProtectedPageAuthType(value.index), + remover: (pref) => pref.setProtectedPageAuthType(null), + value: value, + ); + + Future setProtectedPageAuthPin(CiString? value) => + _setOrRemove( + controller: _protectedPageAuthPinController, + setter: (pref, value) => + pref.setProtectedPageAuthPin(value.toCaseInsensitiveString()), + remover: (pref) => pref.setProtectedPageAuthPin(null), + value: value, + ); + + Future setProtectedPageAuthPassword(CiString? value) => + _setOrRemove( + controller: _protectedPageAuthPasswordController, + setter: (pref, value) => + pref.setProtectedPageAuthPassword(value.toCaseInsensitiveString()), + remover: (pref) => pref.setProtectedPageAuthPassword(null), + value: value, + ); + // ignore: unused_element Future _set({ required BehaviorSubject controller, @@ -273,6 +300,17 @@ class SecurePrefController { ); final DiContainer _c; + @npSubjectAccessor + late final _protectedPageAuthTypeController = BehaviorSubject.seeded(_c + .securePref + .getProtectedPageAuthType() + ?.let((e) => ProtectedPageAuthType.values[e])); + @npSubjectAccessor + late final _protectedPageAuthPinController = + BehaviorSubject.seeded(_c.securePref.getProtectedPageAuthPin()?.toCi()); + @npSubjectAccessor + late final _protectedPageAuthPasswordController = BehaviorSubject.seeded( + _c.securePref.getProtectedPageAuthPassword()?.toCi()); } Future _doSet({ diff --git a/app/lib/controller/pref_controller.g.dart b/app/lib/controller/pref_controller.g.dart index 14990353..63b6abfb 100644 --- a/app/lib/controller/pref_controller.g.dart +++ b/app/lib/controller/pref_controller.g.dart @@ -142,3 +142,32 @@ extension $PrefControllerNpSubjectAccessor on PrefController { secondarySeedColor.distinct().skip(1); Color? get secondarySeedColorValue => _secondarySeedColorController.value; } + +extension $SecurePrefControllerNpSubjectAccessor on SecurePrefController { + // _protectedPageAuthTypeController + ValueStream get protectedPageAuthType => + _protectedPageAuthTypeController.stream; + Stream get protectedPageAuthTypeNew => + protectedPageAuthType.skip(1); + Stream get protectedPageAuthTypeChange => + protectedPageAuthType.distinct().skip(1); + ProtectedPageAuthType? get protectedPageAuthTypeValue => + _protectedPageAuthTypeController.value; +// _protectedPageAuthPinController + ValueStream get protectedPageAuthPin => + _protectedPageAuthPinController.stream; + Stream get protectedPageAuthPinNew => protectedPageAuthPin.skip(1); + Stream get protectedPageAuthPinChange => + protectedPageAuthPin.distinct().skip(1); + CiString? get protectedPageAuthPinValue => + _protectedPageAuthPinController.value; +// _protectedPageAuthPasswordController + ValueStream get protectedPageAuthPassword => + _protectedPageAuthPasswordController.stream; + Stream get protectedPageAuthPasswordNew => + protectedPageAuthPassword.skip(1); + Stream get protectedPageAuthPasswordChange => + protectedPageAuthPassword.distinct().skip(1); + CiString? get protectedPageAuthPasswordValue => + _protectedPageAuthPasswordController.value; +} diff --git a/app/lib/entity/pref.dart b/app/lib/entity/pref.dart index 7117f9cb..411c0e45 100644 --- a/app/lib/entity/pref.dart +++ b/app/lib/entity/pref.dart @@ -109,6 +109,9 @@ enum PrefKey implements PrefKeyInterface { isVideoPlayerMute, isVideoPlayerLoop, secondarySeedColor, + protectedPageAuthType, + protectedPageAuthPin, + protectedPageAuthPassword, ; @override @@ -187,6 +190,12 @@ enum PrefKey implements PrefKeyInterface { return "isVideoPlayerLoop"; case PrefKey.secondarySeedColor: return "secondarySeedColor"; + case PrefKey.protectedPageAuthType: + return "protectedPageAuthType"; + case PrefKey.protectedPageAuthPin: + return "protectedPageAuthPin"; + case PrefKey.protectedPageAuthPassword: + return "protectedPageAuthPassword"; } } } diff --git a/app/lib/entity/pref/extension.dart b/app/lib/entity/pref/extension.dart index 18dbdf66..df4ac543 100644 --- a/app/lib/entity/pref/extension.dart +++ b/app/lib/entity/pref/extension.dart @@ -290,6 +290,44 @@ extension PrefExtension on Pref { (key, value) => provider.setInt(key, value)); } } + + int? getProtectedPageAuthType() => + provider.getInt(PrefKey.protectedPageAuthType); + int getProtectedPageAuthTypeOr(int def) => getProtectedPageAuthType() ?? def; + Future setProtectedPageAuthType(int? value) { + if (value == null) { + return _remove(PrefKey.protectedPageAuthType); + } else { + return _set(PrefKey.protectedPageAuthType, value, + (key, value) => provider.setInt(key, value)); + } + } + + String? getProtectedPageAuthPin() => + provider.getString(PrefKey.protectedPageAuthPin); + String getProtectedPageAuthPinOr(String def) => + getProtectedPageAuthPin() ?? def; + Future setProtectedPageAuthPin(String? value) { + if (value == null) { + return _remove(PrefKey.protectedPageAuthPin); + } else { + return _set(PrefKey.protectedPageAuthPin, value, + (key, value) => provider.setString(key, value)); + } + } + + String? getProtectedPageAuthPassword() => + provider.getString(PrefKey.protectedPageAuthPassword); + String getProtectedPageAuthPasswordOr(String def) => + getProtectedPageAuthPassword() ?? def; + Future setProtectedPageAuthPassword(String? value) { + if (value == null) { + return _remove(PrefKey.protectedPageAuthPassword); + } else { + return _set(PrefKey.protectedPageAuthPassword, value, + (key, value) => provider.setString(key, value)); + } + } } extension AccountPrefExtension on AccountPref { diff --git a/app/lib/l10n/app_en.arb b/app/lib/l10n/app_en.arb index f6dc99ba..92eb809d 100644 --- a/app/lib/l10n/app_en.arb +++ b/app/lib/l10n/app_en.arb @@ -1435,6 +1435,14 @@ } } }, + "appLockUnlockHint": "Unlock the app", + "@appLockUnlockHint": { + "description": "Unlock app via selected means (e.g., password) in case app lock is enabled by user" + }, + "appLockUnlockWrongPassword": "Incorrect password", + "@appLockUnlockWrongPassword": { + "description": "Unlock app via selected means (e.g., password) in case app lock is enabled by user" + }, "errorUnauthenticated": "Unauthenticated access. Please sign-in again if the problem continues", "@errorUnauthenticated": { diff --git a/app/lib/l10n/untranslated-messages.txt b/app/lib/l10n/untranslated-messages.txt index f9c35c48..ffeef471 100644 --- a/app/lib/l10n/untranslated-messages.txt +++ b/app/lib/l10n/untranslated-messages.txt @@ -227,6 +227,8 @@ "contributorsTooltip", "setAsTooltip", "deleteAccountConfirmDialogText", + "appLockUnlockHint", + "appLockUnlockWrongPassword", "errorUnauthenticated", "errorDisconnected", "errorLocked", @@ -240,7 +242,9 @@ "cs": [ "settingsThemePrimaryColor", "settingsThemeSecondaryColor", - "settingsThemePresets" + "settingsThemePresets", + "appLockUnlockHint", + "appLockUnlockWrongPassword" ], "de": [ @@ -272,7 +276,9 @@ "searchFilterBubbleTypeImageText", "imageSaveOptionDialogTitle", "imageSaveOptionDialogContent", - "loopTooltip" + "loopTooltip", + "appLockUnlockHint", + "appLockUnlockWrongPassword" ], "el": [ @@ -387,25 +393,33 @@ "accountSettingsTooltip", "contributorsTooltip", "setAsTooltip", - "deleteAccountConfirmDialogText" + "deleteAccountConfirmDialogText", + "appLockUnlockHint", + "appLockUnlockWrongPassword" ], "es": [ "settingsThemePrimaryColor", "settingsThemeSecondaryColor", - "settingsThemePresets" + "settingsThemePresets", + "appLockUnlockHint", + "appLockUnlockWrongPassword" ], "fi": [ "settingsThemePrimaryColor", "settingsThemeSecondaryColor", - "settingsThemePresets" + "settingsThemePresets", + "appLockUnlockHint", + "appLockUnlockWrongPassword" ], "fr": [ "settingsThemePrimaryColor", "settingsThemeSecondaryColor", - "settingsThemePresets" + "settingsThemePresets", + "appLockUnlockHint", + "appLockUnlockWrongPassword" ], "it": [ @@ -416,7 +430,9 @@ "settingsThemePresets", "unmuteTooltip", "slideshowTooltip", - "enhanceColorPopTitle" + "enhanceColorPopTitle", + "appLockUnlockHint", + "appLockUnlockWrongPassword" ], "nl": [ @@ -764,6 +780,8 @@ "contributorsTooltip", "setAsTooltip", "deleteAccountConfirmDialogText", + "appLockUnlockHint", + "appLockUnlockWrongPassword", "errorUnauthenticated", "errorDisconnected", "errorLocked", @@ -781,7 +799,9 @@ "settingsThemePresets", "enhanceColorPopTitle", "imageEditTransformOrientationClockwise", - "imageEditTransformOrientationCounterclockwise" + "imageEditTransformOrientationCounterclockwise", + "appLockUnlockHint", + "appLockUnlockWrongPassword" ], "pt": [ @@ -807,19 +827,25 @@ "accountSettingsTooltip", "contributorsTooltip", "setAsTooltip", - "deleteAccountConfirmDialogText" + "deleteAccountConfirmDialogText", + "appLockUnlockHint", + "appLockUnlockWrongPassword" ], "ru": [ "settingsThemePrimaryColor", "settingsThemeSecondaryColor", - "settingsThemePresets" + "settingsThemePresets", + "appLockUnlockHint", + "appLockUnlockWrongPassword" ], "tr": [ "settingsThemePrimaryColor", "settingsThemeSecondaryColor", - "settingsThemePresets" + "settingsThemePresets", + "appLockUnlockHint", + "appLockUnlockWrongPassword" ], "zh": [ @@ -950,7 +976,9 @@ "accountSettingsTooltip", "contributorsTooltip", "setAsTooltip", - "deleteAccountConfirmDialogText" + "deleteAccountConfirmDialogText", + "appLockUnlockHint", + "appLockUnlockWrongPassword" ], "zh_Hant": [ @@ -1081,6 +1109,8 @@ "accountSettingsTooltip", "contributorsTooltip", "setAsTooltip", - "deleteAccountConfirmDialogText" + "deleteAccountConfirmDialogText", + "appLockUnlockHint", + "appLockUnlockWrongPassword" ] } diff --git a/app/lib/protected_page_handler.dart b/app/lib/protected_page_handler.dart new file mode 100644 index 00000000..3872dade --- /dev/null +++ b/app/lib/protected_page_handler.dart @@ -0,0 +1,149 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:local_auth/local_auth.dart'; +import 'package:logging/logging.dart'; +import 'package:nc_photos/app_localizations.dart'; +import 'package:nc_photos/controller/pref_controller.dart'; +import 'package:nc_photos/widget/protected_page_password_auth_dialog.dart'; +import 'package:nc_photos/widget/protected_page_pin_auth_dialog.dart'; +import 'package:np_codegen/np_codegen.dart'; +import 'package:np_string/np_string.dart'; + +part 'protected_page_handler.g.dart'; + +enum ProtectedPageAuthType { + biometric, + pin, + password, + ; +} + +class ProtectedPageAuthException implements Exception { + const ProtectedPageAuthException([this.message]); + + @override + String toString() => "ProtectedPageAuthException: $message"; + + final dynamic message; +} + +extension ProtectedPageBuildContextExtension on NavigatorState { + Future pushReplacementProtected( + String routeName, { + U? result, + Object? arguments, + }) async { + if (await _auth()) { + return pushReplacementNamed(routeName, + arguments: arguments, result: result); + } else { + throw const ProtectedPageAuthException(); + } + } + + Future pushProtected( + String routeName, { + Object? arguments, + }) async { + if (await _auth()) { + return pushNamed(routeName, arguments: arguments); + } else { + throw const ProtectedPageAuthException(); + } + } + + Future _auth() async { + final securePrefController = context.read(); + switch (securePrefController.protectedPageAuthTypeValue) { + case null: + // unprotected + return true; + case ProtectedPageAuthType.biometric: + return _authBiometric(securePrefController); + case ProtectedPageAuthType.pin: + return _authPin(securePrefController); + case ProtectedPageAuthType.password: + return _authPassword(securePrefController); + } + } + + Future _authBiometric(SecurePrefController securePrefController) async { + if (await _BiometricAuthHandler().auth()) { + return true; + } else { + if (securePrefController.protectedPageAuthPasswordValue != null) { + return _authPassword(securePrefController); + } else { + return _authPin(securePrefController); + } + } + } + + Future _authPin(SecurePrefController securePrefController) => + _PinAuthHandler(context, securePrefController.protectedPageAuthPinValue!) + .auth(); + + Future _authPassword(SecurePrefController securePrefController) => + _PasswordAuthHandler( + context, securePrefController.protectedPageAuthPasswordValue!) + .auth(); +} + +abstract class _AuthHandler { + Future auth(); +} + +@npLog +class _BiometricAuthHandler implements _AuthHandler { + @override + Future auth() async { + try { + final localAuth = LocalAuthentication(); + final available = await localAuth.getAvailableBiometrics(); + if (available.isEmpty) { + return false; + } + return await localAuth.authenticate( + localizedReason: L10n.global().appLockUnlockHint, + options: const AuthenticationOptions( + biometricOnly: true, + ), + ); + } catch (e, stackTrace) { + _log.severe("[auth] Exception", e, stackTrace); + return false; + } + } +} + +class _PinAuthHandler implements _AuthHandler { + const _PinAuthHandler(this.context, this.pin); + + @override + Future auth() async { + final result = await showDialog( + context: context, + builder: (context) => ProtectedPagePinAuthDialog(pin: pin), + ); + return result == true; + } + + final BuildContext context; + final CiString pin; +} + +class _PasswordAuthHandler implements _AuthHandler { + const _PasswordAuthHandler(this.context, this.password); + + @override + Future auth() async { + final result = await showDialog( + context: context, + builder: (context) => ProtectedPagePasswordAuthDialog(password: password), + ); + return result == true; + } + + final BuildContext context; + final CiString password; +} diff --git a/app/lib/protected_page_handler.g.dart b/app/lib/protected_page_handler.g.dart new file mode 100644 index 00000000..3d7b343d --- /dev/null +++ b/app/lib/protected_page_handler.g.dart @@ -0,0 +1,14 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'protected_page_handler.dart'; + +// ************************************************************************** +// NpLogGenerator +// ************************************************************************** + +extension _$_BiometricAuthHandlerNpLog on _BiometricAuthHandler { + // ignore: unused_element + Logger get _log => log; + + static final log = Logger("protected_page_handler._BiometricAuthHandler"); +} diff --git a/app/lib/widget/protected_page_password_auth_dialog.dart b/app/lib/widget/protected_page_password_auth_dialog.dart new file mode 100644 index 00000000..efa3d82e --- /dev/null +++ b/app/lib/widget/protected_page_password_auth_dialog.dart @@ -0,0 +1,100 @@ +import 'package:copy_with/copy_with.dart'; +import 'package:crypto/crypto.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:logging/logging.dart'; +import 'package:nc_photos/animation_util.dart'; +import 'package:nc_photos/app_localizations.dart'; +import 'package:nc_photos/bloc_util.dart'; +import 'package:nc_photos/k.dart' as k; +import 'package:np_codegen/np_codegen.dart'; +import 'package:np_common/unique.dart'; +import 'package:np_string/np_string.dart'; +import 'package:to_string/to_string.dart'; + +part 'protected_page_password_auth_dialog.g.dart'; +part 'protected_page_password_auth_dialog/bloc.dart'; +part 'protected_page_password_auth_dialog/state_event.dart'; +part 'protected_page_password_auth_dialog/view.dart'; + +class ProtectedPagePasswordAuthDialog extends StatelessWidget { + const ProtectedPagePasswordAuthDialog({ + super.key, + required this.password, + }); + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => _Bloc( + password: password, + ), + child: _WrappedProtectedPagePasswordAuthDialog(), + ); + } + + final CiString password; +} + +class _WrappedProtectedPagePasswordAuthDialog extends StatelessWidget { + @override + Widget build(BuildContext context) { + return MultiBlocListener( + listeners: [ + _BlocListenerT( + selector: (state) => state.isAuthorized, + listener: (context, isAuthorized) { + if (isAuthorized.value == true) { + Navigator.of(context).pop(true); + } + }, + ), + ], + child: AlertDialog( + title: Text(L10n.global().appLockUnlockHint), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + keyboardType: TextInputType.text, + obscureText: true, + enableSuggestions: false, + autocorrect: false, + decoration: InputDecoration( + hintText: L10n.global().passwordInputHint, + ), + onSubmitted: (value) { + context.addEvent(_Submit(value)); + }, + ), + _BlocSelector( + selector: (state) => state.isAuthorized, + builder: (context, isAuthorized) { + if (isAuthorized.value == false) { + return const Padding( + padding: EdgeInsets.only(top: 8, bottom: 4), + child: _ErrorNotice(), + ); + } else { + return const SizedBox.shrink(); + } + }, + ), + ], + ), + ), + ); + } +} + +// typedef _BlocBuilder = BlocBuilder<_Bloc, _State>; +// typedef _BlocListener = BlocListener<_Bloc, _State>; +typedef _BlocListenerT = BlocListenerT<_Bloc, _State, T>; +typedef _BlocSelector = BlocSelector<_Bloc, _State, T>; + +extension on BuildContext { + _Bloc get bloc => read<_Bloc>(); + // _State get state => bloc.state; + void addEvent(_Event event) => bloc.add(event); +} diff --git a/app/lib/widget/protected_page_password_auth_dialog.g.dart b/app/lib/widget/protected_page_password_auth_dialog.g.dart new file mode 100644 index 00000000..afa29997 --- /dev/null +++ b/app/lib/widget/protected_page_password_auth_dialog.g.dart @@ -0,0 +1,63 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'protected_page_password_auth_dialog.dart'; + +// ************************************************************************** +// CopyWithLintRuleGenerator +// ************************************************************************** + +// ignore_for_file: library_private_types_in_public_api, duplicate_ignore + +// ************************************************************************** +// CopyWithGenerator +// ************************************************************************** + +abstract class $_StateCopyWithWorker { + _State call({Unique? isAuthorized}); +} + +class _$_StateCopyWithWorkerImpl implements $_StateCopyWithWorker { + _$_StateCopyWithWorkerImpl(this.that); + + @override + _State call({dynamic isAuthorized}) { + return _State( + isAuthorized: isAuthorized as Unique? ?? that.isAuthorized); + } + + final _State that; +} + +extension $_StateCopyWith on _State { + $_StateCopyWithWorker get copyWith => _$copyWith; + $_StateCopyWithWorker get _$copyWith => _$_StateCopyWithWorkerImpl(this); +} + +// ************************************************************************** +// NpLogGenerator +// ************************************************************************** + +extension _$_BlocNpLog on _Bloc { + // ignore: unused_element + Logger get _log => log; + + static final log = Logger("widget.protected_page_password_auth_dialog._Bloc"); +} + +// ************************************************************************** +// ToStringGenerator +// ************************************************************************** + +extension _$_StateToString on _State { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_State {isAuthorized: $isAuthorized}"; + } +} + +extension _$_SubmitToString on _Submit { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_Submit {value: $value}"; + } +} diff --git a/app/lib/widget/protected_page_password_auth_dialog/bloc.dart b/app/lib/widget/protected_page_password_auth_dialog/bloc.dart new file mode 100644 index 00000000..a9e0a4b0 --- /dev/null +++ b/app/lib/widget/protected_page_password_auth_dialog/bloc.dart @@ -0,0 +1,22 @@ +part of '../protected_page_password_auth_dialog.dart'; + +@npLog +class _Bloc extends Bloc<_Event, _State> with BlocLogger { + _Bloc({ + required this.password, + }) : _hasher = sha256, + super(_State.init()) { + on<_Submit>(_onSubmit); + } + + void _onSubmit(_Submit ev, Emitter<_State> emit) { + _log.info(ev); + final hash = _hasher.convert(ev.value.codeUnits); + final isAuth = hash.toString().toCi() == password; + emit(state.copyWith(isAuthorized: Unique(isAuth))); + } + + final CiString password; + + final Hash _hasher; +} diff --git a/app/lib/widget/protected_page_password_auth_dialog/state_event.dart b/app/lib/widget/protected_page_password_auth_dialog/state_event.dart new file mode 100644 index 00000000..d74bc537 --- /dev/null +++ b/app/lib/widget/protected_page_password_auth_dialog/state_event.dart @@ -0,0 +1,32 @@ +part of '../protected_page_password_auth_dialog.dart'; + +@genCopyWith +@toString +class _State { + const _State({ + required this.isAuthorized, + }); + + factory _State.init() => _State( + isAuthorized: Unique(null), + ); + + @override + String toString() => _$toString(); + + final Unique isAuthorized; +} + +abstract class _Event { + const _Event(); +} + +@toString +class _Submit implements _Event { + const _Submit(this.value); + + @override + String toString() => _$toString(); + + final String value; +} diff --git a/app/lib/widget/protected_page_password_auth_dialog/view.dart b/app/lib/widget/protected_page_password_auth_dialog/view.dart new file mode 100644 index 00000000..03ee19db --- /dev/null +++ b/app/lib/widget/protected_page_password_auth_dialog/view.dart @@ -0,0 +1,44 @@ +part of '../protected_page_password_auth_dialog.dart'; + +class _ErrorNotice extends StatefulWidget { + const _ErrorNotice(); + + @override + State createState() => _ErrorNoticeState(); +} + +class _ErrorNoticeState extends State<_ErrorNotice> + with TickerProviderStateMixin { + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + _controller.forward(from: 0); + }); + } + + @override + Widget build(BuildContext context) { + return _BlocListenerT( + selector: (state) => state.isAuthorized, + listener: (context, isAuthorized) { + if (isAuthorized.value == false) { + _controller.forward(from: 0); + } + }, + child: SlideTransition( + position: _controller.drive(Animatable.fromCallback( + (t) => Offset(tremblingTransform(3, t) * .05, 0))), + child: Text( + L10n.global().appLockUnlockWrongPassword, + style: Theme.of(context).textTheme.bodySmall!.copyWith( + color: Theme.of(context).colorScheme.error, + ), + ), + ), + ); + } + + late final _controller = AnimationController(vsync: this) + ..duration = k.animationDurationLong; +} diff --git a/app/lib/widget/protected_page_pin_auth_dialog.dart b/app/lib/widget/protected_page_pin_auth_dialog.dart new file mode 100644 index 00000000..efb261fe --- /dev/null +++ b/app/lib/widget/protected_page_pin_auth_dialog.dart @@ -0,0 +1,154 @@ +import 'dart:math'; + +import 'package:copy_with/copy_with.dart'; +import 'package:crypto/crypto.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:logging/logging.dart'; +import 'package:nc_photos/animation_util.dart'; +import 'package:nc_photos/app_localizations.dart'; +import 'package:nc_photos/bloc_util.dart'; +import 'package:nc_photos/k.dart' as k; +import 'package:np_codegen/np_codegen.dart'; +import 'package:np_collection/np_collection.dart'; +import 'package:np_common/unique.dart'; +import 'package:np_string/np_string.dart'; +import 'package:to_string/to_string.dart'; + +part 'protected_page_pin_auth_dialog.g.dart'; +part 'protected_page_pin_auth_dialog/bloc.dart'; +part 'protected_page_pin_auth_dialog/state_event.dart'; +part 'protected_page_pin_auth_dialog/view.dart'; + +class ProtectedPagePinAuthDialog extends StatelessWidget { + const ProtectedPagePinAuthDialog({ + super.key, + required this.pin, + }); + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => _Bloc( + pin: pin, + removeItemBuilder: (_, animation, value) => ScaleTransition( + scale: animation.drive(CurveTween(curve: Curves.linear)), + child: _ObsecuredDigitDisplay(randomInt: value), + ), + ), + child: _WrappedProtectedPagePinAuthDialog(), + ); + } + + final CiString pin; +} + +class _WrappedProtectedPagePinAuthDialog extends StatelessWidget { + @override + Widget build(BuildContext context) { + return MultiBlocListener( + listeners: [ + _BlocListenerT( + selector: (state) => state.isAuthorized, + listener: (context, isAuthorized) { + if (isAuthorized) { + Navigator.of(context).pop(true); + } + }, + ), + ], + child: AlertDialog( + title: Text(L10n.global().appLockUnlockHint), + scrollable: true, + content: SizedBox( + width: 280, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox( + height: 64, + child: Align( + alignment: Alignment(0, -0.5), + child: _ObsecuredInputView(), + ), + ), + Table( + defaultVerticalAlignment: TableCellVerticalAlignment.middle, + children: [ + TableRow( + children: [1, 2, 3] + .map( + (e) => _DigitButton( + child: Text(e.toString()), + onTap: () { + context.addEvent(_PushDigit(e)); + }, + ), + ) + .toList(), + ), + TableRow( + children: [4, 5, 6] + .map( + (e) => _DigitButton( + child: Text(e.toString()), + onTap: () { + context.addEvent(_PushDigit(e)); + }, + ), + ) + .toList(), + ), + TableRow( + children: [7, 8, 9] + .map( + (e) => _DigitButton( + child: Text(e.toString()), + onTap: () { + context.addEvent(_PushDigit(e)); + }, + ), + ) + .toList(), + ), + TableRow( + children: [ + const SizedBox.shrink(), + _DigitButton( + child: const Text("0"), + onTap: () { + context.addEvent(const _PushDigit(0)); + }, + ), + _BlocSelector( + selector: (state) => state.obsecuredInput.isEmpty, + builder: (context, isEmpty) => _BackspaceButton( + onTap: isEmpty + ? null + : () { + context.addEvent(const _PopDigit()); + }, + ), + ), + ], + ), + ], + ), + ], + ), + ), + ), + ); + } +} + +// typedef _BlocBuilder = BlocBuilder<_Bloc, _State>; +// typedef _BlocListener = BlocListener<_Bloc, _State>; +typedef _BlocListenerT = BlocListenerT<_Bloc, _State, T>; +typedef _BlocSelector = BlocSelector<_Bloc, _State, T>; + +extension on BuildContext { + _Bloc get bloc => read<_Bloc>(); + _State get state => bloc.state; + void addEvent(_Event event) => bloc.add(event); +} diff --git a/app/lib/widget/protected_page_pin_auth_dialog.g.dart b/app/lib/widget/protected_page_pin_auth_dialog.g.dart new file mode 100644 index 00000000..c2d3667b --- /dev/null +++ b/app/lib/widget/protected_page_pin_auth_dialog.g.dart @@ -0,0 +1,81 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'protected_page_pin_auth_dialog.dart'; + +// ************************************************************************** +// CopyWithLintRuleGenerator +// ************************************************************************** + +// ignore_for_file: library_private_types_in_public_api, duplicate_ignore + +// ************************************************************************** +// CopyWithGenerator +// ************************************************************************** + +abstract class $_StateCopyWithWorker { + _State call( + {String? input, + List? obsecuredInput, + bool? isAuthorized, + Unique? isPinError}); +} + +class _$_StateCopyWithWorkerImpl implements $_StateCopyWithWorker { + _$_StateCopyWithWorkerImpl(this.that); + + @override + _State call( + {dynamic input, + dynamic obsecuredInput, + dynamic isAuthorized, + dynamic isPinError}) { + return _State( + input: input as String? ?? that.input, + obsecuredInput: obsecuredInput as List? ?? that.obsecuredInput, + isAuthorized: isAuthorized as bool? ?? that.isAuthorized, + isPinError: isPinError as Unique? ?? that.isPinError); + } + + final _State that; +} + +extension $_StateCopyWith on _State { + $_StateCopyWithWorker get copyWith => _$copyWith; + $_StateCopyWithWorker get _$copyWith => _$_StateCopyWithWorkerImpl(this); +} + +// ************************************************************************** +// NpLogGenerator +// ************************************************************************** + +extension _$_BlocNpLog on _Bloc { + // ignore: unused_element + Logger get _log => log; + + static final log = Logger("widget.protected_page_pin_auth_dialog._Bloc"); +} + +// ************************************************************************** +// ToStringGenerator +// ************************************************************************** + +extension _$_StateToString on _State { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_State {input: $input, obsecuredInput: [length: ${obsecuredInput.length}], isAuthorized: $isAuthorized, isPinError: $isPinError}"; + } +} + +extension _$_PushDigitToString on _PushDigit { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_PushDigit {digit: $digit}"; + } +} + +extension _$_PopDigitToString on _PopDigit { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_PopDigit {}"; + } +} diff --git a/app/lib/widget/protected_page_pin_auth_dialog/bloc.dart b/app/lib/widget/protected_page_pin_auth_dialog/bloc.dart new file mode 100644 index 00000000..3bff621c --- /dev/null +++ b/app/lib/widget/protected_page_pin_auth_dialog/bloc.dart @@ -0,0 +1,68 @@ +part of '../protected_page_pin_auth_dialog.dart'; + +@npLog +class _Bloc extends Bloc<_Event, _State> with BlocLogger { + _Bloc({ + required this.pin, + required this.removeItemBuilder, + }) : _rand = Random(), + _hasher = sha256, + super(_State.init()) { + on<_PushDigit>(_onPushDigit); + on<_PopDigit>(_onPopDigit); + } + + void _onPushDigit(_PushDigit ev, Emitter<_State> emit) { + _log.info(ev); + if (state.input.length >= 6) { + // max length of pin is 6 + emit(state.copyWith(isPinError: Unique(true))); + return; + } + final index = state.input.length; + emit(state.copyWith( + input: "${state.input}${ev.digit}", + obsecuredInput: state.obsecuredInput.added(_rand.nextInt(65536)), + )); + listKey.currentState?.insertItem( + index, + duration: k.animationDurationLong, + ); + + if (state.input.length >= 4) { + // valid pin must contain at least 4 digits + final hash = _hasher.convert(state.input.codeUnits); + if (hash.toString().toCi() == pin) { + emit(state.copyWith(isAuthorized: true)); + } + } + } + + void _onPopDigit(_PopDigit ev, Emitter<_State> emit) { + _log.info(ev); + if (state.input.isEmpty) { + return; + } + final index = state.input.length - 1; + final item = state.obsecuredInput.last; + emit(state.copyWith( + input: state.input.slice(0, -1), + obsecuredInput: state.obsecuredInput.slice(0, -1), + )); + listKey.currentState?.removeItem( + index, + (context, animation) => removeItemBuilder(context, animation, item), + duration: k.animationDurationNormal, + ); + } + + final CiString pin; + final Widget Function( + BuildContext context, Animation animation, int value) + removeItemBuilder; + + final listKey = GlobalKey(); + + final Random _rand; + final Hash _hasher; +} diff --git a/app/lib/widget/protected_page_pin_auth_dialog/state_event.dart b/app/lib/widget/protected_page_pin_auth_dialog/state_event.dart new file mode 100644 index 00000000..df3d5bfb --- /dev/null +++ b/app/lib/widget/protected_page_pin_auth_dialog/state_event.dart @@ -0,0 +1,49 @@ +part of '../protected_page_pin_auth_dialog.dart'; + +@genCopyWith +@toString +class _State { + const _State({ + required this.input, + required this.obsecuredInput, + required this.isAuthorized, + required this.isPinError, + }); + + factory _State.init() => _State( + input: "", + obsecuredInput: const [], + isAuthorized: false, + isPinError: Unique(null), + ); + + @override + String toString() => _$toString(); + + final String input; + final List obsecuredInput; + final bool isAuthorized; + final Unique isPinError; +} + +abstract class _Event { + const _Event(); +} + +@toString +class _PushDigit implements _Event { + const _PushDigit(this.digit); + + @override + String toString() => _$toString(); + + final int digit; +} + +@toString +class _PopDigit implements _Event { + const _PopDigit(); + + @override + String toString() => _$toString(); +} diff --git a/app/lib/widget/protected_page_pin_auth_dialog/view.dart b/app/lib/widget/protected_page_pin_auth_dialog/view.dart new file mode 100644 index 00000000..7d1ebbf3 --- /dev/null +++ b/app/lib/widget/protected_page_pin_auth_dialog/view.dart @@ -0,0 +1,130 @@ +part of '../protected_page_pin_auth_dialog.dart'; + +class _ObsecuredInputView extends StatefulWidget { + const _ObsecuredInputView(); + + @override + State createState() => _ObsecuredInputViewState(); +} + +class _ObsecuredInputViewState extends State<_ObsecuredInputView> + with TickerProviderStateMixin { + @override + Widget build(BuildContext context) { + return _BlocListenerT( + selector: (state) => state.isPinError, + listener: (context, isPinError) { + if (isPinError.value == true) { + _controller.forward(from: 0); + } + }, + child: SlideTransition( + position: _controller.drive(Animatable.fromCallback( + (t) => Offset(tremblingTransform(3, t) * .05, 0))), + child: AnimatedList( + key: context.bloc.listKey, + scrollDirection: Axis.horizontal, + physics: const NeverScrollableScrollPhysics(), + shrinkWrap: true, + initialItemCount: context.state.obsecuredInput.length, + itemBuilder: (context, index, animation) => ScaleTransition( + scale: animation.drive(CurveTween(curve: Curves.elasticOut)), + child: _ObsecuredDigitDisplay( + randomInt: context.state.obsecuredInput[index], + ), + ), + ), + ), + ); + } + + late final _controller = AnimationController(vsync: this) + ..duration = k.animationDurationLong; +} + +class _ObsecuredDigitDisplay extends StatelessWidget { + _ObsecuredDigitDisplay({ + required int randomInt, + }) : text = String.fromCharCode(0x1f600 + (randomInt % 0x30)); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 2), + child: Text( + text, + style: Theme.of(context).textTheme.titleLarge, + ), + ); + } + + final String text; +} + +class _DigitButton extends StatelessWidget { + const _DigitButton({ + required this.child, + this.onTap, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(2), + child: ClipRRect( + borderRadius: BorderRadius.circular(16), + child: Material( + type: MaterialType.transparency, + child: InkWell( + onTap: onTap, + child: Container( + alignment: Alignment.center, + padding: const EdgeInsets.all(8), + height: 56, + child: DefaultTextStyle( + style: Theme.of(context).textTheme.headlineMedium!, + child: child, + ), + ), + ), + ), + ), + ); + } + + final Widget child; + final VoidCallback? onTap; +} + +class _BackspaceButton extends StatelessWidget { + const _BackspaceButton({ + this.onTap, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(4), + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Material( + type: MaterialType.transparency, + child: InkWell( + onTap: onTap, + child: Container( + alignment: Alignment.center, + padding: const EdgeInsets.all(8), + height: 56, + child: Icon( + Icons.backspace_outlined, + color: onTap == null ? Theme.of(context).disabledColor : null, + ), + ), + ), + ), + ), + ); + } + + final VoidCallback? onTap; +} diff --git a/app/lib/widget/splash.dart b/app/lib/widget/splash.dart index b2bd92b8..52c54a86 100644 --- a/app/lib/widget/splash.dart +++ b/app/lib/widget/splash.dart @@ -12,6 +12,7 @@ import 'package:nc_photos/entity/pref.dart'; import 'package:nc_photos/k.dart' as k; import 'package:nc_photos/mobile/android/activity.dart'; import 'package:nc_photos/mobile/android/permission_util.dart'; +import 'package:nc_photos/protected_page_handler.dart'; import 'package:nc_photos/use_case/compat/v29.dart'; import 'package:nc_photos/use_case/compat/v46.dart'; import 'package:nc_photos/use_case/compat/v55.dart'; @@ -64,7 +65,7 @@ class _SplashState extends State { _isUpgrading = false; }); } - unawaited(_exit()); + _exit(); } @override @@ -135,24 +136,30 @@ class _SplashState extends State { ); } - Future _exit() async { + void _exit() { _log.info("[_exit]"); final account = Pref().getCurrentAccount(); if (isNeedSetup()) { - unawaited(Navigator.pushReplacementNamed(context, Setup.routeName)); + Navigator.pushReplacementNamed(context, Setup.routeName); } else if (account == null) { - unawaited(Navigator.pushReplacementNamed(context, SignIn.routeName)); + Navigator.pushReplacementNamed(context, SignIn.routeName); } else { - unawaited( - Navigator.pushReplacementNamed(context, Home.routeName, - arguments: HomeArguments(account)), - ); - if (getRawPlatform() == NpPlatform.android) { - final initialRoute = await Activity.consumeInitialRoute(); - if (initialRoute != null) { - unawaited(Navigator.pushNamed(context, initialRoute)); + Navigator.of(context) + .pushReplacementProtected(Home.routeName, + arguments: HomeArguments(account)) + .then((value) async { + if (getRawPlatform() == NpPlatform.android) { + final initialRoute = await Activity.consumeInitialRoute(); + if (initialRoute != null) { + unawaited(Navigator.pushNamed(context, initialRoute)); + } } - } + }).onError((_, __) async { + _log.warning("[_exit] Auth failed"); + await Future.delayed(const Duration(seconds: 2)); + _exit(); + return null; + }); } } diff --git a/app/pubspec.lock b/app/pubspec.lock index 25f75ff8..3be0b2a3 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -277,7 +277,7 @@ packages: source: hosted version: "1.6.3" crypto: - dependency: transitive + dependency: "direct main" description: name: crypto sha256: ff625774173754681d66daaf4a448684fb04b78f902da9cb3d308c19cc5e8bab @@ -814,6 +814,46 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.1" + local_auth: + dependency: "direct main" + description: + name: local_auth + sha256: "280421b416b32de31405b0a25c3bd42dfcef2538dfbb20c03019e02a5ed55ed0" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + local_auth_android: + dependency: transitive + description: + name: local_auth_android + sha256: e0e5b1ea247c5a0951c13a7ee13dc1beae69750e6a2e1910d1ed6a3cd4d56943 + url: "https://pub.dev" + source: hosted + version: "1.0.38" + local_auth_darwin: + dependency: transitive + description: + name: local_auth_darwin + sha256: e424ebf90d5233452be146d4a7da4bcd7a70278b67791592f3fde1bda8eef9e2 + url: "https://pub.dev" + source: hosted + version: "1.3.1" + local_auth_platform_interface: + dependency: transitive + description: + name: local_auth_platform_interface + sha256: "1b842ff177a7068442eae093b64abe3592f816afd2a533c0ebcdbe40f9d2075a" + url: "https://pub.dev" + source: hosted + version: "1.0.10" + local_auth_windows: + dependency: transitive + description: + name: local_auth_windows + sha256: "505ba3367ca781efb1c50d3132e44a2446bccc4163427bc203b9b4d8994d97ea" + url: "https://pub.dev" + source: hosted + version: "1.0.10" logger: dependency: transitive description: @@ -1842,18 +1882,18 @@ packages: dependency: transitive description: name: win32 - sha256: "5a751eddf9db89b3e5f9d50c20ab8612296e4e8db69009788d6c8b060a84191c" + sha256: f2add6fa510d3ae152903412227bda57d0d5a8da61d2c39c1fb022c9429a41c0 url: "https://pub.dev" source: hosted - version: "4.1.4" + version: "5.0.6" win32_registry: dependency: transitive description: name: win32_registry - sha256: "1c52f994bdccb77103a6231ad4ea331a244dbcef5d1f37d8462f713143b0bfae" + sha256: e4506d60b7244251bc59df15656a3093501c37fb5af02105a944d73eb95be4c9 url: "https://pub.dev" source: hosted - version: "1.1.0" + version: "1.1.1" wkt_parser: dependency: transitive description: diff --git a/app/pubspec.yaml b/app/pubspec.yaml index a1be6b4a..1de56491 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -46,6 +46,7 @@ dependencies: url: https://gitlab.com/nkming2/dart-copy-with path: copy_with ref: copy_with-1.3.0 + crypto: ^3.0.3 devicelocale: ^0.6.0 device_info_plus: ^9.0.1 draggable_scrollbar: @@ -83,6 +84,7 @@ dependencies: path: library intl: 0.18.1 kiwi: ^4.1.0 + local_auth: ^2.2.0 logging: ^1.2.0 memory_info: ^0.0.4 mime: ^1.0.5 diff --git a/np_collection/lib/src/list_extension.dart b/np_collection/lib/src/list_extension.dart index aab9c15c..b7260f66 100644 --- a/np_collection/lib/src/list_extension.dart +++ b/np_collection/lib/src/list_extension.dart @@ -82,4 +82,6 @@ extension ListExtension on List { ); } } + + List added(T value) => toList()..add(value); } diff --git a/np_common/lib/unique.dart b/np_common/lib/unique.dart new file mode 100644 index 00000000..8e1e2a80 --- /dev/null +++ b/np_common/lib/unique.dart @@ -0,0 +1,16 @@ +import 'package:to_string/to_string.dart'; + +part 'unique.g.dart'; + +/// An unique value does not compare equal with others having the same value, +/// instead only the same instances are considered equal +@toString +class Unique { + // no const! + Unique(this.value); + + @override + String toString() => _$toString(); + + final T value; +} diff --git a/np_common/lib/unique.g.dart b/np_common/lib/unique.g.dart new file mode 100644 index 00000000..02c2fe65 --- /dev/null +++ b/np_common/lib/unique.g.dart @@ -0,0 +1,14 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'unique.dart'; + +// ************************************************************************** +// ToStringGenerator +// ************************************************************************** + +extension _$UniqueToString on Unique { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "Unique {value: $value}"; + } +} diff --git a/np_common/test/unique_test.dart b/np_common/test/unique_test.dart new file mode 100644 index 00000000..0c8dd06a --- /dev/null +++ b/np_common/test/unique_test.dart @@ -0,0 +1,15 @@ +import 'package:np_common/unique.dart'; +import 'package:test/test.dart'; + +void main() { + group("Unique", () { + test("same value", () { + expect(Unique(1) == Unique(1), false); + }); + + test("same instance", () { + final a = Unique(1); + expect(a == a, true); + }); + }); +}