diff --git a/auth/lib/core/configuration.dart b/auth/lib/core/configuration.dart index 096fe91b2f..586a519b65 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'; @@ -140,6 +141,7 @@ class Configuration { iOptions: _secureStorageOptionsIOS, ); } + await LockScreenSettings.instance.removePinAndPassword(); await AuthenticatorDB.instance.clearTable(); _key = null; _cachedToken = null; @@ -469,7 +471,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 +485,7 @@ class Configuration { } } - Future setShouldShowLockScreen(bool value) { + Future setSystemLockScreen(bool value) { return _preferences.setBool(keyShouldShowLockScreen, value); } diff --git a/auth/lib/l10n/arb/app_en.arb b/auth/lib/l10n/arb/app_en.arb index 1ef825f3f8..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", @@ -445,5 +445,25 @@ "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", + "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/main.dart b/auth/lib/main.dart index 003c432c08..452f2a90f8 100644 --- a/auth/lib/main.dart +++ b/auth/lib/main.dart @@ -23,17 +23,15 @@ 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'; 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(); } @@ -115,7 +112,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, @@ -174,24 +171,5 @@ Future _init(bool bool, {String? via}) async { await NotificationService.instance.init(); await UpdateService.instance.init(); await IconUtils.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, - ); + await LockScreenSettings.instance.init(); } diff --git a/auth/lib/services/local_authentication_service.dart b/auth/lib/services/local_authentication_service.dart index 44c2a758a7..f47ed693cd 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'; @@ -21,11 +23,15 @@ class LocalAuthenticationService { BuildContext context, String infoMessage, ) async { - if (await _isLocalAuthSupportedOnDevice()) { + 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( - Configuration.instance.shouldShowLockScreen(), + await Configuration.instance.shouldShowLockScreen(), ); if (!result) { showToast(context, infoMessage); @@ -37,6 +43,50 @@ class LocalAuthenticationService { return true; } + Future requestEnteAuthForLockScreen( + BuildContext context, + String? savedPin, + String? savedPassword, { + bool isAuthenticatingOnAppLaunch = false, + bool isAuthenticatingForInAppChange = false, + }) async { + if (savedPassword != null) { + final result = await Navigator.of(context).push( + MaterialPageRoute( + builder: (BuildContext context) { + return LockScreenPassword( + isChangingLockScreenSettings: true, + isAuthenticatingForInAppChange: isAuthenticatingForInAppChange, + isAuthenticatingOnAppLaunch: isAuthenticatingOnAppLaunch, + authPass: savedPassword, + ); + }, + ), + ); + if (result) { + return true; + } + } + if (savedPin != null) { + final result = await Navigator.of(context).push( + MaterialPageRoute( + builder: (BuildContext context) { + return LockScreenPin( + isChangingLockScreenSettings: true, + isAuthenticatingForInAppChange: isAuthenticatingForInAppChange, + isAuthenticatingOnAppLaunch: isAuthenticatingOnAppLaunch, + authPin: savedPin, + ); + }, + ), + ); + if (result) { + return true; + } + } + return false; + } + Future requestLocalAuthForLockScreen( BuildContext context, bool shouldEnableLockScreen, @@ -44,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, @@ -53,11 +103,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 @@ -70,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/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..3bdf091f16 --- /dev/null +++ b/auth/lib/ui/settings/lock_screen/custom_pin_keypad.dart @@ -0,0 +1,203 @@ +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), + child: Column( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + 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(); + }, + ), + ], + ), + ], + ), + ), + ], + ), + ), + ); + } + + 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_auto_lock.dart b/auth/lib/ui/settings/lock_screen/lock_screen_auto_lock.dart new file mode 100644 index 0000000000..869bb1e40a --- /dev/null +++ b/auth/lib/ui/settings/lock_screen/lock_screen_auto_lock.dart @@ -0,0 +1,143 @@ +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'; +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: [ + TitleBarWidget( + flexibleSpaceTitle: TitleBarTitleWidget( + title: context.l10n.autoLock, + ), + ), + 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 = []; + Duration currentAutoLockTime = const Duration(seconds: 5); + + @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 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 new file mode 100644 index 0000000000..dee6fd2db9 --- /dev/null +++ b/auth/lib/ui/settings/lock_screen/lock_screen_confirm_password.dart @@ -0,0 +1,186 @@ +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"; +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: context.l10n.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( + color: colorTheme.fillFaintPressed, + value: 1, + strokeWidth: 1.5, + ), + ), + IconButtonWidget( + icon: Icons.lock, + iconButtonType: IconButtonType.primary, + iconColor: colorTheme.textBase, + ), + ], + ), + ), + Text( + context.l10n.reEnterPassword, + style: textTheme.bodyBold, + ), + const Padding(padding: EdgeInsets.all(12)), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: TextInputWidget( + hintText: context.l10n.confirmPassword, + 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..5ed7c48796 --- /dev/null +++ b/auth/lib/ui/settings/lock_screen/lock_screen_confirm_pin.dart @@ -0,0 +1,211 @@ +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"; +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; + bool isPlatformDesktop = 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 initState() { + super.initState(); + isPlatformDesktop = + Platform.isLinux || Platform.isMacOS || Platform.isWindows; + } + + @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, + ), + ), + ), + floatingActionButton: isPlatformDesktop + ? null + : CustomPinKeypad(controller: _confirmPinController), + floatingActionButtonLocation: FloatingActionButtonLocation.centerDocked, + body: SingleChildScrollView( + child: _getBody(colorTheme, textTheme), + ), + ); + } + + Widget _getBody(colorTheme, textTheme) { + 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( + context.l10n.reEnterPin, + style: textTheme.bodyBold, + ), + const Padding(padding: EdgeInsets.all(12)), + Pinput( + length: 4, + showCursor: false, + useNativeKeyboard: isPlatformDesktop, + autofocus: true, + 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(); + }, + ), + ], + ), + ); + } +} 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..2c600a0d1d --- /dev/null +++ b/auth/lib/ui/settings/lock_screen/lock_screen_options.dart @@ -0,0 +1,368 @@ +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"; +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:ente_auth/utils/platform_util.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; + late int autoLockTimeInMilliseconds; + late bool hideAppContent; + + @override + void initState() { + super.initState(); + hideAppContent = _lockscreenSetting.getShouldHideAppContent(); + autoLockTimeInMilliseconds = _lockscreenSetting.getAutoLockTime(); + _initializeSettings(); + appLock = isPinEnabled || + isPasswordEnabled || + _configuration.shouldShowSystemLockScreen(); + } + + 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; + }); + } + + 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(); + if (PlatformUtil.isMobile()) { + await _lockscreenSetting.setHideAppContent(!appLock); + } + setState(() { + _initializeSettings(); + appLock = !appLock; + hideAppContent = _lockscreenSetting.getShouldHideAppContent(); + }); + } + + Future _onAutoLock() async { + await routeToPage( + context, + const LockScreenAutoLock(), + ).then( + (value) { + setState(() { + autoLockTimeInMilliseconds = _lockscreenSetting.getAutoLockTime(); + }); + }, + ); + } + + Future _onHideContent() async { + setState(() { + hideAppContent = !hideAppContent; + }); + await _lockscreenSetting.setHideAppContent(hideAppContent); + } + + String _formatTime(Duration duration) { + if (duration.inHours != 0) { + return "in ${duration.inHours} hour${duration.inHours > 1 ? 's' : ''}"; + } else if (duration.inMinutes != 0) { + return "in ${duration.inMinutes} minute${duration.inMinutes > 1 ? 's' : ''}"; + } else if (duration.inSeconds != 0) { + return "in ${duration.inSeconds} second${duration.inSeconds > 1 ? 's' : ''}"; + } else { + return context.l10n.immediately; + } + } + + @override + Widget build(BuildContext context) { + final colorTheme = getEnteColorScheme(context); + final textTheme = getEnteTextTheme(context); + return Scaffold( + body: CustomScrollView( + primary: false, + slivers: [ + TitleBarWidget( + flexibleSpaceTitle: TitleBarTitleWidget( + title: context.l10n.appLock, + ), + ), + 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( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MenuItemWidget( + captionedTextWidget: CaptionedTextWidget( + title: context.l10n.appLock, + ), + alignCaptionedTextToLeft: true, + singleBorderRadius: 8, + menuItemColor: colorTheme.fillFaint, + trailingWidget: ToggleSwitchWidget( + value: () => appLock, + onChanged: () => _onToggleSwitch(), + ), + ), + 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), + ), + ], + ), + 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, + ), + surfaceExecutionStates: false, + 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, + ), + surfaceExecutionStates: false, + 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, + ), + surfaceExecutionStates: false, + alignCaptionedTextToLeft: true, + isTopBorderRadiusRemoved: true, + isBottomBorderRadiusRemoved: false, + menuItemColor: colorTheme.fillFaint, + trailingIcon: isPasswordEnabled + ? Icons.check + : null, + trailingIconColor: colorTheme.textBase, + onTap: () => _passwordLock(), + ), + const SizedBox( + height: 24, + ), + 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: + CrossAxisAlignment.start, + children: [ + const SizedBox(height: 24), + MenuItemWidget( + captionedTextWidget: + CaptionedTextWidget( + title: + context.l10n.hideContent, + ), + alignCaptionedTextToLeft: true, + singleBorderRadius: 8, + 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, + ), + ), + ], + ) + : const SizedBox.shrink(), + ], + ) + : const SizedBox.shrink(), + ), + ], + ), + ), + ); + }, + 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..43a103523a --- /dev/null +++ b/auth/lib/ui/settings/lock_screen/lock_screen_password.dart @@ -0,0 +1,250 @@ +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"; +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.isChangingLockScreenSettings = false, + this.isAuthenticatingOnAppLaunch = false, + this.isAuthenticatingForInAppChange = false, + this.authPass, + }); + + final bool isChangingLockScreenSettings; + final bool isAuthenticatingOnAppLaunch; + final bool isAuthenticatingForInAppChange; + 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: context.l10n.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( + color: colorTheme.fillFaintPressed, + value: 1, + strokeWidth: 1.5, + ), + ), + IconButtonWidget( + icon: Icons.lock, + iconButtonType: IconButtonType.primary, + iconColor: colorTheme.textBase, + ), + ], + ), + ), + Text( + widget.isChangingLockScreenSettings + ? context.l10n.enterPassword + : context.l10n.setNewPassword, + textAlign: TextAlign.center, + style: textTheme.bodyBold, + ), + const Padding(padding: EdgeInsets.all(12)), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: TextInputWidget( + hintText: context.l10n.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( + utf8.encode(inputtedPassword), + salt!, + sodium.crypto.pwhash.memLimitInteractive, + sodium.crypto.pwhash.opsLimitSensitive, + sodium, + ); + if (widget.authPass == base64Encode(hash)) { + await _lockscreenSetting.setInvalidAttemptCount(0); + + widget.isAuthenticatingOnAppLaunch || + widget.isAuthenticatingForInAppChange + ? Navigator.of(context).pop(true) + : Navigator.of(context).pushReplacement( + MaterialPageRoute( + builder: (context) => const LockScreenOptions(), + ), + ); + return true; + } else { + if (widget.isAuthenticatingOnAppLaunch) { + invalidAttemptsCount++; + await _lockscreenSetting.setInvalidAttemptCount(invalidAttemptsCount); + if (invalidAttemptsCount > 4) { + Navigator.of(context).pop(false); + } + } + + await HapticFeedback.vibrate(); + throw Exception("Incorrect password"); + } + } + + Future _confirmPassword() async { + if (widget.isChangingLockScreenSettings) { + 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..8cd4509f51 --- /dev/null +++ b/auth/lib/ui/settings/lock_screen/lock_screen_pin.dart @@ -0,0 +1,284 @@ +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"; +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.isChangingLockScreenSettings = false, + this.isAuthenticatingOnAppLaunch = false, + this.isAuthenticatingForInAppChange = false, + this.authPin, + }); + + final bool isAuthenticatingOnAppLaunch; + final bool isChangingLockScreenSettings; + final bool isAuthenticatingForInAppChange; + 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; + bool isPlatformDesktop = false; + @override + void initState() { + super.initState(); + isPlatformDesktop = + Platform.isLinux || Platform.isMacOS || Platform.isWindows; + invalidAttemptsCount = _lockscreenSetting.getInvalidAttemptCount(); + } + + @override + void dispose() { + super.dispose(); + _pinController.dispose(); + } + + Future confirmPinAuth(String inputtedPin) async { + final Uint8List? salt = await _lockscreenSetting.getSalt(); + final hash = cryptoPwHash( + utf8.encode(inputtedPin), + salt!, + sodium.crypto.pwhash.memLimitInteractive, + sodium.crypto.pwhash.opsLimitSensitive, + sodium, + ); + if (widget.authPin == base64Encode(hash)) { + invalidAttemptsCount = 0; + await _lockscreenSetting.setInvalidAttemptCount(0); + widget.isAuthenticatingOnAppLaunch || + widget.isAuthenticatingForInAppChange + ? 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.isAuthenticatingOnAppLaunch) { + invalidAttemptsCount++; + await _lockscreenSetting.setInvalidAttemptCount(invalidAttemptsCount); + if (invalidAttemptsCount > 4) { + Navigator.of(context).pop(false); + } + } + return false; + } + } + + Future _confirmPin(String inputtedPin) async { + if (widget.isChangingLockScreenSettings) { + 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, + ), + ), + ), + floatingActionButton: isPlatformDesktop + ? null + : CustomPinKeypad(controller: _pinController), + floatingActionButtonLocation: FloatingActionButtonLocation.centerDocked, + body: SingleChildScrollView( + child: _getBody(colorTheme, textTheme), + ), + ); + } + + Widget _getBody( + EnteColorScheme colorTheme, + EnteTextTheme textTheme, + ) { + 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.isChangingLockScreenSettings + ? context.l10n.enterPin + : context.l10n.setNewPin, + style: textTheme.bodyBold, + ), + const Padding(padding: EdgeInsets.all(12)), + Pinput( + length: 4, + showCursor: false, + useNativeKeyboard: isPlatformDesktop, + controller: _pinController, + autofocus: true, + 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); + }, + ), + ], + ), + ); + } +} diff --git a/auth/lib/ui/settings/security_section_widget.dart b/auth/lib/ui/settings/security_section_widget.dart index 678a4ddfa1..9ba4289a36 100644 --- a/auth/lib/ui/settings/security_section_widget.dart +++ b/auth/lib/ui/settings/security_section_widget.dart @@ -15,6 +15,8 @@ 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'; @@ -66,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( @@ -102,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, @@ -133,26 +135,38 @@ class _SecuritySectionWidgetState extends State { children.add(sectionOptionSpacing); } children.addAll([ + sectionOptionSpacing, MenuItemWidget( captionedTextWidget: CaptionedTextWidget( - title: l10n.lockscreen, + title: context.l10n.appLock, ), - trailingWidget: ToggleSwitchWidget( - value: () => _config.shouldShowLockScreen(), - onChanged: () async { - final hasAuthenticated = await LocalAuthenticationService.instance - .requestLocalAuthForLockScreen( + surfaceExecutionStates: false, + trailingIcon: Icons.chevron_right_outlined, + trailingIconIsMuted: true, + onTap: () async { + if (await LocalAuthenticationService.instance + .isLocalAuthSupportedOnDevice()) { + final bool result = await requestAuthentication( context, - !_config.shouldShowLockScreen(), context.l10n.authToChangeLockscreenSetting, - context.l10n.lockScreenEnablePreSteps, ); - 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, + context.l10n.noSystemLockFound, + context.l10n.toEnableAppLockPleaseSetupDevicePasscodeOrScreen, + ); + } + }, ), sectionOptionSpacing, ]); 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) { diff --git a/auth/lib/ui/tools/lock_screen.dart b/auth/lib/ui/tools/lock_screen.dart index b6e2126e1d..a77ba6d155 100644 --- a/auth/lib/ui/tools/lock_screen.dart +++ b/auth/lib/ui/tools/lock_screen.dart @@ -1,10 +1,17 @@ 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/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'; +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 +27,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 +46,145 @@ 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, + 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"); + }, + 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: isTimerRunning ? 0 : 1, + end: isTimerRunning + ? _getFractionOfTimeElapsed() + : 1, + ), + 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( + context.l10n.tooManyIncorrectAttempts, + 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( + context.l10n.tapToUnlock, + style: textTheme.small, + ), + ), + const Padding( + padding: EdgeInsets.only(bottom: 24), + ), ], ), - ], + ), ), ), ); @@ -77,6 +198,18 @@ class _LockScreenState extends State with WidgetsBindingObserver { return shortestSide > 600 ? true : false; } + void _onLogoutTapped(BuildContext context) { + showChoiceActionSheet( + context, + title: context.l10n.areYouSureYouWantToLogout, + firstButtonLabel: context.l10n.yesLogout, + isCritical: true, + firstButtonOnTap: () async { + await UserService.instance.logout(context); + }, + ); + } + @override void didChangeAppLifecycleState(AppLifecycleState state) { _logger.info(state.toString()); @@ -90,10 +223,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 +255,112 @@ 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"); + 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..86dfa503e9 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,20 +94,22 @@ 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: '', + defaultPinTheme: _pinPutDecoration, + followingPinTheme: _pinPutDecoration.copyWith( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10.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..df11211c92 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,26 @@ 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, + bool isAuthenticatingForInAppChange = 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, + isAuthenticatingOnAppLaunch: isOpeningApp, + isAuthenticatingForInAppChange: isAuthenticatingForInAppChange, + ); + } 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..b857bcc9f5 --- /dev/null +++ b/auth/lib/utils/lock_screen_settings.dart @@ -0,0 +1,155 @@ +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:privacy_screen/privacy_screen.dart"; +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"; + static const autoLockTime = "ls_auto_lock_time"; + static const keyHideAppContent = "ls_hide_app_content"; + final List autoLockDurations = const [ + Duration(seconds: 0), + Duration(seconds: 5), + Duration(seconds: 15), + Duration(minutes: 1), + Duration(minutes: 5), + Duration(minutes: 30), + ]; + + late SharedPreferences _preferences; + late FlutterSecureStorage _secureStorage; + + Future init() async { + _secureStorage = const FlutterSecureStorage(); + _preferences = await SharedPreferences.getInstance(); + + ///Workaround for privacyScreen not working when app is killed and opened. + await setHideAppContent(getShouldHideAppContent()); + } + + Future setHideAppContent(bool hideContent) async { + !hideContent + ? PrivacyScreen.instance.disable() + : await PrivacyScreen.instance.enable( + iosOptions: const PrivacyIosOptions( + enablePrivacy: true, + ), + androidOptions: const PrivacyAndroidOptions( + enableSecure: true, + ), + blurEffect: PrivacyBlurEffect.extraLight, + ); + await _preferences.setBool(keyHideAppContent, hideContent); + } + + bool getShouldHideAppContent() { + return _preferences.getBool(keyHideAppContent) ?? true; + } + + Future setAutoLockTime(Duration duration) async { + await _preferences.setInt(autoLockTime, duration.inMilliseconds); + } + + int getAutoLockTime() { + return _preferences.getInt(autoLockTime) ?? 5000; + } + + 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.randombytes.buf(sodium.crypto.pwhash.saltBytes); + } + + Future setPin(String userPin) async { + await _secureStorage.delete(key: saltKey); + final salt = _generateSalt(); + + final hash = cryptoPwHash( + utf8.encode(userPin), + salt, + sodium.crypto.pwhash.memLimitInteractive, + 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); + + return; + } + + Future getSalt() async { + final String? salt = await _secureStorage.read(key: saltKey); + if (salt == null) return null; + return base64Decode(salt); + } + + Future getPin() async { + return _secureStorage.read(key: pin); + } + + Future setPassword(String pass) async { + await _secureStorage.delete(key: saltKey); + final salt = _generateSalt(); + + final hash = cryptoPwHash( + utf8.encode(pass), + salt, + sodium.crypto.pwhash.memLimitInteractive, + sodium.crypto.pwhash.opsLimitSensitive, + sodium, + ); + + await _secureStorage.write(key: saltKey, value: base64Encode(salt)); + await _secureStorage.write(key: password, value: base64Encode(hash)); + await _secureStorage.delete(key: pin); + + return; + } + + Future getPassword() async { + return _secureStorage.read(key: password); + } + + Future removePinAndPassword() async { + await _secureStorage.delete(key: saltKey); + await _secureStorage.delete(key: pin); + await _secureStorage.delete(key: password); + } + + Future isPinSet() async { + return await _secureStorage.containsKey(key: pin); + } + + Future isPasswordSet() async { + return await _secureStorage.containsKey(key: password); + } +} diff --git a/auth/pubspec.lock b/auth/pubspec.lock index 99f3295f5f..12908fc253 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: "7bf9aa7d0eeb3da9f7d49d2087c7bc7d36cd277d2e94cc31c6da52e1ebb048d0" url: "https://pub.dev" source: hosted - version: "1.2.2" + version: "5.0.0" platform: dependency: transitive description: @@ -1575,6 +1591,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: diff --git a/auth/pubspec.yaml b/auth/pubspec.yaml index 1ef7e0c2ef..393e4dda62 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: ^5.0.0 pointycastle: ^3.7.3 privacy_screen: ^0.0.6 protobuf: ^3.0.0 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.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 641ce9963d..6c4020d6ee 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, @@ -42,11 +43,7 @@ import { } from "./services/fs"; import { convertToJPEG, generateImageThumbnail } from "./services/image"; import { logout } from "./services/logout"; -import { - computeCLIPImageEmbedding, - computeCLIPTextEmbeddingIfAvailable, -} from "./services/ml-clip"; -import { computeFaceEmbeddings, detectFaces } from "./services/ml-face"; +import { createMLWorker } from "./services/ml"; import { encryptionKey, lastShownChangelogVersion, @@ -184,24 +181,6 @@ export const attachIPCHandlers = () => { ) => ffmpegExec(command, dataOrPathOrZipItem, outputFileExtension), ); - // - ML - - 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) => @@ -231,6 +210,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("createMLWorker", () => createMLWorker(mainWindow)); +}; + /** * Sibling of {@link attachIPCHandlers} that attaches handlers specific to the * watch folder functionality. 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-worker.ts b/desktop/src/main/services/ml-worker.ts new file mode 100644 index 0000000000..f4b9221f64 --- /dev/null +++ b/desktop/src/main/services/ml-worker.ts @@ -0,0 +1,315 @@ +/** + * @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. + */ + +// See [Note: Using Electron APIs in UtilityProcess] about what we can and +// cannot import. + +import Tokenizer from "clip-bpe-js"; +import { expose } from "comlink"; +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 { messagePortMainEndpoint } from "../utils/comlink"; +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`); + +process.parentPort.once("message", (e) => { + // Initialize ourselves with the data we got from our parent. + parseInitData(e.data); + // Expose an instance of `ElectronMLWorker` on the port we got from our + // parent. + expose( + { + computeCLIPImageEmbedding, + computeCLIPTextEmbeddingIfAvailable, + detectFaces, + computeFaceEmbeddings, + }, + messagePortMainEndpoint(ensure(e.ports[0])), + ); +}); + +/** + * 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"); + } +}; + +/** + * 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.errorString( + `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(userDataPath(), "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 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 */ + 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 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; +}; + +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 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; +}; + +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 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 */ + 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 55bb8d79c2..cc1ae5764c 100644 --- a/desktop/src/main/services/ml.ts +++ b/desktop/src/main/services/ml.ts @@ -1,126 +1,147 @@ /** - * @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. - * - * 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. + * @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 { + MessageChannelMain, + type BrowserWindow, + type UtilityProcess, +} from "electron"; +import { app, utilityProcess } from "electron/main"; import path from "node:path"; -import * as ort from "onnxruntime-node"; import log from "../log"; -import { writeStream } from "../stream"; + +/** The active ML worker (utility) process, if any. */ +let _child: UtilityProcess | undefined; /** - * 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. + * Create a new ML worker process, terminating the older ones (if any). * - * 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. + * [Note: ML IPC] * - * 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. + * 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. * - * {@link makeCachedInferenceSession} can itself be called anytime, it doesn't - * actively trigger a download until the returned function is called. + * 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: * - * @param modelName The name of the model to download. + * Node.js main <-> Renderer main <-> Renderer web worker * - * @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. + * 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. * - * @returns a function. calling that function returns a promise to an ONNX - * session. + * 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. + * + * > 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. 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. + * + * 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 + * + * The RPC protocol is handled using comlink on both ends. The port itself needs + * to be relayed using `postMessage`. */ -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); - } +export const createMLWorker = (window: BrowserWindow) => { + if (_child) { + log.debug(() => "Terminating previous ML worker process"); + _child.kill(); + _child = undefined; } - return modelPath; -}; + const { port1, port2 } = new MessageChannelMain(); -/** 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 child = utilityProcess.fork(path.join(__dirname, "ml-worker.js")); + const userDataPath = app.getPath("userData"); + child.postMessage({ userDataPath }, [port1]); -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}`); + window.webContents.postMessage("createMLWorker/port", undefined, [port2]); + + handleMessagesFromUtilityProcess(child); + + _child = child; }; /** - * Crete an ONNX {@link InferenceSession} with some defaults. + * Handle messages posted 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, + * + * - 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. */ -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 handleMessagesFromUtilityProcess = (child: UtilityProcess) => { + const logTag = "[ml-worker]"; + child.on("message", (m: unknown) => { + 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") { + log.error(`${logTag} ${p}`); + return; + } + break; + case "log.info": + 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.info("Ignoring unknown message from ML worker", m); }); }; diff --git a/desktop/src/main/stream.ts b/desktop/src/main/stream.ts index 749c94f491..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, @@ -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 @@ -159,39 +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: ReadableStream) => - 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/comlink.ts b/desktop/src/main/utils/comlink.ts new file mode 100644 index 0000000000..d2006e795b --- /dev/null +++ b/desktop/src/main/utils/comlink.ts @@ -0,0 +1,42 @@ +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 following function from comlink: + * https://github.com/GoogleChromeLabs/comlink/blob/main/src/node-adapter.ts + * + * 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 abundant type + * casts. Caveat emptor. + */ +export const messagePortMainEndpoint = (mp: MessagePortMain): Endpoint => { + type NL = EventListenerOrEventListenerObject; + type EL = (data: Electron.MessageEvent) => void; + const listeners = new WeakMap(); + return { + postMessage: (message, transfer) => { + mp.postMessage(message, transfer as unknown as MessagePortMain[]); + }, + addEventListener: (_, eh) => { + const l: EL = (data) => + "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), + }; +}; 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); + }); + }); +}; diff --git a/desktop/src/preload.ts b/desktop/src/preload.ts index f2366aa63d..8472e91ff0 100644 --- a/desktop/src/preload.ts +++ b/desktop/src/preload.ts @@ -36,10 +36,33 @@ * - [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. +// +// [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"; // 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, @@ -48,6 +71,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,17 +199,17 @@ const ffmpegExec = ( // - ML -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); +const createMLWorker = () => { + 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 @@ -281,8 +317,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". @@ -339,10 +378,7 @@ contextBridge.exposeInMainWorld("electron", { // - ML - computeCLIPImageEmbedding, - computeCLIPTextEmbeddingIfAvailable, - detectFaces, - computeFaceEmbeddings, + createMLWorker, // - Watch 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/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? diff --git a/mobile/assets/2.0x/active_subscription.png b/mobile/assets/2.0x/active_subscription.png new file mode 100644 index 0000000000..8175b5ea6a Binary files /dev/null and b/mobile/assets/2.0x/active_subscription.png differ diff --git a/mobile/assets/2.0x/popular_subscription.png b/mobile/assets/2.0x/popular_subscription.png new file mode 100644 index 0000000000..c609e539c5 Binary files /dev/null and b/mobile/assets/2.0x/popular_subscription.png differ diff --git a/mobile/assets/3.0x/active_subscription.png b/mobile/assets/3.0x/active_subscription.png new file mode 100644 index 0000000000..7207692c80 Binary files /dev/null and b/mobile/assets/3.0x/active_subscription.png differ diff --git a/mobile/assets/3.0x/popular_subscription.png b/mobile/assets/3.0x/popular_subscription.png new file mode 100644 index 0000000000..8a15496ab5 Binary files /dev/null and b/mobile/assets/3.0x/popular_subscription.png differ diff --git a/mobile/assets/active_subscription.png b/mobile/assets/active_subscription.png new file mode 100644 index 0000000000..2b6c028fac Binary files /dev/null and b/mobile/assets/active_subscription.png differ diff --git a/mobile/assets/popular_subscription.png b/mobile/assets/popular_subscription.png new file mode 100644 index 0000000000..3308b82879 Binary files /dev/null and b/mobile/assets/popular_subscription.png differ 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..b72c59f5ba 100644 --- a/mobile/lib/models/ffmpeg/ffprobe_props.dart +++ b/mobile/lib/models/ffmpeg/ffprobe_props.dart @@ -18,20 +18,56 @@ class FFProbeProps { String? bitrate; String? majorBrand; String? fps; - String? codecWidth; - String? codecHeight; + 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(' * '); } + 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 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() { @@ -132,11 +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]; } } } diff --git a/mobile/lib/models/subscription.dart b/mobile/lib/models/subscription.dart index 51fca19e3a..50735a7c48 100644 --- a/mobile/lib/models/subscription.dart +++ b/mobile/lib/models/subscription.dart @@ -1,6 +1,7 @@ import 'dart:convert'; const freeProductID = "free"; +const popularProductIDs = ["200gb_yearly", "200gb_monthly"]; const stripe = "stripe"; const appStore = "appstore"; const playStore = "playstore"; @@ -47,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/theme/colors.dart b/mobile/lib/theme/colors.dart index 694106e398..be8ed8e7e4 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; @@ -74,6 +75,7 @@ class EnteColorScheme { this.fillMuted, this.fillFaint, this.fillFaintPressed, + this.fillBaseGrey, this.strokeBase, this.strokeMuted, this.strokeFaint, @@ -114,6 +116,7 @@ const EnteColorScheme lightScheme = EnteColorScheme( fillMutedLight, fillFaintLight, fillFaintPressedLight, + fillBaseGreyLight, strokeBaseLight, strokeMutedLight, strokeFaintLight, @@ -142,6 +145,7 @@ const EnteColorScheme darkScheme = EnteColorScheme( fillMutedDark, fillFaintDark, fillFaintPressedDark, + fillBaseGreyDark, strokeBaseDark, strokeMutedDark, strokeFaintDark, @@ -189,6 +193,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); @@ -196,6 +201,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); @@ -216,7 +222,6 @@ const Color blurStrokePressedDark = Color.fromRGBO(255, 255, 255, 0.50); // Other colors const Color tabIconLight = Color.fromRGBO(0, 0, 0, 0.85); - const Color tabIconDark = Color.fromRGBO(255, 255, 255, 0.80); // Fixed Colors 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 3925bf0177..fc7fa5adeb 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'; @@ -14,19 +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'; @@ -37,8 +37,8 @@ class StoreSubscriptionPage extends StatefulWidget { const StoreSubscriptionPage({ this.isOnboarding = false, - Key? key, - }) : super(key: key); + super.key, + }); @override State createState() => _StoreSubscriptionPageState(); @@ -69,9 +69,9 @@ class _StoreSubscriptionPageState extends State { @override void initState() { + super.initState(); _billingService.setIsOnSubscriptionPage(true); _setupPurchaseUpdateStreamListener(); - super.initState(); } void _setupPurchaseUpdateStreamListener() { @@ -155,20 +155,42 @@ class _StoreSubscriptionPageState extends State { @override Widget build(BuildContext context) { + final textTheme = getEnteTextTheme(context); colorScheme = getEnteColorScheme(context); if (!_isLoading) { _isLoading = true; _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, - body: _getBody(), + appBar: AppBar(), + 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" + : "${S.of(context).subscription}${kDebugMode ? ' Store' : ''}", + ), + _isFreePlanUser() || !_hasLoadedData + ? const SizedBox.shrink() + : Text( + convertBytesToReadableFormat( + _userDetails.getTotalStorage(), + ), + style: textTheme.smallMuted, + ), + ], + ), + ), + Expanded(child: _getBody()), + ], + ), ); } @@ -233,6 +255,17 @@ class _StoreSubscriptionPageState extends State { ), ); + if (hasYearlyPlans) { + widgets.add( + SubscriptionToggle( + onToggle: (p0) { + showYearlyPlan = p0; + _filterStorePlansForUi(); + }, + ), + ); + } + widgets.addAll([ Column( mainAxisAlignment: MainAxisAlignment.center, @@ -240,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( @@ -254,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 && @@ -285,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( @@ -302,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() @@ -328,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, @@ -385,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; @@ -457,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; } @@ -470,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, ), ), ); @@ -494,11 +487,35 @@ class _StoreSubscriptionPageState extends State { _currentSubscription!.productID == freeProductID) { foundActivePlan = true; planWidgets.add( - SubscriptionPlanWidget( - storage: _freePlan.storage, - price: S.of(context).freeTrial, - period: "", - isActive: true, + 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, + ), ), ); } @@ -510,71 +527,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, ), ), ); @@ -594,17 +611,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() & 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: _currentSubscription!.storage, + price: _currentSubscription!.price, + period: _currentSubscription!.period, + isActive: true, + isOnboarding: widget.isOnboarding, ), ), ); } + + bool _isPopularPlan(BillingPlan plan) { + return popularProductIDs.contains(plan.id); + } } diff --git a/mobile/lib/ui/payment/stripe_subscription_page.dart b/mobile/lib/ui/payment/stripe_subscription_page.dart index 31694f174b..7ab1a0f9cc 100644 --- a/mobile/lib/ui/payment/stripe_subscription_page.dart +++ b/mobile/lib/ui/payment/stripe_subscription_page.dart @@ -1,32 +1,32 @@ 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/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'; 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/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/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/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"; +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'; @@ -38,8 +38,8 @@ class StripeSubscriptionPage extends StatefulWidget { const StripeSubscriptionPage({ this.isOnboarding = false, - Key? key, - }) : super(key: key); + super.key, + }); @override State createState() => _StripeSubscriptionPageState(); @@ -64,11 +64,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 +122,65 @@ 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", + ), + _isFreePlanUser() || !_hasLoadedData + ? const SizedBox.shrink() + : Text( + convertBytesToReadableFormat( + _userDetails.getTotalStorage(), + ), + style: textTheme.smallMuted, + ), + ], + ), ), + Expanded(child: _getBody()), ], ), ); @@ -211,6 +212,15 @@ class _StripeSubscriptionPageState extends State { ), ); + widgets.add( + SubscriptionToggle( + onToggle: (p0) { + _showYearlyPlan = p0; + _filterStripeForUI(); + }, + ), + ); + widgets.addAll([ Column( mainAxisAlignment: MainAxisAlignment.center, @@ -219,8 +229,6 @@ class _StripeSubscriptionPageState extends State { const Padding(padding: EdgeInsets.all(4)), ]); - widgets.add(_showSubscriptionToggle()); - if (_currentSubscription != null) { widgets.add( ValidityWidget( @@ -228,49 +236,23 @@ 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); } if (_currentSubscription!.productID == freeProductID) { - if (widget.isOnboarding) { - widgets.add(SkipSubscriptionWidget(freePlan: _freePlan)); - } widgets.add( SubFaqWidget(isOnboarding: widget.isOnboarding), ); } - // 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, @@ -290,9 +272,43 @@ class _StripeSubscriptionPageState extends State { ), ); widgets.add(ViewAddOnButton(_userDetails.bonusData)); - widgets.add(const SizedBox(height: 80)); } + if (_currentSubscription!.productID != freeProductID) { + widgets.add( + Padding( + padding: const EdgeInsets.fromLTRB(16, 2, 16, 2), + child: MenuItemWidget( + captionedTextWidget: CaptionedTextWidget( + title: "Manage payment method", + ), + 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)); + return SingleChildScrollView( child: Column( mainAxisAlignment: MainAxisAlignment.spaceEvenly, @@ -360,16 +376,20 @@ 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 { + alwaysShowSuccessState: false, + surfaceExecutionStates: false, + 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( @@ -452,9 +472,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; } @@ -515,13 +553,15 @@ class _StripeSubscriptionPageState extends State { }, ), ).then((value) => onWebPaymentGoBack(value)); - }, - 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, ), ), ); @@ -537,67 +577,14 @@ class _StripeSubscriptionPageState extends State { freeProductID == _currentSubscription!.productID; } - 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)), - ], - ), - ); + 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. + if (_showYearlyPlan != _currentSubscription!.isYearlyPlan() && _currentSubscription!.productID != freeProductID) { return; @@ -610,15 +597,34 @@ 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() && 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: _currentSubscription!.storage, + 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 6d3cf66594..5a1c95c563 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"; @@ -16,10 +15,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 +29,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, 32, 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), - ), - ], - ), + ), + ], ), ), ); @@ -86,15 +68,17 @@ 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) { - return const SizedBox.shrink(); - } final List addOnBonus = bonusData?.getAddOnBonuses() ?? []; + if (currentSubscription == null || + (currentSubscription!.isFreePlan() && addOnBonus.isEmpty)) { + return const SizedBox( + height: 56, + ); + } final bool isFreeTrialSub = currentSubscription!.productID == freeProductID; bool hideSubValidityView = false; if (isFreeTrialSub && addOnBonus.isNotEmpty) { @@ -109,11 +93,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; @@ -121,15 +101,21 @@ class ValidityWidget extends StatelessWidget { } return Padding( - padding: const EdgeInsets.only(top: 0), + padding: const EdgeInsets.fromLTRB(16, 16, 16, 16), 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(), ], @@ -151,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, ), ); @@ -170,7 +156,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, @@ -197,3 +183,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); + } +} diff --git a/mobile/lib/ui/payment/subscription_plan_widget.dart b/mobile/lib/ui/payment/subscription_plan_widget.dart index 00d8769fe8..185c4e0462 100644 --- a/mobile/lib/ui/payment/subscription_plan_widget.dart +++ b/mobile/lib/ui/payment/subscription_plan_widget.dart @@ -1,73 +1,127 @@ +import "package:flutter/foundation.dart"; import 'package:flutter/material.dart'; -import "package:photos/generated/l10n.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'; -class SubscriptionPlanWidget extends StatelessWidget { +class SubscriptionPlanWidget extends StatefulWidget { const SubscriptionPlanWidget({ - Key? key, + super.key, required this.storage, required this.price, required this.period, + required this.isOnboarding, this.isActive = false, - }) : super(key: key); + this.isPopular = false, + }); final int storage; final String price; final String period; final bool isActive; + final bool isPopular; + final bool isOnboarding; - String _displayPrice(BuildContext context) { - // todo: l10n pricing part - final result = price + (period.isNotEmpty ? " / " + period : ""); - return price.isNotEmpty ? result : S.of(context).freeTrial; + @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 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), + final brightness = _platformDispatcher.platformBrightness; + final numAndUnit = convertBytesToNumberAndUnit(widget.storage); + final String storageValue = numAndUnit.$1.toString(); + final String storageUnit = numAndUnit.$2; + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), 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), - ], + color: backgroundElevated2Light, + borderRadius: BorderRadius.circular(8), + 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, + ), + ], ), - // color: Colors.yellow, - padding: - EdgeInsets.symmetric(horizontal: isActive ? 22 : 20, vertical: 18), - child: Column( + child: Stack( children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - convertBytesToReadableFormat(storage), - style: Theme.of(context) - .textTheme - .titleLarge! - .copyWith(color: textColor), - ), - Text( - _displayPrice(context), - style: Theme.of(context).textTheme.titleLarge!.copyWith( - color: textColor, - fontWeight: FontWeight.normal, + widget.isActive && !widget.isOnboarding + ? 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), + ], + ), ), ], ), @@ -75,3 +129,57 @@ class SubscriptionPlanWidget extends StatelessWidget { ); } } + +class _Price extends StatelessWidget { + final String price; + final String period; + const _Price({required this.price, required this.period}); + + @override + Widget build(BuildContext context) { + final textTheme = getEnteTextTheme(context); + if (price.isEmpty) { + return Text( + "Free", + style: textTheme.largeBold.copyWith(color: textBaseLight), + ); + } + if (period == "month") { + 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); + final priceDouble = double.parse(priceWithoutCurrency); + final pricePerMonth = priceDouble / 12; + final pricePerMonthString = pricePerMonth.toStringAsFixed(2); + return Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + currencySymbol + pricePerMonthString + ' / ' + 'month', + style: textTheme.largeBold.copyWith(color: textBaseLight), + ), + Text( + price + " / " + "yr", + style: textTheme.small.copyWith(color: textFaintLight), + ), + ], + ) + .animate(delay: const Duration(milliseconds: 100)) + .fadeIn(duration: const Duration(milliseconds: 250)); + } else { + assert(false, "Invalid period: $period"); + return const Text(""); + } + } +} 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, 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"); 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; diff --git a/mobile/lib/utils/lock_screen_settings.dart b/mobile/lib/utils/lock_screen_settings.dart index dc47a61e8a..7349632f88 100644 --- a/mobile/lib/utils/lock_screen_settings.dart +++ b/mobile/lib/utils/lock_screen_settings.dart @@ -127,11 +127,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; 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: 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", } } 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/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/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/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/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/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 new file mode 100644 index 0000000000..9006614f98 --- /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": "", + "active": "Ενεργό", + "subscription_info_free": "", + "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": "" +} 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}}", 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}} 取消", diff --git a/web/packages/base/types/ipc.ts b/web/packages/base/types/ipc.ts index 7a11553835..c0644760c0 100644 --- a/web/packages/base/types/ipc.ts +++ b/web/packages/base/types/ipc.ts @@ -335,57 +335,21 @@ export interface Electron { // - ML /** - * Return a CLIP embedding of the given image. + * Create a new ML worker, terminating the older ones (if any). * - * See: [Note: Natural language search using CLIP] + * 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". * - * 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. + * At the other end of that port will be an object that conforms to the + * {@link ElectronMLWorker} interface. * - * @returns A CLIP embedding (an array of 512 floating point values). + * 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 createMLWorker}. */ - 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; + createMLWorker: () => void; // - Watch @@ -574,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/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/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 = ({ 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/services/ml/clip.ts b/web/packages/new/photos/services/ml/clip.ts index eecf7e2209..78eff1c04d 100644 --- a/web/packages/new/photos/services/ml/clip.ts +++ b/web/packages/new/photos/services/ml/clip.ts @@ -1,9 +1,9 @@ -import type { Electron } 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 { MLWorkerElectron } from "./worker-types"; +import type { CLIPMatches } from "./worker-types"; /** * The version of the CLIP indexing pipeline implemented by the current client. @@ -98,19 +98,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)); @@ -167,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/embedding.ts b/web/packages/new/photos/services/ml/embedding.ts index 6da4e765d1..32395476be 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. 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/index.ts b/web/packages/new/photos/services/ml/index.ts index c3432b1023..5b57dade21 100644 --- a/web/packages/new/photos/services/ml/index.ts +++ b/web/packages/new/photos/services/ml/index.ts @@ -6,17 +6,20 @@ 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"; +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"; 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. @@ -33,7 +36,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}. @@ -50,29 +53,28 @@ 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(); - const mlWorkerElectron = { - detectFaces: electron.detectFaces, - computeFaceEmbeddings: electron.computeFaceEmbeddings, - computeCLIPImageEmbedding: electron.computeCLIPImageEmbedding, - }; const delegate = { 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)), ); + await cw.remote.then((w) => - w.init(proxy(mlWorkerElectron), proxy(delegate)), + // Forward the port to the web worker. + w.init(transfer(messagePort, [messagePort]), proxy(delegate)), ); + return cw; }; @@ -85,13 +87,40 @@ 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; } }; +/** + * Obtain a port from the Node.js layer that can be used to communicate with the + * ML worker process. + */ +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. + + 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") { + window.removeEventListener("message", l); + resolve(ensure(ports[0])); + } + }; + window.addEventListener("message", l); + }); + + electron.createMLWorker(); + + return port; +}; + /** * Return true if the current client supports ML. * @@ -163,7 +192,7 @@ export const disableML = async () => { await updateIsMLEnabledRemote(false); setIsMLEnabledLocal(false); _isMLEnabled = false; - terminateMLWorker(); + await terminateMLWorker(); triggerStatusUpdate(); }; @@ -369,6 +398,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 1eb43933a3..72d6bce61b 100644 --- a/web/packages/new/photos/services/ml/worker-types.ts +++ b/web/packages/new/photos/services/ml/worker-types.ts @@ -1,22 +1,7 @@ /** - * @file Type 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. */ -/** - * 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 +10,18 @@ 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; } + +/** + * 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 ca9e4bd054..e012ebab16 100644 --- a/web/packages/new/photos/services/ml/worker.ts +++ b/web/packages/new/photos/services/ml/worker.ts @@ -3,14 +3,15 @@ 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"; 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 { cmpNewLib2, extractRawExif, type RawExifTags } from "../exif"; import { getAllLocalFiles, getLocalTrashedFiles } from "../files"; import type { UploadItem } from "../upload/types"; import { @@ -18,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, @@ -29,10 +35,11 @@ import { import { fetchDerivedData, putDerivedData, + type RawRemoteDerivedData, type RemoteDerivedData, } from "./embedding"; import { faceIndexingVersion, indexFaces, type FaceIndex } from "./face"; -import type { MLWorkerDelegate, MLWorkerElectron } from "./worker-types"; +import type { CLIPMatches, MLWorkerDelegate } from "./worker-types"; const idleDurationStart = 5; /* 5 seconds */ const idleDurationMax = 16 * 60; /* 16 minutes */ @@ -64,14 +71,16 @@ interface IndexableItem { * where: * * - "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 + * particular, for finding the closest CLIP match when the user does a search. */ export class MLWorker { - private electron: MLWorkerElectron | undefined; + private electron: ElectronMLWorker | undefined; private delegate: MLWorkerDelegate | undefined; - private state: "idle" | "indexing" = "idle"; + private state: "idle" | "tick" | "pull" | "indexing" = "idle"; private liveQ: IndexableItem[] = []; private idleTimeout: ReturnType | undefined; private idleDuration = idleDurationStart; /* unit: seconds */ @@ -82,15 +91,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(electron: MLWorkerElectron, delegate?: MLWorkerDelegate) { - this.electron = electron; + async init(port: MessagePort, delegate: MLWorkerDelegate) { + 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. @@ -119,9 +129,11 @@ export class MLWorker { * 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. + * waiting for it complete. + * + * 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.wakeUp(); @@ -130,9 +142,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 = "tick"; + // Enqueue a tick. void this.tick(); } else { // In the middle of a task. Do nothing, `this.tick` will @@ -176,6 +192,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", @@ -193,7 +216,7 @@ export class MLWorker { this.state = "indexing"; // Use the liveQ if present, otherwise get the next batch to backfill. - const items = liveQ.length > 0 ? liveQ : await this.backfillQ(); + const items = liveQ.length ? liveQ : await this.backfillQ(); const allSuccess = await indexNextBatch( items, @@ -224,7 +247,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( @@ -256,7 +279,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 @@ -270,19 +293,42 @@ 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); - delegate?.workerDidProcessFile(); - // Possibly unnecessary, but let us drain the microtask queue. - await wait(0); - } 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 (i < items.length && !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); } + // Wait for the pending tasks to drain out. + await Promise.all(tasks); + // Return true if nothing failed. return allSuccess; }; @@ -373,7 +419,7 @@ const syncWithLocalFilesAndGetFilesToIndex = async ( */ const index = async ( { enteFile, uploadItem, remoteDerivedData }: IndexableItem, - electron: MLWorkerElectron, + electron: ElectronMLWorker, ) => { const f = fileLogID(enteFile); const fileID = enteFile.id; @@ -391,13 +437,14 @@ 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 ( 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 }; } @@ -411,10 +458,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 }, @@ -462,10 +509,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] @@ -474,15 +518,19 @@ const index = async ( throw e; } - if (originalImageBlob) - 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; 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)`; }); @@ -503,22 +551,27 @@ 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]); + 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 { @@ -549,3 +602,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; + } +}; 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;