Add app lock

This commit is contained in:
Ming Ming 2024-05-25 15:51:42 +08:00
parent fd8251b86c
commit 58171f14c9
28 changed files with 1206 additions and 33 deletions

View file

@ -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"

View file

@ -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;
}
}

View file

@ -12,6 +12,7 @@ class BlocListenerT<B extends StateStreamable<S>, S, T>
extends SingleChildStatelessWidget {
const BlocListenerT({
super.key,
super.child,
required this.selector,
required this.listener,
});

View file

@ -239,9 +239,36 @@ class PrefController {
_c.pref.getSecondarySeedColor()?.run(Color.new));
}
@npSubjectAccessor
class SecurePrefController {
SecurePrefController(this._c);
Future<void> setProtectedPageAuthType(ProtectedPageAuthType? value) =>
_setOrRemove<ProtectedPageAuthType>(
controller: _protectedPageAuthTypeController,
setter: (pref, value) => pref.setProtectedPageAuthType(value.index),
remover: (pref) => pref.setProtectedPageAuthType(null),
value: value,
);
Future<void> setProtectedPageAuthPin(CiString? value) =>
_setOrRemove<CiString>(
controller: _protectedPageAuthPinController,
setter: (pref, value) =>
pref.setProtectedPageAuthPin(value.toCaseInsensitiveString()),
remover: (pref) => pref.setProtectedPageAuthPin(null),
value: value,
);
Future<void> setProtectedPageAuthPassword(CiString? value) =>
_setOrRemove<CiString>(
controller: _protectedPageAuthPasswordController,
setter: (pref, value) =>
pref.setProtectedPageAuthPassword(value.toCaseInsensitiveString()),
remover: (pref) => pref.setProtectedPageAuthPassword(null),
value: value,
);
// ignore: unused_element
Future<void> _set<T>({
required BehaviorSubject<T> 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<void> _doSet<T>({

View file

@ -142,3 +142,32 @@ extension $PrefControllerNpSubjectAccessor on PrefController {
secondarySeedColor.distinct().skip(1);
Color? get secondarySeedColorValue => _secondarySeedColorController.value;
}
extension $SecurePrefControllerNpSubjectAccessor on SecurePrefController {
// _protectedPageAuthTypeController
ValueStream<ProtectedPageAuthType?> get protectedPageAuthType =>
_protectedPageAuthTypeController.stream;
Stream<ProtectedPageAuthType?> get protectedPageAuthTypeNew =>
protectedPageAuthType.skip(1);
Stream<ProtectedPageAuthType?> get protectedPageAuthTypeChange =>
protectedPageAuthType.distinct().skip(1);
ProtectedPageAuthType? get protectedPageAuthTypeValue =>
_protectedPageAuthTypeController.value;
// _protectedPageAuthPinController
ValueStream<CiString?> get protectedPageAuthPin =>
_protectedPageAuthPinController.stream;
Stream<CiString?> get protectedPageAuthPinNew => protectedPageAuthPin.skip(1);
Stream<CiString?> get protectedPageAuthPinChange =>
protectedPageAuthPin.distinct().skip(1);
CiString? get protectedPageAuthPinValue =>
_protectedPageAuthPinController.value;
// _protectedPageAuthPasswordController
ValueStream<CiString?> get protectedPageAuthPassword =>
_protectedPageAuthPasswordController.stream;
Stream<CiString?> get protectedPageAuthPasswordNew =>
protectedPageAuthPassword.skip(1);
Stream<CiString?> get protectedPageAuthPasswordChange =>
protectedPageAuthPassword.distinct().skip(1);
CiString? get protectedPageAuthPasswordValue =>
_protectedPageAuthPasswordController.value;
}

View file

@ -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";
}
}
}

View file

@ -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<bool> setProtectedPageAuthType(int? value) {
if (value == null) {
return _remove(PrefKey.protectedPageAuthType);
} else {
return _set<int>(PrefKey.protectedPageAuthType, value,
(key, value) => provider.setInt(key, value));
}
}
String? getProtectedPageAuthPin() =>
provider.getString(PrefKey.protectedPageAuthPin);
String getProtectedPageAuthPinOr(String def) =>
getProtectedPageAuthPin() ?? def;
Future<bool> setProtectedPageAuthPin(String? value) {
if (value == null) {
return _remove(PrefKey.protectedPageAuthPin);
} else {
return _set<String>(PrefKey.protectedPageAuthPin, value,
(key, value) => provider.setString(key, value));
}
}
String? getProtectedPageAuthPassword() =>
provider.getString(PrefKey.protectedPageAuthPassword);
String getProtectedPageAuthPasswordOr(String def) =>
getProtectedPageAuthPassword() ?? def;
Future<bool> setProtectedPageAuthPassword(String? value) {
if (value == null) {
return _remove(PrefKey.protectedPageAuthPassword);
} else {
return _set<String>(PrefKey.protectedPageAuthPassword, value,
(key, value) => provider.setString(key, value));
}
}
}
extension AccountPrefExtension on AccountPref {

View file

@ -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": {

View file

@ -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"
]
}

View file

@ -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<T?> pushReplacementProtected<T extends Object?, U extends Object?>(
String routeName, {
U? result,
Object? arguments,
}) async {
if (await _auth()) {
return pushReplacementNamed(routeName,
arguments: arguments, result: result);
} else {
throw const ProtectedPageAuthException();
}
}
Future<T?> pushProtected<T extends Object?>(
String routeName, {
Object? arguments,
}) async {
if (await _auth()) {
return pushNamed(routeName, arguments: arguments);
} else {
throw const ProtectedPageAuthException();
}
}
Future<bool> _auth() async {
final securePrefController = context.read<SecurePrefController>();
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<bool> _authBiometric(SecurePrefController securePrefController) async {
if (await _BiometricAuthHandler().auth()) {
return true;
} else {
if (securePrefController.protectedPageAuthPasswordValue != null) {
return _authPassword(securePrefController);
} else {
return _authPin(securePrefController);
}
}
}
Future<bool> _authPin(SecurePrefController securePrefController) =>
_PinAuthHandler(context, securePrefController.protectedPageAuthPinValue!)
.auth();
Future<bool> _authPassword(SecurePrefController securePrefController) =>
_PasswordAuthHandler(
context, securePrefController.protectedPageAuthPasswordValue!)
.auth();
}
abstract class _AuthHandler {
Future<bool> auth();
}
@npLog
class _BiometricAuthHandler implements _AuthHandler {
@override
Future<bool> 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<bool> auth() async {
final result = await showDialog<bool>(
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<bool> auth() async {
final result = await showDialog<bool>(
context: context,
builder: (context) => ProtectedPagePasswordAuthDialog(password: password),
);
return result == true;
}
final BuildContext context;
final CiString password;
}

View file

@ -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");
}

View file

@ -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<T> = BlocListenerT<_Bloc, _State, T>;
typedef _BlocSelector<T> = BlocSelector<_Bloc, _State, T>;
extension on BuildContext {
_Bloc get bloc => read<_Bloc>();
// _State get state => bloc.state;
void addEvent(_Event event) => bloc.add(event);
}

View file

@ -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<bool?>? isAuthorized});
}
class _$_StateCopyWithWorkerImpl implements $_StateCopyWithWorker {
_$_StateCopyWithWorkerImpl(this.that);
@override
_State call({dynamic isAuthorized}) {
return _State(
isAuthorized: isAuthorized as Unique<bool?>? ?? 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}";
}
}

View file

@ -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;
}

View file

@ -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<bool?> isAuthorized;
}
abstract class _Event {
const _Event();
}
@toString
class _Submit implements _Event {
const _Submit(this.value);
@override
String toString() => _$toString();
final String value;
}

View file

@ -0,0 +1,44 @@
part of '../protected_page_password_auth_dialog.dart';
class _ErrorNotice extends StatefulWidget {
const _ErrorNotice();
@override
State<StatefulWidget> 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<Offset>.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;
}

View file

@ -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<T> = BlocListenerT<_Bloc, _State, T>;
typedef _BlocSelector<T> = BlocSelector<_Bloc, _State, T>;
extension on BuildContext {
_Bloc get bloc => read<_Bloc>();
_State get state => bloc.state;
void addEvent(_Event event) => bloc.add(event);
}

View file

@ -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<int>? obsecuredInput,
bool? isAuthorized,
Unique<bool?>? 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<int>? ?? that.obsecuredInput,
isAuthorized: isAuthorized as bool? ?? that.isAuthorized,
isPinError: isPinError as Unique<bool?>? ?? 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 {}";
}
}

View file

@ -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<double> animation, int value)
removeItemBuilder;
final listKey = GlobalKey<AnimatedListState>();
final Random _rand;
final Hash _hasher;
}

View file

@ -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<int> obsecuredInput;
final bool isAuthorized;
final Unique<bool?> 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();
}

View file

@ -0,0 +1,130 @@
part of '../protected_page_pin_auth_dialog.dart';
class _ObsecuredInputView extends StatefulWidget {
const _ObsecuredInputView();
@override
State<StatefulWidget> 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<Offset>.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;
}

View file

@ -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<Splash> {
_isUpgrading = false;
});
}
unawaited(_exit());
_exit();
}
@override
@ -135,24 +136,30 @@ class _SplashState extends State<Splash> {
);
}
Future<void> _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)),
);
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<ProtectedPageAuthException>((_, __) async {
_log.warning("[_exit] Auth failed");
await Future.delayed(const Duration(seconds: 2));
_exit();
return null;
});
}
}

View file

@ -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:

View file

@ -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

View file

@ -82,4 +82,6 @@ extension ListExtension<T> on List<T> {
);
}
}
List<T> added(T value) => toList()..add(value);
}

16
np_common/lib/unique.dart Normal file
View file

@ -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<T> {
// no const!
Unique(this.value);
@override
String toString() => _$toString();
final T value;
}

View file

@ -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}";
}
}

View file

@ -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);
});
});
}