From 9982c73d5a8a66137d0c10d5bd7cb3abdf559bcb Mon Sep 17 00:00:00 2001 From: Aman Raj Singh Mourya Date: Thu, 4 Jul 2024 16:55:33 +0530 Subject: [PATCH 001/123] [mob][auth] Implemented Lock screen --- auth/lib/core/configuration.dart | 11 +- auth/lib/main.dart | 4 +- .../local_authentication_service.dart | 49 +++- auth/lib/ui/components/text_input_widget.dart | 28 +- .../lock_screen/custom_pin_keypad.dart | 196 +++++++++++++ .../lock_screen_confirm_password.dart | 185 ++++++++++++ .../lock_screen/lock_screen_confirm_pin.dart | 206 +++++++++++++ .../lock_screen/lock_screen_options.dart | 224 ++++++++++++++ .../lock_screen/lock_screen_password.dart | 231 +++++++++++++++ .../settings/lock_screen/lock_screen_pin.dart | 269 +++++++++++++++++ .../ui/settings/security_section_widget.dart | 42 ++- auth/lib/ui/tools/lock_screen.dart | 276 +++++++++++++++--- .../ui/two_factor_authentication_page.dart | 55 ++-- auth/lib/utils/auth_util.dart | 20 +- auth/lib/utils/lock_screen_settings.dart | 118 ++++++++ auth/pubspec.yaml | 3 +- 16 files changed, 1840 insertions(+), 77 deletions(-) create mode 100644 auth/lib/ui/settings/lock_screen/custom_pin_keypad.dart create mode 100644 auth/lib/ui/settings/lock_screen/lock_screen_confirm_password.dart create mode 100644 auth/lib/ui/settings/lock_screen/lock_screen_confirm_pin.dart create mode 100644 auth/lib/ui/settings/lock_screen/lock_screen_options.dart create mode 100644 auth/lib/ui/settings/lock_screen/lock_screen_password.dart create mode 100644 auth/lib/ui/settings/lock_screen/lock_screen_pin.dart create mode 100644 auth/lib/utils/lock_screen_settings.dart 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 From 45331de54eaf2d94f3c92b045de221b52ddc591a Mon Sep 17 00:00:00 2001 From: Aman Raj Singh Mourya Date: Sat, 6 Jul 2024 16:58:26 +0530 Subject: [PATCH 002/123] [mob][photos] Custom keypad position fixed --- .../lock_screen/custom_pin_keypad.dart | 208 +++++++++--------- .../lock_screen_confirm_password.dart | 2 +- .../lock_screen/lock_screen_confirm_pin.dart | 29 ++- .../lock_screen/lock_screen_password.dart | 4 +- .../settings/lock_screen/lock_screen_pin.dart | 31 +-- auth/lib/ui/tools/lock_screen.dart | 6 +- 6 files changed, 150 insertions(+), 130 deletions(-) diff --git a/auth/lib/ui/settings/lock_screen/custom_pin_keypad.dart b/auth/lib/ui/settings/lock_screen/custom_pin_keypad.dart index e2a8c2b485..a7edce4c61 100644 --- a/auth/lib/ui/settings/lock_screen/custom_pin_keypad.dart +++ b/auth/lib/ui/settings/lock_screen/custom_pin_keypad.dart @@ -10,108 +10,116 @@ class CustomPinKeypad extends StatelessWidget { return SafeArea( child: Container( padding: const EdgeInsets.all(2), - color: getEnteColorScheme(context).strokeFainter, + // color: getEnteColorScheme(context).strokeFainter, child: Column( + mainAxisAlignment: MainAxisAlignment.end, 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(); - }, - ), - ], + Container( + 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(); + }, + ), + ], + ), + ], + ), ), ], ), 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 index ebb7c3112b..c24d35391d 100644 --- a/auth/lib/ui/settings/lock_screen/lock_screen_confirm_password.dart +++ b/auth/lib/ui/settings/lock_screen/lock_screen_confirm_password.dart @@ -138,7 +138,7 @@ class _LockScreenConfirmPasswordState extends State { height: 75, width: 75, child: CircularProgressIndicator( - backgroundColor: colorTheme.fillFaintPressed, + color: colorTheme.fillFaintPressed, value: 1, strokeWidth: 1.5, ), 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 index da14aa4f88..124e38328a 100644 --- a/auth/lib/ui/settings/lock_screen/lock_screen_confirm_pin.dart +++ b/auth/lib/ui/settings/lock_screen/lock_screen_confirm_pin.dart @@ -69,14 +69,19 @@ class _LockScreenConfirmPinState extends State { ), ), ), - body: OrientationBuilder( - builder: (context, orientation) { - return orientation == Orientation.portrait - ? _getBody(colorTheme, textTheme, isPortrait: true) - : SingleChildScrollView( - child: _getBody(colorTheme, textTheme, isPortrait: false), - ); - }, + floatingActionButton: CustomPinKeypad(controller: _confirmPinController), + floatingActionButtonLocation: FloatingActionButtonLocation.centerDocked, + // body: OrientationBuilder( + // builder: (context, orientation) { + // return orientation == Orientation.portrait + // ? _getBody(colorTheme, textTheme, isPortrait: true) + // : SingleChildScrollView( + // child: _getBody(colorTheme, textTheme, isPortrait: false), + // ); + // }, + // ), + body: SingleChildScrollView( + child: _getBody(colorTheme, textTheme, isPortrait: true), ), ); } @@ -195,10 +200,10 @@ class _LockScreenConfirmPinState extends State { await _confirmPinMatch(); }, ), - isPortrait - ? const Spacer() - : const Padding(padding: EdgeInsets.all(12)), - CustomPinKeypad(controller: _confirmPinController), + // isPortrait + // ? const Spacer() + // : const Padding(padding: EdgeInsets.all(12)), + // CustomPinKeypad(controller: _confirmPinController), ], ), ); diff --git a/auth/lib/ui/settings/lock_screen/lock_screen_password.dart b/auth/lib/ui/settings/lock_screen/lock_screen_password.dart index 504ff506b4..6ea0a61d61 100644 --- a/auth/lib/ui/settings/lock_screen/lock_screen_password.dart +++ b/auth/lib/ui/settings/lock_screen/lock_screen_password.dart @@ -135,7 +135,7 @@ class _LockScreenPasswordState extends State { height: 75, width: 75, child: CircularProgressIndicator( - backgroundColor: colorTheme.fillFaintPressed, + color: colorTheme.fillFaintPressed, value: 1, strokeWidth: 1.5, ), @@ -202,8 +202,8 @@ class _LockScreenPasswordState extends State { } else { if (widget.isOnOpeningApp) { invalidAttemptsCount++; + await _lockscreenSetting.setInvalidAttemptCount(invalidAttemptsCount); if (invalidAttemptsCount > 4) { - await _lockscreenSetting.setInvalidAttemptCount(invalidAttemptsCount); Navigator.of(context).pop(false); } } diff --git a/auth/lib/ui/settings/lock_screen/lock_screen_pin.dart b/auth/lib/ui/settings/lock_screen/lock_screen_pin.dart index cc4948b45d..05fee3ac2e 100644 --- a/auth/lib/ui/settings/lock_screen/lock_screen_pin.dart +++ b/auth/lib/ui/settings/lock_screen/lock_screen_pin.dart @@ -77,8 +77,8 @@ class _LockScreenPinState extends State { if (widget.isOnOpeningApp) { invalidAttemptsCount++; + await _lockscreenSetting.setInvalidAttemptCount(invalidAttemptsCount); if (invalidAttemptsCount > 4) { - await _lockscreenSetting.setInvalidAttemptCount(invalidAttemptsCount); Navigator.of(context).pop(false); } } @@ -128,14 +128,19 @@ class _LockScreenPinState extends State { ), ), ), - body: OrientationBuilder( - builder: (context, orientation) { - return orientation == Orientation.portrait - ? _getBody(colorTheme, textTheme, isPortrait: true) - : SingleChildScrollView( - child: _getBody(colorTheme, textTheme, isPortrait: false), - ); - }, + floatingActionButton: CustomPinKeypad(controller: _pinController), + floatingActionButtonLocation: FloatingActionButtonLocation.centerDocked, + // body: OrientationBuilder( + // builder: (context, orientation) { + // return orientation == Orientation.portrait + // ? _getBody(colorTheme, textTheme, isPortrait: true) + // : SingleChildScrollView( + // child: _getBody(colorTheme, textTheme, isPortrait: false), + // ); + // }, + // ), + body: SingleChildScrollView( + child: _getBody(colorTheme, textTheme, isPortrait: true), ), ); } @@ -258,10 +263,10 @@ class _LockScreenPinState extends State { await _confirmPin(_pinController.text); }, ), - isPortrait - ? const Spacer() - : const Padding(padding: EdgeInsets.all(12)), - CustomPinKeypad(controller: _pinController), + // isPortrait + // ? const Spacer() + // : const Padding(padding: EdgeInsets.all(12)), + // CustomPinKeypad(controller: _pinController), ], ), ); diff --git a/auth/lib/ui/tools/lock_screen.dart b/auth/lib/ui/tools/lock_screen.dart index d28eefefe3..81655b2d1d 100644 --- a/auth/lib/ui/tools/lock_screen.dart +++ b/auth/lib/ui/tools/lock_screen.dart @@ -109,8 +109,10 @@ class _LockScreenState extends State with WidgetsBindingObserver { width: 75, child: TweenAnimationBuilder( tween: Tween( - begin: 0, - end: _getFractionOfTimeElapsed(), + begin: isTimerRunning ? 0 : 1, + end: isTimerRunning + ? _getFractionOfTimeElapsed() + : 1, ), duration: const Duration(seconds: 1), builder: (context, value, _) => From 7a06cf236451f8970bb8277087eedbd5a3874c2d Mon Sep 17 00:00:00 2001 From: Aman Raj Singh Mourya Date: Sat, 6 Jul 2024 17:08:24 +0530 Subject: [PATCH 003/123] [mob][auth] Added logout option on lockscreen --- auth/lib/ui/tools/lock_screen.dart | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/auth/lib/ui/tools/lock_screen.dart b/auth/lib/ui/tools/lock_screen.dart index 81655b2d1d..c75d906221 100644 --- a/auth/lib/ui/tools/lock_screen.dart +++ b/auth/lib/ui/tools/lock_screen.dart @@ -3,6 +3,7 @@ import 'dart:math'; import 'package:ente_auth/core/configuration.dart'; import 'package:ente_auth/l10n/l10n.dart'; +import 'package:ente_auth/services/user_service.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'; @@ -54,6 +55,16 @@ class _LockScreenState extends State with WidgetsBindingObserver { final colorTheme = getEnteColorScheme(context); final textTheme = getEnteTextTheme(context); return Scaffold( + appBar: AppBar( + elevation: 0, + leading: IconButton( + icon: const Icon(Icons.logout_outlined), + color: Theme.of(context).iconTheme.color, + onPressed: () { + _onLogoutTapped(context); + }, + ), + ), body: GestureDetector( onTap: () { isTimerRunning ? null : _showLockScreen(source: "tap"); @@ -187,6 +198,18 @@ class _LockScreenState extends State with WidgetsBindingObserver { return shortestSide > 600 ? true : false; } + void _onLogoutTapped(BuildContext context) { + showChoiceActionSheet( + context, + title: "Are you sure you want to logout?", + firstButtonLabel: "Yes, logout", + isCritical: true, + firstButtonOnTap: () async { + await UserService.instance.logout(context); + }, + ); + } + @override void didChangeAppLifecycleState(AppLifecycleState state) { _logger.info(state.toString()); @@ -278,7 +301,6 @@ class _LockScreenState extends State with WidgetsBindingObserver { Future _autoLogoutOnMaxInvalidAttempts() async { _logger.info("Auto logout on max invalid attempts"); - await _lockscreenSetting.setInvalidAttemptCount(0); await showErrorDialog( context, "Too many incorrect attempts", From e39ba3c578e7d806fc4dca690fbde5d3803ea5fd Mon Sep 17 00:00:00 2001 From: Aman Raj Singh Mourya Date: Wed, 10 Jul 2024 00:24:50 +0530 Subject: [PATCH 004/123] [mob][auth] Added Auto lock UI --- .../lock_screen/lock_screen_auto_lock.dart | 141 ++++++++++++++++++ .../lock_screen/lock_screen_options.dart | 20 +++ auth/lib/utils/lock_screen_settings.dart | 18 +++ 3 files changed, 179 insertions(+) create mode 100644 auth/lib/ui/settings/lock_screen/lock_screen_auto_lock.dart diff --git a/auth/lib/ui/settings/lock_screen/lock_screen_auto_lock.dart b/auth/lib/ui/settings/lock_screen/lock_screen_auto_lock.dart new file mode 100644 index 0000000000..c4315c69e1 --- /dev/null +++ b/auth/lib/ui/settings/lock_screen/lock_screen_auto_lock.dart @@ -0,0 +1,141 @@ +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/separators.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/utils/lock_screen_settings.dart'; +import 'package:flutter/material.dart'; + +class LockScreenAutoLock extends StatefulWidget { + const LockScreenAutoLock({super.key}); + + @override + State createState() => _LockScreenAutoLockState(); +} + +class _LockScreenAutoLockState extends State { + @override + Widget build(BuildContext context) { + return Scaffold( + body: CustomScrollView( + primary: false, + slivers: [ + const TitleBarWidget( + flexibleSpaceTitle: TitleBarTitleWidget( + title: "Auto lock", + ), + ), + SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) { + return const Padding( + padding: EdgeInsets.symmetric( + horizontal: 16, + vertical: 20, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Column( + children: [ + ClipRRect( + borderRadius: BorderRadius.all(Radius.circular(8)), + child: AutoLockItems(), + ), + ], + ), + ], + ), + ); + }, + childCount: 1, + ), + ), + ], + ), + ); + } +} + +class AutoLockItems extends StatefulWidget { + const AutoLockItems({super.key}); + + @override + State createState() => _AutoLockItemsState(); +} + +class _AutoLockItemsState extends State { + final autoLockDurations = LockScreenSettings.instance.autoLockDurations; + List items = []; + late Duration currentAutoLockTime; + @override + void initState() { + for (Duration autoLockDuration in autoLockDurations) { + if (autoLockDuration.inMilliseconds == + LockScreenSettings.instance.getAutoLockTime()) { + currentAutoLockTime = autoLockDuration; + break; + } + } + super.initState(); + } + + @override + Widget build(BuildContext context) { + items.clear(); + for (Duration autoLockDuration in autoLockDurations) { + items.add( + _menuItemForPicker(autoLockDuration), + ); + } + items = addSeparators( + items, + DividerWidget( + dividerType: DividerType.menuNoIcon, + bgColor: getEnteColorScheme(context).fillFaint, + ), + ); + return Column( + mainAxisSize: MainAxisSize.min, + children: items, + ); + } + + Widget _menuItemForPicker(Duration autoLockTime) { + return MenuItemWidget( + key: ValueKey(autoLockTime), + menuItemColor: getEnteColorScheme(context).fillFaint, + captionedTextWidget: CaptionedTextWidget( + title: _formatTime(autoLockTime), + ), + trailingIcon: currentAutoLockTime == autoLockTime ? Icons.check : null, + alignCaptionedTextToLeft: true, + isTopBorderRadiusRemoved: true, + isBottomBorderRadiusRemoved: true, + showOnlyLoadingState: true, + onTap: () async { + await LockScreenSettings.instance.setAutoLockTime(autoLockTime).then( + (value) => { + setState(() { + currentAutoLockTime = autoLockTime; + }), + }, + ); + }, + ); + } + + String _formatTime(Duration duration) { + if (duration.inHours != 0) { + return "${duration.inHours}hr"; + } else if (duration.inMinutes != 0) { + return "${duration.inMinutes}m"; + } else if (duration.inSeconds != 0) { + return "${duration.inSeconds}s"; + } else { + return "Disable"; + } + } +} diff --git a/auth/lib/ui/settings/lock_screen/lock_screen_options.dart b/auth/lib/ui/settings/lock_screen/lock_screen_options.dart index da98826e66..27c3b1f513 100644 --- a/auth/lib/ui/settings/lock_screen/lock_screen_options.dart +++ b/auth/lib/ui/settings/lock_screen/lock_screen_options.dart @@ -1,3 +1,5 @@ +import "dart:async"; + 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"; @@ -6,10 +8,12 @@ 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_auto_lock.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:ente_auth/utils/navigation_util.dart"; import "package:flutter/material.dart"; class LockScreenOptions extends StatefulWidget { @@ -95,6 +99,22 @@ class _LockScreenOptionsState extends State { }); } + Future _onAutoLock() async { + routeToPage(context, LockScreenAutoLock()); + } + + String _formatTime(Duration duration) { + if (duration.inHours != 0) { + return "${duration.inHours}hr"; + } else if (duration.inMinutes != 0) { + return "${duration.inMinutes}m"; + } else if (duration.inSeconds != 0) { + return "${duration.inSeconds}s"; + } else { + return "Disable"; + } + } + @override Widget build(BuildContext context) { final colorTheme = getEnteColorScheme(context); diff --git a/auth/lib/utils/lock_screen_settings.dart b/auth/lib/utils/lock_screen_settings.dart index 5d2aff80ea..bc5764a3e9 100644 --- a/auth/lib/utils/lock_screen_settings.dart +++ b/auth/lib/utils/lock_screen_settings.dart @@ -10,6 +10,16 @@ class LockScreenSettings { static const saltKey = "ls_salt"; static const keyInvalidAttempts = "ls_invalid_attempts"; static const lastInvalidAttemptTime = "ls_last_invalid_attempt_time"; + static const autoLockTime = "ls_auto_lock_time"; + final List autoLockDurations = const [ + Duration(seconds: 0), + Duration(seconds: 30), + Duration(minutes: 1), + Duration(minutes: 5), + Duration(minutes: 15), + Duration(minutes: 30), + Duration(hours: 1), + ]; late SharedPreferences _preferences; @@ -17,6 +27,14 @@ class LockScreenSettings { _preferences = await SharedPreferences.getInstance(); } + Future setAutoLockTime(Duration duration) async { + await _preferences.setInt(autoLockTime, duration.inMilliseconds); + } + + int getAutoLockTime() { + return _preferences.getInt(autoLockTime) ?? 0; + } + Future setLastInvalidAttemptTime(int time) async { await _preferences.setInt(lastInvalidAttemptTime, time); } From d06586eb1c627f6476d694bfb9afb8a0a091734d Mon Sep 17 00:00:00 2001 From: Aman Raj Singh Mourya Date: Wed, 10 Jul 2024 13:21:52 +0530 Subject: [PATCH 005/123] [mob][auth] Auto lock duration added to the app_lock file --- .../lock_screen/lock_screen_options.dart | 35 +++++++++++++++++-- auth/lib/ui/tools/app_lock.dart | 9 +++-- 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/auth/lib/ui/settings/lock_screen/lock_screen_options.dart b/auth/lib/ui/settings/lock_screen/lock_screen_options.dart index 27c3b1f513..18a967b515 100644 --- a/auth/lib/ui/settings/lock_screen/lock_screen_options.dart +++ b/auth/lib/ui/settings/lock_screen/lock_screen_options.dart @@ -29,10 +29,15 @@ class _LockScreenOptionsState extends State { late bool appLock; bool isPinEnabled = false; bool isPasswordEnabled = false; - + late String autoLockTime; @override void initState() { super.initState(); + autoLockTime = _formatTime( + Duration( + milliseconds: _lockscreenSetting.getAutoLockTime(), + ), + ); _initializeSettings(); appLock = isPinEnabled || isPasswordEnabled || @@ -100,7 +105,20 @@ class _LockScreenOptionsState extends State { } Future _onAutoLock() async { - routeToPage(context, LockScreenAutoLock()); + await routeToPage( + context, + const LockScreenAutoLock(), + ).then( + (value) { + setState(() { + autoLockTime = _formatTime( + Duration( + milliseconds: _lockscreenSetting.getAutoLockTime(), + ), + ); + }); + }, + ); } String _formatTime(Duration duration) { @@ -226,6 +244,19 @@ class _LockScreenOptionsState extends State { trailingIconColor: colorTheme.textBase, onTap: () => _passwordLock(), ), + const SizedBox( + height: 24, + ), + MenuItemWidget( + captionedTextWidget: CaptionedTextWidget( + title: "Auto-lock", + subTitle: autoLockTime, + ), + singleBorderRadius: 8, + alignCaptionedTextToLeft: true, + menuItemColor: colorTheme.fillFaint, + onTap: () => _onAutoLock(), + ), ], ) : Container(), diff --git a/auth/lib/ui/tools/app_lock.dart b/auth/lib/ui/tools/app_lock.dart index df55f81164..b4bad2d1ff 100644 --- a/auth/lib/ui/tools/app_lock.dart +++ b/auth/lib/ui/tools/app_lock.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:ente_auth/locale.dart'; +import 'package:ente_auth/utils/lock_screen_settings.dart'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; @@ -83,8 +84,12 @@ class _AppLockState extends State with WidgetsBindingObserver { if (state == AppLifecycleState.paused && (!this._isLocked && this._didUnlockForAppLaunch)) { - this._backgroundLockLatencyTimer = - Timer(this.widget.backgroundLockLatency, () => this.showLockScreen()); + this._backgroundLockLatencyTimer = Timer( + Duration( + milliseconds: LockScreenSettings.instance.getAutoLockTime(), + ), + () => this.showLockScreen(), + ); } if (state == AppLifecycleState.resumed) { From fb0d938cb54a8b571a5c6d03c047e991c94d1f16 Mon Sep 17 00:00:00 2001 From: Aman Raj Singh Mourya Date: Fri, 12 Jul 2024 15:58:23 +0530 Subject: [PATCH 006/123] [mob][auth] Implemented Pin/Password hashing using ente_crypto --- auth/lib/events/app_lock_update_event.dart | 14 +++ .../local_authentication_service.dart | 19 +++- .../lock_screen/custom_pin_keypad.dart | 1 - .../lock_screen/lock_screen_confirm_pin.dart | 17 +-- .../lock_screen/lock_screen_options.dart | 71 +++++++++--- .../lock_screen/lock_screen_password.dart | 52 ++++++--- .../settings/lock_screen/lock_screen_pin.dart | 71 ++++++------ .../ui/settings/security_section_widget.dart | 18 ++- auth/lib/utils/auth_util.dart | 4 +- auth/lib/utils/lock_screen_settings.dart | 106 ++++++++++-------- 10 files changed, 233 insertions(+), 140 deletions(-) create mode 100644 auth/lib/events/app_lock_update_event.dart diff --git a/auth/lib/events/app_lock_update_event.dart b/auth/lib/events/app_lock_update_event.dart new file mode 100644 index 0000000000..3c83cde08b --- /dev/null +++ b/auth/lib/events/app_lock_update_event.dart @@ -0,0 +1,14 @@ +import "package:ente_auth/events/event.dart"; + +enum AppLockUpdateType { + none, + device, + pin, + password, +} + +class AppLockUpdateEvent extends Event { + final AppLockUpdateType type; + + AppLockUpdateEvent(this.type); +} diff --git a/auth/lib/services/local_authentication_service.dart b/auth/lib/services/local_authentication_service.dart index a1f016b7a9..9ca1636997 100644 --- a/auth/lib/services/local_authentication_service.dart +++ b/auth/lib/services/local_authentication_service.dart @@ -25,7 +25,11 @@ class LocalAuthenticationService { ) async { if (await _isLocalAuthSupportedOnDevice()) { AppLock.of(context)!.setEnabled(false); - final result = await requestAuthentication(context, infoMessage); + final result = await requestAuthentication( + context, + infoMessage, + isAuthenticatingForInAppChange: true, + ); AppLock.of(context)!.setEnabled( await Configuration.instance.shouldShowLockScreen(), ); @@ -43,15 +47,17 @@ class LocalAuthenticationService { BuildContext context, String? savedPin, String? savedPassword, { - bool isOnOpeningApp = false, + bool isAuthenticatingOnAppLaunch = false, + bool isAuthenticatingForInAppChange = false, }) async { if (savedPassword != null) { final result = await Navigator.of(context).push( MaterialPageRoute( builder: (BuildContext context) { return LockScreenPassword( - isAuthenticating: true, - isOnOpeningApp: isOnOpeningApp, + isChangingLockScreenSettings: true, + isAuthenticatingForInAppChange: isAuthenticatingForInAppChange, + isAuthenticatingOnAppLaunch: isAuthenticatingOnAppLaunch, authPass: savedPassword, ); }, @@ -66,8 +72,9 @@ class LocalAuthenticationService { MaterialPageRoute( builder: (BuildContext context) { return LockScreenPin( - isAuthenticating: true, - isOnOpeningApp: isOnOpeningApp, + isChangingLockScreenSettings: true, + isAuthenticatingForInAppChange: isAuthenticatingForInAppChange, + isAuthenticatingOnAppLaunch: isAuthenticatingOnAppLaunch, authPin: savedPin, ); }, diff --git a/auth/lib/ui/settings/lock_screen/custom_pin_keypad.dart b/auth/lib/ui/settings/lock_screen/custom_pin_keypad.dart index a7edce4c61..3bdf091f16 100644 --- a/auth/lib/ui/settings/lock_screen/custom_pin_keypad.dart +++ b/auth/lib/ui/settings/lock_screen/custom_pin_keypad.dart @@ -10,7 +10,6 @@ class CustomPinKeypad extends StatelessWidget { return SafeArea( child: Container( padding: const EdgeInsets.all(2), - // color: getEnteColorScheme(context).strokeFainter, child: Column( mainAxisAlignment: MainAxisAlignment.end, children: [ 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 index 124e38328a..508710a869 100644 --- a/auth/lib/ui/settings/lock_screen/lock_screen_confirm_pin.dart +++ b/auth/lib/ui/settings/lock_screen/lock_screen_confirm_pin.dart @@ -71,22 +71,13 @@ class _LockScreenConfirmPinState extends State { ), floatingActionButton: CustomPinKeypad(controller: _confirmPinController), floatingActionButtonLocation: FloatingActionButtonLocation.centerDocked, - // body: OrientationBuilder( - // builder: (context, orientation) { - // return orientation == Orientation.portrait - // ? _getBody(colorTheme, textTheme, isPortrait: true) - // : SingleChildScrollView( - // child: _getBody(colorTheme, textTheme, isPortrait: false), - // ); - // }, - // ), body: SingleChildScrollView( - child: _getBody(colorTheme, textTheme, isPortrait: true), + child: _getBody(colorTheme, textTheme), ), ); } - Widget _getBody(colorTheme, textTheme, {required bool isPortrait}) { + Widget _getBody(colorTheme, textTheme) { return Center( child: Column( crossAxisAlignment: CrossAxisAlignment.center, @@ -200,10 +191,6 @@ class _LockScreenConfirmPinState extends State { 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 index 18a967b515..ff34b5ef85 100644 --- a/auth/lib/ui/settings/lock_screen/lock_screen_options.dart +++ b/auth/lib/ui/settings/lock_screen/lock_screen_options.dart @@ -1,6 +1,8 @@ import "dart:async"; import "package:ente_auth/core/configuration.dart"; +import "package:ente_auth/core/event_bus.dart"; +import "package:ente_auth/events/app_lock_update_event.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"; @@ -29,15 +31,11 @@ class _LockScreenOptionsState extends State { late bool appLock; bool isPinEnabled = false; bool isPasswordEnabled = false; - late String autoLockTime; + late int autoLockTimeInMilliseconds; @override void initState() { super.initState(); - autoLockTime = _formatTime( - Duration( - milliseconds: _lockscreenSetting.getAutoLockTime(), - ), - ); + autoLockTimeInMilliseconds = _lockscreenSetting.getAutoLockTime(); _initializeSettings(); appLock = isPinEnabled || isPasswordEnabled || @@ -56,6 +54,12 @@ class _LockScreenOptionsState extends State { Future _deviceLock() async { await _lockscreenSetting.removePinAndPassword(); await _initializeSettings(); + await _lockscreenSetting.setAppLockType("Device lock"); + Bus.instance.fire( + AppLockUpdateEvent( + AppLockUpdateType.device, + ), + ); } Future _pinLock() async { @@ -69,6 +73,12 @@ class _LockScreenOptionsState extends State { setState(() { _initializeSettings(); if (result) { + Bus.instance.fire( + AppLockUpdateEvent( + AppLockUpdateType.pin, + ), + ); + _lockscreenSetting.setAppLockType("Pin"); appLock = isPinEnabled || isPasswordEnabled || _configuration.shouldShowSystemLockScreen(); @@ -87,6 +97,12 @@ class _LockScreenOptionsState extends State { setState(() { _initializeSettings(); if (result) { + Bus.instance.fire( + AppLockUpdateEvent( + AppLockUpdateType.password, + ), + ); + _lockscreenSetting.setAppLockType("Password"); appLock = isPinEnabled || isPasswordEnabled || _configuration.shouldShowSystemLockScreen(); @@ -98,6 +114,12 @@ class _LockScreenOptionsState extends State { AppLock.of(context)!.setEnabled(!appLock); await _configuration.setSystemLockScreen(!appLock); await _lockscreenSetting.removePinAndPassword(); + await _lockscreenSetting.setAppLockType(appLock ? "None" : "Device lock"); + Bus.instance.fire( + AppLockUpdateEvent( + appLock ? AppLockUpdateType.none : AppLockUpdateType.device, + ), + ); setState(() { _initializeSettings(); appLock = !appLock; @@ -111,11 +133,7 @@ class _LockScreenOptionsState extends State { ).then( (value) { setState(() { - autoLockTime = _formatTime( - Duration( - milliseconds: _lockscreenSetting.getAutoLockTime(), - ), - ); + autoLockTimeInMilliseconds = _lockscreenSetting.getAutoLockTime(); }); }, ); @@ -123,11 +141,11 @@ class _LockScreenOptionsState extends State { String _formatTime(Duration duration) { if (duration.inHours != 0) { - return "${duration.inHours}hr"; + return "in ${duration.inHours} hour${duration.inHours > 1 ? 's' : ''}"; } else if (duration.inMinutes != 0) { - return "${duration.inMinutes}m"; + return "in ${duration.inMinutes} minute${duration.inMinutes > 1 ? 's' : ''}"; } else if (duration.inSeconds != 0) { - return "${duration.inSeconds}s"; + return "in ${duration.inSeconds} second${duration.inSeconds > 1 ? 's' : ''}"; } else { return "Disable"; } @@ -191,6 +209,7 @@ class _LockScreenOptionsState extends State { ), appLock ? Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ MenuItemWidget( captionedTextWidget: @@ -249,14 +268,32 @@ class _LockScreenOptionsState extends State { ), MenuItemWidget( captionedTextWidget: CaptionedTextWidget( - title: "Auto-lock", - subTitle: autoLockTime, + title: "Auto lock", + subTitle: _formatTime( + Duration( + milliseconds: + autoLockTimeInMilliseconds, + ), + ), ), - singleBorderRadius: 8, alignCaptionedTextToLeft: true, + singleBorderRadius: 8, menuItemColor: colorTheme.fillFaint, + trailingIconColor: colorTheme.textBase, onTap: () => _onAutoLock(), ), + Padding( + padding: const EdgeInsets.only( + top: 14, + left: 14, + right: 12, + ), + child: Text( + "Require ${_lockscreenSetting.getAppLockType()} if away for some time .", + style: textTheme.miniFaint, + textAlign: TextAlign.left, + ), + ), ], ) : Container(), diff --git a/auth/lib/ui/settings/lock_screen/lock_screen_password.dart b/auth/lib/ui/settings/lock_screen/lock_screen_password.dart index 6ea0a61d61..1c43ff8229 100644 --- a/auth/lib/ui/settings/lock_screen/lock_screen_password.dart +++ b/auth/lib/ui/settings/lock_screen/lock_screen_password.dart @@ -1,3 +1,5 @@ +import "dart:convert"; + 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"; @@ -5,20 +7,32 @@ 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:ente_crypto_dart/ente_crypto_dart.dart"; import "package:flutter/material.dart"; import "package:flutter/services.dart"; +/// [isChangingLockScreenSettings] Authentication required for changing lock screen settings. +/// Set to true when the app requires the user to authenticate before allowing +/// changes to the lock screen settings. + +/// [isAuthenticatingOnAppLaunch] Authentication required on app launch. +/// Set to true when the app requires the user to authenticate immediately upon opening. + +/// [isAuthenticatingForInAppChange] Authentication required for in-app changes (e.g., email, password). +/// Set to true when the app requires the to authenticate for sensitive actions like email, password changes. + class LockScreenPassword extends StatefulWidget { const LockScreenPassword({ super.key, - this.isAuthenticating = false, - this.isOnOpeningApp = false, + this.isChangingLockScreenSettings = false, + this.isAuthenticatingOnAppLaunch = false, + this.isAuthenticatingForInAppChange = false, this.authPass, }); - //Is false when setting a new password - final bool isAuthenticating; - final bool isOnOpeningApp; + final bool isChangingLockScreenSettings; + final bool isAuthenticatingOnAppLaunch; + final bool isAuthenticatingForInAppChange; final String? authPass; @override State createState() => _LockScreenPasswordState(); @@ -149,7 +163,9 @@ class _LockScreenPasswordState extends State { ), ), Text( - widget.isAuthenticating ? "Enter Password" : "Set new Password", + widget.isChangingLockScreenSettings + ? "Enter Password" + : "Set new Password", textAlign: TextAlign.center, style: textTheme.bodyBold, ), @@ -181,17 +197,19 @@ class _LockScreenPasswordState extends State { } 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) { + final Uint8List? salt = await _lockscreenSetting.getSalt(); + final hash = cryptoPwHash( + utf8.encode(inputtedPassword), + salt!, + sodium.crypto.pwhash.memLimitSensitive, + sodium.crypto.pwhash.opsLimitSensitive, + sodium, + ); + if (widget.authPass == base64Encode(hash)) { await _lockscreenSetting.setInvalidAttemptCount(0); - widget.isOnOpeningApp + widget.isAuthenticatingOnAppLaunch || + widget.isAuthenticatingForInAppChange ? Navigator.of(context).pop(true) : Navigator.of(context).pushReplacement( MaterialPageRoute( @@ -200,7 +218,7 @@ class _LockScreenPasswordState extends State { ); return true; } else { - if (widget.isOnOpeningApp) { + if (widget.isAuthenticatingOnAppLaunch) { invalidAttemptsCount++; await _lockscreenSetting.setInvalidAttemptCount(invalidAttemptsCount); if (invalidAttemptsCount > 4) { @@ -214,7 +232,7 @@ class _LockScreenPasswordState extends State { } Future _confirmPassword() async { - if (widget.isAuthenticating) { + if (widget.isChangingLockScreenSettings) { await _confirmPasswordAuth(_passwordController.text); return; } else { diff --git a/auth/lib/ui/settings/lock_screen/lock_screen_pin.dart b/auth/lib/ui/settings/lock_screen/lock_screen_pin.dart index 05fee3ac2e..e45ec75ac0 100644 --- a/auth/lib/ui/settings/lock_screen/lock_screen_pin.dart +++ b/auth/lib/ui/settings/lock_screen/lock_screen_pin.dart @@ -1,3 +1,5 @@ +import "dart:convert"; + import "package:ente_auth/theme/colors.dart"; import "package:ente_auth/theme/ente_theme.dart"; import "package:ente_auth/theme/text_style.dart"; @@ -5,21 +7,33 @@ 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:ente_crypto_dart/ente_crypto_dart.dart"; import "package:flutter/material.dart"; import "package:flutter/services.dart"; import 'package:pinput/pinput.dart'; +/// [isChangingLockScreenSettings] Authentication required for changing lock screen settings. +/// Set to true when the app requires the user to authenticate before allowing +/// changes to the lock screen settings. + +/// [isAuthenticatingOnAppLaunch] Authentication required on app launch. +/// Set to true when the app requires the user to authenticate immediately upon opening. + +/// [isAuthenticatingForInAppChange] Authentication required for in-app changes (e.g., email, password). +/// Set to true when the app requires the to authenticate for sensitive actions like email, password changes. + class LockScreenPin extends StatefulWidget { const LockScreenPin({ super.key, - this.isAuthenticating = false, - this.isOnOpeningApp = false, + this.isChangingLockScreenSettings = false, + this.isAuthenticatingOnAppLaunch = false, + this.isAuthenticatingForInAppChange = false, this.authPin, }); - //Is false when setting a new password - final bool isAuthenticating; - final bool isOnOpeningApp; + final bool isAuthenticatingOnAppLaunch; + final bool isChangingLockScreenSettings; + final bool isAuthenticatingForInAppChange; final String? authPin; @override State createState() => _LockScreenPinState(); @@ -45,18 +59,19 @@ class _LockScreenPinState extends State { } 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) { + final Uint8List? salt = await _lockscreenSetting.getSalt(); + final hash = cryptoPwHash( + utf8.encode(inputtedPin), + salt!, + sodium.crypto.pwhash.memLimitSensitive, + sodium.crypto.pwhash.opsLimitSensitive, + sodium, + ); + if (widget.authPin == base64Encode(hash)) { invalidAttemptsCount = 0; await _lockscreenSetting.setInvalidAttemptCount(0); - widget.isOnOpeningApp + widget.isAuthenticatingOnAppLaunch || + widget.isAuthenticatingForInAppChange ? Navigator.of(context).pop(true) : Navigator.of(context).pushReplacement( MaterialPageRoute( @@ -75,7 +90,7 @@ class _LockScreenPinState extends State { isPinValid = false; }); - if (widget.isOnOpeningApp) { + if (widget.isAuthenticatingOnAppLaunch) { invalidAttemptsCount++; await _lockscreenSetting.setInvalidAttemptCount(invalidAttemptsCount); if (invalidAttemptsCount > 4) { @@ -87,7 +102,7 @@ class _LockScreenPinState extends State { } Future _confirmPin(String inputtedPin) async { - if (widget.isAuthenticating) { + if (widget.isChangingLockScreenSettings) { await confirmPinAuth(inputtedPin); return; } else { @@ -130,26 +145,16 @@ class _LockScreenPinState extends State { ), floatingActionButton: CustomPinKeypad(controller: _pinController), floatingActionButtonLocation: FloatingActionButtonLocation.centerDocked, - // body: OrientationBuilder( - // builder: (context, orientation) { - // return orientation == Orientation.portrait - // ? _getBody(colorTheme, textTheme, isPortrait: true) - // : SingleChildScrollView( - // child: _getBody(colorTheme, textTheme, isPortrait: false), - // ); - // }, - // ), body: SingleChildScrollView( - child: _getBody(colorTheme, textTheme, isPortrait: true), + child: _getBody(colorTheme, textTheme), ), ); } Widget _getBody( EnteColorScheme colorTheme, - EnteTextTheme textTheme, { - required bool isPortrait, - }) { + EnteTextTheme textTheme, + ) { return Center( child: Column( crossAxisAlignment: CrossAxisAlignment.center, @@ -219,7 +224,7 @@ class _LockScreenPinState extends State { ), ), Text( - widget.isAuthenticating ? "Enter PIN" : "Set new PIN", + widget.isChangingLockScreenSettings ? "Enter PIN" : "Set new PIN", style: textTheme.bodyBold, ), const Padding(padding: EdgeInsets.all(12)), @@ -263,10 +268,6 @@ class _LockScreenPinState extends State { 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 8fa3249bf5..af45dab4b0 100644 --- a/auth/lib/ui/settings/security_section_widget.dart +++ b/auth/lib/ui/settings/security_section_widget.dart @@ -2,6 +2,8 @@ import 'dart:async'; import 'dart:typed_data'; import 'package:ente_auth/core/configuration.dart'; +import 'package:ente_auth/core/event_bus.dart'; +import 'package:ente_auth/events/app_lock_update_event.dart'; import 'package:ente_auth/l10n/l10n.dart'; import 'package:ente_auth/models/user_details.dart'; import 'package:ente_auth/services/local_authentication_service.dart'; @@ -18,6 +20,7 @@ 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/lock_screen_settings.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'; @@ -37,15 +40,25 @@ class _SecuritySectionWidgetState extends State { final _config = Configuration.instance; late bool _hasLoggedIn; final Logger _logger = Logger('SecuritySectionWidget'); - + late String appLockSubtitle; + late StreamSubscription _appLockUpdateEvent; @override void initState() { _hasLoggedIn = _config.hasConfiguredAccount(); + appLockSubtitle = LockScreenSettings.instance.getAppLockType(); + _appLockUpdateEvent = Bus.instance.on().listen((event) { + if (mounted) { + setState(() { + appLockSubtitle = LockScreenSettings.instance.getAppLockType(); + }); + } + }); super.initState(); } @override void dispose() { + _appLockUpdateEvent.cancel(); super.dispose(); } @@ -137,8 +150,9 @@ class _SecuritySectionWidgetState extends State { } children.addAll([ MenuItemWidget( - captionedTextWidget: const CaptionedTextWidget( + captionedTextWidget: CaptionedTextWidget( title: "App lock", + subTitle: appLockSubtitle, ), trailingIcon: Icons.chevron_right_outlined, trailingIconIsMuted: true, diff --git a/auth/lib/utils/auth_util.dart b/auth/lib/utils/auth_util.dart index 97c4bb2da3..df11211c92 100644 --- a/auth/lib/utils/auth_util.dart +++ b/auth/lib/utils/auth_util.dart @@ -14,6 +14,7 @@ Future requestAuthentication( BuildContext context, String reason, { bool isOpeningApp = false, + bool isAuthenticatingForInAppChange = false, }) async { Logger("AuthUtil").info("Requesting authentication"); @@ -25,7 +26,8 @@ Future requestAuthentication( context, savedPin, savedPassword, - isOnOpeningApp: isOpeningApp, + isAuthenticatingOnAppLaunch: isOpeningApp, + isAuthenticatingForInAppChange: isAuthenticatingForInAppChange, ); } if (Platform.isMacOS || Platform.isLinux) { diff --git a/auth/lib/utils/lock_screen_settings.dart b/auth/lib/utils/lock_screen_settings.dart index bc5764a3e9..e1b88bd040 100644 --- a/auth/lib/utils/lock_screen_settings.dart +++ b/auth/lib/utils/lock_screen_settings.dart @@ -1,3 +1,8 @@ +import "dart:convert"; +import "dart:typed_data"; + +import "package:ente_crypto_dart/ente_crypto_dart.dart"; +import "package:flutter_secure_storage/flutter_secure_storage.dart"; import "package:shared_preferences/shared_preferences.dart"; class LockScreenSettings { @@ -11,6 +16,7 @@ class LockScreenSettings { static const keyInvalidAttempts = "ls_invalid_attempts"; static const lastInvalidAttemptTime = "ls_last_invalid_attempt_time"; static const autoLockTime = "ls_auto_lock_time"; + static const appLockType = "ls_app_lock_type"; final List autoLockDurations = const [ Duration(seconds: 0), Duration(seconds: 30), @@ -22,11 +28,20 @@ class LockScreenSettings { ]; late SharedPreferences _preferences; - + late FlutterSecureStorage _secureStorage; Future init() async { + _secureStorage = const FlutterSecureStorage(); _preferences = await SharedPreferences.getInstance(); } + Future setAppLockType(String lockType) async { + await _preferences.setString(appLockType, lockType); + } + + String getAppLockType() { + return _preferences.getString(appLockType) ?? "None"; + } + Future setAutoLockTime(Duration duration) async { await _preferences.setInt(autoLockTime, duration.inMilliseconds); } @@ -51,86 +66,85 @@ class LockScreenSettings { await _preferences.setInt(keyInvalidAttempts, count); } - // static Uint8List _generateSalt() { - // return Sodium.randombytesBuf(Sodium.cryptoPwhashSaltbytes); - // } + static Uint8List _generateSalt() { + return sodium.randombytes.buf(sodium.crypto.pwhash.saltBytes); + } Future setPin(String userPin) async { - //await _secureStorage.delete(key: saltKey); + 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 salt = _generateSalt(); - // final String saltPin = base64Encode(salt); - // final String hashedPin = base64Encode(hash); + final hash = cryptoPwHash( + utf8.encode(userPin), + salt, + sodium.crypto.pwhash.memLimitSensitive, + sodium.crypto.pwhash.opsLimitSensitive, + sodium, + ); + 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); + 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 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); + return _secureStorage.read(key: pin); } Future setPassword(String pass) async { await _preferences.setString(password, pass); await _preferences.remove(pin); - // await _secureStorage.delete(key: saltKey); + await _secureStorage.delete(key: saltKey); - // final salt = _generateSalt(); - // final hash = cryptoPwHash({ - // "password": utf8.encode(pass), - // "salt": salt, - // "opsLimit": Sodium.cryptoPwhashOpslimitInteractive, - // "memLimit": Sodium.cryptoPwhashMemlimitInteractive, - // }); + final salt = _generateSalt(); - // final String saltPassword = base64Encode(salt); - // final String hashPassword = base64Encode(hash); + final hash = cryptoPwHash( + utf8.encode(pass), + salt, + sodium.crypto.pwhash.memLimitSensitive, + sodium.crypto.pwhash.opsLimitSensitive, + sodium, + ); - // await _secureStorage.write(key: saltKey, value: saltPassword); - // await _secureStorage.write(key: password, value: hashPassword); - // await _secureStorage.delete(key: pin); + 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); + 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); + 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); + return await _secureStorage.containsKey(key: pin); } Future isPasswordSet() async { - return _preferences.containsKey(password); - // return await _secureStorage.containsKey(key: password); + return await _secureStorage.containsKey(key: password); } } From 0ce9ceba12b25d5f4ce2eb5daab325bd36ccd551 Mon Sep 17 00:00:00 2001 From: Aman Raj Singh Mourya Date: Fri, 12 Jul 2024 16:48:33 +0530 Subject: [PATCH 007/123] [mob][auth] Used memLimitInteractive instead of memLimitSensitive to avoid delay --- .../ui/settings/lock_screen/lock_screen_password.dart | 2 +- auth/lib/ui/settings/lock_screen/lock_screen_pin.dart | 2 +- auth/lib/utils/lock_screen_settings.dart | 11 ++--------- 3 files changed, 4 insertions(+), 11 deletions(-) diff --git a/auth/lib/ui/settings/lock_screen/lock_screen_password.dart b/auth/lib/ui/settings/lock_screen/lock_screen_password.dart index 1c43ff8229..5252a2def9 100644 --- a/auth/lib/ui/settings/lock_screen/lock_screen_password.dart +++ b/auth/lib/ui/settings/lock_screen/lock_screen_password.dart @@ -201,7 +201,7 @@ class _LockScreenPasswordState extends State { final hash = cryptoPwHash( utf8.encode(inputtedPassword), salt!, - sodium.crypto.pwhash.memLimitSensitive, + sodium.crypto.pwhash.memLimitInteractive, sodium.crypto.pwhash.opsLimitSensitive, sodium, ); diff --git a/auth/lib/ui/settings/lock_screen/lock_screen_pin.dart b/auth/lib/ui/settings/lock_screen/lock_screen_pin.dart index e45ec75ac0..c4b8a6070e 100644 --- a/auth/lib/ui/settings/lock_screen/lock_screen_pin.dart +++ b/auth/lib/ui/settings/lock_screen/lock_screen_pin.dart @@ -63,7 +63,7 @@ class _LockScreenPinState extends State { final hash = cryptoPwHash( utf8.encode(inputtedPin), salt!, - sodium.crypto.pwhash.memLimitSensitive, + sodium.crypto.pwhash.memLimitInteractive, sodium.crypto.pwhash.opsLimitSensitive, sodium, ); diff --git a/auth/lib/utils/lock_screen_settings.dart b/auth/lib/utils/lock_screen_settings.dart index e1b88bd040..8153e6b99f 100644 --- a/auth/lib/utils/lock_screen_settings.dart +++ b/auth/lib/utils/lock_screen_settings.dart @@ -72,14 +72,12 @@ class LockScreenSettings { 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( utf8.encode(userPin), salt, - sodium.crypto.pwhash.memLimitSensitive, + sodium.crypto.pwhash.memLimitInteractive, sodium.crypto.pwhash.opsLimitSensitive, sodium, ); @@ -104,16 +102,13 @@ class LockScreenSettings { } 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( utf8.encode(pass), salt, - sodium.crypto.pwhash.memLimitSensitive, + sodium.crypto.pwhash.memLimitInteractive, sodium.crypto.pwhash.opsLimitSensitive, sodium, ); @@ -133,8 +128,6 @@ class LockScreenSettings { } 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); From 9292dc6d046390593d6af36f8b832e625baa38df Mon Sep 17 00:00:00 2001 From: Aman Raj Singh Mourya Date: Sat, 13 Jul 2024 14:45:45 +0530 Subject: [PATCH 008/123] [mob][auth] Do not show CustomPinKeypad on Desktop --- .../lock_screen/lock_screen_confirm_pin.dart | 18 +++++++++++++++--- .../settings/lock_screen/lock_screen_pin.dart | 12 +++++++++--- 2 files changed, 24 insertions(+), 6 deletions(-) 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 index 508710a869..77a2c155fc 100644 --- a/auth/lib/ui/settings/lock_screen/lock_screen_confirm_pin.dart +++ b/auth/lib/ui/settings/lock_screen/lock_screen_confirm_pin.dart @@ -1,3 +1,5 @@ +import "dart:io"; + 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"; @@ -15,7 +17,7 @@ class LockScreenConfirmPin extends StatefulWidget { class _LockScreenConfirmPinState extends State { final _confirmPinController = TextEditingController(text: null); bool isConfirmPinValid = false; - + bool isPlatformDesktop = false; final LockScreenSettings _lockscreenSetting = LockScreenSettings.instance; final _pinPutDecoration = PinTheme( height: 48, @@ -27,6 +29,13 @@ class _LockScreenConfirmPinState extends State { ), ); + @override + void initState() { + super.initState(); + isPlatformDesktop = + Platform.isLinux || Platform.isMacOS || Platform.isWindows; + } + @override void dispose() { super.dispose(); @@ -69,7 +78,9 @@ class _LockScreenConfirmPinState extends State { ), ), ), - floatingActionButton: CustomPinKeypad(controller: _confirmPinController), + floatingActionButton: isPlatformDesktop + ? null + : CustomPinKeypad(controller: _confirmPinController), floatingActionButtonLocation: FloatingActionButtonLocation.centerDocked, body: SingleChildScrollView( child: _getBody(colorTheme, textTheme), @@ -154,7 +165,8 @@ class _LockScreenConfirmPinState extends State { Pinput( length: 4, showCursor: false, - useNativeKeyboard: false, + useNativeKeyboard: isPlatformDesktop, + autofocus: true, controller: _confirmPinController, defaultPinTheme: _pinPutDecoration, submittedPinTheme: _pinPutDecoration.copyWith( diff --git a/auth/lib/ui/settings/lock_screen/lock_screen_pin.dart b/auth/lib/ui/settings/lock_screen/lock_screen_pin.dart index c4b8a6070e..2488a2e39f 100644 --- a/auth/lib/ui/settings/lock_screen/lock_screen_pin.dart +++ b/auth/lib/ui/settings/lock_screen/lock_screen_pin.dart @@ -1,4 +1,5 @@ import "dart:convert"; +import "dart:io"; import "package:ente_auth/theme/colors.dart"; import "package:ente_auth/theme/ente_theme.dart"; @@ -45,10 +46,12 @@ class _LockScreenPinState extends State { final LockScreenSettings _lockscreenSetting = LockScreenSettings.instance; bool isPinValid = false; int invalidAttemptsCount = 0; - + bool isPlatformDesktop = false; @override void initState() { super.initState(); + isPlatformDesktop = + Platform.isLinux || Platform.isMacOS || Platform.isWindows; invalidAttemptsCount = _lockscreenSetting.getInvalidAttemptCount(); } @@ -143,7 +146,9 @@ class _LockScreenPinState extends State { ), ), ), - floatingActionButton: CustomPinKeypad(controller: _pinController), + floatingActionButton: isPlatformDesktop + ? null + : CustomPinKeypad(controller: _pinController), floatingActionButtonLocation: FloatingActionButtonLocation.centerDocked, body: SingleChildScrollView( child: _getBody(colorTheme, textTheme), @@ -231,8 +236,9 @@ class _LockScreenPinState extends State { Pinput( length: 4, showCursor: false, - useNativeKeyboard: false, + useNativeKeyboard: isPlatformDesktop, controller: _pinController, + autofocus: true, defaultPinTheme: _pinPutDecoration, submittedPinTheme: _pinPutDecoration.copyWith( textStyle: textTheme.h3Bold, From 147be37fdb53782b2505c3aff23690bc52f41251 Mon Sep 17 00:00:00 2001 From: Aman Raj Singh Mourya Date: Sun, 14 Jul 2024 22:58:17 +0530 Subject: [PATCH 009/123] [mob][auth] Removed dialog box on auto-logout --- auth/lib/core/configuration.dart | 1 + auth/lib/ui/tools/lock_screen.dart | 6 ------ 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/auth/lib/core/configuration.dart b/auth/lib/core/configuration.dart index aed7516b9f..586a519b65 100644 --- a/auth/lib/core/configuration.dart +++ b/auth/lib/core/configuration.dart @@ -141,6 +141,7 @@ class Configuration { iOptions: _secureStorageOptionsIOS, ); } + await LockScreenSettings.instance.removePinAndPassword(); await AuthenticatorDB.instance.clearTable(); _key = null; _cachedToken = null; diff --git a/auth/lib/ui/tools/lock_screen.dart b/auth/lib/ui/tools/lock_screen.dart index c75d906221..fcacea8231 100644 --- a/auth/lib/ui/tools/lock_screen.dart +++ b/auth/lib/ui/tools/lock_screen.dart @@ -301,12 +301,6 @@ class _LockScreenState extends State with WidgetsBindingObserver { Future _autoLogoutOnMaxInvalidAttempts() async { _logger.info("Auto logout on max invalid attempts"); - 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 ..."); From 5f08e44e58354e965e0ed8e1207c5a2c81903053 Mon Sep 17 00:00:00 2001 From: Aman Raj Singh Mourya Date: Tue, 16 Jul 2024 15:23:21 +0530 Subject: [PATCH 010/123] [mob][auth] Auto lock fixes --- .../lock_screen/lock_screen_auto_lock.dart | 2 +- .../lock_screen/lock_screen_options.dart | 12 +++++++----- auth/lib/utils/lock_screen_settings.dart | 18 ++++++++++++++++-- 3 files changed, 24 insertions(+), 8 deletions(-) diff --git a/auth/lib/ui/settings/lock_screen/lock_screen_auto_lock.dart b/auth/lib/ui/settings/lock_screen/lock_screen_auto_lock.dart index c4315c69e1..c0c1941eb5 100644 --- a/auth/lib/ui/settings/lock_screen/lock_screen_auto_lock.dart +++ b/auth/lib/ui/settings/lock_screen/lock_screen_auto_lock.dart @@ -135,7 +135,7 @@ class _AutoLockItemsState extends State { } else if (duration.inSeconds != 0) { return "${duration.inSeconds}s"; } else { - return "Disable"; + return "Disabled"; } } } diff --git a/auth/lib/ui/settings/lock_screen/lock_screen_options.dart b/auth/lib/ui/settings/lock_screen/lock_screen_options.dart index ff34b5ef85..ba33b5e5e1 100644 --- a/auth/lib/ui/settings/lock_screen/lock_screen_options.dart +++ b/auth/lib/ui/settings/lock_screen/lock_screen_options.dart @@ -54,7 +54,7 @@ class _LockScreenOptionsState extends State { Future _deviceLock() async { await _lockscreenSetting.removePinAndPassword(); await _initializeSettings(); - await _lockscreenSetting.setAppLockType("Device lock"); + await _lockscreenSetting.setAppLockType(AppLockUpdateType.device); Bus.instance.fire( AppLockUpdateEvent( AppLockUpdateType.device, @@ -78,7 +78,7 @@ class _LockScreenOptionsState extends State { AppLockUpdateType.pin, ), ); - _lockscreenSetting.setAppLockType("Pin"); + _lockscreenSetting.setAppLockType(AppLockUpdateType.pin); appLock = isPinEnabled || isPasswordEnabled || _configuration.shouldShowSystemLockScreen(); @@ -102,7 +102,7 @@ class _LockScreenOptionsState extends State { AppLockUpdateType.password, ), ); - _lockscreenSetting.setAppLockType("Password"); + _lockscreenSetting.setAppLockType(AppLockUpdateType.password); appLock = isPinEnabled || isPasswordEnabled || _configuration.shouldShowSystemLockScreen(); @@ -114,7 +114,9 @@ class _LockScreenOptionsState extends State { AppLock.of(context)!.setEnabled(!appLock); await _configuration.setSystemLockScreen(!appLock); await _lockscreenSetting.removePinAndPassword(); - await _lockscreenSetting.setAppLockType(appLock ? "None" : "Device lock"); + await _lockscreenSetting.setAppLockType( + appLock ? AppLockUpdateType.none : AppLockUpdateType.device, + ); Bus.instance.fire( AppLockUpdateEvent( appLock ? AppLockUpdateType.none : AppLockUpdateType.device, @@ -147,7 +149,7 @@ class _LockScreenOptionsState extends State { } else if (duration.inSeconds != 0) { return "in ${duration.inSeconds} second${duration.inSeconds > 1 ? 's' : ''}"; } else { - return "Disable"; + return "Disabled"; } } diff --git a/auth/lib/utils/lock_screen_settings.dart b/auth/lib/utils/lock_screen_settings.dart index 8153e6b99f..01c42fd29f 100644 --- a/auth/lib/utils/lock_screen_settings.dart +++ b/auth/lib/utils/lock_screen_settings.dart @@ -1,6 +1,7 @@ import "dart:convert"; import "dart:typed_data"; +import "package:ente_auth/events/app_lock_update_event.dart"; import "package:ente_crypto_dart/ente_crypto_dart.dart"; import "package:flutter_secure_storage/flutter_secure_storage.dart"; import "package:shared_preferences/shared_preferences.dart"; @@ -34,8 +35,21 @@ class LockScreenSettings { _preferences = await SharedPreferences.getInstance(); } - Future setAppLockType(String lockType) async { - await _preferences.setString(appLockType, lockType); + Future setAppLockType(AppLockUpdateType lockType) async { + switch (lockType) { + case AppLockUpdateType.device: + await _preferences.setString(appLockType, "Device lock"); + break; + case AppLockUpdateType.pin: + await _preferences.setString(appLockType, "Pin"); + break; + case AppLockUpdateType.password: + await _preferences.setString(appLockType, "Password"); + break; + default: + await _preferences.setString(appLockType, "None"); + break; + } } String getAppLockType() { From e3e58eb9c2ec294d1cca776f23de8f02ac1b87bd Mon Sep 17 00:00:00 2001 From: Aman Raj Singh Mourya Date: Mon, 22 Jul 2024 14:58:03 +0530 Subject: [PATCH 011/123] [mob][auth] Show app content option added --- .../lock_screen/lock_screen_auto_lock.dart | 5 ++- .../lock_screen/lock_screen_options.dart | 42 ++++++++++++++++++- auth/lib/utils/lock_screen_settings.dart | 8 ++-- 3 files changed, 47 insertions(+), 8 deletions(-) diff --git a/auth/lib/ui/settings/lock_screen/lock_screen_auto_lock.dart b/auth/lib/ui/settings/lock_screen/lock_screen_auto_lock.dart index c0c1941eb5..0f0bc8b708 100644 --- a/auth/lib/ui/settings/lock_screen/lock_screen_auto_lock.dart +++ b/auth/lib/ui/settings/lock_screen/lock_screen_auto_lock.dart @@ -69,7 +69,8 @@ class AutoLockItems extends StatefulWidget { class _AutoLockItemsState extends State { final autoLockDurations = LockScreenSettings.instance.autoLockDurations; List items = []; - late Duration currentAutoLockTime; + Duration currentAutoLockTime = const Duration(seconds: 5); + @override void initState() { for (Duration autoLockDuration in autoLockDurations) { @@ -135,7 +136,7 @@ class _AutoLockItemsState extends State { } else if (duration.inSeconds != 0) { return "${duration.inSeconds}s"; } else { - return "Disabled"; + return "Immediately"; } } } diff --git a/auth/lib/ui/settings/lock_screen/lock_screen_options.dart b/auth/lib/ui/settings/lock_screen/lock_screen_options.dart index ba33b5e5e1..3c24734d6b 100644 --- a/auth/lib/ui/settings/lock_screen/lock_screen_options.dart +++ b/auth/lib/ui/settings/lock_screen/lock_screen_options.dart @@ -32,6 +32,7 @@ class _LockScreenOptionsState extends State { bool isPinEnabled = false; bool isPasswordEnabled = false; late int autoLockTimeInMilliseconds; + @override void initState() { super.initState(); @@ -149,7 +150,7 @@ class _LockScreenOptionsState extends State { } else if (duration.inSeconds != 0) { return "in ${duration.inSeconds} second${duration.inSeconds > 1 ? 's' : ''}"; } else { - return "Disabled"; + return "Immediately"; } } @@ -291,7 +292,44 @@ class _LockScreenOptionsState extends State { right: 12, ), child: Text( - "Require ${_lockscreenSetting.getAppLockType()} if away for some time .", + "Time after which the app locks after being put in the background", + style: textTheme.miniFaint, + textAlign: TextAlign.left, + ), + ), + const SizedBox(height: 24), + MenuItemWidget( + captionedTextWidget: CaptionedTextWidget( + title: "App content in Task switcher", + textStyle: textTheme.small.copyWith( + color: colorTheme.textMuted, + ), + ), + isBottomBorderRadiusRemoved: true, + alignCaptionedTextToLeft: true, + menuItemColor: colorTheme.fillFaint, + ), + MenuItemWidget( + captionedTextWidget: + const CaptionedTextWidget( + title: "Show Content", + ), + alignCaptionedTextToLeft: true, + isTopBorderRadiusRemoved: true, + menuItemColor: colorTheme.fillFaint, + trailingWidget: ToggleSwitchWidget( + value: () => appLock, + onChanged: () => _onToggleSwitch(), + ), + ), + Padding( + padding: const EdgeInsets.only( + top: 14, + left: 14, + right: 12, + ), + child: Text( + "If disabled app content will be displayed in the task switcher", style: textTheme.miniFaint, textAlign: TextAlign.left, ), diff --git a/auth/lib/utils/lock_screen_settings.dart b/auth/lib/utils/lock_screen_settings.dart index 01c42fd29f..c3fa3daa5e 100644 --- a/auth/lib/utils/lock_screen_settings.dart +++ b/auth/lib/utils/lock_screen_settings.dart @@ -20,16 +20,16 @@ class LockScreenSettings { static const appLockType = "ls_app_lock_type"; final List autoLockDurations = const [ Duration(seconds: 0), - Duration(seconds: 30), + Duration(seconds: 5), + Duration(seconds: 15), Duration(minutes: 1), Duration(minutes: 5), - Duration(minutes: 15), Duration(minutes: 30), - Duration(hours: 1), ]; late SharedPreferences _preferences; late FlutterSecureStorage _secureStorage; + Future init() async { _secureStorage = const FlutterSecureStorage(); _preferences = await SharedPreferences.getInstance(); @@ -61,7 +61,7 @@ class LockScreenSettings { } int getAutoLockTime() { - return _preferences.getInt(autoLockTime) ?? 0; + return _preferences.getInt(autoLockTime) ?? 5000; } Future setLastInvalidAttemptTime(int time) async { From 78306ccf1d9e0e866eb83e05196b0b141ffe1937 Mon Sep 17 00:00:00 2001 From: Aman Raj Singh Mourya Date: Mon, 22 Jul 2024 16:27:12 +0530 Subject: [PATCH 012/123] [mob][auth] Implemented logic for show app content --- auth/lib/main.dart | 23 ---- .../lock_screen/lock_screen_options.dart | 107 ++++++++++++------ auth/lib/utils/lock_screen_settings.dart | 28 +++++ 3 files changed, 98 insertions(+), 60 deletions(-) diff --git a/auth/lib/main.dart b/auth/lib/main.dart index de0257805d..bfc33d7ea8 100644 --- a/auth/lib/main.dart +++ b/auth/lib/main.dart @@ -28,12 +28,10 @@ import 'package:ente_auth/utils/window_protocol_handler.dart'; import 'package:ente_crypto_dart/ente_crypto_dart.dart'; import 'package:flutter/foundation.dart'; import "package:flutter/material.dart"; -import 'package:flutter/scheduler.dart'; import 'package:flutter/services.dart'; import 'package:flutter_displaymode/flutter_displaymode.dart'; import 'package:logging/logging.dart'; import 'package:path_provider/path_provider.dart'; -import 'package:privacy_screen/privacy_screen.dart'; import 'package:tray_manager/tray_manager.dart'; import 'package:window_manager/window_manager.dart'; @@ -85,7 +83,6 @@ void main() async { } await _runInForeground(); - await _setupPrivacyScreen(); if (Platform.isAndroid) { FlutterDisplayMode.setHighRefreshRate().ignore(); } @@ -176,23 +173,3 @@ Future _init(bool bool, {String? via}) async { await IconUtils.instance.init(); await LockScreenSettings.instance.init(); } - -Future _setupPrivacyScreen() async { - if (!PlatformUtil.isMobile() || kDebugMode) return; - final brightness = - SchedulerBinding.instance.platformDispatcher.platformBrightness; - bool isInDarkMode = brightness == Brightness.dark; - await PrivacyScreen.instance.enable( - iosOptions: const PrivacyIosOptions( - enablePrivacy: true, - privacyImageName: "LaunchImage", - lockTrigger: IosLockTrigger.didEnterBackground, - ), - androidOptions: const PrivacyAndroidOptions( - enableSecure: true, - ), - backgroundColor: isInDarkMode ? Colors.black : Colors.white, - blurEffect: - isInDarkMode ? PrivacyBlurEffect.dark : PrivacyBlurEffect.extraLight, - ); -} diff --git a/auth/lib/ui/settings/lock_screen/lock_screen_options.dart b/auth/lib/ui/settings/lock_screen/lock_screen_options.dart index 3c24734d6b..e0ec3dd63e 100644 --- a/auth/lib/ui/settings/lock_screen/lock_screen_options.dart +++ b/auth/lib/ui/settings/lock_screen/lock_screen_options.dart @@ -16,7 +16,9 @@ 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:ente_auth/utils/navigation_util.dart"; +import "package:ente_auth/utils/platform_util.dart"; import "package:flutter/material.dart"; +import "package:flutter/widgets.dart"; class LockScreenOptions extends StatefulWidget { const LockScreenOptions({super.key}); @@ -32,10 +34,12 @@ class _LockScreenOptionsState extends State { bool isPinEnabled = false; bool isPasswordEnabled = false; late int autoLockTimeInMilliseconds; + bool showAppContent = true; @override void initState() { super.initState(); + showAppContent = _lockscreenSetting.getShouldShowAppContent(); autoLockTimeInMilliseconds = _lockscreenSetting.getAutoLockTime(); _initializeSettings(); appLock = isPinEnabled || @@ -46,9 +50,12 @@ class _LockScreenOptionsState extends State { Future _initializeSettings() async { final bool passwordEnabled = await _lockscreenSetting.isPasswordSet(); final bool pinEnabled = await _lockscreenSetting.isPinSet(); + final bool shouldShowAppContent = + _lockscreenSetting.getShouldShowAppContent(); setState(() { isPasswordEnabled = passwordEnabled; isPinEnabled = pinEnabled; + showAppContent = shouldShowAppContent; }); } @@ -115,6 +122,9 @@ class _LockScreenOptionsState extends State { AppLock.of(context)!.setEnabled(!appLock); await _configuration.setSystemLockScreen(!appLock); await _lockscreenSetting.removePinAndPassword(); + if (appLock == true) { + await _lockscreenSetting.shouldShowAppContent(showAppContent: true); + } await _lockscreenSetting.setAppLockType( appLock ? AppLockUpdateType.none : AppLockUpdateType.device, ); @@ -142,6 +152,16 @@ class _LockScreenOptionsState extends State { ); } + Future _onShowContent() async { + showAppContent = _lockscreenSetting.getShouldShowAppContent(); + await _lockscreenSetting.shouldShowAppContent( + showAppContent: !showAppContent, + ); + setState(() { + showAppContent = !showAppContent; + }); + } + String _formatTime(Duration duration) { if (duration.inHours != 0) { return "in ${duration.inHours} hour${duration.inHours > 1 ? 's' : ''}"; @@ -297,43 +317,56 @@ class _LockScreenOptionsState extends State { textAlign: TextAlign.left, ), ), - const SizedBox(height: 24), - MenuItemWidget( - captionedTextWidget: CaptionedTextWidget( - title: "App content in Task switcher", - textStyle: textTheme.small.copyWith( - color: colorTheme.textMuted, - ), - ), - isBottomBorderRadiusRemoved: true, - alignCaptionedTextToLeft: true, - menuItemColor: colorTheme.fillFaint, - ), - MenuItemWidget( - captionedTextWidget: - const CaptionedTextWidget( - title: "Show Content", - ), - alignCaptionedTextToLeft: true, - isTopBorderRadiusRemoved: true, - menuItemColor: colorTheme.fillFaint, - trailingWidget: ToggleSwitchWidget( - value: () => appLock, - onChanged: () => _onToggleSwitch(), - ), - ), - Padding( - padding: const EdgeInsets.only( - top: 14, - left: 14, - right: 12, - ), - child: Text( - "If disabled app content will be displayed in the task switcher", - style: textTheme.miniFaint, - textAlign: TextAlign.left, - ), - ), + PlatformUtil.isMobile() + ? Column( + children: [ + const SizedBox(height: 24), + MenuItemWidget( + captionedTextWidget: + CaptionedTextWidget( + title: + "App content in Task switcher", + textStyle: + textTheme.small.copyWith( + color: colorTheme.textMuted, + ), + ), + isBottomBorderRadiusRemoved: true, + alignCaptionedTextToLeft: true, + menuItemColor: + colorTheme.fillFaint, + ), + MenuItemWidget( + captionedTextWidget: + const CaptionedTextWidget( + title: "Show Content", + ), + alignCaptionedTextToLeft: true, + isTopBorderRadiusRemoved: true, + menuItemColor: + colorTheme.fillFaint, + trailingWidget: + ToggleSwitchWidget( + value: () => showAppContent, + onChanged: () => + _onShowContent(), + ), + ), + Padding( + padding: const EdgeInsets.only( + top: 14, + left: 14, + right: 12, + ), + child: Text( + "If disabled app content will be displayed in the task switcher", + style: textTheme.miniFaint, + textAlign: TextAlign.left, + ), + ), + ], + ) + : Container(), ], ) : Container(), diff --git a/auth/lib/utils/lock_screen_settings.dart b/auth/lib/utils/lock_screen_settings.dart index c3fa3daa5e..071887c0b6 100644 --- a/auth/lib/utils/lock_screen_settings.dart +++ b/auth/lib/utils/lock_screen_settings.dart @@ -1,9 +1,13 @@ import "dart:convert"; import "dart:typed_data"; +import "dart:ui"; import "package:ente_auth/events/app_lock_update_event.dart"; import "package:ente_crypto_dart/ente_crypto_dart.dart"; +import "package:flutter/material.dart"; +import "package:flutter/scheduler.dart"; import "package:flutter_secure_storage/flutter_secure_storage.dart"; +import "package:privacy_screen/privacy_screen.dart"; import "package:shared_preferences/shared_preferences.dart"; class LockScreenSettings { @@ -18,6 +22,7 @@ class LockScreenSettings { static const lastInvalidAttemptTime = "ls_last_invalid_attempt_time"; static const autoLockTime = "ls_auto_lock_time"; static const appLockType = "ls_app_lock_type"; + static const keyShowAppContent = "ls_show_app_content"; final List autoLockDurations = const [ Duration(seconds: 0), Duration(seconds: 5), @@ -35,6 +40,29 @@ class LockScreenSettings { _preferences = await SharedPreferences.getInstance(); } + Future shouldShowAppContent({bool showAppContent = true}) async { + final brightness = + SchedulerBinding.instance.platformDispatcher.platformBrightness; + bool isInDarkMode = brightness == Brightness.dark; + showAppContent + ? PrivacyScreen.instance.disable() + : await PrivacyScreen.instance.enable( + iosOptions: const PrivacyIosOptions(), + androidOptions: const PrivacyAndroidOptions( + enableSecure: true, + ), + backgroundColor: isInDarkMode ? Colors.black : Colors.white, + blurEffect: isInDarkMode + ? PrivacyBlurEffect.dark + : PrivacyBlurEffect.extraLight, + ); + await _preferences.setBool(keyShowAppContent, showAppContent); + } + + bool getShouldShowAppContent() { + return _preferences.getBool(keyShowAppContent) ?? true; + } + Future setAppLockType(AppLockUpdateType lockType) async { switch (lockType) { case AppLockUpdateType.device: From e0beb414f9c73c5f42e8f9443122b5fb94f378dd Mon Sep 17 00:00:00 2001 From: Aman Raj Singh Mourya Date: Mon, 22 Jul 2024 16:32:34 +0530 Subject: [PATCH 013/123] [mob][auth] Removed app lock subtitle from the setting_section_widget --- auth/lib/events/app_lock_update_event.dart | 14 --------- .../lock_screen/lock_screen_options.dart | 29 ------------------- .../ui/settings/security_section_widget.dart | 18 ++---------- auth/lib/utils/lock_screen_settings.dart | 24 --------------- 4 files changed, 2 insertions(+), 83 deletions(-) delete mode 100644 auth/lib/events/app_lock_update_event.dart diff --git a/auth/lib/events/app_lock_update_event.dart b/auth/lib/events/app_lock_update_event.dart deleted file mode 100644 index 3c83cde08b..0000000000 --- a/auth/lib/events/app_lock_update_event.dart +++ /dev/null @@ -1,14 +0,0 @@ -import "package:ente_auth/events/event.dart"; - -enum AppLockUpdateType { - none, - device, - pin, - password, -} - -class AppLockUpdateEvent extends Event { - final AppLockUpdateType type; - - AppLockUpdateEvent(this.type); -} diff --git a/auth/lib/ui/settings/lock_screen/lock_screen_options.dart b/auth/lib/ui/settings/lock_screen/lock_screen_options.dart index e0ec3dd63e..685cc384db 100644 --- a/auth/lib/ui/settings/lock_screen/lock_screen_options.dart +++ b/auth/lib/ui/settings/lock_screen/lock_screen_options.dart @@ -1,8 +1,6 @@ import "dart:async"; import "package:ente_auth/core/configuration.dart"; -import "package:ente_auth/core/event_bus.dart"; -import "package:ente_auth/events/app_lock_update_event.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"; @@ -18,7 +16,6 @@ import "package:ente_auth/utils/lock_screen_settings.dart"; import "package:ente_auth/utils/navigation_util.dart"; import "package:ente_auth/utils/platform_util.dart"; import "package:flutter/material.dart"; -import "package:flutter/widgets.dart"; class LockScreenOptions extends StatefulWidget { const LockScreenOptions({super.key}); @@ -62,12 +59,6 @@ class _LockScreenOptionsState extends State { Future _deviceLock() async { await _lockscreenSetting.removePinAndPassword(); await _initializeSettings(); - await _lockscreenSetting.setAppLockType(AppLockUpdateType.device); - Bus.instance.fire( - AppLockUpdateEvent( - AppLockUpdateType.device, - ), - ); } Future _pinLock() async { @@ -81,12 +72,6 @@ class _LockScreenOptionsState extends State { setState(() { _initializeSettings(); if (result) { - Bus.instance.fire( - AppLockUpdateEvent( - AppLockUpdateType.pin, - ), - ); - _lockscreenSetting.setAppLockType(AppLockUpdateType.pin); appLock = isPinEnabled || isPasswordEnabled || _configuration.shouldShowSystemLockScreen(); @@ -105,12 +90,6 @@ class _LockScreenOptionsState extends State { setState(() { _initializeSettings(); if (result) { - Bus.instance.fire( - AppLockUpdateEvent( - AppLockUpdateType.password, - ), - ); - _lockscreenSetting.setAppLockType(AppLockUpdateType.password); appLock = isPinEnabled || isPasswordEnabled || _configuration.shouldShowSystemLockScreen(); @@ -125,14 +104,6 @@ class _LockScreenOptionsState extends State { if (appLock == true) { await _lockscreenSetting.shouldShowAppContent(showAppContent: true); } - await _lockscreenSetting.setAppLockType( - appLock ? AppLockUpdateType.none : AppLockUpdateType.device, - ); - Bus.instance.fire( - AppLockUpdateEvent( - appLock ? AppLockUpdateType.none : AppLockUpdateType.device, - ), - ); setState(() { _initializeSettings(); appLock = !appLock; diff --git a/auth/lib/ui/settings/security_section_widget.dart b/auth/lib/ui/settings/security_section_widget.dart index af45dab4b0..8fa3249bf5 100644 --- a/auth/lib/ui/settings/security_section_widget.dart +++ b/auth/lib/ui/settings/security_section_widget.dart @@ -2,8 +2,6 @@ import 'dart:async'; import 'dart:typed_data'; import 'package:ente_auth/core/configuration.dart'; -import 'package:ente_auth/core/event_bus.dart'; -import 'package:ente_auth/events/app_lock_update_event.dart'; import 'package:ente_auth/l10n/l10n.dart'; import 'package:ente_auth/models/user_details.dart'; import 'package:ente_auth/services/local_authentication_service.dart'; @@ -20,7 +18,6 @@ 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/lock_screen_settings.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'; @@ -40,25 +37,15 @@ class _SecuritySectionWidgetState extends State { final _config = Configuration.instance; late bool _hasLoggedIn; final Logger _logger = Logger('SecuritySectionWidget'); - late String appLockSubtitle; - late StreamSubscription _appLockUpdateEvent; + @override void initState() { _hasLoggedIn = _config.hasConfiguredAccount(); - appLockSubtitle = LockScreenSettings.instance.getAppLockType(); - _appLockUpdateEvent = Bus.instance.on().listen((event) { - if (mounted) { - setState(() { - appLockSubtitle = LockScreenSettings.instance.getAppLockType(); - }); - } - }); super.initState(); } @override void dispose() { - _appLockUpdateEvent.cancel(); super.dispose(); } @@ -150,9 +137,8 @@ class _SecuritySectionWidgetState extends State { } children.addAll([ MenuItemWidget( - captionedTextWidget: CaptionedTextWidget( + captionedTextWidget: const CaptionedTextWidget( title: "App lock", - subTitle: appLockSubtitle, ), trailingIcon: Icons.chevron_right_outlined, trailingIconIsMuted: true, diff --git a/auth/lib/utils/lock_screen_settings.dart b/auth/lib/utils/lock_screen_settings.dart index 071887c0b6..2f0e464288 100644 --- a/auth/lib/utils/lock_screen_settings.dart +++ b/auth/lib/utils/lock_screen_settings.dart @@ -1,8 +1,6 @@ import "dart:convert"; import "dart:typed_data"; -import "dart:ui"; -import "package:ente_auth/events/app_lock_update_event.dart"; import "package:ente_crypto_dart/ente_crypto_dart.dart"; import "package:flutter/material.dart"; import "package:flutter/scheduler.dart"; @@ -21,7 +19,6 @@ class LockScreenSettings { static const keyInvalidAttempts = "ls_invalid_attempts"; static const lastInvalidAttemptTime = "ls_last_invalid_attempt_time"; static const autoLockTime = "ls_auto_lock_time"; - static const appLockType = "ls_app_lock_type"; static const keyShowAppContent = "ls_show_app_content"; final List autoLockDurations = const [ Duration(seconds: 0), @@ -63,27 +60,6 @@ class LockScreenSettings { return _preferences.getBool(keyShowAppContent) ?? true; } - Future setAppLockType(AppLockUpdateType lockType) async { - switch (lockType) { - case AppLockUpdateType.device: - await _preferences.setString(appLockType, "Device lock"); - break; - case AppLockUpdateType.pin: - await _preferences.setString(appLockType, "Pin"); - break; - case AppLockUpdateType.password: - await _preferences.setString(appLockType, "Password"); - break; - default: - await _preferences.setString(appLockType, "None"); - break; - } - } - - String getAppLockType() { - return _preferences.getString(appLockType) ?? "None"; - } - Future setAutoLockTime(Duration duration) async { await _preferences.setInt(autoLockTime, duration.inMilliseconds); } From 8a35b71bb87a9e19377e99354ec746dbf51fd045 Mon Sep 17 00:00:00 2001 From: ashilkn Date: Tue, 23 Jul 2024 17:11:16 +0530 Subject: [PATCH 014/123] [mob][auth] Extract strings --- auth/lib/l10n/arb/app_en.arb | 13 ++++++- .../lock_screen/lock_screen_auto_lock.dart | 7 ++-- .../lock_screen_confirm_password.dart | 7 ++-- .../lock_screen/lock_screen_confirm_pin.dart | 3 +- .../lock_screen/lock_screen_password.dart | 9 ++--- .../ui/settings/security_section_widget.dart | 10 +++--- auth/lib/ui/tools/lock_screen.dart | 8 ++--- .../ui/two_factor_authentication_page.dart | 11 ------ auth/pubspec.lock | 36 +++++++++++++++++-- 9 files changed, 70 insertions(+), 34 deletions(-) diff --git a/auth/lib/l10n/arb/app_en.arb b/auth/lib/l10n/arb/app_en.arb index 1ef825f3f8..c7010cf14b 100644 --- a/auth/lib/l10n/arb/app_en.arb +++ b/auth/lib/l10n/arb/app_en.arb @@ -445,5 +445,16 @@ "updateNotAvailable": "Update not available", "viewRawCodes": "View raw codes", "rawCodes": "Raw codes", - "rawCodeData": "Raw code data" + "rawCodeData": "Raw code data", + "appLock": "App lock", + "noSystemLockFound": "No system lock found", + "toEnableAppLockPleaseSetupDevicePasscodeOrScreen": "To enable app lock, please setup device passcode or screen lock in your system settings.", + "autoLock": "Auto lock", + "immediately": "Immediately", + "reEnterPassword": "Re-enter password", + "reEnterPin": "Re-enter PIN", + "next": "Next", + "tooManyIncorrectAttempts": "Too many incorrect attempts", + "tapToUnlock": "Tap to unlock", + "setNewPassword": "Set new password" } \ No newline at end of file diff --git a/auth/lib/ui/settings/lock_screen/lock_screen_auto_lock.dart b/auth/lib/ui/settings/lock_screen/lock_screen_auto_lock.dart index 0f0bc8b708..869bb1e40a 100644 --- a/auth/lib/ui/settings/lock_screen/lock_screen_auto_lock.dart +++ b/auth/lib/ui/settings/lock_screen/lock_screen_auto_lock.dart @@ -1,3 +1,4 @@ +import 'package:ente_auth/l10n/l10n.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'; @@ -22,9 +23,9 @@ class _LockScreenAutoLockState extends State { body: CustomScrollView( primary: false, slivers: [ - const TitleBarWidget( + TitleBarWidget( flexibleSpaceTitle: TitleBarTitleWidget( - title: "Auto lock", + title: context.l10n.autoLock, ), ), SliverList( @@ -136,7 +137,7 @@ class _AutoLockItemsState extends State { } else if (duration.inSeconds != 0) { return "${duration.inSeconds}s"; } else { - return "Immediately"; + return context.l10n.immediately; } } } 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 index c24d35391d..dee6fd2db9 100644 --- a/auth/lib/ui/settings/lock_screen/lock_screen_confirm_password.dart +++ b/auth/lib/ui/settings/lock_screen/lock_screen_confirm_password.dart @@ -1,3 +1,4 @@ +import "package:ente_auth/l10n/l10n.dart"; 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"; @@ -87,7 +88,7 @@ class _LockScreenConfirmPasswordState extends State { builder: (context, isFormValid, child) { return DynamicFAB( isKeypadOpen: isKeypadOpen, - buttonText: "Confirm", + buttonText: context.l10n.confirm, isFormValid: isFormValid, onPressedFunction: () async { _submitNotifier.value = !_submitNotifier.value; @@ -152,14 +153,14 @@ class _LockScreenConfirmPasswordState extends State { ), ), Text( - "Re-enter Password", + context.l10n.reEnterPassword, style: textTheme.bodyBold, ), const Padding(padding: EdgeInsets.all(12)), Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: TextInputWidget( - hintText: "Confirm Password", + hintText: context.l10n.confirmPassword, autoFocus: true, textCapitalization: TextCapitalization.none, isPasswordInput: true, 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 index 77a2c155fc..5ed7c48796 100644 --- a/auth/lib/ui/settings/lock_screen/lock_screen_confirm_pin.dart +++ b/auth/lib/ui/settings/lock_screen/lock_screen_confirm_pin.dart @@ -1,5 +1,6 @@ import "dart:io"; +import "package:ente_auth/l10n/l10n.dart"; 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"; @@ -158,7 +159,7 @@ class _LockScreenConfirmPinState extends State { ), ), Text( - "Re-enter PIN", + context.l10n.reEnterPin, style: textTheme.bodyBold, ), const Padding(padding: EdgeInsets.all(12)), diff --git a/auth/lib/ui/settings/lock_screen/lock_screen_password.dart b/auth/lib/ui/settings/lock_screen/lock_screen_password.dart index 5252a2def9..43a103523a 100644 --- a/auth/lib/ui/settings/lock_screen/lock_screen_password.dart +++ b/auth/lib/ui/settings/lock_screen/lock_screen_password.dart @@ -1,5 +1,6 @@ import "dart:convert"; +import "package:ente_auth/l10n/l10n.dart"; 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"; @@ -98,7 +99,7 @@ class _LockScreenPasswordState extends State { builder: (context, isFormValid, child) { return DynamicFAB( isKeypadOpen: isKeypadOpen, - buttonText: "Next", + buttonText: context.l10n.next, isFormValid: isFormValid, onPressedFunction: () async { _submitNotifier.value = !_submitNotifier.value; @@ -164,8 +165,8 @@ class _LockScreenPasswordState extends State { ), Text( widget.isChangingLockScreenSettings - ? "Enter Password" - : "Set new Password", + ? context.l10n.enterPassword + : context.l10n.setNewPassword, textAlign: TextAlign.center, style: textTheme.bodyBold, ), @@ -173,7 +174,7 @@ class _LockScreenPasswordState extends State { Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: TextInputWidget( - hintText: "Password", + hintText: context.l10n.password, autoFocus: true, textCapitalization: TextCapitalization.none, isPasswordInput: true, diff --git a/auth/lib/ui/settings/security_section_widget.dart b/auth/lib/ui/settings/security_section_widget.dart index 8fa3249bf5..b98c59d7d8 100644 --- a/auth/lib/ui/settings/security_section_widget.dart +++ b/auth/lib/ui/settings/security_section_widget.dart @@ -137,8 +137,8 @@ class _SecuritySectionWidgetState extends State { } children.addAll([ MenuItemWidget( - captionedTextWidget: const CaptionedTextWidget( - title: "App lock", + captionedTextWidget: CaptionedTextWidget( + title: context.l10n.appLock, ), trailingIcon: Icons.chevron_right_outlined, trailingIconIsMuted: true, @@ -146,7 +146,7 @@ class _SecuritySectionWidgetState extends State { if (await LocalAuthentication().isDeviceSupported()) { final bool result = await requestAuthentication( context, - "Please authenticate to change lockscreen setting", + context.l10n.authToChangeLockscreenSetting, ); if (result) { await Navigator.of(context).push( @@ -160,8 +160,8 @@ class _SecuritySectionWidgetState extends State { } else { await showErrorDialog( context, - "No system lock found", - "To enable app lock, please setup device passcode or screen lock in your system settings.", + context.l10n.noSystemLockFound, + context.l10n.toEnableAppLockPleaseSetupDevicePasscodeOrScreen, ); } }, diff --git a/auth/lib/ui/tools/lock_screen.dart b/auth/lib/ui/tools/lock_screen.dart index fcacea8231..a77ba6d155 100644 --- a/auth/lib/ui/tools/lock_screen.dart +++ b/auth/lib/ui/tools/lock_screen.dart @@ -149,7 +149,7 @@ class _LockScreenState extends State with WidgetsBindingObserver { alignment: Alignment.center, children: [ Text( - "Too many incorrect attempts", + context.l10n.tooManyIncorrectAttempts, style: textTheme.small, ) .animate( @@ -175,7 +175,7 @@ class _LockScreenState extends State with WidgetsBindingObserver { : GestureDetector( onTap: () => _showLockScreen(source: "tap"), child: Text( - "Tap to unlock", + context.l10n.tapToUnlock, style: textTheme.small, ), ), @@ -201,8 +201,8 @@ class _LockScreenState extends State with WidgetsBindingObserver { void _onLogoutTapped(BuildContext context) { showChoiceActionSheet( context, - title: "Are you sure you want to logout?", - firstButtonLabel: "Yes, logout", + title: context.l10n.areYouSureYouWantToLogout, + firstButtonLabel: context.l10n.yesLogout, isCritical: true, firstButtonOnTap: () async { await UserService.instance.logout(context); diff --git a/auth/lib/ui/two_factor_authentication_page.dart b/auth/lib/ui/two_factor_authentication_page.dart index 14b352a95f..86dfa503e9 100644 --- a/auth/lib/ui/two_factor_authentication_page.dart +++ b/auth/lib/ui/two_factor_authentication_page.dart @@ -102,10 +102,6 @@ class _TwoFactorAuthenticationPageState ), ), ), - // submittedFieldDecoration: _pinPutDecoration.copyWith( - // borderRadius: BorderRadius.circular(20.0), - // ), - // selectedFieldDecoration: _pinPutDecoration, defaultPinTheme: _pinPutDecoration, followingPinTheme: _pinPutDecoration.copyWith( decoration: BoxDecoration( @@ -115,13 +111,6 @@ class _TwoFactorAuthenticationPageState ), ), ), - // 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/pubspec.lock b/auth/pubspec.lock index 99f3295f5f..8d3705ac5f 100644 --- a/auth/pubspec.lock +++ b/auth/pubspec.lock @@ -440,6 +440,14 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_animate: + dependency: "direct main" + description: + name: flutter_animate + sha256: "7c8a6594a9252dad30cc2ef16e33270b6248c4dedc3b3d06c86c4f3f4dc05ae5" + url: "https://pub.dev" + source: hosted + version: "4.5.0" flutter_bloc: dependency: "direct main" description: @@ -639,6 +647,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.2" + flutter_shaders: + dependency: transitive + description: + name: flutter_shaders + sha256: "02750b545c01ff4d8e9bbe8f27a7731aa3778402506c67daa1de7f5fc3f4befe" + url: "https://pub.dev" + source: hosted + version: "0.1.2" flutter_slidable: dependency: "direct main" description: @@ -1133,10 +1149,10 @@ packages: dependency: "direct main" description: name: pinput - sha256: "27eb69042f75755bdb6544f6e79a50a6ed09d6e97e2d75c8421744df1e392949" + sha256: "543da5bfdefd9e06914a12100f8c9156f84cef3efc14bca507c49e966c5b813b" url: "https://pub.dev" source: hosted - version: "1.2.2" + version: "2.3.0" platform: dependency: transitive description: @@ -1358,6 +1374,14 @@ packages: description: flutter source: sdk version: "0.0.99" + smart_auth: + dependency: transitive + description: + name: smart_auth + sha256: a25229b38c02f733d0a4e98d941b42bed91a976cb589e934895e60ccfa674cf6 + url: "https://pub.dev" + source: hosted + version: "1.1.1" sodium: dependency: transitive description: @@ -1575,6 +1599,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.2" + universal_platform: + dependency: transitive + description: + name: universal_platform + sha256: "64e16458a0ea9b99260ceb5467a214c1f298d647c659af1bff6d3bf82536b1ec" + url: "https://pub.dev" + source: hosted + version: "1.1.0" url_launcher: dependency: "direct main" description: From 47203af4ff4792b59a07d4f4f71c21274d91b660 Mon Sep 17 00:00:00 2001 From: ashilkn Date: Tue, 23 Jul 2024 17:13:30 +0530 Subject: [PATCH 015/123] [mob][auth] Bump up pinput --- auth/pubspec.lock | 12 ++---------- auth/pubspec.yaml | 2 +- 2 files changed, 3 insertions(+), 11 deletions(-) diff --git a/auth/pubspec.lock b/auth/pubspec.lock index 8d3705ac5f..12908fc253 100644 --- a/auth/pubspec.lock +++ b/auth/pubspec.lock @@ -1149,10 +1149,10 @@ packages: dependency: "direct main" description: name: pinput - sha256: "543da5bfdefd9e06914a12100f8c9156f84cef3efc14bca507c49e966c5b813b" + sha256: "7bf9aa7d0eeb3da9f7d49d2087c7bc7d36cd277d2e94cc31c6da52e1ebb048d0" url: "https://pub.dev" source: hosted - version: "2.3.0" + version: "5.0.0" platform: dependency: transitive description: @@ -1374,14 +1374,6 @@ packages: description: flutter source: sdk version: "0.0.99" - smart_auth: - dependency: transitive - description: - name: smart_auth - sha256: a25229b38c02f733d0a4e98d941b42bed91a976cb589e934895e60ccfa674cf6 - url: "https://pub.dev" - source: hosted - version: "1.1.1" sodium: dependency: transitive description: diff --git a/auth/pubspec.yaml b/auth/pubspec.yaml index c6ac0a8152..393e4dda62 100644 --- a/auth/pubspec.yaml +++ b/auth/pubspec.yaml @@ -78,7 +78,7 @@ dependencies: password_strength: ^0.2.0 path: ^1.8.3 path_provider: ^2.0.11 - pinput: ^2.0.2 + pinput: ^5.0.0 pointycastle: ^3.7.3 privacy_screen: ^0.0.6 protobuf: ^3.0.0 From 875b0798504355e35b657e682e438972379c8444 Mon Sep 17 00:00:00 2001 From: ashilkn Date: Tue, 23 Jul 2024 17:17:04 +0530 Subject: [PATCH 016/123] [mob] Minor refactor --- auth/lib/utils/lock_screen_settings.dart | 7 ++----- mobile/lib/utils/lock_screen_settings.dart | 7 ++----- 2 files changed, 4 insertions(+), 10 deletions(-) diff --git a/auth/lib/utils/lock_screen_settings.dart b/auth/lib/utils/lock_screen_settings.dart index 2f0e464288..9415e3828d 100644 --- a/auth/lib/utils/lock_screen_settings.dart +++ b/auth/lib/utils/lock_screen_settings.dart @@ -131,11 +131,8 @@ class LockScreenSettings { sodium, ); - 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.write(key: saltKey, value: base64Encode(salt)); + await _secureStorage.write(key: password, value: base64Encode(hash)); await _secureStorage.delete(key: pin); return; diff --git a/mobile/lib/utils/lock_screen_settings.dart b/mobile/lib/utils/lock_screen_settings.dart index d8d5cc511f..aef012cc31 100644 --- a/mobile/lib/utils/lock_screen_settings.dart +++ b/mobile/lib/utils/lock_screen_settings.dart @@ -103,11 +103,8 @@ class LockScreenSettings { "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.write(key: saltKey, value: base64Encode(salt)); + await _secureStorage.write(key: password, value: base64Encode(hash)); await _secureStorage.delete(key: pin); return; From a73de2848eeeeb313f73544a0099444561415024 Mon Sep 17 00:00:00 2001 From: ashilkn Date: Tue, 23 Jul 2024 17:19:04 +0530 Subject: [PATCH 017/123] [mob][auth] Remove unused import --- auth/lib/main.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/auth/lib/main.dart b/auth/lib/main.dart index 9092bf58bb..452f2a90f8 100644 --- a/auth/lib/main.dart +++ b/auth/lib/main.dart @@ -29,7 +29,6 @@ import 'package:ente_auth/utils/window_protocol_handler.dart'; import 'package:ente_crypto_dart/ente_crypto_dart.dart'; import 'package:flutter/foundation.dart'; import "package:flutter/material.dart"; -import 'package:flutter/services.dart'; import 'package:flutter_displaymode/flutter_displaymode.dart'; import 'package:logging/logging.dart'; import 'package:path_provider/path_provider.dart'; From 869ecb832ebc280a9f12d68903d2f102a3e4a53b Mon Sep 17 00:00:00 2001 From: Aman Raj Singh Mourya Date: Tue, 23 Jul 2024 23:30:36 +0530 Subject: [PATCH 018/123] [mob][auth] Minor fixes and used better names --- .../lock_screen/lock_screen_options.dart | 48 +++++++------------ auth/lib/utils/lock_screen_settings.dart | 18 ++++--- 2 files changed, 28 insertions(+), 38 deletions(-) diff --git a/auth/lib/ui/settings/lock_screen/lock_screen_options.dart b/auth/lib/ui/settings/lock_screen/lock_screen_options.dart index 685cc384db..ed75a47e65 100644 --- a/auth/lib/ui/settings/lock_screen/lock_screen_options.dart +++ b/auth/lib/ui/settings/lock_screen/lock_screen_options.dart @@ -31,12 +31,12 @@ class _LockScreenOptionsState extends State { bool isPinEnabled = false; bool isPasswordEnabled = false; late int autoLockTimeInMilliseconds; - bool showAppContent = true; + late bool hideAppContent; @override void initState() { super.initState(); - showAppContent = _lockscreenSetting.getShouldShowAppContent(); + hideAppContent = _lockscreenSetting.getShouldHideAppContent(); autoLockTimeInMilliseconds = _lockscreenSetting.getAutoLockTime(); _initializeSettings(); appLock = isPinEnabled || @@ -47,12 +47,12 @@ class _LockScreenOptionsState extends State { Future _initializeSettings() async { final bool passwordEnabled = await _lockscreenSetting.isPasswordSet(); final bool pinEnabled = await _lockscreenSetting.isPinSet(); - final bool shouldShowAppContent = - _lockscreenSetting.getShouldShowAppContent(); + final bool shouldHideAppContent = + _lockscreenSetting.getShouldHideAppContent(); setState(() { isPasswordEnabled = passwordEnabled; isPinEnabled = pinEnabled; - showAppContent = shouldShowAppContent; + hideAppContent = shouldHideAppContent; }); } @@ -102,7 +102,7 @@ class _LockScreenOptionsState extends State { await _configuration.setSystemLockScreen(!appLock); await _lockscreenSetting.removePinAndPassword(); if (appLock == true) { - await _lockscreenSetting.shouldShowAppContent(showAppContent: true); + await _lockscreenSetting.shouldHideAppContent(isContentVisible: false); } setState(() { _initializeSettings(); @@ -123,14 +123,13 @@ class _LockScreenOptionsState extends State { ); } - Future _onShowContent() async { - showAppContent = _lockscreenSetting.getShouldShowAppContent(); - await _lockscreenSetting.shouldShowAppContent( - showAppContent: !showAppContent, - ); + Future _onHideContent() async { setState(() { - showAppContent = !showAppContent; + hideAppContent = !hideAppContent; }); + await _lockscreenSetting.shouldHideAppContent( + isContentVisible: hideAppContent, + ); } String _formatTime(Duration duration) { @@ -290,27 +289,14 @@ class _LockScreenOptionsState extends State { ), PlatformUtil.isMobile() ? Column( + crossAxisAlignment: + CrossAxisAlignment.start, children: [ const SizedBox(height: 24), - MenuItemWidget( - captionedTextWidget: - CaptionedTextWidget( - title: - "App content in Task switcher", - textStyle: - textTheme.small.copyWith( - color: colorTheme.textMuted, - ), - ), - isBottomBorderRadiusRemoved: true, - alignCaptionedTextToLeft: true, - menuItemColor: - colorTheme.fillFaint, - ), MenuItemWidget( captionedTextWidget: const CaptionedTextWidget( - title: "Show Content", + title: "Hide Content", ), alignCaptionedTextToLeft: true, isTopBorderRadiusRemoved: true, @@ -318,9 +304,9 @@ class _LockScreenOptionsState extends State { colorTheme.fillFaint, trailingWidget: ToggleSwitchWidget( - value: () => showAppContent, + value: () => hideAppContent, onChanged: () => - _onShowContent(), + _onHideContent(), ), ), Padding( @@ -330,7 +316,7 @@ class _LockScreenOptionsState extends State { right: 12, ), child: Text( - "If disabled app content will be displayed in the task switcher", + "Hides app content in the app switcher", style: textTheme.miniFaint, textAlign: TextAlign.left, ), diff --git a/auth/lib/utils/lock_screen_settings.dart b/auth/lib/utils/lock_screen_settings.dart index 9415e3828d..bd19582280 100644 --- a/auth/lib/utils/lock_screen_settings.dart +++ b/auth/lib/utils/lock_screen_settings.dart @@ -19,7 +19,7 @@ class LockScreenSettings { static const keyInvalidAttempts = "ls_invalid_attempts"; static const lastInvalidAttemptTime = "ls_last_invalid_attempt_time"; static const autoLockTime = "ls_auto_lock_time"; - static const keyShowAppContent = "ls_show_app_content"; + static const keyHideAppContent = "ls_show_app_content"; final List autoLockDurations = const [ Duration(seconds: 0), Duration(seconds: 5), @@ -35,16 +35,19 @@ class LockScreenSettings { Future init() async { _secureStorage = const FlutterSecureStorage(); _preferences = await SharedPreferences.getInstance(); + await shouldHideAppContent(isContentVisible: getShouldHideAppContent()); } - Future shouldShowAppContent({bool showAppContent = true}) async { + Future shouldHideAppContent({bool isContentVisible = true}) async { final brightness = SchedulerBinding.instance.platformDispatcher.platformBrightness; bool isInDarkMode = brightness == Brightness.dark; - showAppContent + !isContentVisible ? PrivacyScreen.instance.disable() : await PrivacyScreen.instance.enable( - iosOptions: const PrivacyIosOptions(), + iosOptions: const PrivacyIosOptions( + enablePrivacy: true, + ), androidOptions: const PrivacyAndroidOptions( enableSecure: true, ), @@ -53,11 +56,11 @@ class LockScreenSettings { ? PrivacyBlurEffect.dark : PrivacyBlurEffect.extraLight, ); - await _preferences.setBool(keyShowAppContent, showAppContent); + await _preferences.setBool(keyHideAppContent, isContentVisible); } - bool getShouldShowAppContent() { - return _preferences.getBool(keyShowAppContent) ?? true; + bool getShouldHideAppContent() { + return _preferences.getBool(keyHideAppContent) ?? false; } Future setAutoLockTime(Duration duration) async { @@ -146,6 +149,7 @@ class LockScreenSettings { await _secureStorage.delete(key: saltKey); await _secureStorage.delete(key: pin); await _secureStorage.delete(key: password); + await _preferences.remove(keyHideAppContent); } Future isPinSet() async { From 275e521c4007735b39d2fb5c86275dab71a80099 Mon Sep 17 00:00:00 2001 From: Aman Raj Singh Mourya Date: Wed, 24 Jul 2024 16:12:31 +0530 Subject: [PATCH 019/123] [mob][auth] Fixes --- .../lock_screen/lock_screen_options.dart | 37 +++++++++---------- auth/lib/utils/lock_screen_settings.dart | 11 +++--- 2 files changed, 24 insertions(+), 24 deletions(-) diff --git a/auth/lib/ui/settings/lock_screen/lock_screen_options.dart b/auth/lib/ui/settings/lock_screen/lock_screen_options.dart index ed75a47e65..40347f54cb 100644 --- a/auth/lib/ui/settings/lock_screen/lock_screen_options.dart +++ b/auth/lib/ui/settings/lock_screen/lock_screen_options.dart @@ -1,6 +1,8 @@ import "dart:async"; +import "dart:io"; import "package:ente_auth/core/configuration.dart"; +import "package:ente_auth/l10n/l10n.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"; @@ -47,12 +49,10 @@ class _LockScreenOptionsState extends State { Future _initializeSettings() async { final bool passwordEnabled = await _lockscreenSetting.isPasswordSet(); final bool pinEnabled = await _lockscreenSetting.isPinSet(); - final bool shouldHideAppContent = - _lockscreenSetting.getShouldHideAppContent(); setState(() { isPasswordEnabled = passwordEnabled; isPinEnabled = pinEnabled; - hideAppContent = shouldHideAppContent; + hideAppContent = _lockscreenSetting.getShouldHideAppContent(); }); } @@ -101,8 +101,8 @@ class _LockScreenOptionsState extends State { AppLock.of(context)!.setEnabled(!appLock); await _configuration.setSystemLockScreen(!appLock); await _lockscreenSetting.removePinAndPassword(); - if (appLock == true) { - await _lockscreenSetting.shouldHideAppContent(isContentVisible: false); + if (appLock == false) { + await _lockscreenSetting.setHideAppContent(true); } setState(() { _initializeSettings(); @@ -127,9 +127,7 @@ class _LockScreenOptionsState extends State { setState(() { hideAppContent = !hideAppContent; }); - await _lockscreenSetting.shouldHideAppContent( - isContentVisible: hideAppContent, - ); + await _lockscreenSetting.setHideAppContent(hideAppContent); } String _formatTime(Duration duration) { @@ -152,9 +150,9 @@ class _LockScreenOptionsState extends State { body: CustomScrollView( primary: false, slivers: [ - const TitleBarWidget( + TitleBarWidget( flexibleSpaceTitle: TitleBarTitleWidget( - title: 'App lock', + title: context.l10n.appLock, ), ), SliverList( @@ -170,8 +168,8 @@ class _LockScreenOptionsState extends State { Column( children: [ MenuItemWidget( - captionedTextWidget: const CaptionedTextWidget( - title: 'App lock', + captionedTextWidget: CaptionedTextWidget( + title: context.l10n.appLock, ), alignCaptionedTextToLeft: true, singleBorderRadius: 8, @@ -205,9 +203,8 @@ class _LockScreenOptionsState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ MenuItemWidget( - captionedTextWidget: - const CaptionedTextWidget( - title: "Device lock", + captionedTextWidget: CaptionedTextWidget( + title: context.l10n.deviceLock, ), alignCaptionedTextToLeft: true, isTopBorderRadiusRemoved: false, @@ -261,7 +258,7 @@ class _LockScreenOptionsState extends State { ), MenuItemWidget( captionedTextWidget: CaptionedTextWidget( - title: "Auto lock", + title: context.l10n.autoLock, subTitle: _formatTime( Duration( milliseconds: @@ -295,8 +292,8 @@ class _LockScreenOptionsState extends State { const SizedBox(height: 24), MenuItemWidget( captionedTextWidget: - const CaptionedTextWidget( - title: "Hide Content", + CaptionedTextWidget( + title: context.l10n.deviceLock, ), alignCaptionedTextToLeft: true, isTopBorderRadiusRemoved: true, @@ -316,7 +313,9 @@ class _LockScreenOptionsState extends State { right: 12, ), child: Text( - "Hides app content in the app switcher", + Platform.isAndroid + ? "Hides app content in the app switcher and disables screenshots" + : "Hides app content in the app switcher", style: textTheme.miniFaint, textAlign: TextAlign.left, ), diff --git a/auth/lib/utils/lock_screen_settings.dart b/auth/lib/utils/lock_screen_settings.dart index bd19582280..17bcde97e5 100644 --- a/auth/lib/utils/lock_screen_settings.dart +++ b/auth/lib/utils/lock_screen_settings.dart @@ -35,14 +35,16 @@ class LockScreenSettings { Future init() async { _secureStorage = const FlutterSecureStorage(); _preferences = await SharedPreferences.getInstance(); - await shouldHideAppContent(isContentVisible: getShouldHideAppContent()); + + ///Workaround for privacyScreen not working when app is killed and opened. + await setHideAppContent(getShouldHideAppContent()); } - Future shouldHideAppContent({bool isContentVisible = true}) async { + Future setHideAppContent(bool showContent) async { final brightness = SchedulerBinding.instance.platformDispatcher.platformBrightness; bool isInDarkMode = brightness == Brightness.dark; - !isContentVisible + !showContent ? PrivacyScreen.instance.disable() : await PrivacyScreen.instance.enable( iosOptions: const PrivacyIosOptions( @@ -56,7 +58,7 @@ class LockScreenSettings { ? PrivacyBlurEffect.dark : PrivacyBlurEffect.extraLight, ); - await _preferences.setBool(keyHideAppContent, isContentVisible); + await _preferences.setBool(keyHideAppContent, showContent); } bool getShouldHideAppContent() { @@ -149,7 +151,6 @@ class LockScreenSettings { await _secureStorage.delete(key: saltKey); await _secureStorage.delete(key: pin); await _secureStorage.delete(key: password); - await _preferences.remove(keyHideAppContent); } Future isPinSet() async { From 386a2f841e12a63fff3920ea199c89022f14d8cb Mon Sep 17 00:00:00 2001 From: Aman Raj Singh Mourya Date: Wed, 24 Jul 2024 16:35:47 +0530 Subject: [PATCH 020/123] [mob][auth] Extracted strings --- auth/lib/l10n/arb/app_en.arb | 15 ++++++++++--- .../lock_screen/lock_screen_options.dart | 22 +++++++++---------- .../settings/lock_screen/lock_screen_pin.dart | 5 ++++- auth/lib/utils/lock_screen_settings.dart | 6 ++--- 4 files changed, 30 insertions(+), 18 deletions(-) diff --git a/auth/lib/l10n/arb/app_en.arb b/auth/lib/l10n/arb/app_en.arb index c7010cf14b..94698bf0b2 100644 --- a/auth/lib/l10n/arb/app_en.arb +++ b/auth/lib/l10n/arb/app_en.arb @@ -421,9 +421,9 @@ "waitingForVerification": "Waiting for verification...", "passkey": "Passkey", "passKeyPendingVerification": "Verification is still pending", - "loginSessionExpired" : "Session expired", + "loginSessionExpired": "Session expired", "loginSessionExpiredDetails": "Your session has expired. Please login again.", - "developerSettingsWarning":"Are you sure that you want to modify Developer settings?", + "developerSettingsWarning": "Are you sure that you want to modify Developer settings?", "developerSettings": "Developer settings", "serverEndpoint": "Server endpoint", "invalidEndpoint": "Invalid endpoint", @@ -456,5 +456,14 @@ "next": "Next", "tooManyIncorrectAttempts": "Too many incorrect attempts", "tapToUnlock": "Tap to unlock", - "setNewPassword": "Set new password" + "setNewPassword": "Set new password", + "deviceLock": "Device lock", + "hideContent": "Hide content", + "hideContentDescriptionAndroid": "Hides app content in the app switcher and disables screenshots", + "hideContentDescriptioniOS": "Hides app content in the app switcher", + "autoLockFeatureDescription": "Time after which the app locks after being put in the background", + "appLockDescription": "Choose between your device's default lock screen and a custom lock screen with a PIN or password.", + "pinLock": "Pin lock", + "enterPin": "Enter PIN", + "setNewPin": "Set new PIN" } \ No newline at end of file diff --git a/auth/lib/ui/settings/lock_screen/lock_screen_options.dart b/auth/lib/ui/settings/lock_screen/lock_screen_options.dart index 40347f54cb..de4d2f5173 100644 --- a/auth/lib/ui/settings/lock_screen/lock_screen_options.dart +++ b/auth/lib/ui/settings/lock_screen/lock_screen_options.dart @@ -138,7 +138,7 @@ class _LockScreenOptionsState extends State { } else if (duration.inSeconds != 0) { return "in ${duration.inSeconds} second${duration.inSeconds > 1 ? 's' : ''}"; } else { - return "Immediately"; + return context.l10n.immediately; } } @@ -187,7 +187,7 @@ class _LockScreenOptionsState extends State { right: 12, ), child: Text( - 'Choose between your device\'s default lock screen and a custom lock screen with a PIN or password.', + context.l10n.appLockDescription, style: textTheme.miniFaint, textAlign: TextAlign.left, ), @@ -222,9 +222,8 @@ class _LockScreenOptionsState extends State { bgColor: colorTheme.fillFaint, ), MenuItemWidget( - captionedTextWidget: - const CaptionedTextWidget( - title: "Pin lock", + captionedTextWidget: CaptionedTextWidget( + title: context.l10n.pinLock, ), alignCaptionedTextToLeft: true, isTopBorderRadiusRemoved: true, @@ -240,9 +239,8 @@ class _LockScreenOptionsState extends State { bgColor: colorTheme.fillFaint, ), MenuItemWidget( - captionedTextWidget: - const CaptionedTextWidget( - title: "Password", + captionedTextWidget: CaptionedTextWidget( + title: context.l10n.password, ), alignCaptionedTextToLeft: true, isTopBorderRadiusRemoved: true, @@ -279,7 +277,7 @@ class _LockScreenOptionsState extends State { right: 12, ), child: Text( - "Time after which the app locks after being put in the background", + context.l10n.autoLockFeatureDescription, style: textTheme.miniFaint, textAlign: TextAlign.left, ), @@ -314,8 +312,10 @@ class _LockScreenOptionsState extends State { ), child: Text( Platform.isAndroid - ? "Hides app content in the app switcher and disables screenshots" - : "Hides app content in the app switcher", + ? context.l10n + .hideContentDescriptionAndroid + : context.l10n + .hideContentDescriptioniOS, style: textTheme.miniFaint, textAlign: TextAlign.left, ), diff --git a/auth/lib/ui/settings/lock_screen/lock_screen_pin.dart b/auth/lib/ui/settings/lock_screen/lock_screen_pin.dart index 2488a2e39f..8cd4509f51 100644 --- a/auth/lib/ui/settings/lock_screen/lock_screen_pin.dart +++ b/auth/lib/ui/settings/lock_screen/lock_screen_pin.dart @@ -1,6 +1,7 @@ import "dart:convert"; import "dart:io"; +import "package:ente_auth/l10n/l10n.dart"; import "package:ente_auth/theme/colors.dart"; import "package:ente_auth/theme/ente_theme.dart"; import "package:ente_auth/theme/text_style.dart"; @@ -229,7 +230,9 @@ class _LockScreenPinState extends State { ), ), Text( - widget.isChangingLockScreenSettings ? "Enter PIN" : "Set new PIN", + widget.isChangingLockScreenSettings + ? context.l10n.enterPin + : context.l10n.setNewPin, style: textTheme.bodyBold, ), const Padding(padding: EdgeInsets.all(12)), diff --git a/auth/lib/utils/lock_screen_settings.dart b/auth/lib/utils/lock_screen_settings.dart index 17bcde97e5..9c9638cd8a 100644 --- a/auth/lib/utils/lock_screen_settings.dart +++ b/auth/lib/utils/lock_screen_settings.dart @@ -40,11 +40,11 @@ class LockScreenSettings { await setHideAppContent(getShouldHideAppContent()); } - Future setHideAppContent(bool showContent) async { + Future setHideAppContent(bool hideContent) async { final brightness = SchedulerBinding.instance.platformDispatcher.platformBrightness; bool isInDarkMode = brightness == Brightness.dark; - !showContent + !hideContent ? PrivacyScreen.instance.disable() : await PrivacyScreen.instance.enable( iosOptions: const PrivacyIosOptions( @@ -58,7 +58,7 @@ class LockScreenSettings { ? PrivacyBlurEffect.dark : PrivacyBlurEffect.extraLight, ); - await _preferences.setBool(keyHideAppContent, showContent); + await _preferences.setBool(keyHideAppContent, hideContent); } bool getShouldHideAppContent() { From 3fd7100dd75a93ed1de8335b6a40c4aa6767c9ae Mon Sep 17 00:00:00 2001 From: Aman Raj Singh Mourya Date: Wed, 24 Jul 2024 17:26:49 +0530 Subject: [PATCH 021/123] [mob][auth] Used better names --- auth/lib/ui/settings/lock_screen/lock_screen_options.dart | 4 +++- auth/lib/utils/lock_screen_settings.dart | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/auth/lib/ui/settings/lock_screen/lock_screen_options.dart b/auth/lib/ui/settings/lock_screen/lock_screen_options.dart index de4d2f5173..7d24f995fc 100644 --- a/auth/lib/ui/settings/lock_screen/lock_screen_options.dart +++ b/auth/lib/ui/settings/lock_screen/lock_screen_options.dart @@ -49,10 +49,12 @@ class _LockScreenOptionsState extends State { Future _initializeSettings() async { final bool passwordEnabled = await _lockscreenSetting.isPasswordSet(); final bool pinEnabled = await _lockscreenSetting.isPinSet(); + final bool shouldHideAppContent = + _lockscreenSetting.getShouldHideAppContent(); setState(() { isPasswordEnabled = passwordEnabled; isPinEnabled = pinEnabled; - hideAppContent = _lockscreenSetting.getShouldHideAppContent(); + hideAppContent = shouldHideAppContent; }); } diff --git a/auth/lib/utils/lock_screen_settings.dart b/auth/lib/utils/lock_screen_settings.dart index 9c9638cd8a..53b087198b 100644 --- a/auth/lib/utils/lock_screen_settings.dart +++ b/auth/lib/utils/lock_screen_settings.dart @@ -19,7 +19,7 @@ class LockScreenSettings { static const keyInvalidAttempts = "ls_invalid_attempts"; static const lastInvalidAttemptTime = "ls_last_invalid_attempt_time"; static const autoLockTime = "ls_auto_lock_time"; - static const keyHideAppContent = "ls_show_app_content"; + static const keyHideAppContent = "ls_hide_app_content"; final List autoLockDurations = const [ Duration(seconds: 0), Duration(seconds: 5), From 28b9d5512f13be5edecf0c3a8e58b806b6d8ec53 Mon Sep 17 00:00:00 2001 From: Aman Raj Singh Mourya Date: Thu, 25 Jul 2024 13:19:19 +0530 Subject: [PATCH 022/123] [mob][auth] Add animation when toggling app lock --- .../lock_screen/lock_screen_options.dart | 278 +++++++++--------- 1 file changed, 145 insertions(+), 133 deletions(-) diff --git a/auth/lib/ui/settings/lock_screen/lock_screen_options.dart b/auth/lib/ui/settings/lock_screen/lock_screen_options.dart index 7d24f995fc..614917e5d1 100644 --- a/auth/lib/ui/settings/lock_screen/lock_screen_options.dart +++ b/auth/lib/ui/settings/lock_screen/lock_screen_options.dart @@ -181,153 +181,165 @@ class _LockScreenOptionsState extends State { onChanged: () => _onToggleSwitch(), ), ), - !appLock - ? Padding( - padding: const EdgeInsets.only( - top: 14, - left: 14, - right: 12, - ), - child: Text( - context.l10n.appLockDescription, - style: textTheme.miniFaint, - textAlign: TextAlign.left, - ), - ) - : const SizedBox(), + AnimatedSwitcher( + duration: const Duration(milliseconds: 210), + switchInCurve: Curves.easeOut, + switchOutCurve: Curves.easeIn, + child: !appLock + ? Padding( + padding: const EdgeInsets.only( + top: 14, + left: 14, + right: 12, + ), + child: Text( + context.l10n.appLockDescription, + style: textTheme.miniFaint, + textAlign: TextAlign.left, + ), + ) + : const SizedBox(), + ), const Padding( padding: EdgeInsets.only(top: 24), ), ], ), - appLock - ? Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - MenuItemWidget( - captionedTextWidget: CaptionedTextWidget( - title: context.l10n.deviceLock, + AnimatedSwitcher( + duration: const Duration(milliseconds: 210), + switchInCurve: Curves.easeOut, + switchOutCurve: Curves.easeIn, + child: appLock + ? Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MenuItemWidget( + captionedTextWidget: CaptionedTextWidget( + title: context.l10n.deviceLock, + ), + alignCaptionedTextToLeft: true, + isTopBorderRadiusRemoved: false, + isBottomBorderRadiusRemoved: true, + menuItemColor: colorTheme.fillFaint, + trailingIcon: + !(isPasswordEnabled || isPinEnabled) + ? Icons.check + : null, + trailingIconColor: colorTheme.textBase, + onTap: () => _deviceLock(), ), - 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: CaptionedTextWidget( - title: context.l10n.pinLock, + DividerWidget( + dividerType: DividerType.menuNoIcon, + bgColor: colorTheme.fillFaint, ), - 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: CaptionedTextWidget( - title: context.l10n.password, + MenuItemWidget( + captionedTextWidget: CaptionedTextWidget( + title: context.l10n.pinLock, + ), + alignCaptionedTextToLeft: true, + isTopBorderRadiusRemoved: true, + isBottomBorderRadiusRemoved: true, + menuItemColor: colorTheme.fillFaint, + trailingIcon: + isPinEnabled ? Icons.check : null, + trailingIconColor: colorTheme.textBase, + onTap: () => _pinLock(), ), - alignCaptionedTextToLeft: true, - isTopBorderRadiusRemoved: true, - isBottomBorderRadiusRemoved: false, - menuItemColor: colorTheme.fillFaint, - trailingIcon: - isPasswordEnabled ? Icons.check : null, - trailingIconColor: colorTheme.textBase, - onTap: () => _passwordLock(), - ), - const SizedBox( - height: 24, - ), - MenuItemWidget( - captionedTextWidget: CaptionedTextWidget( - title: context.l10n.autoLock, - subTitle: _formatTime( - Duration( - milliseconds: - autoLockTimeInMilliseconds, + DividerWidget( + dividerType: DividerType.menuNoIcon, + bgColor: colorTheme.fillFaint, + ), + MenuItemWidget( + captionedTextWidget: CaptionedTextWidget( + title: context.l10n.password, + ), + alignCaptionedTextToLeft: true, + isTopBorderRadiusRemoved: true, + isBottomBorderRadiusRemoved: false, + menuItemColor: colorTheme.fillFaint, + trailingIcon: isPasswordEnabled + ? Icons.check + : null, + trailingIconColor: colorTheme.textBase, + onTap: () => _passwordLock(), + ), + const SizedBox( + height: 24, + ), + MenuItemWidget( + captionedTextWidget: CaptionedTextWidget( + title: context.l10n.autoLock, + subTitle: _formatTime( + Duration( + milliseconds: + autoLockTimeInMilliseconds, + ), ), ), + alignCaptionedTextToLeft: true, + singleBorderRadius: 8, + menuItemColor: colorTheme.fillFaint, + trailingIconColor: colorTheme.textBase, + onTap: () => _onAutoLock(), ), - alignCaptionedTextToLeft: true, - singleBorderRadius: 8, - menuItemColor: colorTheme.fillFaint, - trailingIconColor: colorTheme.textBase, - onTap: () => _onAutoLock(), - ), - Padding( - padding: const EdgeInsets.only( - top: 14, - left: 14, - right: 12, + Padding( + padding: const EdgeInsets.only( + top: 14, + left: 14, + right: 12, + ), + child: Text( + context.l10n.autoLockFeatureDescription, + style: textTheme.miniFaint, + textAlign: TextAlign.left, + ), ), - child: Text( - context.l10n.autoLockFeatureDescription, - style: textTheme.miniFaint, - textAlign: TextAlign.left, - ), - ), - PlatformUtil.isMobile() - ? Column( - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - const SizedBox(height: 24), - MenuItemWidget( - captionedTextWidget: - CaptionedTextWidget( - title: context.l10n.deviceLock, + PlatformUtil.isMobile() + ? Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + const SizedBox(height: 24), + MenuItemWidget( + captionedTextWidget: + CaptionedTextWidget( + title: + context.l10n.deviceLock, + ), + alignCaptionedTextToLeft: true, + isTopBorderRadiusRemoved: true, + menuItemColor: + colorTheme.fillFaint, + trailingWidget: + ToggleSwitchWidget( + value: () => hideAppContent, + onChanged: () => + _onHideContent(), + ), ), - alignCaptionedTextToLeft: true, - isTopBorderRadiusRemoved: true, - menuItemColor: - colorTheme.fillFaint, - trailingWidget: - ToggleSwitchWidget( - value: () => hideAppContent, - onChanged: () => - _onHideContent(), + Padding( + padding: const EdgeInsets.only( + top: 14, + left: 14, + right: 12, + ), + child: Text( + Platform.isAndroid + ? context.l10n + .hideContentDescriptionAndroid + : context.l10n + .hideContentDescriptioniOS, + style: textTheme.miniFaint, + textAlign: TextAlign.left, + ), ), - ), - Padding( - padding: const EdgeInsets.only( - top: 14, - left: 14, - right: 12, - ), - child: Text( - Platform.isAndroid - ? context.l10n - .hideContentDescriptionAndroid - : context.l10n - .hideContentDescriptioniOS, - style: textTheme.miniFaint, - textAlign: TextAlign.left, - ), - ), - ], - ) - : Container(), - ], - ) - : Container(), + ], + ) + : Container(), + ], + ) + : Container(), + ), ], ), ), From 10e19ffae2780c5d7f95c27a00f9d151b4097a8c Mon Sep 17 00:00:00 2001 From: Aman Raj Singh Mourya Date: Thu, 25 Jul 2024 13:22:06 +0530 Subject: [PATCH 023/123] [mob][auth] Minor UI changes --- auth/lib/ui/settings/lock_screen/lock_screen_options.dart | 4 ++++ auth/lib/ui/settings/security_section_widget.dart | 1 + 2 files changed, 5 insertions(+) diff --git a/auth/lib/ui/settings/lock_screen/lock_screen_options.dart b/auth/lib/ui/settings/lock_screen/lock_screen_options.dart index 614917e5d1..e0e71cc03f 100644 --- a/auth/lib/ui/settings/lock_screen/lock_screen_options.dart +++ b/auth/lib/ui/settings/lock_screen/lock_screen_options.dart @@ -217,6 +217,7 @@ class _LockScreenOptionsState extends State { captionedTextWidget: CaptionedTextWidget( title: context.l10n.deviceLock, ), + surfaceExecutionStates: false, alignCaptionedTextToLeft: true, isTopBorderRadiusRemoved: false, isBottomBorderRadiusRemoved: true, @@ -236,6 +237,7 @@ class _LockScreenOptionsState extends State { captionedTextWidget: CaptionedTextWidget( title: context.l10n.pinLock, ), + surfaceExecutionStates: false, alignCaptionedTextToLeft: true, isTopBorderRadiusRemoved: true, isBottomBorderRadiusRemoved: true, @@ -253,6 +255,7 @@ class _LockScreenOptionsState extends State { captionedTextWidget: CaptionedTextWidget( title: context.l10n.password, ), + surfaceExecutionStates: false, alignCaptionedTextToLeft: true, isTopBorderRadiusRemoved: true, isBottomBorderRadiusRemoved: false, @@ -276,6 +279,7 @@ class _LockScreenOptionsState extends State { ), ), ), + surfaceExecutionStates: false, alignCaptionedTextToLeft: true, singleBorderRadius: 8, menuItemColor: colorTheme.fillFaint, diff --git a/auth/lib/ui/settings/security_section_widget.dart b/auth/lib/ui/settings/security_section_widget.dart index b98c59d7d8..348c6304a5 100644 --- a/auth/lib/ui/settings/security_section_widget.dart +++ b/auth/lib/ui/settings/security_section_widget.dart @@ -140,6 +140,7 @@ class _SecuritySectionWidgetState extends State { captionedTextWidget: CaptionedTextWidget( title: context.l10n.appLock, ), + surfaceExecutionStates: false, trailingIcon: Icons.chevron_right_outlined, trailingIconIsMuted: true, onTap: () async { From 3edc32327241d02c5bf4ab66c21ad1f9e6f86819 Mon Sep 17 00:00:00 2001 From: ashilkn Date: Fri, 26 Jul 2024 12:20:39 +0530 Subject: [PATCH 024/123] [mob][photos] Redesign and change logic in SubscriptionPlanWidget to match new design --- .../ui/payment/subscription_plan_widget.dart | 166 ++++++++++++------ mobile/lib/utils/data_util.dart | 9 + 2 files changed, 122 insertions(+), 53 deletions(-) diff --git a/mobile/lib/ui/payment/subscription_plan_widget.dart b/mobile/lib/ui/payment/subscription_plan_widget.dart index 00d8769fe8..a4e870d710 100644 --- a/mobile/lib/ui/payment/subscription_plan_widget.dart +++ b/mobile/lib/ui/payment/subscription_plan_widget.dart @@ -1,77 +1,137 @@ import 'package:flutter/material.dart'; import "package:photos/generated/l10n.dart"; +import "package:photos/theme/ente_theme.dart"; import 'package:photos/utils/data_util.dart'; class SubscriptionPlanWidget extends StatelessWidget { const SubscriptionPlanWidget({ - Key? key, + super.key, required this.storage, required this.price, required this.period, this.isActive = false, - }) : super(key: key); + }); final int storage; final String price; final String period; final bool isActive; - String _displayPrice(BuildContext context) { - // todo: l10n pricing part - final result = price + (period.isNotEmpty ? " / " + period : ""); - return price.isNotEmpty ? result : S.of(context).freeTrial; - } - @override Widget build(BuildContext context) { - final Color textColor = isActive ? Colors.white : Colors.black; - return Container( - width: double.infinity, - color: Theme.of(context).colorScheme.onPrimary, - padding: EdgeInsets.symmetric(horizontal: isActive ? 8 : 16, vertical: 4), - child: Container( - decoration: BoxDecoration( - color: isActive - ? const Color(0xFF22763F) - : const Color.fromRGBO(240, 240, 240, 1.0), - gradient: isActive - ? const LinearGradient( - begin: Alignment.centerLeft, - end: Alignment.centerRight, - colors: [ - Color(0xFF2CD267), - Color(0xFF1DB954), - ], - ) - : null, - ), - // color: Colors.yellow, - padding: - EdgeInsets.symmetric(horizontal: isActive ? 22 : 20, vertical: 18), - child: Column( - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - convertBytesToReadableFormat(storage), - style: Theme.of(context) - .textTheme - .titleLarge! - .copyWith(color: textColor), + final numAndUnit = convertBytesToNumberAndUnit(storage); + final String storageValue = numAndUnit.$1.toString(); + final String storageUnit = numAndUnit.$2; + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + RichText( + text: TextSpan( + children: [ + TextSpan( + text: storageValue, + style: TextStyle( + fontSize: 40, + fontWeight: FontWeight.w600, + color: getEnteColorScheme(context).textBase, ), - Text( - _displayPrice(context), - style: Theme.of(context).textTheme.titleLarge!.copyWith( - color: textColor, - fontWeight: FontWeight.normal, - ), + ), + WidgetSpan( + child: Transform.translate( + offset: const Offset(2, -16), + child: Text( + storageUnit, + style: getEnteTextTheme(context).h3Muted, + ), ), - ], - ), - ], + ), + ], + ), ), - ), + _Price(price: price, period: period), + ], ); } } + +class _Price extends StatelessWidget { + final String price; + final String period; + const _Price({required this.price, required this.period}); + + @override + Widget build(BuildContext context) { + final colorScheme = getEnteColorScheme(context); + if (price.isEmpty) { + return Text(S.of(context).freeTrial); + } + if (period == "month") { + return RichText( + text: TextSpan( + children: [ + TextSpan( + text: price, + style: TextStyle( + fontSize: 20, + color: colorScheme.textBase, + fontWeight: FontWeight.w600, + ), + ), + TextSpan( + text: ' / ' 'month', + style: TextStyle( + fontSize: 16, + color: colorScheme.textBase, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ); + } else if (period == "year") { + final currencySymbol = price[0]; + final priceWithoutCurrency = price.substring(1); + final priceDouble = double.parse(priceWithoutCurrency); + final pricePerMonth = priceDouble / 12; + final pricePerMonthString = pricePerMonth.toStringAsFixed(2); + return Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + RichText( + text: TextSpan( + children: [ + TextSpan( + text: currencySymbol + pricePerMonthString, + style: TextStyle( + fontSize: 20, + color: colorScheme.textBase, + fontWeight: FontWeight.w600, + ), + ), + TextSpan( + text: ' / ' 'month', + style: TextStyle( + fontSize: 16, + color: colorScheme.textBase, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + Text( + price + " / " + "year", + style: TextStyle( + fontSize: 12, + color: colorScheme.textFaint, + fontWeight: FontWeight.w600, + ), + ), + ], + ); + } else { + assert(false, "Invalid period: $period"); + return const Text(""); + } + } +} diff --git a/mobile/lib/utils/data_util.dart b/mobile/lib/utils/data_util.dart index 0f3feca736..48a0bbf79a 100644 --- a/mobile/lib/utils/data_util.dart +++ b/mobile/lib/utils/data_util.dart @@ -11,6 +11,15 @@ String convertBytesToReadableFormat(int bytes) { return bytes.toString() + " " + storageUnits[storageUnitIndex]; } +(int, String) convertBytesToNumberAndUnit(int bytes) { + int storageUnitIndex = 0; + while (bytes >= 1024 && storageUnitIndex < storageUnits.length - 1) { + storageUnitIndex++; + bytes = (bytes / 1024).round(); + } + return (bytes, storageUnits[storageUnitIndex]); +} + String formatBytes(int bytes, [int decimals = 2]) { if (bytes == 0) return '0 bytes'; const k = 1024; From af42c421419e76b9b1912d76f26fe1b18ea1dfe0 Mon Sep 17 00:00:00 2001 From: ashilkn Date: Fri, 26 Jul 2024 12:49:07 +0530 Subject: [PATCH 025/123] [mob][photos] Make borders, bg color, stroke color and padding same as design on SubscriptionPlanWidget --- mobile/lib/theme/colors.dart | 16 ++++- .../ui/payment/subscription_plan_widget.dart | 67 ++++++++++++------- 2 files changed, 55 insertions(+), 28 deletions(-) diff --git a/mobile/lib/theme/colors.dart b/mobile/lib/theme/colors.dart index 694106e398..6159d1f141 100644 --- a/mobile/lib/theme/colors.dart +++ b/mobile/lib/theme/colors.dart @@ -35,6 +35,7 @@ class EnteColorScheme { final Color blurStrokeBase; final Color blurStrokeFaint; final Color blurStrokePressed; + final Color subscriptionPlanWidgetStoke; // Fixed Colors final Color primary700; @@ -56,6 +57,7 @@ class EnteColorScheme { //other colors final Color tabIcon; final List avatarColors; + final Color subscriptionPlanWidgetColor; const EnteColorScheme( this.backgroundBase, @@ -81,8 +83,10 @@ class EnteColorScheme { this.blurStrokeBase, this.blurStrokeFaint, this.blurStrokePressed, + this.subscriptionPlanWidgetStoke, this.tabIcon, - this.avatarColors, { + this.avatarColors, + this.subscriptionPlanWidgetColor, { this.primary700 = _primary700, this.primary500 = _primary500, this.primary400 = _primary400, @@ -121,8 +125,10 @@ const EnteColorScheme lightScheme = EnteColorScheme( blurStrokeBaseLight, blurStrokeFaintLight, blurStrokePressedLight, + subscriptionPlanWidgetStokeLight, tabIconLight, avatarLight, + subscriptionPlanWidgetLight, ); const EnteColorScheme darkScheme = EnteColorScheme( @@ -149,8 +155,10 @@ const EnteColorScheme darkScheme = EnteColorScheme( blurStrokeBaseDark, blurStrokeFaintDark, blurStrokePressedDark, + subscriptionPlanWidgetDark, tabIconDark, avatarDark, + subscriptionPlanWidgetDark, ); // Background Colors @@ -214,10 +222,14 @@ const Color blurStrokeBaseDark = Color.fromRGBO(255, 255, 255, 0.90); const Color blurStrokeFaintDark = Color.fromRGBO(255, 255, 255, 0.06); const Color blurStrokePressedDark = Color.fromRGBO(255, 255, 255, 0.50); +const Color subscriptionPlanWidgetStokeLight = Color.fromRGBO(229, 229, 229, 1); +const Color subscriptionPlanWidgetStokeDark = Color.fromRGBO(44, 44, 44, 1); + // Other colors const Color tabIconLight = Color.fromRGBO(0, 0, 0, 0.85); - const Color tabIconDark = Color.fromRGBO(255, 255, 255, 0.80); +const Color subscriptionPlanWidgetDark = Color.fromRGBO(255, 255, 255, 0.04); +const Color subscriptionPlanWidgetLight = Color.fromRGBO(251, 251, 251, 1); // Fixed Colors diff --git a/mobile/lib/ui/payment/subscription_plan_widget.dart b/mobile/lib/ui/payment/subscription_plan_widget.dart index a4e870d710..119de4d3b5 100644 --- a/mobile/lib/ui/payment/subscription_plan_widget.dart +++ b/mobile/lib/ui/payment/subscription_plan_widget.dart @@ -22,34 +22,49 @@ class SubscriptionPlanWidget extends StatelessWidget { final numAndUnit = convertBytesToNumberAndUnit(storage); final String storageValue = numAndUnit.$1.toString(); final String storageUnit = numAndUnit.$2; - return Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - RichText( - text: TextSpan( - children: [ - TextSpan( - text: storageValue, - style: TextStyle( - fontSize: 40, - fontWeight: FontWeight.w600, - color: getEnteColorScheme(context).textBase, - ), - ), - WidgetSpan( - child: Transform.translate( - offset: const Offset(2, -16), - child: Text( - storageUnit, - style: getEnteTextTheme(context).h3Muted, - ), - ), - ), - ], + final colorScheme = getEnteColorScheme(context); + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: colorScheme.subscriptionPlanWidgetColor, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: colorScheme.subscriptionPlanWidgetStoke, + width: 1, ), ), - _Price(price: price, period: period), - ], + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + RichText( + text: TextSpan( + children: [ + TextSpan( + text: storageValue, + style: TextStyle( + fontSize: 40, + fontWeight: FontWeight.w600, + color: getEnteColorScheme(context).textBase, + ), + ), + WidgetSpan( + child: Transform.translate( + offset: const Offset(2, -16), + child: Text( + storageUnit, + style: getEnteTextTheme(context).h3Muted, + ), + ), + ), + ], + ), + ), + _Price(price: price, period: period), + ], + ), + ), ); } } From f01b3b9defc6a9f3674b31e7bb60f8db618eb4ff Mon Sep 17 00:00:00 2001 From: ashilkn Date: Fri, 26 Jul 2024 13:29:33 +0530 Subject: [PATCH 026/123] [mob][photos] Redesign app bar of subscription screen --- .../ui/payment/store_subscription_page.dart | 40 +++++- .../ui/payment/stripe_subscription_page.dart | 119 +++++++++--------- 2 files changed, 98 insertions(+), 61 deletions(-) diff --git a/mobile/lib/ui/payment/store_subscription_page.dart b/mobile/lib/ui/payment/store_subscription_page.dart index 3925bf0177..93390da22b 100644 --- a/mobile/lib/ui/payment/store_subscription_page.dart +++ b/mobile/lib/ui/payment/store_subscription_page.dart @@ -1,7 +1,6 @@ import 'dart:async'; import 'dart:io'; -import "package:flutter/cupertino.dart"; import "package:flutter/foundation.dart"; import 'package:flutter/material.dart'; import 'package:in_app_purchase/in_app_purchase.dart'; @@ -22,6 +21,7 @@ import 'package:photos/ui/common/loading_widget.dart'; import 'package:photos/ui/common/progress_dialog.dart'; import "package:photos/ui/components/captioned_text_widget.dart"; import "package:photos/ui/components/menu_item_widget/menu_item_widget.dart"; +import "package:photos/ui/components/title_bar_title_widget.dart"; import 'package:photos/ui/payment/child_subscription_widget.dart'; import 'package:photos/ui/payment/skip_subscription_widget.dart'; import 'package:photos/ui/payment/subscription_common_widgets.dart'; @@ -37,8 +37,8 @@ class StoreSubscriptionPage extends StatefulWidget { const StoreSubscriptionPage({ this.isOnboarding = false, - Key? key, - }) : super(key: key); + super.key, + }); @override State createState() => _StoreSubscriptionPageState(); @@ -155,6 +155,7 @@ class _StoreSubscriptionPageState extends State { @override Widget build(BuildContext context) { + final textTheme = getEnteTextTheme(context); colorScheme = getEnteColorScheme(context); if (!_isLoading) { _isLoading = true; @@ -168,7 +169,38 @@ class _StoreSubscriptionPageState extends State { ); return Scaffold( appBar: appBar, - body: _getBody(), + body: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TitleBarTitleWidget( + title: + widget.isOnboarding ? "Select your plan" : "Subscription", + ), + widget.isOnboarding + ? Text( + "Ente preserves your memories, so they’re always available to you, even if you lose your device.", + style: textTheme.smallMuted, + ) + : _isFreePlanUser() + ? const SizedBox.shrink() + : Text( + convertBytesToReadableFormat( + // _userDetails.getTotalStorage(), + 1234, + ), + style: textTheme.smallMuted, + ), + ], + ), + ), + _getBody(), + ], + ), ); } diff --git a/mobile/lib/ui/payment/stripe_subscription_page.dart b/mobile/lib/ui/payment/stripe_subscription_page.dart index 31694f174b..52e92118b5 100644 --- a/mobile/lib/ui/payment/stripe_subscription_page.dart +++ b/mobile/lib/ui/payment/stripe_subscription_page.dart @@ -1,7 +1,5 @@ import 'dart:async'; -import "package:flutter/cupertino.dart"; -import "package:flutter/foundation.dart"; import 'package:flutter/material.dart'; import "package:logging/logging.dart"; import 'package:photos/ente_theme_data.dart'; @@ -14,13 +12,13 @@ import "package:photos/services/update_service.dart"; import 'package:photos/services/user_service.dart'; import "package:photos/theme/colors.dart"; import 'package:photos/theme/ente_theme.dart'; -import 'package:photos/ui/common/bottom_shadow.dart'; import 'package:photos/ui/common/loading_widget.dart'; import 'package:photos/ui/common/progress_dialog.dart'; import 'package:photos/ui/common/web_page.dart'; import 'package:photos/ui/components/buttons/button_widget.dart'; import "package:photos/ui/components/captioned_text_widget.dart"; import "package:photos/ui/components/menu_item_widget/menu_item_widget.dart"; +import "package:photos/ui/components/title_bar_title_widget.dart"; import 'package:photos/ui/payment/child_subscription_widget.dart'; import 'package:photos/ui/payment/payment_web_page.dart'; import 'package:photos/ui/payment/skip_subscription_widget.dart'; @@ -38,8 +36,8 @@ class StripeSubscriptionPage extends StatefulWidget { const StripeSubscriptionPage({ this.isOnboarding = false, - Key? key, - }) : super(key: key); + super.key, + }); @override State createState() => _StripeSubscriptionPageState(); @@ -64,11 +62,6 @@ class _StripeSubscriptionPageState extends State { EnteColorScheme colorScheme = darkScheme; final Logger logger = Logger("StripeSubscriptionPage"); - @override - void initState() { - super.initState(); - } - Future _fetchSub() async { return _userService .getUserDetailsV2(memoryCount: false) @@ -127,59 +120,71 @@ class _StripeSubscriptionPageState extends State { } } - @override - void dispose() { - super.dispose(); - } - @override Widget build(BuildContext context) { colorScheme = getEnteColorScheme(context); - final appBar = PreferredSize( - preferredSize: const Size(double.infinity, 60), - child: Container( - decoration: BoxDecoration( - boxShadow: [ - BoxShadow( - color: Theme.of(context).colorScheme.background, - blurRadius: 16, - offset: const Offset(0, 8), - ), - ], - ), - child: widget.isOnboarding - ? AppBar( - elevation: 0, - title: Hero( - tag: "subscription", - child: StepProgressIndicator( - totalSteps: 4, - currentStep: 4, - selectedColor: - Theme.of(context).colorScheme.greenAlternative, - roundedEdges: const Radius.circular(10), - unselectedColor: Theme.of(context) - .colorScheme - .stepProgressUnselectedColor, - ), - ), - ) - : AppBar( - elevation: 0, - title: Text("${S.of(context).subscription}${kDebugMode ? ' ' - 'Stripe' : ''}"), - ), - ), - ); + final textTheme = getEnteTextTheme(context); + return Scaffold( - appBar: appBar, - body: Stack( - alignment: Alignment.bottomCenter, + appBar: widget.isOnboarding + ? AppBar( + scrolledUnderElevation: 0, + elevation: 0, + title: Hero( + tag: "subscription", + child: StepProgressIndicator( + totalSteps: 4, + currentStep: 4, + selectedColor: Theme.of(context).colorScheme.greenAlternative, + roundedEdges: const Radius.circular(10), + unselectedColor: + Theme.of(context).colorScheme.stepProgressUnselectedColor, + ), + ), + ) + : AppBar( + scrolledUnderElevation: 0, + toolbarHeight: 48, + leadingWidth: 48, + leading: GestureDetector( + onTap: () { + Navigator.pop(context); + }, + child: const Icon( + Icons.arrow_back_outlined, + ), + ), + ), + body: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - _getBody(), - const BottomShadowWidget( - offsetDy: 40, + Padding( + padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TitleBarTitleWidget( + title: + widget.isOnboarding ? "Select your plan" : "Subscription", + ), + widget.isOnboarding + ? Text( + "Ente preserves your memories, so they’re always available to you, even if you lose your device.", + style: textTheme.smallMuted, + ) + : _isFreePlanUser() + ? const SizedBox.shrink() + : Text( + convertBytesToReadableFormat( + // _userDetails.getTotalStorage(), + 1234, + ), + style: textTheme.smallMuted, + ), + ], + ), ), + Expanded(child: _getBody()), ], ), ); From 87e3aa4d11007482aca8c58095e0c1627fade00f Mon Sep 17 00:00:00 2001 From: ashilkn Date: Fri, 26 Jul 2024 16:09:35 +0530 Subject: [PATCH 027/123] [mob][photos] Redesign header widget of subscription page --- .../ui/payment/store_subscription_page.dart | 20 ++---- .../ui/payment/stripe_subscription_page.dart | 20 ++---- .../payment/subscription_common_widgets.dart | 65 +++++++------------ 3 files changed, 38 insertions(+), 67 deletions(-) diff --git a/mobile/lib/ui/payment/store_subscription_page.dart b/mobile/lib/ui/payment/store_subscription_page.dart index 93390da22b..94b999b850 100644 --- a/mobile/lib/ui/payment/store_subscription_page.dart +++ b/mobile/lib/ui/payment/store_subscription_page.dart @@ -181,20 +181,14 @@ class _StoreSubscriptionPageState extends State { title: widget.isOnboarding ? "Select your plan" : "Subscription", ), - widget.isOnboarding - ? Text( - "Ente preserves your memories, so they’re always available to you, even if you lose your device.", + _isFreePlanUser() || !_hasLoadedData + ? const SizedBox.shrink() + : Text( + convertBytesToReadableFormat( + _userDetails.getTotalStorage(), + ), style: textTheme.smallMuted, - ) - : _isFreePlanUser() - ? const SizedBox.shrink() - : Text( - convertBytesToReadableFormat( - // _userDetails.getTotalStorage(), - 1234, - ), - style: textTheme.smallMuted, - ), + ), ], ), ), diff --git a/mobile/lib/ui/payment/stripe_subscription_page.dart b/mobile/lib/ui/payment/stripe_subscription_page.dart index 52e92118b5..fa34d2e7a3 100644 --- a/mobile/lib/ui/payment/stripe_subscription_page.dart +++ b/mobile/lib/ui/payment/stripe_subscription_page.dart @@ -167,20 +167,14 @@ class _StripeSubscriptionPageState extends State { title: widget.isOnboarding ? "Select your plan" : "Subscription", ), - widget.isOnboarding - ? Text( - "Ente preserves your memories, so they’re always available to you, even if you lose your device.", + _isFreePlanUser() || !_hasLoadedData + ? const SizedBox.shrink() + : Text( + convertBytesToReadableFormat( + _userDetails.getTotalStorage(), + ), style: textTheme.smallMuted, - ) - : _isFreePlanUser() - ? const SizedBox.shrink() - : Text( - convertBytesToReadableFormat( - // _userDetails.getTotalStorage(), - 1234, - ), - style: textTheme.smallMuted, - ), + ), ], ), ), diff --git a/mobile/lib/ui/payment/subscription_common_widgets.dart b/mobile/lib/ui/payment/subscription_common_widgets.dart index 6d3cf66594..49a9a4aaed 100644 --- a/mobile/lib/ui/payment/subscription_common_widgets.dart +++ b/mobile/lib/ui/payment/subscription_common_widgets.dart @@ -16,10 +16,10 @@ class SubscriptionHeaderWidget extends StatefulWidget { final int? currentUsage; const SubscriptionHeaderWidget({ - Key? key, + super.key, this.isOnboarding, this.currentUsage, - }) : super(key: key); + }); @override State createState() { @@ -30,51 +30,34 @@ class SubscriptionHeaderWidget extends StatefulWidget { class _SubscriptionHeaderWidgetState extends State { @override Widget build(BuildContext context) { + final textTheme = getEnteTextTheme(context); + final colorScheme = getEnteColorScheme(context); if (widget.isOnboarding!) { return Padding( - padding: const EdgeInsets.fromLTRB(20, 20, 20, 24), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - S.of(context).selectYourPlan, - style: Theme.of(context).textTheme.headlineMedium, - ), - const SizedBox(height: 10), - Text( - S.of(context).enteSubscriptionPitch, - style: Theme.of(context).textTheme.bodySmall, - ), - const SizedBox(height: 4), - Text( - S.of(context).enteSubscriptionShareWithFamily, - style: Theme.of(context).textTheme.bodySmall, - ), - ], + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Text( + S.of(context).enteSubscriptionPitch, + style: getEnteTextTheme(context).smallFaint, ), ); } else { - return SizedBox( - height: 72, - width: double.infinity, - child: Padding( - padding: const EdgeInsets.all(24.0), - child: RichText( - text: TextSpan( - children: [ - TextSpan( - text: S.of(context).currentUsageIs, - style: Theme.of(context).textTheme.titleMedium, + return Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 0), + child: RichText( + text: TextSpan( + children: [ + TextSpan( + text: S.of(context).currentUsageIs, + style: textTheme.bodyFaint, + ), + TextSpan( + text: formatBytes(widget.currentUsage!), + style: textTheme.body.copyWith( + color: colorScheme.primary700, + fontWeight: FontWeight.w600, ), - TextSpan( - text: formatBytes(widget.currentUsage!), - style: Theme.of(context) - .textTheme - .titleMedium! - .copyWith(fontWeight: FontWeight.bold), - ), - ], - ), + ), + ], ), ), ); From fc93deb575cdfe1c9bbf6b585894d0b97619d20d Mon Sep 17 00:00:00 2001 From: ashilkn Date: Fri, 26 Jul 2024 18:50:11 +0530 Subject: [PATCH 028/123] [mob][photos] Create new subscription toggle --- .../ui/payment/stripe_subscription_page.dart | 221 +++++++++++++----- 1 file changed, 163 insertions(+), 58 deletions(-) diff --git a/mobile/lib/ui/payment/stripe_subscription_page.dart b/mobile/lib/ui/payment/stripe_subscription_page.dart index fa34d2e7a3..e90c3e57f7 100644 --- a/mobile/lib/ui/payment/stripe_subscription_page.dart +++ b/mobile/lib/ui/payment/stripe_subscription_page.dart @@ -8,7 +8,6 @@ import 'package:photos/models/billing_plan.dart'; import 'package:photos/models/subscription.dart'; import 'package:photos/models/user_details.dart'; import 'package:photos/services/billing_service.dart'; -import "package:photos/services/update_service.dart"; import 'package:photos/services/user_service.dart'; import "package:photos/theme/colors.dart"; import 'package:photos/theme/ente_theme.dart'; @@ -210,6 +209,8 @@ class _StripeSubscriptionPageState extends State { ), ); + widgets.add(_showSubscriptionToggle()); + widgets.addAll([ Column( mainAxisAlignment: MainAxisAlignment.center, @@ -218,8 +219,6 @@ class _StripeSubscriptionPageState extends State { const Padding(padding: EdgeInsets.all(4)), ]); - widgets.add(_showSubscriptionToggle()); - if (_currentSubscription != null) { widgets.add( ValidityWidget( @@ -537,61 +536,58 @@ class _StripeSubscriptionPageState extends State { } Widget _showSubscriptionToggle() { - return Container( - padding: const EdgeInsets.only(left: 8, right: 8, top: 2, bottom: 2), - margin: const EdgeInsets.only(bottom: 6), - child: Column( - children: [ - RepaintBoundary( - child: SizedBox( - width: 250, - child: Row( - mainAxisSize: MainAxisSize.max, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Expanded( - child: SegmentedButton( - style: SegmentedButton.styleFrom( - selectedBackgroundColor: - getEnteColorScheme(context).fillMuted, - selectedForegroundColor: - getEnteColorScheme(context).textBase, - side: BorderSide( - color: getEnteColorScheme(context).strokeMuted, - width: 1, - ), - ), - segments: >[ - ButtonSegment( - label: Text(S.of(context).monthly), - value: false, - ), - ButtonSegment( - label: Text(S.of(context).yearly), - value: true, - ), - ], - selected: {_showYearlyPlan}, - onSelectionChanged: (p0) { - _showYearlyPlan = p0.first; - _filterStripeForUI(); - }, - ), - ), - ], - ), - ), - ), - _isFreePlanUser() && !UpdateService.instance.isPlayStoreFlavor() - ? Text( - S.of(context).twoMonthsFreeOnYearlyPlans, - style: getEnteTextTheme(context).miniMuted, - ) - : const SizedBox.shrink(), - const Padding(padding: EdgeInsets.all(8)), - ], - ), - ); + // return Container( + // padding: const EdgeInsets.fromLTRB(16, 32, 16, 6), + // child: Column( + // children: [ + // RepaintBoundary( + // child: SizedBox( + // width: 250, + // child: Row( + // mainAxisSize: MainAxisSize.max, + // mainAxisAlignment: MainAxisAlignment.center, + // children: [ + // Expanded( + // child: SegmentedButton( + // style: SegmentedButton.styleFrom( + // selectedBackgroundColor: + // getEnteColorScheme(context).fillMuted, + // selectedForegroundColor: + // getEnteColorScheme(context).textBase, + // side: BorderSide( + // color: getEnteColorScheme(context).strokeMuted, + // width: 1, + // ), + // ), + // segments: >[ + // ButtonSegment( + // label: Text(S.of(context).monthly), + // value: false, + // ), + // ButtonSegment( + // label: Text(S.of(context).yearly), + // value: true, + // ), + // ], + // selected: {_showYearlyPlan}, + // onSelectionChanged: (p0) { + // _showYearlyPlan = p0.first; + // _filterStripeForUI(); + // }, + // ), + // ), + // ], + // ), + // ), + // ), + // const Padding(padding: EdgeInsets.all(8)), + // ], + // ), + // ); + + // + + return SubscriptionToggle(); } void _addCurrentPlanWidget(List planWidgets) { @@ -623,3 +619,112 @@ class _StripeSubscriptionPageState extends State { ); } } + +class SubscriptionToggle extends StatefulWidget { + const SubscriptionToggle({super.key}); + + @override + State createState() => _SubscriptionToggleState(); +} + +class _SubscriptionToggleState extends State { + bool _isYearly = false; + @override + Widget build(BuildContext context) { + const borderPadding = 2.5; + const spaceBetweenButtons = 4.0; + final textTheme = getEnteTextTheme(context); + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 32), + child: LayoutBuilder( + builder: (context, constrains) { + final widthOfButton = (constrains.maxWidth - + (borderPadding * 2) - + spaceBetweenButtons) / + 2; + return Container( + decoration: BoxDecoration( + color: const Color.fromRGBO(242, 242, 242, 1), + borderRadius: BorderRadius.circular(50), + ), + padding: const EdgeInsets.symmetric( + vertical: borderPadding, + horizontal: borderPadding, + ), + width: double.infinity, + child: Stack( + children: [ + Row( + children: [ + GestureDetector( + onTap: () { + setState(() { + _isYearly = false; + }); + }, + child: Container( + padding: const EdgeInsets.symmetric( + vertical: 8, + ), + width: widthOfButton, + child: Center( + child: Text( + "Monthly", + style: textTheme.bodyFaint, + ), + ), + ), + ), + const SizedBox(width: spaceBetweenButtons), + GestureDetector( + onTap: () { + setState(() { + _isYearly = true; + }); + }, + child: Container( + padding: const EdgeInsets.symmetric( + vertical: 8, + ), + width: widthOfButton, + child: Center( + child: Text( + "Yearly", + style: textTheme.bodyFaint, + ), + ), + ), + ), + ], + ), + AnimatedPositioned( + duration: const Duration(milliseconds: 500), + curve: Curves.easeInOutQuart, + left: _isYearly ? widthOfButton + spaceBetweenButtons : 0, + child: Container( + width: widthOfButton, + height: 40, + decoration: BoxDecoration( + color: const Color.fromRGBO(255, 255, 255, 1), + borderRadius: BorderRadius.circular(50), + ), + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 500), + switchInCurve: Curves.easeInOutExpo, + switchOutCurve: Curves.easeInOutExpo, + child: Text( + key: ValueKey(_isYearly), + _isYearly ? "Yearly" : "Monthly", + style: textTheme.body, + ), + ), + ), + ), + ], + ), + ); + }, + ), + ); + } +} From ef1429685bfd10fa8713abc3a864696df32ae3af Mon Sep 17 00:00:00 2001 From: ashilkn Date: Fri, 26 Jul 2024 19:19:42 +0530 Subject: [PATCH 029/123] [mob][photos] Make subscription toggle work --- .../ui/payment/stripe_subscription_page.dart | 90 ++++++------------- 1 file changed, 25 insertions(+), 65 deletions(-) diff --git a/mobile/lib/ui/payment/stripe_subscription_page.dart b/mobile/lib/ui/payment/stripe_subscription_page.dart index e90c3e57f7..18d24729e1 100644 --- a/mobile/lib/ui/payment/stripe_subscription_page.dart +++ b/mobile/lib/ui/payment/stripe_subscription_page.dart @@ -209,7 +209,16 @@ class _StripeSubscriptionPageState extends State { ), ); - widgets.add(_showSubscriptionToggle()); + widgets.add( + SubscriptionToggle( + onToggle: (p0) { + Future.delayed(const Duration(milliseconds: 175), () { + _showYearlyPlan = p0; + _filterStripeForUI(); + }); + }, + ), + ); widgets.addAll([ Column( @@ -535,61 +544,6 @@ class _StripeSubscriptionPageState extends State { freeProductID == _currentSubscription!.productID; } - Widget _showSubscriptionToggle() { - // return Container( - // padding: const EdgeInsets.fromLTRB(16, 32, 16, 6), - // child: Column( - // children: [ - // RepaintBoundary( - // child: SizedBox( - // width: 250, - // child: Row( - // mainAxisSize: MainAxisSize.max, - // mainAxisAlignment: MainAxisAlignment.center, - // children: [ - // Expanded( - // child: SegmentedButton( - // style: SegmentedButton.styleFrom( - // selectedBackgroundColor: - // getEnteColorScheme(context).fillMuted, - // selectedForegroundColor: - // getEnteColorScheme(context).textBase, - // side: BorderSide( - // color: getEnteColorScheme(context).strokeMuted, - // width: 1, - // ), - // ), - // segments: >[ - // ButtonSegment( - // label: Text(S.of(context).monthly), - // value: false, - // ), - // ButtonSegment( - // label: Text(S.of(context).yearly), - // value: true, - // ), - // ], - // selected: {_showYearlyPlan}, - // onSelectionChanged: (p0) { - // _showYearlyPlan = p0.first; - // _filterStripeForUI(); - // }, - // ), - // ), - // ], - // ), - // ), - // ), - // const Padding(padding: EdgeInsets.all(8)), - // ], - // ), - // ); - - // - - return SubscriptionToggle(); - } - void _addCurrentPlanWidget(List planWidgets) { // don't add current plan if it's monthly plan but UI is showing yearly plans // and vice versa. @@ -621,7 +575,8 @@ class _StripeSubscriptionPageState extends State { } class SubscriptionToggle extends StatefulWidget { - const SubscriptionToggle({super.key}); + final Function(bool) onToggle; + const SubscriptionToggle({required this.onToggle, super.key}); @override State createState() => _SubscriptionToggleState(); @@ -658,10 +613,9 @@ class _SubscriptionToggleState extends State { children: [ GestureDetector( onTap: () { - setState(() { - _isYearly = false; - }); + setIsYearly(false); }, + behavior: HitTestBehavior.opaque, child: Container( padding: const EdgeInsets.symmetric( vertical: 8, @@ -678,10 +632,9 @@ class _SubscriptionToggleState extends State { const SizedBox(width: spaceBetweenButtons), GestureDetector( onTap: () { - setState(() { - _isYearly = true; - }); + setIsYearly(true); }, + behavior: HitTestBehavior.opaque, child: Container( padding: const EdgeInsets.symmetric( vertical: 8, @@ -698,7 +651,7 @@ class _SubscriptionToggleState extends State { ], ), AnimatedPositioned( - duration: const Duration(milliseconds: 500), + duration: const Duration(milliseconds: 350), curve: Curves.easeInOutQuart, left: _isYearly ? widthOfButton + spaceBetweenButtons : 0, child: Container( @@ -709,7 +662,7 @@ class _SubscriptionToggleState extends State { borderRadius: BorderRadius.circular(50), ), child: AnimatedSwitcher( - duration: const Duration(milliseconds: 500), + duration: const Duration(milliseconds: 350), switchInCurve: Curves.easeInOutExpo, switchOutCurve: Curves.easeInOutExpo, child: Text( @@ -727,4 +680,11 @@ class _SubscriptionToggleState extends State { ), ); } + + setIsYearly(bool isYearly) { + setState(() { + _isYearly = isYearly; + }); + widget.onToggle(isYearly); + } } From 6d5af2e6a582287ac6d332e2aeaf88a04a6d71bb Mon Sep 17 00:00:00 2001 From: ashilkn Date: Fri, 26 Jul 2024 19:43:25 +0530 Subject: [PATCH 030/123] [mob][photos] Update colors and text style --- mobile/lib/theme/colors.dart | 6 +++ .../ui/payment/stripe_subscription_page.dart | 6 +-- .../ui/payment/subscription_plan_widget.dart | 42 +++++-------------- 3 files changed, 20 insertions(+), 34 deletions(-) diff --git a/mobile/lib/theme/colors.dart b/mobile/lib/theme/colors.dart index 6159d1f141..27d868c1c3 100644 --- a/mobile/lib/theme/colors.dart +++ b/mobile/lib/theme/colors.dart @@ -26,6 +26,7 @@ class EnteColorScheme { final Color fillMuted; final Color fillFaint; final Color fillFaintPressed; + final Color fillBaseGrey; // Stroke Colors final Color strokeBase; @@ -76,6 +77,7 @@ class EnteColorScheme { this.fillMuted, this.fillFaint, this.fillFaintPressed, + this.fillBaseGrey, this.strokeBase, this.strokeMuted, this.strokeFaint, @@ -118,6 +120,7 @@ const EnteColorScheme lightScheme = EnteColorScheme( fillMutedLight, fillFaintLight, fillFaintPressedLight, + fillBaseGreyLight, strokeBaseLight, strokeMutedLight, strokeFaintLight, @@ -148,6 +151,7 @@ const EnteColorScheme darkScheme = EnteColorScheme( fillMutedDark, fillFaintDark, fillFaintPressedDark, + fillBaseGreyDark, strokeBaseDark, strokeMutedDark, strokeFaintDark, @@ -197,6 +201,7 @@ const Color fillStrongLight = Color.fromRGBO(0, 0, 0, 0.24); const Color fillMutedLight = Color.fromRGBO(0, 0, 0, 0.12); const Color fillFaintLight = Color.fromRGBO(0, 0, 0, 0.04); const Color fillFaintPressedLight = Color.fromRGBO(0, 0, 0, 0.08); +const Color fillBaseGreyLight = Color.fromRGBO(242, 242, 242, 1); const Color fillBaseDark = Color.fromRGBO(255, 255, 255, 1); const Color fillBasePressedDark = Color.fromRGBO(255, 255, 255, 0.9); @@ -204,6 +209,7 @@ const Color fillStrongDark = Color.fromRGBO(255, 255, 255, 0.32); const Color fillMutedDark = Color.fromRGBO(255, 255, 255, 0.16); const Color fillFaintDark = Color.fromRGBO(255, 255, 255, 0.12); const Color fillFaintPressedDark = Color.fromRGBO(255, 255, 255, 0.06); +const Color fillBaseGreyDark = Color.fromRGBO(66, 66, 66, 1); // Stroke Colors const Color strokeBaseLight = Color.fromRGBO(0, 0, 0, 1); diff --git a/mobile/lib/ui/payment/stripe_subscription_page.dart b/mobile/lib/ui/payment/stripe_subscription_page.dart index 18d24729e1..97530a04b6 100644 --- a/mobile/lib/ui/payment/stripe_subscription_page.dart +++ b/mobile/lib/ui/payment/stripe_subscription_page.dart @@ -583,7 +583,7 @@ class SubscriptionToggle extends StatefulWidget { } class _SubscriptionToggleState extends State { - bool _isYearly = false; + bool _isYearly = true; @override Widget build(BuildContext context) { const borderPadding = 2.5; @@ -599,7 +599,7 @@ class _SubscriptionToggleState extends State { 2; return Container( decoration: BoxDecoration( - color: const Color.fromRGBO(242, 242, 242, 1), + color: getEnteColorScheme(context).fillBaseGrey, borderRadius: BorderRadius.circular(50), ), padding: const EdgeInsets.symmetric( @@ -658,7 +658,7 @@ class _SubscriptionToggleState extends State { width: widthOfButton, height: 40, decoration: BoxDecoration( - color: const Color.fromRGBO(255, 255, 255, 1), + color: getEnteColorScheme(context).backgroundBase, borderRadius: BorderRadius.circular(50), ), child: AnimatedSwitcher( diff --git a/mobile/lib/ui/payment/subscription_plan_widget.dart b/mobile/lib/ui/payment/subscription_plan_widget.dart index 119de4d3b5..75860347cf 100644 --- a/mobile/lib/ui/payment/subscription_plan_widget.dart +++ b/mobile/lib/ui/payment/subscription_plan_widget.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import "package:photos/generated/l10n.dart"; import "package:photos/theme/ente_theme.dart"; import 'package:photos/utils/data_util.dart'; @@ -77,8 +76,12 @@ class _Price extends StatelessWidget { @override Widget build(BuildContext context) { final colorScheme = getEnteColorScheme(context); + final textTheme = getEnteTextTheme(context); if (price.isEmpty) { - return Text(S.of(context).freeTrial); + return Text( + "Free", + style: textTheme.largeBold, + ); } if (period == "month") { return RichText( @@ -86,20 +89,9 @@ class _Price extends StatelessWidget { children: [ TextSpan( text: price, - style: TextStyle( - fontSize: 20, - color: colorScheme.textBase, - fontWeight: FontWeight.w600, - ), - ), - TextSpan( - text: ' / ' 'month', - style: TextStyle( - fontSize: 16, - color: colorScheme.textBase, - fontWeight: FontWeight.w600, - ), + style: textTheme.largeBold, ), + TextSpan(text: ' / ' 'month', style: textTheme.largeBold), ], ), ); @@ -117,30 +109,18 @@ class _Price extends StatelessWidget { children: [ TextSpan( text: currencySymbol + pricePerMonthString, - style: TextStyle( - fontSize: 20, - color: colorScheme.textBase, - fontWeight: FontWeight.w600, - ), + style: textTheme.largeBold, ), TextSpan( text: ' / ' 'month', - style: TextStyle( - fontSize: 16, - color: colorScheme.textBase, - fontWeight: FontWeight.w600, - ), + style: textTheme.largeBold, ), ], ), ), Text( - price + " / " + "year", - style: TextStyle( - fontSize: 12, - color: colorScheme.textFaint, - fontWeight: FontWeight.w600, - ), + price + " / " + "yr", + style: textTheme.bodyFaint, ), ], ); From b2103e389368279f59c3eceadaad23350ba6ee48 Mon Sep 17 00:00:00 2001 From: ashilkn Date: Fri, 26 Jul 2024 20:09:02 +0530 Subject: [PATCH 031/123] [mob][photos] Update subscription page colors --- mobile/lib/theme/colors.dart | 15 +---- .../ui/payment/subscription_plan_widget.dart | 57 ++++++++++++++----- 2 files changed, 43 insertions(+), 29 deletions(-) diff --git a/mobile/lib/theme/colors.dart b/mobile/lib/theme/colors.dart index 27d868c1c3..be8ed8e7e4 100644 --- a/mobile/lib/theme/colors.dart +++ b/mobile/lib/theme/colors.dart @@ -36,7 +36,6 @@ class EnteColorScheme { final Color blurStrokeBase; final Color blurStrokeFaint; final Color blurStrokePressed; - final Color subscriptionPlanWidgetStoke; // Fixed Colors final Color primary700; @@ -58,7 +57,6 @@ class EnteColorScheme { //other colors final Color tabIcon; final List avatarColors; - final Color subscriptionPlanWidgetColor; const EnteColorScheme( this.backgroundBase, @@ -85,10 +83,8 @@ class EnteColorScheme { this.blurStrokeBase, this.blurStrokeFaint, this.blurStrokePressed, - this.subscriptionPlanWidgetStoke, this.tabIcon, - this.avatarColors, - this.subscriptionPlanWidgetColor, { + this.avatarColors, { this.primary700 = _primary700, this.primary500 = _primary500, this.primary400 = _primary400, @@ -128,10 +124,8 @@ const EnteColorScheme lightScheme = EnteColorScheme( blurStrokeBaseLight, blurStrokeFaintLight, blurStrokePressedLight, - subscriptionPlanWidgetStokeLight, tabIconLight, avatarLight, - subscriptionPlanWidgetLight, ); const EnteColorScheme darkScheme = EnteColorScheme( @@ -159,10 +153,8 @@ const EnteColorScheme darkScheme = EnteColorScheme( blurStrokeBaseDark, blurStrokeFaintDark, blurStrokePressedDark, - subscriptionPlanWidgetDark, tabIconDark, avatarDark, - subscriptionPlanWidgetDark, ); // Background Colors @@ -228,14 +220,9 @@ const Color blurStrokeBaseDark = Color.fromRGBO(255, 255, 255, 0.90); const Color blurStrokeFaintDark = Color.fromRGBO(255, 255, 255, 0.06); const Color blurStrokePressedDark = Color.fromRGBO(255, 255, 255, 0.50); -const Color subscriptionPlanWidgetStokeLight = Color.fromRGBO(229, 229, 229, 1); -const Color subscriptionPlanWidgetStokeDark = Color.fromRGBO(44, 44, 44, 1); - // Other colors const Color tabIconLight = Color.fromRGBO(0, 0, 0, 0.85); const Color tabIconDark = Color.fromRGBO(255, 255, 255, 0.80); -const Color subscriptionPlanWidgetDark = Color.fromRGBO(255, 255, 255, 0.04); -const Color subscriptionPlanWidgetLight = Color.fromRGBO(251, 251, 251, 1); // Fixed Colors diff --git a/mobile/lib/ui/payment/subscription_plan_widget.dart b/mobile/lib/ui/payment/subscription_plan_widget.dart index 75860347cf..e7a1ea2fc3 100644 --- a/mobile/lib/ui/payment/subscription_plan_widget.dart +++ b/mobile/lib/ui/payment/subscription_plan_widget.dart @@ -1,8 +1,11 @@ +import "package:flutter/foundation.dart"; import 'package:flutter/material.dart'; +import "package:flutter/scheduler.dart"; +import "package:photos/theme/colors.dart"; import "package:photos/theme/ente_theme.dart"; import 'package:photos/utils/data_util.dart'; -class SubscriptionPlanWidget extends StatelessWidget { +class SubscriptionPlanWidget extends StatefulWidget { const SubscriptionPlanWidget({ super.key, required this.storage, @@ -16,9 +19,23 @@ class SubscriptionPlanWidget extends StatelessWidget { final String period; final bool isActive; + @override + State createState() => _SubscriptionPlanWidgetState(); +} + +class _SubscriptionPlanWidgetState extends State { + late final PlatformDispatcher _platformDispatcher; + + @override + void initState() { + super.initState(); + _platformDispatcher = SchedulerBinding.instance.platformDispatcher; + } + @override Widget build(BuildContext context) { - final numAndUnit = convertBytesToNumberAndUnit(storage); + final brightness = _platformDispatcher.platformBrightness; + final numAndUnit = convertBytesToNumberAndUnit(widget.storage); final String storageValue = numAndUnit.$1.toString(); final String storageUnit = numAndUnit.$2; final colorScheme = getEnteColorScheme(context); @@ -27,10 +44,16 @@ class SubscriptionPlanWidget extends StatelessWidget { child: Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), decoration: BoxDecoration( - color: colorScheme.subscriptionPlanWidgetColor, + color: backgroundElevated2Light, borderRadius: BorderRadius.circular(8), border: Border.all( - color: colorScheme.subscriptionPlanWidgetStoke, + color: brightness == Brightness.dark + ? widget.isActive + ? const Color.fromRGBO(191, 191, 191, 1) + : strokeMutedLight + : widget.isActive + ? const Color.fromRGBO(177, 177, 177, 1) + : const Color.fromRGBO(66, 66, 66, 0.4), width: 1, ), ), @@ -42,10 +65,10 @@ class SubscriptionPlanWidget extends StatelessWidget { children: [ TextSpan( text: storageValue, - style: TextStyle( + style: const TextStyle( fontSize: 40, fontWeight: FontWeight.w600, - color: getEnteColorScheme(context).textBase, + color: textBaseLight, ), ), WidgetSpan( @@ -53,14 +76,16 @@ class SubscriptionPlanWidget extends StatelessWidget { offset: const Offset(2, -16), child: Text( storageUnit, - style: getEnteTextTheme(context).h3Muted, + style: getEnteTextTheme(context).h3.copyWith( + color: textMutedLight, + ), ), ), ), ], ), ), - _Price(price: price, period: period), + _Price(price: widget.price, period: widget.period), ], ), ), @@ -75,12 +100,11 @@ class _Price extends StatelessWidget { @override Widget build(BuildContext context) { - final colorScheme = getEnteColorScheme(context); final textTheme = getEnteTextTheme(context); if (price.isEmpty) { return Text( "Free", - style: textTheme.largeBold, + style: textTheme.largeBold.copyWith(color: textBaseLight), ); } if (period == "month") { @@ -89,9 +113,12 @@ class _Price extends StatelessWidget { children: [ TextSpan( text: price, - style: textTheme.largeBold, + style: textTheme.largeBold.copyWith(color: textBaseLight), + ), + TextSpan( + text: ' / ' 'month', + style: textTheme.largeBold.copyWith(color: textBaseLight), ), - TextSpan(text: ' / ' 'month', style: textTheme.largeBold), ], ), ); @@ -109,18 +136,18 @@ class _Price extends StatelessWidget { children: [ TextSpan( text: currencySymbol + pricePerMonthString, - style: textTheme.largeBold, + style: textTheme.largeBold.copyWith(color: textBaseLight), ), TextSpan( text: ' / ' 'month', - style: textTheme.largeBold, + style: textTheme.largeBold.copyWith(color: textBaseLight), ), ], ), ), Text( price + " / " + "yr", - style: textTheme.bodyFaint, + style: textTheme.body.copyWith(color: textFaintLight), ), ], ); From bebaa7608592c2e5e23b063e0f9ec728ef46bea3 Mon Sep 17 00:00:00 2001 From: ashilkn Date: Sat, 27 Jul 2024 12:09:25 +0530 Subject: [PATCH 032/123] [mob][photos] Show which plan is popular and active in subscription screen --- mobile/assets/2.0x/active_subscription.png | Bin 0 -> 20420 bytes mobile/assets/2.0x/popular_subscription.png | Bin 0 -> 28507 bytes mobile/assets/3.0x/active_subscription.png | Bin 0 -> 39581 bytes mobile/assets/3.0x/popular_subscription.png | Bin 0 -> 55474 bytes mobile/assets/active_subscription.png | Bin 0 -> 6565 bytes mobile/assets/popular_subscription.png | Bin 0 -> 9056 bytes mobile/lib/models/subscription.dart | 1 + .../ui/payment/stripe_subscription_page.dart | 5 ++ .../ui/payment/subscription_plan_widget.dart | 77 ++++++++++++------ 9 files changed, 60 insertions(+), 23 deletions(-) create mode 100644 mobile/assets/2.0x/active_subscription.png create mode 100644 mobile/assets/2.0x/popular_subscription.png create mode 100644 mobile/assets/3.0x/active_subscription.png create mode 100644 mobile/assets/3.0x/popular_subscription.png create mode 100644 mobile/assets/active_subscription.png create mode 100644 mobile/assets/popular_subscription.png diff --git a/mobile/assets/2.0x/active_subscription.png b/mobile/assets/2.0x/active_subscription.png new file mode 100644 index 0000000000000000000000000000000000000000..8175b5ea6ad65a75dd0828385558fbb89b9b8ef6 GIT binary patch literal 20420 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 literal 0 HcmV?d00001 diff --git a/mobile/assets/2.0x/popular_subscription.png b/mobile/assets/2.0x/popular_subscription.png new file mode 100644 index 0000000000000000000000000000000000000000..62c008ee506f7d75af23d5778866790d207aff5b GIT binary patch literal 28507 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 R=*UQ)j+VjGY7M*S{{t!yB+UQ- literal 0 HcmV?d00001 diff --git a/mobile/assets/3.0x/popular_subscription.png b/mobile/assets/3.0x/popular_subscription.png new file mode 100644 index 0000000000000000000000000000000000000000..6b7260731dfd92d2512260d4c1d740c8d0225894 GIT binary patch literal 55474 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 literal 0 HcmV?d00001 diff --git a/mobile/assets/popular_subscription.png b/mobile/assets/popular_subscription.png new file mode 100644 index 0000000000000000000000000000000000000000..e0891b7bd50a560baddd1b0eb781bf37240e6eac GIT binary patch literal 9056 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 { price: plan.price, period: plan.period, isActive: isActive && !_hideCurrentPlanSelection, + isPopular: _isPopularPlan(plan), ), ), ), @@ -544,6 +545,10 @@ class _StripeSubscriptionPageState extends State { freeProductID == _currentSubscription!.productID; } + bool _isPopularPlan(BillingPlan plan) { + return popularProductIDs.contains(plan.id); + } + void _addCurrentPlanWidget(List planWidgets) { // don't add current plan if it's monthly plan but UI is showing yearly plans // and vice versa. diff --git a/mobile/lib/ui/payment/subscription_plan_widget.dart b/mobile/lib/ui/payment/subscription_plan_widget.dart index e7a1ea2fc3..b993abcaa8 100644 --- a/mobile/lib/ui/payment/subscription_plan_widget.dart +++ b/mobile/lib/ui/payment/subscription_plan_widget.dart @@ -12,12 +12,14 @@ class SubscriptionPlanWidget extends StatefulWidget { required this.price, required this.period, this.isActive = false, + this.isPopular = false, }); final int storage; final String price; final String period; final bool isActive; + final bool isPopular; @override State createState() => _SubscriptionPlanWidgetState(); @@ -42,7 +44,6 @@ class _SubscriptionPlanWidgetState extends State { return Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), child: Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), decoration: BoxDecoration( color: backgroundElevated2Light, borderRadius: BorderRadius.circular(8), @@ -57,35 +58,65 @@ class _SubscriptionPlanWidgetState extends State { width: 1, ), ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, + child: Stack( children: [ - RichText( - text: TextSpan( - children: [ - TextSpan( - text: storageValue, - style: const TextStyle( - fontSize: 40, - fontWeight: FontWeight.w600, - color: textBaseLight, - ), - ), - WidgetSpan( - child: Transform.translate( - offset: const Offset(2, -16), - child: Text( - storageUnit, - style: getEnteTextTheme(context).h3.copyWith( - color: textMutedLight, - ), + widget.isActive + ? Positioned( + top: 0, + right: 0, + child: ClipRRect( + borderRadius: const BorderRadius.only( + topRight: Radius.circular(8), + ), + child: Image.asset( + "assets/active_subscription.png", ), ), + ) + : widget.isPopular + ? ClipRRect( + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(8), + ), + child: Image.asset( + "assets/popular_subscription.png", + ), + ) + : const SizedBox.shrink(), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + RichText( + text: TextSpan( + children: [ + TextSpan( + text: storageValue, + style: const TextStyle( + fontSize: 40, + fontWeight: FontWeight.w600, + color: textBaseLight, + ), + ), + WidgetSpan( + child: Transform.translate( + offset: const Offset(2, -16), + child: Text( + storageUnit, + style: getEnteTextTheme(context).h3.copyWith( + color: textMutedLight, + ), + ), + ), + ), + ], + ), ), + _Price(price: widget.price, period: widget.period), ], ), ), - _Price(price: widget.price, period: widget.period), ], ), ), From 41f59ec9ca9089feebaeafafa92ce684fc90f44b Mon Sep 17 00:00:00 2001 From: ashilkn Date: Sat, 27 Jul 2024 12:16:47 +0530 Subject: [PATCH 033/123] [mob][photos] Move SubscriptionToggle widget to subscription_common_widgets.dart --- .../ui/payment/stripe_subscription_page.dart | 115 ----------------- .../payment/subscription_common_widgets.dart | 117 ++++++++++++++++++ 2 files changed, 117 insertions(+), 115 deletions(-) diff --git a/mobile/lib/ui/payment/stripe_subscription_page.dart b/mobile/lib/ui/payment/stripe_subscription_page.dart index 95274d8f05..02b782c700 100644 --- a/mobile/lib/ui/payment/stripe_subscription_page.dart +++ b/mobile/lib/ui/payment/stripe_subscription_page.dart @@ -578,118 +578,3 @@ class _StripeSubscriptionPageState extends State { ); } } - -class SubscriptionToggle extends StatefulWidget { - final Function(bool) onToggle; - const SubscriptionToggle({required this.onToggle, super.key}); - - @override - State createState() => _SubscriptionToggleState(); -} - -class _SubscriptionToggleState extends State { - bool _isYearly = true; - @override - Widget build(BuildContext context) { - const borderPadding = 2.5; - const spaceBetweenButtons = 4.0; - final textTheme = getEnteTextTheme(context); - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 32), - child: LayoutBuilder( - builder: (context, constrains) { - final widthOfButton = (constrains.maxWidth - - (borderPadding * 2) - - spaceBetweenButtons) / - 2; - return Container( - decoration: BoxDecoration( - color: getEnteColorScheme(context).fillBaseGrey, - borderRadius: BorderRadius.circular(50), - ), - padding: const EdgeInsets.symmetric( - vertical: borderPadding, - horizontal: borderPadding, - ), - width: double.infinity, - child: Stack( - children: [ - Row( - children: [ - GestureDetector( - onTap: () { - setIsYearly(false); - }, - behavior: HitTestBehavior.opaque, - child: Container( - padding: const EdgeInsets.symmetric( - vertical: 8, - ), - width: widthOfButton, - child: Center( - child: Text( - "Monthly", - style: textTheme.bodyFaint, - ), - ), - ), - ), - const SizedBox(width: spaceBetweenButtons), - GestureDetector( - onTap: () { - setIsYearly(true); - }, - behavior: HitTestBehavior.opaque, - child: Container( - padding: const EdgeInsets.symmetric( - vertical: 8, - ), - width: widthOfButton, - child: Center( - child: Text( - "Yearly", - style: textTheme.bodyFaint, - ), - ), - ), - ), - ], - ), - AnimatedPositioned( - duration: const Duration(milliseconds: 350), - curve: Curves.easeInOutQuart, - left: _isYearly ? widthOfButton + spaceBetweenButtons : 0, - child: Container( - width: widthOfButton, - height: 40, - decoration: BoxDecoration( - color: getEnteColorScheme(context).backgroundBase, - borderRadius: BorderRadius.circular(50), - ), - child: AnimatedSwitcher( - duration: const Duration(milliseconds: 350), - switchInCurve: Curves.easeInOutExpo, - switchOutCurve: Curves.easeInOutExpo, - child: Text( - key: ValueKey(_isYearly), - _isYearly ? "Yearly" : "Monthly", - style: textTheme.body, - ), - ), - ), - ), - ], - ), - ); - }, - ), - ); - } - - setIsYearly(bool isYearly) { - setState(() { - _isYearly = isYearly; - }); - widget.onToggle(isYearly); - } -} diff --git a/mobile/lib/ui/payment/subscription_common_widgets.dart b/mobile/lib/ui/payment/subscription_common_widgets.dart index 49a9a4aaed..90f2864d56 100644 --- a/mobile/lib/ui/payment/subscription_common_widgets.dart +++ b/mobile/lib/ui/payment/subscription_common_widgets.dart @@ -180,3 +180,120 @@ class SubFaqWidget extends StatelessWidget { ); } } + +class SubscriptionToggle extends StatefulWidget { + final Function(bool) onToggle; + const SubscriptionToggle({required this.onToggle, super.key}); + + @override + State createState() => _SubscriptionToggleState(); +} + +class _SubscriptionToggleState extends State { + bool _isYearly = true; + @override + Widget build(BuildContext context) { + const borderPadding = 2.5; + const spaceBetweenButtons = 4.0; + final textTheme = getEnteTextTheme(context); + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 32), + child: LayoutBuilder( + builder: (context, constrains) { + final widthOfButton = (constrains.maxWidth - + (borderPadding * 2) - + spaceBetweenButtons) / + 2; + return Container( + decoration: BoxDecoration( + color: getEnteColorScheme(context).fillBaseGrey, + borderRadius: BorderRadius.circular(50), + ), + padding: const EdgeInsets.symmetric( + vertical: borderPadding, + horizontal: borderPadding, + ), + width: double.infinity, + child: Stack( + children: [ + Row( + children: [ + GestureDetector( + onTap: () { + setIsYearly(false); + }, + behavior: HitTestBehavior.opaque, + child: Container( + padding: const EdgeInsets.symmetric( + vertical: 8, + ), + width: widthOfButton, + child: Center( + child: Text( + "Monthly", + style: textTheme.bodyFaint, + ), + ), + ), + ), + const SizedBox(width: spaceBetweenButtons), + GestureDetector( + onTap: () { + setIsYearly(true); + }, + behavior: HitTestBehavior.opaque, + child: Container( + padding: const EdgeInsets.symmetric( + vertical: 8, + ), + width: widthOfButton, + child: Center( + child: Text( + "Yearly", + style: textTheme.bodyFaint, + ), + ), + ), + ), + ], + ), + AnimatedPositioned( + duration: const Duration(milliseconds: 350), + curve: Curves.easeInOutQuart, + left: _isYearly ? widthOfButton + spaceBetweenButtons : 0, + child: Container( + padding: const EdgeInsets.symmetric( + vertical: 8, + ), + width: widthOfButton, + decoration: BoxDecoration( + color: getEnteColorScheme(context).backgroundBase, + borderRadius: BorderRadius.circular(50), + ), + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 350), + switchInCurve: Curves.easeInOutExpo, + switchOutCurve: Curves.easeInOutExpo, + child: Text( + key: ValueKey(_isYearly), + _isYearly ? "Yearly" : "Monthly", + style: textTheme.body, + ), + ), + ), + ), + ], + ), + ); + }, + ), + ); + } + + setIsYearly(bool isYearly) { + setState(() { + _isYearly = isYearly; + }); + widget.onToggle(isYearly); + } +} From 63fe67d677b8d450d799d177d0aaf60f982d2a7c Mon Sep 17 00:00:00 2001 From: ashilkn Date: Sat, 27 Jul 2024 12:48:07 +0530 Subject: [PATCH 034/123] [mob][photos] Add animation when to price when switching between monthly and yearly plans --- .../ui/payment/stripe_subscription_page.dart | 6 +-- .../ui/payment/subscription_plan_widget.dart | 40 ++++++------------- 2 files changed, 15 insertions(+), 31 deletions(-) diff --git a/mobile/lib/ui/payment/stripe_subscription_page.dart b/mobile/lib/ui/payment/stripe_subscription_page.dart index 02b782c700..0bc844acf7 100644 --- a/mobile/lib/ui/payment/stripe_subscription_page.dart +++ b/mobile/lib/ui/payment/stripe_subscription_page.dart @@ -212,10 +212,8 @@ class _StripeSubscriptionPageState extends State { widgets.add( SubscriptionToggle( onToggle: (p0) { - Future.delayed(const Duration(milliseconds: 175), () { - _showYearlyPlan = p0; - _filterStripeForUI(); - }); + _showYearlyPlan = p0; + _filterStripeForUI(); }, ), ); diff --git a/mobile/lib/ui/payment/subscription_plan_widget.dart b/mobile/lib/ui/payment/subscription_plan_widget.dart index b993abcaa8..e1221e6b31 100644 --- a/mobile/lib/ui/payment/subscription_plan_widget.dart +++ b/mobile/lib/ui/payment/subscription_plan_widget.dart @@ -1,6 +1,7 @@ import "package:flutter/foundation.dart"; import 'package:flutter/material.dart'; import "package:flutter/scheduler.dart"; +import "package:flutter_animate/flutter_animate.dart"; import "package:photos/theme/colors.dart"; import "package:photos/theme/ente_theme.dart"; import 'package:photos/utils/data_util.dart'; @@ -139,19 +140,14 @@ class _Price extends StatelessWidget { ); } if (period == "month") { - return RichText( - text: TextSpan( - children: [ - TextSpan( - text: price, - style: textTheme.largeBold.copyWith(color: textBaseLight), - ), - TextSpan( - text: ' / ' 'month', - style: textTheme.largeBold.copyWith(color: textBaseLight), - ), - ], - ), + return Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + price + ' / ' + 'month', + style: textTheme.largeBold.copyWith(color: textBaseLight), + ).animate().fadeIn(duration: const Duration(milliseconds: 175)), + ], ); } else if (period == "year") { final currencySymbol = price[0]; @@ -162,26 +158,16 @@ class _Price extends StatelessWidget { return Column( crossAxisAlignment: CrossAxisAlignment.end, children: [ - RichText( - text: TextSpan( - children: [ - TextSpan( - text: currencySymbol + pricePerMonthString, - style: textTheme.largeBold.copyWith(color: textBaseLight), - ), - TextSpan( - text: ' / ' 'month', - style: textTheme.largeBold.copyWith(color: textBaseLight), - ), - ], - ), + Text( + currencySymbol + pricePerMonthString + ' / ' + 'month', + style: textTheme.largeBold.copyWith(color: textBaseLight), ), Text( price + " / " + "yr", style: textTheme.body.copyWith(color: textFaintLight), ), ], - ); + ).animate().fadeIn(duration: const Duration(milliseconds: 175)); } else { assert(false, "Invalid period: $period"); return const Text(""); From d3d859f25220c465fd2834c28ba918aedbedb9b0 Mon Sep 17 00:00:00 2001 From: ashilkn Date: Sat, 27 Jul 2024 12:56:16 +0530 Subject: [PATCH 035/123] [mob][photos] Update asset --- mobile/assets/2.0x/popular_subscription.png | Bin 28507 -> 28338 bytes mobile/assets/3.0x/popular_subscription.png | Bin 55474 -> 55362 bytes mobile/assets/popular_subscription.png | Bin 9056 -> 9034 bytes 3 files changed, 0 insertions(+), 0 deletions(-) diff --git a/mobile/assets/2.0x/popular_subscription.png b/mobile/assets/2.0x/popular_subscription.png index 62c008ee506f7d75af23d5778866790d207aff5b..c609e539c53352f22dac2548ae34121bdd8c639d 100644 GIT binary patch delta 26681 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 delta 26885 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 EKhZbJnE(I) delta 7321 zcmbtZ_d6R7(6_0oRx5Vw8I)2ZLTyo0X(h3%b}1!6?O3&m6}$FKVnu!JR;}2bHmwz_ zMs2Ei`~C&*5ARR+%iZ(b-E*INKKHrqdTL?=bqYJ@@;uBj&rba6F1&XHT+Fy1@faT4(uK&#fLUXfCLXx#HcfW# z`{@CudTn(5RzgcJIZYS_K(ztGk<@zwOlWgo2F`S#5uJZ&SBJwZ5e{m}Aq!_mi{;A4 zT5Ae>!DYSrudbSi7pDR5?8m63>nb*JbV4A4`0CU-RSpkShCrco(HI7tP@s*N^Vmn8%HB8&emLSWUlT@P#Gbux99Pg9wuq;zEUnE^_y77^n~#Rg+N1DaBUqzvFE`PTphF@ibbz8DqF z3-3fIMp^a%4z*wHFzzIYt*d=Ss{__g@279Umd58(SRTMfeXDG@p}!t}gD7n;tzSq+@J zTjn0$s+B(CGTYq38N1TR%Du)#qJNZC8AXTIhX?KmAN~;>G~HfvmS<~2gWRMtunK@ z2Phsfz6bk)^5#9km3FERGSOWo3*i9)COcN@`{Vg_;WbQ1j{ovkAcEjCZ zl9x7{wWY%v11Fka>GL&<_Q)U%=fno`R~uMB*Oq+Z+?li+FW7~TJ{^`p?+Y>J-tG-6 zswMe%(G`1)Fis|c=(Q&rV^^y6ODWB@WkDC7umN0E0Yn}G4lz1O(-UoWst`|?{MBE8 z{|kG{7{)5cQ`53LmHOU6UkiRFi-a)^y&;sP2zkOY&{;xyHF{)$bdpnG>Dv~(Vr)mZ z%_tReFfl*!X)gZn-a0=q-w?mt?Zv^XdRg)=j7FY*vY3%#Y!h1)M7rf)J@lbY4)uVN zT4QjD?VRl^DI&U>)hfsIYHg z3aJ*8qyR24$esIlJ0z{Etxh+59kpm;=rXkg!h8WF&yA)M}30{GnS zSrt>!IY|dwD)Nh4Q-6gb9$dlLg&qH#WRC`D1d)DMI1aod3Q15`$(`LG*XG70Urcwz z^dc3W($!ZdA3B+jCL1%HvawwK+8Dd3X;4YicRt*gYYw2Q{Xx@1kHC=Bcyg|#L{MzK6S5!Rs?M&bf2Ae1B#}i))VE0XC zHmITw!^%r?>GB!EL}~6%HBwJ|JMeb{N35h*%PmH3WakJ)4s;KtI!Q9Bpu26<5b-JF zWvqE&HMiMN18Hb<>$AfRx}VIeHKI^_NM?|u90jd#Ja}Z}3mFt4mt^^c=&tQUoNUGa=i}s!CoVNw(vC+FQh*Skx2~rXriNLNo3qHWmW#8? zj64CYNn}Vl;nl8aWjvh7ARBFZo2q79IxtjPrxxcp@P#hb+P2;<%1gf~nAa=xU-D$6 z6rurbeKtG%o!s@j>Bq7gzK7^A;zoFIa!M@yxXrRzqm-vI#f1K9Rayb~uF|X#bz4kt zEuXV`ay00XtdFe27Xk`T@h0ENa7t>_nVy$@E5U4r#_Li_$ z>?+PMNU`k;Pm#%;;xt1;cuwk8v-UO8L)+T_ihWv`&Sd4x$hqG)&2cXD?rVu2w{k3j z_4GyMb*v~Z0-7DPj&O{dVnlOWcUKM!Z3Z?9G)h%dSiY9Z|5Q>v@@>y_ zYEU{ixvf{|!{r}-Bwj)gJ_Yg9!SduZl;iaIpJ z+RD1LS=()Qir(Z`^4D+}3&hL2EhTh0cCO0-Zh6APiA!lByzV}>h2Kk~)&-CFAIYq( zuM6|vs?3)tCx6*gb0}tJMQX(L%%L4H#ARLp>Ni39XqzIHr}Z_e4lC!d7Mz|Lt?d@T zAnWW49zgoG-0~Z%%t+G2UO&-WeCz%yT75*vVrSD&ABEvhSu4QER1plWFFRi%Ty2s- zMj=)K-w%t24rAOc)Nn zWnl#r{U#7Xx=6`e&jy_Z3eaaiiB{#?A56YCJKVrcT-tVjmF4QcB8~YaV}flYxDJnZ z@dB4z?N`k6dJ@p!pK9NW2IDF6pEzOJ%WrU+)hf;Uyv%r8Atkf7Vo*`~F>;_p zN^J7iyR*h(2Uedc(V~nZsR+jwmA{-;Re#6)88{>RN%@m*b966!72mYS7fQMHW%E+s z@g$WQJ4k8anM}vwQD9tI`dD5_r7T z5ZGm=YD?gftH?RjguMDL=Le$zz-JZL&X^GPoI@14Qi+-MHo$~8$CE*`!yB;xsa0o9 zd55%s4A8H(6adCz<1X2PjzR zIW2CB&&8svb{`%&;eK#WvQbLO(x#-BQyv95No2Q;alOo1EI>{A2q=Vmq+?u`9?5^qp`YD6lT@6>79#6pq@EKNLL8QWO8c=jx@4egCI1Kw2@6 z+b-v;+fAKq`ik`ex@CQnL~oY8f!=E>1qk5ph^D_fWG9Dy>L(Pns>CIf zSlQ!BtxRO?`P;+fy88C%^Mn|OBxqOezbxQ05osR$$FO>=1~dQeJ9T_$9$H(dxyuVR zL!2Z2^i>YosLd!)zm@qO23UquGb>9Wq-E@HaXN;@e`p(e+T@PxkeFBr-hqwp3N6X- zqC@LFK*)Q3CEMIN-`vVjR%uT(1e8U^h*|I0S>|EjSI$hp-*Ai=SFnMo3skbNO*hO7 zhS81*!sG&#aGEP)jF8GFBG1@>6PAF4eHW+4^l6e#t&>`Jqg2;kngdW_`R$BQM>B*s zy6PK{9TkLBt#3D^45+p?#ZvFs|1?KeQ*RL@hUWWt4ApLQM{ewx%S-CPVdi zFYXLgvBswxawXPBbdF8$dCDpvpRZE1?;`k)5#}^g9A!N$ZoogHdIl(8%XR4t@vvE~ zK8&e!Q>i)KK+E{bN;bYx@BrQ7xYqAxA!$#twt=y%?5)J=rqmRn-c_{63yDG~ShOD? z>~6-5xC#z@qoUboM!>V)m?1Q2Gz947fK~O~UvO&5_WR0?yw8gcTKosp^f~qD@{X)? zP>7qWZ|%LRVcESxGeGE~cz|DaDK^+~7pd`pN6knW$?9A0*jZc)M=Kr;V8>@G)emO{ z(*56w;C&zPFl%`rwZNOb6^a=ews)IGP0Q?)Z&NzX#b*);M#&3iWN%ha4dJY??_~Vu z>N|g`vS%X!aBV4auBs-{P6#ee%^@E{rAM{D9suTkYa&~~T2&ii#GJ6t2Jop$eVTtL zp+9cF^RN}c1))cyclL;y7Ed;ii>kYPNq6agGXJC1I~p3%^y5h1+o8{F8G>jdZa)Ar z^vl#XB|J149dGB`PomzZ3sHqUnG06|kL5fqu)@Xw?o9YxI4jPlI`gZE13BGNOXw|< zwkch&=-WZ>n`R8R=t-=`3_5o-^x#e(lH*R*R!ia1ieFgJZam+}X0Te5YUXq8yq7ol z;pCX|6v~$)jMMvJ5yasfQ%99siRN9zzPVy10U<*zP71?`_XfVg(3RV4lr>bP@O8~G zUi_mj-3_&pdij>acmF!z7GPb$boLk#NE;7Uw8zS=h~?k`K@Z-ODnE~g`vcC^cD|a9 zT{lj7_}KuR%&^J zDAAmsLdg|UuX}02YK{FE1T_Bg!b&2k8u-eHZX-PDn+v9r|FU$w`EPfh@J_-gzk;O^ zC_iR%@Iyr?S!H3`TYYYg47$oubZfFY0y?$+K|hyxlkefYHrL`U52*31t9guAM;?Ey zV9mZE5Cr!Le$vDEydC*+`M2enUN&x| z6g}i?ck685Bmw+^ozl^!wwu>Z@?~E>i1a330ms9yJplPallrR9O1HcT$LIR}R7#Bl zc!lo$37ikOlf$(1v?YwI7R%RjSae4`Kw73jBAkYiM+r z%!xwGCB>8+##^CM9PIQ5xbb2^xQi1BZ9Tx43vS&3)jCu&-VQj|l!>@Sk!#(2FSeC9 z2b)*2rz@OEsT(b5)OWa}l#hP*CfBP3#K3kO; zYrNfpmtuLMztO$6FIbJasi~qhHH>IPK7FY)qLOuKJF(b7GtQowF51+l??A~FeHBaV zjuj}Zb6vIj?v_fs{G3NO(PDZTEFZJm$Uuy>m_{k{qo6B~<>vE?0OLiPIg(W3wsV_a zaZpkr-(D8|Yumyk_9i+idCQ412TW#Cx>L<)>C?PD-Kl-bSM(hmgIEc)69*kD&#w}PH}5^W0t#GS>j;Nz&q={TSVvn zPul8mN{OhjxdPQA-gKKV5cSj@&%zpg&rM_j0Qi%2=~clzSP3}l-uNfn8j;|As6-%^I(o`aewaou^=G&I9h)`wvl3X%D>#n%~y$5p# zoIGths#cPHFafwV=!D*7pq?lbe;sPj`SI3Rxm`wQ=_B9Y3Q~y3>GYq5f&=I&H3yuK zt6vACY;*I5%8$O(tnOc{U`amX*yp+;Vdu%rBqhu#9{ORk$?Zg(39?r&n%p~YSr;8* z8yP_>{CjkC)XNC!sM^0M&hJVe62y7xx0Q9fd*7coQ8NpF5Q4TSuqQgy>rU@YZwNRp zBrF~53;yGH@weo5`Lq>c6AwB+Oa3|G)y3DZ3JxsVEY^jxO_-5cTUELe#2|;%$axs4 zIqbB3|MgqHsf1FJm_mw{RdLVHuGMtE{O`U|!eM5T(~T^c)B+JG-odgw!IGsHdTrlux!hTZ;2FySL?~(STOQU7Zujcr&Y7&OY{66uD9^6o$ze2Rv&F_m^CU$MI zd90Sm-8`CZp-}fNT_-YjvGR=TcAA^Odt(6{U@^C+p6H1c^{L=S4q0uNT+ez@J|@VU z24t5F`Gjf*M7x4w;EzctkK@nltQ1qkSUiAGwPNmZoA9>8Bn=ajEEy)q8+sI z@JbTcma3O8OWo6cak5BqM;7MaK8Gu5Q%WonwpM!tI zM^5&lU7OnKRPe6e`8WHi{lmG5|EWYs!NQBT`Pl2Tu2&a1+;0uuZFvwY)k(@zu@{L) zy5OnGFd8^WgHI=nWEeSf#6L~lR-@P;y z#m?@nR%J_JPI*wtq`X5epUZ3zF2(o0dssfY(JkjoCZ0jx$nS7kaKSV@VvM&1Klx)- z>a6c0yOFSqfa9Dw%s2v>b)iD@YY53t$1g5nCCZgtESQp|lCh<^;U8eqNd6h~ns48B zySvHHTi|c~6y&nQe{G*kKKLxdmG#5&Kha?k4AfM^m+idIE?i_ibPsW`c-zpF#ySQa zaZ~9O^)fSirA3tT2AmlN_h@zFM0#xmQqJ?RHOR?PIFYWKU9t$CmHm}t3Q%_|h3~#^ zhsnZrYX4HrzAT}Z(@+>MrXJjQpE8Ke`6q`CsS@eSmdj~Q9jzCpuR*?KM!B{{;-Z&C zGh14Y!JwjdwOfk3q;t*teX~gg!FaUHu-rd+^fB)a^q2flfW9rph@TV;2#q2xRUY51 zDnjW5LA?+)^Qn1t1)C5MG7n56D7T!@OZfhXbdsEmpkf%w4k;GWpApDwi@63$uD=1@ zIB1-|xeS?|Wv#l1NiDi39AyJ9e5_tOmDJ;HBui;AAO zm1G201D6%9{~B2T)vDV<23oiEX3L#>%zaq_uI6$tvZ#EthChAsQ%^Wgzd+*Kv5KCE zBb70h-gK{AEQ;;e@7UlewK4=^|9KhS0dO)-3%zkX_58o&OJm;bWCWne(H1wXCK>JZ z__16n8~hgUZet={W~Ot#N=eC=tl1LT2XmQPqbWPfJWAG3uO*&0JZQyF|Ma(&!vnr> zrZBY?n``~ti+bKd!Djr1-Fp8NC;Ki-0~2^3*I2GoMjPmpMl@>*Z$>hLQ&QxiBiDr% z31$(hDPr=nB~DG^WPeoqNYUy0$^E<;q8V{3=;1`_NzH56>b--Ys5^2pR)~`%80e-z z^&s|%sZGGh;%V)|2-;Rt5&I1OsruM6dR(N=E@p;ZvOig+so}=?-b!fV51|>JtC-U9 zp}a(63NPh}kH0LAMehboi<6FKYq%_Bnh*Wg#@+_1$dKo1r+xqC&)<}+<32sA_s(d- z-}F`Hk%@Bp`t=y)^NoKv(&r-dGJhtaFzbVH)g|R`EUstoC>$M@s?q1IkPs~|kE1&p zYC2lTbnsH(K~#7F?R;CTT-SNty6nr$nK?I# zhZHSRk|kP}Dalqs*@^2UW}Q0C%|(KO1>C0SLje~k8uYmj4XKAd6zE$~)O~2t7O-51 zfP)%|Dptj8)K6ESre_X32!4VrDSb15g*O&cXHiWhpQ>MSzF%g&@#T! zE*ZVg8ho}*JARMP=oj~z$j+TXUzXol^ugy^c8yP#&3Lc$#eb~yjb4n5ZudDHGp^u0 z?=k_MCK)%{+DAP%Q;pI({$iuP~dOv$k2VTjpSG^sFO90Cd~kuts)0(WzfxP>qKO!ZEPOetb8*;C+&lL zV;d=L?v*}p%?kjWq)LtUiT(s6jznOg7+_*Dh+T;nX-@)H6akGCVi$Oyvn~aZP_9{u z0gasZ89FGP%ja_H}*^2#3KPlCecq7k6|I$|bljm7i0oY)i!+4yw!x>IQ`jXGoe-yqP`*9)jxQ_Wq z8+`9{+>}b^W0qm1%MfjQ9IDb4+u1$lAGur9PJ(7=1a+V?0fLk`DO^tgO98byfr_?e z7XY+_49J84YFH`mSXj(&yYqLi|JMyzm2`IStIHfK7r1Z;p(B5I(a-XSjSAe+6i{AyVv^5@z zlmp23wWCB(YPx=*cwhH} z4}N~*)7Lla*DJ2-I1IyU)0e}N-#bsKf_78IRH|^M1VEe6f0va-!K_5mwJ0hvztO#ASR86e@qw0XNMiXxMYReh`P^Ioo1kt z_l}-BY2O5=k*v_Drt|ntR4@h^>r4e3Rkr+WsTrh9;#_L9ls0@NJ}0rAlR3g62vLCh z@*()V-WeBF=KMZ$+$oW`DQFOBr-QYs7P_j*?231yO(RI6mQ)*Cao_;AUdmRw3;woy zKfnIpf7i)Lgp$NYkul5*!{>*YEmlg`@#IieBuP|lSE>>n0i;T7oqNM!cshVas>FOy zb&Y^sD=-Awsoj+^H>zvIL5Ynf$kGSygGIyh1ZcoTe3Hwo%Zj$~y-^9Gf7YCQ^yE!J zvtPRY*w=(PCnd3z4tn83t@j?tZ0@a$u&Sy!f1R3!HIG8CPf9ZH_bk4veD~qsyY@eK zR{Kv4ga%lpsv2%7FZdU?|A%)$VW0WlX!RUJC1Gro3aW`~_@0bGh!rUe?UhmcW5f_z z(i$;gq%0a$@EL)M?Pj4H!!zY}Tp2hW<$%JEg}Np0GPsh+C}WJzJ>a%cSo`|8o?f5C zf2IB>4t!$VaKpDeGNY^4#G4v){>C>%Y7C z^LzfuZ}jS|SJ$Q&UwCbD;rr6Hf46GSK{**WN!+W^RuY3P!IWzzubB*$D9wtHwmA*d zLo-dGW!`bb5MD_~c#%W`XQUsR0p1EJgr$x3#3f(2-1h9eIUp-WEZ-6|t43KQjBy+X z^TN*8&pfe}@;}@YMG2 zub&&7$?xxeaOvp6$A06!xrawk?{nw--~MLLZfr>CCI(Hj90W`h%F449q{N_|l131O zTqFS}MU92Iy0rV=YFI}vt}z?AnF2>?40Nb)N?yQYddk{KWnHUf&zONZYHobkC)OYP zytQg@QxkksobPSF|8UE9fA0VPS3i9@&O$xAtCtFtF06tjDm>E~u}eBx4PE)K*Z>9B zm;>cKtq(2!(#pZ|@OHy-#z!>(uL9U|LfA|{DsE*2DJ`WY@11M0HvN#h`4|h zjRr~%(xc|4NjEk3f2a-9`)Cp3Ju2D{x`n%{_ZdQ`uE2#?rhf^)y#g;RI#ywCg~WXnFv)!XLhy1G|nVO#aojQ zULA<0P>cZ;*|{)$Vdv=L$M$}3@#8;rdHm8jG1*qA5C^29e`=Tsp*sl43m^a}1feY8 zreb)bCV88LW@G=nkIhgUm5niDzqK%ynCCu*nznJtykbh5nsmoo9d4F^Na+p6uR*%0 z`eA!}y?XW8U)=M_J2IO^a-7WN5&SLT6+H%n>qx<5iQj|7%5`buGL!ov!MsM_wCD9) z-y2@@4)4)te~z{5`bGCZ_hfU9^x-i3%gz!co*hynmA+IFD5@KnjAOX9?s;>5Y z-?SkC24E5)@U!=Lp$*hl;@lw72c<5iqRi8l*)s3J#GBmOTZ1+WaIKjI#H@D#)b7-U?m7l0f<#j3)4>$^34E=j5M8wglvy^L(X1+ zBSQ!hHNde0v^sd4y+iDZ@1Ps8G(|d;I_ahJ=B9`6G-2gUx?720Y7&1+3}5Iy^`%D^ zKlWSSf9`+n^~R{HRk4!4XP(q3x7FGTRU8Sx=96T-llpq9uTvGpObXlfA71*^gNNrn zP`*6;&i}YH{O(IW^HInxa}bSsQ7t7eNn`1lSP-o?r1rF1Ug?u4z9#fBopqKx6D@)qX^+0yHF-P$0j65%<&P zCfs~Im(4qHt~x4%SDPeetYH_nA?8gW<2{`}H!-aCn-C%?J# z#V-u&{*a{9NP1m(8EBMnFv;-?>BRyDSqinekd)|5;{;q1TP*U#Kks4{6eRBosXH){ zWkWsZsGXSCDr8v^>bjz*rHM;Nj@*x-uihB6CM(bsO00TOtDZ`jHPATNzW0t%+=+Fp zDrXDjXIy-eP#=YAWZot<_o)$6jeLx4QroWaxp@DPgrL7~No!uT>eK3~HD;JYH^b3V?E=F%BF^yGiesE z|MkOg7%x4!^@aa3s(S+#N16~wyrVwb2t`^T(zDtobV~cC3MMyO01@M`%N0Q_ypBIG zCjx0hDlW1;jhU1x(YNPyw9PXI$fnFDqfZl-_fFEEo;hPsnYy9(6q`01ti+#;^|Y#^ zi~u@?%F>1B$IIv7Jr@H*Lh?Gt-tf+&hP*4reH}Q7Bi6~B4ve_aSTCt7j1iE zgS^wq6Kob;A3t!hb9nH$%gbopf;^C_E25sVC{@8s*93AGDj79!4a}`&NM?pmqTS1D zSzPO_-8A2wsrRv}no_D`9G7O5@c5qA!|xx2E!V5JClW~OeNn4@Qj-P|5(%^~KeX73 zn_oARArVP`rgnt%e4dURJCbfzX;(?p0ER_M{5eE)qHCof*pm81RFEG65! z6nFDZv%;>bYcmHqtY%6$e5!P8>Hy9(pn1tsAeI%6=Ee4@*CfYF1y&XAFakIa)q`o1 z5DC^>i8ZZSCR_`Wr}j(1^Q1TpE1)L_&`4#(X`?BBSh!Z6LDphDiW6va8M_C&v2Xe^ zXmg`12MZNagVsWDlL%xb&qris7rWW={jc>dJR<>CvjQ3cp(Ut&6N*AuXO(h&BDV}Q zk4+7L@;PbF4xG!Yy`mFH8W2j%Dv~4X7h^3P15T!3N)3`q-gB4fRb2) zlpK7`ks8gz?<(pNr#&zDo}z7ZsnEHv%Z;mvi~R% zLru}cwHkDSECOKFz>l?#P)(*<1C2$PV5-wJ1_#M&+OugKr+Y@5>BNZ>H@9h0AJjsF zB+1AetR&G>T*>eF>D9rXU6lxMrZxa-f9uYLz4?KK;Sj|#CPcGtX0;pU)zrRvrg>w z4y9Wu;boFi(L|bNPB{deX$V& z^=0P$CH;JK;U9kD!;8Q6%TI0p!L$9aIZnE>M@*VJ1+|rdg%l$Zg9O{2HQkzYK~v!3 z7-->ywy{cF3;h!X&Dbu9VfU#?5s7{R7;rL=@6!bB3g#l5cuXWR(6${5YUJ2|;m&Dv zBS>l4szz9*rMo-z*7+;3zi!g4Y)eq@saZaRu~Abfnw&Ljlb64EbpB)i99{@!cygg% zZ$IAIGGB%|@d(;yeDT`3LI5DSS`U|D7@7>$X1!S_Kbdb*1U zG(VEcR%wKJA?}6xmGHhpe47BaKZn-*qT0&r$xNSmj9TIn7 z5-uFLJ^r3qGq-X$swXgilDZ@JRprMV{5Y5ez(kLdxlAgue264zkrM z@_Isx86aUTSjg6>atqxW_Bie{Wn49@>7+S%OX_K#xcZrYPj6_nsx90p&4{OFKP5?| z>S$ua(V*zhnZ^uUUL`Ml6vHRyz+y#if`1r2(^$`peu;fkn>fN)hYD_tDJ4Y>mFAl<{s z@DEB#4;hQOrC6qMboI2pI$5Z*!Nu_Os~h3Ou@m7|w~yK{U;WI#gIfx+jkBqLI!VJc zpt)w>nrrsmcy$u9q_=P_4X%yS5z?zB1yd`je9!z(KiKhq3;Qol&i?84WV0uemv}M= zA=MO5l(@}`K?evq3+Mo(lHd*Cxj31Oga^M5A$p+D^xHu|I@!XQts(M?V#ARv>=!k#X0JP(e4_TukpKy9g!Z z5Z;w)O~){Q#!PFv+H6Y=>4zS!bgn6HXCmgeb=w;Wg8=5pK5kD4%aAMpFjm;w= zor`5GyECwAIG-+h+K$BtPQXmV^3mnlF3Vd!kX~hf6$j(N=vnZf7ZjQaa55U4P|xy? zxbC@YL=ux)izHLwt}&$#Y?wE>d4g4Gbw$!mqFw|P4n%`+Ep9S)%v!2~j2BaCLyADB z2`Q3_Qj* zbdolI>ML=6QpEM)E75$_q~pi_P5dD-Gpt)nUOi~w&VWE-L%+M}L~1;mTL-|%fXX#_ zfg(t1nNGwvX=dIRh3$)R=b3F0H&$ZgQkI2^af`bcYqKkFz#a- z`NG4{BR~*FpTw}IysC#`ha|7=$L-qhV61U61Gks;Qr*UDTJO{wA>K1aS?EeID}y>a za45{*Jq;^YKN?RT(~Q-v+OpYUpSbp!e<_oV88LsE@n@nfkMf>H70OYRjpk#uQ8e&H z8$^_nIE^fNAS{BTfC?jm4^qBBOT1}!T4@SGgzr@=!R5j@O%4vy z=zK5rr?;m~gi2J&)0OJH67}{1?1Xs=upMpCLX;Q(#oBASYKZ^t@==02Ur*D>#1{hC*A{YW)5$p zzPUZEnYFa`DEQb3^Vb4QKob>gHj~(9=S_cVa#Fl5wh0lVtC*L{1T-(Hn2E%U+^NR> zF9Bqe^g*hp)KAD93je4nTA5&$mvJ(k$XWI+0OLXuX0V=gcHiCG%}4U(CgLu|q@h^tn2Lyur~xy!9iJuv%~19Ne79wk9@#cYD*-+lKh>4JYb zl8&2Cy^*~*Vg6cR%?>x)Ehsw%#op+S$V?&yd`22w?3)+DA$BNKYNuGACcCe3CRt^M zp_WVoY*?$PP^BYrK~z&#B&?Y)E7~h3d;sOMqQn_Vz*GjL=^565fTlna%KM;Vb!t_$ zZFd6Tj;Y;3E@V>sun@WnD^-S1^`3u)`U=LgvNBiSdtkYqHxGsO;ip3z+-v#5ay*R{ zgeuN{93mTAeiXn|(rCb#kPU$OR*sno(26_gFwM5y3+oHB$qCQc=X7IM@}$!^hT8>$m%XIGmvie z7b!P8FvePUPE(2^tIt_rQ0>XhW7_qg8rEbXM=ncZM@UvK(xo{q$vorpZg>L)on&#> z(@rGJiQEVv;SQy$l{2&%iDZ8bA-;_7e4RZ(^+qGQ`@RGe4JL7uka8Yen z*J3u91at6UxMVKH)vK%Vf*SX@y83{ILyA)RQKjmy1WjoIOBhhGGS<5)*w6z~2_z1~ zZcLb_0+52CF(J5t#DcB?2&_wDL4IGvtVZ|}sETkMwEJL*lfhs8Xt0#;?3FvSk$faHf ziytWAs#<&Vc$--QO#x-N2+C0JPx}sEioLI{ zrDG_5prDJ_4`x~aHl*sWIymQ=R*N8z8ZiHkyp@C_8L0yNaX6OR*t-af=4IyPc56{$JGm3saT)*I|XYOG_hq_Ee;i?gOkN2k(zBo!>I&wx`itFU@_um2~XL2@R%?oAhRoZnVYo zZ}V_8>_&{fm)$gy!^*rCBuEo*9iYMBYf++NXajo%DU`6NfYm8U8-}oXFzk?-I7E2k zTkQbx#JLkj+|dKW9@`bfY#tP@i}1#U?aG6;tOPTG-RysD)epe1Z^N*+4?iSC>m1--p^C0POGcuzz^%vp?e{vk}Cu@gh~#ON|f$mTSJtrxv7Yv}?1naxV6VRV~OgRu}%q+cPAEhuItwu+QN!kaw^me-9rxNC*iVc&=(qRXi}-W$!~enK7#VBHO%keWGuEXm%( zRU=@LCY-bHwo9vNY)o z0aSk*QrCwHb|k~V;wwq$O1+%+A!t5}{#VRuT$ecTcmhnj>L=wN{k4jDI~*C}0a$oO z}KgOoHP z&4UN`LZw?tRq$?rvku8Rf)Gc`{s8O;)bD@o0vHd}Y~9%UY?4D~%rMj)h}4vx5aoJ? zJnfR~9ZVmdqSvZxkIVwS;0{WmTUV#Gd;Mx)zTF~dUZ1=gz6&q->dtC7Y}(-}jQa(% z9FLh}arMzvsN(T>!k&=-r{l-fUfs%JKmXcedv9|sH|^H4Cu%bCwgg)k4hJS{FRFhR z>@n94(%WGG*%8LRkml2+_)1#BI|-b}_s2`ymm+`d(d&`5o5-!JKi~l+zAZ>j5rUtSwlng*g_B;{yC4cv_lY0ndMRoK_BY-QIo>(mIo>(mIez5h&jAlfeG{;<#jF4T002ovPDHLk FV1nHEm-7Gs delta 8568 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 Date: Sat, 27 Jul 2024 13:17:12 +0530 Subject: [PATCH 036/123] [mob][photos] Update font size in subscription screen --- .../lib/ui/payment/subscription_plan_widget.dart | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/mobile/lib/ui/payment/subscription_plan_widget.dart b/mobile/lib/ui/payment/subscription_plan_widget.dart index e1221e6b31..e633de0f69 100644 --- a/mobile/lib/ui/payment/subscription_plan_widget.dart +++ b/mobile/lib/ui/payment/subscription_plan_widget.dart @@ -140,15 +140,10 @@ class _Price extends StatelessWidget { ); } if (period == "month") { - return Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Text( - price + ' / ' + 'month', - style: textTheme.largeBold.copyWith(color: textBaseLight), - ).animate().fadeIn(duration: const Duration(milliseconds: 175)), - ], - ); + return Text( + price + ' / ' + 'month', + style: textTheme.largeBold.copyWith(color: textBaseLight), + ).animate().fadeIn(duration: const Duration(milliseconds: 175)); } else if (period == "year") { final currencySymbol = price[0]; final priceWithoutCurrency = price.substring(1); @@ -164,7 +159,7 @@ class _Price extends StatelessWidget { ), Text( price + " / " + "yr", - style: textTheme.body.copyWith(color: textFaintLight), + style: textTheme.small.copyWith(color: textFaintLight), ), ], ).animate().fadeIn(duration: const Duration(milliseconds: 175)); From b5d577f09098cf60ea6240e27420c6ffae9d0735 Mon Sep 17 00:00:00 2001 From: ashilkn Date: Sat, 27 Jul 2024 13:23:11 +0530 Subject: [PATCH 037/123] [mob][photos] Tweak animation --- .../ui/payment/subscription_plan_widget.dart | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/mobile/lib/ui/payment/subscription_plan_widget.dart b/mobile/lib/ui/payment/subscription_plan_widget.dart index e633de0f69..b11e6f05c1 100644 --- a/mobile/lib/ui/payment/subscription_plan_widget.dart +++ b/mobile/lib/ui/payment/subscription_plan_widget.dart @@ -140,10 +140,17 @@ class _Price extends StatelessWidget { ); } if (period == "month") { - return Text( - price + ' / ' + 'month', - style: textTheme.largeBold.copyWith(color: textBaseLight), - ).animate().fadeIn(duration: const Duration(milliseconds: 175)); + return Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + price + ' / ' + 'month', + style: textTheme.largeBold.copyWith(color: textBaseLight), + ) + .animate(delay: const Duration(milliseconds: 100)) + .fadeIn(duration: const Duration(milliseconds: 250)), + ], + ); } else if (period == "year") { final currencySymbol = price[0]; final priceWithoutCurrency = price.substring(1); @@ -162,7 +169,9 @@ class _Price extends StatelessWidget { style: textTheme.small.copyWith(color: textFaintLight), ), ], - ).animate().fadeIn(duration: const Duration(milliseconds: 175)); + ) + .animate(delay: const Duration(milliseconds: 100)) + .fadeIn(duration: const Duration(milliseconds: 250)); } else { assert(false, "Invalid period: $period"); return const Text(""); From efab8918f23673ee2375431e6340b1dfb9ca6e24 Mon Sep 17 00:00:00 2001 From: ashilkn Date: Sat, 27 Jul 2024 17:47:13 +0530 Subject: [PATCH 038/123] [mob][photos] Many changes to subscription page --- mobile/lib/models/subscription.dart | 4 + .../ui/payment/stripe_subscription_page.dart | 164 +++++++++++------- .../payment/subscription_common_widgets.dart | 17 +- .../ui/payment/subscription_plan_widget.dart | 1 - 4 files changed, 116 insertions(+), 70 deletions(-) diff --git a/mobile/lib/models/subscription.dart b/mobile/lib/models/subscription.dart index 272e46cf32..50735a7c48 100644 --- a/mobile/lib/models/subscription.dart +++ b/mobile/lib/models/subscription.dart @@ -48,6 +48,10 @@ class Subscription { return 'year' == period; } + bool isFreePlan() { + return productID == freeProductID; + } + static fromMap(Map? map) { if (map == null) return null; return Subscription( diff --git a/mobile/lib/ui/payment/stripe_subscription_page.dart b/mobile/lib/ui/payment/stripe_subscription_page.dart index 0bc844acf7..4607abf38b 100644 --- a/mobile/lib/ui/payment/stripe_subscription_page.dart +++ b/mobile/lib/ui/payment/stripe_subscription_page.dart @@ -2,7 +2,9 @@ import 'dart:async'; import 'package:flutter/material.dart'; import "package:logging/logging.dart"; +import "package:photos/core/event_bus.dart"; import 'package:photos/ente_theme_data.dart'; +import "package:photos/events/subscription_purchased_event.dart"; import "package:photos/generated/l10n.dart"; import 'package:photos/models/billing_plan.dart'; import 'package:photos/models/subscription.dart'; @@ -24,6 +26,7 @@ import 'package:photos/ui/payment/skip_subscription_widget.dart'; import 'package:photos/ui/payment/subscription_common_widgets.dart'; import 'package:photos/ui/payment/subscription_plan_widget.dart'; import "package:photos/ui/payment/view_add_on_widget.dart"; +import "package:photos/ui/tabs/home_widget.dart"; import "package:photos/utils/data_util.dart"; import 'package:photos/utils/dialog_util.dart'; import 'package:photos/utils/toast_util.dart'; @@ -244,38 +247,10 @@ class _StripeSubscriptionPageState extends State { ); } - // only active subscription can be renewed/canceled - if (_hasActiveSubscription && _isStripeSubscriber) { - widgets.add(_stripeRenewOrCancelButton()); - } - - if (_currentSubscription!.productID != freeProductID) { - widgets.add( - Padding( - padding: const EdgeInsets.fromLTRB(16, 40, 16, 4), - child: MenuItemWidget( - captionedTextWidget: CaptionedTextWidget( - title: S.of(context).paymentDetails, - ), - menuItemColor: colorScheme.fillFaint, - trailingWidget: Icon( - Icons.chevron_right_outlined, - color: colorScheme.strokeBase, - ), - singleBorderRadius: 4, - alignCaptionedTextToLeft: true, - onTap: () async { - _redirectToPaymentPortal(); - }, - ), - ), - ); - } - if (!widget.isOnboarding) { widgets.add( Padding( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 0), + padding: const EdgeInsets.fromLTRB(16, 2, 16, 2), child: MenuItemWidget( captionedTextWidget: CaptionedTextWidget( title: S.of(context).manageFamily, @@ -295,6 +270,40 @@ class _StripeSubscriptionPageState extends State { ), ); widgets.add(ViewAddOnButton(_userDetails.bonusData)); + } + + if (_currentSubscription!.productID != freeProductID) { + widgets.add( + Padding( + padding: const EdgeInsets.fromLTRB(16, 2, 16, 2), + child: MenuItemWidget( + captionedTextWidget: CaptionedTextWidget( + title: S.of(context).paymentDetails, + ), + menuItemColor: colorScheme.fillFaint, + trailingWidget: Icon( + Icons.chevron_right_outlined, + color: colorScheme.strokeBase, + ), + singleBorderRadius: 4, + alignCaptionedTextToLeft: true, + onTap: () async { + _redirectToPaymentPortal(); + }, + ), + ), + ); + } + + // only active subscription can be renewed/canceled + if (_hasActiveSubscription && _isStripeSubscriber) { + widgets.add( + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 2), + child: _stripeRenewOrCancelButton(), + ), + ); + widgets.add(const SizedBox(height: 80)); } @@ -365,16 +374,18 @@ class _StripeSubscriptionPageState extends State { final String title = isRenewCancelled ? S.of(context).renewSubscription : S.of(context).cancelSubscription; - return TextButton( - child: Text( - title, - style: TextStyle( - color: (isRenewCancelled - ? colorScheme.primary700 - : colorScheme.textMuted), - ), + return MenuItemWidget( + captionedTextWidget: CaptionedTextWidget( + title: title, ), - onPressed: () async { + menuItemColor: colorScheme.fillFaint, + trailingWidget: Icon( + Icons.chevron_right_outlined, + color: colorScheme.strokeBase, + ), + singleBorderRadius: 4, + alignCaptionedTextToLeft: true, + onTap: () async { bool confirmAction = false; if (isRenewCancelled) { final choice = await showChoiceDialog( @@ -457,9 +468,27 @@ class _StripeSubscriptionPageState extends State { foundActivePlan = true; } planWidgets.add( - Material( - child: InkWell( - onTap: () async { + GestureDetector( + onTap: () async { + if (widget.isOnboarding && plan.id == freeProductID) { + Bus.instance.fire(SubscriptionPurchasedEvent()); + // ignore: unawaited_futures + Navigator.of(context).pushAndRemoveUntil( + MaterialPageRoute( + builder: (BuildContext context) { + return const HomeWidget(); + }, + ), + (route) => false, + ); + unawaited( + BillingService.instance.verifySubscription( + freeProductID, + "", + paymentProvider: "ente", + ), + ); + } else { if (isActive) { return; } @@ -520,14 +549,14 @@ class _StripeSubscriptionPageState extends State { }, ), ).then((value) => onWebPaymentGoBack(value)); - }, - child: SubscriptionPlanWidget( - storage: plan.storage, - price: plan.price, - period: plan.period, - isActive: isActive && !_hideCurrentPlanSelection, - isPopular: _isPopularPlan(plan), - ), + } + }, + child: SubscriptionPlanWidget( + storage: plan.storage, + price: plan.price, + period: plan.period, + isActive: isActive && !_hideCurrentPlanSelection, + isPopular: _isPopularPlan(plan), ), ), ); @@ -550,6 +579,7 @@ class _StripeSubscriptionPageState extends State { void _addCurrentPlanWidget(List planWidgets) { // don't add current plan if it's monthly plan but UI is showing yearly plans // and vice versa. + if (_showYearlyPlan != _currentSubscription!.isYearlyPlan() && _currentSubscription!.productID != freeProductID) { return; @@ -562,15 +592,33 @@ class _StripeSubscriptionPageState extends State { } planWidgets.insert( activePlanIndex, - Material( - child: InkWell( - onTap: () {}, - child: SubscriptionPlanWidget( - storage: _currentSubscription!.storage, - price: _currentSubscription!.price, - period: _currentSubscription!.period, - isActive: _currentSubscription!.isValid(), - ), + GestureDetector( + onTap: () { + if (_currentSubscription!.isFreePlan()) { + Bus.instance.fire(SubscriptionPurchasedEvent()); + // ignore: unawaited_futures + Navigator.of(context).pushAndRemoveUntil( + MaterialPageRoute( + builder: (BuildContext context) { + return const HomeWidget(); + }, + ), + (route) => false, + ); + unawaited( + BillingService.instance.verifySubscription( + freeProductID, + "", + paymentProvider: "ente", + ), + ); + } + }, + child: SubscriptionPlanWidget( + storage: _currentSubscription!.storage, + price: _currentSubscription!.price, + period: _currentSubscription!.period, + isActive: _currentSubscription!.isValid(), ), ), ); diff --git a/mobile/lib/ui/payment/subscription_common_widgets.dart b/mobile/lib/ui/payment/subscription_common_widgets.dart index 90f2864d56..ce6a8a9ae6 100644 --- a/mobile/lib/ui/payment/subscription_common_widgets.dart +++ b/mobile/lib/ui/payment/subscription_common_widgets.dart @@ -4,7 +4,6 @@ import 'package:photos/ente_theme_data.dart'; import "package:photos/generated/l10n.dart"; import "package:photos/models/api/storage_bonus/bonus.dart"; import 'package:photos/models/subscription.dart'; -import "package:photos/services/update_service.dart"; import "package:photos/theme/ente_theme.dart"; import "package:photos/ui/components/captioned_text_widget.dart"; import "package:photos/ui/components/menu_item_widget/menu_item_widget.dart"; @@ -69,15 +68,15 @@ class ValidityWidget extends StatelessWidget { final Subscription? currentSubscription; final BonusData? bonusData; - const ValidityWidget({Key? key, this.currentSubscription, this.bonusData}) - : super(key: key); + const ValidityWidget({super.key, this.currentSubscription, this.bonusData}); @override Widget build(BuildContext context) { - if (currentSubscription == null) { + final List addOnBonus = bonusData?.getAddOnBonuses() ?? []; + if (currentSubscription == null || + (currentSubscription!.isFreePlan() && addOnBonus.isEmpty)) { return const SizedBox.shrink(); } - final List addOnBonus = bonusData?.getAddOnBonuses() ?? []; final bool isFreeTrialSub = currentSubscription!.productID == freeProductID; bool hideSubValidityView = false; if (isFreeTrialSub && addOnBonus.isNotEmpty) { @@ -92,11 +91,7 @@ class ValidityWidget extends StatelessWidget { ); var message = S.of(context).renewsOn(endDate); - if (isFreeTrialSub) { - message = UpdateService.instance.isPlayStoreFlavor() - ? S.of(context).playStoreFreeTrialValidTill(endDate) - : S.of(context).freeTrialValidTill(endDate); - } else if (currentSubscription!.attributes?.isCancelled ?? false) { + if (currentSubscription!.attributes?.isCancelled ?? false) { message = S.of(context).subWillBeCancelledOn(endDate); if (addOnBonus.isNotEmpty) { hideSubValidityView = true; @@ -153,7 +148,7 @@ class SubFaqWidget extends StatelessWidget { Widget build(BuildContext context) { final colorScheme = getEnteColorScheme(context); return Padding( - padding: EdgeInsets.fromLTRB(16, 40, 16, isOnboarding ? 40 : 4), + padding: const EdgeInsets.fromLTRB(16, 2, 16, 2), child: MenuItemWidget( captionedTextWidget: CaptionedTextWidget( title: S.of(context).faqs, diff --git a/mobile/lib/ui/payment/subscription_plan_widget.dart b/mobile/lib/ui/payment/subscription_plan_widget.dart index b11e6f05c1..6f5f828e71 100644 --- a/mobile/lib/ui/payment/subscription_plan_widget.dart +++ b/mobile/lib/ui/payment/subscription_plan_widget.dart @@ -41,7 +41,6 @@ class _SubscriptionPlanWidgetState extends State { final numAndUnit = convertBytesToNumberAndUnit(widget.storage); final String storageValue = numAndUnit.$1.toString(); final String storageUnit = numAndUnit.$2; - final colorScheme = getEnteColorScheme(context); return Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), child: Container( From 4e589840ff9b76df83143772bcbaa2dce0c00608 Mon Sep 17 00:00:00 2001 From: ashilkn Date: Mon, 29 Jul 2024 17:15:03 +0530 Subject: [PATCH 039/123] [mob][photos] Subscription page UI improvements --- .../ui/payment/stripe_subscription_page.dart | 12 ++++------ .../payment/subscription_common_widgets.dart | 24 ++++++++++++------- mobile/lib/ui/payment/view_add_on_widget.dart | 2 +- 3 files changed, 22 insertions(+), 16 deletions(-) diff --git a/mobile/lib/ui/payment/stripe_subscription_page.dart b/mobile/lib/ui/payment/stripe_subscription_page.dart index 4607abf38b..80087096ad 100644 --- a/mobile/lib/ui/payment/stripe_subscription_page.dart +++ b/mobile/lib/ui/payment/stripe_subscription_page.dart @@ -22,7 +22,6 @@ import "package:photos/ui/components/menu_item_widget/menu_item_widget.dart"; import "package:photos/ui/components/title_bar_title_widget.dart"; import 'package:photos/ui/payment/child_subscription_widget.dart'; import 'package:photos/ui/payment/payment_web_page.dart'; -import 'package:photos/ui/payment/skip_subscription_widget.dart'; import 'package:photos/ui/payment/subscription_common_widgets.dart'; import 'package:photos/ui/payment/subscription_plan_widget.dart'; import "package:photos/ui/payment/view_add_on_widget.dart"; @@ -236,12 +235,11 @@ class _StripeSubscriptionPageState extends State { bonusData: _userDetails.bonusData, ), ); + } else { + const SizedBox(height: 56); } if (_currentSubscription!.productID == freeProductID) { - if (widget.isOnboarding) { - widgets.add(SkipSubscriptionWidget(freePlan: _freePlan)); - } widgets.add( SubFaqWidget(isOnboarding: widget.isOnboarding), ); @@ -278,7 +276,7 @@ class _StripeSubscriptionPageState extends State { padding: const EdgeInsets.fromLTRB(16, 2, 16, 2), child: MenuItemWidget( captionedTextWidget: CaptionedTextWidget( - title: S.of(context).paymentDetails, + title: "Manage payment method", ), menuItemColor: colorScheme.fillFaint, trailingWidget: Icon( @@ -303,10 +301,10 @@ class _StripeSubscriptionPageState extends State { child: _stripeRenewOrCancelButton(), ), ); - - widgets.add(const SizedBox(height: 80)); } + widgets.add(const SizedBox(height: 80)); + return SingleChildScrollView( child: Column( mainAxisAlignment: MainAxisAlignment.spaceEvenly, diff --git a/mobile/lib/ui/payment/subscription_common_widgets.dart b/mobile/lib/ui/payment/subscription_common_widgets.dart index ce6a8a9ae6..9f4b0d4305 100644 --- a/mobile/lib/ui/payment/subscription_common_widgets.dart +++ b/mobile/lib/ui/payment/subscription_common_widgets.dart @@ -75,7 +75,9 @@ class ValidityWidget extends StatelessWidget { final List addOnBonus = bonusData?.getAddOnBonuses() ?? []; if (currentSubscription == null || (currentSubscription!.isFreePlan() && addOnBonus.isEmpty)) { - return const SizedBox.shrink(); + return const SizedBox( + height: 56, + ); } final bool isFreeTrialSub = currentSubscription!.productID == freeProductID; bool hideSubValidityView = false; @@ -99,15 +101,21 @@ class ValidityWidget extends StatelessWidget { } return Padding( - padding: const EdgeInsets.only(top: 0), + padding: const EdgeInsets.fromLTRB(16, 47, 16, 72), child: Column( children: [ if (!hideSubValidityView) - Text( - message, - style: Theme.of(context).textTheme.bodySmall, - textAlign: TextAlign.center, + Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Text( + message, + style: getEnteTextTheme(context).body.copyWith( + fontWeight: FontWeight.w600, + ), + textAlign: TextAlign.center, + ), ), + const SizedBox(height: 8), if (addOnBonus.isNotEmpty) ...addOnBonus.map((bonus) => AddOnBonusValidity(bonus)).toList(), ], @@ -129,10 +137,10 @@ class AddOnBonusValidity extends StatelessWidget { ); final String storage = convertBytesToReadableFormat(bonus.storage); return Padding( - padding: const EdgeInsets.only(top: 8, bottom: 8), + padding: const EdgeInsets.only(top: 4, bottom: 4), child: Text( S.of(context).addOnValidTill(storage, endDate), - style: Theme.of(context).textTheme.bodySmall, + style: getEnteTextTheme(context).smallFaint, textAlign: TextAlign.center, ), ); diff --git a/mobile/lib/ui/payment/view_add_on_widget.dart b/mobile/lib/ui/payment/view_add_on_widget.dart index 18c5c10d92..5dd392d46e 100644 --- a/mobile/lib/ui/payment/view_add_on_widget.dart +++ b/mobile/lib/ui/payment/view_add_on_widget.dart @@ -20,7 +20,7 @@ class ViewAddOnButton extends StatelessWidget { } final EnteColorScheme colorScheme = getEnteColorScheme(context); return Padding( - padding: const EdgeInsets.fromLTRB(16, 4, 16, 0), + padding: const EdgeInsets.fromLTRB(16, 2, 16, 2), child: MenuItemWidget( captionedTextWidget: CaptionedTextWidget( title: S.of(context).viewAddOnButton, From 7d94ef0bbdaa82fe12d6e9e48004df4638529241 Mon Sep 17 00:00:00 2001 From: ashilkn Date: Mon, 29 Jul 2024 17:56:58 +0530 Subject: [PATCH 040/123] [mob][photos] Subscription page final UI tweaks --- mobile/lib/ui/payment/stripe_subscription_page.dart | 8 ++++++++ mobile/lib/ui/payment/subscription_common_widgets.dart | 4 ++-- mobile/lib/ui/payment/subscription_plan_widget.dart | 4 +++- 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/mobile/lib/ui/payment/stripe_subscription_page.dart b/mobile/lib/ui/payment/stripe_subscription_page.dart index 80087096ad..0f912f58e9 100644 --- a/mobile/lib/ui/payment/stripe_subscription_page.dart +++ b/mobile/lib/ui/payment/stripe_subscription_page.dart @@ -18,6 +18,7 @@ import 'package:photos/ui/common/progress_dialog.dart'; import 'package:photos/ui/common/web_page.dart'; import 'package:photos/ui/components/buttons/button_widget.dart'; import "package:photos/ui/components/captioned_text_widget.dart"; +import "package:photos/ui/components/divider_widget.dart"; import "package:photos/ui/components/menu_item_widget/menu_item_widget.dart"; import "package:photos/ui/components/title_bar_title_widget.dart"; import 'package:photos/ui/payment/child_subscription_widget.dart'; @@ -235,7 +236,10 @@ class _StripeSubscriptionPageState extends State { bonusData: _userDetails.bonusData, ), ); + widgets.add(const DividerWidget(dividerType: DividerType.bottomBar)); + widgets.add(const SizedBox(height: 20)); } else { + widgets.add(const DividerWidget(dividerType: DividerType.bottomBar)); const SizedBox(height: 56); } @@ -376,6 +380,8 @@ class _StripeSubscriptionPageState extends State { captionedTextWidget: CaptionedTextWidget( title: title, ), + alwaysShowSuccessState: false, + surfaceExecutionStates: false, menuItemColor: colorScheme.fillFaint, trailingWidget: Icon( Icons.chevron_right_outlined, @@ -555,6 +561,7 @@ class _StripeSubscriptionPageState extends State { period: plan.period, isActive: isActive && !_hideCurrentPlanSelection, isPopular: _isPopularPlan(plan), + isOnboarding: widget.isOnboarding, ), ), ); @@ -617,6 +624,7 @@ class _StripeSubscriptionPageState extends State { price: _currentSubscription!.price, period: _currentSubscription!.period, isActive: _currentSubscription!.isValid(), + isOnboarding: widget.isOnboarding, ), ), ); diff --git a/mobile/lib/ui/payment/subscription_common_widgets.dart b/mobile/lib/ui/payment/subscription_common_widgets.dart index 9f4b0d4305..5a1c95c563 100644 --- a/mobile/lib/ui/payment/subscription_common_widgets.dart +++ b/mobile/lib/ui/payment/subscription_common_widgets.dart @@ -41,7 +41,7 @@ class _SubscriptionHeaderWidgetState extends State { ); } else { return Padding( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 0), + padding: const EdgeInsets.fromLTRB(16, 32, 16, 0), child: RichText( text: TextSpan( children: [ @@ -101,7 +101,7 @@ class ValidityWidget extends StatelessWidget { } return Padding( - padding: const EdgeInsets.fromLTRB(16, 47, 16, 72), + padding: const EdgeInsets.fromLTRB(16, 16, 16, 16), child: Column( children: [ if (!hideSubValidityView) diff --git a/mobile/lib/ui/payment/subscription_plan_widget.dart b/mobile/lib/ui/payment/subscription_plan_widget.dart index 6f5f828e71..706fa6ae2b 100644 --- a/mobile/lib/ui/payment/subscription_plan_widget.dart +++ b/mobile/lib/ui/payment/subscription_plan_widget.dart @@ -12,6 +12,7 @@ class SubscriptionPlanWidget extends StatefulWidget { required this.storage, required this.price, required this.period, + required this.isOnboarding, this.isActive = false, this.isPopular = false, }); @@ -21,6 +22,7 @@ class SubscriptionPlanWidget extends StatefulWidget { final String period; final bool isActive; final bool isPopular; + final bool isOnboarding; @override State createState() => _SubscriptionPlanWidgetState(); @@ -60,7 +62,7 @@ class _SubscriptionPlanWidgetState extends State { ), child: Stack( children: [ - widget.isActive + widget.isActive && !widget.isOnboarding ? Positioned( top: 0, right: 0, From 2a3fe8c49ff374c2785c5b1549d8dcf36c45515b Mon Sep 17 00:00:00 2001 From: ashilkn Date: Mon, 29 Jul 2024 19:22:53 +0530 Subject: [PATCH 041/123] [mob][photos] Copy stripe subscription page changes to store subscription page --- .../ui/payment/skip_subscription_widget.dart | 55 --- .../ui/payment/store_subscription_page.dart | 339 +++++++++--------- 2 files changed, 165 insertions(+), 229 deletions(-) delete mode 100644 mobile/lib/ui/payment/skip_subscription_widget.dart diff --git a/mobile/lib/ui/payment/skip_subscription_widget.dart b/mobile/lib/ui/payment/skip_subscription_widget.dart deleted file mode 100644 index c2949a238a..0000000000 --- a/mobile/lib/ui/payment/skip_subscription_widget.dart +++ /dev/null @@ -1,55 +0,0 @@ -import "dart:async"; - -import 'package:flutter/material.dart'; -import 'package:photos/core/event_bus.dart'; -import 'package:photos/events/subscription_purchased_event.dart'; -import "package:photos/generated/l10n.dart"; -import 'package:photos/models/billing_plan.dart'; -import 'package:photos/models/subscription.dart'; -import 'package:photos/services/billing_service.dart'; -import "package:photos/ui/tabs/home_widget.dart"; - -class SkipSubscriptionWidget extends StatelessWidget { - const SkipSubscriptionWidget({ - Key? key, - required this.freePlan, - }) : super(key: key); - - final FreePlan freePlan; - - @override - Widget build(BuildContext context) { - return Container( - width: double.infinity, - height: 64, - margin: const EdgeInsets.fromLTRB(0, 30, 0, 0), - padding: const EdgeInsets.fromLTRB(20, 0, 20, 0), - child: OutlinedButton( - style: Theme.of(context).outlinedButtonTheme.style?.copyWith( - textStyle: MaterialStateProperty.resolveWith( - (Set states) { - return Theme.of(context).textTheme.titleMedium!; - }, - ), - ), - onPressed: () async { - Bus.instance.fire(SubscriptionPurchasedEvent()); - // ignore: unawaited_futures - Navigator.of(context).pushAndRemoveUntil( - MaterialPageRoute( - builder: (BuildContext context) { - return const HomeWidget(); - }, - ), - (route) => false, - ); - unawaited( - BillingService.instance - .verifySubscription(freeProductID, "", paymentProvider: "ente"), - ); - }, - child: Text(S.of(context).continueOnFreeTrial), - ), - ); - } -} diff --git a/mobile/lib/ui/payment/store_subscription_page.dart b/mobile/lib/ui/payment/store_subscription_page.dart index 94b999b850..6dc9d02fc9 100644 --- a/mobile/lib/ui/payment/store_subscription_page.dart +++ b/mobile/lib/ui/payment/store_subscription_page.dart @@ -13,20 +13,20 @@ import 'package:photos/models/billing_plan.dart'; import 'package:photos/models/subscription.dart'; import 'package:photos/models/user_details.dart'; import 'package:photos/services/billing_service.dart'; -import "package:photos/services/update_service.dart"; import 'package:photos/services/user_service.dart'; import "package:photos/theme/colors.dart"; import "package:photos/theme/ente_theme.dart"; import 'package:photos/ui/common/loading_widget.dart'; import 'package:photos/ui/common/progress_dialog.dart'; import "package:photos/ui/components/captioned_text_widget.dart"; +import "package:photos/ui/components/divider_widget.dart"; import "package:photos/ui/components/menu_item_widget/menu_item_widget.dart"; import "package:photos/ui/components/title_bar_title_widget.dart"; import 'package:photos/ui/payment/child_subscription_widget.dart'; -import 'package:photos/ui/payment/skip_subscription_widget.dart'; import 'package:photos/ui/payment/subscription_common_widgets.dart'; import 'package:photos/ui/payment/subscription_plan_widget.dart'; import "package:photos/ui/payment/view_add_on_widget.dart"; +import "package:photos/ui/tabs/home_widget.dart"; import "package:photos/utils/data_util.dart"; import 'package:photos/utils/dialog_util.dart'; import 'package:photos/utils/toast_util.dart'; @@ -69,9 +69,9 @@ class _StoreSubscriptionPageState extends State { @override void initState() { + super.initState(); _billingService.setIsOnSubscriptionPage(true); _setupPurchaseUpdateStreamListener(); - super.initState(); } void _setupPurchaseUpdateStreamListener() { @@ -162,13 +162,8 @@ class _StoreSubscriptionPageState extends State { _fetchSubData(); } _dialog = createProgressDialog(context, S.of(context).pleaseWait); - final appBar = AppBar( - title: widget.isOnboarding - ? null - : Text("${S.of(context).subscription}${kDebugMode ? ' Store' : ''}"), - ); return Scaffold( - appBar: appBar, + appBar: AppBar(), body: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -178,8 +173,9 @@ class _StoreSubscriptionPageState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ TitleBarTitleWidget( - title: - widget.isOnboarding ? "Select your plan" : "Subscription", + title: widget.isOnboarding + ? "Select your plan" + : "${S.of(context).subscription}${kDebugMode ? ' Store' : ''}", ), _isFreePlanUser() || !_hasLoadedData ? const SizedBox.shrink() @@ -192,7 +188,7 @@ class _StoreSubscriptionPageState extends State { ], ), ), - _getBody(), + Expanded(child: _getBody()), ], ), ); @@ -259,6 +255,17 @@ class _StoreSubscriptionPageState extends State { ), ); + if (hasYearlyPlans) { + widgets.add( + SubscriptionToggle( + onToggle: (p0) { + showYearlyPlan = p0; + _filterStorePlansForUi(); + }, + ), + ); + } + widgets.addAll([ Column( mainAxisAlignment: MainAxisAlignment.center, @@ -266,13 +273,9 @@ class _StoreSubscriptionPageState extends State { ? _getStripePlanWidgets() : _getMobilePlanWidgets(), ), - const Padding(padding: EdgeInsets.all(8)), + const Padding(padding: EdgeInsets.all(4)), ]); - if (hasYearlyPlans) { - widgets.add(_showSubscriptionToggle()); - } - if (_currentSubscription != null) { widgets.add( ValidityWidget( @@ -280,15 +283,11 @@ class _StoreSubscriptionPageState extends State { bonusData: _userDetails.bonusData, ), ); - } - - if (_currentSubscription!.productID == freeProductID) { - if (widget.isOnboarding) { - widgets.add(SkipSubscriptionWidget(freePlan: _freePlan)); - } - widgets.add( - SubFaqWidget(isOnboarding: widget.isOnboarding), - ); + widgets.add(const DividerWidget(dividerType: DividerType.bottomBar)); + widgets.add(const SizedBox(height: 20)); + } else { + widgets.add(const DividerWidget(dividerType: DividerType.bottomBar)); + const SizedBox(height: 56); } if (_hasActiveSubscription && @@ -311,7 +310,7 @@ class _StoreSubscriptionPageState extends State { padding: const EdgeInsets.fromLTRB(16, 40, 16, 4), child: MenuItemWidget( captionedTextWidget: CaptionedTextWidget( - title: S.of(context).paymentDetails, + title: "Manage payment method", ), menuItemColor: colorScheme.fillFaint, trailingWidget: Icon( @@ -328,10 +327,15 @@ class _StoreSubscriptionPageState extends State { ); } } + + widgets.add( + SubFaqWidget(isOnboarding: widget.isOnboarding), + ); + if (!widget.isOnboarding) { widgets.add( Padding( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 0), + padding: const EdgeInsets.fromLTRB(16, 2, 16, 2), child: MenuItemWidget( captionedTextWidget: CaptionedTextWidget( title: _isFreePlanUser() @@ -354,8 +358,10 @@ class _StoreSubscriptionPageState extends State { ), ); widgets.add(ViewAddOnButton(_userDetails.bonusData)); - widgets.add(const SizedBox(height: 80)); } + + widgets.add(const SizedBox(height: 80)); + return SingleChildScrollView( child: Column( mainAxisAlignment: MainAxisAlignment.spaceEvenly, @@ -411,64 +417,6 @@ class _StoreSubscriptionPageState extends State { setState(() {}); } - Widget _showSubscriptionToggle() { - return Container( - padding: const EdgeInsets.only(left: 8, right: 8, top: 2, bottom: 2), - margin: const EdgeInsets.only(bottom: 6), - child: Column( - children: [ - RepaintBoundary( - child: SizedBox( - width: 250, - child: Row( - mainAxisSize: MainAxisSize.max, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Expanded( - child: SegmentedButton( - style: SegmentedButton.styleFrom( - selectedBackgroundColor: - getEnteColorScheme(context).fillMuted, - selectedForegroundColor: - getEnteColorScheme(context).textBase, - side: BorderSide( - color: getEnteColorScheme(context).strokeMuted, - width: 1, - ), - ), - segments: >[ - ButtonSegment( - label: Text(S.of(context).monthly), - value: false, - ), - ButtonSegment( - label: Text(S.of(context).yearly), - value: true, - ), - ], - selected: {showYearlyPlan}, - onSelectionChanged: (p0) { - showYearlyPlan = p0.first; - _filterStorePlansForUi(); - }, - ), - ), - ], - ), - ), - ), - _isFreePlanUser() && !UpdateService.instance.isPlayStoreFlavor() - ? Text( - S.of(context).twoMonthsFreeOnYearlyPlans, - style: getEnteTextTheme(context).miniMuted, - ) - : const SizedBox.shrink(), - const Padding(padding: EdgeInsets.all(8)), - ], - ), - ); - } - List _getStripePlanWidgets() { final List planWidgets = []; bool foundActivePlan = false; @@ -483,10 +431,27 @@ class _StoreSubscriptionPageState extends State { foundActivePlan = true; } planWidgets.add( - Material( - color: Colors.transparent, - child: InkWell( - onTap: () async { + GestureDetector( + onTap: () async { + if (widget.isOnboarding && plan.id == freeProductID) { + Bus.instance.fire(SubscriptionPurchasedEvent()); + // ignore: unawaited_futures + Navigator.of(context).pushAndRemoveUntil( + MaterialPageRoute( + builder: (BuildContext context) { + return const HomeWidget(); + }, + ), + (route) => false, + ); + unawaited( + BillingService.instance.verifySubscription( + freeProductID, + "", + paymentProvider: "ente", + ), + ); + } else { if (isActive) { return; } @@ -496,13 +461,15 @@ class _StoreSubscriptionPageState extends State { S.of(context).sorry, S.of(context).visitWebToManage, ); - }, - child: SubscriptionPlanWidget( - storage: plan.storage, - price: plan.price, - period: plan.period, - isActive: isActive && !_hideCurrentPlanSelection, - ), + } + }, + child: SubscriptionPlanWidget( + storage: plan.storage, + price: plan.price, + period: plan.period, + isActive: isActive && !_hideCurrentPlanSelection, + isPopular: _isPopularPlan(plan), + isOnboarding: widget.isOnboarding, ), ), ); @@ -522,9 +489,10 @@ class _StoreSubscriptionPageState extends State { planWidgets.add( SubscriptionPlanWidget( storage: _freePlan.storage, - price: S.of(context).freeTrial, - period: "", + price: "", + period: S.of(context).freeTrial, isActive: true, + isOnboarding: widget.isOnboarding, ), ); } @@ -536,71 +504,71 @@ class _StoreSubscriptionPageState extends State { foundActivePlan = true; } planWidgets.add( - Material( - child: InkWell( - onTap: () async { - if (isActive) { - return; - } - final int addOnBonus = - _userDetails.bonusData?.totalAddOnBonus() ?? 0; - if (_userDetails.getFamilyOrPersonalUsage() > - (plan.storage + addOnBonus)) { - _logger.warning( - " familyUsage ${convertBytesToReadableFormat(_userDetails.getFamilyOrPersonalUsage())}" - " plan storage ${convertBytesToReadableFormat(plan.storage)} " - "addOnBonus ${convertBytesToReadableFormat(addOnBonus)}," - "overshooting by ${convertBytesToReadableFormat(_userDetails.getFamilyOrPersonalUsage() - (plan.storage + addOnBonus))}", - ); - // ignore: unawaited_futures - showErrorDialog( - context, - S.of(context).sorry, - S.of(context).youCannotDowngradeToThisPlan, - ); - return; - } - await _dialog.show(); - final ProductDetailsResponse response = - await InAppPurchase.instance.queryProductDetails({productID}); - if (response.notFoundIDs.isNotEmpty) { - final errMsg = "Could not find products: " + - response.notFoundIDs.toString(); - _logger.severe(errMsg); - await _dialog.hide(); - await showGenericErrorDialog( - context: context, - error: Exception(errMsg), - ); - return; - } - final isCrossGradingOnAndroid = Platform.isAndroid && - _hasActiveSubscription && - _currentSubscription!.productID != freeProductID && - _currentSubscription!.productID != plan.androidID; - if (isCrossGradingOnAndroid) { - await _dialog.hide(); - // ignore: unawaited_futures - showErrorDialog( - context, - S.of(context).couldNotUpdateSubscription, - S.of(context).pleaseContactSupportAndWeWillBeHappyToHelp, - ); - return; - } else { - await InAppPurchase.instance.buyNonConsumable( - purchaseParam: PurchaseParam( - productDetails: response.productDetails[0], - ), - ); - } - }, - child: SubscriptionPlanWidget( - storage: plan.storage, - price: plan.price, - period: plan.period, - isActive: isActive, - ), + GestureDetector( + onTap: () async { + if (isActive) { + return; + } + final int addOnBonus = + _userDetails.bonusData?.totalAddOnBonus() ?? 0; + if (_userDetails.getFamilyOrPersonalUsage() > + (plan.storage + addOnBonus)) { + _logger.warning( + " familyUsage ${convertBytesToReadableFormat(_userDetails.getFamilyOrPersonalUsage())}" + " plan storage ${convertBytesToReadableFormat(plan.storage)} " + "addOnBonus ${convertBytesToReadableFormat(addOnBonus)}," + "overshooting by ${convertBytesToReadableFormat(_userDetails.getFamilyOrPersonalUsage() - (plan.storage + addOnBonus))}", + ); + // ignore: unawaited_futures + showErrorDialog( + context, + S.of(context).sorry, + S.of(context).youCannotDowngradeToThisPlan, + ); + return; + } + await _dialog.show(); + final ProductDetailsResponse response = + await InAppPurchase.instance.queryProductDetails({productID}); + if (response.notFoundIDs.isNotEmpty) { + final errMsg = + "Could not find products: " + response.notFoundIDs.toString(); + _logger.severe(errMsg); + await _dialog.hide(); + await showGenericErrorDialog( + context: context, + error: Exception(errMsg), + ); + return; + } + final isCrossGradingOnAndroid = Platform.isAndroid && + _hasActiveSubscription && + _currentSubscription!.productID != freeProductID && + _currentSubscription!.productID != plan.androidID; + if (isCrossGradingOnAndroid) { + await _dialog.hide(); + // ignore: unawaited_futures + showErrorDialog( + context, + S.of(context).couldNotUpdateSubscription, + S.of(context).pleaseContactSupportAndWeWillBeHappyToHelp, + ); + return; + } else { + await InAppPurchase.instance.buyNonConsumable( + purchaseParam: PurchaseParam( + productDetails: response.productDetails[0], + ), + ); + } + }, + child: SubscriptionPlanWidget( + storage: plan.storage, + price: plan.price, + period: plan.period, + isActive: isActive, + isPopular: _isPopularPlan(plan), + isOnboarding: widget.isOnboarding, ), ), ); @@ -620,17 +588,40 @@ class _StoreSubscriptionPageState extends State { } planWidgets.insert( activePlanIndex, - Material( - child: InkWell( - onTap: () {}, - child: SubscriptionPlanWidget( - storage: _currentSubscription!.storage, - price: _currentSubscription!.price, - period: _currentSubscription!.period, - isActive: true, - ), + GestureDetector( + onTap: () { + if (_currentSubscription!.isFreePlan()) { + Bus.instance.fire(SubscriptionPurchasedEvent()); + // ignore: unawaited_futures + Navigator.of(context).pushAndRemoveUntil( + MaterialPageRoute( + builder: (BuildContext context) { + return const HomeWidget(); + }, + ), + (route) => false, + ); + unawaited( + BillingService.instance.verifySubscription( + freeProductID, + "", + paymentProvider: "ente", + ), + ); + } + }, + child: SubscriptionPlanWidget( + storage: _currentSubscription!.storage, + price: _currentSubscription!.price, + period: _currentSubscription!.period, + isActive: true, + isOnboarding: widget.isOnboarding, ), ), ); } + + bool _isPopularPlan(BillingPlan plan) { + return popularProductIDs.contains(plan.id); + } } From d5711095f98a20696e1ade584e76b772d641e60e Mon Sep 17 00:00:00 2001 From: ashilkn Date: Mon, 29 Jul 2024 19:34:47 +0530 Subject: [PATCH 042/123] [mob][photos] Change border of plans in subscription screen --- .../ui/payment/subscription_plan_widget.dart | 24 +++++++++++-------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/mobile/lib/ui/payment/subscription_plan_widget.dart b/mobile/lib/ui/payment/subscription_plan_widget.dart index 706fa6ae2b..185c4e0462 100644 --- a/mobile/lib/ui/payment/subscription_plan_widget.dart +++ b/mobile/lib/ui/payment/subscription_plan_widget.dart @@ -49,16 +49,20 @@ class _SubscriptionPlanWidgetState extends State { decoration: BoxDecoration( color: backgroundElevated2Light, borderRadius: BorderRadius.circular(8), - border: Border.all( - color: brightness == Brightness.dark - ? widget.isActive - ? const Color.fromRGBO(191, 191, 191, 1) - : strokeMutedLight - : widget.isActive - ? const Color.fromRGBO(177, 177, 177, 1) - : const Color.fromRGBO(66, 66, 66, 0.4), - width: 1, - ), + border: widget.isActive + ? Border.all( + color: getEnteColorScheme(context).primary700, + width: brightness == Brightness.dark ? 1.5 : 1, + strokeAlign: BorderSide.strokeAlignInside, + ) + : null, + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.08), + offset: const Offset(0, 4), + blurRadius: 4, + ), + ], ), child: Stack( children: [ From 3dbdea472be184ebaaa0ecb294b341f3f905457c Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Mon, 29 Jul 2024 20:36:35 +0530 Subject: [PATCH 043/123] Add a top level catch handler instead of silent swallows --- web/packages/new/photos/services/ml/worker.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/web/packages/new/photos/services/ml/worker.ts b/web/packages/new/photos/services/ml/worker.ts index ca9e4bd054..3baf512a8c 100644 --- a/web/packages/new/photos/services/ml/worker.ts +++ b/web/packages/new/photos/services/ml/worker.ts @@ -278,7 +278,8 @@ const indexNextBatch = async ( delegate?.workerDidProcessFile(); // Possibly unnecessary, but let us drain the microtask queue. await wait(0); - } catch { + } catch (e) { + log.warn(`Skipping unindexable file ${item.enteFile.id}`, e); allSuccess = false; } } @@ -474,7 +475,7 @@ const index = async ( throw e; } - if (originalImageBlob) + if (originalImageBlob && exif) await cmpNewLib2(enteFile, originalImageBlob, exif); log.debug(() => { From 088cec27169836a537bee227ac94b2a08dfdb92a Mon Sep 17 00:00:00 2001 From: ashilkn Date: Mon, 29 Jul 2024 21:38:45 +0530 Subject: [PATCH 044/123] [mob][photos] Fix on tap not working on free plan when onboarding --- .../ui/payment/store_subscription_page.dart | 37 +++++++++++++++---- .../ui/payment/stripe_subscription_page.dart | 2 +- 2 files changed, 31 insertions(+), 8 deletions(-) diff --git a/mobile/lib/ui/payment/store_subscription_page.dart b/mobile/lib/ui/payment/store_subscription_page.dart index 6dc9d02fc9..fc7fa5adeb 100644 --- a/mobile/lib/ui/payment/store_subscription_page.dart +++ b/mobile/lib/ui/payment/store_subscription_page.dart @@ -487,12 +487,35 @@ class _StoreSubscriptionPageState extends State { _currentSubscription!.productID == freeProductID) { foundActivePlan = true; planWidgets.add( - SubscriptionPlanWidget( - storage: _freePlan.storage, - price: "", - period: S.of(context).freeTrial, - isActive: true, - isOnboarding: widget.isOnboarding, + GestureDetector( + onTap: () { + if (_currentSubscription!.isFreePlan() && widget.isOnboarding) { + Bus.instance.fire(SubscriptionPurchasedEvent()); + // ignore: unawaited_futures + Navigator.of(context).pushAndRemoveUntil( + MaterialPageRoute( + builder: (BuildContext context) { + return const HomeWidget(); + }, + ), + (route) => false, + ); + unawaited( + BillingService.instance.verifySubscription( + freeProductID, + "", + paymentProvider: "ente", + ), + ); + } + }, + child: SubscriptionPlanWidget( + storage: _freePlan.storage, + price: "", + period: S.of(context).freeTrial, + isActive: true, + isOnboarding: widget.isOnboarding, + ), ), ); } @@ -590,7 +613,7 @@ class _StoreSubscriptionPageState extends State { activePlanIndex, GestureDetector( onTap: () { - if (_currentSubscription!.isFreePlan()) { + if (_currentSubscription!.isFreePlan() & widget.isOnboarding) { Bus.instance.fire(SubscriptionPurchasedEvent()); // ignore: unawaited_futures Navigator.of(context).pushAndRemoveUntil( diff --git a/mobile/lib/ui/payment/stripe_subscription_page.dart b/mobile/lib/ui/payment/stripe_subscription_page.dart index 0f912f58e9..7ab1a0f9cc 100644 --- a/mobile/lib/ui/payment/stripe_subscription_page.dart +++ b/mobile/lib/ui/payment/stripe_subscription_page.dart @@ -599,7 +599,7 @@ class _StripeSubscriptionPageState extends State { activePlanIndex, GestureDetector( onTap: () { - if (_currentSubscription!.isFreePlan()) { + if (_currentSubscription!.isFreePlan() && widget.isOnboarding) { Bus.instance.fire(SubscriptionPurchasedEvent()); // ignore: unawaited_futures Navigator.of(context).pushAndRemoveUntil( From 142a4ddbc454fb0495451d5eaa5a535d7600ba2d Mon Sep 17 00:00:00 2001 From: ashilkn Date: Mon, 29 Jul 2024 21:44:18 +0530 Subject: [PATCH 045/123] [mob][photo] Bump up to v0.9.16 --- mobile/pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 91480248e2..a03ba4143a 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -12,7 +12,7 @@ description: ente photos application # Read more about iOS versioning at # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html -version: 0.9.15+915 +version: 0.9.16+916 publish_to: none environment: From 82f808e5334ca2cd2995ae0ba28c1bc4a2d1aba5 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Tue, 30 Jul 2024 09:59:33 +0530 Subject: [PATCH 046/123] Outline --- desktop/src/main/services/ml.ts | 60 ++++++++++++++++++++++++++++++--- 1 file changed, 56 insertions(+), 4 deletions(-) diff --git a/desktop/src/main/services/ml.ts b/desktop/src/main/services/ml.ts index 55bb8d79c2..dcda511df6 100644 --- a/desktop/src/main/services/ml.ts +++ b/desktop/src/main/services/ml.ts @@ -5,10 +5,6 @@ * * The ML runtime we use for inference is [ONNX](https://onnxruntime.ai). Models * for various tasks are not shipped with the app but are downloaded on demand. - * - * The primary reason for doing these tasks in the Node.js layer is so that we - * can use the binary ONNX runtime which is 10-20x faster than the WASM based - * web one. */ import { app, net } from "electron/main"; @@ -19,6 +15,62 @@ import * as ort from "onnxruntime-node"; import log from "../log"; import { writeStream } from "../stream"; +/** + * Create a new ML session. + * + * [Note: ML IPC] + * + * The primary reason for doing ML tasks in the Node.js layer is so that we can + * use the binary ONNX runtime, which is 10-20x faster than the WASM one that + * can be used directly on the web layer. + * + * For this to work, the main and renderer process need to communicate with each + * other. Further, in the web layer the ML indexing runs in a web worker (so as + * to not get in the way of the main thread). So the communication has 2 hops: + * + * Node.js main <-> Renderer main <-> Renderer web worker + * + * This naive way works, but has a problem. The Node.js main process is in the + * code path for delivering user events to the renderer process. The ML tasks we + * do take in the order of 100-300 ms (possibly more) for each individual + * inference. Thus, the Node.js main process is busy for those 100-300 ms, and + * does not forward events to the renderer, causing the UI to jitter. + * + * The solution for this is to spawn an Electron UtilityProcess, which we can + * think of a regular Node.js child process. This frees up the Node.js main + * process, and would remove the jitter. + * https://www.electronjs.org/docs/latest/tutorial/process-model + * + * It would seem that this introduces another hop in our IPC + * + * Node.js utility process <-> Node.js main <-> ... + * + * but here we can use the special bit about Electron utility processes that + * separates them from regular Node.js child processes: their support for + * message ports. https://www.electronjs.org/docs/latest/tutorial/message-ports + * + * As a brief summary, a MessagePort is a web feature that allows two contexts + * to communicate. A pair of message ports is called a message channel. The cool + * thing about these is that we can pass these ports themselves over IPC. + * + * So we + * + * 1. Spawn a utility process. + * 2. In the utility process create a message channel. + * 3. Keep one port of the pair with us, and send the other over IPC to the + * _web worker_ that is coordinating the ML indexing on the web layer. + * + * Thereafter, the utility process and web worker can directly talk to each + * other! + * + * Node.js utility process <-> Renderer web worker + * + */ +export const createMLSession = () => { + // }: Promise => { + throw new Error("Not implemented"); +}; + /** * Return a function that can be used to trigger a download of the specified * model, and the creating of an ONNX inference session initialized using it. From 65d2bfe1c1f79c6971c53d8a8a458db84feec4a9 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Tue, 30 Jul 2024 10:19:03 +0530 Subject: [PATCH 047/123] Split on the main/utility axis --- desktop/src/main/ipc.ts | 5 +- desktop/src/main/services/ml-clip.ts | 68 ------- desktop/src/main/services/ml-face.ts | 53 ------ desktop/src/main/services/ml-utility.ts | 235 ++++++++++++++++++++++++ desktop/src/main/services/ml.ts | 136 +------------- 5 files changed, 248 insertions(+), 249 deletions(-) delete mode 100644 desktop/src/main/services/ml-clip.ts delete mode 100644 desktop/src/main/services/ml-face.ts create mode 100644 desktop/src/main/services/ml-utility.ts diff --git a/desktop/src/main/ipc.ts b/desktop/src/main/ipc.ts index 641ce9963d..37ee0478e5 100644 --- a/desktop/src/main/ipc.ts +++ b/desktop/src/main/ipc.ts @@ -45,8 +45,9 @@ import { logout } from "./services/logout"; import { computeCLIPImageEmbedding, computeCLIPTextEmbeddingIfAvailable, -} from "./services/ml-clip"; -import { computeFaceEmbeddings, detectFaces } from "./services/ml-face"; + computeFaceEmbeddings, + detectFaces, +} from "./services/ml-utility"; import { encryptionKey, lastShownChangelogVersion, diff --git a/desktop/src/main/services/ml-clip.ts b/desktop/src/main/services/ml-clip.ts deleted file mode 100644 index cea1d667b5..0000000000 --- a/desktop/src/main/services/ml-clip.ts +++ /dev/null @@ -1,68 +0,0 @@ -/** - * @file Compute CLIP embeddings for images and text. - * - * The embeddings are computed using ONNX runtime, with CLIP as the model. - */ - -import Tokenizer from "clip-bpe-js"; -import * as ort from "onnxruntime-node"; -import log from "../log"; -import { ensure, wait } from "../utils/common"; -import { makeCachedInferenceSession } from "./ml"; - -const cachedCLIPImageSession = makeCachedInferenceSession( - "clip-image-vit-32-float32.onnx", - 351468764 /* 335.2 MB */, -); - -export const computeCLIPImageEmbedding = async (input: Float32Array) => { - const session = await cachedCLIPImageSession(); - const t = Date.now(); - const feeds = { - input: new ort.Tensor("float32", input, [1, 3, 224, 224]), - }; - const results = await session.run(feeds); - log.debug(() => `ONNX/CLIP image embedding took ${Date.now() - t} ms`); - /* Need these model specific casts to type the result */ - return ensure(results.output).data as Float32Array; -}; - -const cachedCLIPTextSession = makeCachedInferenceSession( - "clip-text-vit-32-uint8.onnx", - 64173509 /* 61.2 MB */, -); - -let _tokenizer: Tokenizer | undefined; -const getTokenizer = () => { - if (!_tokenizer) _tokenizer = new Tokenizer(); - return _tokenizer; -}; - -export const computeCLIPTextEmbeddingIfAvailable = async (text: string) => { - const sessionOrSkip = await Promise.race([ - cachedCLIPTextSession(), - // Wait for a tick to get the session promise to resolved the first time - // this code runs on each app start (and the model has been downloaded). - wait(0).then(() => 1), - ]); - - // Don't wait for the download to complete. - if (typeof sessionOrSkip == "number") { - log.info( - "Ignoring CLIP text embedding request because model download is pending", - ); - return undefined; - } - - const session = sessionOrSkip; - const t = Date.now(); - const tokenizer = getTokenizer(); - const tokenizedText = Int32Array.from(tokenizer.encodeForCLIP(text)); - const feeds = { - input: new ort.Tensor("int32", tokenizedText, [1, 77]), - }; - - const results = await session.run(feeds); - log.debug(() => `ONNX/CLIP text embedding took ${Date.now() - t} ms`); - return ensure(results.output).data as Float32Array; -}; diff --git a/desktop/src/main/services/ml-face.ts b/desktop/src/main/services/ml-face.ts deleted file mode 100644 index 33c09efaa2..0000000000 --- a/desktop/src/main/services/ml-face.ts +++ /dev/null @@ -1,53 +0,0 @@ -/** - * @file Various face recognition related tasks. - * - * - Face detection with the YOLO model. - * - Face embedding with the MobileFaceNet model. - * - * The runtime used is ONNX. - */ - -import * as ort from "onnxruntime-node"; -import log from "../log"; -import { ensure } from "../utils/common"; -import { makeCachedInferenceSession } from "./ml"; - -const cachedFaceDetectionSession = makeCachedInferenceSession( - "yolov5s_face_640_640_dynamic.onnx", - 30762872 /* 29.3 MB */, -); - -export const detectFaces = async (input: Float32Array) => { - const session = await cachedFaceDetectionSession(); - const t = Date.now(); - const feeds = { - input: new ort.Tensor("float32", input, [1, 3, 640, 640]), - }; - const results = await session.run(feeds); - log.debug(() => `ONNX/YOLO face detection took ${Date.now() - t} ms`); - return ensure(results.output).data; -}; - -const cachedFaceEmbeddingSession = makeCachedInferenceSession( - "mobilefacenet_opset15.onnx", - 5286998 /* 5 MB */, -); - -export const computeFaceEmbeddings = async (input: Float32Array) => { - // Dimension of each face (alias) - const mobileFaceNetFaceSize = 112; - // Smaller alias - const z = mobileFaceNetFaceSize; - // Size of each face's data in the batch - const n = Math.round(input.length / (z * z * 3)); - const inputTensor = new ort.Tensor("float32", input, [n, z, z, 3]); - - const session = await cachedFaceEmbeddingSession(); - const t = Date.now(); - const feeds = { img_inputs: inputTensor }; - const results = await session.run(feeds); - log.debug(() => `ONNX/MFNT face embedding took ${Date.now() - t} ms`); - /* Need these model specific casts to extract and type the result */ - return (results.embeddings as unknown as Record) - .cpuData as Float32Array; -}; diff --git a/desktop/src/main/services/ml-utility.ts b/desktop/src/main/services/ml-utility.ts new file mode 100644 index 0000000000..79d39edea4 --- /dev/null +++ b/desktop/src/main/services/ml-utility.ts @@ -0,0 +1,235 @@ +/** + * @file ML related tasks. This code runs in a utility process. + * + * The ML runtime we use for inference is [ONNX](https://onnxruntime.ai). Models + * for various tasks are not shipped with the app but are downloaded on demand. + */ + +import Tokenizer from "clip-bpe-js"; +import { app, net } from "electron/main"; +import { existsSync } from "fs"; +import fs from "node:fs/promises"; +import path from "node:path"; +import * as ort from "onnxruntime-node"; +import log from "../log"; +import { writeStream } from "../stream"; +import { ensure, wait } from "../utils/common"; + +/** + * Return a function that can be used to trigger a download of the specified + * model, and the creating of an ONNX inference session initialized using it. + * + * Multiple parallel calls to the returned function are fine, it ensures that + * the the model will be downloaded and the session created using it only once. + * All pending calls to it meanwhile will just await on the same promise. + * + * And once the promise is resolved, the create ONNX inference session will be + * cached, so subsequent calls to the returned function will just reuse the same + * session. + * + * {@link makeCachedInferenceSession} can itself be called anytime, it doesn't + * actively trigger a download until the returned function is called. + * + * @param modelName The name of the model to download. + * + * @param modelByteSize The size in bytes that we expect the model to have. If + * the size of the downloaded model does not match the expected size, then we + * will redownload it. + * + * @returns a function. calling that function returns a promise to an ONNX + * session. + */ +const makeCachedInferenceSession = ( + modelName: string, + modelByteSize: number, +) => { + let session: Promise | undefined; + + const download = () => + modelPathDownloadingIfNeeded(modelName, modelByteSize); + + const createSession = (modelPath: string) => + createInferenceSession(modelPath); + + const cachedInferenceSession = () => { + if (!session) session = download().then(createSession); + return session; + }; + + return cachedInferenceSession; +}; + +/** + * Download the model named {@link modelName} if we don't already have it. + * + * Also verify that the size of the model we get matches {@expectedByteSize} (if + * not, redownload it). + * + * @returns the path to the model on the local machine. + */ +const modelPathDownloadingIfNeeded = async ( + modelName: string, + expectedByteSize: number, +) => { + const modelPath = modelSavePath(modelName); + + if (!existsSync(modelPath)) { + log.info("CLIP image model not found, downloading"); + await downloadModel(modelPath, modelName); + } else { + const size = (await fs.stat(modelPath)).size; + if (size !== expectedByteSize) { + log.error( + `The size ${size} of model ${modelName} does not match the expected size, downloading again`, + ); + await downloadModel(modelPath, modelName); + } + } + + return modelPath; +}; + +/** Return the path where the given {@link modelName} is meant to be saved */ +const modelSavePath = (modelName: string) => + path.join(app.getPath("userData"), "models", modelName); + +const downloadModel = async (saveLocation: string, name: string) => { + // `mkdir -p` the directory where we want to save the model. + const saveDir = path.dirname(saveLocation); + await fs.mkdir(saveDir, { recursive: true }); + // Download. + log.info(`Downloading ML model from ${name}`); + const url = `https://models.ente.io/${name}`; + const res = await net.fetch(url); + if (!res.ok) throw new Error(`Failed to fetch ${url}: HTTP ${res.status}`); + const body = res.body; + if (!body) throw new Error(`Received an null response for ${url}`); + // Save. + await writeStream(saveLocation, body); + log.info(`Downloaded CLIP model ${name}`); +}; + +/** + * Create an ONNX {@link InferenceSession} with some defaults. + */ +const createInferenceSession = async (modelPath: string) => { + return await ort.InferenceSession.create(modelPath, { + // Restrict the number of threads to 1. + intraOpNumThreads: 1, + // Be more conservative with RAM usage. + enableCpuMemArena: false, + }); +}; + +const cachedCLIPImageSession = makeCachedInferenceSession( + "clip-image-vit-32-float32.onnx", + 351468764 /* 335.2 MB */, +); + +/** + * Compute CLIP embeddings for an image. + * + * The embeddings are computed using ONNX runtime, with CLIP as the model. + */ +export const computeCLIPImageEmbedding = async (input: Float32Array) => { + const session = await cachedCLIPImageSession(); + const t = Date.now(); + const feeds = { + input: new ort.Tensor("float32", input, [1, 3, 224, 224]), + }; + const results = await session.run(feeds); + log.debug(() => `ONNX/CLIP image embedding took ${Date.now() - t} ms`); + /* Need these model specific casts to type the result */ + return ensure(results.output).data as Float32Array; +}; + +const cachedCLIPTextSession = makeCachedInferenceSession( + "clip-text-vit-32-uint8.onnx", + 64173509 /* 61.2 MB */, +); + +let _tokenizer: Tokenizer | undefined; +const getTokenizer = () => { + if (!_tokenizer) _tokenizer = new Tokenizer(); + return _tokenizer; +}; + +/** + * Compute CLIP embeddings for an text snippet. + * + * The embeddings are computed using ONNX runtime, with CLIP as the model. + */ +export const computeCLIPTextEmbeddingIfAvailable = async (text: string) => { + const sessionOrSkip = await Promise.race([ + cachedCLIPTextSession(), + // Wait for a tick to get the session promise to resolved the first time + // this code runs on each app start (and the model has been downloaded). + wait(0).then(() => 1), + ]); + + // Don't wait for the download to complete. + if (typeof sessionOrSkip == "number") { + log.info( + "Ignoring CLIP text embedding request because model download is pending", + ); + return undefined; + } + + const session = sessionOrSkip; + const t = Date.now(); + const tokenizer = getTokenizer(); + const tokenizedText = Int32Array.from(tokenizer.encodeForCLIP(text)); + const feeds = { + input: new ort.Tensor("int32", tokenizedText, [1, 77]), + }; + + const results = await session.run(feeds); + log.debug(() => `ONNX/CLIP text embedding took ${Date.now() - t} ms`); + return ensure(results.output).data as Float32Array; +}; + +const cachedFaceDetectionSession = makeCachedInferenceSession( + "yolov5s_face_640_640_dynamic.onnx", + 30762872 /* 29.3 MB */, +); + +/** + * Face detection with the YOLO model and ONNX runtime. + */ +export const detectFaces = async (input: Float32Array) => { + const session = await cachedFaceDetectionSession(); + const t = Date.now(); + const feeds = { + input: new ort.Tensor("float32", input, [1, 3, 640, 640]), + }; + const results = await session.run(feeds); + log.debug(() => `ONNX/YOLO face detection took ${Date.now() - t} ms`); + return ensure(results.output).data; +}; + +const cachedFaceEmbeddingSession = makeCachedInferenceSession( + "mobilefacenet_opset15.onnx", + 5286998 /* 5 MB */, +); + +/** + * Face embedding with the MobileFaceNet model and ONNX runtime. + */ +export const computeFaceEmbeddings = async (input: Float32Array) => { + // Dimension of each face (alias) + const mobileFaceNetFaceSize = 112; + // Smaller alias + const z = mobileFaceNetFaceSize; + // Size of each face's data in the batch + const n = Math.round(input.length / (z * z * 3)); + const inputTensor = new ort.Tensor("float32", input, [n, z, z, 3]); + + const session = await cachedFaceEmbeddingSession(); + const t = Date.now(); + const feeds = { img_inputs: inputTensor }; + const results = await session.run(feeds); + log.debug(() => `ONNX/MFNT face embedding took ${Date.now() - t} ms`); + /* Need these model specific casts to extract and type the result */ + return (results.embeddings as unknown as Record) + .cpuData as Float32Array; +}; diff --git a/desktop/src/main/services/ml.ts b/desktop/src/main/services/ml.ts index dcda511df6..5d8b0cf1e3 100644 --- a/desktop/src/main/services/ml.ts +++ b/desktop/src/main/services/ml.ts @@ -1,20 +1,7 @@ /** - * @file ML related functionality, generic layer. - * - * @see also `ml-clip.ts`, `ml-face.ts`. - * - * The ML runtime we use for inference is [ONNX](https://onnxruntime.ai). Models - * for various tasks are not shipped with the app but are downloaded on demand. + * @file ML related functionality. This code runs in the main process. */ -import { app, net } from "electron/main"; -import { existsSync } from "fs"; -import fs from "node:fs/promises"; -import path from "node:path"; -import * as ort from "onnxruntime-node"; -import log from "../log"; -import { writeStream } from "../stream"; - /** * Create a new ML session. * @@ -53,15 +40,18 @@ import { writeStream } from "../stream"; * to communicate. A pair of message ports is called a message channel. The cool * thing about these is that we can pass these ports themselves over IPC. * + * > One caveat here is that the message ports can only be passed using the + * > `postMessage` APIs, not the usual send/invoke APIs. + * * So we * - * 1. Spawn a utility process. - * 2. In the utility process create a message channel. - * 3. Keep one port of the pair with us, and send the other over IPC to the - * _web worker_ that is coordinating the ML indexing on the web layer. + * 1. In the utility process create a message channel. + * 2. Spawn a utility process, and send one port of the pair to it. + * 3. Send the other port of the pair to the renderer. * - * Thereafter, the utility process and web worker can directly talk to each - * other! + * The renderer will forward that port to the web worker that is coordinating + * the ML indexing on the web layer. Thereafter, the utility process and web + * worker can directly talk to each other! * * Node.js utility process <-> Renderer web worker * @@ -70,109 +60,3 @@ export const createMLSession = () => { // }: Promise => { throw new Error("Not implemented"); }; - -/** - * Return a function that can be used to trigger a download of the specified - * model, and the creating of an ONNX inference session initialized using it. - * - * Multiple parallel calls to the returned function are fine, it ensures that - * the the model will be downloaded and the session created using it only once. - * All pending calls to it meanwhile will just await on the same promise. - * - * And once the promise is resolved, the create ONNX inference session will be - * cached, so subsequent calls to the returned function will just reuse the same - * session. - * - * {@link makeCachedInferenceSession} can itself be called anytime, it doesn't - * actively trigger a download until the returned function is called. - * - * @param modelName The name of the model to download. - * - * @param modelByteSize The size in bytes that we expect the model to have. If - * the size of the downloaded model does not match the expected size, then we - * will redownload it. - * - * @returns a function. calling that function returns a promise to an ONNX - * session. - */ -export const makeCachedInferenceSession = ( - modelName: string, - modelByteSize: number, -) => { - let session: Promise | undefined; - - const download = () => - modelPathDownloadingIfNeeded(modelName, modelByteSize); - - const createSession = (modelPath: string) => - createInferenceSession(modelPath); - - const cachedInferenceSession = () => { - if (!session) session = download().then(createSession); - return session; - }; - - return cachedInferenceSession; -}; - -/** - * Download the model named {@link modelName} if we don't already have it. - * - * Also verify that the size of the model we get matches {@expectedByteSize} (if - * not, redownload it). - * - * @returns the path to the model on the local machine. - */ -const modelPathDownloadingIfNeeded = async ( - modelName: string, - expectedByteSize: number, -) => { - const modelPath = modelSavePath(modelName); - - if (!existsSync(modelPath)) { - log.info("CLIP image model not found, downloading"); - await downloadModel(modelPath, modelName); - } else { - const size = (await fs.stat(modelPath)).size; - if (size !== expectedByteSize) { - log.error( - `The size ${size} of model ${modelName} does not match the expected size, downloading again`, - ); - await downloadModel(modelPath, modelName); - } - } - - return modelPath; -}; - -/** Return the path where the given {@link modelName} is meant to be saved */ -const modelSavePath = (modelName: string) => - path.join(app.getPath("userData"), "models", modelName); - -const downloadModel = async (saveLocation: string, name: string) => { - // `mkdir -p` the directory where we want to save the model. - const saveDir = path.dirname(saveLocation); - await fs.mkdir(saveDir, { recursive: true }); - // Download. - log.info(`Downloading ML model from ${name}`); - const url = `https://models.ente.io/${name}`; - const res = await net.fetch(url); - if (!res.ok) throw new Error(`Failed to fetch ${url}: HTTP ${res.status}`); - const body = res.body; - if (!body) throw new Error(`Received an null response for ${url}`); - // Save. - await writeStream(saveLocation, body); - log.info(`Downloaded CLIP model ${name}`); -}; - -/** - * Crete an ONNX {@link InferenceSession} with some defaults. - */ -const createInferenceSession = async (modelPath: string) => { - return await ort.InferenceSession.create(modelPath, { - // Restrict the number of threads to 1. - intraOpNumThreads: 1, - // Be more conservative with RAM usage. - enableCpuMemArena: false, - }); -}; From 1a9170632e9f0fb85ac61198aac9bc0636801745 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Tue, 30 Jul 2024 11:16:04 +0530 Subject: [PATCH 048/123] Take 1 --- desktop/src/main/services/ml.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/desktop/src/main/services/ml.ts b/desktop/src/main/services/ml.ts index 5d8b0cf1e3..a6407e55c4 100644 --- a/desktop/src/main/services/ml.ts +++ b/desktop/src/main/services/ml.ts @@ -2,6 +2,9 @@ * @file ML related functionality. This code runs in the main process. */ +import { ipcRenderer, MessageChannelMain } from "electron"; +import { utilityProcess } from "electron/main"; + /** * Create a new ML session. * @@ -57,6 +60,10 @@ * */ export const createMLSession = () => { - // }: Promise => { - throw new Error("Not implemented"); + const { port1, port2 } = new MessageChannelMain(); + + const child = utilityProcess.fork("./ml-utility"); + child.postMessage(/* unused */ "", [port1]); + + ipcRenderer.postMessage("ml-session-port", /* unused */ "", [port2]); }; From 1e720b4b7d7010a35ae248db04714ad55dc8435d Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Tue, 30 Jul 2024 11:23:32 +0530 Subject: [PATCH 049/123] Scaffold --- desktop/src/main/services/ml-util-test.ts | 10 ++++++++++ desktop/src/main/services/ml.ts | 2 +- web/packages/base/types/ipc.ts | 11 +++++++++++ 3 files changed, 22 insertions(+), 1 deletion(-) create mode 100644 desktop/src/main/services/ml-util-test.ts diff --git a/desktop/src/main/services/ml-util-test.ts b/desktop/src/main/services/ml-util-test.ts new file mode 100644 index 0000000000..684b782c4b --- /dev/null +++ b/desktop/src/main/services/ml-util-test.ts @@ -0,0 +1,10 @@ +console.log("in utility process"); + +process.parentPort.once("message", (e) => { + console.log("got message in utility process", e); + const [port] = e.ports; + + port?.on("message", (e2) => { + console.log("got message on port in utility process", e2); + }); +}); diff --git a/desktop/src/main/services/ml.ts b/desktop/src/main/services/ml.ts index a6407e55c4..d7ea06caec 100644 --- a/desktop/src/main/services/ml.ts +++ b/desktop/src/main/services/ml.ts @@ -62,7 +62,7 @@ import { utilityProcess } from "electron/main"; export const createMLSession = () => { const { port1, port2 } = new MessageChannelMain(); - const child = utilityProcess.fork("./ml-utility"); + const child = utilityProcess.fork("./ml-util-test"); child.postMessage(/* unused */ "", [port1]); ipcRenderer.postMessage("ml-session-port", /* unused */ "", [port2]); diff --git a/web/packages/base/types/ipc.ts b/web/packages/base/types/ipc.ts index 7a11553835..ebf2670da2 100644 --- a/web/packages/base/types/ipc.ts +++ b/web/packages/base/types/ipc.ts @@ -334,6 +334,17 @@ export interface Electron { // - ML + /** + * Create a new ML session. + * + * This creates a new Node.js utility process, and sets things up so that we + * can communicate directly with that utility process using a + * {@link MessagePort} we get back. + * + * For more details about the IPC flow, see: [Note: ML IPC]. + */ + createMLSession: () => Promise; + /** * Return a CLIP embedding of the given image. * From 67a9417528354788d6fd23a73285454b16ad813d Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Tue, 30 Jul 2024 11:35:53 +0530 Subject: [PATCH 050/123] Scaffold --- desktop/src/main/ipc.ts | 3 +++ desktop/src/main/services/ml.ts | 2 +- desktop/src/preload.ts | 16 ++++++++++++++++ web/packages/base/types/ipc.ts | 3 +++ web/packages/new/photos/services/ml/index.ts | 5 +++++ 5 files changed, 28 insertions(+), 1 deletion(-) diff --git a/desktop/src/main/ipc.ts b/desktop/src/main/ipc.ts index 37ee0478e5..f891fb390b 100644 --- a/desktop/src/main/ipc.ts +++ b/desktop/src/main/ipc.ts @@ -42,6 +42,7 @@ import { } from "./services/fs"; import { convertToJPEG, generateImageThumbnail } from "./services/image"; import { logout } from "./services/logout"; +import { createMLSession } from "./services/ml"; import { computeCLIPImageEmbedding, computeCLIPTextEmbeddingIfAvailable, @@ -187,6 +188,8 @@ export const attachIPCHandlers = () => { // - ML + ipcMain.on("createMLSession", () => createMLSession()); + ipcMain.handle("computeCLIPImageEmbedding", (_, input: Float32Array) => computeCLIPImageEmbedding(input), ); diff --git a/desktop/src/main/services/ml.ts b/desktop/src/main/services/ml.ts index d7ea06caec..4f69fd90b1 100644 --- a/desktop/src/main/services/ml.ts +++ b/desktop/src/main/services/ml.ts @@ -65,5 +65,5 @@ export const createMLSession = () => { const child = utilityProcess.fork("./ml-util-test"); child.postMessage(/* unused */ "", [port1]); - ipcRenderer.postMessage("ml-session-port", /* unused */ "", [port2]); + ipcRenderer.postMessage("createMLSession/port", /* unused */ "", [port2]); }; diff --git a/desktop/src/preload.ts b/desktop/src/preload.ts index f2366aa63d..16b271ce9f 100644 --- a/desktop/src/preload.ts +++ b/desktop/src/preload.ts @@ -163,6 +163,21 @@ const ffmpegExec = ( // - ML +const createMLSession = () => { + ipcRenderer.send("createMLSession"); + + // The main process will do its thing, and send back the port it created to + // us by sending an message on the "createMLSession/port" channel via the + // postMessage API. This roundabout way is needed because MessagePorts + // cannot be transferred via the usual send/invoke pattern. + + return new Promise((resolve) => { + ipcRenderer.on("createMLSession/port", (event) => { + resolve(event.ports[0]); + }); + }); +}; + const computeCLIPImageEmbedding = (input: Float32Array) => ipcRenderer.invoke("computeCLIPImageEmbedding", input); @@ -339,6 +354,7 @@ contextBridge.exposeInMainWorld("electron", { // - ML + createMLSession, computeCLIPImageEmbedding, computeCLIPTextEmbeddingIfAvailable, detectFaces, diff --git a/web/packages/base/types/ipc.ts b/web/packages/base/types/ipc.ts index ebf2670da2..7188019408 100644 --- a/web/packages/base/types/ipc.ts +++ b/web/packages/base/types/ipc.ts @@ -342,6 +342,9 @@ export interface Electron { * {@link MessagePort} we get back. * * For more details about the IPC flow, see: [Note: ML IPC]. + * + * Note: For simplicity of implementation, we assume that there is at most + * one outstanding call to {@link createMLSession}. */ createMLSession: () => Promise; diff --git a/web/packages/new/photos/services/ml/index.ts b/web/packages/new/photos/services/ml/index.ts index c3432b1023..493ac6612f 100644 --- a/web/packages/new/photos/services/ml/index.ts +++ b/web/packages/new/photos/services/ml/index.ts @@ -114,6 +114,11 @@ export const canEnableML = async () => */ export const initML = () => { _isMLEnabled = isMLEnabledLocal(); + void (async () => { + console.log("yyy", 1); + const port = await ensureElectron().createMLSession(); + console.log("yyy", port); + })(); }; export const logoutML = async () => { From 4087c6ef4e6f24c3d4d3e85ffda477e8cf77d15b Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Tue, 30 Jul 2024 11:39:58 +0530 Subject: [PATCH 051/123] Fix path --- desktop/src/main/services/ml.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/desktop/src/main/services/ml.ts b/desktop/src/main/services/ml.ts index 4f69fd90b1..a8ab08ed16 100644 --- a/desktop/src/main/services/ml.ts +++ b/desktop/src/main/services/ml.ts @@ -4,6 +4,7 @@ import { ipcRenderer, MessageChannelMain } from "electron"; import { utilityProcess } from "electron/main"; +import path from "node:path"; /** * Create a new ML session. @@ -62,7 +63,7 @@ import { utilityProcess } from "electron/main"; export const createMLSession = () => { const { port1, port2 } = new MessageChannelMain(); - const child = utilityProcess.fork("./ml-util-test"); + const child = utilityProcess.fork(path.join(__dirname, "ml-util-test.js")); child.postMessage(/* unused */ "", [port1]); ipcRenderer.postMessage("createMLSession/port", /* unused */ "", [port2]); From 7d42f23abfea677fcd6597c976d8ed94f33b1586 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Tue, 30 Jul 2024 11:48:00 +0530 Subject: [PATCH 052/123] Send to the right person --- desktop/src/main.ts | 2 ++ desktop/src/main/ipc.ts | 13 +++++++++++-- desktop/src/main/services/ml.ts | 8 +++++--- 3 files changed, 18 insertions(+), 5 deletions(-) diff --git a/desktop/src/main.ts b/desktop/src/main.ts index de969e3cf7..4ebe565bca 100644 --- a/desktop/src/main.ts +++ b/desktop/src/main.ts @@ -21,6 +21,7 @@ import { attachFSWatchIPCHandlers, attachIPCHandlers, attachLogoutIPCHandler, + attachMainWindowIPCHandlers, } from "./main/ipc"; import log, { initLogging } from "./main/log"; import { createApplicationMenu, createTrayContextMenu } from "./main/menu"; @@ -121,6 +122,7 @@ const main = () => { // Setup IPC and streams. const watcher = createWatcher(mainWindow); attachIPCHandlers(); + attachMainWindowIPCHandlers(mainWindow); attachFSWatchIPCHandlers(watcher); attachLogoutIPCHandler(watcher); registerStreamProtocol(); diff --git a/desktop/src/main/ipc.ts b/desktop/src/main/ipc.ts index f891fb390b..9bb5d4c001 100644 --- a/desktop/src/main/ipc.ts +++ b/desktop/src/main/ipc.ts @@ -9,6 +9,7 @@ */ import type { FSWatcher } from "chokidar"; +import type { BrowserWindow } from "electron"; import { ipcMain } from "electron/main"; import type { CollectionMapping, @@ -188,8 +189,6 @@ export const attachIPCHandlers = () => { // - ML - ipcMain.on("createMLSession", () => createMLSession()); - ipcMain.handle("computeCLIPImageEmbedding", (_, input: Float32Array) => computeCLIPImageEmbedding(input), ); @@ -235,6 +234,16 @@ export const attachIPCHandlers = () => { ipcMain.handle("clearPendingUploads", () => clearPendingUploads()); }; +/** + * A subset of {@link attachIPCHandlers} for functions that need a reference to + * the main window to do their thing. + */ +export const attachMainWindowIPCHandlers = (mainWindow: BrowserWindow) => { + // - ML + + ipcMain.on("createMLSession", () => createMLSession(mainWindow)); +}; + /** * Sibling of {@link attachIPCHandlers} that attaches handlers specific to the * watch folder functionality. diff --git a/desktop/src/main/services/ml.ts b/desktop/src/main/services/ml.ts index a8ab08ed16..316d70c346 100644 --- a/desktop/src/main/services/ml.ts +++ b/desktop/src/main/services/ml.ts @@ -2,7 +2,7 @@ * @file ML related functionality. This code runs in the main process. */ -import { ipcRenderer, MessageChannelMain } from "electron"; +import { MessageChannelMain, type BrowserWindow } from "electron"; import { utilityProcess } from "electron/main"; import path from "node:path"; @@ -60,11 +60,13 @@ import path from "node:path"; * Node.js utility process <-> Renderer web worker * */ -export const createMLSession = () => { +export const createMLSession = (window: BrowserWindow) => { const { port1, port2 } = new MessageChannelMain(); const child = utilityProcess.fork(path.join(__dirname, "ml-util-test.js")); child.postMessage(/* unused */ "", [port1]); - ipcRenderer.postMessage("createMLSession/port", /* unused */ "", [port2]); + window.webContents.postMessage("createMLSession/port", /* unused */ "", [ + port2, + ]); }; From 180389f3e25b1f3ce8e8f24df5557497a992c142 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Tue, 30 Jul 2024 12:00:44 +0530 Subject: [PATCH 053/123] Can't circumvert that way --- desktop/src/preload.ts | 15 +------------- web/packages/base/types/ipc.ts | 4 ++-- web/packages/new/photos/services/ml/index.ts | 21 +++++++++++++++++++- 3 files changed, 23 insertions(+), 17 deletions(-) diff --git a/desktop/src/preload.ts b/desktop/src/preload.ts index 16b271ce9f..699e1ebc52 100644 --- a/desktop/src/preload.ts +++ b/desktop/src/preload.ts @@ -163,20 +163,7 @@ const ffmpegExec = ( // - ML -const createMLSession = () => { - ipcRenderer.send("createMLSession"); - - // The main process will do its thing, and send back the port it created to - // us by sending an message on the "createMLSession/port" channel via the - // postMessage API. This roundabout way is needed because MessagePorts - // cannot be transferred via the usual send/invoke pattern. - - return new Promise((resolve) => { - ipcRenderer.on("createMLSession/port", (event) => { - resolve(event.ports[0]); - }); - }); -}; +const createMLSession = () => ipcRenderer.send("createMLSession"); const computeCLIPImageEmbedding = (input: Float32Array) => ipcRenderer.invoke("computeCLIPImageEmbedding", input); diff --git a/web/packages/base/types/ipc.ts b/web/packages/base/types/ipc.ts index 7188019408..122735e31a 100644 --- a/web/packages/base/types/ipc.ts +++ b/web/packages/base/types/ipc.ts @@ -339,14 +339,14 @@ export interface Electron { * * This creates a new Node.js utility process, and sets things up so that we * can communicate directly with that utility process using a - * {@link MessagePort} we get back. + * {@link MessagePort} that gets posted using "createMLSession/port". * * For more details about the IPC flow, see: [Note: ML IPC]. * * Note: For simplicity of implementation, we assume that there is at most * one outstanding call to {@link createMLSession}. */ - createMLSession: () => Promise; + createMLSession: () => void; /** * Return a CLIP embedding of the given image. diff --git a/web/packages/new/photos/services/ml/index.ts b/web/packages/new/photos/services/ml/index.ts index 493ac6612f..1c5d5e9437 100644 --- a/web/packages/new/photos/services/ml/index.ts +++ b/web/packages/new/photos/services/ml/index.ts @@ -9,6 +9,7 @@ import log from "@/base/log"; import { ComlinkWorker } from "@/base/worker/comlink-worker"; import { FileType } from "@/media/file-type"; import type { EnteFile } from "@/new/photos/types/file"; +import { ensure } from "@/utils/ensure"; import { throttled } from "@/utils/promise"; import { proxy } from "comlink"; import { isInternalUser } from "../feature-flags"; @@ -116,11 +117,29 @@ export const initML = () => { _isMLEnabled = isMLEnabledLocal(); void (async () => { console.log("yyy", 1); - const port = await ensureElectron().createMLSession(); + const port = await createMLSession(); console.log("yyy", port); })(); }; +const createMLSession = async () => { + ensureElectron().createMLSession(); + + // The main process will do its thing, and send back the port it created to + // us by sending an message on the "createMLSession/port" channel via the + // postMessage API. This roundabout way is needed because MessagePorts + // cannot be transferred via the usual send/invoke pattern. + + return new Promise((resolve) => { + window.onmessage = ({ source, data, ports }: MessageEvent) => { + // The source check verifies that the message is coming from the + // preload script. The data is used as an arbitrary identifying tag. + if (source == window && data == "createMLSession/port") + resolve(ensure(ports[0])); + }; + }); +}; + export const logoutML = async () => { // `terminateMLWorker` is conceptually also part of this sequence, but for // the reasons mentioned in [Note: Caching IDB instances in separate From ea8bb4529f85cef2d09ba70f329562ceebc38233 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Tue, 30 Jul 2024 12:29:11 +0530 Subject: [PATCH 054/123] We need to go via the preload --- desktop/src/preload.ts | 36 +++++++++++++++++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/desktop/src/preload.ts b/desktop/src/preload.ts index 699e1ebc52..79792aa1c0 100644 --- a/desktop/src/preload.ts +++ b/desktop/src/preload.ts @@ -36,6 +36,20 @@ * - [main] desktop/src/main/ipc.ts contains impl */ +// This code runs in the (isolated) web layer. Contrary to the impression given +// by the Electron docs (as of 2024), the window object is actually available to +// the preload script, and it is necessary for legitimate uses too. +// +// > The isolated world is connected to the DOM just the same is the main world, +// > it is just the JS contexts that are separated. +// > +// > https://github.com/electron/electron/issues/27024#issuecomment-745618327 +// +// Adding this reference here tells TypeScript that DOM typings (in particular, +// window) should be introduced in the ambient scope. +// +/// + import { contextBridge, ipcRenderer, webUtils } from "electron/renderer"; // While we can't import other code, we can import types since they're just @@ -48,6 +62,19 @@ import type { ZipItem, } from "./types/ipc"; +// - Infrastructure + +// We need to wait until the renderer is ready before sending ports via +// postMessage, and this promise comes handy in such cases. We create the +// promise at the top level so that it is guaranteed to be registered before the +// load event is fired. +// +// See: https://www.electronjs.org/docs/latest/tutorial/message-ports + +const windowLoaded = new Promise((resolve) => { + window.onload = resolve; +}); + // - General const appVersion = () => ipcRenderer.invoke("appVersion"); @@ -163,7 +190,14 @@ const ffmpegExec = ( // - ML -const createMLSession = () => ipcRenderer.send("createMLSession"); +const createMLSession = () => { + ipcRenderer.send("createMLSession"); + ipcRenderer.on("createMLSession/port", (event) => { + void windowLoaded.then(() => { + window.postMessage("createMLSession/port", "", event.ports); + }); + }); +}; const computeCLIPImageEmbedding = (input: Float32Array) => ipcRenderer.invoke("computeCLIPImageEmbedding", input); From 0195a9b494c99a97f89ad9423715153eb932338a Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Tue, 30 Jul 2024 12:41:01 +0530 Subject: [PATCH 055/123] Add workaround --- desktop/src/main/stream.ts | 8 ++++++-- desktop/src/preload.ts | 8 ++++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/desktop/src/main/stream.ts b/desktop/src/main/stream.ts index 749c94f491..c86232fd64 100644 --- a/desktop/src/main/stream.ts +++ b/desktop/src/main/stream.ts @@ -142,6 +142,7 @@ const handleReadZip = async (zipPath: string, entryName: string) => { // https://github.com/antelle/node-stream-zip/blob/master/node_stream_zip.js const modifiedMs = entry.time; + // @ts-expect-error [Note: Node and web stream type mismatch] return new Response(webReadableStream, { headers: { // We don't know the exact type, but it doesn't really matter, just @@ -168,9 +169,12 @@ const handleWrite = async (path: string, request: Request) => { * * @param readableStream A web * [ReadableStream](https://developer.mozilla.org/en-US/docs/Web/API/ReadableStream). + * */ -export const writeStream = (filePath: string, readableStream: ReadableStream) => - writeNodeStream(filePath, Readable.fromWeb(readableStream)); +export const writeStream = ( + filePath: string, + readableStream: unknown /*ReadableStream*/, // @ts-expect-error [Note: Node and web stream type mismatch] +) => writeNodeStream(filePath, Readable.fromWeb(readableStream)); const writeNodeStream = async (filePath: string, fileStream: Readable) => { const writeable = createWriteStream(filePath); diff --git a/desktop/src/preload.ts b/desktop/src/preload.ts index 79792aa1c0..ad36052dc0 100644 --- a/desktop/src/preload.ts +++ b/desktop/src/preload.ts @@ -48,6 +48,14 @@ // Adding this reference here tells TypeScript that DOM typings (in particular, // window) should be introduced in the ambient scope. // +// [Note: Node and web stream type mismatch] +// +// Unfortunately, adding this reference causes the ReadableStream typings to +// break since lib.dom.d.ts adds its own incompatible definitions of +// ReadableStream to the global scope. +// +// https://github.com/DefinitelyTyped/DefinitelyTyped/discussions/68407 + /// import { contextBridge, ipcRenderer, webUtils } from "electron/renderer"; From e54910f8d09ad0e04025355e6868e456c43dcaad Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Tue, 30 Jul 2024 12:43:49 +0530 Subject: [PATCH 056/123] Fix origin --- desktop/src/preload.ts | 3 ++- web/packages/new/photos/services/ml/index.ts | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/desktop/src/preload.ts b/desktop/src/preload.ts index ad36052dc0..283a1fca16 100644 --- a/desktop/src/preload.ts +++ b/desktop/src/preload.ts @@ -202,7 +202,8 @@ const createMLSession = () => { ipcRenderer.send("createMLSession"); ipcRenderer.on("createMLSession/port", (event) => { void windowLoaded.then(() => { - window.postMessage("createMLSession/port", "", event.ports); + // "*"" is the origin + window.postMessage("createMLSession/port", "*", event.ports); }); }); }; diff --git a/web/packages/new/photos/services/ml/index.ts b/web/packages/new/photos/services/ml/index.ts index 1c5d5e9437..ec583b75eb 100644 --- a/web/packages/new/photos/services/ml/index.ts +++ b/web/packages/new/photos/services/ml/index.ts @@ -133,7 +133,7 @@ const createMLSession = async () => { return new Promise((resolve) => { window.onmessage = ({ source, data, ports }: MessageEvent) => { // The source check verifies that the message is coming from the - // preload script. The data is used as an arbitrary identifying tag. + // preload script. The data is the message that was posted. if (source == window && data == "createMLSession/port") resolve(ensure(ports[0])); }; From 24bc175f1ce35662fb91407f3b156ebd837b32a6 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Tue, 30 Jul 2024 13:21:31 +0530 Subject: [PATCH 057/123] Forward --- desktop/src/main/ipc.ts | 4 +- desktop/src/main/services/ml.ts | 10 ++-- desktop/src/preload.ts | 10 ++-- web/packages/base/types/ipc.ts | 6 +-- web/packages/base/worker/comlink-worker.ts | 2 +- web/packages/new/photos/services/ml/index.ts | 53 +++++++++++-------- web/packages/new/photos/services/ml/worker.ts | 4 ++ 7 files changed, 49 insertions(+), 40 deletions(-) diff --git a/desktop/src/main/ipc.ts b/desktop/src/main/ipc.ts index 9bb5d4c001..caca5758b5 100644 --- a/desktop/src/main/ipc.ts +++ b/desktop/src/main/ipc.ts @@ -43,7 +43,7 @@ import { } from "./services/fs"; import { convertToJPEG, generateImageThumbnail } from "./services/image"; import { logout } from "./services/logout"; -import { createMLSession } from "./services/ml"; +import { createMLWorker } from "./services/ml"; import { computeCLIPImageEmbedding, computeCLIPTextEmbeddingIfAvailable, @@ -241,7 +241,7 @@ export const attachIPCHandlers = () => { export const attachMainWindowIPCHandlers = (mainWindow: BrowserWindow) => { // - ML - ipcMain.on("createMLSession", () => createMLSession(mainWindow)); + ipcMain.on("createMLWorker", () => createMLWorker(mainWindow)); }; /** diff --git a/desktop/src/main/services/ml.ts b/desktop/src/main/services/ml.ts index 316d70c346..05de34fc49 100644 --- a/desktop/src/main/services/ml.ts +++ b/desktop/src/main/services/ml.ts @@ -7,7 +7,7 @@ import { utilityProcess } from "electron/main"; import path from "node:path"; /** - * Create a new ML session. + * Create a new ML worker process. * * [Note: ML IPC] * @@ -60,13 +60,11 @@ import path from "node:path"; * Node.js utility process <-> Renderer web worker * */ -export const createMLSession = (window: BrowserWindow) => { +export const createMLWorker = (window: BrowserWindow) => { const { port1, port2 } = new MessageChannelMain(); const child = utilityProcess.fork(path.join(__dirname, "ml-util-test.js")); - child.postMessage(/* unused */ "", [port1]); + child.postMessage(undefined, [port1]); - window.webContents.postMessage("createMLSession/port", /* unused */ "", [ - port2, - ]); + window.webContents.postMessage("createMLWorker/port", undefined, [port2]); }; diff --git a/desktop/src/preload.ts b/desktop/src/preload.ts index 283a1fca16..5bd2f28987 100644 --- a/desktop/src/preload.ts +++ b/desktop/src/preload.ts @@ -198,12 +198,12 @@ const ffmpegExec = ( // - ML -const createMLSession = () => { - ipcRenderer.send("createMLSession"); - ipcRenderer.on("createMLSession/port", (event) => { +const createMLWorker = () => { + ipcRenderer.send("createMLWorker"); + ipcRenderer.on("createMLWorker/port", (event) => { void windowLoaded.then(() => { // "*"" is the origin - window.postMessage("createMLSession/port", "*", event.ports); + window.postMessage("createMLWorker/port", "*", event.ports); }); }); }; @@ -384,7 +384,7 @@ contextBridge.exposeInMainWorld("electron", { // - ML - createMLSession, + createMLWorker, computeCLIPImageEmbedding, computeCLIPTextEmbeddingIfAvailable, detectFaces, diff --git a/web/packages/base/types/ipc.ts b/web/packages/base/types/ipc.ts index 122735e31a..ed7877c966 100644 --- a/web/packages/base/types/ipc.ts +++ b/web/packages/base/types/ipc.ts @@ -339,14 +339,14 @@ export interface Electron { * * This creates a new Node.js utility process, and sets things up so that we * can communicate directly with that utility process using a - * {@link MessagePort} that gets posted using "createMLSession/port". + * {@link MessagePort} that gets posted using "createMLWorker/port". * * For more details about the IPC flow, see: [Note: ML IPC]. * * Note: For simplicity of implementation, we assume that there is at most - * one outstanding call to {@link createMLSession}. + * one outstanding call to {@link createMLWorker}. */ - createMLSession: () => void; + createMLWorker: () => void; /** * Return a CLIP embedding of the given image. diff --git a/web/packages/base/worker/comlink-worker.ts b/web/packages/base/worker/comlink-worker.ts index 4562805b3b..330c5637bd 100644 --- a/web/packages/base/worker/comlink-worker.ts +++ b/web/packages/base/worker/comlink-worker.ts @@ -28,7 +28,7 @@ export class ComlinkWorker InstanceType> { /** The class (T) exposed by the web worker */ public remote: Promise>>; /** The web worker */ - private worker: Worker; + public worker: Worker; /** An arbitrary name associated with this ComlinkWorker for debugging. */ private name: string; diff --git a/web/packages/new/photos/services/ml/index.ts b/web/packages/new/photos/services/ml/index.ts index ec583b75eb..697757da59 100644 --- a/web/packages/new/photos/services/ml/index.ts +++ b/web/packages/new/photos/services/ml/index.ts @@ -6,6 +6,7 @@ import { isDesktop } from "@/base/app"; import { blobCache } from "@/base/blob-cache"; import { ensureElectron } from "@/base/electron"; import log from "@/base/log"; +import type { Electron } from "@/base/types/ipc"; import { ComlinkWorker } from "@/base/worker/comlink-worker"; import { FileType } from "@/media/file-type"; import type { EnteFile } from "@/new/photos/types/file"; @@ -67,6 +68,9 @@ const createComlinkWorker = async () => { workerDidProcessFile, }; + // Obtain a message port from the Electron layer. + const messagePort = await createMLWorker(electron); + const cw = new ComlinkWorker( "ML", new Worker(new URL("worker.ts", import.meta.url)), @@ -74,6 +78,10 @@ const createComlinkWorker = async () => { await cw.remote.then((w) => w.init(proxy(mlWorkerElectron), proxy(delegate)), ); + + // Pass the message port to our web worker. + cw.worker.postMessage("createMLWorker/port", [messagePort]); + return cw; }; @@ -93,6 +101,28 @@ export const terminateMLWorker = () => { } }; +/** + * Obtain a port from the Node.js layer that can be used to communicate with the + * ML worker process. + */ +const createMLWorker = async (electron: Electron): Promise => { + electron.createMLWorker(); + + // The main process will do its thing, and send back the port it created to + // us by sending an message on the "createMLWorker/port" channel via the + // postMessage API. This roundabout way is needed because MessagePorts + // cannot be transferred via the usual send/invoke pattern. + + return new Promise((resolve) => { + window.onmessage = ({ source, data, ports }: MessageEvent) => { + // The source check verifies that the message is coming from the + // preload script. The data is the message that was posted. + if (source == window && data == "createMLWorker/port") + resolve(ensure(ports[0])); + }; + }); +}; + /** * Return true if the current client supports ML. * @@ -115,29 +145,6 @@ export const canEnableML = async () => */ export const initML = () => { _isMLEnabled = isMLEnabledLocal(); - void (async () => { - console.log("yyy", 1); - const port = await createMLSession(); - console.log("yyy", port); - })(); -}; - -const createMLSession = async () => { - ensureElectron().createMLSession(); - - // The main process will do its thing, and send back the port it created to - // us by sending an message on the "createMLSession/port" channel via the - // postMessage API. This roundabout way is needed because MessagePorts - // cannot be transferred via the usual send/invoke pattern. - - return new Promise((resolve) => { - window.onmessage = ({ source, data, ports }: MessageEvent) => { - // The source check verifies that the message is coming from the - // preload script. The data is the message that was posted. - if (source == window && data == "createMLSession/port") - resolve(ensure(ports[0])); - }; - }); }; export const logoutML = async () => { diff --git a/web/packages/new/photos/services/ml/worker.ts b/web/packages/new/photos/services/ml/worker.ts index 3baf512a8c..043c339e3d 100644 --- a/web/packages/new/photos/services/ml/worker.ts +++ b/web/packages/new/photos/services/ml/worker.ts @@ -245,6 +245,10 @@ export class MLWorker { expose(MLWorker); +globalThis.onmessage = (event: MessageEvent) => { + console.log("worker onmessage", event); +}; + /** * Find out files which need to be indexed. Then index the next batch of them. * From b28e8c2fb45e46af548ee9472ff56e1691d577ec Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Tue, 30 Jul 2024 13:41:58 +0530 Subject: [PATCH 058/123] IPC --- desktop/src/main/services/ml-util-test.ts | 35 +++++++++++++++++++---- 1 file changed, 29 insertions(+), 6 deletions(-) diff --git a/desktop/src/main/services/ml-util-test.ts b/desktop/src/main/services/ml-util-test.ts index 684b782c4b..2dd68e2ffb 100644 --- a/desktop/src/main/services/ml-util-test.ts +++ b/desktop/src/main/services/ml-util-test.ts @@ -1,10 +1,33 @@ -console.log("in utility process"); +import log from "../log"; +import { ensure, wait } from "../utils/common"; + +log.debug(() => "Started ML worker process"); process.parentPort.once("message", (e) => { - console.log("got message in utility process", e); - const [port] = e.ports; - - port?.on("message", (e2) => { - console.log("got message on port in utility process", e2); + const port = ensure(e.ports[0]); + port.on("message", (event) => { + void handleMessage(event.data).then((response) => { + if (response) port.postMessage(response); + }); }); }); + +/** Our hand-rolled IPC handler and router */ +const handleMessage = async (m: unknown) => { + if (m && typeof m == "object" && "type" in m) { + switch (m.type) { + case "foo": + if ("a" in m && typeof m.a == "string") return await foo(m.a); + break; + } + } + + log.info("Ignoring unexpected message", m); + return undefined; +}; + +const foo = async (a: string) => { + console.log("got message foo with argument", a); + await wait(0); + return a.length; +}; From 3eaa9b449ac17123eeef2f20f76c20eee0bf0308 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Tue, 30 Jul 2024 14:14:45 +0530 Subject: [PATCH 059/123] IPC --- desktop/src/main/services/ml-util-test.ts | 12 +++-- web/packages/new/photos/services/ml/worker.ts | 51 +++++++++++++++++-- 2 files changed, 56 insertions(+), 7 deletions(-) diff --git a/desktop/src/main/services/ml-util-test.ts b/desktop/src/main/services/ml-util-test.ts index 2dd68e2ffb..26c6ad9096 100644 --- a/desktop/src/main/services/ml-util-test.ts +++ b/desktop/src/main/services/ml-util-test.ts @@ -12,12 +12,18 @@ process.parentPort.once("message", (e) => { }); }); -/** Our hand-rolled IPC handler and router */ +/** + * Our hand-rolled IPC handler and router - the Node.js utility process end. + * + * Sibling of the electronMLWorker function (in `ml/worker.ts`) in the web code. + */ const handleMessage = async (m: unknown) => { - if (m && typeof m == "object" && "type" in m) { + if (m && typeof m == "object" && "type" in m && "id" in m) { + const id = m.id; switch (m.type) { case "foo": - if ("a" in m && typeof m.a == "string") return await foo(m.a); + if ("a" in m && typeof m.a == "string") + return { id, data: await foo(m.a) }; break; } } diff --git a/web/packages/new/photos/services/ml/worker.ts b/web/packages/new/photos/services/ml/worker.ts index 043c339e3d..5e00f47e88 100644 --- a/web/packages/new/photos/services/ml/worker.ts +++ b/web/packages/new/photos/services/ml/worker.ts @@ -9,6 +9,7 @@ import { ensure } from "@/utils/ensure"; import { wait } from "@/utils/promise"; import { DOMParser } from "@xmldom/xmldom"; import { expose } from "comlink"; +import { z } from "zod"; import downloadManager from "../download"; import { cmpNewLib2, extractRawExif } from "../exif"; import { getAllLocalFiles, getLocalTrashedFiles } from "../files"; @@ -46,6 +47,46 @@ interface IndexableItem { remoteDerivedData: RemoteDerivedData | undefined; } +/** + * The port used to communicate with the Node.js ML worker process + * + * See: [Note: ML IPC] + * */ +let _port: MessagePort | undefined; + +globalThis.onmessage = (event: MessageEvent) => { + if (event.data == "createMLWorker/port") { + _port = event.ports[0]; + _port?.start(); + } +}; + +const IPCResponse = z.object({ + id: z.number(), + data: z.any(), +}); + +/** + * Our hand-rolled IPC handler and router - the web worker end. + * + * Sibling of the handleMessage function (in `ml-worker.ts`) in the desktop app. + */ +const electronMLWorker = async (type: string, data: string) => { + const port = ensure(_port); + // Generate a unique nonce to identify this RPC interaction. + const id = Math.random(); + return new Promise((resolve) => { + const handleMessage = (event: MessageEvent) => { + const response = IPCResponse.parse(event.data); + if (response.id != id) return; + port.removeEventListener("message", handleMessage); + resolve(response.data); + }; + port.addEventListener("message", handleMessage); + port.postMessage({ type, id, data }); + }); +}; + /** * Run operations related to machine learning (e.g. indexing) in a Web Worker. * @@ -113,6 +154,12 @@ export class MLWorker { // need to monkey patch it (This also ensures that it is not tree // shaken). globalThis.DOMParser = DOMParser; + + void (async () => { + console.log("yyy calling foo with 3"); + const res = await electronMLWorker("foo", "3"); + console.log("yyy calling foo with 3 result", res); + })(); } /** @@ -245,10 +292,6 @@ export class MLWorker { expose(MLWorker); -globalThis.onmessage = (event: MessageEvent) => { - console.log("worker onmessage", event); -}; - /** * Find out files which need to be indexed. Then index the next batch of them. * From d92a31d8d8df09dfaf2c93a8471e662b190ddf2e Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Tue, 30 Jul 2024 14:27:27 +0530 Subject: [PATCH 060/123] Indicate error --- web/packages/new/photos/services/ml/worker.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/web/packages/new/photos/services/ml/worker.ts b/web/packages/new/photos/services/ml/worker.ts index 5e00f47e88..68a1e81c21 100644 --- a/web/packages/new/photos/services/ml/worker.ts +++ b/web/packages/new/photos/services/ml/worker.ts @@ -72,7 +72,13 @@ const IPCResponse = z.object({ * Sibling of the handleMessage function (in `ml-worker.ts`) in the desktop app. */ const electronMLWorker = async (type: string, data: string) => { - const port = ensure(_port); + const port = _port; + if (!port) { + throw new Error( + "No MessagePort to communicate with Electron ML worker", + ); + } + // Generate a unique nonce to identify this RPC interaction. const id = Math.random(); return new Promise((resolve) => { From a14a8b0cfbe828cbc0e4dbf677de7f35d2b77215 Mon Sep 17 00:00:00 2001 From: Aman Raj Singh Mourya Date: Tue, 30 Jul 2024 14:40:32 +0530 Subject: [PATCH 061/123] [mob][auth] Lockscreen fixes --- .../ui/settings/lock_screen/lock_screen_options.dart | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/auth/lib/ui/settings/lock_screen/lock_screen_options.dart b/auth/lib/ui/settings/lock_screen/lock_screen_options.dart index e0e71cc03f..2bbc3750ac 100644 --- a/auth/lib/ui/settings/lock_screen/lock_screen_options.dart +++ b/auth/lib/ui/settings/lock_screen/lock_screen_options.dart @@ -103,8 +103,10 @@ class _LockScreenOptionsState extends State { AppLock.of(context)!.setEnabled(!appLock); await _configuration.setSystemLockScreen(!appLock); await _lockscreenSetting.removePinAndPassword(); - if (appLock == false) { - await _lockscreenSetting.setHideAppContent(true); + if (PlatformUtil.isMobile()) { + if (appLock == false) { + await _lockscreenSetting.setHideAppContent(true); + } } setState(() { _initializeSettings(); @@ -168,6 +170,7 @@ class _LockScreenOptionsState extends State { mainAxisSize: MainAxisSize.min, children: [ Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ MenuItemWidget( captionedTextWidget: CaptionedTextWidget( @@ -308,10 +311,10 @@ class _LockScreenOptionsState extends State { captionedTextWidget: CaptionedTextWidget( title: - context.l10n.deviceLock, + context.l10n.hideContent, ), alignCaptionedTextToLeft: true, - isTopBorderRadiusRemoved: true, + singleBorderRadius: 8, menuItemColor: colorTheme.fillFaint, trailingWidget: From 3d83786f6c917138ea602cd63a4d0d174b4155ee Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Tue, 30 Jul 2024 14:45:34 +0530 Subject: [PATCH 062/123] Workaround --- desktop/src/main/services/ml-util-test.ts | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/desktop/src/main/services/ml-util-test.ts b/desktop/src/main/services/ml-util-test.ts index 26c6ad9096..c7bec54356 100644 --- a/desktop/src/main/services/ml-util-test.ts +++ b/desktop/src/main/services/ml-util-test.ts @@ -1,6 +1,24 @@ -import log from "../log"; +/** + * [Note: Using Electron APIs in UtilityProcess] + * + * Only a small subset of the Electron APIs are available to a UtilityProcess. + * As of writing (Jul 2024, Electron 30), only the following are available: + * - net + * - systemPreferences + * + * In particular, `app` is not available. + * + * We structure our code so that it doesn't need anything apart from `net`. + */ + +// import log from "../log"; import { ensure, wait } from "../utils/common"; +const log = { + info: (...ms: unknown[]) => console.log(...ms), + debug: (fn: () => unknown) => console.log(fn()), +}; + log.debug(() => "Started ML worker process"); process.parentPort.once("message", (e) => { From c124cdff203f39c66f65a45c7c79895ac4714387 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Tue, 30 Jul 2024 14:49:31 +0530 Subject: [PATCH 063/123] Fix ordering --- web/packages/new/photos/services/ml/index.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/web/packages/new/photos/services/ml/index.ts b/web/packages/new/photos/services/ml/index.ts index 697757da59..c067006cf2 100644 --- a/web/packages/new/photos/services/ml/index.ts +++ b/web/packages/new/photos/services/ml/index.ts @@ -75,12 +75,13 @@ const createComlinkWorker = async () => { "ML", new Worker(new URL("worker.ts", import.meta.url)), ); - await cw.remote.then((w) => - w.init(proxy(mlWorkerElectron), proxy(delegate)), - ); - // Pass the message port to our web worker. - cw.worker.postMessage("createMLWorker/port", [messagePort]); + await cw.remote.then((w) => { + // Pass the message port to our web worker. + cw.worker.postMessage("createMLWorker/port", [messagePort]); + // Initialize it. + return w.init(proxy(mlWorkerElectron), proxy(delegate)); + }); return cw; }; From 29877d119c80fcdc30a54ba212359c1343d344e2 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Tue, 30 Jul 2024 14:50:25 +0530 Subject: [PATCH 064/123] Let it flow --- desktop/src/main/services/ml-util-test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/desktop/src/main/services/ml-util-test.ts b/desktop/src/main/services/ml-util-test.ts index c7bec54356..34bed7eec1 100644 --- a/desktop/src/main/services/ml-util-test.ts +++ b/desktop/src/main/services/ml-util-test.ts @@ -28,6 +28,7 @@ process.parentPort.once("message", (e) => { if (response) port.postMessage(response); }); }); + port.start(); }); /** @@ -40,8 +41,8 @@ const handleMessage = async (m: unknown) => { const id = m.id; switch (m.type) { case "foo": - if ("a" in m && typeof m.a == "string") - return { id, data: await foo(m.a) }; + if ("data" in m && typeof m.data == "string") + return { id, data: await foo(m.data) }; break; } } From 48e566ae689cfa0e39e14ebe13dde21871bfdc77 Mon Sep 17 00:00:00 2001 From: ashilkn Date: Tue, 30 Jul 2024 15:40:17 +0530 Subject: [PATCH 065/123] [mob][photos] Stop updating dimension in pubmmd as it could be inverted for some images --- mobile/lib/ui/viewer/file/zoomable_image.dart | 29 ++----------------- 1 file changed, 2 insertions(+), 27 deletions(-) diff --git a/mobile/lib/ui/viewer/file/zoomable_image.dart b/mobile/lib/ui/viewer/file/zoomable_image.dart index c6c7ba003d..4df004b1a2 100644 --- a/mobile/lib/ui/viewer/file/zoomable_image.dart +++ b/mobile/lib/ui/viewer/file/zoomable_image.dart @@ -14,8 +14,6 @@ import 'package:photos/events/files_updated_event.dart'; import 'package:photos/events/local_photos_updated_event.dart'; import "package:photos/models/file/extensions/file_props.dart"; import 'package:photos/models/file/file.dart'; -import "package:photos/models/metadata/file_magic.dart"; -import "package:photos/services/file_magic_service.dart"; import "package:photos/ui/actions/file/file_actions.dart"; import 'package:photos/ui/common/loading_widget.dart'; import 'package:photos/utils/file_util.dart'; @@ -31,12 +29,12 @@ class ZoomableImage extends StatefulWidget { const ZoomableImage( this.photo, { - Key? key, + super.key, this.shouldDisableScroll, required this.tagPrefix, this.backgroundDecoration, this.shouldCover = false, - }) : super(key: key); + }); @override State createState() => _ZoomableImageState(); @@ -359,29 +357,6 @@ class _ZoomableImageState extends State { if (finalImageInfo == null && canUpdateMetadata && !_photo.hasDimensions) { finalImageInfo = await getImageInfo(finalImageProvider); } - if (finalImageInfo != null && canUpdateMetadata) { - _updateAspectRatioIfNeeded(_photo, finalImageInfo).ignore(); - } - } - - // Fallback logic to finish back fill and update aspect - // ratio if needed. - Future _updateAspectRatioIfNeeded( - EnteFile enteFile, - ImageInfo imageInfo, - ) async { - final int h = imageInfo.image.height, w = imageInfo.image.width; - if (h != enteFile.height || w != enteFile.width) { - final logMessage = - 'Updating aspect ratio for from ${enteFile.height}x${enteFile.width} to ${h}x$w'; - _logger.info(logMessage); - await FileMagicService.instance.updatePublicMagicMetadata([ - enteFile, - ], { - heightKey: h, - widthKey: w, - }); - } } bool _isGIF() => _photo.displayName.toLowerCase().endsWith(".gif"); From 37367f72603e60178360193757c793d3f2afb74b Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Tue, 30 Jul 2024 15:41:48 +0530 Subject: [PATCH 066/123] Logging 1 --- desktop/src/main/services/ml-util-test.ts | 43 +++++++++++------- desktop/src/main/services/ml.ts | 44 ++++++++++++++++++- web/packages/new/photos/services/ml/worker.ts | 3 +- 3 files changed, 70 insertions(+), 20 deletions(-) diff --git a/desktop/src/main/services/ml-util-test.ts b/desktop/src/main/services/ml-util-test.ts index 34bed7eec1..9d312a4d04 100644 --- a/desktop/src/main/services/ml-util-test.ts +++ b/desktop/src/main/services/ml-util-test.ts @@ -1,30 +1,39 @@ -/** - * [Note: Using Electron APIs in UtilityProcess] - * - * Only a small subset of the Electron APIs are available to a UtilityProcess. - * As of writing (Jul 2024, Electron 30), only the following are available: - * - net - * - systemPreferences - * - * In particular, `app` is not available. - * - * We structure our code so that it doesn't need anything apart from `net`. - */ - -// import log from "../log"; +// import { ensure, wait } from "../utils/common"; +/** + * We cannot do + * + * import log from "../log"; + * + * because that requires the Electron APIs that are not available to a utility + * process (See: [Note: Using Electron APIs in UtilityProcess]). But even if + * that were to work, logging will still be problematic since we'd try opening + * the log file from two different Node.js processes (this one, and the main + * one), and I didn't find any indication in the electron-log repository that + * the log file's integrity would be maintained in such cases. + * + * So instead we create this proxy log object that uses `process.parentPort` to + * transport the logs over to the main process. + */ const log = { - info: (...ms: unknown[]) => console.log(...ms), + info: (...ms: unknown[]) => mainProcess("log.info", ms), debug: (fn: () => unknown) => console.log(fn()), }; +/** + * Send a message to the main process using a barebones protocol. + */ +const mainProcess = (method: string, params: unknown[]) => { + process.parentPort.postMessage({ method, params }); +}; + log.debug(() => "Started ML worker process"); process.parentPort.once("message", (e) => { const port = ensure(e.ports[0]); port.on("message", (event) => { - void handleMessage(event.data).then((response) => { + void handleMessageFromRenderer(event.data).then((response) => { if (response) port.postMessage(response); }); }); @@ -36,7 +45,7 @@ process.parentPort.once("message", (e) => { * * Sibling of the electronMLWorker function (in `ml/worker.ts`) in the web code. */ -const handleMessage = async (m: unknown) => { +const handleMessageFromRenderer = async (m: unknown) => { if (m && typeof m == "object" && "type" in m && "id" in m) { const id = m.id; switch (m.type) { diff --git a/desktop/src/main/services/ml.ts b/desktop/src/main/services/ml.ts index 05de34fc49..cb7ac17d68 100644 --- a/desktop/src/main/services/ml.ts +++ b/desktop/src/main/services/ml.ts @@ -2,9 +2,14 @@ * @file ML related functionality. This code runs in the main process. */ -import { MessageChannelMain, type BrowserWindow } from "electron"; +import { + MessageChannelMain, + type BrowserWindow, + type UtilityProcess, +} from "electron"; import { utilityProcess } from "electron/main"; import path from "node:path"; +import log from "../log"; /** * Create a new ML worker process. @@ -58,7 +63,6 @@ import path from "node:path"; * worker can directly talk to each other! * * Node.js utility process <-> Renderer web worker - * */ export const createMLWorker = (window: BrowserWindow) => { const { port1, port2 } = new MessageChannelMain(); @@ -67,4 +71,40 @@ export const createMLWorker = (window: BrowserWindow) => { child.postMessage(undefined, [port1]); window.webContents.postMessage("createMLWorker/port", undefined, [port2]); + + handleMLWorkerRequests(child); +}; + +/** + * Handle requests from the utility process. + * + * [Note: Using Electron APIs in UtilityProcess] + * + * Only a small subset of the Electron APIs are available to a UtilityProcess. + * As of writing (Jul 2024, Electron 30), only the following are available: + * + * - net + * - systemPreferences + * + * In particular, `app` is not available. + * + * We structure our code so that it doesn't need anything apart from `net`. + * + * For the other cases, + * + * - When we need to communicate from the utility process to the main process, + * we use the `parentPort` in the utility process. + */ +const handleMLWorkerRequests = (child: UtilityProcess) => { + child.on("message", (m: unknown) => { + if (m && typeof m == "object" && "method" in m) { + switch (m.method) { + default: + break; + } + } + + log.info("Ignoring unexpected message", m); + return undefined; + }); }; diff --git a/web/packages/new/photos/services/ml/worker.ts b/web/packages/new/photos/services/ml/worker.ts index 68a1e81c21..540b15eafd 100644 --- a/web/packages/new/photos/services/ml/worker.ts +++ b/web/packages/new/photos/services/ml/worker.ts @@ -69,7 +69,8 @@ const IPCResponse = z.object({ /** * Our hand-rolled IPC handler and router - the web worker end. * - * Sibling of the handleMessage function (in `ml-worker.ts`) in the desktop app. + * Sibling of the handleMessageFromRenderer function (in `ml-worker.ts`) in the + * desktop code. */ const electronMLWorker = async (type: string, data: string) => { const port = _port; From e66e9251dbebcd2dedb995b8c749cf740cf500e4 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Tue, 30 Jul 2024 15:48:44 +0530 Subject: [PATCH 067/123] Fancier --- desktop/src/main/services/ml.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/desktop/src/main/services/ml.ts b/desktop/src/main/services/ml.ts index cb7ac17d68..98eee821c6 100644 --- a/desktop/src/main/services/ml.ts +++ b/desktop/src/main/services/ml.ts @@ -97,14 +97,19 @@ export const createMLWorker = (window: BrowserWindow) => { */ const handleMLWorkerRequests = (child: UtilityProcess) => { child.on("message", (m: unknown) => { - if (m && typeof m == "object" && "method" in m) { + if (m && typeof m == "object" && "method" in m && "params" in m) { switch (m.method) { + case "log.info": + if (Array.isArray(m.params)) { + const params = m.params as unknown[]; + log.info("[ml-worker]", ...params); + return; + } + break; default: break; } } - - log.info("Ignoring unexpected message", m); - return undefined; + log.warn("Ignoring unexpected message from ML worker", m); }); }; From 81b52419a5b1ed128cbaa651530792df401a5456 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Tue, 30 Jul 2024 16:01:17 +0530 Subject: [PATCH 068/123] debug strings --- desktop/src/main/services/ml-util-test.ts | 16 +++++++++++----- desktop/src/main/services/ml.ts | 19 ++++++++++++++----- 2 files changed, 25 insertions(+), 10 deletions(-) diff --git a/desktop/src/main/services/ml-util-test.ts b/desktop/src/main/services/ml-util-test.ts index 9d312a4d04..18293c2a20 100644 --- a/desktop/src/main/services/ml-util-test.ts +++ b/desktop/src/main/services/ml-util-test.ts @@ -18,17 +18,23 @@ import { ensure, wait } from "../utils/common"; */ const log = { info: (...ms: unknown[]) => mainProcess("log.info", ms), - debug: (fn: () => unknown) => console.log(fn()), + /** + * Unlike the real {@link log.debug}, this is (a) eagerly evaluated, and (b) + * accepts only strings. + */ + debugString: (s: string) => mainProcess("log.debugString", s), }; /** * Send a message to the main process using a barebones protocol. */ -const mainProcess = (method: string, params: unknown[]) => { - process.parentPort.postMessage({ method, params }); +const mainProcess = (method: string, param: unknown) => { + process.parentPort.postMessage({ method, param }); }; -log.debug(() => "Started ML worker process"); +log.debugString( + `Started ML worker process with args ${process.argv.join(" ")}`, +); process.parentPort.once("message", (e) => { const port = ensure(e.ports[0]); @@ -61,7 +67,7 @@ const handleMessageFromRenderer = async (m: unknown) => { }; const foo = async (a: string) => { - console.log("got message foo with argument", a); + log.info("got message foo with argument", a); await wait(0); return a.length; }; diff --git a/desktop/src/main/services/ml.ts b/desktop/src/main/services/ml.ts index 98eee821c6..5de35b4b28 100644 --- a/desktop/src/main/services/ml.ts +++ b/desktop/src/main/services/ml.ts @@ -96,20 +96,29 @@ export const createMLWorker = (window: BrowserWindow) => { * we use the `parentPort` in the utility process. */ const handleMLWorkerRequests = (child: UtilityProcess) => { + const logTag = "[ml-worker]"; child.on("message", (m: unknown) => { - if (m && typeof m == "object" && "method" in m && "params" in m) { + if (m && typeof m == "object" && "method" in m && "param" in m) { + const p = m.param; switch (m.method) { case "log.info": - if (Array.isArray(m.params)) { - const params = m.params as unknown[]; - log.info("[ml-worker]", ...params); + if (Array.isArray(p)) { + // Need to cast from any[] to unknown[] + log.info(logTag, ...(p as unknown[])); return; } break; + case "log.debugString": + if (typeof p == "string") { + log.debug(() => `${logTag} ${p}`); + return; + } + break; + default: break; } } - log.warn("Ignoring unexpected message from ML worker", m); + log.info("Ignoring unexpected message from ML worker", m); }); }; From b2556e893b57b0abfe4f5a6ddb014078d26bb980 Mon Sep 17 00:00:00 2001 From: ashilkn Date: Tue, 30 Jul 2024 16:18:30 +0530 Subject: [PATCH 069/123] [mob][photos] Parse rotation also when parsing video properties using ffprobe --- mobile/lib/models/ffmpeg/ffprobe_keys.dart | 4 +++- mobile/lib/models/ffmpeg/ffprobe_props.dart | 3 +++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/mobile/lib/models/ffmpeg/ffprobe_keys.dart b/mobile/lib/models/ffmpeg/ffprobe_keys.dart index 081fe0ff7e..5eddee32c8 100644 --- a/mobile/lib/models/ffmpeg/ffprobe_keys.dart +++ b/mobile/lib/models/ffmpeg/ffprobe_keys.dart @@ -28,7 +28,7 @@ class FFProbeKeys { static const date = 'date'; static const disposition = 'disposition'; static const duration = 'duration'; - static const quickTimeLocation ="com.apple.quicktime.location.ISO6709"; + static const quickTimeLocation = "com.apple.quicktime.location.ISO6709"; static const durationMicros = 'duration_us'; static const encoder = 'encoder'; static const extraDataSize = 'extradata_size'; @@ -70,6 +70,8 @@ class FFProbeKeys { static const vendorId = 'vendor_id'; static const width = 'width'; static const xiaomiSlowMoment = 'com.xiaomi.slow_moment'; + static const sideDataList = 'side_data_list'; + static const rotation = 'rotation'; } class MediaStreamTypes { diff --git a/mobile/lib/models/ffmpeg/ffprobe_props.dart b/mobile/lib/models/ffmpeg/ffprobe_props.dart index 027d0377ee..9023f83461 100644 --- a/mobile/lib/models/ffmpeg/ffprobe_props.dart +++ b/mobile/lib/models/ffmpeg/ffprobe_props.dart @@ -20,6 +20,7 @@ class FFProbeProps { String? fps; String? codecWidth; String? codecHeight; + int? rotation; // dot separated bitrate, fps, codecWidth, codecHeight. Ignore null value String get videoInfo { @@ -137,6 +138,8 @@ class FFProbeProps { } else if (key == FFProbeKeys.codedHeight) { result.codecHeight = stream[key].toString(); parsedData[key] = result.codecHeight; + } else if (key == FFProbeKeys.sideDataList) { + result.rotation = stream[key][0][FFProbeKeys.rotation]; } } } From 3f0855d9a41d91e5a947618f8dc267501d1ca7e0 Mon Sep 17 00:00:00 2001 From: ashilkn Date: Tue, 30 Jul 2024 16:27:43 +0530 Subject: [PATCH 070/123] [mob][photos] write getter for video dimensions considering rotation in FFProbeProps --- mobile/lib/models/ffmpeg/ffprobe_props.dart | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/mobile/lib/models/ffmpeg/ffprobe_props.dart b/mobile/lib/models/ffmpeg/ffprobe_props.dart index 9023f83461..cbe8d98f53 100644 --- a/mobile/lib/models/ffmpeg/ffprobe_props.dart +++ b/mobile/lib/models/ffmpeg/ffprobe_props.dart @@ -1,8 +1,10 @@ // Adapted from: https://github.com/deckerst/aves import "dart:developer"; +import "dart:ui"; import "package:collection/collection.dart"; +import "package:flutter/widgets.dart"; import "package:intl/intl.dart"; import "package:photos/models/ffmpeg/channel_layouts.dart"; import "package:photos/models/ffmpeg/codecs.dart"; @@ -33,6 +35,20 @@ class FFProbeProps { return info.join(' * '); } + Size? get videoDimentionsConsideringRotation { + final int width = int.tryParse(codecWidth ?? '0') ?? 0; + final int height = int.tryParse(codecHeight ?? '0') ?? 0; + if (width == 0 || height == 0) return null; + + if (rotation != null) { + if ((rotation! ~/ 90).isEven) { + return Size(width.toDouble(), height.toDouble()); + } else { + return Size(height.toDouble(), width.toDouble()); + } + } + } + // toString() method @override String toString() { From 6842218d2bf6f70f887f30f56424668790a40262 Mon Sep 17 00:00:00 2001 From: ashilkn Date: Tue, 30 Jul 2024 16:37:10 +0530 Subject: [PATCH 071/123] [mob][photos] Remove unnecessary int to double conversion --- mobile/lib/models/ffmpeg/ffprobe_props.dart | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/mobile/lib/models/ffmpeg/ffprobe_props.dart b/mobile/lib/models/ffmpeg/ffprobe_props.dart index cbe8d98f53..edf6845c7a 100644 --- a/mobile/lib/models/ffmpeg/ffprobe_props.dart +++ b/mobile/lib/models/ffmpeg/ffprobe_props.dart @@ -1,10 +1,8 @@ // Adapted from: https://github.com/deckerst/aves import "dart:developer"; -import "dart:ui"; import "package:collection/collection.dart"; -import "package:flutter/widgets.dart"; import "package:intl/intl.dart"; import "package:photos/models/ffmpeg/channel_layouts.dart"; import "package:photos/models/ffmpeg/codecs.dart"; @@ -35,16 +33,22 @@ class FFProbeProps { return info.join(' * '); } - Size? get videoDimentionsConsideringRotation { - final int width = int.tryParse(codecWidth ?? '0') ?? 0; - final int height = int.tryParse(codecHeight ?? '0') ?? 0; - if (width == 0 || height == 0) return null; + Map? get videoDimentionsConsideringRotation { + final int w = int.tryParse(codecWidth ?? '0') ?? 0; + final int h = int.tryParse(codecHeight ?? '0') ?? 0; + if (w == 0 || h == 0) return null; if (rotation != null) { if ((rotation! ~/ 90).isEven) { - return Size(width.toDouble(), height.toDouble()); + return { + "width": w, + "height": h, + }; } else { - return Size(height.toDouble(), width.toDouble()); + return { + "width": h, + "height": w, + }; } } } From 60d9a819f44ebf751b0b3c55e0b63c252c52abbe Mon Sep 17 00:00:00 2001 From: ashilkn Date: Tue, 30 Jul 2024 16:52:36 +0530 Subject: [PATCH 072/123] [mob][photos] Rename --- mobile/lib/models/ffmpeg/ffprobe_props.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mobile/lib/models/ffmpeg/ffprobe_props.dart b/mobile/lib/models/ffmpeg/ffprobe_props.dart index edf6845c7a..367a143149 100644 --- a/mobile/lib/models/ffmpeg/ffprobe_props.dart +++ b/mobile/lib/models/ffmpeg/ffprobe_props.dart @@ -33,7 +33,7 @@ class FFProbeProps { return info.join(' * '); } - Map? get videoDimentionsConsideringRotation { + Map? get dimentionsConsideringRotation { final int w = int.tryParse(codecWidth ?? '0') ?? 0; final int h = int.tryParse(codecHeight ?? '0') ?? 0; if (w == 0 || h == 0) return null; From 08ba58d790d793a1b6961db104cb99b0e60b2913 Mon Sep 17 00:00:00 2001 From: ashilkn Date: Tue, 30 Jul 2024 17:30:20 +0530 Subject: [PATCH 073/123] [mob][photos] Write getters to access correct height and width considering the rotation data and keep the raw codec height and width properties private in FFProbeProps --- mobile/lib/models/ffmpeg/ffprobe_props.dart | 65 +++++++++++++-------- 1 file changed, 40 insertions(+), 25 deletions(-) diff --git a/mobile/lib/models/ffmpeg/ffprobe_props.dart b/mobile/lib/models/ffmpeg/ffprobe_props.dart index 367a143149..b72c59f5ba 100644 --- a/mobile/lib/models/ffmpeg/ffprobe_props.dart +++ b/mobile/lib/models/ffmpeg/ffprobe_props.dart @@ -18,41 +18,56 @@ class FFProbeProps { String? bitrate; String? majorBrand; String? fps; - String? codecWidth; - String? codecHeight; - int? rotation; + String? _codecWidth; + String? _codecHeight; + int? _rotation; // dot separated bitrate, fps, codecWidth, codecHeight. Ignore null value String get videoInfo { final List info = []; if (bitrate != null) info.add('$bitrate'); if (fps != null) info.add('ƒ/$fps'); - if (codecWidth != null && codecHeight != null) { - info.add('$codecWidth x $codecHeight'); + if (_codecWidth != null && _codecHeight != null) { + info.add('$_codecWidth x $_codecHeight'); } return info.join(' * '); } - Map? get dimentionsConsideringRotation { - final int w = int.tryParse(codecWidth ?? '0') ?? 0; - final int h = int.tryParse(codecHeight ?? '0') ?? 0; - if (w == 0 || h == 0) return null; - - if (rotation != null) { - if ((rotation! ~/ 90).isEven) { - return { - "width": w, - "height": h, - }; + int? get width { + if (_codecWidth == null || _codecHeight == null) return null; + final intCodecWidth = int.tryParse(_codecWidth!); + if (_rotation == null) { + return intCodecWidth; + } else { + if ((_rotation! ~/ 90).isEven) { + return intCodecWidth; } else { - return { - "width": h, - "height": w, - }; + return int.tryParse(_codecHeight!); } } } + int? get height { + if (_codecWidth == null || _codecHeight == null) return null; + final intCodecHeight = int.tryParse(_codecHeight!); + if (_rotation == null) { + return intCodecHeight; + } else { + if ((_rotation! ~/ 90).isEven) { + return intCodecHeight; + } else { + return int.tryParse(_codecWidth!); + } + } + } + + double? get aspectRatio { + if (width == null || height == null || height == 0 || width == 0) { + return null; + } + return width! / height!; + } + // toString() method @override String toString() { @@ -153,13 +168,13 @@ class FFProbeProps { result.fps = _formatFPS(stream[key]); parsedData[key] = result.fps; } else if (key == FFProbeKeys.codedWidth) { - result.codecWidth = stream[key].toString(); - parsedData[key] = result.codecWidth; + result._codecWidth = stream[key].toString(); + parsedData[key] = result._codecWidth; } else if (key == FFProbeKeys.codedHeight) { - result.codecHeight = stream[key].toString(); - parsedData[key] = result.codecHeight; + result._codecHeight = stream[key].toString(); + parsedData[key] = result._codecHeight; } else if (key == FFProbeKeys.sideDataList) { - result.rotation = stream[key][0][FFProbeKeys.rotation]; + result._rotation = stream[key][0][FFProbeKeys.rotation]; } } } From c3c2dd5cc63c2d64ce48f35b8a12eee09160ae15 Mon Sep 17 00:00:00 2001 From: ashilkn Date: Tue, 30 Jul 2024 18:14:59 +0530 Subject: [PATCH 074/123] [mob][auth] Fix 'App lock' not working onTap from security section on macOS and Linux --- auth/lib/services/local_authentication_service.dart | 6 +++--- auth/lib/ui/settings/security_section_widget.dart | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/auth/lib/services/local_authentication_service.dart b/auth/lib/services/local_authentication_service.dart index 9ca1636997..f47ed693cd 100644 --- a/auth/lib/services/local_authentication_service.dart +++ b/auth/lib/services/local_authentication_service.dart @@ -23,7 +23,7 @@ class LocalAuthenticationService { BuildContext context, String infoMessage, ) async { - if (await _isLocalAuthSupportedOnDevice()) { + if (await isLocalAuthSupportedOnDevice()) { AppLock.of(context)!.setEnabled(false); final result = await requestAuthentication( context, @@ -94,7 +94,7 @@ class LocalAuthenticationService { String errorDialogContent, [ String errorDialogTitle = "", ]) async { - if (await _isLocalAuthSupportedOnDevice()) { + if (await isLocalAuthSupportedOnDevice()) { AppLock.of(context)!.disable(); final result = await requestAuthentication( context, @@ -120,7 +120,7 @@ class LocalAuthenticationService { return false; } - Future _isLocalAuthSupportedOnDevice() async { + Future isLocalAuthSupportedOnDevice() async { try { return Platform.isMacOS || Platform.isLinux ? await FlutterLocalAuthentication().canAuthenticate() diff --git a/auth/lib/ui/settings/security_section_widget.dart b/auth/lib/ui/settings/security_section_widget.dart index 348c6304a5..c5349afa97 100644 --- a/auth/lib/ui/settings/security_section_widget.dart +++ b/auth/lib/ui/settings/security_section_widget.dart @@ -23,7 +23,6 @@ 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 { @@ -144,7 +143,8 @@ class _SecuritySectionWidgetState extends State { trailingIcon: Icons.chevron_right_outlined, trailingIconIsMuted: true, onTap: () async { - if (await LocalAuthentication().isDeviceSupported()) { + if (await LocalAuthenticationService.instance + .isLocalAuthSupportedOnDevice()) { final bool result = await requestAuthentication( context, context.l10n.authToChangeLockscreenSetting, From 878d22fd4a26ba4f7381008a827d6cbfc133c35d Mon Sep 17 00:00:00 2001 From: ashilkn Date: Tue, 30 Jul 2024 18:46:03 +0530 Subject: [PATCH 075/123] [mob][auth]: Show auto lock feature only on mobile --- .../lock_screen/lock_screen_options.dart | 69 ++++++++++--------- 1 file changed, 38 insertions(+), 31 deletions(-) diff --git a/auth/lib/ui/settings/lock_screen/lock_screen_options.dart b/auth/lib/ui/settings/lock_screen/lock_screen_options.dart index 2bbc3750ac..71bb539509 100644 --- a/auth/lib/ui/settings/lock_screen/lock_screen_options.dart +++ b/auth/lib/ui/settings/lock_screen/lock_screen_options.dart @@ -272,35 +272,42 @@ class _LockScreenOptionsState extends State { const SizedBox( height: 24, ), - MenuItemWidget( - captionedTextWidget: CaptionedTextWidget( - title: context.l10n.autoLock, - subTitle: _formatTime( - Duration( - milliseconds: - autoLockTimeInMilliseconds, - ), - ), - ), - surfaceExecutionStates: false, - alignCaptionedTextToLeft: true, - singleBorderRadius: 8, - menuItemColor: colorTheme.fillFaint, - trailingIconColor: colorTheme.textBase, - onTap: () => _onAutoLock(), - ), - Padding( - padding: const EdgeInsets.only( - top: 14, - left: 14, - right: 12, - ), - child: Text( - context.l10n.autoLockFeatureDescription, - style: textTheme.miniFaint, - textAlign: TextAlign.left, - ), - ), + PlatformUtil.isMobile() + ? MenuItemWidget( + captionedTextWidget: + CaptionedTextWidget( + title: context.l10n.autoLock, + subTitle: _formatTime( + Duration( + milliseconds: + autoLockTimeInMilliseconds, + ), + ), + ), + surfaceExecutionStates: false, + alignCaptionedTextToLeft: true, + singleBorderRadius: 8, + menuItemColor: colorTheme.fillFaint, + trailingIconColor: + colorTheme.textBase, + onTap: () => _onAutoLock(), + ) + : const SizedBox.shrink(), + PlatformUtil.isMobile() + ? Padding( + padding: const EdgeInsets.only( + top: 14, + left: 14, + right: 12, + ), + child: Text( + context.l10n + .autoLockFeatureDescription, + style: textTheme.miniFaint, + textAlign: TextAlign.left, + ), + ) + : const SizedBox.shrink(), PlatformUtil.isMobile() ? Column( crossAxisAlignment: @@ -342,10 +349,10 @@ class _LockScreenOptionsState extends State { ), ], ) - : Container(), + : const SizedBox.shrink(), ], ) - : Container(), + : const SizedBox.shrink(), ), ], ), From 4ca40085c1e1b4da288884b26ce078cf73b0b9b3 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Tue, 30 Jul 2024 19:13:54 +0530 Subject: [PATCH 076/123] init --- desktop/src/main/services/ml-util-test.ts | 36 +++++++++++++++++++++-- desktop/src/main/services/ml.ts | 15 ++++++++-- 2 files changed, 46 insertions(+), 5 deletions(-) diff --git a/desktop/src/main/services/ml-util-test.ts b/desktop/src/main/services/ml-util-test.ts index 18293c2a20..9ed2f3a46d 100644 --- a/desktop/src/main/services/ml-util-test.ts +++ b/desktop/src/main/services/ml-util-test.ts @@ -1,4 +1,6 @@ -// +// See [Note: Using Electron APIs in UtilityProcess] about what we can and +// cannot import. + import { ensure, wait } from "../utils/common"; /** @@ -17,6 +19,11 @@ import { ensure, wait } from "../utils/common"; * transport the logs over to the main process. */ const log = { + /** + * Unlike the real {@link log.error}, this accepts only the first string + * argument, not the second optional error one. + */ + errorString: (s: string) => mainProcess("log.errorString", s), info: (...ms: unknown[]) => mainProcess("log.info", ms), /** * Unlike the real {@link log.debug}, this is (a) eagerly evaluated, and (b) @@ -37,6 +44,8 @@ log.debugString( ); process.parentPort.once("message", (e) => { + parseInitData(e.data); + const port = ensure(e.ports[0]); port.on("message", (event) => { void handleMessageFromRenderer(event.data).then((response) => { @@ -46,6 +55,29 @@ process.parentPort.once("message", (e) => { port.start(); }); +/** + * We cannot access Electron's {@link app} object within a utility process, so + * we pass the value of `app.getPath("userData")` during initialization, and it + * can be subsequently retrieved from here. + */ +let _userDataPath: string | undefined; + +/** Equivalent to app.getPath("userData") */ +const userDataPath = () => ensure(_userDataPath); + +const parseInitData = (data: unknown) => { + if ( + data && + typeof data == "object" && + "userDataPath" in data && + typeof data.userDataPath == "string" + ) { + _userDataPath = data.userDataPath; + } else { + log.errorString("Unparseable initialization data"); + } +}; + /** * Our hand-rolled IPC handler and router - the Node.js utility process end. * @@ -67,7 +99,7 @@ const handleMessageFromRenderer = async (m: unknown) => { }; const foo = async (a: string) => { - log.info("got message foo with argument", a); + log.info("got message foo with argument", a, userDataPath()); await wait(0); return a.length; }; diff --git a/desktop/src/main/services/ml.ts b/desktop/src/main/services/ml.ts index 5de35b4b28..6e975d3910 100644 --- a/desktop/src/main/services/ml.ts +++ b/desktop/src/main/services/ml.ts @@ -7,7 +7,7 @@ import { type BrowserWindow, type UtilityProcess, } from "electron"; -import { utilityProcess } from "electron/main"; +import { app, utilityProcess } from "electron/main"; import path from "node:path"; import log from "../log"; @@ -68,7 +68,8 @@ export const createMLWorker = (window: BrowserWindow) => { const { port1, port2 } = new MessageChannelMain(); const child = utilityProcess.fork(path.join(__dirname, "ml-util-test.js")); - child.postMessage(undefined, [port1]); + const userDataPath = app.getPath("userData"); + child.postMessage({ userDataPath }, [port1]); window.webContents.postMessage("createMLWorker/port", undefined, [port2]); @@ -92,6 +93,9 @@ export const createMLWorker = (window: BrowserWindow) => { * * For the other cases, * + * - Additional parameters to the utility process are passed alongwith the + * initial message where we provide it the message port. + * * - When we need to communicate from the utility process to the main process, * we use the `parentPort` in the utility process. */ @@ -101,6 +105,12 @@ const handleMLWorkerRequests = (child: UtilityProcess) => { if (m && typeof m == "object" && "method" in m && "param" in m) { const p = m.param; switch (m.method) { + case "log.errorString": + if (typeof p == "string") { + log.error(`${logTag} ${p}`); + return; + } + break; case "log.info": if (Array.isArray(p)) { // Need to cast from any[] to unknown[] @@ -114,7 +124,6 @@ const handleMLWorkerRequests = (child: UtilityProcess) => { return; } break; - default: break; } From 18cb596d575e3e596d9913a583fa97429dc7da64 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Tue, 30 Jul 2024 19:41:51 +0530 Subject: [PATCH 077/123] Error 1 --- desktop/src/main/services/ml-util-test.ts | 52 ++++++++++++++++------- desktop/src/main/services/ml.ts | 10 ++--- 2 files changed, 41 insertions(+), 21 deletions(-) diff --git a/desktop/src/main/services/ml-util-test.ts b/desktop/src/main/services/ml-util-test.ts index 9ed2f3a46d..69c0f33598 100644 --- a/desktop/src/main/services/ml-util-test.ts +++ b/desktop/src/main/services/ml-util-test.ts @@ -35,9 +35,8 @@ const log = { /** * Send a message to the main process using a barebones protocol. */ -const mainProcess = (method: string, param: unknown) => { - process.parentPort.postMessage({ method, param }); -}; +const mainProcess = (method: string, param: unknown) => + process.parentPort.postMessage({ method, p: param }); log.debugString( `Started ML worker process with args ${process.argv.join(" ")}`, @@ -47,10 +46,10 @@ process.parentPort.once("message", (e) => { parseInitData(e.data); const port = ensure(e.ports[0]); - port.on("message", (event) => { - void handleMessageFromRenderer(event.data).then((response) => { - if (response) port.postMessage(response); - }); + port.on("message", (request) => { + void handleMessageFromRenderer(request.data).then((response) => + port.postMessage(response), + ); }); port.start(); }); @@ -69,6 +68,7 @@ const parseInitData = (data: unknown) => { if ( data && typeof data == "object" && + "userDataPateh" in data && "userDataPath" in data && typeof data.userDataPath == "string" ) { @@ -79,23 +79,43 @@ const parseInitData = (data: unknown) => { }; /** - * Our hand-rolled IPC handler and router - the Node.js utility process end. + * Our hand-rolled RPC handler and router - the Node.js utility process end. * * Sibling of the electronMLWorker function (in `ml/worker.ts`) in the web code. + * + * [Note: Node.js ML worker RPC protocol] + * + * - Each RPC call (i.e. request message) has a "method" (string), "id" + * (number) and "p" (arbitrary param). + * + * - Each RPC result (i.e. response message) has an "id" (number) that is the + * same as the "id" for the request which it corresponds to. + * + * - If the RPC call was a success, then the response messege will have an + * "result" (arbitrary result) property. Otherwise it will have a "error" + * (string) property describing what went wrong. */ const handleMessageFromRenderer = async (m: unknown) => { - if (m && typeof m == "object" && "type" in m && "id" in m) { + if (m && typeof m == "object" && "method" in m && "id" in m && "p" in m) { const id = m.id; - switch (m.type) { - case "foo": - if ("data" in m && typeof m.data == "string") - return { id, data: await foo(m.data) }; - break; + const p = m.p; + try { + switch (m.method) { + case "foo": + if (p && typeof p == "string") + return { id, result: await foo(p) }; + break; + } + } catch (e) { + return { id, error: e instanceof Error ? e.message : String(e) }; } + return { id, error: "Unknown message" }; } - log.info("Ignoring unexpected message", m); - return undefined; + // We don't even have an "id", so at least log it lest the renderer also + // ignore the "id"-less response. + log.info("Ignoring unknown message", m); + return { error: "Unknown message" }; }; const foo = async (a: string) => { diff --git a/desktop/src/main/services/ml.ts b/desktop/src/main/services/ml.ts index 6e975d3910..3f35d8573d 100644 --- a/desktop/src/main/services/ml.ts +++ b/desktop/src/main/services/ml.ts @@ -73,11 +73,11 @@ export const createMLWorker = (window: BrowserWindow) => { window.webContents.postMessage("createMLWorker/port", undefined, [port2]); - handleMLWorkerRequests(child); + handleMessagesFromUtilityProcess(child); }; /** - * Handle requests from the utility process. + * Handle messages posted from the utility process. * * [Note: Using Electron APIs in UtilityProcess] * @@ -99,11 +99,11 @@ export const createMLWorker = (window: BrowserWindow) => { * - When we need to communicate from the utility process to the main process, * we use the `parentPort` in the utility process. */ -const handleMLWorkerRequests = (child: UtilityProcess) => { +const handleMessagesFromUtilityProcess = (child: UtilityProcess) => { const logTag = "[ml-worker]"; child.on("message", (m: unknown) => { - if (m && typeof m == "object" && "method" in m && "param" in m) { - const p = m.param; + if (m && typeof m == "object" && "method" in m && "p" in m) { + const p = m.p; switch (m.method) { case "log.errorString": if (typeof p == "string") { From 3f3d10f57b6d8538fedc8ceb3a9d7daf448a0be3 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Tue, 30 Jul 2024 19:53:39 +0530 Subject: [PATCH 078/123] Error 2 --- desktop/src/main/services/ml-util-test.ts | 2 +- desktop/src/main/services/ml.ts | 2 +- web/packages/new/photos/services/ml/worker.ts | 19 ++++++++++--------- 3 files changed, 12 insertions(+), 11 deletions(-) diff --git a/desktop/src/main/services/ml-util-test.ts b/desktop/src/main/services/ml-util-test.ts index 69c0f33598..54f4cd8e0f 100644 --- a/desktop/src/main/services/ml-util-test.ts +++ b/desktop/src/main/services/ml-util-test.ts @@ -33,7 +33,7 @@ const log = { }; /** - * Send a message to the main process using a barebones protocol. + * Send a message to the main process using a barebones RPC protocol. */ const mainProcess = (method: string, param: unknown) => process.parentPort.postMessage({ method, p: param }); diff --git a/desktop/src/main/services/ml.ts b/desktop/src/main/services/ml.ts index 3f35d8573d..eef362ccc4 100644 --- a/desktop/src/main/services/ml.ts +++ b/desktop/src/main/services/ml.ts @@ -128,6 +128,6 @@ const handleMessagesFromUtilityProcess = (child: UtilityProcess) => { break; } } - log.info("Ignoring unexpected message from ML worker", m); + log.info("Ignoring unknown message from ML worker", m); }); }; diff --git a/web/packages/new/photos/services/ml/worker.ts b/web/packages/new/photos/services/ml/worker.ts index 540b15eafd..2ce6315715 100644 --- a/web/packages/new/photos/services/ml/worker.ts +++ b/web/packages/new/photos/services/ml/worker.ts @@ -63,16 +63,15 @@ globalThis.onmessage = (event: MessageEvent) => { const IPCResponse = z.object({ id: z.number(), - data: z.any(), + result: z.any().optional(), + error: z.string().optional(), }); /** - * Our hand-rolled IPC handler and router - the web worker end. - * - * Sibling of the handleMessageFromRenderer function (in `ml-worker.ts`) in the - * desktop code. + * Make a call to the ML worker running in the Node.js layer using our + * hand-rolled RPC protocol. See: [Note: Node.js ML worker RPC protocol]. */ -const electronMLWorker = async (type: string, data: string) => { +const electronMLWorker = async (method: string, p: string) => { const port = _port; if (!port) { throw new Error( @@ -82,15 +81,17 @@ const electronMLWorker = async (type: string, data: string) => { // Generate a unique nonce to identify this RPC interaction. const id = Math.random(); - return new Promise((resolve) => { + return new Promise((resolve, reject) => { const handleMessage = (event: MessageEvent) => { const response = IPCResponse.parse(event.data); if (response.id != id) return; port.removeEventListener("message", handleMessage); - resolve(response.data); + const error = response.error; + if (error) reject(new Error(error)); + else resolve(response.result); }; port.addEventListener("message", handleMessage); - port.postMessage({ type, id, data }); + port.postMessage({ id, method, p }); }); }; From 65cfcc27a8db626c31738b13d1e66e4f62c004d8 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Tue, 30 Jul 2024 20:00:21 +0530 Subject: [PATCH 079/123] Rearrange --- web/packages/new/photos/services/ml/worker.ts | 68 +++++++++---------- 1 file changed, 34 insertions(+), 34 deletions(-) diff --git a/web/packages/new/photos/services/ml/worker.ts b/web/packages/new/photos/services/ml/worker.ts index 2ce6315715..10e8f2fd7b 100644 --- a/web/packages/new/photos/services/ml/worker.ts +++ b/web/packages/new/photos/services/ml/worker.ts @@ -61,40 +61,6 @@ globalThis.onmessage = (event: MessageEvent) => { } }; -const IPCResponse = z.object({ - id: z.number(), - result: z.any().optional(), - error: z.string().optional(), -}); - -/** - * Make a call to the ML worker running in the Node.js layer using our - * hand-rolled RPC protocol. See: [Note: Node.js ML worker RPC protocol]. - */ -const electronMLWorker = async (method: string, p: string) => { - const port = _port; - if (!port) { - throw new Error( - "No MessagePort to communicate with Electron ML worker", - ); - } - - // Generate a unique nonce to identify this RPC interaction. - const id = Math.random(); - return new Promise((resolve, reject) => { - const handleMessage = (event: MessageEvent) => { - const response = IPCResponse.parse(event.data); - if (response.id != id) return; - port.removeEventListener("message", handleMessage); - const error = response.error; - if (error) reject(new Error(error)); - else resolve(response.result); - }; - port.addEventListener("message", handleMessage); - port.postMessage({ id, method, p }); - }); -}; - /** * Run operations related to machine learning (e.g. indexing) in a Web Worker. * @@ -300,6 +266,40 @@ export class MLWorker { expose(MLWorker); +/** + * Make a call to the ML worker running in the Node.js layer using our + * hand-rolled RPC protocol. See: [Note: Node.js ML worker RPC protocol]. + */ +const electronMLWorker = async (method: string, p: string) => { + const port = _port; + if (!port) { + throw new Error( + "No MessagePort to communicate with Electron ML worker", + ); + } + + // Generate a unique nonce to identify this RPC interaction. + const id = Math.random(); + return new Promise((resolve, reject) => { + const handleMessage = (event: MessageEvent) => { + const response = RPCResponse.parse(event.data); + if (response.id != id) return; + port.removeEventListener("message", handleMessage); + const error = response.error; + if (error) reject(new Error(error)); + else resolve(response.result); + }; + port.addEventListener("message", handleMessage); + port.postMessage({ id, method, p }); + }); +}; + +const RPCResponse = z.object({ + id: z.number(), + result: z.any().optional(), + error: z.string().optional(), +}); + /** * Find out files which need to be indexed. Then index the next batch of them. * From 7baacc6a778df9378e1fffca93e5bc90883339b6 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Tue, 30 Jul 2024 20:22:09 +0530 Subject: [PATCH 080/123] For real - 1 --- desktop/src/main/services/ml-util-test.ts | 125 ---------------- .../services/{ml-utility.ts => ml-worker.ts} | 135 ++++++++++++++++-- desktop/src/main/stream.ts | 38 +---- desktop/src/main/utils/stream.ts | 39 +++++ 4 files changed, 166 insertions(+), 171 deletions(-) delete mode 100644 desktop/src/main/services/ml-util-test.ts rename desktop/src/main/services/{ml-utility.ts => ml-worker.ts} (63%) create mode 100644 desktop/src/main/utils/stream.ts diff --git a/desktop/src/main/services/ml-util-test.ts b/desktop/src/main/services/ml-util-test.ts deleted file mode 100644 index 54f4cd8e0f..0000000000 --- a/desktop/src/main/services/ml-util-test.ts +++ /dev/null @@ -1,125 +0,0 @@ -// See [Note: Using Electron APIs in UtilityProcess] about what we can and -// cannot import. - -import { ensure, wait } from "../utils/common"; - -/** - * We cannot do - * - * import log from "../log"; - * - * because that requires the Electron APIs that are not available to a utility - * process (See: [Note: Using Electron APIs in UtilityProcess]). But even if - * that were to work, logging will still be problematic since we'd try opening - * the log file from two different Node.js processes (this one, and the main - * one), and I didn't find any indication in the electron-log repository that - * the log file's integrity would be maintained in such cases. - * - * So instead we create this proxy log object that uses `process.parentPort` to - * transport the logs over to the main process. - */ -const log = { - /** - * Unlike the real {@link log.error}, this accepts only the first string - * argument, not the second optional error one. - */ - errorString: (s: string) => mainProcess("log.errorString", s), - info: (...ms: unknown[]) => mainProcess("log.info", ms), - /** - * Unlike the real {@link log.debug}, this is (a) eagerly evaluated, and (b) - * accepts only strings. - */ - debugString: (s: string) => mainProcess("log.debugString", s), -}; - -/** - * Send a message to the main process using a barebones RPC protocol. - */ -const mainProcess = (method: string, param: unknown) => - process.parentPort.postMessage({ method, p: param }); - -log.debugString( - `Started ML worker process with args ${process.argv.join(" ")}`, -); - -process.parentPort.once("message", (e) => { - parseInitData(e.data); - - const port = ensure(e.ports[0]); - port.on("message", (request) => { - void handleMessageFromRenderer(request.data).then((response) => - port.postMessage(response), - ); - }); - port.start(); -}); - -/** - * We cannot access Electron's {@link app} object within a utility process, so - * we pass the value of `app.getPath("userData")` during initialization, and it - * can be subsequently retrieved from here. - */ -let _userDataPath: string | undefined; - -/** Equivalent to app.getPath("userData") */ -const userDataPath = () => ensure(_userDataPath); - -const parseInitData = (data: unknown) => { - if ( - data && - typeof data == "object" && - "userDataPateh" in data && - "userDataPath" in data && - typeof data.userDataPath == "string" - ) { - _userDataPath = data.userDataPath; - } else { - log.errorString("Unparseable initialization data"); - } -}; - -/** - * Our hand-rolled RPC handler and router - the Node.js utility process end. - * - * Sibling of the electronMLWorker function (in `ml/worker.ts`) in the web code. - * - * [Note: Node.js ML worker RPC protocol] - * - * - Each RPC call (i.e. request message) has a "method" (string), "id" - * (number) and "p" (arbitrary param). - * - * - Each RPC result (i.e. response message) has an "id" (number) that is the - * same as the "id" for the request which it corresponds to. - * - * - If the RPC call was a success, then the response messege will have an - * "result" (arbitrary result) property. Otherwise it will have a "error" - * (string) property describing what went wrong. - */ -const handleMessageFromRenderer = async (m: unknown) => { - if (m && typeof m == "object" && "method" in m && "id" in m && "p" in m) { - const id = m.id; - const p = m.p; - try { - switch (m.method) { - case "foo": - if (p && typeof p == "string") - return { id, result: await foo(p) }; - break; - } - } catch (e) { - return { id, error: e instanceof Error ? e.message : String(e) }; - } - return { id, error: "Unknown message" }; - } - - // We don't even have an "id", so at least log it lest the renderer also - // ignore the "id"-less response. - log.info("Ignoring unknown message", m); - return { error: "Unknown message" }; -}; - -const foo = async (a: string) => { - log.info("got message foo with argument", a, userDataPath()); - await wait(0); - return a.length; -}; diff --git a/desktop/src/main/services/ml-utility.ts b/desktop/src/main/services/ml-worker.ts similarity index 63% rename from desktop/src/main/services/ml-utility.ts rename to desktop/src/main/services/ml-worker.ts index 79d39edea4..255dbd3f5b 100644 --- a/desktop/src/main/services/ml-utility.ts +++ b/desktop/src/main/services/ml-worker.ts @@ -5,15 +5,132 @@ * for various tasks are not shipped with the app but are downloaded on demand. */ +// See [Note: Using Electron APIs in UtilityProcess] about what we can and +// cannot import. + import Tokenizer from "clip-bpe-js"; -import { app, net } from "electron/main"; +import { net } from "electron/main"; import { existsSync } from "fs"; import fs from "node:fs/promises"; import path from "node:path"; import * as ort from "onnxruntime-node"; -import log from "../log"; -import { writeStream } from "../stream"; import { ensure, wait } from "../utils/common"; +import { writeStream } from "../utils/stream"; + +/** + * We cannot do + * + * import log from "../log"; + * + * because that requires the Electron APIs that are not available to a utility + * process (See: [Note: Using Electron APIs in UtilityProcess]). But even if + * that were to work, logging will still be problematic since we'd try opening + * the log file from two different Node.js processes (this one, and the main + * one), and I didn't find any indication in the electron-log repository that + * the log file's integrity would be maintained in such cases. + * + * So instead we create this proxy log object that uses `process.parentPort` to + * transport the logs over to the main process. + */ +const log = { + /** + * Unlike the real {@link log.error}, this accepts only the first string + * argument, not the second optional error one. + */ + errorString: (s: string) => mainProcess("log.errorString", s), + info: (...ms: unknown[]) => mainProcess("log.info", ms), + /** + * Unlike the real {@link log.debug}, this is (a) eagerly evaluated, and (b) + * accepts only strings. + */ + debugString: (s: string) => mainProcess("log.debugString", s), +}; + +/** + * Send a message to the main process using a barebones RPC protocol. + */ +const mainProcess = (method: string, param: unknown) => + process.parentPort.postMessage({ method, p: param }); + +log.debugString( + `Started ML worker process with args ${process.argv.join(" ")}`, +); + +process.parentPort.once("message", (e) => { + parseInitData(e.data); + + const port = ensure(e.ports[0]); + port.on("message", (request) => { + void handleMessageFromRenderer(request.data).then((response) => + port.postMessage(response), + ); + }); + port.start(); +}); + +/** + * We cannot access Electron's {@link app} object within a utility process, so + * we pass the value of `app.getPath("userData")` during initialization, and it + * can be subsequently retrieved from here. + */ +let _userDataPath: string | undefined; + +/** Equivalent to app.getPath("userData") */ +const userDataPath = () => ensure(_userDataPath); + +const parseInitData = (data: unknown) => { + if ( + data && + typeof data == "object" && + "userDataPateh" in data && + "userDataPath" in data && + typeof data.userDataPath == "string" + ) { + _userDataPath = data.userDataPath; + } else { + log.errorString("Unparseable initialization data"); + } +}; + +/** + * Our hand-rolled RPC handler and router - the Node.js utility process end. + * + * Sibling of the electronMLWorker function (in `ml/worker.ts`) in the web code. + * + * [Note: Node.js ML worker RPC protocol] + * + * - Each RPC call (i.e. request message) has a "method" (string), "id" + * (number) and "p" (arbitrary param). + * + * - Each RPC result (i.e. response message) has an "id" (number) that is the + * same as the "id" for the request which it corresponds to. + * + * - If the RPC call was a success, then the response messege will have an + * "result" (arbitrary result) property. Otherwise it will have a "error" + * (string) property describing what went wrong. + */ +const handleMessageFromRenderer = async (m: unknown) => { + if (m && typeof m == "object" && "method" in m && "id" in m && "p" in m) { + const id = m.id; + const p = m.p; + try { + switch (m.method) { + case "foo": + if (p && typeof p == "string") + return { id, result: await foo(p) }; + break; + } + } catch (e) { + return { id, error: e instanceof Error ? e.message : String(e) }; + } + return { id, error: "Unknown message" }; + } + + // We don't even have an "id", so at least log it lest the renderer also + // ignore the "id"-less response. + log.info("Ignoring unknown message", m); + return { error: "Unknown message" }; +}; /** * Return a function that can be used to trigger a download of the specified @@ -79,7 +196,7 @@ const modelPathDownloadingIfNeeded = async ( } else { const size = (await fs.stat(modelPath)).size; if (size !== expectedByteSize) { - log.error( + log.errorString( `The size ${size} of model ${modelName} does not match the expected size, downloading again`, ); await downloadModel(modelPath, modelName); @@ -91,7 +208,7 @@ const modelPathDownloadingIfNeeded = async ( /** Return the path where the given {@link modelName} is meant to be saved */ const modelSavePath = (modelName: string) => - path.join(app.getPath("userData"), "models", modelName); + path.join(userDataPath(), "models", modelName); const downloadModel = async (saveLocation: string, name: string) => { // `mkdir -p` the directory where we want to save the model. @@ -138,7 +255,7 @@ export const computeCLIPImageEmbedding = async (input: Float32Array) => { input: new ort.Tensor("float32", input, [1, 3, 224, 224]), }; const results = await session.run(feeds); - log.debug(() => `ONNX/CLIP image embedding took ${Date.now() - t} ms`); + log.debugString(`ONNX/CLIP image embedding took ${Date.now() - t} ms`); /* Need these model specific casts to type the result */ return ensure(results.output).data as Float32Array; }; @@ -184,7 +301,7 @@ export const computeCLIPTextEmbeddingIfAvailable = async (text: string) => { }; const results = await session.run(feeds); - log.debug(() => `ONNX/CLIP text embedding took ${Date.now() - t} ms`); + log.debugString(`ONNX/CLIP text embedding took ${Date.now() - t} ms`); return ensure(results.output).data as Float32Array; }; @@ -203,7 +320,7 @@ export const detectFaces = async (input: Float32Array) => { input: new ort.Tensor("float32", input, [1, 3, 640, 640]), }; const results = await session.run(feeds); - log.debug(() => `ONNX/YOLO face detection took ${Date.now() - t} ms`); + log.debugString(`ONNX/YOLO face detection took ${Date.now() - t} ms`); return ensure(results.output).data; }; @@ -228,7 +345,7 @@ export const computeFaceEmbeddings = async (input: Float32Array) => { const t = Date.now(); const feeds = { img_inputs: inputTensor }; const results = await session.run(feeds); - log.debug(() => `ONNX/MFNT face embedding took ${Date.now() - t} ms`); + log.debugString(`ONNX/MFNT face embedding took ${Date.now() - t} ms`); /* Need these model specific casts to extract and type the result */ return (results.embeddings as unknown as Record) .cpuData as Float32Array; diff --git a/desktop/src/main/stream.ts b/desktop/src/main/stream.ts index c86232fd64..d32eecc627 100644 --- a/desktop/src/main/stream.ts +++ b/desktop/src/main/stream.ts @@ -3,7 +3,6 @@ */ import { net, protocol } from "electron/main"; import { randomUUID } from "node:crypto"; -import { createWriteStream, existsSync } from "node:fs"; import fs from "node:fs/promises"; import { Readable } from "node:stream"; import { ReadableStream } from "node:stream/web"; @@ -12,6 +11,7 @@ import log from "./log"; import { ffmpegConvertToMP4 } from "./services/ffmpeg"; import { markClosableZip, openZip } from "./services/zip"; import { ensure } from "./utils/common"; +import { writeStream } from "./utils/stream"; import { deleteTempFile, deleteTempFileIgnoringErrors, @@ -160,42 +160,6 @@ const handleWrite = async (path: string, request: Request) => { return new Response("", { status: 200 }); }; -/** - * Write a (web) ReadableStream to a file at the given {@link filePath}. - * - * The returned promise resolves when the write completes. - * - * @param filePath The local file system path where the file should be written. - * - * @param readableStream A web - * [ReadableStream](https://developer.mozilla.org/en-US/docs/Web/API/ReadableStream). - * - */ -export const writeStream = ( - filePath: string, - readableStream: unknown /*ReadableStream*/, // @ts-expect-error [Note: Node and web stream type mismatch] -) => writeNodeStream(filePath, Readable.fromWeb(readableStream)); - -const writeNodeStream = async (filePath: string, fileStream: Readable) => { - const writeable = createWriteStream(filePath); - - fileStream.on("error", (err) => { - writeable.destroy(err); // Close the writable stream with an error - }); - - fileStream.pipe(writeable); - - await new Promise((resolve, reject) => { - writeable.on("finish", resolve); - writeable.on("error", (err) => { - if (existsSync(filePath)) { - void fs.unlink(filePath); - } - reject(err); - }); - }); -}; - /** * A map from token to file paths for convert-to-mp4 requests that we have * received. diff --git a/desktop/src/main/utils/stream.ts b/desktop/src/main/utils/stream.ts new file mode 100644 index 0000000000..f5a98de0f7 --- /dev/null +++ b/desktop/src/main/utils/stream.ts @@ -0,0 +1,39 @@ +import { createWriteStream, existsSync } from "node:fs"; +import fs from "node:fs/promises"; +import { Readable } from "node:stream"; + +/** + * Write a (web) ReadableStream to a file at the given {@link filePath}. + * + * The returned promise resolves when the write completes. + * + * @param filePath The local file system path where the file should be written. + * + * @param readableStream A web + * [ReadableStream](https://developer.mozilla.org/en-US/docs/Web/API/ReadableStream). + * + */ +export const writeStream = ( + filePath: string, + readableStream: unknown /*ReadableStream*/, // @ts-expect-error [Note: Node and web stream type mismatch] +) => writeNodeStream(filePath, Readable.fromWeb(readableStream)); + +const writeNodeStream = async (filePath: string, fileStream: Readable) => { + const writeable = createWriteStream(filePath); + + fileStream.on("error", (err) => { + writeable.destroy(err); // Close the writable stream with an error + }); + + fileStream.pipe(writeable); + + await new Promise((resolve, reject) => { + writeable.on("finish", resolve); + writeable.on("error", (err) => { + if (existsSync(filePath)) { + void fs.unlink(filePath); + } + reject(err); + }); + }); +}; From 2101817b23e601e902f930e42b01b38cdd6a6a16 Mon Sep 17 00:00:00 2001 From: Aman Raj Singh Mourya Date: Tue, 30 Jul 2024 21:49:40 +0530 Subject: [PATCH 081/123] [mob][auth] Code clean up --- auth/lib/utils/lock_screen_settings.dart | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/auth/lib/utils/lock_screen_settings.dart b/auth/lib/utils/lock_screen_settings.dart index 53b087198b..89db3df7ae 100644 --- a/auth/lib/utils/lock_screen_settings.dart +++ b/auth/lib/utils/lock_screen_settings.dart @@ -2,8 +2,6 @@ import "dart:convert"; import "dart:typed_data"; import "package:ente_crypto_dart/ente_crypto_dart.dart"; -import "package:flutter/material.dart"; -import "package:flutter/scheduler.dart"; import "package:flutter_secure_storage/flutter_secure_storage.dart"; import "package:privacy_screen/privacy_screen.dart"; import "package:shared_preferences/shared_preferences.dart"; @@ -41,9 +39,6 @@ class LockScreenSettings { } Future setHideAppContent(bool hideContent) async { - final brightness = - SchedulerBinding.instance.platformDispatcher.platformBrightness; - bool isInDarkMode = brightness == Brightness.dark; !hideContent ? PrivacyScreen.instance.disable() : await PrivacyScreen.instance.enable( @@ -53,10 +48,7 @@ class LockScreenSettings { androidOptions: const PrivacyAndroidOptions( enableSecure: true, ), - backgroundColor: isInDarkMode ? Colors.black : Colors.white, - blurEffect: isInDarkMode - ? PrivacyBlurEffect.dark - : PrivacyBlurEffect.extraLight, + blurEffect: PrivacyBlurEffect.extraLight, ); await _preferences.setBool(keyHideAppContent, hideContent); } From 30cecf53b3b72bc4dfca21f3734ebb09914f5644 Mon Sep 17 00:00:00 2001 From: Aman Raj Singh Mourya Date: Tue, 30 Jul 2024 22:02:50 +0530 Subject: [PATCH 082/123] [mob][auth] Hide content default value set to true when applock enabled --- auth/lib/ui/settings/lock_screen/lock_screen_options.dart | 5 ++--- auth/lib/utils/lock_screen_settings.dart | 2 +- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/auth/lib/ui/settings/lock_screen/lock_screen_options.dart b/auth/lib/ui/settings/lock_screen/lock_screen_options.dart index 71bb539509..2c600a0d1d 100644 --- a/auth/lib/ui/settings/lock_screen/lock_screen_options.dart +++ b/auth/lib/ui/settings/lock_screen/lock_screen_options.dart @@ -104,13 +104,12 @@ class _LockScreenOptionsState extends State { await _configuration.setSystemLockScreen(!appLock); await _lockscreenSetting.removePinAndPassword(); if (PlatformUtil.isMobile()) { - if (appLock == false) { - await _lockscreenSetting.setHideAppContent(true); - } + await _lockscreenSetting.setHideAppContent(!appLock); } setState(() { _initializeSettings(); appLock = !appLock; + hideAppContent = _lockscreenSetting.getShouldHideAppContent(); }); } diff --git a/auth/lib/utils/lock_screen_settings.dart b/auth/lib/utils/lock_screen_settings.dart index 89db3df7ae..b857bcc9f5 100644 --- a/auth/lib/utils/lock_screen_settings.dart +++ b/auth/lib/utils/lock_screen_settings.dart @@ -54,7 +54,7 @@ class LockScreenSettings { } bool getShouldHideAppContent() { - return _preferences.getBool(keyHideAppContent) ?? false; + return _preferences.getBool(keyHideAppContent) ?? true; } Future setAutoLockTime(Duration duration) async { From 95facd60e02c5a470b67b8d8a9b719179af41e23 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Wed, 31 Jul 2024 09:20:29 +0530 Subject: [PATCH 083/123] integrate wip 1 --- web/packages/base/types/ipc.ts | 53 ------- .../new/photos/services/ml/worker-rpc.ts | 131 ++++++++++++++++++ web/packages/new/photos/services/ml/worker.ts | 55 +------- 3 files changed, 134 insertions(+), 105 deletions(-) create mode 100644 web/packages/new/photos/services/ml/worker-rpc.ts diff --git a/web/packages/base/types/ipc.ts b/web/packages/base/types/ipc.ts index ed7877c966..4aca8b865d 100644 --- a/web/packages/base/types/ipc.ts +++ b/web/packages/base/types/ipc.ts @@ -348,59 +348,6 @@ export interface Electron { */ createMLWorker: () => void; - /** - * Return a CLIP embedding of the given image. - * - * See: [Note: Natural language search using CLIP] - * - * The input is a opaque float32 array representing the image. The layout - * and exact encoding of the input is specific to our implementation and the - * ML model (CLIP) we use. - * - * @returns A CLIP embedding (an array of 512 floating point values). - */ - computeCLIPImageEmbedding: (input: Float32Array) => Promise; - - /** - * Return a CLIP embedding of the given image if we already have the model - * downloaded and prepped. If the model is not available return `undefined`. - * - * This differs from the other sibling ML functions in that it doesn't wait - * for the model download to finish. It does trigger a model download, but - * then immediately returns `undefined`. At some future point, when the - * model downloaded finishes, calls to this function will start returning - * the result we seek. - * - * The reason for doing it in this asymmetric way is because CLIP text - * embeddings are used as part of deducing user initiated search results, - * and we don't want to block that interaction on a large network request. - * - * See: [Note: Natural language search using CLIP] - * - * @param text The string whose embedding we want to compute. - * - * @returns A CLIP embedding. - */ - computeCLIPTextEmbeddingIfAvailable: ( - text: string, - ) => Promise; - - /** - * Detect faces in the given image using YOLO. - * - * Both the input and output are opaque binary data whose internal structure - * is specific to our implementation and the model (YOLO) we use. - */ - detectFaces: (input: Float32Array) => Promise; - - /** - * Return a MobileFaceNet embeddings for the given faces. - * - * Both the input and output are opaque binary data whose internal structure - * is specific to our implementation and the model (MobileFaceNet) we use. - */ - computeFaceEmbeddings: (input: Float32Array) => Promise; - // - Watch /** diff --git a/web/packages/new/photos/services/ml/worker-rpc.ts b/web/packages/new/photos/services/ml/worker-rpc.ts new file mode 100644 index 0000000000..782c0a3bd3 --- /dev/null +++ b/web/packages/new/photos/services/ml/worker-rpc.ts @@ -0,0 +1,131 @@ +import { z } from "zod"; + +/** + * The port used to communicate with the Node.js ML worker process + * + * See: [Note: ML IPC] + * */ +let _port: MessagePort | undefined; + +/** + * Use the given {@link MessagePort} to communicate with the Node.js ML worker + * process. + */ +export const startUsingMessagePort = (port: MessagePort) => { + _port = port; + port.start(); +}; + +/** + * Return a CLIP embedding of the given image. + * + * See: [Note: Natural language search using CLIP] + * + * The input is a opaque float32 array representing the image. The layout + * and exact encoding of the input is specific to our implementation and the + * ML model (CLIP) we use. + * + * @returns A CLIP embedding (an array of 512 floating point values). + */ +export const computeCLIPImageEmbedding = ( + input: Float32Array, +): Promise => + ensureFloat32Array(electronMLWorker("computeCLIPImageEmbedding", input)); + +/** + * Return a CLIP embedding of the given image if we already have the model + * downloaded and prepped. If the model is not available return `undefined`. + * + * This differs from the other sibling ML functions in that it doesn't wait + * for the model download to finish. It does trigger a model download, but + * then immediately returns `undefined`. At some future point, when the + * model downloaded finishes, calls to this function will start returning + * the result we seek. + * + * The reason for doing it in this asymmetric way is because CLIP text + * embeddings are used as part of deducing user initiated search results, + * and we don't want to block that interaction on a large network request. + * + * See: [Note: Natural language search using CLIP] + * + * @param text The string whose embedding we want to compute. + * + * @returns A CLIP embedding. + */ +export const computeCLIPTextEmbeddingIfAvailable = async ( + text: string, +): Promise => + ensureOptionalFloat32Array( + electronMLWorker("computeCLIPTextEmbeddingIfAvailable", text), + ); + +/** + * Detect faces in the given image using YOLO. + * + * Both the input and output are opaque binary data whose internal structure + * is specific to our implementation and the model (YOLO) we use. + */ +export const detectFaces = (input: Float32Array): Promise => + ensureFloat32Array(electronMLWorker("detectFaces", input)); + +/** + * Return a MobileFaceNet embeddings for the given faces. + * + * Both the input and output are opaque binary data whose internal structure + * is specific to our implementation and the model (MobileFaceNet) we use. + */ +export const computeFaceEmbeddings = ( + input: Float32Array, +): Promise => + ensureFloat32Array(electronMLWorker("computeFaceEmbeddings", input)); + +const ensureFloat32Array = async ( + pu: Promise, +): Promise => { + const u = await pu; + if (u instanceof Float32Array) return u; + throw new Error(`Expected a Float32Array but instead got ${typeof u}`); +}; + +const ensureOptionalFloat32Array = async ( + pu: Promise, +): Promise => { + const u = await pu; + if (u === undefined) return u; + if (u instanceof Float32Array) return u; + throw new Error(`Expected a Float32Array but instead got ${typeof u}`); +}; + +/** + * Make a call to the ML worker running in the Node.js layer using our + * hand-rolled RPC protocol. See: [Note: Node.js ML worker RPC protocol]. + */ +const electronMLWorker = async (method: string, p: string | Float32Array) => { + const port = _port; + if (!port) { + throw new Error( + "No MessagePort to communicate with Electron ML worker", + ); + } + + // Generate a unique nonce to identify this RPC interaction. + const id = Math.random(); + return new Promise((resolve, reject) => { + const handleMessage = (event: MessageEvent) => { + const response = RPCResponse.parse(event.data); + if (response.id != id) return; + port.removeEventListener("message", handleMessage); + const error = response.error; + if (error) reject(new Error(error)); + else resolve(response.result); + }; + port.addEventListener("message", handleMessage); + port.postMessage({ id, method, p }); + }); +}; + +const RPCResponse = z.object({ + id: z.number(), + result: z.any().optional(), + error: z.string().optional(), +}); diff --git a/web/packages/new/photos/services/ml/worker.ts b/web/packages/new/photos/services/ml/worker.ts index 10e8f2fd7b..03b8adf2a2 100644 --- a/web/packages/new/photos/services/ml/worker.ts +++ b/web/packages/new/photos/services/ml/worker.ts @@ -9,7 +9,6 @@ import { ensure } from "@/utils/ensure"; import { wait } from "@/utils/promise"; import { DOMParser } from "@xmldom/xmldom"; import { expose } from "comlink"; -import { z } from "zod"; import downloadManager from "../download"; import { cmpNewLib2, extractRawExif } from "../exif"; import { getAllLocalFiles, getLocalTrashedFiles } from "../files"; @@ -33,6 +32,7 @@ import { type RemoteDerivedData, } from "./embedding"; import { faceIndexingVersion, indexFaces, type FaceIndex } from "./face"; +import { startUsingMessagePort } from "./worker-rpc"; import type { MLWorkerDelegate, MLWorkerElectron } from "./worker-types"; const idleDurationStart = 5; /* 5 seconds */ @@ -47,18 +47,9 @@ interface IndexableItem { remoteDerivedData: RemoteDerivedData | undefined; } -/** - * The port used to communicate with the Node.js ML worker process - * - * See: [Note: ML IPC] - * */ -let _port: MessagePort | undefined; - globalThis.onmessage = (event: MessageEvent) => { - if (event.data == "createMLWorker/port") { - _port = event.ports[0]; - _port?.start(); - } + if (event.data == "createMLWorker/port") + startUsingMessagePort(ensure(event.ports[0])); }; /** @@ -128,12 +119,6 @@ export class MLWorker { // need to monkey patch it (This also ensures that it is not tree // shaken). globalThis.DOMParser = DOMParser; - - void (async () => { - console.log("yyy calling foo with 3"); - const res = await electronMLWorker("foo", "3"); - console.log("yyy calling foo with 3 result", res); - })(); } /** @@ -266,40 +251,6 @@ export class MLWorker { expose(MLWorker); -/** - * Make a call to the ML worker running in the Node.js layer using our - * hand-rolled RPC protocol. See: [Note: Node.js ML worker RPC protocol]. - */ -const electronMLWorker = async (method: string, p: string) => { - const port = _port; - if (!port) { - throw new Error( - "No MessagePort to communicate with Electron ML worker", - ); - } - - // Generate a unique nonce to identify this RPC interaction. - const id = Math.random(); - return new Promise((resolve, reject) => { - const handleMessage = (event: MessageEvent) => { - const response = RPCResponse.parse(event.data); - if (response.id != id) return; - port.removeEventListener("message", handleMessage); - const error = response.error; - if (error) reject(new Error(error)); - else resolve(response.result); - }; - port.addEventListener("message", handleMessage); - port.postMessage({ id, method, p }); - }); -}; - -const RPCResponse = z.object({ - id: z.number(), - result: z.any().optional(), - error: z.string().optional(), -}); - /** * Find out files which need to be indexed. Then index the next batch of them. * From f2f7b483fdd471932bd787f35663a2c923bed632 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Wed, 31 Jul 2024 09:40:08 +0530 Subject: [PATCH 084/123] comlink wip --- desktop/package.json | 1 + desktop/src/main/ipc.ts | 2 +- desktop/src/main/services/ml-worker.ts | 34 +++++++++++++------ desktop/yarn.lock | 5 +++ web/packages/new/photos/services/ml/index.ts | 11 ++---- web/packages/new/photos/services/ml/worker.ts | 11 ++++-- 6 files changed, 42 insertions(+), 22 deletions(-) diff --git a/desktop/package.json b/desktop/package.json index 453bb931fa..c3da2c3591 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -28,6 +28,7 @@ "auto-launch": "^5.0", "chokidar": "^3.6", "clip-bpe-js": "^0.0.6", + "comlink": "^4.4.1", "compare-versions": "^6.1", "electron-log": "^5.1", "electron-store": "^8.2", diff --git a/desktop/src/main/ipc.ts b/desktop/src/main/ipc.ts index caca5758b5..511460c6c7 100644 --- a/desktop/src/main/ipc.ts +++ b/desktop/src/main/ipc.ts @@ -49,7 +49,7 @@ import { computeCLIPTextEmbeddingIfAvailable, computeFaceEmbeddings, detectFaces, -} from "./services/ml-utility"; +} from "./services/ml-worker"; import { encryptionKey, lastShownChangelogVersion, diff --git a/desktop/src/main/services/ml-worker.ts b/desktop/src/main/services/ml-worker.ts index 255dbd3f5b..21553da3ec 100644 --- a/desktop/src/main/services/ml-worker.ts +++ b/desktop/src/main/services/ml-worker.ts @@ -9,6 +9,10 @@ // cannot import. import Tokenizer from "clip-bpe-js"; +import { expose } from "comlink"; +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore +import nodeEndpoint from "comlink/dist/umd/node-adapter"; import { net } from "electron/main"; import { existsSync } from "fs"; import fs from "node:fs/promises"; @@ -60,12 +64,22 @@ process.parentPort.once("message", (e) => { parseInitData(e.data); const port = ensure(e.ports[0]); - port.on("message", (request) => { - void handleMessageFromRenderer(request.data).then((response) => - port.postMessage(response), - ); - }); - port.start(); + expose( + { + computeCLIPImageEmbedding, + computeCLIPTextEmbeddingIfAvailable, + detectFaces, + computeFaceEmbeddings, + }, + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-explicit-any + nodeEndpoint(port as unknown as any), + ); + // port.on("message", (request) => { + // void handleMessageFromRenderer(request.data).then((response) => + // port.postMessage(response), + // ); + // }); + // port.start(); }); /** @@ -109,15 +123,15 @@ const parseInitData = (data: unknown) => { * "result" (arbitrary result) property. Otherwise it will have a "error" * (string) property describing what went wrong. */ -const handleMessageFromRenderer = async (m: unknown) => { +export const handleMessageFromRenderer = (m: unknown) => { if (m && typeof m == "object" && "method" in m && "id" in m && "p" in m) { const id = m.id; - const p = m.p; + // const p = m.p; try { switch (m.method) { case "foo": - if (p && typeof p == "string") - return { id, result: await foo(p) }; + // if (p && typeof p == "string") + // return { id, result: await foo(p) }; break; } } catch (e) { diff --git a/desktop/yarn.lock b/desktop/yarn.lock index 5feaf65f6f..afbe850a91 100644 --- a/desktop/yarn.lock +++ b/desktop/yarn.lock @@ -968,6 +968,11 @@ combined-stream@^1.0.8: dependencies: delayed-stream "~1.0.0" +comlink@^4.4.1: + version "4.4.1" + resolved "https://registry.yarnpkg.com/comlink/-/comlink-4.4.1.tgz#e568b8e86410b809e8600eb2cf40c189371ef981" + integrity sha512-+1dlx0aY5Jo1vHy/tSsIGpSkN4tS9rZSW8FIhG0JH/crs9wwweswIo/POr451r7bZww3hFbPAKnTpimzL/mm4Q== + commander@^5.0.0: version "5.1.0" resolved "https://registry.yarnpkg.com/commander/-/commander-5.1.0.tgz#46abbd1652f8e059bddaef99bbdcb2ad9cf179ae" diff --git a/web/packages/new/photos/services/ml/index.ts b/web/packages/new/photos/services/ml/index.ts index c067006cf2..b903e0188c 100644 --- a/web/packages/new/photos/services/ml/index.ts +++ b/web/packages/new/photos/services/ml/index.ts @@ -12,7 +12,7 @@ import { FileType } from "@/media/file-type"; import type { EnteFile } from "@/new/photos/types/file"; import { ensure } from "@/utils/ensure"; import { throttled } from "@/utils/promise"; -import { proxy } from "comlink"; +import { proxy, transfer } from "comlink"; import { isInternalUser } from "../feature-flags"; import { getRemoteFlag, updateRemoteFlag } from "../remote-store"; import type { UploadItem } from "../upload/types"; @@ -59,11 +59,6 @@ const worker = async () => { const createComlinkWorker = async () => { const electron = ensureElectron(); - const mlWorkerElectron = { - detectFaces: electron.detectFaces, - computeFaceEmbeddings: electron.computeFaceEmbeddings, - computeCLIPImageEmbedding: electron.computeCLIPImageEmbedding, - }; const delegate = { workerDidProcessFile, }; @@ -78,9 +73,9 @@ const createComlinkWorker = async () => { await cw.remote.then((w) => { // Pass the message port to our web worker. - cw.worker.postMessage("createMLWorker/port", [messagePort]); + // cw.worker.postMessage("createMLWorker/port", [messagePort]); // Initialize it. - return w.init(proxy(mlWorkerElectron), proxy(delegate)); + return w.init(transfer(messagePort, [messagePort]), proxy(delegate)); }); return cw; diff --git a/web/packages/new/photos/services/ml/worker.ts b/web/packages/new/photos/services/ml/worker.ts index 03b8adf2a2..d27f3021f4 100644 --- a/web/packages/new/photos/services/ml/worker.ts +++ b/web/packages/new/photos/services/ml/worker.ts @@ -8,7 +8,7 @@ import { fileLogID } from "@/new/photos/utils/file"; import { ensure } from "@/utils/ensure"; import { wait } from "@/utils/promise"; import { DOMParser } from "@xmldom/xmldom"; -import { expose } from "comlink"; +import { expose, wrap } from "comlink"; import downloadManager from "../download"; import { cmpNewLib2, extractRawExif } from "../exif"; import { getAllLocalFiles, getLocalTrashedFiles } from "../files"; @@ -95,8 +95,13 @@ export class MLWorker { * @param delegate The {@link MLWorkerDelegate} the worker can use to inform * the main thread of interesting events. */ - async init(electron: MLWorkerElectron, delegate?: MLWorkerDelegate) { - this.electron = electron; + async init(port: MessagePort, delegate: MLWorkerDelegate) { + // this.electron = electron; + this.electron = wrap(port); /* mlWorkerElectron = { + detectFaces: electron.detectFaces, + computeFaceEmbeddings: electron.computeFaceEmbeddings, + computeCLIPImageEmbedding: electron.computeCLIPImageEmbedding, + };*/ this.delegate = delegate; // Initialize the downloadManager running in the web worker with the // user's token. It'll be used to download files to index if needed. From 1ae0f9723c04fb8f78042f402f01b6b3ac6b2245 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Wed, 31 Jul 2024 09:54:44 +0530 Subject: [PATCH 085/123] Fix 1 --- desktop/src/main/ipc.ts | 32 +++++++++++--------------- desktop/src/main/services/ml-worker.ts | 5 ++-- desktop/src/main/services/ml.ts | 2 +- 3 files changed, 17 insertions(+), 22 deletions(-) diff --git a/desktop/src/main/ipc.ts b/desktop/src/main/ipc.ts index 511460c6c7..fd6325c5cc 100644 --- a/desktop/src/main/ipc.ts +++ b/desktop/src/main/ipc.ts @@ -44,12 +44,7 @@ import { import { convertToJPEG, generateImageThumbnail } from "./services/image"; import { logout } from "./services/logout"; import { createMLWorker } from "./services/ml"; -import { - computeCLIPImageEmbedding, - computeCLIPTextEmbeddingIfAvailable, - computeFaceEmbeddings, - detectFaces, -} from "./services/ml-worker"; + import { encryptionKey, lastShownChangelogVersion, @@ -189,21 +184,22 @@ export const attachIPCHandlers = () => { // - ML - ipcMain.handle("computeCLIPImageEmbedding", (_, input: Float32Array) => - computeCLIPImageEmbedding(input), - ); + // TODO: + // ipcMain.handle("computeCLIPImageEmbedding", (_, input: Float32Array) => + // computeCLIPImageEmbedding(input), + // ); - ipcMain.handle("computeCLIPTextEmbeddingIfAvailable", (_, text: string) => - computeCLIPTextEmbeddingIfAvailable(text), - ); + // ipcMain.handle("computeCLIPTextEmbeddingIfAvailable", (_, text: string) => + // computeCLIPTextEmbeddingIfAvailable(text), + // ); - ipcMain.handle("detectFaces", (_, input: Float32Array) => - detectFaces(input), - ); + // ipcMain.handle("detectFaces", (_, input: Float32Array) => + // detectFaces(input), + // ); - ipcMain.handle("computeFaceEmbeddings", (_, input: Float32Array) => - computeFaceEmbeddings(input), - ); + // ipcMain.handle("computeFaceEmbeddings", (_, input: Float32Array) => + // computeFaceEmbeddings(input), + // ); // - Upload diff --git a/desktop/src/main/services/ml-worker.ts b/desktop/src/main/services/ml-worker.ts index 21553da3ec..7112b5be78 100644 --- a/desktop/src/main/services/ml-worker.ts +++ b/desktop/src/main/services/ml-worker.ts @@ -10,9 +10,6 @@ import Tokenizer from "clip-bpe-js"; import { expose } from "comlink"; -// eslint-disable-next-line @typescript-eslint/ban-ts-comment -// @ts-ignore -import nodeEndpoint from "comlink/dist/umd/node-adapter"; import { net } from "electron/main"; import { existsSync } from "fs"; import fs from "node:fs/promises"; @@ -21,6 +18,8 @@ import * as ort from "onnxruntime-node"; import { ensure, wait } from "../utils/common"; import { writeStream } from "../utils/stream"; +const nodeEndpoint = require("comlink/dist/umd/node-adapter"); + /** * We cannot do * diff --git a/desktop/src/main/services/ml.ts b/desktop/src/main/services/ml.ts index eef362ccc4..66c699b5d2 100644 --- a/desktop/src/main/services/ml.ts +++ b/desktop/src/main/services/ml.ts @@ -67,7 +67,7 @@ import log from "../log"; export const createMLWorker = (window: BrowserWindow) => { const { port1, port2 } = new MessageChannelMain(); - const child = utilityProcess.fork(path.join(__dirname, "ml-util-test.js")); + const child = utilityProcess.fork(path.join(__dirname, "ml-worker.js")); const userDataPath = app.getPath("userData"); child.postMessage({ userDataPath }, [port1]); From b69d23028b884f263fd74b0b48af8c2aa23417e6 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Wed, 31 Jul 2024 09:56:45 +0530 Subject: [PATCH 086/123] Remove test code --- desktop/src/main/services/ml-worker.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/desktop/src/main/services/ml-worker.ts b/desktop/src/main/services/ml-worker.ts index 7112b5be78..1a1f5cd0d7 100644 --- a/desktop/src/main/services/ml-worker.ts +++ b/desktop/src/main/services/ml-worker.ts @@ -95,7 +95,6 @@ const parseInitData = (data: unknown) => { if ( data && typeof data == "object" && - "userDataPateh" in data && "userDataPath" in data && typeof data.userDataPath == "string" ) { From daed8a72dad89118d7467ebb4f37afd5740d6e6a Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Wed, 31 Jul 2024 10:17:08 +0530 Subject: [PATCH 087/123] Only once --- web/packages/new/photos/services/ml/index.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/web/packages/new/photos/services/ml/index.ts b/web/packages/new/photos/services/ml/index.ts index b903e0188c..511b71e5cc 100644 --- a/web/packages/new/photos/services/ml/index.ts +++ b/web/packages/new/photos/services/ml/index.ts @@ -35,7 +35,7 @@ import { MLWorker } from "./worker"; let _isMLEnabled = false; /** Cached instance of the {@link ComlinkWorker} that wraps our web worker. */ -let _comlinkWorker: ComlinkWorker | undefined; +let _comlinkWorker: Promise> | undefined; /** * Subscriptions to {@link MLStatus}. @@ -52,10 +52,8 @@ let _mlStatusListeners: (() => void)[] = []; let _mlStatusSnapshot: MLStatus | undefined; /** Lazily created, cached, instance of {@link MLWorker}. */ -const worker = async () => { - if (!_comlinkWorker) _comlinkWorker = await createComlinkWorker(); - return _comlinkWorker.remote; -}; +const worker = () => + (_comlinkWorker ??= createComlinkWorker()).then((cw) => cw.remote); const createComlinkWorker = async () => { const electron = ensureElectron(); From a97e01171a1a3068761b95bd1ff46280744354e9 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Wed, 31 Jul 2024 11:27:15 +0530 Subject: [PATCH 088/123] Commit incorrect but original motivations --- desktop/src/main/services/ml-worker.ts | 1 + desktop/src/main/utils/comlink-endpoint.ts | 66 ++++++++++++++++++++++ 2 files changed, 67 insertions(+) create mode 100644 desktop/src/main/utils/comlink-endpoint.ts diff --git a/desktop/src/main/services/ml-worker.ts b/desktop/src/main/services/ml-worker.ts index 1a1f5cd0d7..af5d2add62 100644 --- a/desktop/src/main/services/ml-worker.ts +++ b/desktop/src/main/services/ml-worker.ts @@ -63,6 +63,7 @@ process.parentPort.once("message", (e) => { parseInitData(e.data); const port = ensure(e.ports[0]); + port.on("message", (me: Electron.MessageEvent) => {}); expose( { computeCLIPImageEmbedding, diff --git a/desktop/src/main/utils/comlink-endpoint.ts b/desktop/src/main/utils/comlink-endpoint.ts new file mode 100644 index 0000000000..ee87d4176e --- /dev/null +++ b/desktop/src/main/utils/comlink-endpoint.ts @@ -0,0 +1,66 @@ +import type { Endpoint } from "comlink"; +import type { MessagePortMain } from "electron"; + +/** + * An adaptation of the `nodeEndpoint` function from comlink suitable for use in + * TypeScript with an Electron utility process. + * + * This is an adaption of the + * + * Comlink provides a `nodeEndpoint` [function][1] to allow a Node worker_thread + * to be treated as an {@link Endpoint} and be used with comlink. + * + * The first issue we run into when using it is that this the function is not + * exported as part of the normal comlink.d.ts. Accessing it via this + * [workaround][2] doesn't work for us either since we cannot currently change + * our package type to "module". + * + * We could skirt around that by doing + * + * const nodeEndpoint = require("comlink/dist/umd/node-adapter"); + * + * and silencing tsc and eslint. However, we then run into a different issue: + * the comlink implementation of the adapter adds an extra layer of nesting. + * This line: + * + * eh({ data } as MessageEvent); + * + * Should be + * + * eh(data) + * + * I don't currently know if it is because of an impedance mismatch between + * Node's worker_threads and Electron's UtilityProcesses, or if it is something + * else that I'm doing wrong somewhere else causing this to happen. + * + * To solve both these issues, we create this variant. This also removes the + * need for us to type cast when passing MessagePortMain. + * + * References: + * 1. https://github.com/GoogleChromeLabs/comlink/blob/main/src/node-adapter.ts + * 2. https://github.com/GoogleChromeLabs/comlink/pull/542 + * 3. https://github.com/GoogleChromeLabs/comlink/issues/129 + */ +export const messagePortMainEndpoint = (mp: MessagePortMain): Endpoint => { + const listeners = new WeakMap(); + return { + postMessage: mp.postMessage.bind(mp), + addEventListener: (_, eh) => { + const l = (data: Electron.MessageEvent) => + "handleEvent" in eh + ? eh.handleEvent({ data } as MessageEvent) + : eh(data as unknown as MessageEvent); + mp.on("message", (data) => { + l(data); + }); + listeners.set(eh, l); + }, + removeEventListener: (_, eh) => { + const l = listeners.get(eh); + if (!l) return; + mp.off("message", l); + listeners.delete(eh); + }, + start: mp.start.bind(mp), + }; +}; From 62f723e50cf2ea29753a06b2f7799dec9dbe5539 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Wed, 31 Jul 2024 11:33:18 +0530 Subject: [PATCH 089/123] Adapt --- desktop/src/main/services/ml-worker.ts | 7 +-- desktop/src/main/utils/comlink-endpoint.ts | 50 ++++++---------------- 2 files changed, 15 insertions(+), 42 deletions(-) diff --git a/desktop/src/main/services/ml-worker.ts b/desktop/src/main/services/ml-worker.ts index af5d2add62..e028dd01d5 100644 --- a/desktop/src/main/services/ml-worker.ts +++ b/desktop/src/main/services/ml-worker.ts @@ -15,11 +15,10 @@ import { existsSync } from "fs"; import fs from "node:fs/promises"; import path from "node:path"; import * as ort from "onnxruntime-node"; +import { messagePortMainEndpoint } from "../utils/comlink-endpoint"; import { ensure, wait } from "../utils/common"; import { writeStream } from "../utils/stream"; -const nodeEndpoint = require("comlink/dist/umd/node-adapter"); - /** * We cannot do * @@ -63,7 +62,6 @@ process.parentPort.once("message", (e) => { parseInitData(e.data); const port = ensure(e.ports[0]); - port.on("message", (me: Electron.MessageEvent) => {}); expose( { computeCLIPImageEmbedding, @@ -71,8 +69,7 @@ process.parentPort.once("message", (e) => { detectFaces, computeFaceEmbeddings, }, - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-explicit-any - nodeEndpoint(port as unknown as any), + messagePortMainEndpoint(port), ); // port.on("message", (request) => { // void handleMessageFromRenderer(request.data).then((response) => diff --git a/desktop/src/main/utils/comlink-endpoint.ts b/desktop/src/main/utils/comlink-endpoint.ts index ee87d4176e..4572837306 100644 --- a/desktop/src/main/utils/comlink-endpoint.ts +++ b/desktop/src/main/utils/comlink-endpoint.ts @@ -5,48 +5,24 @@ import type { MessagePortMain } from "electron"; * An adaptation of the `nodeEndpoint` function from comlink suitable for use in * TypeScript with an Electron utility process. * - * This is an adaption of the + * This is an adaption of the following function from comlink: + * https://github.com/GoogleChromeLabs/comlink/blob/main/src/node-adapter.ts * - * Comlink provides a `nodeEndpoint` [function][1] to allow a Node worker_thread - * to be treated as an {@link Endpoint} and be used with comlink. - * - * The first issue we run into when using it is that this the function is not - * exported as part of the normal comlink.d.ts. Accessing it via this - * [workaround][2] doesn't work for us either since we cannot currently change - * our package type to "module". - * - * We could skirt around that by doing - * - * const nodeEndpoint = require("comlink/dist/umd/node-adapter"); - * - * and silencing tsc and eslint. However, we then run into a different issue: - * the comlink implementation of the adapter adds an extra layer of nesting. - * This line: - * - * eh({ data } as MessageEvent); - * - * Should be - * - * eh(data) - * - * I don't currently know if it is because of an impedance mismatch between - * Node's worker_threads and Electron's UtilityProcesses, or if it is something - * else that I'm doing wrong somewhere else causing this to happen. - * - * To solve both these issues, we create this variant. This also removes the - * need for us to type cast when passing MessagePortMain. - * - * References: - * 1. https://github.com/GoogleChromeLabs/comlink/blob/main/src/node-adapter.ts - * 2. https://github.com/GoogleChromeLabs/comlink/pull/542 - * 3. https://github.com/GoogleChromeLabs/comlink/issues/129 + * It has been modified (somewhat hackily) to be useful with an Electron + * MessagePortMain instead of a Node.js worker_thread. Only things that we + * currently need have been made to work as you can see by the abuntant type + * casts. Caveat emptor. */ export const messagePortMainEndpoint = (mp: MessagePortMain): Endpoint => { - const listeners = new WeakMap(); + type NL = EventListenerOrEventListenerObject; + type EL = (data: Electron.MessageEvent) => void; + const listeners = new WeakMap(); return { - postMessage: mp.postMessage.bind(mp), + postMessage: (message, transfer) => { + mp.postMessage(message, transfer as unknown as MessagePortMain[]); + }, addEventListener: (_, eh) => { - const l = (data: Electron.MessageEvent) => + const l: EL = (data) => "handleEvent" in eh ? eh.handleEvent({ data } as MessageEvent) : eh(data as unknown as MessageEvent); From 423f0b6719f9e773f5cd648fd8fc2da65e76dbf8 Mon Sep 17 00:00:00 2001 From: ashilkn Date: Wed, 31 Jul 2024 11:50:10 +0530 Subject: [PATCH 090/123] [mob][auth] Reorder security section --- .../ui/settings/security_section_widget.dart | 21 ++++++++++--------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/auth/lib/ui/settings/security_section_widget.dart b/auth/lib/ui/settings/security_section_widget.dart index c5349afa97..9ba4289a36 100644 --- a/auth/lib/ui/settings/security_section_widget.dart +++ b/auth/lib/ui/settings/security_section_widget.dart @@ -68,16 +68,6 @@ class _SecuritySectionWidgetState extends State { UserService.instance.getUserDetailsV2().ignore(); } children.addAll([ - sectionOptionSpacing, - MenuItemWidget( - captionedTextWidget: CaptionedTextWidget( - title: l10n.passkey, - ), - pressedColor: getEnteColorScheme(context).fillFaint, - trailingIcon: Icons.chevron_right_outlined, - trailingIconIsMuted: true, - onTap: () async => await onPasskeyClick(context), - ), sectionOptionSpacing, MenuItemWidget( captionedTextWidget: CaptionedTextWidget( @@ -104,6 +94,16 @@ class _SecuritySectionWidgetState extends State { ), ), sectionOptionSpacing, + MenuItemWidget( + captionedTextWidget: CaptionedTextWidget( + title: l10n.passkey, + ), + pressedColor: getEnteColorScheme(context).fillFaint, + trailingIcon: Icons.chevron_right_outlined, + trailingIconIsMuted: true, + onTap: () async => await onPasskeyClick(context), + ), + sectionOptionSpacing, MenuItemWidget( captionedTextWidget: CaptionedTextWidget( title: context.l10n.viewActiveSessions, @@ -135,6 +135,7 @@ class _SecuritySectionWidgetState extends State { children.add(sectionOptionSpacing); } children.addAll([ + sectionOptionSpacing, MenuItemWidget( captionedTextWidget: CaptionedTextWidget( title: context.l10n.appLock, From 6ad27a2d42d60e08284423be33cda799805a8751 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Wed, 31 Jul 2024 11:58:22 +0530 Subject: [PATCH 091/123] Cleanup --- desktop/src/main/services/ml-worker.ts | 18 +-- desktop/src/main/services/ml.ts | 3 + .../utils/{comlink-endpoint.ts => comlink.ts} | 2 +- web/packages/base/types/ipc.ts | 64 ++++++++- web/packages/new/photos/services/ml/clip.ts | 9 +- web/packages/new/photos/services/ml/face.ts | 12 +- .../new/photos/services/ml/worker-rpc.ts | 131 ------------------ .../new/photos/services/ml/worker-types.ts | 20 +-- web/packages/new/photos/services/ml/worker.ts | 29 ++-- 9 files changed, 95 insertions(+), 193 deletions(-) rename desktop/src/main/utils/{comlink-endpoint.ts => comlink.ts} (99%) delete mode 100644 web/packages/new/photos/services/ml/worker-rpc.ts diff --git a/desktop/src/main/services/ml-worker.ts b/desktop/src/main/services/ml-worker.ts index e028dd01d5..379a1b1f4a 100644 --- a/desktop/src/main/services/ml-worker.ts +++ b/desktop/src/main/services/ml-worker.ts @@ -15,7 +15,7 @@ import { existsSync } from "fs"; import fs from "node:fs/promises"; import path from "node:path"; import * as ort from "onnxruntime-node"; -import { messagePortMainEndpoint } from "../utils/comlink-endpoint"; +import { messagePortMainEndpoint } from "../utils/comlink"; import { ensure, wait } from "../utils/common"; import { writeStream } from "../utils/stream"; @@ -54,14 +54,12 @@ const log = { const mainProcess = (method: string, param: unknown) => process.parentPort.postMessage({ method, p: param }); -log.debugString( - `Started ML worker process with args ${process.argv.join(" ")}`, -); +log.debugString(`Started ML worker process`); process.parentPort.once("message", (e) => { + // Initialize ourselves with the data we got from our parent. parseInitData(e.data); - - const port = ensure(e.ports[0]); + // Expose an expose( { computeCLIPImageEmbedding, @@ -69,14 +67,8 @@ process.parentPort.once("message", (e) => { detectFaces, computeFaceEmbeddings, }, - messagePortMainEndpoint(port), + messagePortMainEndpoint(ensure(e.ports[0])), ); - // port.on("message", (request) => { - // void handleMessageFromRenderer(request.data).then((response) => - // port.postMessage(response), - // ); - // }); - // port.start(); }); /** diff --git a/desktop/src/main/services/ml.ts b/desktop/src/main/services/ml.ts index 66c699b5d2..05642fc95f 100644 --- a/desktop/src/main/services/ml.ts +++ b/desktop/src/main/services/ml.ts @@ -63,6 +63,9 @@ import log from "../log"; * worker can directly talk to each other! * * Node.js utility process <-> Renderer web worker + * + * The RPC protocol is handled using comlink on both ends. The port itself needs + * to be relayed using `postMessage`. */ export const createMLWorker = (window: BrowserWindow) => { const { port1, port2 } = new MessageChannelMain(); diff --git a/desktop/src/main/utils/comlink-endpoint.ts b/desktop/src/main/utils/comlink.ts similarity index 99% rename from desktop/src/main/utils/comlink-endpoint.ts rename to desktop/src/main/utils/comlink.ts index 4572837306..d2006e795b 100644 --- a/desktop/src/main/utils/comlink-endpoint.ts +++ b/desktop/src/main/utils/comlink.ts @@ -10,7 +10,7 @@ import type { MessagePortMain } from "electron"; * * It has been modified (somewhat hackily) to be useful with an Electron * MessagePortMain instead of a Node.js worker_thread. Only things that we - * currently need have been made to work as you can see by the abuntant type + * currently need have been made to work as you can see by the abundant type * casts. Caveat emptor. */ export const messagePortMainEndpoint = (mp: MessagePortMain): Endpoint => { diff --git a/web/packages/base/types/ipc.ts b/web/packages/base/types/ipc.ts index 4aca8b865d..748490ed58 100644 --- a/web/packages/base/types/ipc.ts +++ b/web/packages/base/types/ipc.ts @@ -335,12 +335,15 @@ export interface Electron { // - ML /** - * Create a new ML session. + * Create a new ML worker. * * This creates a new Node.js utility process, and sets things up so that we * can communicate directly with that utility process using a * {@link MessagePort} that gets posted using "createMLWorker/port". * + * At the other end of that port will be an object that conforms to the + * {@link ElectronMLWorker} interface. + * * For more details about the IPC flow, see: [Note: ML IPC]. * * Note: For simplicity of implementation, we assume that there is at most @@ -535,6 +538,65 @@ export interface Electron { clearPendingUploads: () => Promise; } +/** + * The shape of the object exposed by the Node.js ML worker process on the + * message port that the web layer obtains by doing {@link createMLWorker}. + */ +export interface ElectronMLWorker { + /** + * Return a CLIP embedding of the given image. + * + * See: [Note: Natural language search using CLIP] + * + * The input is a opaque float32 array representing the image. The layout + * and exact encoding of the input is specific to our implementation and the + * ML model (CLIP) we use. + * + * @returns A CLIP embedding (an array of 512 floating point values). + */ + computeCLIPImageEmbedding: (input: Float32Array) => Promise; + + /** + * Return a CLIP embedding of the given image if we already have the model + * downloaded and prepped. If the model is not available return `undefined`. + * + * This differs from the other sibling ML functions in that it doesn't wait + * for the model download to finish. It does trigger a model download, but + * then immediately returns `undefined`. At some future point, when the + * model downloaded finishes, calls to this function will start returning + * the result we seek. + * + * The reason for doing it in this asymmetric way is because CLIP text + * embeddings are used as part of deducing user initiated search results, + * and we don't want to block that interaction on a large network request. + * + * See: [Note: Natural language search using CLIP] + * + * @param text The string whose embedding we want to compute. + * + * @returns A CLIP embedding. + */ + computeCLIPTextEmbeddingIfAvailable: ( + text: string, + ) => Promise; + + /** + * Detect faces in the given image using YOLO. + * + * Both the input and output are opaque binary data whose internal structure + * is specific to our implementation and the model (YOLO) we use. + */ + detectFaces: (input: Float32Array) => Promise; + + /** + * Return a MobileFaceNet embeddings for the given faces. + * + * Both the input and output are opaque binary data whose internal structure + * is specific to our implementation and the model (MobileFaceNet) we use. + */ + computeFaceEmbeddings: (input: Float32Array) => Promise; +} + /** * Errors that have special semantics on the web side. * diff --git a/web/packages/new/photos/services/ml/clip.ts b/web/packages/new/photos/services/ml/clip.ts index eecf7e2209..f6230f2466 100644 --- a/web/packages/new/photos/services/ml/clip.ts +++ b/web/packages/new/photos/services/ml/clip.ts @@ -1,9 +1,8 @@ -import type { Electron } from "@/base/types/ipc"; +import type { Electron, ElectronMLWorker } from "@/base/types/ipc"; import type { ImageBitmapAndData } from "./blob"; import { clipIndexes } from "./db"; import { pixelRGBBicubic } from "./image"; import { dotProduct, norm } from "./math"; -import type { MLWorkerElectron } from "./worker-types"; /** * The version of the CLIP indexing pipeline implemented by the current client. @@ -98,19 +97,19 @@ export type LocalCLIPIndex = CLIPIndex & { * be set to the {@link UploadItem} that was uploaded. This way, we can directly * use the on-disk file instead of needing to download the original from remote. * - * @param electron The {@link MLWorkerElectron} instance that allows us to call + * @param electron The {@link ElectronMLWorker} instance that allows us to call * our Node.js layer to run the ONNX inference. */ export const indexCLIP = async ( image: ImageBitmapAndData, - electron: MLWorkerElectron, + electron: ElectronMLWorker, ): Promise => ({ embedding: await computeEmbedding(image.data, electron), }); const computeEmbedding = async ( imageData: ImageData, - electron: MLWorkerElectron, + electron: ElectronMLWorker, ): Promise => { const clipInput = convertToCLIPInput(imageData); return normalized(await electron.computeCLIPImageEmbedding(clipInput)); diff --git a/web/packages/new/photos/services/ml/face.ts b/web/packages/new/photos/services/ml/face.ts index 910970d3b9..7ecbf06002 100644 --- a/web/packages/new/photos/services/ml/face.ts +++ b/web/packages/new/photos/services/ml/face.ts @@ -7,6 +7,7 @@ // /* eslint-disable @typescript-eslint/no-non-null-assertion */ +import type { ElectronMLWorker } from "@/base/types/ipc"; import type { EnteFile } from "@/new/photos/types/file"; import { Matrix } from "ml-matrix"; import { getSimilarityTransformation } from "similarity-transformation"; @@ -24,7 +25,6 @@ import { warpAffineFloat32List, } from "./image"; import { clamp } from "./math"; -import type { MLWorkerElectron } from "./worker-types"; /** * The version of the face indexing pipeline implemented by the current client. @@ -236,13 +236,13 @@ export interface Box { * * @param image The file's contents. * - * @param electron The {@link MLWorkerElectron} instance that allows us to call + * @param electron The {@link ElectronMLWorker} instance that allows us to call * our Node.js layer to run the ONNX inference. */ export const indexFaces = async ( enteFile: EnteFile, { data: imageData }: ImageBitmapAndData, - electron: MLWorkerElectron, + electron: ElectronMLWorker, ): Promise => ({ width: imageData.width, height: imageData.height, @@ -252,7 +252,7 @@ export const indexFaces = async ( const indexFaces_ = async ( fileID: number, imageData: ImageData, - electron: MLWorkerElectron, + electron: ElectronMLWorker, ): Promise => { const { width, height } = imageData; const imageDimensions = { width, height }; @@ -316,7 +316,7 @@ const indexFaces_ = async ( */ const detectFaces = async ( imageData: ImageData, - electron: MLWorkerElectron, + electron: ElectronMLWorker, ): Promise => { const rect = ({ width, height }: Dimensions) => ({ x: 0, @@ -878,7 +878,7 @@ const mobileFaceNetEmbeddingSize = 192; */ const computeEmbeddings = async ( faceData: Float32Array, - electron: MLWorkerElectron, + electron: ElectronMLWorker, ): Promise => { const outputData = await electron.computeFaceEmbeddings(faceData); diff --git a/web/packages/new/photos/services/ml/worker-rpc.ts b/web/packages/new/photos/services/ml/worker-rpc.ts deleted file mode 100644 index 782c0a3bd3..0000000000 --- a/web/packages/new/photos/services/ml/worker-rpc.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { z } from "zod"; - -/** - * The port used to communicate with the Node.js ML worker process - * - * See: [Note: ML IPC] - * */ -let _port: MessagePort | undefined; - -/** - * Use the given {@link MessagePort} to communicate with the Node.js ML worker - * process. - */ -export const startUsingMessagePort = (port: MessagePort) => { - _port = port; - port.start(); -}; - -/** - * Return a CLIP embedding of the given image. - * - * See: [Note: Natural language search using CLIP] - * - * The input is a opaque float32 array representing the image. The layout - * and exact encoding of the input is specific to our implementation and the - * ML model (CLIP) we use. - * - * @returns A CLIP embedding (an array of 512 floating point values). - */ -export const computeCLIPImageEmbedding = ( - input: Float32Array, -): Promise => - ensureFloat32Array(electronMLWorker("computeCLIPImageEmbedding", input)); - -/** - * Return a CLIP embedding of the given image if we already have the model - * downloaded and prepped. If the model is not available return `undefined`. - * - * This differs from the other sibling ML functions in that it doesn't wait - * for the model download to finish. It does trigger a model download, but - * then immediately returns `undefined`. At some future point, when the - * model downloaded finishes, calls to this function will start returning - * the result we seek. - * - * The reason for doing it in this asymmetric way is because CLIP text - * embeddings are used as part of deducing user initiated search results, - * and we don't want to block that interaction on a large network request. - * - * See: [Note: Natural language search using CLIP] - * - * @param text The string whose embedding we want to compute. - * - * @returns A CLIP embedding. - */ -export const computeCLIPTextEmbeddingIfAvailable = async ( - text: string, -): Promise => - ensureOptionalFloat32Array( - electronMLWorker("computeCLIPTextEmbeddingIfAvailable", text), - ); - -/** - * Detect faces in the given image using YOLO. - * - * Both the input and output are opaque binary data whose internal structure - * is specific to our implementation and the model (YOLO) we use. - */ -export const detectFaces = (input: Float32Array): Promise => - ensureFloat32Array(electronMLWorker("detectFaces", input)); - -/** - * Return a MobileFaceNet embeddings for the given faces. - * - * Both the input and output are opaque binary data whose internal structure - * is specific to our implementation and the model (MobileFaceNet) we use. - */ -export const computeFaceEmbeddings = ( - input: Float32Array, -): Promise => - ensureFloat32Array(electronMLWorker("computeFaceEmbeddings", input)); - -const ensureFloat32Array = async ( - pu: Promise, -): Promise => { - const u = await pu; - if (u instanceof Float32Array) return u; - throw new Error(`Expected a Float32Array but instead got ${typeof u}`); -}; - -const ensureOptionalFloat32Array = async ( - pu: Promise, -): Promise => { - const u = await pu; - if (u === undefined) return u; - if (u instanceof Float32Array) return u; - throw new Error(`Expected a Float32Array but instead got ${typeof u}`); -}; - -/** - * Make a call to the ML worker running in the Node.js layer using our - * hand-rolled RPC protocol. See: [Note: Node.js ML worker RPC protocol]. - */ -const electronMLWorker = async (method: string, p: string | Float32Array) => { - const port = _port; - if (!port) { - throw new Error( - "No MessagePort to communicate with Electron ML worker", - ); - } - - // Generate a unique nonce to identify this RPC interaction. - const id = Math.random(); - return new Promise((resolve, reject) => { - const handleMessage = (event: MessageEvent) => { - const response = RPCResponse.parse(event.data); - if (response.id != id) return; - port.removeEventListener("message", handleMessage); - const error = response.error; - if (error) reject(new Error(error)); - else resolve(response.result); - }; - port.addEventListener("message", handleMessage); - port.postMessage({ id, method, p }); - }); -}; - -const RPCResponse = z.object({ - id: z.number(), - result: z.any().optional(), - error: z.string().optional(), -}); diff --git a/web/packages/new/photos/services/ml/worker-types.ts b/web/packages/new/photos/services/ml/worker-types.ts index 1eb43933a3..a83a215ea4 100644 --- a/web/packages/new/photos/services/ml/worker-types.ts +++ b/web/packages/new/photos/services/ml/worker-types.ts @@ -1,22 +1,8 @@ /** - * @file Type for the objects shared (as a Comlink proxy) by the main thread and - * the ML worker. + * @file Types for the objects shared (as a Comlink proxy) by the main thread + * and the ML worker. */ -/** - * A subset of {@link Electron} provided to the {@link MLWorker}. - * - * `globalThis.electron` does not exist in the execution context of web workers. - * So instead, we manually provide a proxy object of type - * {@link MLWorkerElectron} that exposes a subset of the functions from - * {@link Electron} that are needed by the code running in the ML web worker. - */ -export interface MLWorkerElectron { - detectFaces: (input: Float32Array) => Promise; - computeFaceEmbeddings: (input: Float32Array) => Promise; - computeCLIPImageEmbedding: (input: Float32Array) => Promise; -} - /** * Callbacks invoked by the worker at various points in the indexing pipeline to * notify the main thread of events it might be interested in. @@ -25,7 +11,7 @@ export interface MLWorkerDelegate { /** * Called whenever a file is processed during indexing. * - * It is called both when the indexing was successful or failed. + * It is called both when the indexing was successful or it failed. */ workerDidProcessFile: () => void; } diff --git a/web/packages/new/photos/services/ml/worker.ts b/web/packages/new/photos/services/ml/worker.ts index d27f3021f4..e137531f6d 100644 --- a/web/packages/new/photos/services/ml/worker.ts +++ b/web/packages/new/photos/services/ml/worker.ts @@ -3,6 +3,7 @@ import { isHTTP4xxError } from "@/base/http"; import { getKVN } from "@/base/kv"; import { ensureAuthToken } from "@/base/local-user"; import log from "@/base/log"; +import type { ElectronMLWorker } from "@/base/types/ipc"; import type { EnteFile } from "@/new/photos/types/file"; import { fileLogID } from "@/new/photos/utils/file"; import { ensure } from "@/utils/ensure"; @@ -32,8 +33,7 @@ import { type RemoteDerivedData, } from "./embedding"; import { faceIndexingVersion, indexFaces, type FaceIndex } from "./face"; -import { startUsingMessagePort } from "./worker-rpc"; -import type { MLWorkerDelegate, MLWorkerElectron } from "./worker-types"; +import type { MLWorkerDelegate } from "./worker-types"; const idleDurationStart = 5; /* 5 seconds */ const idleDurationMax = 16 * 60; /* 16 minutes */ @@ -47,11 +47,6 @@ interface IndexableItem { remoteDerivedData: RemoteDerivedData | undefined; } -globalThis.onmessage = (event: MessageEvent) => { - if (event.data == "createMLWorker/port") - startUsingMessagePort(ensure(event.ports[0])); -}; - /** * Run operations related to machine learning (e.g. indexing) in a Web Worker. * @@ -75,7 +70,7 @@ globalThis.onmessage = (event: MessageEvent) => { * - "idle": in between state transitions. */ export class MLWorker { - private electron: MLWorkerElectron | undefined; + private electron: ElectronMLWorker | undefined; private delegate: MLWorkerDelegate | undefined; private state: "idle" | "indexing" = "idle"; private liveQ: IndexableItem[] = []; @@ -88,20 +83,16 @@ export class MLWorker { * This is conceptually the constructor, however it is easier to have this * as a separate function to avoid complicating the comlink types further. * - * @param electron The {@link MLWorkerElectron} that allows the worker to - * use the functionality provided by our Node.js layer when running in the - * context of our desktop app. + * @param port A {@link MessagePort} that allows us to communicate with an + * Electron utility process running in the Node.js layer of our desktop app, + * exposing an object that conforms to the {@link ElectronMLWorker} + * interface. * * @param delegate The {@link MLWorkerDelegate} the worker can use to inform * the main thread of interesting events. */ async init(port: MessagePort, delegate: MLWorkerDelegate) { - // this.electron = electron; - this.electron = wrap(port); /* mlWorkerElectron = { - detectFaces: electron.detectFaces, - computeFaceEmbeddings: electron.computeFaceEmbeddings, - computeCLIPImageEmbedding: electron.computeCLIPImageEmbedding, - };*/ + this.electron = wrap(port); this.delegate = delegate; // Initialize the downloadManager running in the web worker with the // user's token. It'll be used to download files to index if needed. @@ -267,7 +258,7 @@ expose(MLWorker); */ const indexNextBatch = async ( items: IndexableItem[], - electron: MLWorkerElectron, + electron: ElectronMLWorker, delegate: MLWorkerDelegate | undefined, ) => { // Don't try to index if we wouldn't be able to upload them anyway. The @@ -385,7 +376,7 @@ const syncWithLocalFilesAndGetFilesToIndex = async ( */ const index = async ( { enteFile, uploadItem, remoteDerivedData }: IndexableItem, - electron: MLWorkerElectron, + electron: ElectronMLWorker, ) => { const f = fileLogID(enteFile); const fileID = enteFile.id; From e55a7facc37ac874ceaf019ba006046f2a1d0e67 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Wed, 31 Jul 2024 12:11:53 +0530 Subject: [PATCH 092/123] Replace our homebrew RPC --- desktop/src/main/ipc.ts | 19 ------------------- desktop/src/main/services/ml-worker.ts | 3 ++- web/apps/photos/src/services/logout.ts | 2 +- web/packages/new/photos/services/ml/index.ts | 16 +++++++--------- 4 files changed, 10 insertions(+), 30 deletions(-) diff --git a/desktop/src/main/ipc.ts b/desktop/src/main/ipc.ts index fd6325c5cc..6b837d8b4d 100644 --- a/desktop/src/main/ipc.ts +++ b/desktop/src/main/ipc.ts @@ -182,25 +182,6 @@ export const attachIPCHandlers = () => { ) => ffmpegExec(command, dataOrPathOrZipItem, outputFileExtension), ); - // - ML - - // TODO: - // ipcMain.handle("computeCLIPImageEmbedding", (_, input: Float32Array) => - // computeCLIPImageEmbedding(input), - // ); - - // ipcMain.handle("computeCLIPTextEmbeddingIfAvailable", (_, text: string) => - // computeCLIPTextEmbeddingIfAvailable(text), - // ); - - // ipcMain.handle("detectFaces", (_, input: Float32Array) => - // detectFaces(input), - // ); - - // ipcMain.handle("computeFaceEmbeddings", (_, input: Float32Array) => - // computeFaceEmbeddings(input), - // ); - // - Upload ipcMain.handle("listZipItems", (_, zipPath: string) => diff --git a/desktop/src/main/services/ml-worker.ts b/desktop/src/main/services/ml-worker.ts index 379a1b1f4a..48f819c8bf 100644 --- a/desktop/src/main/services/ml-worker.ts +++ b/desktop/src/main/services/ml-worker.ts @@ -59,7 +59,8 @@ log.debugString(`Started ML worker process`); process.parentPort.once("message", (e) => { // Initialize ourselves with the data we got from our parent. parseInitData(e.data); - // Expose an + // Expose an instance of `ElectronMLWorker` on the port we got from our + // parent. expose( { computeCLIPImageEmbedding, diff --git a/web/apps/photos/src/services/logout.ts b/web/apps/photos/src/services/logout.ts index 9722757689..ab4b1aa6c5 100644 --- a/web/apps/photos/src/services/logout.ts +++ b/web/apps/photos/src/services/logout.ts @@ -22,7 +22,7 @@ export const photosLogout = async () => { // See: [Note: Caching IDB instances in separate execution contexts]. try { - terminateMLWorker(); + await terminateMLWorker(); } catch (e) { ignoreError("face", e); } diff --git a/web/packages/new/photos/services/ml/index.ts b/web/packages/new/photos/services/ml/index.ts index 511b71e5cc..5c114c0eb1 100644 --- a/web/packages/new/photos/services/ml/index.ts +++ b/web/packages/new/photos/services/ml/index.ts @@ -69,12 +69,10 @@ const createComlinkWorker = async () => { new Worker(new URL("worker.ts", import.meta.url)), ); - await cw.remote.then((w) => { - // Pass the message port to our web worker. - // cw.worker.postMessage("createMLWorker/port", [messagePort]); - // Initialize it. - return w.init(transfer(messagePort, [messagePort]), proxy(delegate)); - }); + await cw.remote.then((w) => + // Forward the port to the web worker. + w.init(transfer(messagePort, [messagePort]), proxy(delegate)), + ); return cw; }; @@ -88,9 +86,9 @@ const createComlinkWorker = async () => { * * It is also called when the user pauses or disables ML. */ -export const terminateMLWorker = () => { +export const terminateMLWorker = async () => { if (_comlinkWorker) { - _comlinkWorker.terminate(); + await _comlinkWorker.then((cw) => cw.terminate()); _comlinkWorker = undefined; } }; @@ -188,7 +186,7 @@ export const disableML = async () => { await updateIsMLEnabledRemote(false); setIsMLEnabledLocal(false); _isMLEnabled = false; - terminateMLWorker(); + await terminateMLWorker(); triggerStatusUpdate(); }; From 5a3838be342ac2bb9656b67bb6a27b12c2c3b0ea Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Wed, 31 Jul 2024 12:30:15 +0530 Subject: [PATCH 093/123] Route via workers --- web/apps/photos/src/services/searchService.ts | 5 ++-- web/packages/new/photos/services/ml/clip.ts | 20 ++++------------ web/packages/new/photos/services/ml/index.ts | 17 +++++++++++++ .../new/photos/services/ml/worker-types.ts | 14 +++++++++-- web/packages/new/photos/services/ml/worker.ts | 24 +++++++++++++++---- 5 files changed, 56 insertions(+), 24 deletions(-) diff --git a/web/apps/photos/src/services/searchService.ts b/web/apps/photos/src/services/searchService.ts index 8b90652e64..750a1fb186 100644 --- a/web/apps/photos/src/services/searchService.ts +++ b/web/apps/photos/src/services/searchService.ts @@ -1,13 +1,12 @@ import { isDesktop } from "@/base/app"; -import { ensureElectron } from "@/base/electron"; import log from "@/base/log"; import { FileType } from "@/media/file-type"; import { + clipMatches, isMLEnabled, isMLSupported, mlStatusSnapshot, } from "@/new/photos/services/ml"; -import { clipMatches } from "@/new/photos/services/ml/clip"; import type { Person } from "@/new/photos/services/ml/people"; import { EnteFile } from "@/new/photos/types/file"; import * as chrono from "chrono-node"; @@ -374,7 +373,7 @@ const searchClip = async ( searchPhrase: string, ): Promise => { if (!isMLEnabled()) return undefined; - const matches = await clipMatches(searchPhrase, ensureElectron()); + const matches = await clipMatches(searchPhrase); log.debug(() => ["clip/scores", matches]); return matches; }; diff --git a/web/packages/new/photos/services/ml/clip.ts b/web/packages/new/photos/services/ml/clip.ts index f6230f2466..78eff1c04d 100644 --- a/web/packages/new/photos/services/ml/clip.ts +++ b/web/packages/new/photos/services/ml/clip.ts @@ -1,8 +1,9 @@ -import type { Electron, ElectronMLWorker } from "@/base/types/ipc"; +import type { ElectronMLWorker } from "@/base/types/ipc"; import type { ImageBitmapAndData } from "./blob"; import { clipIndexes } from "./db"; import { pixelRGBBicubic } from "./image"; import { dotProduct, norm } from "./math"; +import type { CLIPMatches } from "./worker-types"; /** * The version of the CLIP indexing pipeline implemented by the current client. @@ -166,26 +167,15 @@ const normalized = (embedding: Float32Array) => { }; /** - * Use CLIP to perform a natural language search over image embeddings. - * - * @param searchPhrase The text entered by the user in the search box. - * - * @param electron The {@link Electron} instance to use to communicate with the - * native code running in our desktop app (the embedding happens in the native - * layer). - * - * It returns file (IDs) that should be shown in the search results. They're - * returned as a map from fileIDs to the scores they got (higher is better). - * This map will only contains entries whose score was above our minimum - * threshold. + * Find the files whose CLIP embedding "matches" the given {@link searchPhrase}. * * The result can also be `undefined`, which indicates that the download for the * ML model is still in progress (trying again later should succeed). */ export const clipMatches = async ( searchPhrase: string, - electron: Electron, -): Promise | undefined> => { + electron: ElectronMLWorker, +): Promise => { const t = await electron.computeCLIPTextEmbeddingIfAvailable(searchPhrase); if (!t) return undefined; diff --git a/web/packages/new/photos/services/ml/index.ts b/web/packages/new/photos/services/ml/index.ts index 5c114c0eb1..ba8083ad74 100644 --- a/web/packages/new/photos/services/ml/index.ts +++ b/web/packages/new/photos/services/ml/index.ts @@ -19,6 +19,7 @@ import type { UploadItem } from "../upload/types"; import { regenerateFaceCrops } from "./crop"; import { clearMLDB, faceIndex, indexableAndIndexedCounts } from "./db"; import { MLWorker } from "./worker"; +import type { CLIPMatches } from "./worker-types"; /** * In-memory flag that tracks if ML is enabled. @@ -392,6 +393,22 @@ const setInterimScheduledStatus = () => { const workerDidProcessFile = throttled(updateMLStatusSnapshot, 2000); +/** + * Use CLIP to perform a natural language search over image embeddings. + * + * @param searchPhrase The text entered by the user in the search box. + * + * It returns file (IDs) that should be shown in the search results, along with + * their scores. + * + * The result can also be `undefined`, which indicates that the download for the + * ML model is still in progress (trying again later should succeed). + */ +export const clipMatches = ( + searchPhrase: string, +): Promise => + worker().then((w) => w.clipMatches(searchPhrase)); + /** * Return the IDs of all the faces in the given {@link enteFile} that are not * associated with a person cluster. diff --git a/web/packages/new/photos/services/ml/worker-types.ts b/web/packages/new/photos/services/ml/worker-types.ts index a83a215ea4..72d6bce61b 100644 --- a/web/packages/new/photos/services/ml/worker-types.ts +++ b/web/packages/new/photos/services/ml/worker-types.ts @@ -1,6 +1,5 @@ /** - * @file Types for the objects shared (as a Comlink proxy) by the main thread - * and the ML worker. + * @file Types for the objects shared between the main thread and the ML worker. */ /** @@ -15,3 +14,14 @@ export interface MLWorkerDelegate { */ workerDidProcessFile: () => void; } + +/** + * The result of file ids that should be considered as matches for a particular + * search phrase, each with their associated score. + * + * This is a map of file (IDs) that should be shown in the search results. + * They're returned as a map from fileIDs to the scores they got (higher is + * better). This map will only contains entries whose score was above our + * minimum threshold. + */ +export type CLIPMatches = Map; diff --git a/web/packages/new/photos/services/ml/worker.ts b/web/packages/new/photos/services/ml/worker.ts index e137531f6d..cbeba5f844 100644 --- a/web/packages/new/photos/services/ml/worker.ts +++ b/web/packages/new/photos/services/ml/worker.ts @@ -19,7 +19,12 @@ import { indexableBlobs, type ImageBitmapAndData, } from "./blob"; -import { clipIndexingVersion, indexCLIP, type CLIPIndex } from "./clip"; +import { + clipIndexingVersion, + clipMatches, + indexCLIP, + type CLIPIndex, +} from "./clip"; import { saveFaceCrops } from "./crop"; import { indexableFileIDs, @@ -33,7 +38,7 @@ import { type RemoteDerivedData, } from "./embedding"; import { faceIndexingVersion, indexFaces, type FaceIndex } from "./face"; -import type { MLWorkerDelegate } from "./worker-types"; +import type { CLIPMatches, MLWorkerDelegate } from "./worker-types"; const idleDurationStart = 5; /* 5 seconds */ const idleDurationMax = 16 * 60; /* 16 minutes */ @@ -68,6 +73,9 @@ interface IndexableItem { * - "backfillq": fetching remote embeddings of unindexed items, and then * indexing them if needed, * - "idle": in between state transitions. + * + * In addition, MLWorker can also be invoked for interactive tasks: in + * particular, for finding the closest CLIP match when the user does a search. */ export class MLWorker { private electron: ElectronMLWorker | undefined; @@ -178,6 +186,13 @@ export class MLWorker { return this.state == "indexing"; } + /** + * Find {@link CLIPMatches} for a given {@link searchPhrase}. + */ + async clipMatches(searchPhrase: string): Promise { + return clipMatches(searchPhrase, ensure(this.electron)); + } + private async tick() { log.debug(() => [ "ml/tick", @@ -226,7 +241,7 @@ export class MLWorker { } /** Return the next batch of items to backfill (if any). */ - async backfillQ() { + private async backfillQ() { const userID = ensure(await getKVN("userID")); // Find files that our local DB thinks need syncing. const filesByID = await syncWithLocalFilesAndGetFilesToIndex( @@ -278,7 +293,8 @@ const indexNextBatch = async ( try { await index(item, electron); delegate?.workerDidProcessFile(); - // Possibly unnecessary, but let us drain the microtask queue. + // Let us drain the microtask queue. This also gives a chance for other + // interactive tasks like `clipMatches` to run. await wait(0); } catch (e) { log.warn(`Skipping unindexable file ${item.enteFile.id}`, e); From 3a5843f5324c0813d6e2d9a47dd6d73563397bbc Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Wed, 31 Jul 2024 12:34:30 +0530 Subject: [PATCH 094/123] tail --- web/packages/new/photos/services/ml/blob.ts | 13 +++++++------ web/packages/new/photos/utils/native-stream.ts | 7 +++---- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/web/packages/new/photos/services/ml/blob.ts b/web/packages/new/photos/services/ml/blob.ts index 015dc7462d..d52772b6a4 100644 --- a/web/packages/new/photos/services/ml/blob.ts +++ b/web/packages/new/photos/services/ml/blob.ts @@ -1,4 +1,5 @@ import { basename } from "@/base/file"; +import type { ElectronMLWorker } from "@/base/types/ipc"; import { FileType } from "@/media/file-type"; import { decodeLivePhoto } from "@/media/live-photo"; import { ensure } from "@/utils/ensure"; @@ -7,7 +8,6 @@ import { renderableImageBlob } from "../../utils/file"; import { readStream } from "../../utils/native-stream"; import DownloadManager from "../download"; import type { UploadItem } from "../upload/types"; -import type { MLWorkerElectron } from "./worker-types"; /** * A pair of blobs - the original, and a possibly converted "renderable" one - @@ -103,13 +103,14 @@ export const imageBitmapAndData = async ( * be set to the {@link UploadItem} that was uploaded. This way, we can directly * use the on-disk file instead of needing to download the original from remote. * - * @param electron The {@link MLWorkerElectron} instance that allows us to call - * our Node.js layer for various functionality. + * @param electron The {@link ElectronMLWorker} instance that stands as a + * witness that we're actually running in our desktop app (and thus can safely + * call our Node.js layer for various functionality). */ export const indexableBlobs = async ( enteFile: EnteFile, uploadItem: UploadItem | undefined, - electron: MLWorkerElectron, + electron: ElectronMLWorker, ): Promise => uploadItem ? await indexableUploadItemBlobs(enteFile, uploadItem, electron) @@ -118,7 +119,7 @@ export const indexableBlobs = async ( const indexableUploadItemBlobs = async ( enteFile: EnteFile, uploadItem: UploadItem, - electron: MLWorkerElectron, + electron: ElectronMLWorker, ) => { const fileType = enteFile.metadata.fileType; let originalImageBlob: Blob | undefined; @@ -149,7 +150,7 @@ const indexableUploadItemBlobs = async ( */ const readNonVideoUploadItem = async ( uploadItem: UploadItem, - electron: MLWorkerElectron, + electron: ElectronMLWorker, ): Promise => { if (typeof uploadItem == "string" || Array.isArray(uploadItem)) { const { response, lastModifiedMs } = await readStream( diff --git a/web/packages/new/photos/utils/native-stream.ts b/web/packages/new/photos/utils/native-stream.ts index 6f61656597..6aee016c0c 100644 --- a/web/packages/new/photos/utils/native-stream.ts +++ b/web/packages/new/photos/utils/native-stream.ts @@ -6,8 +6,7 @@ * See: [Note: IPC streams]. */ -import type { Electron, ZipItem } from "@/base/types/ipc"; -import type { MLWorkerElectron } from "../services/ml/worker-types"; +import type { Electron, ElectronMLWorker, ZipItem } from "@/base/types/ipc"; /** * Stream the given file or zip entry from the user's local file system. @@ -18,7 +17,7 @@ import type { MLWorkerElectron } from "../services/ml/worker-types"; * * To avoid accidentally invoking it in a non-desktop app context, it requires * the {@link Electron} (or a functionally similar) object as a parameter (even - * though it doesn't use it). + * though it doesn't need or use it). * * @param pathOrZipItem Either the path on the file on the user's local file * system whose contents we want to stream. Or a tuple containing the path to a @@ -36,7 +35,7 @@ import type { MLWorkerElectron } from "../services/ml/worker-types"; * reading, expressed as epoch milliseconds. */ export const readStream = async ( - _: Electron | MLWorkerElectron, + _: Electron | ElectronMLWorker, pathOrZipItem: string | ZipItem, ): Promise<{ response: Response; size: number; lastModifiedMs: number }> => { let url: URL; From 192e491acbe8ca81cf3fdefd2aaf2bbdf2556b8b Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Wed, 31 Jul 2024 12:46:02 +0530 Subject: [PATCH 095/123] Match the documented behaviour --- web/packages/new/photos/services/ml/worker.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/web/packages/new/photos/services/ml/worker.ts b/web/packages/new/photos/services/ml/worker.ts index cbeba5f844..5a3719d983 100644 --- a/web/packages/new/photos/services/ml/worker.ts +++ b/web/packages/new/photos/services/ml/worker.ts @@ -292,14 +292,14 @@ const indexNextBatch = async ( for (const item of items) { try { await index(item, electron); - delegate?.workerDidProcessFile(); - // Let us drain the microtask queue. This also gives a chance for other - // interactive tasks like `clipMatches` to run. - await wait(0); } catch (e) { log.warn(`Skipping unindexable file ${item.enteFile.id}`, e); allSuccess = false; } + delegate?.workerDidProcessFile(); + // Let us drain the microtask queue. This also gives a chance for other + // interactive tasks like `clipMatches` to run. + await wait(0); } // Return true if nothing failed. From 4647f9fac21847e7e9c95e94abce1232ec62c460 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Wed, 31 Jul 2024 12:47:52 +0530 Subject: [PATCH 096/123] Undup and scope --- web/packages/new/photos/services/ml/worker.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/web/packages/new/photos/services/ml/worker.ts b/web/packages/new/photos/services/ml/worker.ts index 5a3719d983..8ccd190cbb 100644 --- a/web/packages/new/photos/services/ml/worker.ts +++ b/web/packages/new/photos/services/ml/worker.ts @@ -292,8 +292,7 @@ const indexNextBatch = async ( for (const item of items) { try { await index(item, electron); - } catch (e) { - log.warn(`Skipping unindexable file ${item.enteFile.id}`, e); + } catch { allSuccess = false; } delegate?.workerDidProcessFile(); @@ -493,8 +492,12 @@ const index = async ( throw e; } - if (originalImageBlob && exif) - await cmpNewLib2(enteFile, originalImageBlob, exif); + try { + if (originalImageBlob && exif) + await cmpNewLib2(enteFile, originalImageBlob, exif); + } catch (e) { + log.warn(`Skipping exif cmp for ${f}`, e); + } log.debug(() => { const ms = Date.now() - startTime; From ebbb9a61ee1e2bc03146416a8c260d0043a319ed Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Wed, 31 Jul 2024 12:55:48 +0530 Subject: [PATCH 097/123] Don't fail on exif errors --- web/packages/new/photos/services/ml/worker.ts | 43 ++++++++++++++++--- 1 file changed, 36 insertions(+), 7 deletions(-) diff --git a/web/packages/new/photos/services/ml/worker.ts b/web/packages/new/photos/services/ml/worker.ts index 8ccd190cbb..5de8c08390 100644 --- a/web/packages/new/photos/services/ml/worker.ts +++ b/web/packages/new/photos/services/ml/worker.ts @@ -11,7 +11,7 @@ import { wait } from "@/utils/promise"; import { DOMParser } from "@xmldom/xmldom"; import { expose, wrap } from "comlink"; import downloadManager from "../download"; -import { cmpNewLib2, extractRawExif } from "../exif"; +import { cmpNewLib2, extractRawExif, type RawExifTags } from "../exif"; import { getAllLocalFiles, getLocalTrashedFiles } from "../files"; import type { UploadItem } from "../upload/types"; import { @@ -35,6 +35,7 @@ import { import { fetchDerivedData, putDerivedData, + type RawRemoteDerivedData, type RemoteDerivedData, } from "./embedding"; import { faceIndexingVersion, indexFaces, type FaceIndex } from "./face"; @@ -480,10 +481,7 @@ const index = async ( [faceIndex, clipIndex, exif] = await Promise.all([ existingFaceIndex ?? indexFaces(enteFile, image, electron), existingCLIPIndex ?? indexCLIP(image, electron), - existingExif ?? - (originalImageBlob - ? extractRawExif(originalImageBlob) - : undefined), + existingExif ?? tryExtractExif(originalImageBlob, f), ]); } catch (e) { // See: [Note: Transient and permanent indexing failures] @@ -525,11 +523,11 @@ const index = async ( // parts. See: [Note: Preserve unknown derived data fields]. const existingRawDerivedData = remoteDerivedData?.raw ?? {}; - const rawDerivedData = { + const rawDerivedData: RawRemoteDerivedData = { ...existingRawDerivedData, face: remoteFaceIndex, clip: remoteCLIPIndex, - exif, + ...(exif ? { exif } : {}), }; log.debug(() => ["Uploading derived data", rawDerivedData]); @@ -571,3 +569,34 @@ const index = async ( image.bitmap.close(); } }; + +/** + * A helper function that tries to extract the raw Exif, but returns `undefined` + * if something goes wrong (or it isn't possible) instead of throwing. + * + * Exif extraction is not a critical item, we don't want the actual indexing to + * fail because we were unable to extract Exif. This is not rare: one scenario + * is if we were trying to index a file in an exotic format. The ML indexing + * will succeed (because we convert it to a renderable blob), but the Exif + * extraction will fail (since it needs the original blob, but the original blob + * can be an arbitrary format). + * + * @param originalImageBlob A {@link Blob} containing the original data for the + * image (or the image component of a live photo) whose Exif we're trying to + * extract. If this is not available, we skip the extraction and return + * `undefined`. + * + * @param f The {@link fileLogID} for the file this blob corresponds to. + */ +export const tryExtractExif = async ( + originalImageBlob: Blob | undefined, + f: string, +): Promise => { + if (!originalImageBlob) return undefined; + try { + return await extractRawExif(originalImageBlob); + } catch (e) { + log.warn(`Ignoring error during Exif extraction for ${f}`, e); + return undefined; + } +}; From ef3231380786b172f14f9a3275087938775ea2ee Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Wed, 31 Jul 2024 14:04:29 +0530 Subject: [PATCH 098/123] x4 --- web/apps/cast/src/services/pair.ts | 3 +- web/packages/new/photos/services/ml/worker.ts | 31 +++++++++++++++---- 2 files changed, 27 insertions(+), 7 deletions(-) diff --git a/web/apps/cast/src/services/pair.ts b/web/apps/cast/src/services/pair.ts index b5646698cc..287122c456 100644 --- a/web/apps/cast/src/services/pair.ts +++ b/web/apps/cast/src/services/pair.ts @@ -82,7 +82,8 @@ export const register = async (): Promise => { // Register keypair with museum to get a pairing code. let pairingCode: string | undefined; - // TODO: eslint has fixed this spurious warning, but we're not on the latest + // [TODO: spurious while(true) eslint warning]. + // eslint has fixed this spurious warning, but we're not on the latest // version yet, so add a disable. // https://github.com/eslint/eslint/pull/18286 /* eslint-disable no-constant-condition */ diff --git a/web/packages/new/photos/services/ml/worker.ts b/web/packages/new/photos/services/ml/worker.ts index 5de8c08390..ca5f112468 100644 --- a/web/packages/new/photos/services/ml/worker.ts +++ b/web/packages/new/photos/services/ml/worker.ts @@ -288,15 +288,34 @@ const indexNextBatch = async ( // Nothing to do. if (items.length == 0) return false; - // Index, keeping track if any of the items failed. + // Keep track if any of the items failed. let allSuccess = true; - for (const item of items) { - try { - await index(item, electron); - } catch { - allSuccess = false; + + // Index up to 4 items simultaneously. + const tasks = new Array | undefined>(4).fill(undefined); + + let i = 0; + while (i < items.length) { + for (let j = 0; j < tasks.length; j++) { + if (!tasks[j]) { + tasks[j] = index(ensure(items[i++]), electron) + .then(() => { + tasks[j] = undefined; + }) + .catch(() => { + allSuccess = false; + tasks[j] = undefined; + }); + } } + + // Wait for at least one to complete (the other runners continue running + // even if one promise reaches the finish line). + await Promise.race(tasks); + + // Let the main thread now we're doing something. delegate?.workerDidProcessFile(); + // Let us drain the microtask queue. This also gives a chance for other // interactive tasks like `clipMatches` to run. await wait(0); From bf6aa5f8406b976114afd54ed8cc1df74510dab2 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Wed, 31 Jul 2024 14:08:16 +0530 Subject: [PATCH 099/123] Fix --- web/packages/new/photos/services/ml/worker.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/packages/new/photos/services/ml/worker.ts b/web/packages/new/photos/services/ml/worker.ts index ca5f112468..12eeaa648a 100644 --- a/web/packages/new/photos/services/ml/worker.ts +++ b/web/packages/new/photos/services/ml/worker.ts @@ -297,7 +297,7 @@ const indexNextBatch = async ( let i = 0; while (i < items.length) { for (let j = 0; j < tasks.length; j++) { - if (!tasks[j]) { + if (i < items.length && !tasks[j]) { tasks[j] = index(ensure(items[i++]), electron) .then(() => { tasks[j] = undefined; From 1b0fe5fd4cca0636050fdadf1b53ecc4308a2483 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Wed, 31 Jul 2024 14:19:25 +0530 Subject: [PATCH 100/123] Tighten timings --- desktop/src/main/services/ml-worker.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/desktop/src/main/services/ml-worker.ts b/desktop/src/main/services/ml-worker.ts index 48f819c8bf..bc788a9fad 100644 --- a/desktop/src/main/services/ml-worker.ts +++ b/desktop/src/main/services/ml-worker.ts @@ -253,10 +253,10 @@ const cachedCLIPImageSession = makeCachedInferenceSession( */ export const computeCLIPImageEmbedding = async (input: Float32Array) => { const session = await cachedCLIPImageSession(); - const t = Date.now(); const feeds = { input: new ort.Tensor("float32", input, [1, 3, 224, 224]), }; + const t = Date.now(); const results = await session.run(feeds); log.debugString(`ONNX/CLIP image embedding took ${Date.now() - t} ms`); /* Need these model specific casts to type the result */ @@ -296,13 +296,13 @@ export const computeCLIPTextEmbeddingIfAvailable = async (text: string) => { } const session = sessionOrSkip; - const t = Date.now(); const tokenizer = getTokenizer(); const tokenizedText = Int32Array.from(tokenizer.encodeForCLIP(text)); const feeds = { input: new ort.Tensor("int32", tokenizedText, [1, 77]), }; + const t = Date.now(); const results = await session.run(feeds); log.debugString(`ONNX/CLIP text embedding took ${Date.now() - t} ms`); return ensure(results.output).data as Float32Array; @@ -318,10 +318,10 @@ const cachedFaceDetectionSession = makeCachedInferenceSession( */ export const detectFaces = async (input: Float32Array) => { const session = await cachedFaceDetectionSession(); - const t = Date.now(); const feeds = { input: new ort.Tensor("float32", input, [1, 3, 640, 640]), }; + const t = Date.now(); const results = await session.run(feeds); log.debugString(`ONNX/YOLO face detection took ${Date.now() - t} ms`); return ensure(results.output).data; @@ -345,8 +345,8 @@ export const computeFaceEmbeddings = async (input: Float32Array) => { const inputTensor = new ort.Tensor("float32", input, [n, z, z, 3]); const session = await cachedFaceEmbeddingSession(); - const t = Date.now(); const feeds = { img_inputs: inputTensor }; + const t = Date.now(); const results = await session.run(feeds); log.debugString(`ONNX/MFNT face embedding took ${Date.now() - t} ms`); /* Need these model specific casts to extract and type the result */ From 59cc01053ae2e16514cbc4d733282ff12596e592 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Wed, 31 Jul 2024 14:35:47 +0530 Subject: [PATCH 101/123] Handle refresh --- desktop/src/main/services/ml.ts | 13 ++++++++++++- web/packages/base/types/ipc.ts | 2 +- web/packages/new/photos/services/ml/index.ts | 19 ++++++++++++------- 3 files changed, 25 insertions(+), 9 deletions(-) diff --git a/desktop/src/main/services/ml.ts b/desktop/src/main/services/ml.ts index 05642fc95f..cc1ae5764c 100644 --- a/desktop/src/main/services/ml.ts +++ b/desktop/src/main/services/ml.ts @@ -11,8 +11,11 @@ import { app, utilityProcess } from "electron/main"; import path from "node:path"; import log from "../log"; +/** The active ML worker (utility) process, if any. */ +let _child: UtilityProcess | undefined; + /** - * Create a new ML worker process. + * Create a new ML worker process, terminating the older ones (if any). * * [Note: ML IPC] * @@ -68,6 +71,12 @@ import log from "../log"; * to be relayed using `postMessage`. */ export const createMLWorker = (window: BrowserWindow) => { + if (_child) { + log.debug(() => "Terminating previous ML worker process"); + _child.kill(); + _child = undefined; + } + const { port1, port2 } = new MessageChannelMain(); const child = utilityProcess.fork(path.join(__dirname, "ml-worker.js")); @@ -77,6 +86,8 @@ export const createMLWorker = (window: BrowserWindow) => { window.webContents.postMessage("createMLWorker/port", undefined, [port2]); handleMessagesFromUtilityProcess(child); + + _child = child; }; /** diff --git a/web/packages/base/types/ipc.ts b/web/packages/base/types/ipc.ts index 748490ed58..c0644760c0 100644 --- a/web/packages/base/types/ipc.ts +++ b/web/packages/base/types/ipc.ts @@ -335,7 +335,7 @@ export interface Electron { // - ML /** - * Create a new ML worker. + * Create a new ML worker, terminating the older ones (if any). * * This creates a new Node.js utility process, and sets things up so that we * can communicate directly with that utility process using a diff --git a/web/packages/new/photos/services/ml/index.ts b/web/packages/new/photos/services/ml/index.ts index ba8083ad74..5b57dade21 100644 --- a/web/packages/new/photos/services/ml/index.ts +++ b/web/packages/new/photos/services/ml/index.ts @@ -98,22 +98,27 @@ export const terminateMLWorker = async () => { * Obtain a port from the Node.js layer that can be used to communicate with the * ML worker process. */ -const createMLWorker = async (electron: Electron): Promise => { - electron.createMLWorker(); - +const createMLWorker = (electron: Electron): Promise => { // The main process will do its thing, and send back the port it created to // us by sending an message on the "createMLWorker/port" channel via the // postMessage API. This roundabout way is needed because MessagePorts // cannot be transferred via the usual send/invoke pattern. - return new Promise((resolve) => { - window.onmessage = ({ source, data, ports }: MessageEvent) => { - // The source check verifies that the message is coming from the + const port = new Promise((resolve) => { + const l = ({ source, data, ports }: MessageEvent) => { + // The source check verifies that the message is coming from our own // preload script. The data is the message that was posted. - if (source == window && data == "createMLWorker/port") + if (source == window && data == "createMLWorker/port") { + window.removeEventListener("message", l); resolve(ensure(ports[0])); + } }; + window.addEventListener("message", l); }); + + electron.createMLWorker(); + + return port; }; /** From 5e055b6039e27dd2b673b92a6905df334f115384 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Wed, 31 Jul 2024 14:44:42 +0530 Subject: [PATCH 102/123] opt unnecessary uploads --- web/packages/new/photos/services/ml/worker.ts | 21 ++++++++++++------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/web/packages/new/photos/services/ml/worker.ts b/web/packages/new/photos/services/ml/worker.ts index 12eeaa648a..37d6dc259e 100644 --- a/web/packages/new/photos/services/ml/worker.ts +++ b/web/packages/new/photos/services/ml/worker.ts @@ -549,15 +549,20 @@ const index = async ( ...(exif ? { exif } : {}), }; - log.debug(() => ["Uploading derived data", rawDerivedData]); + if (existingFaceIndex && existingCLIPIndex && !exif) { + // If we were indexing just for exif, but exif generation didn't + // happen, there is no need to upload. + } else { + log.debug(() => ["Uploading derived data", rawDerivedData]); - try { - await putDerivedData(enteFile, rawDerivedData); - } catch (e) { - // See: [Note: Transient and permanent indexing failures] - log.error(`Failed to put derived data for ${f}`, e); - if (isHTTP4xxError(e)) await markIndexingFailed(enteFile.id); - throw e; + try { + await putDerivedData(enteFile, rawDerivedData); + } catch (e) { + // See: [Note: Transient and permanent indexing failures] + log.error(`Failed to put derived data for ${f}`, e); + if (isHTTP4xxError(e)) await markIndexingFailed(enteFile.id); + throw e; + } } try { From 46cc696ccd957dc8178c3a9ac79ef48a1be84c65 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Wed, 31 Jul 2024 14:52:08 +0530 Subject: [PATCH 103/123] Avoid jargon people might not understand --- .../photos/src/components/Sidebar/AdvancedSettings.tsx | 2 +- web/apps/photos/src/components/Sidebar/Preferences.tsx | 7 +------ web/packages/new/photos/components/MLSettings.tsx | 10 +++++----- web/packages/new/photos/components/MLSettingsBeta.tsx | 2 +- 4 files changed, 8 insertions(+), 13 deletions(-) diff --git a/web/apps/photos/src/components/Sidebar/AdvancedSettings.tsx b/web/apps/photos/src/components/Sidebar/AdvancedSettings.tsx index 5fdcd23852..8980c8ed5f 100644 --- a/web/apps/photos/src/components/Sidebar/AdvancedSettings.tsx +++ b/web/apps/photos/src/components/Sidebar/AdvancedSettings.tsx @@ -82,7 +82,7 @@ export default function AdvancedSettings({ open, onClose, onRootClose }) { } onClick={() => setOpenMLSettings(true)} - label={pt("ML search")} + label={pt("Face and magic search")} /> diff --git a/web/apps/photos/src/components/Sidebar/Preferences.tsx b/web/apps/photos/src/components/Sidebar/Preferences.tsx index 38cccf45de..000a1e44cc 100644 --- a/web/apps/photos/src/components/Sidebar/Preferences.tsx +++ b/web/apps/photos/src/components/Sidebar/Preferences.tsx @@ -85,14 +85,9 @@ export default function Preferences({ open, onClose, onRootClose }) { } onClick={() => setOpenMLSettings(true)} - label={pt("ML search")} + label={pt("Face and magic search")} /> - )} diff --git a/web/packages/new/photos/components/MLSettings.tsx b/web/packages/new/photos/components/MLSettings.tsx index 7e2d53f872..2e5a0c6def 100644 --- a/web/packages/new/photos/components/MLSettings.tsx +++ b/web/packages/new/photos/components/MLSettings.tsx @@ -124,7 +124,7 @@ export const MLSettings: React.FC = ({ {component} @@ -305,7 +305,7 @@ const ManageML: React.FC = ({ let status: string; switch (phase) { case "indexing": - status = pt("Indexing"); + status = pt("Running"); break; case "scheduled": status = pt("Scheduled"); @@ -319,9 +319,9 @@ const ManageML: React.FC = ({ const confirmDisableML = () => { setDialogBoxAttributesV2({ - title: pt("Disable ML search"), + title: pt("Disable face and magic search"), content: pt( - "Do you want to disable ML search on all your devices?", + "Do you want to disable face and magic search on all your devices?", ), close: { text: t("cancel") }, proceed: { @@ -356,7 +356,7 @@ const ManageML: React.FC = ({ justifyContent={"space-between"} > - {pt("Status")} + {pt("Indexing")} {status} diff --git a/web/packages/new/photos/components/MLSettingsBeta.tsx b/web/packages/new/photos/components/MLSettingsBeta.tsx index db1b83da11..2f9bae19f4 100644 --- a/web/packages/new/photos/components/MLSettingsBeta.tsx +++ b/web/packages/new/photos/components/MLSettingsBeta.tsx @@ -42,7 +42,7 @@ export const MLSettingsBeta: React.FC = ({ From 154fffd620226a144e364f8e4ccadbabf6437435 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Wed, 31 Jul 2024 15:06:13 +0530 Subject: [PATCH 104/123] Clean unused --- desktop/src/main/ipc.ts | 1 - desktop/src/main/services/ml-worker.ts | 40 -------------------------- desktop/src/preload.ts | 18 +----------- 3 files changed, 1 insertion(+), 58 deletions(-) diff --git a/desktop/src/main/ipc.ts b/desktop/src/main/ipc.ts index 6b837d8b4d..6c4020d6ee 100644 --- a/desktop/src/main/ipc.ts +++ b/desktop/src/main/ipc.ts @@ -44,7 +44,6 @@ import { import { convertToJPEG, generateImageThumbnail } from "./services/image"; import { logout } from "./services/logout"; import { createMLWorker } from "./services/ml"; - import { encryptionKey, lastShownChangelogVersion, diff --git a/desktop/src/main/services/ml-worker.ts b/desktop/src/main/services/ml-worker.ts index bc788a9fad..f4b9221f64 100644 --- a/desktop/src/main/services/ml-worker.ts +++ b/desktop/src/main/services/ml-worker.ts @@ -95,46 +95,6 @@ const parseInitData = (data: unknown) => { } }; -/** - * Our hand-rolled RPC handler and router - the Node.js utility process end. - * - * Sibling of the electronMLWorker function (in `ml/worker.ts`) in the web code. - * - * [Note: Node.js ML worker RPC protocol] - * - * - Each RPC call (i.e. request message) has a "method" (string), "id" - * (number) and "p" (arbitrary param). - * - * - Each RPC result (i.e. response message) has an "id" (number) that is the - * same as the "id" for the request which it corresponds to. - * - * - If the RPC call was a success, then the response messege will have an - * "result" (arbitrary result) property. Otherwise it will have a "error" - * (string) property describing what went wrong. - */ -export const handleMessageFromRenderer = (m: unknown) => { - if (m && typeof m == "object" && "method" in m && "id" in m && "p" in m) { - const id = m.id; - // const p = m.p; - try { - switch (m.method) { - case "foo": - // if (p && typeof p == "string") - // return { id, result: await foo(p) }; - break; - } - } catch (e) { - return { id, error: e instanceof Error ? e.message : String(e) }; - } - return { id, error: "Unknown message" }; - } - - // We don't even have an "id", so at least log it lest the renderer also - // ignore the "id"-less response. - log.info("Ignoring unknown message", m); - return { error: "Unknown message" }; -}; - /** * Return a function that can be used to trigger a download of the specified * model, and the creating of an ONNX inference session initialized using it. diff --git a/desktop/src/preload.ts b/desktop/src/preload.ts index 5bd2f28987..5b83e441ad 100644 --- a/desktop/src/preload.ts +++ b/desktop/src/preload.ts @@ -202,24 +202,12 @@ const createMLWorker = () => { ipcRenderer.send("createMLWorker"); ipcRenderer.on("createMLWorker/port", (event) => { void windowLoaded.then(() => { - // "*"" is the origin + // "*"" is the origin to send to. window.postMessage("createMLWorker/port", "*", event.ports); }); }); }; -const computeCLIPImageEmbedding = (input: Float32Array) => - ipcRenderer.invoke("computeCLIPImageEmbedding", input); - -const computeCLIPTextEmbeddingIfAvailable = (text: string) => - ipcRenderer.invoke("computeCLIPTextEmbeddingIfAvailable", text); - -const detectFaces = (input: Float32Array) => - ipcRenderer.invoke("detectFaces", input); - -const computeFaceEmbeddings = (input: Float32Array) => - ipcRenderer.invoke("computeFaceEmbeddings", input); - // - Watch const watchGet = () => ipcRenderer.invoke("watchGet"); @@ -385,10 +373,6 @@ contextBridge.exposeInMainWorld("electron", { // - ML createMLWorker, - computeCLIPImageEmbedding, - computeCLIPTextEmbeddingIfAvailable, - detectFaces, - computeFaceEmbeddings, // - Watch From 0bc360c55caa88cb153420e6ff7a413faba6c0d0 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Wed, 31 Jul 2024 15:21:52 +0530 Subject: [PATCH 105/123] Add link --- desktop/src/preload.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/desktop/src/preload.ts b/desktop/src/preload.ts index 5b83e441ad..3058a6376f 100644 --- a/desktop/src/preload.ts +++ b/desktop/src/preload.ts @@ -314,8 +314,11 @@ const clearPendingUploads = () => ipcRenderer.invoke("clearPendingUploads"); * operation when it happens across threads. * https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Transferable_objects * - * In our case though, we're not dealing with threads but separate processes. So - * the ArrayBuffer will be copied: + * In our case though, we're not dealing with threads but separate processes. + * Electron currently only supports transferring MessagePorts: + * https://github.com/electron/electron/issues/34905 + * + * So the ArrayBuffer will be copied: * * > "parameters, errors and return values are **copied** when they're sent over * > the bridge". From d52ea49a96ffaf5776d5fe8f3a62511fc9b73857 Mon Sep 17 00:00:00 2001 From: vishnukvmd Date: Mon, 29 Jul 2024 15:42:12 +0530 Subject: [PATCH 106/123] Update plan IDs for pricing-v4 --- server/pkg/utils/billing/billing.go | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/server/pkg/utils/billing/billing.go b/server/pkg/utils/billing/billing.go index 88be8be14d..d5f5f57057 100644 --- a/server/pkg/utils/billing/billing.go +++ b/server/pkg/utils/billing/billing.go @@ -122,14 +122,14 @@ func GetFreePlan() ente.FreePlan { func GetActivePlanIDs() []string { return []string{ - "50gb_monthly", - "200gb_monthly", - "500gb_monthly", - "2000gb_monthly", - "50gb_yearly", - "200gb_yearly", - "500gb_yearly", - "2000gb_yearly", + "50gb_monthly_v4", + "200gb_monthly_v4", + "1000gb_monthly_v4", + "2000gb_monthly_v4", + "50gb_yearly_v4", + "200gb_yearly_v4", + "1000gb_yearly_v4", + "2000gb_yearly_v4", } } From 81d1b15aafb045e1a6d2a942fce657fc9d99950a Mon Sep 17 00:00:00 2001 From: Crowdin Bot Date: Wed, 31 Jul 2024 10:14:24 +0000 Subject: [PATCH 107/123] New Crowdin translations by GitHub Action --- .../base/locales/el-GR/translation.json | 645 ++++++++++++++++++ 1 file changed, 645 insertions(+) create mode 100644 web/packages/base/locales/el-GR/translation.json diff --git a/web/packages/base/locales/el-GR/translation.json b/web/packages/base/locales/el-GR/translation.json new file mode 100644 index 0000000000..6f7b7e85e8 --- /dev/null +++ b/web/packages/base/locales/el-GR/translation.json @@ -0,0 +1,645 @@ +{ + "HERO_SLIDE_1_TITLE": "
Ιδιωτικά αντίγραφα ασφαλείας
για τις αναμνήσεις σας
", + "HERO_SLIDE_1": "Από προεπιλογή κρυπτογραφημένο από άκρο σε άκρο", + "HERO_SLIDE_2_TITLE": "", + "HERO_SLIDE_2": "Σχεδιάστηκε για να επιζήσει", + "HERO_SLIDE_3_TITLE": "
Διαθέσιμο
παντού
", + "HERO_SLIDE_3": "", + "LOGIN": "Σύνδεση", + "SIGN_UP": "Εγγραφή", + "NEW_USER": "Νέος/α στο Ente", + "EXISTING_USER": "Υπάρχων χρήστης", + "ENTER_NAME": "Εισάγετε όνομα", + "PUBLIC_UPLOADER_NAME_MESSAGE": "Προσθέστε ένα όνομα, ώστε οι φίλοι σας να γνωρίζουν ποιον να ευχαριστήσουν για αυτές τις υπέροχες φωτογραφίες!", + "ENTER_EMAIL": "Εισάγετε διεύθυνση ηλ. ταχυδρομείου", + "EMAIL_ERROR": "Εισάγετε μία έγκυρη διεύθυνση ηλ. ταχυδρομείου", + "REQUIRED": "Υποχρεωτικό", + "EMAIL_SENT": "Ο κωδικός επαλήθευσης στάλθηκε στο {{email}}", + "CHECK_INBOX": "Παρακαλώ ελέγξτε τα εισερχόμενά σας (και τα ανεπιθύμητα) για να ολοκληρώσετε την επαλήθευση", + "ENTER_OTT": "Κωδικός επαλήθευσης", + "RESEND_MAIL": "Επαναποστολή κωδικού", + "VERIFY": "Επαλήθευση", + "UNKNOWN_ERROR": "Κάτι πήγε στραβά, παρακαλώ προσπαθήστε ξανά", + "INVALID_CODE": "Μη έγκυρος κωδικός επαλήθευσης", + "EXPIRED_CODE": "Ο κωδικός επαλήθευσης σας έχει λήξει", + "SENDING": "Αποστολή...", + "SENT": "Στάλθηκε!", + "password": "Κωδικόs πρόσβασης", + "link_password_description": "Εισάγετε κωδικό πρόσβασης για να ξεκλειδώσετε το άλμπουμ", + "unlock": "Ξεκλείδωμα", + "SET_PASSPHRASE": "Ορισμός κωδικού πρόσβασης", + "VERIFY_PASSPHRASE": "Σύνδεση", + "INCORRECT_PASSPHRASE": "Λάθος κωδικός πρόσβασης", + "ENTER_ENC_PASSPHRASE": "Εισάγετε έναν κωδικό πρόσβασης που μπορούμε να χρησιμοποιήσουμε για την κρυπτογράφηση των δεδομένων σας", + "PASSPHRASE_DISCLAIMER": "", + "WELCOME_TO_ENTE_HEADING": "Καλώς ήρθατε στο ", + "WELCOME_TO_ENTE_SUBHEADING": "", + "WHERE_YOUR_BEST_PHOTOS_LIVE": "Εκεί όπου υπάρχουν οι καλύτερες φωτογραφίες σας", + "KEY_GENERATION_IN_PROGRESS_MESSAGE": "Δημιουργία κλειδιών κρυπτογράφησης...", + "PASSPHRASE_HINT": "Κωδικόs πρόσβασης", + "CONFIRM_PASSPHRASE": "Επιβεβαίωση κωδικού πρόσβασης", + "REFERRAL_CODE_HINT": "Πώς ακούσατε για το Ente; (προαιρετικό)", + "REFERRAL_INFO": "Δεν παρακολουθούμε τις εγκαταστάσεις εφαρμογών. Θα μας βοηθούσε αν μας λέγατε που μας βρήκατε!", + "PASSPHRASE_MATCH_ERROR": "Οι κωδικοί πρόσβασης δεν ταιριάζουν", + "CREATE_COLLECTION": "Νέο άλμπουμ", + "ENTER_ALBUM_NAME": "Όνομα άλμπουμ", + "CLOSE_OPTION": "Κλείσιμο (Esc)", + "ENTER_FILE_NAME": "Όνομα αρχείου", + "CLOSE": "Κλείσιμο", + "NO": "Όχι", + "NOTHING_HERE": "", + "upload": "Μεταφόρτωση", + "import": "Εισαγωγή", + "ADD_PHOTOS": "Προσθήκη φωτογραφιών", + "ADD_MORE_PHOTOS": "Προσθήκη περισσότερων φωτογραφιών", + "add_photos_one": "Προσθήκη 1 αντικειμένου", + "add_photos_other": "Προσθήκη {{count, number}} αντικειμένων", + "select_photos": "Επιλογή φωτογραφιών", + "FILE_UPLOAD": "Μεταφόρτωση Αρχείου", + "UPLOAD_STAGE_MESSAGE": { + "0": "Προετοιμασία για μεταφόρτωση", + "1": "", + "2": "", + "3": "", + "4": "Ακύρωση των υπολειπόμενων μεταφορτώσεων", + "5": "Το αντίγραφο ασφαλείας ολοκληρώθηκε" + }, + "FILE_NOT_UPLOADED_LIST": "Τα ακόλουθα αρχεία δεν μεταφορτώθηκαν", + "INITIAL_LOAD_DELAY_WARNING": "", + "USER_DOES_NOT_EXIST": "Συγγνώμη, δε βρέθηκε χρήστης με αυτή τη διεύθυνση ηλ. ταχυδρομείου", + "NO_ACCOUNT": "Δεν έχετε λογαριασμό", + "ACCOUNT_EXISTS": "Έχετε ήδη λογαριασμό", + "CREATE": "Δημιουργία", + "DOWNLOAD": "Λήψη", + "DOWNLOAD_OPTION": "Λήψη (D)", + "DOWNLOAD_FAVORITES": "Λήψη αγαπημένων", + "DOWNLOAD_UNCATEGORIZED": "", + "DOWNLOAD_HIDDEN_ITEMS": "Λήψη κρυφών αντικειμένων", + "COPY_OPTION": "Αντιγραφή ως PNG (Ctrl/Cmd - C)", + "TOGGLE_FULLSCREEN": "Εναλλαγή πλήρους οθόνης (F)", + "ZOOM_IN_OUT": "", + "PREVIOUS": "Προηγούμενο (←)", + "NEXT": "Επόμενο (→)", + "title_photos": "Ente Photos", + "title_auth": "Ente Auth", + "title_accounts": "", + "UPLOAD_FIRST_PHOTO": "Μεταφορτώστε την πρώτη σας φωτογραφία", + "IMPORT_YOUR_FOLDERS": "Εισάγετε τους φακέλους σας", + "UPLOAD_DROPZONE_MESSAGE": "", + "WATCH_FOLDER_DROPZONE_MESSAGE": "", + "TRASH_FILES_TITLE": "Διαγραφή αρχείων;", + "TRASH_FILE_TITLE": "Διαγραφή αρχείου;", + "DELETE_FILES_TITLE": "", + "DELETE_FILES_MESSAGE": "Τα επιλεγμένα αρχεία θα διαγραφούν οριστικά από τον Ente λογαριασμό σας.", + "DELETE": "Διαγραφή", + "DELETE_OPTION": "Διαγραφή (DEL)", + "FAVORITE_OPTION": "Αγαπημένο (L)", + "UNFAVORITE_OPTION": "", + "MULTI_FOLDER_UPLOAD": "Εντοπίστηκαν πολλαπλοί φάκελοι", + "UPLOAD_STRATEGY_CHOICE": "", + "UPLOAD_STRATEGY_SINGLE_COLLECTION": "Ένα ενιαίο άλμπουμ", + "OR": "ή", + "UPLOAD_STRATEGY_COLLECTION_PER_FOLDER": "Ξεχωριστά άλμπουμ", + "SESSION_EXPIRED_MESSAGE": "Η συνεδρία σας έληξε, παρακαλούμε συνδεθείτε ξανά για να συνεχίσετε", + "SESSION_EXPIRED": "Η συνεδρία έληξε", + "PASSWORD_GENERATION_FAILED": "", + "CHANGE_PASSWORD": "Αλλαγή κωδικού πρόσβασής", + "password_changed_elsewhere": "", + "password_changed_elsewhere_message": "", + "GO_BACK": "Επιστροφή", + "RECOVERY_KEY": "Κλειδί ανάκτησης", + "SAVE_LATER": "Κάντε το αργότερα", + "SAVE": "Αποθήκευση Κλειδιού", + "RECOVERY_KEY_DESCRIPTION": "Εάν ξεχάσετε τον κωδικό πρόσβασής σας, ο μόνος τρόπος για να ανακτήσετε τα δεδομένα σας είναι με αυτό το κλειδί.", + "RECOVER_KEY_GENERATION_FAILED": "Δεν ήταν δυνατή η δημιουργία κωδικού ανάκτησης, παρακαλώ προσπαθήστε ξανά", + "KEY_NOT_STORED_DISCLAIMER": "", + "FORGOT_PASSWORD": "Ξέχασα τον κωδικό πρόσβασης", + "RECOVER_ACCOUNT": "Ανάκτηση λογαριασμού", + "RECOVERY_KEY_HINT": "Κλειδί ανάκτησης", + "RECOVER": "Ανάκτηση", + "NO_RECOVERY_KEY": "", + "INCORRECT_RECOVERY_KEY": "Εσφαλμένο κλειδί ανάκτησης", + "SORRY": "", + "NO_RECOVERY_KEY_MESSAGE": "", + "NO_TWO_FACTOR_RECOVERY_KEY_MESSAGE": "Παρακαλώ αφήστε ένα μήνυμα ηλ. ταχυδρομείου στο {{emailID}} από την καταχωρημένη διεύθυνση σας", + "CONTACT_SUPPORT": "Επικοινωνήστε με την υποστήριξη", + "REQUEST_FEATURE": "", + "SUPPORT": "Υποστήριξη", + "CONFIRM": "Επιβεβαίωση", + "cancel": "Ακύρωση", + "LOGOUT": "Αποσυνδέση", + "delete_account": "Διαγραφή λογαριασμού", + "delete_account_manually_message": "", + "LOGOUT_MESSAGE": "Είστε σίγουροι ότι θέλετε να αποσυνδεθείτε;", + "CHANGE_EMAIL": "Αλλαγή διεύθυνσης ηλ. ταχυδρομείου", + "OK": "ΟΚ", + "SUCCESS": "Επιτυχία", + "ERROR": "Σφάλμα", + "MESSAGE": "Μήνυμα", + "OFFLINE_MSG": "", + "INSTALL_MOBILE_APP": "", + "DOWNLOAD_APP_MESSAGE": "", + "DOWNLOAD_APP": "Λήψη εφαρμογής για υπολογιστές", + "EXPORT": "Εξαγωγή Δεδομένων", + "SUBSCRIPTION": "Συνδρομή", + "SUBSCRIBE": "Εγγραφείτε", + "MANAGEMENT_PORTAL": "Διαχείριση μεθόδου πληρωμής", + "MANAGE_FAMILY_PORTAL": "Διαχείριση οικογένειας", + "LEAVE_FAMILY_PLAN": "Αποχώρηση απ' το οικογενειακό πρόγραμμα", + "LEAVE": "Αποχώρηση", + "LEAVE_FAMILY_CONFIRM": "Είστε σίγουροι ότι θέλετε να αποχωρήσετε από το οικογενειακό πρόγραμμα;", + "CHOOSE_PLAN": "Επιλέξτε το πρόγραμμά σας", + "MANAGE_PLAN": "Διαχειριστείτε τη συνδρομή σας", + "CURRENT_USAGE": "Η τρέχουσα χρήση είναι {{usage}}", + "TWO_MONTHS_FREE": "Αποκτήστε 2 μήνες δωρεάν στα ετήσια προγράμματα", + "POPULAR": "Δημοφιλές", + "free_plan_option": "Συνέχεια με δωρεάν δοκιμή", + "free_plan_description": "{{storage}} για 1 χρόνο", + "active": "Ενεργό", + "subscription_info_free": "Είστε στο δωρεάν πρόγραμμα που λήγει στις {{date, date}}", + "subscription_info_family": "Είστε στο οικογενειακό πρόγραμμα που διαχειρίζεται από", + "subscription_info_expired": "Η συνδρομή σας έχει λήξει, παρακαλώ ανανεώστε", + "subscription_info_renewal_cancelled": "Η συνδρομή σας θα ακυρωθεί στις {{date, date}}", + "subscription_info_storage_quota_exceeded": "", + "subscription_status_renewal_active": "", + "subscription_status_renewal_cancelled": "", + "add_on_valid_till": "", + "subscription_expired": "", + "storage_quota_exceeded": "", + "SUBSCRIPTION_PURCHASE_SUCCESS": "", + "SUBSCRIPTION_PURCHASE_CANCELLED": "", + "SUBSCRIPTION_PURCHASE_FAILED": "", + "SUBSCRIPTION_UPDATE_FAILED": "", + "UPDATE_PAYMENT_METHOD_MESSAGE": "", + "STRIPE_AUTHENTICATION_FAILED": "", + "UPDATE_PAYMENT_METHOD": "", + "MONTHLY": "Μηνιαία", + "YEARLY": "Ετήσια", + "MONTH_SHORT": "", + "YEAR": "", + "update_subscription_title": "Επιβεβαίωση αλλαγής προγράμματος", + "UPDATE_SUBSCRIPTION_MESSAGE": "Είστε σίγουροι ότι θέλετε να αλλάξετε το πρόγραμμά σας;", + "UPDATE_SUBSCRIPTION": "Αλλαγή προγράμματος", + "CANCEL_SUBSCRIPTION": "Ακύρωση συνδρομής", + "CANCEL_SUBSCRIPTION_MESSAGE": "", + "CANCEL_SUBSCRIPTION_WITH_ADDON_MESSAGE": "", + "SUBSCRIPTION_CANCEL_FAILED": "Αποτυχία ακύρωσης συνδρομής", + "SUBSCRIPTION_CANCEL_SUCCESS": "Η συνδρομή ακυρώθηκε επιτυχώς", + "REACTIVATE_SUBSCRIPTION": "Επανενεργοποίηση συνδρομής", + "REACTIVATE_SUBSCRIPTION_MESSAGE": "", + "SUBSCRIPTION_ACTIVATE_SUCCESS": "", + "SUBSCRIPTION_ACTIVATE_FAILED": "", + "SUBSCRIPTION_PURCHASE_SUCCESS_TITLE": "Ευχαριστούμε", + "CANCEL_SUBSCRIPTION_ON_MOBILE": "", + "CANCEL_SUBSCRIPTION_ON_MOBILE_MESSAGE": "", + "MAIL_TO_MANAGE_SUBSCRIPTION": "Επικοινωνήστε μαζί μας στο {{emailID}} για να διαχειριστείτε τη συνδρομή σας", + "RENAME": "Μετονομασία", + "RENAME_FILE": "Μετονομασία αρχείου", + "RENAME_COLLECTION": "Μετονομασία άλμπουμ", + "DELETE_COLLECTION_TITLE": "Διαγραφή άλμπουμ;", + "DELETE_COLLECTION": "Διαγραφή άλμπουμ", + "DELETE_COLLECTION_MESSAGE": "", + "DELETE_PHOTOS": "Διαγραφή φωτογραφιών", + "KEEP_PHOTOS": "Διατήρηση φωτογραφιών", + "SHARE_COLLECTION": "Κοινοποίηση άλμπουμ", + "SHARE_WITH_SELF": "", + "ALREADY_SHARED": "", + "SHARING_BAD_REQUEST_ERROR": "Δεν επιτρέπεται η κοινοποίηση άλμπουμ", + "SHARING_DISABLED_FOR_FREE_ACCOUNTS": "Η κοινοποίηση είναι απενεργοποιημένη στους δωρεάν λογαριασμούς", + "DOWNLOAD_COLLECTION": "Λήψη άλμπουμ", + "CREATE_ALBUM_FAILED": "Αποτυχία δημιουργίας άλμπουμ, παρακαλώ προσπαθήστε ξανά", + "SEARCH": "Αναζήτηση", + "SEARCH_RESULTS": "Αποτελέσματα αναζήτησης", + "NO_RESULTS": "Δε βρέθηκαν αποτελέσματα", + "SEARCH_HINT": "", + "SEARCH_TYPE": { + "COLLECTION": "Άλμπουμ", + "LOCATION": "Τοποθεσία", + "CITY": "Τοποθεσία", + "DATE": "Ημερομηνία", + "FILE_NAME": "Όνομα αρχείου", + "THING": "Περιεχόμενο", + "FILE_CAPTION": "Περιγραφή", + "FILE_TYPE": "Τύπος αρχείου", + "CLIP": "" + }, + "photos_count_zero": "", + "photos_count_one": "1 ανάμνηση", + "photos_count_other": "{{count, number}} αναμνήσεις", + "TERMS_AND_CONDITIONS": "Συμφωνώ με τους όρους χρήσης και την πολιτική απορρήτου", + "ADD_TO_COLLECTION": "Προσθήκη στο άλμπουμ", + "SELECTED": "", + "PEOPLE": "", + "indexing_scheduled": "", + "indexing_photos": "", + "indexing_people": "", + "indexing_done": "", + "UNIDENTIFIED_FACES": "Απροσδιόριστα πρόσωπα", + "OBJECTS": "αντικείμενα", + "TEXT": "κείμενο", + "INFO": "Πληροφορίες ", + "INFO_OPTION": "Πληροφορίες (I)", + "FILE_NAME": "Όνομα αρχείου", + "CAPTION_PLACEHOLDER": "Προσθήκη περιγραφής", + "LOCATION": "Τοποθεσία", + "SHOW_ON_MAP": "Προβολή στο OpenStreetMap", + "MAP": "Χάρτης", + "MAP_SETTINGS": "Ρυθμίσεις Χάρτη", + "ENABLE_MAPS": "Ενεργοποίηση Χαρτών;", + "ENABLE_MAP": "Ενεργοποίηση χάρτη", + "DISABLE_MAPS": "Απενεργοποίηση Χαρτών;", + "ENABLE_MAP_DESCRIPTION": "", + "DISABLE_MAP_DESCRIPTION": "", + "DISABLE_MAP": "Απενεργοποίηση χάρτη", + "DETAILS": "Λεπτομέρειες", + "view_exif": "Προβολή όλων των δεδομένων Exif", + "no_exif": "Χωρίς δεδομένα Exif", + "exif": "Exif", + "ISO": "ISO", + "TWO_FACTOR": "", + "TWO_FACTOR_AUTHENTICATION": "Αυθεντικοποίηση δύο παραγόντων", + "TWO_FACTOR_QR_INSTRUCTION": "Σαρώστε τον παρακάτω κωδικό QR με την αγαπημένη σας εφαρμογή αυθεντικοποίησης", + "ENTER_CODE_MANUALLY": "Εισάγετε τον κωδικό χειροκίνητα", + "TWO_FACTOR_MANUAL_CODE_INSTRUCTION": "", + "SCAN_QR_CODE": "", + "ENABLE_TWO_FACTOR": "", + "ENABLE": "Ενεργοποίηση", + "LOST_DEVICE": "", + "INCORRECT_CODE": "Εσφαλμένος κωδικός", + "TWO_FACTOR_INFO": "", + "DISABLE_TWO_FACTOR_LABEL": "", + "UPDATE_TWO_FACTOR_LABEL": "", + "DISABLE": "Απενεργοποίηση", + "RECONFIGURE": "", + "UPDATE_TWO_FACTOR": "", + "UPDATE_TWO_FACTOR_MESSAGE": "", + "UPDATE": "Ενημέρωση", + "DISABLE_TWO_FACTOR": "", + "DISABLE_TWO_FACTOR_MESSAGE": "", + "TWO_FACTOR_DISABLE_FAILED": "", + "EXPORT_DATA": "Εξαγωγή δεδομένων", + "select_folder": "Επιλέξτε φάκελο", + "select_zips": "", + "faq": "Συχνές Ερωτήσεις", + "takeout_hint": "", + "DESTINATION": "Προορισμός", + "START": "Εκκίνηση", + "LAST_EXPORT_TIME": "", + "EXPORT_AGAIN": "", + "LOCAL_STORAGE_NOT_ACCESSIBLE": "", + "LOCAL_STORAGE_NOT_ACCESSIBLE_MESSAGE": "", + "SEND_OTT": "", + "EMAIl_ALREADY_OWNED": "", + "ETAGS_BLOCKED": "", + "LIVE_PHOTOS_DETECTED": "", + "RETRY_FAILED": "Επανάληψη αποτυχημένων μεταφορτώσεων", + "FAILED_UPLOADS": "", + "failed_uploads_hint": "", + "SKIPPED_FILES": "", + "THUMBNAIL_GENERATION_FAILED_UPLOADS": "", + "UNSUPPORTED_FILES": "", + "SUCCESSFUL_UPLOADS": "", + "SKIPPED_INFO": "", + "UNSUPPORTED_INFO": "", + "BLOCKED_UPLOADS": "", + "INPROGRESS_METADATA_EXTRACTION": "", + "INPROGRESS_UPLOADS": "", + "TOO_LARGE_UPLOADS": "Μεγάλα αρχεία", + "LARGER_THAN_AVAILABLE_STORAGE_UPLOADS": "Ανεπαρκής χώρος αποθήκευσης", + "LARGER_THAN_AVAILABLE_STORAGE_INFO": "", + "TOO_LARGE_INFO": "", + "THUMBNAIL_GENERATION_FAILED_INFO": "", + "UPLOAD_TO_COLLECTION": "Μεταφόρτωση στο άλμπουμ", + "UNCATEGORIZED": "Χωρίς Κατηγορία", + "ARCHIVE": "Αρχειοθέτηση", + "FAVORITES": "Αγαπημένα", + "ARCHIVE_COLLECTION": "Αρχειοθέτηση άλμπουμ", + "ARCHIVE_SECTION_NAME": "Αρχειοθέτηση", + "ALL_SECTION_NAME": "Όλα", + "MOVE_TO_COLLECTION": "Μετακίνηση στο άλμπουμ", + "UNARCHIVE": "Κατάργηση αρχειοθέτησης", + "UNARCHIVE_COLLECTION": "Κατάργηση αρχειοθέτησης άλμπουμ", + "HIDE_COLLECTION": "Απόκρυψη άλμπουμ", + "UNHIDE_COLLECTION": "Επανεμφάνιση άλμπουμ", + "MOVE": "Μετακίνηση", + "ADD": "Προσθήκη", + "REMOVE": "Αφαίρεση", + "YES_REMOVE": "Ναι, αφαίρεση", + "REMOVE_FROM_COLLECTION": "Αφαίρεση από το άλμπουμ", + "TRASH": "Κάδος", + "MOVE_TO_TRASH": "Μετακίνηση στον κάδο", + "TRASH_FILES_MESSAGE": "", + "TRASH_FILE_MESSAGE": "", + "DELETE_PERMANENTLY": "Οριστική διαγραφή", + "RESTORE": "Επαναφορά", + "RESTORE_TO_COLLECTION": "Επαναφορά στο άλμπουμ", + "EMPTY_TRASH": "Άδειασμα κάδου", + "EMPTY_TRASH_TITLE": "Άδειασμα κάδου;", + "EMPTY_TRASH_MESSAGE": "", + "LEAVE_SHARED_ALBUM": "Ναι, αποχώρηση", + "LEAVE_ALBUM": "Αποχώρηση απ' το άλμπουμ", + "LEAVE_SHARED_ALBUM_TITLE": "", + "LEAVE_SHARED_ALBUM_MESSAGE": "", + "NOT_FILE_OWNER": "", + "CONFIRM_SELF_REMOVE_MESSAGE": "", + "CONFIRM_SELF_AND_OTHER_REMOVE_MESSAGE": "", + "SORT_BY_CREATION_TIME_ASCENDING": "", + "SORT_BY_UPDATION_TIME_DESCENDING": "Τελευταία ενημέρωση", + "SORT_BY_NAME": "Όνομα", + "FIX_CREATION_TIME": "", + "FIX_CREATION_TIME_IN_PROGRESS": "", + "CREATION_TIME_UPDATED": "Ο χρόνος αρχείου ενημερώθηκε", + "UPDATE_CREATION_TIME_NOT_STARTED": "", + "UPDATE_CREATION_TIME_COMPLETED": "Επιτυχής ενημέρωση όλων των αρχείων", + "UPDATE_CREATION_TIME_COMPLETED_WITH_ERROR": "", + "CAPTION_CHARACTER_LIMIT": "", + "DATE_TIME_ORIGINAL": "", + "DATE_TIME_DIGITIZED": "", + "METADATA_DATE": "", + "CUSTOM_TIME": "", + "REOPEN_PLAN_SELECTOR_MODAL": "", + "OPEN_PLAN_SELECTOR_MODAL_FAILED": "", + "INSTALL": "", + "SHARING_DETAILS": "", + "MODIFY_SHARING": "", + "ADD_COLLABORATORS": "", + "ADD_NEW_EMAIL": "", + "shared_with_people_zero": "", + "shared_with_people_one": "", + "shared_with_people_other": "", + "participants_zero": "", + "participants_one": "", + "participants_other": "", + "ADD_VIEWERS": "", + "CHANGE_PERMISSIONS_TO_VIEWER": "", + "CHANGE_PERMISSIONS_TO_COLLABORATOR": "", + "CONVERT_TO_VIEWER": "", + "CONVERT_TO_COLLABORATOR": "", + "CHANGE_PERMISSION": "", + "REMOVE_PARTICIPANT": "", + "CONFIRM_REMOVE": "", + "MANAGE": "", + "ADDED_AS": "", + "COLLABORATOR_RIGHTS": "", + "REMOVE_PARTICIPANT_HEAD": "", + "OWNER": "", + "COLLABORATORS": "", + "ADD_MORE": "", + "VIEWERS": "", + "OR_ADD_EXISTING": "", + "REMOVE_PARTICIPANT_MESSAGE": "", + "NOT_FOUND": "", + "LINK_EXPIRED": "Ο σύνδεσμος έληξε", + "LINK_EXPIRED_MESSAGE": "Αυτός ο σύνδεσμος έχει λήξει ή έχει απενεργοποιηθεί!", + "MANAGE_LINK": "Διαχείριση συνδέσμου", + "LINK_TOO_MANY_REQUESTS": "", + "FILE_DOWNLOAD": "Επιτρέπονται λήψεις", + "link_password_lock": "Κλείδωμα κωδικού πρόσβασης", + "PUBLIC_COLLECT": "Επιτρέπεται η προσθήκη φωτογραφιών", + "LINK_DEVICE_LIMIT": "Όριο συσκευών", + "NO_DEVICE_LIMIT": "Κανένα", + "LINK_EXPIRY": "", + "NEVER": "Ποτέ", + "DISABLE_FILE_DOWNLOAD": "Απενεργοποίηση λήψεων", + "DISABLE_FILE_DOWNLOAD_MESSAGE": "", + "SHARED_USING": "Κοινοποίηση μέσω ", + "SHARING_REFERRAL_CODE": "Χρησιμοποιήστε τον κωδικό {{referralCode}} για να αποκτήσετε 10 GB δωρεάν", + "LIVE": "ΖΩΝΤΑΝΑ", + "DISABLE_PASSWORD": "Απενεργοποίηση κλειδώματος κωδικού πρόσβασης", + "DISABLE_PASSWORD_MESSAGE": "Είστε σίγουροι ότι θέλετε να απενεργοποιήσετε το κλείδωμα κωδικού πρόσβασης;", + "PASSWORD_LOCK": "Κλείδωμα κωδικού πρόσβασης", + "LOCK": "", + "DOWNLOAD_UPLOAD_LOGS": "", + "file": "Αρχείο", + "folder": "Φάκελος", + "google_takeout": "", + "DEDUPLICATE_FILES": "Διαγραφή διπλότυπων αρχείων", + "NO_DUPLICATES_FOUND": "Δεν υπάρχουν διπλότυπα αρχεία που μπορούν να εκκαθαριστούν", + "FILES": "αρχεία", + "EACH": "", + "DEDUPLICATE_BASED_ON_SIZE": "", + "STOP_ALL_UPLOADS_MESSAGE": "", + "STOP_UPLOADS_HEADER": "Διακοπή μεταφορτώσεων;", + "YES_STOP_UPLOADS": "Ναι, διακοπή μεταφορτώσεων", + "STOP_DOWNLOADS_HEADER": "Διακοπή λήψεων;", + "YES_STOP_DOWNLOADS": "Ναι, διακοπή λήψεων", + "STOP_ALL_DOWNLOADS_MESSAGE": "Είστε σίγουροι ότι θέλετε να διακόψετε όλες τις λήψεις σε εξέλιξη;", + "albums_one": "1 Άλμπουμ", + "albums_other": "{{count, number}} Άλμπουμ", + "ALL_ALBUMS": "Όλα τα Άλμπουμ", + "ALBUMS": "Άλμπουμ", + "ALL_HIDDEN_ALBUMS": "Όλα τα κρυφά άλμπουμ", + "HIDDEN_ALBUMS": "Κρυφά άλμπουμ", + "HIDDEN_ITEMS": "Κρυφά αντικείμενα", + "ENTER_TWO_FACTOR_OTP": "", + "CREATE_ACCOUNT": "Δημιουργία λογαριασμού", + "COPIED": "Αντιγράφηκε", + "WATCH_FOLDERS": "", + "upgrade_now": "Αναβάθμιση τώρα", + "renew_now": "Ανανέωση τώρα", + "STORAGE": "Αποθηκευτικός χώρος", + "USED": "", + "YOU": "Εσείς", + "FAMILY": "", + "FREE": "δωρεάν", + "OF": "", + "WATCHED_FOLDERS": "", + "NO_FOLDERS_ADDED": "Κανένας φάκελος δεν προστέθηκε ακόμα!", + "FOLDERS_AUTOMATICALLY_MONITORED": "", + "UPLOAD_NEW_FILES_TO_ENTE": "Μεταφόρτωση νέων αρχείων στο Ente", + "REMOVE_DELETED_FILES_FROM_ENTE": "Αφαίρεση διαγραμμένων αρχείων από το Ente", + "ADD_FOLDER": "Προσθήκη φακέλου", + "STOP_WATCHING": "Διακοπή παρακολούθησης", + "STOP_WATCHING_FOLDER": "Διακοπή παρακολούθησης φακέλου;", + "STOP_WATCHING_DIALOG_MESSAGE": "", + "YES_STOP": "", + "CHANGE_FOLDER": "", + "FAMILY_PLAN": "", + "DOWNLOAD_LOGS": "", + "DOWNLOAD_LOGS_MESSAGE": "", + "WEAK_DEVICE": "", + "drag_and_drop_hint": "", + "AUTHENTICATE": "", + "UPLOADED_TO_SINGLE_COLLECTION": "", + "UPLOADED_TO_SEPARATE_COLLECTIONS": "", + "NEVERMIND": "", + "UPDATE_AVAILABLE": "", + "UPDATE_INSTALLABLE_MESSAGE": "", + "INSTALL_NOW": "", + "INSTALL_ON_NEXT_LAUNCH": "", + "UPDATE_AVAILABLE_MESSAGE": "", + "DOWNLOAD_AND_INSTALL": "", + "IGNORE_THIS_VERSION": "", + "TODAY": "", + "YESTERDAY": "", + "NAME_PLACEHOLDER": "", + "ROOT_LEVEL_FILE_WITH_FOLDER_NOT_ALLOWED": "", + "ROOT_LEVEL_FILE_WITH_FOLDER_NOT_ALLOWED_MESSAGE": "", + "CHOSE_THEME": "", + "more_details": "", + "ENABLE_FACE_SEARCH": "", + "ENABLE_FACE_SEARCH_TITLE": "", + "ENABLE_FACE_SEARCH_DESCRIPTION": "", + "ADVANCED": "", + "FACE_SEARCH_CONFIRMATION": "", + "LABS": "Εργαστήρια", + "YOURS": "", + "passphrase_strength_weak": "Ισχύς κωδικού πρόσβασης: Ασθενής", + "passphrase_strength_moderate": "Ισχύς κωδικού πρόσβασης: Μέτριος", + "passphrase_strength_strong": "Ισχύς κωδικού πρόσβασης: Ισχυρός", + "PREFERENCES": "Προτιμήσεις", + "LANGUAGE": "Γλώσσα", + "EXPORT_DIRECTORY_DOES_NOT_EXIST": "Μη έγκυρος κατάλογος εξαγωγής", + "EXPORT_DIRECTORY_DOES_NOT_EXIST_MESSAGE": "", + "SUBSCRIPTION_VERIFICATION_ERROR": "Η επαλήθευση της συνδρομής απέτυχε", + "storage_unit": { + "b": "Β", + "kb": "ΚΒ", + "mb": "MB", + "gb": "GB", + "tb": "TB" + }, + "AFTER_TIME": { + "HOUR": "μετά από μία ώρα", + "DAY": "μετά από μια μέρα", + "WEEK": "μετά από μία εβδομάδα", + "MONTH": "μετά από ένα μήνα", + "YEAR": "μετά από ένα έτος" + }, + "COPY_LINK": "Αντιγραφή συνδέσμου", + "DONE": "Ολοκληρώθηκε", + "LINK_SHARE_TITLE": "Ή μοιραστείτε έναν σύνδεσμο", + "REMOVE_LINK": "Αφαίρεση συνδέσμου", + "CREATE_PUBLIC_SHARING": "Δημιουργία δημόσιου συνδέσμου", + "PUBLIC_LINK_CREATED": "Ο δημόσιος σύνδεσμος δημιουργήθηκε", + "PUBLIC_LINK_ENABLED": "", + "COLLECT_PHOTOS": "", + "PUBLIC_COLLECT_SUBTEXT": "", + "STOP_EXPORT": "", + "EXPORT_PROGRESS": "", + "MIGRATING_EXPORT": "", + "RENAMING_COLLECTION_FOLDERS": "", + "TRASHING_DELETED_FILES": "", + "TRASHING_DELETED_COLLECTIONS": "", + "CONTINUOUS_EXPORT": "", + "PENDING_ITEMS": "", + "EXPORT_STARTING": "", + "delete_account_reason_label": "", + "delete_account_reason_placeholder": "", + "delete_reason": { + "missing_feature": "", + "behaviour": "", + "found_another_service": "", + "not_listed": "" + }, + "delete_account_feedback_label": "", + "delete_account_feedback_placeholder": "", + "delete_account_confirm_checkbox_label": "", + "delete_account_confirm": "", + "delete_account_confirm_message": "", + "feedback_required": "", + "feedback_required_found_another_service": "", + "RECOVER_TWO_FACTOR": "", + "at": "", + "AUTH_NEXT": "", + "AUTH_DOWNLOAD_MOBILE_APP": "", + "HIDDEN": "", + "HIDE": "", + "UNHIDE": "", + "UNHIDE_TO_COLLECTION": "", + "SORT_BY": "", + "NEWEST_FIRST": "", + "OLDEST_FIRST": "", + "CONVERSION_FAILED_NOTIFICATION_MESSAGE": "", + "SELECT_COLLECTION": "", + "PIN_ALBUM": "", + "UNPIN_ALBUM": "", + "DOWNLOAD_COMPLETE": "", + "DOWNLOADING_COLLECTION": "", + "DOWNLOAD_FAILED": "", + "DOWNLOAD_PROGRESS": "", + "CHRISTMAS": "", + "CHRISTMAS_EVE": "", + "NEW_YEAR": "", + "NEW_YEAR_EVE": "", + "IMAGE": "", + "VIDEO": "", + "LIVE_PHOTO": "", + "editor": { + "crop": "" + }, + "CONVERT": "", + "CONFIRM_EDITOR_CLOSE_MESSAGE": "", + "CONFIRM_EDITOR_CLOSE_DESCRIPTION": "", + "BRIGHTNESS": "", + "CONTRAST": "", + "SATURATION": "", + "BLUR": "", + "INVERT_COLORS": "", + "ASPECT_RATIO": "", + "SQUARE": "", + "ROTATE_LEFT": "", + "ROTATE_RIGHT": "", + "FLIP_VERTICALLY": "", + "FLIP_HORIZONTALLY": "", + "DOWNLOAD_EDITED": "", + "SAVE_A_COPY_TO_ENTE": "", + "RESTORE_ORIGINAL": "", + "TRANSFORM": "", + "COLORS": "", + "FLIP": "", + "ROTATION": "", + "RESET": "", + "PHOTO_EDITOR": "", + "FASTER_UPLOAD": "", + "FASTER_UPLOAD_DESCRIPTION": "", + "CAST_ALBUM_TO_TV": "", + "ENTER_CAST_PIN_CODE": "", + "PAIR_DEVICE_TO_TV": "", + "TV_NOT_FOUND": "", + "AUTO_CAST_PAIR": "", + "AUTO_CAST_PAIR_DESC": "", + "PAIR_WITH_PIN": "", + "CHOOSE_DEVICE_FROM_BROWSER": "", + "PAIR_WITH_PIN_DESC": "", + "VISIT_CAST_ENTE_IO": "", + "CAST_AUTO_PAIR_FAILED": "", + "FREEHAND": "", + "APPLY_CROP": "", + "PHOTO_EDIT_REQUIRED_TO_SAVE": "", + "passkeys": "", + "passkey_fetch_failed": "", + "manage_passkey": "", + "delete_passkey": "", + "delete_passkey_confirmation": "", + "rename_passkey": "", + "add_passkey": "", + "enter_passkey_name": "", + "passkeys_description": "", + "CREATED_AT": "", + "passkey_add_failed": "", + "passkey_login_failed": "", + "passkey_login_invalid_url": "", + "passkey_login_already_claimed_session": "", + "passkey_login_generic_error": "", + "passkey_login_credential_hint": "", + "passkeys_not_supported": "", + "try_again": "", + "check_status": "", + "passkey_login_instructions": "", + "passkey_login": "", + "passkey": "", + "passkey_verify_description": "", + "waiting_for_verification": "", + "verification_still_pending": "", + "passkey_verified": "", + "redirecting_back_to_app": "", + "redirect_close_instructions": "", + "redirect_again": "", + "autogenerated_first_album_name": "", + "autogenerated_default_album_name": "", + "developer_settings": "", + "server_endpoint": "", + "more_information": "", + "save": "" +} From e2f1d7488bb75d5a6e2324681aadd408d7829071 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Wed, 31 Jul 2024 15:47:39 +0530 Subject: [PATCH 108/123] [web] Free forever copy changes --- web/apps/photos/src/components/Sidebar/index.tsx | 9 +-------- web/packages/base/locales/en-US/translation.json | 6 +++--- 2 files changed, 4 insertions(+), 11 deletions(-) diff --git a/web/apps/photos/src/components/Sidebar/index.tsx b/web/apps/photos/src/components/Sidebar/index.tsx index c6fd7a91c8..84cc686d3a 100644 --- a/web/apps/photos/src/components/Sidebar/index.tsx +++ b/web/apps/photos/src/components/Sidebar/index.tsx @@ -289,14 +289,7 @@ const SubscriptionStatus: React.FC = ({ if (!hasAddOnBonus(userDetails.bonusData)) { if (isSubscriptionActive(userDetails.subscription)) { if (isOnFreePlan(userDetails.subscription)) { - message = ( - - ); + message = t("subscription_info_free"); } else if (isSubscriptionCancelled(userDetails.subscription)) { message = t("subscription_info_renewal_cancelled", { date: userDetails.subscription?.expiryTime, diff --git a/web/packages/base/locales/en-US/translation.json b/web/packages/base/locales/en-US/translation.json index 90afeb6af9..1adafa2a79 100644 --- a/web/packages/base/locales/en-US/translation.json +++ b/web/packages/base/locales/en-US/translation.json @@ -153,10 +153,10 @@ "CURRENT_USAGE": "Current usage is {{usage}}", "TWO_MONTHS_FREE": "Get 2 months free on yearly plans", "POPULAR": "Popular", - "free_plan_option": "Continue with free trial", - "free_plan_description": "{{storage}} for 1 year", + "free_plan_option": "Continue with free plan", + "free_plan_description": "{{storage}} free forever", "active": "Active", - "subscription_info_free": "You are on the free plan that expires on {{date, date}}", + "subscription_info_free": "You are on a free plan", "subscription_info_family": "You are on a family plan managed by", "subscription_info_expired": "Your subscription has expired, please renew", "subscription_info_renewal_cancelled": "Your subscription will be cancelled on {{date, date}}", From 03805b6e752f75783627edcfe0f2b20d8cf04ab7 Mon Sep 17 00:00:00 2001 From: Crowdin Bot Date: Wed, 31 Jul 2024 10:23:46 +0000 Subject: [PATCH 109/123] New Crowdin translations by GitHub Action --- web/packages/base/locales/de-DE/translation.json | 6 +++--- web/packages/base/locales/el-GR/translation.json | 6 +++--- web/packages/base/locales/es-ES/translation.json | 6 +++--- web/packages/base/locales/fr-FR/translation.json | 6 +++--- web/packages/base/locales/id-ID/translation.json | 4 ++-- web/packages/base/locales/it-IT/translation.json | 4 ++-- web/packages/base/locales/nl-NL/translation.json | 6 +++--- web/packages/base/locales/pl-PL/translation.json | 6 +++--- web/packages/base/locales/pt-BR/translation.json | 6 +++--- web/packages/base/locales/ru-RU/translation.json | 6 +++--- web/packages/base/locales/zh-CN/translation.json | 6 +++--- 11 files changed, 31 insertions(+), 31 deletions(-) diff --git a/web/packages/base/locales/de-DE/translation.json b/web/packages/base/locales/de-DE/translation.json index 08367238d8..4c6877426f 100644 --- a/web/packages/base/locales/de-DE/translation.json +++ b/web/packages/base/locales/de-DE/translation.json @@ -153,10 +153,10 @@ "CURRENT_USAGE": "Aktuelle Nutzung ist {{usage}}", "TWO_MONTHS_FREE": "Erhalte 2 Monate kostenlos bei Jahresabonnements", "POPULAR": "Beliebt", - "free_plan_option": "Mit kostenloser Testversion fortfahren", - "free_plan_description": "{{storage}} für 1 Jahr", + "free_plan_option": "", + "free_plan_description": "", "active": "Aktiv", - "subscription_info_free": "Du bist auf dem kostenlosen Plan, der am {{date, date}} ausläuft", + "subscription_info_free": "", "subscription_info_family": "Sie haben einen Familienplan verwaltet von", "subscription_info_expired": "Dein Abonnement ist abgelaufen, bitte erneuere es", "subscription_info_renewal_cancelled": "Ihr Abo endet am {{date, date}}", diff --git a/web/packages/base/locales/el-GR/translation.json b/web/packages/base/locales/el-GR/translation.json index 6f7b7e85e8..9006614f98 100644 --- a/web/packages/base/locales/el-GR/translation.json +++ b/web/packages/base/locales/el-GR/translation.json @@ -153,10 +153,10 @@ "CURRENT_USAGE": "Η τρέχουσα χρήση είναι {{usage}}", "TWO_MONTHS_FREE": "Αποκτήστε 2 μήνες δωρεάν στα ετήσια προγράμματα", "POPULAR": "Δημοφιλές", - "free_plan_option": "Συνέχεια με δωρεάν δοκιμή", - "free_plan_description": "{{storage}} για 1 χρόνο", + "free_plan_option": "", + "free_plan_description": "", "active": "Ενεργό", - "subscription_info_free": "Είστε στο δωρεάν πρόγραμμα που λήγει στις {{date, date}}", + "subscription_info_free": "", "subscription_info_family": "Είστε στο οικογενειακό πρόγραμμα που διαχειρίζεται από", "subscription_info_expired": "Η συνδρομή σας έχει λήξει, παρακαλώ ανανεώστε", "subscription_info_renewal_cancelled": "Η συνδρομή σας θα ακυρωθεί στις {{date, date}}", diff --git a/web/packages/base/locales/es-ES/translation.json b/web/packages/base/locales/es-ES/translation.json index cb10eca6b2..3831ea2a4b 100644 --- a/web/packages/base/locales/es-ES/translation.json +++ b/web/packages/base/locales/es-ES/translation.json @@ -153,10 +153,10 @@ "CURRENT_USAGE": "El uso actual es {{usage}}", "TWO_MONTHS_FREE": "Obtén 2 meses gratis en planes anuales", "POPULAR": "Popular", - "free_plan_option": "Continuar con el plan gratuito", - "free_plan_description": "{{storage}} por 1 año", + "free_plan_option": "", + "free_plan_description": "", "active": "Activo", - "subscription_info_free": "Estás en el plan gratis que expira el {{date, date}}", + "subscription_info_free": "", "subscription_info_family": "Estás en un plan familiar administrado por", "subscription_info_expired": "Tu suscripción ha caducado, por favor renuévala", "subscription_info_renewal_cancelled": "Tu suscripción será cancelada el {{date, date}}", diff --git a/web/packages/base/locales/fr-FR/translation.json b/web/packages/base/locales/fr-FR/translation.json index 2b2ab078e9..241424f558 100644 --- a/web/packages/base/locales/fr-FR/translation.json +++ b/web/packages/base/locales/fr-FR/translation.json @@ -153,10 +153,10 @@ "CURRENT_USAGE": "L'utilisation actuelle est de {{usage}}", "TWO_MONTHS_FREE": "Obtenir 2 mois gratuits sur les plans annuels", "POPULAR": "Populaire", - "free_plan_option": "Poursuivre avec la version d'essai gratuite", - "free_plan_description": "{{storage}} pour 1 an", + "free_plan_option": "", + "free_plan_description": "", "active": "Actif", - "subscription_info_free": "Vous êtes sur le plan gratuit qui expire le {{date, date}}", + "subscription_info_free": "", "subscription_info_family": "Vous êtes sur le plan famille géré par", "subscription_info_expired": "Votre abonnement a expiré, veuillez le renouveler ", "subscription_info_renewal_cancelled": "Votre abonnement sera annulé le {{date, date}}", diff --git a/web/packages/base/locales/id-ID/translation.json b/web/packages/base/locales/id-ID/translation.json index 7d51437564..7a3dfdcf77 100644 --- a/web/packages/base/locales/id-ID/translation.json +++ b/web/packages/base/locales/id-ID/translation.json @@ -153,10 +153,10 @@ "CURRENT_USAGE": "Pemakaian saat ini sebesar {{usage}}", "TWO_MONTHS_FREE": "Dapatkan 2 bulan gratis dengan paket tahunan", "POPULAR": "", - "free_plan_option": "Lanjut dengan percobaan gratis", + "free_plan_option": "", "free_plan_description": "", "active": "Aktif", - "subscription_info_free": "Kamu sedang menggunakan paket gratis yang akan berakhir pada {{date, date}}", + "subscription_info_free": "", "subscription_info_family": "Kamu menggunakan paket keluarga yang diatur oleh", "subscription_info_expired": "Langganan kamu telah kedaluwarsa, silakan perpanjang", "subscription_info_renewal_cancelled": "Langganan kamu akan dibatalkan pada {{date, date}}", diff --git a/web/packages/base/locales/it-IT/translation.json b/web/packages/base/locales/it-IT/translation.json index b9d63884c4..32d22dff1d 100644 --- a/web/packages/base/locales/it-IT/translation.json +++ b/web/packages/base/locales/it-IT/translation.json @@ -154,9 +154,9 @@ "TWO_MONTHS_FREE": "Ottieni 2 mesi gratis sui piani annuali", "POPULAR": "", "free_plan_option": "", - "free_plan_description": "{{storage}} per 1 anno", + "free_plan_description": "", "active": "Attivo", - "subscription_info_free": "Sei sul piano gratuito che scade il {{date, date}}", + "subscription_info_free": "", "subscription_info_family": "Fai parte di un piano famiglia gestito da", "subscription_info_expired": "Il tuo abbonamento è scaduto, per favore rinnova", "subscription_info_renewal_cancelled": "Il tuo abbonamento verrà annullato il {{date, date}}", diff --git a/web/packages/base/locales/nl-NL/translation.json b/web/packages/base/locales/nl-NL/translation.json index dc87afbe87..f96710c822 100644 --- a/web/packages/base/locales/nl-NL/translation.json +++ b/web/packages/base/locales/nl-NL/translation.json @@ -153,10 +153,10 @@ "CURRENT_USAGE": "Huidig gebruik is {{usage}}", "TWO_MONTHS_FREE": "Krijg 2 maanden gratis op jaarlijkse abonnementen", "POPULAR": "Populair", - "free_plan_option": "Doorgaan met gratis account", - "free_plan_description": "{{storage}} voor 1 jaar", + "free_plan_option": "", + "free_plan_description": "", "active": "Actief", - "subscription_info_free": "Je hebt het gratis abonnement dat verloopt op {{date, date}}", + "subscription_info_free": "", "subscription_info_family": "U hebt een familieplan dat beheerd wordt door", "subscription_info_expired": "Uw abonnement is verlopen, gelieve vernieuwen", "subscription_info_renewal_cancelled": "Uw abonnement loopt af op {{date, date}}", diff --git a/web/packages/base/locales/pl-PL/translation.json b/web/packages/base/locales/pl-PL/translation.json index 04e2fe0556..f31afd6139 100644 --- a/web/packages/base/locales/pl-PL/translation.json +++ b/web/packages/base/locales/pl-PL/translation.json @@ -153,10 +153,10 @@ "CURRENT_USAGE": "Bieżące użycie to {{usage}}", "TWO_MONTHS_FREE": "Otrzymaj 2 miesiące za darmo na rocznych planach", "POPULAR": "Popularne", - "free_plan_option": "Kontynuuj bezpłatny okres próbny", - "free_plan_description": "{{storage}} na 1 rok", + "free_plan_option": "", + "free_plan_description": "", "active": "Aktywne", - "subscription_info_free": "Jesteś na bezpłatnym planie, który wygasa {{date, date}}", + "subscription_info_free": "", "subscription_info_family": "Jesteś na planie rodzinnym zarządzanym przez", "subscription_info_expired": "Twoja subskrypcja wygasła, prosimy o odnowienie", "subscription_info_renewal_cancelled": "Twoja subskrypcja zostanie anulowana dnia {{date, date}}", diff --git a/web/packages/base/locales/pt-BR/translation.json b/web/packages/base/locales/pt-BR/translation.json index ce0185ce32..4c92e0b1ff 100644 --- a/web/packages/base/locales/pt-BR/translation.json +++ b/web/packages/base/locales/pt-BR/translation.json @@ -153,10 +153,10 @@ "CURRENT_USAGE": "O uso atual é {{usage}}", "TWO_MONTHS_FREE": "Obtenha 2 meses gratuitos em planos anuais", "POPULAR": "Popular", - "free_plan_option": "Continuar com teste gratuito", - "free_plan_description": "{{storage}} por 1 ano", + "free_plan_option": "", + "free_plan_description": "", "active": "Ativo", - "subscription_info_free": "Você está no plano gratuito que expira em {{date, date}}", + "subscription_info_free": "", "subscription_info_family": "Você está em um plano familiar gerenciado por", "subscription_info_expired": "Sua assinatura expirou, por favor renove-a", "subscription_info_renewal_cancelled": "Sua assinatura será cancelada em {{date, date}}", diff --git a/web/packages/base/locales/ru-RU/translation.json b/web/packages/base/locales/ru-RU/translation.json index d971e36b76..daaf4c479e 100644 --- a/web/packages/base/locales/ru-RU/translation.json +++ b/web/packages/base/locales/ru-RU/translation.json @@ -153,10 +153,10 @@ "CURRENT_USAGE": "Текущее использование составляет {{usage}}", "TWO_MONTHS_FREE": "Получите 2 месяца бесплатно по годовым планам", "POPULAR": "Популярный", - "free_plan_option": "Продолжайте пользоваться бесплатной пробной версией", - "free_plan_description": "{{storage}} на 1 год", + "free_plan_option": "", + "free_plan_description": "", "active": "Активный", - "subscription_info_free": "Вы используете бесплатный тарифный план, истекающий {{date, date}}", + "subscription_info_free": "", "subscription_info_family": "Вы используете семейный план, управляемый", "subscription_info_expired": "Срок действия вашей подписки истек, пожалуйста, продлите", "subscription_info_renewal_cancelled": "Ваша подписка будет отменена в {{date, date}}", diff --git a/web/packages/base/locales/zh-CN/translation.json b/web/packages/base/locales/zh-CN/translation.json index ec6fb597c3..85e8639a84 100644 --- a/web/packages/base/locales/zh-CN/translation.json +++ b/web/packages/base/locales/zh-CN/translation.json @@ -153,10 +153,10 @@ "CURRENT_USAGE": "当前使用量是 {{usage}}", "TWO_MONTHS_FREE": "在年度计划上免费获得 2 个月", "POPULAR": "流行的", - "free_plan_option": "继续免费试用", - "free_plan_description": "{{storage}} 1年", + "free_plan_option": "", + "free_plan_description": "", "active": "已激活", - "subscription_info_free": "您使用的是将于{{date, date}} 过期的免费计划", + "subscription_info_free": "", "subscription_info_family": "您当前使用的计划由下列人员管理", "subscription_info_expired": "您的订阅已过期,请 续期", "subscription_info_renewal_cancelled": "您的订阅将于 {{date, date}} 取消", From af90bfade7431adac558e2f106773182b1ce296c Mon Sep 17 00:00:00 2001 From: vishnukvmd Date: Wed, 31 Jul 2024 19:33:35 +0530 Subject: [PATCH 110/123] Update pricing faq --- docs/docs/photos/faq/subscription.md | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/docs/docs/photos/faq/subscription.md b/docs/docs/photos/faq/subscription.md index 53cc634710..814249cbf2 100644 --- a/docs/docs/photos/faq/subscription.md +++ b/docs/docs/photos/faq/subscription.md @@ -152,10 +152,7 @@ you can gain more value out of a single subscription. ## Is there a forever-free plan? -Sorry, since we're building a business that does not involve monetization of -user data, we have to charge to remain sustainable. - -We do offer a generous free trial for you to experience the product. +Yes, we offer 5 GB of storage for free. ## Will I need to pay for Ente Auth after my Ente Photos free plan expires? From c4103f91364bf4cbe87a31e0c2ad82ac212ac1a3 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Thu, 1 Aug 2024 09:49:54 +0530 Subject: [PATCH 111/123] Restore the pull scaffolding Partially reverts 61b98a99646fc064ba54f51495e0acc3b6c4e5c0 --- web/packages/new/photos/services/ml/worker.ts | 68 ++++++++++++++++--- 1 file changed, 58 insertions(+), 10 deletions(-) diff --git a/web/packages/new/photos/services/ml/worker.ts b/web/packages/new/photos/services/ml/worker.ts index 37d6dc259e..970fcac02d 100644 --- a/web/packages/new/photos/services/ml/worker.ts +++ b/web/packages/new/photos/services/ml/worker.ts @@ -64,15 +64,16 @@ interface IndexableItem { * * ext. event state then state * ------------- --------------- -------------- + * sync -> "pull" -> "idle" * sync -> "backfillq" -> "idle" * upload -> "liveq" -> "idle" * idleTimeout -> "backfillq" -> "idle" * * where: * + * - "pull": pull existing embeddings from remote. * - "liveq": indexing items that are being uploaded, - * - "backfillq": fetching remote embeddings of unindexed items, and then - * indexing them if needed, + * - "backfillq": index unindexed items otherwise. * - "idle": in between state transitions. * * In addition, MLWorker can also be invoked for interactive tasks: in @@ -81,7 +82,9 @@ interface IndexableItem { export class MLWorker { private electron: ElectronMLWorker | undefined; private delegate: MLWorkerDelegate | undefined; - private state: "idle" | "indexing" = "idle"; + private state: "idle" | "pull" | "indexing" = "idle"; + private shouldPull = false; + private havePulledAtLeastOnce = false; private liveQ: IndexableItem[] = []; private idleTimeout: ReturnType | undefined; private idleDuration = idleDurationStart; /* unit: seconds */ @@ -127,14 +130,23 @@ export class MLWorker { } /** - * Start backfilling if needed. + * Pull embeddings from remote, and start backfilling if needed. * - * This function enqueues a backfill attempt and returns immediately without - * waiting for it complete. During a backfill, it will first attempt to - * fetch embeddings for files which don't have that data locally. If we - * fetch and find what we need, we save it locally. Otherwise we index them. + * This function enqueues a pull and returns immediately without waiting for + * the pull to complete. + * + * Once the pull is done, it then schedules a backfill. So calling this also + * implicitly triggers a backfill (which is why we call it a less-precise + * "sync" instead of "pull"). + * + * During a backfill we will first attempt to fetch embeddings for files + * which don't have that data locally. If we fetch and find what we need, we + * save it locally. Otherwise we index them. This the pull upfront is not + * necessary, but it helps a new client get up to speed faster since it can + * fetch all existing embeddings first before getting down to the indexing. */ sync() { + this.shouldPull = true; this.wakeUp(); } @@ -200,18 +212,49 @@ export class MLWorker { { state: this.state, liveQ: this.liveQ, + shouldPull: this.shouldPull, idleDuration: this.idleDuration, }, ]); const scheduleTick = () => void setTimeout(() => this.tick(), 0); + // If we've been asked to pull, do that first (before indexing). + if (this.shouldPull) { + // Allow this flag to be reset while we're pulling (triggering + // another pull when we tick next). + this.shouldPull = false; + this.state = "pull"; + try { + const didPull = await pull(); + // Mark that we completed one attempt at pulling successfully + // (irrespective of whether or not that got us some data). + this.havePulledAtLeastOnce = true; + // Reset the idle duration if we did pull something. + if (didPull) this.idleDuration = idleDurationStart; + } catch (e) { + log.error("Failed to pull embeddings", e); + } + // Tick again, even if we got an error. + // + // While the backfillQ won't be processed until at least a pull has + // happened once (`havePulledAtLeastOnce`), the liveQ can still be + // processed since these are new files without remote embeddings. + scheduleTick(); + return; + } + const liveQ = this.liveQ; this.liveQ = []; this.state = "indexing"; - // Use the liveQ if present, otherwise get the next batch to backfill. - const items = liveQ.length > 0 ? liveQ : await this.backfillQ(); + // Use the liveQ if present, otherwise get the next batch to backfill, + // but only after we've pulled once from remote successfully. + const items = liveQ.length + ? liveQ + : this.havePulledAtLeastOnce + ? await this.backfillQ() + : []; const allSuccess = await indexNextBatch( items, @@ -263,6 +306,11 @@ export class MLWorker { expose(MLWorker); +// eslint-disable-next-line @typescript-eslint/require-await +const pull = async () => { + return ""; +}; + /** * Find out files which need to be indexed. Then index the next batch of them. * From c369db9453808d48b90cfbd6b32e2216cfaa15b2 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Thu, 1 Aug 2024 10:12:40 +0530 Subject: [PATCH 112/123] Impl handler for /embeddings/indexed-files https://github.com/ente-io/ente/pull/2511/ --- .../new/photos/services/ml/embedding.ts | 51 ++++++++++++++++++- web/packages/new/photos/services/ml/worker.ts | 5 ++ 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/web/packages/new/photos/services/ml/embedding.ts b/web/packages/new/photos/services/ml/embedding.ts index 6da4e765d1..16d3adaec9 100644 --- a/web/packages/new/photos/services/ml/embedding.ts +++ b/web/packages/new/photos/services/ml/embedding.ts @@ -235,7 +235,7 @@ const remoteDerivedDataFromJSONString = (jsonString: string) => { * @param fileIDs The ids of the files for which we want the embeddings. * * @returns a list of {@link RemoteEmbedding} for the files which had embeddings - * (and thatt remote was able to successfully retrieve). The order of this list + * (and that remote was able to successfully retrieve). The order of this list * is arbitrary, and the caller should use the {@link fileID} present within the * {@link RemoteEmbedding} to associate an item in the result back to a file * instead of relying on the order or count of items in the result. @@ -308,3 +308,52 @@ const putEmbedding = async ( }); ensureOk(res); }; + +/** A single entry in the response of {@link getIndexedFiles}. */ +const IndexedFile = z.object({ + fileID: z.number(), + updatedAt: z.number(), +}); + +type IndexedFile = z.infer; + +/** + * Fetch the file ids for {@link model} embeddings that have been created or + * updated since the given {@link sinceTime}. + * + * This allows a client to perform a quick "diff" and get the list of files that + * has changed since the last time it checked. It can then fetch those + * corresponding embeddings using the regular fetch API, this speeding up the + * initial sync on a new client. + * + * @param model The {@link EmbeddingModel} which we want. + * + * @param sinceTime Epoch milliseconds. Ask remote to provide us embeddings + * whose {@link updatedAt} is more than the given value. + * + * @param limit The maximum number of files to provide in the response. + * + * @returns a list of {@link RemoteEmbedding} for the files which had embeddings + * (and thatt remote was able to successfully retrieve). The order of this list + * is arbitrary, and the caller should use the {@link fileID} present within the + * {@link RemoteEmbedding} to associate an item in the result back to a file + * instead of relying on the order or count of items in the result. + */ +const getIndexedFiles = async ( + model: EmbeddingModel, + sinceTime: number, + limit: number, +): Promise => { + const params = new URLSearchParams({ + model, + sinceTime: sinceTime.toString(), + limit: limit.toString(), + }); + const url = await apiURL("/embeddings/indexed-files"); + const res = await fetch(`${url}?${params.toString()}`, { + headers: await authenticatedRequestHeaders(), + }); + ensureOk(res); + return z.object({ diff: z.array(IndexedFile) }).parse(await res.json()) + .diff; +}; diff --git a/web/packages/new/photos/services/ml/worker.ts b/web/packages/new/photos/services/ml/worker.ts index 970fcac02d..8ebe207aac 100644 --- a/web/packages/new/photos/services/ml/worker.ts +++ b/web/packages/new/photos/services/ml/worker.ts @@ -306,6 +306,11 @@ export class MLWorker { expose(MLWorker); +/** + * Pull embeddings from remote. + * + * Return true atleast one embedding was pulled. + */ // eslint-disable-next-line @typescript-eslint/require-await const pull = async () => { return ""; From 586d8f86f71ad91bded3461b55e826f65ca66968 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Thu, 1 Aug 2024 10:32:47 +0530 Subject: [PATCH 113/123] Up --- .../new/photos/services/ml/embedding.ts | 50 +++++++++++++------ 1 file changed, 34 insertions(+), 16 deletions(-) diff --git a/web/packages/new/photos/services/ml/embedding.ts b/web/packages/new/photos/services/ml/embedding.ts index 16d3adaec9..1bb54e7669 100644 --- a/web/packages/new/photos/services/ml/embedding.ts +++ b/web/packages/new/photos/services/ml/embedding.ts @@ -309,6 +309,37 @@ const putEmbedding = async ( ensureOk(res); }; +/** + * Fetch new {@link model} embeddings since the given {@link sinceTime}. + * + * This allows a client to perform a quick "diff" and get embeddings that has + * changed (created or updated) since the last time it checked. By fetching + * these all upfront instead of doing them one by one during the indexing, we + * can speed up the initial sync of existing embeddings on a new client. + * + * @param model The {@link EmbeddingModel} which we want. + * + * @param sinceTime Epoch milliseconds. We use this to ask remote to provide us + * embeddings whose {@link updatedAt} is more than the given value. If not + * specified, then we'll start from the beginning. + * + * @param limit The maximum number of files to provide in the response. + * + * @returns a list of {@link RemoteEmbedding}, and the latest {@link updatedAt} + * from amongst all embeddings that were fetched. The caller should persist that + * and use it in subsequent calls to {@link pullEmbeddings} to resume pulling + * from the current checkpoint. + * + * Returns undefined if nothing more is left to pull. + */ +const pullEmbeddings = async ( + model: EmbeddingModel, + sinceTime: number | undefined, + limit: number, +) => { + getIndexedFiles(model) +}; + /** A single entry in the response of {@link getIndexedFiles}. */ const IndexedFile = z.object({ fileID: z.number(), @@ -321,23 +352,10 @@ type IndexedFile = z.infer; * Fetch the file ids for {@link model} embeddings that have been created or * updated since the given {@link sinceTime}. * - * This allows a client to perform a quick "diff" and get the list of files that - * has changed since the last time it checked. It can then fetch those - * corresponding embeddings using the regular fetch API, this speeding up the - * initial sync on a new client. + * See {@link pullEmbeddings} for details about the parameters. * - * @param model The {@link EmbeddingModel} which we want. - * - * @param sinceTime Epoch milliseconds. Ask remote to provide us embeddings - * whose {@link updatedAt} is more than the given value. - * - * @param limit The maximum number of files to provide in the response. - * - * @returns a list of {@link RemoteEmbedding} for the files which had embeddings - * (and thatt remote was able to successfully retrieve). The order of this list - * is arbitrary, and the caller should use the {@link fileID} present within the - * {@link RemoteEmbedding} to associate an item in the result back to a file - * instead of relying on the order or count of items in the result. + * @returns an array of file ids, each with an associated timestamp when the + * embedding for that file was last changed. */ const getIndexedFiles = async ( model: EmbeddingModel, From f869447c7deb83e991cc57e7ed4238c20f8ed639 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Thu, 1 Aug 2024 10:41:31 +0530 Subject: [PATCH 114/123] File IDs --- .../new/photos/services/ml/embedding.ts | 42 ++++++++++++------- 1 file changed, 28 insertions(+), 14 deletions(-) diff --git a/web/packages/new/photos/services/ml/embedding.ts b/web/packages/new/photos/services/ml/embedding.ts index 1bb54e7669..0588340db7 100644 --- a/web/packages/new/photos/services/ml/embedding.ts +++ b/web/packages/new/photos/services/ml/embedding.ts @@ -309,26 +309,39 @@ const putEmbedding = async ( ensureOk(res); }; +interface PullEmbeddingsResult { + /** + * Derived data indexed by the file id whose data this is. + */ + items: Map; + /** + * The latest {@link updatedAt} epoch milliseconds from all the derived data + * in {@link items}. + */ + latestUpdatedAt: number; +} + /** - * Fetch new {@link model} embeddings since the given {@link sinceTime}. + * Fetch derived data that has been created or updated since the given + * {@link sinceTime}. * * This allows a client to perform a quick "diff" and get embeddings that has - * changed (created or updated) since the last time it checked. By fetching - * these all upfront instead of doing them one by one during the indexing, we - * can speed up the initial sync of existing embeddings on a new client. - * - * @param model The {@link EmbeddingModel} which we want. + * changed since the last time it checked. By fetching these all upfront instead + * of doing them one by one during the indexing, we can speed up the initial + * sync of existing embeddings on a new client. * * @param sinceTime Epoch milliseconds. We use this to ask remote to provide us - * embeddings whose {@link updatedAt} is more than the given value. If not + * derived data whose {@link updatedAt} is more than the given value. If not * specified, then we'll start from the beginning. * - * @param limit The maximum number of files to provide in the response. + * @param limit An advisory limit on the number of items to return. * - * @returns a list of {@link RemoteEmbedding}, and the latest {@link updatedAt} - * from amongst all embeddings that were fetched. The caller should persist that - * and use it in subsequent calls to {@link pullEmbeddings} to resume pulling - * from the current checkpoint. + * @returns a map of {@link RemoteDerivedData} indexed by the id of the file + * whose derived data it is, and the latest {@link updatedAt} from amongst all + * the data that was fetched. + * + * The caller should persist the returned timestamp for use in subsequent calls + * to {@link pullEmbeddings} to resume pulling from the current checkpoint. * * Returns undefined if nothing more is left to pull. */ @@ -336,8 +349,9 @@ const pullEmbeddings = async ( model: EmbeddingModel, sinceTime: number | undefined, limit: number, -) => { - getIndexedFiles(model) +): Promise => { + return undefined; + // getIndexedFiles(model) }; /** A single entry in the response of {@link getIndexedFiles}. */ From 523af2600a9b3f551bc097dcae324a04933cfe8c Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Thu, 1 Aug 2024 10:49:45 +0530 Subject: [PATCH 115/123] pull wip --- .../new/photos/services/ml/embedding.ts | 26 ++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/web/packages/new/photos/services/ml/embedding.ts b/web/packages/new/photos/services/ml/embedding.ts index 0588340db7..3e077c1b08 100644 --- a/web/packages/new/photos/services/ml/embedding.ts +++ b/web/packages/new/photos/services/ml/embedding.ts @@ -350,7 +350,31 @@ const pullEmbeddings = async ( sinceTime: number | undefined, limit: number, ): Promise => { - return undefined; + // If since time is not provided, start at 0 (the beginning). + let latestUpdatedAt = sinceTime ?? 0; + + // See if anything changed since then. + const indexedFiles = await getIndexedFiles( + "derived", + latestUpdatedAt, + limit, + ); + + // Nope. Nothing more is left to do. + if (!indexedFiles.length) return undefined; + + // Find the latest from amongst the given updatedAt. This'll serve as our + // checkpoint for the next pull. + latestUpdatedAt = indexedFiles.reduce( + (max, { updatedAt }) => Math.max(max, updatedAt), + latestUpdatedAt, + ); + + // Fetch the embeddings for these guys. In rare cases, remote might return a + // partial response, but that will not have any lasting impact since we + // anyways refetch the derived data before attempting indexing. + const items = await fetchDerivedData() + // getIndexedFiles(model) }; From 5a362b5d4558a2a8be98ca08248257ea3d19cfd3 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Thu, 1 Aug 2024 11:06:41 +0530 Subject: [PATCH 116/123] Move wip --- .../new/photos/services/ml/embedding.ts | 92 ++++--------------- web/packages/new/photos/services/ml/worker.ts | 40 +++++++- 2 files changed, 55 insertions(+), 77 deletions(-) diff --git a/web/packages/new/photos/services/ml/embedding.ts b/web/packages/new/photos/services/ml/embedding.ts index 3e077c1b08..11df71bc84 100644 --- a/web/packages/new/photos/services/ml/embedding.ts +++ b/web/packages/new/photos/services/ml/embedding.ts @@ -309,75 +309,6 @@ const putEmbedding = async ( ensureOk(res); }; -interface PullEmbeddingsResult { - /** - * Derived data indexed by the file id whose data this is. - */ - items: Map; - /** - * The latest {@link updatedAt} epoch milliseconds from all the derived data - * in {@link items}. - */ - latestUpdatedAt: number; -} - -/** - * Fetch derived data that has been created or updated since the given - * {@link sinceTime}. - * - * This allows a client to perform a quick "diff" and get embeddings that has - * changed since the last time it checked. By fetching these all upfront instead - * of doing them one by one during the indexing, we can speed up the initial - * sync of existing embeddings on a new client. - * - * @param sinceTime Epoch milliseconds. We use this to ask remote to provide us - * derived data whose {@link updatedAt} is more than the given value. If not - * specified, then we'll start from the beginning. - * - * @param limit An advisory limit on the number of items to return. - * - * @returns a map of {@link RemoteDerivedData} indexed by the id of the file - * whose derived data it is, and the latest {@link updatedAt} from amongst all - * the data that was fetched. - * - * The caller should persist the returned timestamp for use in subsequent calls - * to {@link pullEmbeddings} to resume pulling from the current checkpoint. - * - * Returns undefined if nothing more is left to pull. - */ -const pullEmbeddings = async ( - model: EmbeddingModel, - sinceTime: number | undefined, - limit: number, -): Promise => { - // If since time is not provided, start at 0 (the beginning). - let latestUpdatedAt = sinceTime ?? 0; - - // See if anything changed since then. - const indexedFiles = await getIndexedFiles( - "derived", - latestUpdatedAt, - limit, - ); - - // Nope. Nothing more is left to do. - if (!indexedFiles.length) return undefined; - - // Find the latest from amongst the given updatedAt. This'll serve as our - // checkpoint for the next pull. - latestUpdatedAt = indexedFiles.reduce( - (max, { updatedAt }) => Math.max(max, updatedAt), - latestUpdatedAt, - ); - - // Fetch the embeddings for these guys. In rare cases, remote might return a - // partial response, but that will not have any lasting impact since we - // anyways refetch the derived data before attempting indexing. - const items = await fetchDerivedData() - - // getIndexedFiles(model) -}; - /** A single entry in the response of {@link getIndexedFiles}. */ const IndexedFile = z.object({ fileID: z.number(), @@ -387,21 +318,32 @@ const IndexedFile = z.object({ type IndexedFile = z.infer; /** - * Fetch the file ids for {@link model} embeddings that have been created or + * Fetch the file ids whose {@link model} derived data has been created or * updated since the given {@link sinceTime}. * - * See {@link pullEmbeddings} for details about the parameters. + * This allows a client to perform a quick "diff" and first fetch all derived + * data that has changed since the last time it checked. By fetching these all + * upfront instead of doing them one by one during the indexing, we can speed up + * the initial sync of existing embeddings on a new client. + * + * @param sinceTime Epoch milliseconds. We use this to ask remote to provide us + * derived data whose {@link updatedAt} is more than the given value. If not + * specified, then we'll start from the beginning. + * + * @param limit An advisory limit on the number of items to return. * * @returns an array of file ids, each with an associated timestamp when the - * embedding for that file was last changed. + * derived data for that file was last changed. + * + * The caller should persist the latest amongst these timestamps and use it in + * subsequent calls to resume pulling from the current checkpoint. */ -const getIndexedFiles = async ( - model: EmbeddingModel, +export const getIndexedDerivedDataFiles = async ( sinceTime: number, limit: number, ): Promise => { const params = new URLSearchParams({ - model, + model: "derived", sinceTime: sinceTime.toString(), limit: limit.toString(), }); diff --git a/web/packages/new/photos/services/ml/worker.ts b/web/packages/new/photos/services/ml/worker.ts index 8ebe207aac..20b017c50f 100644 --- a/web/packages/new/photos/services/ml/worker.ts +++ b/web/packages/new/photos/services/ml/worker.ts @@ -1,6 +1,6 @@ import { clientPackageName } from "@/base/app"; import { isHTTP4xxError } from "@/base/http"; -import { getKVN } from "@/base/kv"; +import { getKVN, setKV } from "@/base/kv"; import { ensureAuthToken } from "@/base/local-user"; import log from "@/base/log"; import type { ElectronMLWorker } from "@/base/types/ipc"; @@ -34,6 +34,7 @@ import { } from "./db"; import { fetchDerivedData, + getIndexedDerivedDataFiles, putDerivedData, type RawRemoteDerivedData, type RemoteDerivedData, @@ -311,8 +312,43 @@ expose(MLWorker); * * Return true atleast one embedding was pulled. */ -// eslint-disable-next-line @typescript-eslint/require-await const pull = async () => { + // If we've never pulled before, start at the beginning (0). + return pullSince((await latestDerivedDataUpdatedAt()) ?? 0); +}; + +const latestDerivedDataUpdatedAt = () => getKVN("latestDerivedDataUpdatedAt"); + +const setLatestDerivedDataUpdatedAt = (n: number) => + setKV("latestDerivedDataUpdatedAt", n); + +const pullSince = async (sinceTime: number) => { + // See if anything has changed since `sinceTime`. + const indexedFiles = await getIndexedDerivedDataFiles(sinceTime, 200); + + // Nope. Nothing more is left to do. + if (!indexedFiles.length) return undefined; + + // Find the latest from amongst all the updatedAt we got back. This'll serve + // as our checkpoint for the next pull. + const latestUpdatedAt = indexedFiles.reduce( + (max, { updatedAt }) => Math.max(max, updatedAt), + sinceTime, + ); + + // Fetch the embeddings for the files which changed. + // + // In rare cases, remote might return a partial response, but that will not + // have any lasting impact since we anyways refetch the derived data before + // attempting indexing. + + + + + const items = await fetchDerivedData(); + + // getIndexedFiles(model) + return ""; }; From eed991a7b2197c38ddd70e8a793362cd75dc3a31 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Thu, 1 Aug 2024 11:22:10 +0530 Subject: [PATCH 117/123] Construct the scaffolding --- web/packages/new/photos/services/ml/worker.ts | 36 ++++++++++++++----- 1 file changed, 28 insertions(+), 8 deletions(-) diff --git a/web/packages/new/photos/services/ml/worker.ts b/web/packages/new/photos/services/ml/worker.ts index 20b017c50f..79124e34cc 100644 --- a/web/packages/new/photos/services/ml/worker.ts +++ b/web/packages/new/photos/services/ml/worker.ts @@ -314,7 +314,11 @@ expose(MLWorker); */ const pull = async () => { // If we've never pulled before, start at the beginning (0). - return pullSince((await latestDerivedDataUpdatedAt()) ?? 0); + const sinceTime = (await latestDerivedDataUpdatedAt()) ?? 0; + // Start fetching, starting the fetched count at 0. + const fetchedCount = await pullSince(sinceTime, 0); + // Return true if something got fetched. + return fetchedCount > 0; }; const latestDerivedDataUpdatedAt = () => getKVN("latestDerivedDataUpdatedAt"); @@ -322,12 +326,12 @@ const latestDerivedDataUpdatedAt = () => getKVN("latestDerivedDataUpdatedAt"); const setLatestDerivedDataUpdatedAt = (n: number) => setKV("latestDerivedDataUpdatedAt", n); -const pullSince = async (sinceTime: number) => { +const pullSince = async (sinceTime: number, fetchedCount: number) => { // See if anything has changed since `sinceTime`. const indexedFiles = await getIndexedDerivedDataFiles(sinceTime, 200); - // Nope. Nothing more is left to do. - if (!indexedFiles.length) return undefined; + // Nothing more is left. Return the previous fetch count we got. + if (!indexedFiles.length) return fetchedCount; // Find the latest from amongst all the updatedAt we got back. This'll serve // as our checkpoint for the next pull. @@ -342,14 +346,30 @@ const pullSince = async (sinceTime: number) => { // have any lasting impact since we anyways refetch the derived data before // attempting indexing. - + const localFiles = await getAllLocalFiles(); + const localFilesByID = new Map(localFiles.map((f) => [f.id, f])); + const filesByID = new Map( + indexedFiles + .map(({ fileID }) => localFilesByID.get(fileID)) + .filter((x) => x !== undefined) + .map((f) => [f.id, f]), + ); - const items = await fetchDerivedData(); + const items = await fetchDerivedData(filesByID); - // getIndexedFiles(model) + // TODO: Save items - return ""; + // Save the checkpoint. + await setLatestDerivedDataUpdatedAt(latestUpdatedAt); + + // Fetch subsequent items. As a safety valve, ensure we don't get into an + // infinite loop by checking that the sinceTime has advanced. + + if (latestUpdatedAt == sinceTime) + throw new Error(`Since time ${sinceTime} did not advance after a pull`); + + return pullSince(latestUpdatedAt, fetchedCount + items.size); }; /** From 97bbf4811fbea83462732fee3ff75bdd8f8575d0 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Thu, 1 Aug 2024 11:45:25 +0530 Subject: [PATCH 118/123] Save --- web/packages/new/photos/services/ml/worker.ts | 70 ++++++++++++++++++- 1 file changed, 69 insertions(+), 1 deletion(-) diff --git a/web/packages/new/photos/services/ml/worker.ts b/web/packages/new/photos/services/ml/worker.ts index 79124e34cc..035f296346 100644 --- a/web/packages/new/photos/services/ml/worker.ts +++ b/web/packages/new/photos/services/ml/worker.ts @@ -358,7 +358,22 @@ const pullSince = async (sinceTime: number, fetchedCount: number) => { const items = await fetchDerivedData(filesByID); - // TODO: Save items + const save = async ([id, data]: [number, RemoteDerivedData]) => { + try { + await saveDerivedData(id, data); + } catch (e) { + // Ignore errors during saving individual items, let the rest of the + // pull proceed. Failures will not have a lasting impact since the + // file will anyways get revisited as part of a backfill. + log.warn( + `Ignoring error when saving pulled derived data for file id ${id}`, + e, + ); + } + }; + + // Save items. + await Promise.all([...items.entries()].map(save)); // Save the checkpoint. await setLatestDerivedDataUpdatedAt(latestUpdatedAt); @@ -372,6 +387,57 @@ const pullSince = async (sinceTime: number, fetchedCount: number) => { return pullSince(latestUpdatedAt, fetchedCount + items.size); }; +/** + * Save the given {@link remoteDerivedData} for {@link fileID}. + * + * This as subset of the save sequence during {@link index}. This one is meant + * to be used during a {@link pull}. + */ +const saveDerivedData = async ( + fileID: number, + remoteDerivedData: RemoteDerivedData, +) => { + // Discard any existing data that is made by an older indexing pipelines. + // See: [Note: Embedding versions] + + const existingRemoteFaceIndex = remoteDerivedData.parsed?.face; + const existingRemoteCLIPIndex = remoteDerivedData.parsed?.clip; + + let existingFaceIndex: FaceIndex | undefined; + if ( + existingRemoteFaceIndex && + existingRemoteFaceIndex.version >= faceIndexingVersion + ) { + const { width, height, faces } = existingRemoteFaceIndex; + existingFaceIndex = { width, height, faces }; + } + + let existingCLIPIndex: CLIPIndex | undefined; + if ( + existingRemoteCLIPIndex && + existingRemoteCLIPIndex.version >= clipIndexingVersion + ) { + const { embedding } = existingRemoteCLIPIndex; + existingCLIPIndex = { embedding }; + } + + // If we have all the required embedding types, then save them, marking a + // file as indexed. + // + // In particular, this means that there might be files which we've marked + // indexed but still don't have the optional derived data types like exif. + // This is fine, we wish to compute the optional type of derived data when + // we can, but by themselves they're not reason enough for us to download + // and index the original. + + if (existingFaceIndex && existingCLIPIndex) { + await saveIndexes( + { fileID, ...existingFaceIndex }, + { fileID, ...existingCLIPIndex }, + ); + } +}; + /** * Find out files which need to be indexed. Then index the next batch of them. * @@ -545,6 +611,8 @@ const index = async ( existingRemoteFaceIndex && existingRemoteFaceIndex.version >= faceIndexingVersion ) { + // Destructure the data we got from remote so that we only retain the + // fields we're interested in the object that gets put into indexed db. const { width, height, faces } = existingRemoteFaceIndex; existingFaceIndex = { width, height, faces }; } From 940c647d507e8cd8448a13e909ee2e92b4814a53 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Thu, 1 Aug 2024 12:18:34 +0530 Subject: [PATCH 119/123] Prevent multiple ticks from being enqueued Noticed multiple ticks when uploading an item, which brought back focus into the app and caused wakeUp also to get triggered because of sync. Not sure if this was the issue, but felt like a potential one. --- web/packages/new/photos/services/ml/worker.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/web/packages/new/photos/services/ml/worker.ts b/web/packages/new/photos/services/ml/worker.ts index 035f296346..ac577d2263 100644 --- a/web/packages/new/photos/services/ml/worker.ts +++ b/web/packages/new/photos/services/ml/worker.ts @@ -83,7 +83,7 @@ interface IndexableItem { export class MLWorker { private electron: ElectronMLWorker | undefined; private delegate: MLWorkerDelegate | undefined; - private state: "idle" | "pull" | "indexing" = "idle"; + private state: "idle" | "waking" | "pull" | "indexing" = "idle"; private shouldPull = false; private havePulledAtLeastOnce = false; private liveQ: IndexableItem[] = []; @@ -154,9 +154,13 @@ export class MLWorker { /** Invoked in response to external events. */ private wakeUp() { if (this.state == "idle") { - // Currently paused. Get back to work. + // We are currently paused. Get back to work. if (this.idleTimeout) clearTimeout(this.idleTimeout); this.idleTimeout = undefined; + // Change state so that multiple calls to `wakeUp` don't cause + // multiple calls to `tick`. + this.state = "waking"; + // Enqueue a tick. void this.tick(); } else { // In the middle of a task. Do nothing, `this.tick` will From 985de0a5cecb32113db566aa58894634c887c7e0 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Thu, 1 Aug 2024 12:26:02 +0530 Subject: [PATCH 120/123] Fix the actual issue described in 940c647d507e8cd8448a13e909ee2e92b4814a53 --- web/packages/new/photos/services/ml/worker.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/web/packages/new/photos/services/ml/worker.ts b/web/packages/new/photos/services/ml/worker.ts index ac577d2263..aecf7b45d3 100644 --- a/web/packages/new/photos/services/ml/worker.ts +++ b/web/packages/new/photos/services/ml/worker.ts @@ -500,6 +500,9 @@ const indexNextBatch = async ( await wait(0); } + // Wait for the pending tasks to drain out. + await Promise.all(tasks); + // Return true if nothing failed. return allSuccess; }; From 9c883eebc66704684e337a7cb3796f9451089eb4 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Thu, 1 Aug 2024 15:27:14 +0530 Subject: [PATCH 121/123] [desktop] Handle logout for utility process --- desktop/src/preload.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/desktop/src/preload.ts b/desktop/src/preload.ts index 3058a6376f..8472e91ff0 100644 --- a/desktop/src/preload.ts +++ b/desktop/src/preload.ts @@ -62,6 +62,7 @@ import { contextBridge, ipcRenderer, webUtils } from "electron/renderer"; // While we can't import other code, we can import types since they're just // needed when compiling and will not be needed or looked around for at runtime. +import type { IpcRendererEvent } from "electron"; import type { AppUpdate, CollectionMapping, @@ -199,13 +200,15 @@ const ffmpegExec = ( // - ML const createMLWorker = () => { - ipcRenderer.send("createMLWorker"); - ipcRenderer.on("createMLWorker/port", (event) => { + const l = (event: IpcRendererEvent) => { void windowLoaded.then(() => { // "*"" is the origin to send to. window.postMessage("createMLWorker/port", "*", event.ports); + ipcRenderer.off("createMLWorker/port", l); }); - }); + }; + ipcRenderer.on("createMLWorker/port", l); + ipcRenderer.send("createMLWorker"); }; // - Watch From 4e51d767918f16da9b015c7305e7e3ee41378801 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Thu, 1 Aug 2024 16:10:31 +0530 Subject: [PATCH 122/123] [desktop] Don't use the indexable-files API Discussed. It is meant for mobile app use cases - us using it also on desktop (where the constraints are different) doesn't really improve on much latency and adds the overhead of extra API requests on each sync. --- .../new/photos/services/ml/embedding.ts | 47 ----- web/packages/new/photos/services/ml/worker.ts | 196 +----------------- 2 files changed, 11 insertions(+), 232 deletions(-) diff --git a/web/packages/new/photos/services/ml/embedding.ts b/web/packages/new/photos/services/ml/embedding.ts index 11df71bc84..32395476be 100644 --- a/web/packages/new/photos/services/ml/embedding.ts +++ b/web/packages/new/photos/services/ml/embedding.ts @@ -308,50 +308,3 @@ const putEmbedding = async ( }); ensureOk(res); }; - -/** A single entry in the response of {@link getIndexedFiles}. */ -const IndexedFile = z.object({ - fileID: z.number(), - updatedAt: z.number(), -}); - -type IndexedFile = z.infer; - -/** - * Fetch the file ids whose {@link model} derived data has been created or - * updated since the given {@link sinceTime}. - * - * This allows a client to perform a quick "diff" and first fetch all derived - * data that has changed since the last time it checked. By fetching these all - * upfront instead of doing them one by one during the indexing, we can speed up - * the initial sync of existing embeddings on a new client. - * - * @param sinceTime Epoch milliseconds. We use this to ask remote to provide us - * derived data whose {@link updatedAt} is more than the given value. If not - * specified, then we'll start from the beginning. - * - * @param limit An advisory limit on the number of items to return. - * - * @returns an array of file ids, each with an associated timestamp when the - * derived data for that file was last changed. - * - * The caller should persist the latest amongst these timestamps and use it in - * subsequent calls to resume pulling from the current checkpoint. - */ -export const getIndexedDerivedDataFiles = async ( - sinceTime: number, - limit: number, -): Promise => { - const params = new URLSearchParams({ - model: "derived", - sinceTime: sinceTime.toString(), - limit: limit.toString(), - }); - const url = await apiURL("/embeddings/indexed-files"); - const res = await fetch(`${url}?${params.toString()}`, { - headers: await authenticatedRequestHeaders(), - }); - ensureOk(res); - return z.object({ diff: z.array(IndexedFile) }).parse(await res.json()) - .diff; -}; diff --git a/web/packages/new/photos/services/ml/worker.ts b/web/packages/new/photos/services/ml/worker.ts index aecf7b45d3..a708fd3257 100644 --- a/web/packages/new/photos/services/ml/worker.ts +++ b/web/packages/new/photos/services/ml/worker.ts @@ -1,6 +1,6 @@ import { clientPackageName } from "@/base/app"; import { isHTTP4xxError } from "@/base/http"; -import { getKVN, setKV } from "@/base/kv"; +import { getKVN } from "@/base/kv"; import { ensureAuthToken } from "@/base/local-user"; import log from "@/base/log"; import type { ElectronMLWorker } from "@/base/types/ipc"; @@ -34,7 +34,6 @@ import { } from "./db"; import { fetchDerivedData, - getIndexedDerivedDataFiles, putDerivedData, type RawRemoteDerivedData, type RemoteDerivedData, @@ -65,14 +64,12 @@ interface IndexableItem { * * ext. event state then state * ------------- --------------- -------------- - * sync -> "pull" -> "idle" * sync -> "backfillq" -> "idle" * upload -> "liveq" -> "idle" * idleTimeout -> "backfillq" -> "idle" * * where: * - * - "pull": pull existing embeddings from remote. * - "liveq": indexing items that are being uploaded, * - "backfillq": index unindexed items otherwise. * - "idle": in between state transitions. @@ -83,9 +80,7 @@ interface IndexableItem { export class MLWorker { private electron: ElectronMLWorker | undefined; private delegate: MLWorkerDelegate | undefined; - private state: "idle" | "waking" | "pull" | "indexing" = "idle"; - private shouldPull = false; - private havePulledAtLeastOnce = false; + private state: "idle" | "tick" | "pull" | "indexing" = "idle"; private liveQ: IndexableItem[] = []; private idleTimeout: ReturnType | undefined; private idleDuration = idleDurationStart; /* unit: seconds */ @@ -131,23 +126,16 @@ export class MLWorker { } /** - * Pull embeddings from remote, and start backfilling if needed. + * Start backfilling if needed. * - * This function enqueues a pull and returns immediately without waiting for - * the pull to complete. + * This function enqueues a backfill attempt and returns immediately without + * waiting for it complete. * - * Once the pull is done, it then schedules a backfill. So calling this also - * implicitly triggers a backfill (which is why we call it a less-precise - * "sync" instead of "pull"). - * - * During a backfill we will first attempt to fetch embeddings for files - * which don't have that data locally. If we fetch and find what we need, we - * save it locally. Otherwise we index them. This the pull upfront is not - * necessary, but it helps a new client get up to speed faster since it can - * fetch all existing embeddings first before getting down to the indexing. + * During a backfill, we first attempt to fetch derived data for files which + * don't have that data locally. If we fetch and find what we need, we save + * it locally. Otherwise we index them. */ sync() { - this.shouldPull = true; this.wakeUp(); } @@ -159,7 +147,7 @@ export class MLWorker { this.idleTimeout = undefined; // Change state so that multiple calls to `wakeUp` don't cause // multiple calls to `tick`. - this.state = "waking"; + this.state = "tick"; // Enqueue a tick. void this.tick(); } else { @@ -217,49 +205,18 @@ export class MLWorker { { state: this.state, liveQ: this.liveQ, - shouldPull: this.shouldPull, idleDuration: this.idleDuration, }, ]); const scheduleTick = () => void setTimeout(() => this.tick(), 0); - // If we've been asked to pull, do that first (before indexing). - if (this.shouldPull) { - // Allow this flag to be reset while we're pulling (triggering - // another pull when we tick next). - this.shouldPull = false; - this.state = "pull"; - try { - const didPull = await pull(); - // Mark that we completed one attempt at pulling successfully - // (irrespective of whether or not that got us some data). - this.havePulledAtLeastOnce = true; - // Reset the idle duration if we did pull something. - if (didPull) this.idleDuration = idleDurationStart; - } catch (e) { - log.error("Failed to pull embeddings", e); - } - // Tick again, even if we got an error. - // - // While the backfillQ won't be processed until at least a pull has - // happened once (`havePulledAtLeastOnce`), the liveQ can still be - // processed since these are new files without remote embeddings. - scheduleTick(); - return; - } - const liveQ = this.liveQ; this.liveQ = []; this.state = "indexing"; - // Use the liveQ if present, otherwise get the next batch to backfill, - // but only after we've pulled once from remote successfully. - const items = liveQ.length - ? liveQ - : this.havePulledAtLeastOnce - ? await this.backfillQ() - : []; + // Use the liveQ if present, otherwise get the next batch to backfill. + const items = liveQ.length ? liveQ : await this.backfillQ(); const allSuccess = await indexNextBatch( items, @@ -311,137 +268,6 @@ export class MLWorker { expose(MLWorker); -/** - * Pull embeddings from remote. - * - * Return true atleast one embedding was pulled. - */ -const pull = async () => { - // If we've never pulled before, start at the beginning (0). - const sinceTime = (await latestDerivedDataUpdatedAt()) ?? 0; - // Start fetching, starting the fetched count at 0. - const fetchedCount = await pullSince(sinceTime, 0); - // Return true if something got fetched. - return fetchedCount > 0; -}; - -const latestDerivedDataUpdatedAt = () => getKVN("latestDerivedDataUpdatedAt"); - -const setLatestDerivedDataUpdatedAt = (n: number) => - setKV("latestDerivedDataUpdatedAt", n); - -const pullSince = async (sinceTime: number, fetchedCount: number) => { - // See if anything has changed since `sinceTime`. - const indexedFiles = await getIndexedDerivedDataFiles(sinceTime, 200); - - // Nothing more is left. Return the previous fetch count we got. - if (!indexedFiles.length) return fetchedCount; - - // Find the latest from amongst all the updatedAt we got back. This'll serve - // as our checkpoint for the next pull. - const latestUpdatedAt = indexedFiles.reduce( - (max, { updatedAt }) => Math.max(max, updatedAt), - sinceTime, - ); - - // Fetch the embeddings for the files which changed. - // - // In rare cases, remote might return a partial response, but that will not - // have any lasting impact since we anyways refetch the derived data before - // attempting indexing. - - const localFiles = await getAllLocalFiles(); - const localFilesByID = new Map(localFiles.map((f) => [f.id, f])); - - const filesByID = new Map( - indexedFiles - .map(({ fileID }) => localFilesByID.get(fileID)) - .filter((x) => x !== undefined) - .map((f) => [f.id, f]), - ); - - const items = await fetchDerivedData(filesByID); - - const save = async ([id, data]: [number, RemoteDerivedData]) => { - try { - await saveDerivedData(id, data); - } catch (e) { - // Ignore errors during saving individual items, let the rest of the - // pull proceed. Failures will not have a lasting impact since the - // file will anyways get revisited as part of a backfill. - log.warn( - `Ignoring error when saving pulled derived data for file id ${id}`, - e, - ); - } - }; - - // Save items. - await Promise.all([...items.entries()].map(save)); - - // Save the checkpoint. - await setLatestDerivedDataUpdatedAt(latestUpdatedAt); - - // Fetch subsequent items. As a safety valve, ensure we don't get into an - // infinite loop by checking that the sinceTime has advanced. - - if (latestUpdatedAt == sinceTime) - throw new Error(`Since time ${sinceTime} did not advance after a pull`); - - return pullSince(latestUpdatedAt, fetchedCount + items.size); -}; - -/** - * Save the given {@link remoteDerivedData} for {@link fileID}. - * - * This as subset of the save sequence during {@link index}. This one is meant - * to be used during a {@link pull}. - */ -const saveDerivedData = async ( - fileID: number, - remoteDerivedData: RemoteDerivedData, -) => { - // Discard any existing data that is made by an older indexing pipelines. - // See: [Note: Embedding versions] - - const existingRemoteFaceIndex = remoteDerivedData.parsed?.face; - const existingRemoteCLIPIndex = remoteDerivedData.parsed?.clip; - - let existingFaceIndex: FaceIndex | undefined; - if ( - existingRemoteFaceIndex && - existingRemoteFaceIndex.version >= faceIndexingVersion - ) { - const { width, height, faces } = existingRemoteFaceIndex; - existingFaceIndex = { width, height, faces }; - } - - let existingCLIPIndex: CLIPIndex | undefined; - if ( - existingRemoteCLIPIndex && - existingRemoteCLIPIndex.version >= clipIndexingVersion - ) { - const { embedding } = existingRemoteCLIPIndex; - existingCLIPIndex = { embedding }; - } - - // If we have all the required embedding types, then save them, marking a - // file as indexed. - // - // In particular, this means that there might be files which we've marked - // indexed but still don't have the optional derived data types like exif. - // This is fine, we wish to compute the optional type of derived data when - // we can, but by themselves they're not reason enough for us to download - // and index the original. - - if (existingFaceIndex && existingCLIPIndex) { - await saveIndexes( - { fileID, ...existingFaceIndex }, - { fileID, ...existingCLIPIndex }, - ); - } -}; - /** * Find out files which need to be indexed. Then index the next batch of them. * From e640302ce0270c244c2d88c1a7bb0dcbb953dbb9 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Thu, 1 Aug 2024 16:30:41 +0530 Subject: [PATCH 123/123] [desktop] Make the exif backfill optional --- web/packages/new/photos/services/ml/worker.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/web/packages/new/photos/services/ml/worker.ts b/web/packages/new/photos/services/ml/worker.ts index aecf7b45d3..4e47c1d97e 100644 --- a/web/packages/new/photos/services/ml/worker.ts +++ b/web/packages/new/photos/services/ml/worker.ts @@ -611,7 +611,6 @@ const index = async ( // this function don't care what's inside it and can just treat it as an // opaque blob. const existingExif = remoteDerivedData?.raw.exif; - const hasExistingExif = existingExif !== undefined && existingExif !== null; let existingFaceIndex: FaceIndex | undefined; if ( @@ -633,10 +632,10 @@ const index = async ( existingCLIPIndex = { embedding }; } - // See if we already have all the derived data fields that we need. If so, - // just update our local db and return. + // See if we already have all the mandatory derived data fields. If so, just + // update our local db and return. - if (existingFaceIndex && existingCLIPIndex && hasExistingExif) { + if (existingFaceIndex && existingCLIPIndex) { try { await saveIndexes( { fileID, ...existingFaceIndex }, @@ -705,7 +704,7 @@ const index = async ( const msg = []; if (!existingFaceIndex) msg.push(`${faceIndex.faces.length} faces`); if (!existingCLIPIndex) msg.push("clip"); - if (!hasExistingExif && originalImageBlob) msg.push("exif"); + if (!existingExif && originalImageBlob) msg.push("exif"); return `Indexed ${msg.join(" and ")} in ${f} (${ms} ms)`; });