Merge branch 'main' into quick_links

This commit is contained in:
ashilkn 2024-08-01 20:10:43 +05:30
commit 2fd960eb0e
84 changed files with 4688 additions and 1213 deletions

View File

@ -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<bool> 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<void> setShouldShowLockScreen(bool value) {
Future<void> setSystemLockScreen(bool value) {
return _preferences.setBool(keyShouldShowLockScreen, value);
}

View File

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

View File

@ -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<void> _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<void> _init(bool bool, {String? via}) async {
await NotificationService.instance.init();
await UpdateService.instance.init();
await IconUtils.instance.init();
}
Future<void> _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();
}

View File

@ -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<bool> 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<bool> 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<bool> _isLocalAuthSupportedOnDevice() async {
Future<bool> isLocalAuthSupportedOnDevice() async {
try {
return Platform.isMacOS || Platform.isLinux
? await FlutterLocalAuthentication().canAuthenticate()

View File

@ -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<TextInputWidget> {
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<TextInputWidget> {
///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<TextInputWidget> {
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<TextInputWidget> {
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<TextInputWidget> {
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

View File

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

View File

@ -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<LockScreenAutoLock> createState() => _LockScreenAutoLockState();
}
class _LockScreenAutoLockState extends State<LockScreenAutoLock> {
@override
Widget build(BuildContext context) {
return Scaffold(
body: CustomScrollView(
primary: false,
slivers: <Widget>[
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<AutoLockItems> createState() => _AutoLockItemsState();
}
class _AutoLockItemsState extends State<AutoLockItems> {
final autoLockDurations = LockScreenSettings.instance.autoLockDurations;
List<Widget> 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;
}
}
}

View File

@ -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<LockScreenConfirmPassword> createState() =>
_LockScreenConfirmPasswordState();
}
class _LockScreenConfirmPasswordState extends State<LockScreenConfirmPassword> {
final _confirmPasswordController = TextEditingController(text: null);
final LockScreenSettings _lockscreenSetting = LockScreenSettings.instance;
final _focusNode = FocusNode();
final _isFormValid = ValueNotifier<bool>(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<void> _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<bool>(
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)),
],
),
),
),
);
}
}

View File

@ -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<LockScreenConfirmPin> createState() => _LockScreenConfirmPinState();
}
class _LockScreenConfirmPinState extends State<LockScreenConfirmPin> {
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<void> _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<double>(
tween: Tween<double>(
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();
},
),
],
),
);
}
}

View File

@ -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<LockScreenOptions> createState() => _LockScreenOptionsState();
}
class _LockScreenOptionsState extends State<LockScreenOptions> {
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<void> _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<void> _deviceLock() async {
await _lockscreenSetting.removePinAndPassword();
await _initializeSettings();
}
Future<void> _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<void> _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<void> _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<void> _onAutoLock() async {
await routeToPage(
context,
const LockScreenAutoLock(),
).then(
(value) {
setState(() {
autoLockTimeInMilliseconds = _lockscreenSetting.getAutoLockTime();
});
},
);
}
Future<void> _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: <Widget>[
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,
),
),
],
),
);
}
}

View File

@ -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<LockScreenPassword> createState() => _LockScreenPasswordState();
}
class _LockScreenPasswordState extends State<LockScreenPassword> {
final _passwordController = TextEditingController(text: null);
final _focusNode = FocusNode();
final _isFormValid = ValueNotifier<bool>(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<bool>(
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<bool> _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<void> _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();
}
}
}

View File

@ -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<LockScreenPin> createState() => _LockScreenPinState();
}
class _LockScreenPinState extends State<LockScreenPin> {
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<bool> 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<void> _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<double>(
tween: Tween<double>(
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);
},
),
],
),
);
}
}

View File

@ -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<SecuritySectionWidget> {
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<SecuritySectionWidget> {
),
),
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<SecuritySectionWidget> {
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,
]);

View File

@ -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<AppLock> 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) {

View File

@ -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<LockScreen> 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<LockScreen> 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<double>(
tween: Tween<double>(
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<LockScreen> 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<LockScreen> 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<LockScreen> with WidgetsBindingObserver {
super.dispose();
}
Future<void> 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<void> _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<void> _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");
}

View File

@ -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<TwoFactorAuthenticationPage> {
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,
),

View File

@ -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<bool> requestAuthentication(BuildContext context, String reason) async {
Future<bool> 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 {

View File

@ -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<Duration> 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<void> init() async {
_secureStorage = const FlutterSecureStorage();
_preferences = await SharedPreferences.getInstance();
///Workaround for privacyScreen not working when app is killed and opened.
await setHideAppContent(getShouldHideAppContent());
}
Future<void> 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<void> setAutoLockTime(Duration duration) async {
await _preferences.setInt(autoLockTime, duration.inMilliseconds);
}
int getAutoLockTime() {
return _preferences.getInt(autoLockTime) ?? 5000;
}
Future<void> setLastInvalidAttemptTime(int time) async {
await _preferences.setInt(lastInvalidAttemptTime, time);
}
int getlastInvalidAttemptTime() {
return _preferences.getInt(lastInvalidAttemptTime) ?? 0;
}
int getInvalidAttemptCount() {
return _preferences.getInt(keyInvalidAttempts) ?? 0;
}
Future<void> setInvalidAttemptCount(int count) async {
await _preferences.setInt(keyInvalidAttempts, count);
}
static Uint8List _generateSalt() {
return sodium.randombytes.buf(sodium.crypto.pwhash.saltBytes);
}
Future<void> 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<Uint8List?> getSalt() async {
final String? salt = await _secureStorage.read(key: saltKey);
if (salt == null) return null;
return base64Decode(salt);
}
Future<String?> getPin() async {
return _secureStorage.read(key: pin);
}
Future<void> 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<String?> getPassword() async {
return _secureStorage.read(key: password);
}
Future<void> removePinAndPassword() async {
await _secureStorage.delete(key: saltKey);
await _secureStorage.delete(key: pin);
await _secureStorage.delete(key: password);
}
Future<bool> isPinSet() async {
return await _secureStorage.containsKey(key: pin);
}
Future<bool> isPasswordSet() async {
return await _secureStorage.containsKey(key: password);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<string, unknown>)
.cpuData as Float32Array;
};

View File

@ -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<ort.InferenceSession> | 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<string, unknown>)
.cpuData as Float32Array;
};

View File

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

View File

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

View File

@ -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<NL, EL>();
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),
};
};

View File

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

View File

@ -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
/// <reference lib="dom" />
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

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

View File

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

View File

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

View File

@ -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<String, dynamic>? map) {
if (map == null) return null;
return Subscription(

View File

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

View File

@ -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<TextStyle>(
(Set<MaterialState> 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),
),
);
}
}

View File

@ -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<StoreSubscriptionPage> createState() => _StoreSubscriptionPageState();
@ -69,9 +69,9 @@ class _StoreSubscriptionPageState extends State<StoreSubscriptionPage> {
@override
void initState() {
super.initState();
_billingService.setIsOnSubscriptionPage(true);
_setupPurchaseUpdateStreamListener();
super.initState();
}
void _setupPurchaseUpdateStreamListener() {
@ -155,20 +155,42 @@ class _StoreSubscriptionPageState extends State<StoreSubscriptionPage> {
@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<StoreSubscriptionPage> {
),
);
if (hasYearlyPlans) {
widgets.add(
SubscriptionToggle(
onToggle: (p0) {
showYearlyPlan = p0;
_filterStorePlansForUi();
},
),
);
}
widgets.addAll([
Column(
mainAxisAlignment: MainAxisAlignment.center,
@ -240,13 +273,9 @@ class _StoreSubscriptionPageState extends State<StoreSubscriptionPage> {
? _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<StoreSubscriptionPage> {
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<StoreSubscriptionPage> {
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<StoreSubscriptionPage> {
);
}
}
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<StoreSubscriptionPage> {
),
);
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<StoreSubscriptionPage> {
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<bool>>[
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<Widget> _getStripePlanWidgets() {
final List<Widget> planWidgets = [];
bool foundActivePlan = false;
@ -457,10 +431,27 @@ class _StoreSubscriptionPageState extends State<StoreSubscriptionPage> {
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<StoreSubscriptionPage> {
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<StoreSubscriptionPage> {
_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<StoreSubscriptionPage> {
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<StoreSubscriptionPage> {
}
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);
}
}

View File

@ -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<StripeSubscriptionPage> createState() => _StripeSubscriptionPageState();
@ -64,11 +64,6 @@ class _StripeSubscriptionPageState extends State<StripeSubscriptionPage> {
EnteColorScheme colorScheme = darkScheme;
final Logger logger = Logger("StripeSubscriptionPage");
@override
void initState() {
super.initState();
}
Future<void> _fetchSub() async {
return _userService
.getUserDetailsV2(memoryCount: false)
@ -127,59 +122,65 @@ class _StripeSubscriptionPageState extends State<StripeSubscriptionPage> {
}
}
@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<StripeSubscriptionPage> {
),
);
widgets.add(
SubscriptionToggle(
onToggle: (p0) {
_showYearlyPlan = p0;
_filterStripeForUI();
},
),
);
widgets.addAll([
Column(
mainAxisAlignment: MainAxisAlignment.center,
@ -219,8 +229,6 @@ class _StripeSubscriptionPageState extends State<StripeSubscriptionPage> {
const Padding(padding: EdgeInsets.all(4)),
]);
widgets.add(_showSubscriptionToggle());
if (_currentSubscription != null) {
widgets.add(
ValidityWidget(
@ -228,49 +236,23 @@ class _StripeSubscriptionPageState extends State<StripeSubscriptionPage> {
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<StripeSubscriptionPage> {
),
);
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<StripeSubscriptionPage> {
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<StripeSubscriptionPage> {
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<StripeSubscriptionPage> {
},
),
).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<StripeSubscriptionPage> {
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<bool>>[
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<Widget> 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<StripeSubscriptionPage> {
}
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,
),
),
);

View File

@ -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<StatefulWidget> createState() {
@ -30,51 +29,34 @@ class SubscriptionHeaderWidget extends StatefulWidget {
class _SubscriptionHeaderWidgetState extends State<SubscriptionHeaderWidget> {
@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<Bonus> addOnBonus = bonusData?.getAddOnBonuses() ?? <Bonus>[];
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<SubscriptionToggle> createState() => _SubscriptionToggleState();
}
class _SubscriptionToggleState extends State<SubscriptionToggle> {
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);
}
}

View File

@ -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<SubscriptionPlanWidget> createState() => _SubscriptionPlanWidgetState();
}
class _SubscriptionPlanWidgetState extends State<SubscriptionPlanWidget> {
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("");
}
}
}

View File

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

View File

@ -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<ZoomableImage> createState() => _ZoomableImageState();
@ -359,29 +357,6 @@ class _ZoomableImageState extends State<ZoomableImage> {
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<void> _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");

View File

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

View File

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

View File

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

View File

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

View File

@ -82,7 +82,8 @@ export const register = async (): Promise<Registration> => {
// 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 */

View File

@ -82,7 +82,7 @@ export default function AdvancedSettings({ open, onClose, onRootClose }) {
<EnteMenuItem
endIcon={<ChevronRight />}
onClick={() => setOpenMLSettings(true)}
label={pt("ML search")}
label={pt("Face and magic search")}
/>
</MenuItemGroup>
</Box>

View File

@ -85,14 +85,9 @@ export default function Preferences({ open, onClose, onRootClose }) {
<EnteMenuItem
endIcon={<ChevronRight />}
onClick={() => setOpenMLSettings(true)}
label={pt("ML search")}
label={pt("Face and magic search")}
/>
</MenuItemGroup>
<MenuSectionTitle
title={pt(
"Face recognition, magic search and more",
)}
/>
</Box>
)}
</Stack>

View File

@ -289,14 +289,7 @@ const SubscriptionStatus: React.FC<SubscriptionStatusProps> = ({
if (!hasAddOnBonus(userDetails.bonusData)) {
if (isSubscriptionActive(userDetails.subscription)) {
if (isOnFreePlan(userDetails.subscription)) {
message = (
<Trans
i18nKey={"subscription_info_free"}
values={{
date: userDetails.subscription?.expiryTime,
}}
/>
);
message = t("subscription_info_free");
} else if (isSubscriptionCancelled(userDetails.subscription)) {
message = t("subscription_info_renewal_cancelled", {
date: userDetails.subscription?.expiryTime,

View File

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

View File

@ -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<ClipSearchScores | undefined> => {
if (!isMLEnabled()) return undefined;
const matches = await clipMatches(searchPhrase, ensureElectron());
const matches = await clipMatches(searchPhrase);
log.debug(() => ["clip/scores", matches]);
return matches;
};

View File

@ -153,10 +153,10 @@
"CURRENT_USAGE": "Aktuelle Nutzung ist <strong>{{usage}}</strong>",
"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 <strong>kostenlosen</strong> 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 <a>erneuere es</a>",
"subscription_info_renewal_cancelled": "Ihr Abo endet am {{date, date}}",

View File

@ -0,0 +1,645 @@
{
"HERO_SLIDE_1_TITLE": "<div>Ιδιωτικά αντίγραφα ασφαλείας</div><div>για τις αναμνήσεις σας</div>",
"HERO_SLIDE_1": "Από προεπιλογή κρυπτογραφημένο από άκρο σε άκρο",
"HERO_SLIDE_2_TITLE": "",
"HERO_SLIDE_2": "Σχεδιάστηκε για να επιζήσει",
"HERO_SLIDE_3_TITLE": "<div>Διαθέσιμο</div><div> παντού</div>",
"HERO_SLIDE_3": "",
"LOGIN": "Σύνδεση",
"SIGN_UP": "Εγγραφή",
"NEW_USER": "Νέος/α στο Ente",
"EXISTING_USER": "Υπάρχων χρήστης",
"ENTER_NAME": "Εισάγετε όνομα",
"PUBLIC_UPLOADER_NAME_MESSAGE": "Προσθέστε ένα όνομα, ώστε οι φίλοι σας να γνωρίζουν ποιον να ευχαριστήσουν για αυτές τις υπέροχες φωτογραφίες!",
"ENTER_EMAIL": "Εισάγετε διεύθυνση ηλ. ταχυδρομείου",
"EMAIL_ERROR": "Εισάγετε μία έγκυρη διεύθυνση ηλ. ταχυδρομείου",
"REQUIRED": "Υποχρεωτικό",
"EMAIL_SENT": "Ο κωδικός επαλήθευσης στάλθηκε στο <a>{{email}}</a>",
"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": "Καλώς ήρθατε στο <a/>",
"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": "Παρακαλώ αφήστε ένα μήνυμα ηλ. ταχυδρομείου στο <a>{{emailID}}</a> από την καταχωρημένη διεύθυνση σας",
"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": "Η τρέχουσα χρήση είναι <strong>{{usage}}</strong>",
"TWO_MONTHS_FREE": "Αποκτήστε 2 μήνες δωρεάν στα ετήσια προγράμματα",
"POPULAR": "Δημοφιλές",
"free_plan_option": "",
"free_plan_description": "",
"active": "Ενεργό",
"subscription_info_free": "",
"subscription_info_family": "Είστε στο οικογενειακό πρόγραμμα που διαχειρίζεται από",
"subscription_info_expired": "Η συνδρομή σας έχει λήξει, παρακαλώ <a>ανανεώστε</a>",
"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": "Επικοινωνήστε μαζί μας στο <a>{{emailID}}</a> για να διαχειριστείτε τη συνδρομή σας",
"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": "Συμφωνώ με τους <a>όρους χρήσης</a> και την <b>πολιτική απορρήτου</b>",
"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": "Χρησιμοποιήστε τον κωδικό <strong>{{referralCode}}</strong> για να αποκτήσετε 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": ""
}

View File

@ -153,10 +153,10 @@
"CURRENT_USAGE": "Current usage is <strong>{{usage}}</strong>",
"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 <strong>free</strong> 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 <a>renew</a>",
"subscription_info_renewal_cancelled": "Your subscription will be cancelled on {{date, date}}",

View File

@ -153,10 +153,10 @@
"CURRENT_USAGE": "El uso actual es <strong>{{usage}}</strong>",
"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 <strong>gratis</strong> 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 <a>renuévala</a>",
"subscription_info_renewal_cancelled": "Tu suscripción será cancelada el {{date, date}}",

View File

@ -153,10 +153,10 @@
"CURRENT_USAGE": "L'utilisation actuelle est de <strong>{{usage}}</strong>",
"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 <strong>gratuit</strong> 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 <a>le renouveler </a>",
"subscription_info_renewal_cancelled": "Votre abonnement sera annulé le {{date, date}}",

View File

@ -153,10 +153,10 @@
"CURRENT_USAGE": "Pemakaian saat ini sebesar <strong>{{usage}}</strong>",
"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 <strong>gratis</strong> 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 <a>perpanjang</a>",
"subscription_info_renewal_cancelled": "Langganan kamu akan dibatalkan pada {{date, date}}",

View File

@ -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 <strong>gratuito</strong> 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 <a>rinnova</a>",
"subscription_info_renewal_cancelled": "Il tuo abbonamento verrà annullato il {{date, date}}",

View File

@ -153,10 +153,10 @@
"CURRENT_USAGE": "Huidig gebruik is <strong>{{usage}}</strong>",
"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 <strong>gratis</strong> 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 <a>vernieuwen</a>",
"subscription_info_renewal_cancelled": "Uw abonnement loopt af op {{date, date}}",

View File

@ -153,10 +153,10 @@
"CURRENT_USAGE": "Bieżące użycie to <strong>{{usage}}</strong>",
"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 <strong>bezpłatnym</strong> 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 <a>odnowienie</a>",
"subscription_info_renewal_cancelled": "Twoja subskrypcja zostanie anulowana dnia {{date, date}}",

View File

@ -153,10 +153,10 @@
"CURRENT_USAGE": "O uso atual é <strong>{{usage}}</strong>",
"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 <strong>gratuito</strong> 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 <a>renove-a</a>",
"subscription_info_renewal_cancelled": "Sua assinatura será cancelada em {{date, date}}",

View File

@ -153,10 +153,10 @@
"CURRENT_USAGE": "Текущее использование составляет <strong>{{usage}}</strong>",
"TWO_MONTHS_FREE": "Получите 2 месяца бесплатно по годовым планам",
"POPULAR": "Популярный",
"free_plan_option": "Продолжайте пользоваться бесплатной пробной версией",
"free_plan_description": "{{storage}} на 1 год",
"free_plan_option": "",
"free_plan_description": "",
"active": "Активный",
"subscription_info_free": "Вы используете <strong>бесплатный</strong> тарифный план, истекающий {{date, date}}",
"subscription_info_free": "",
"subscription_info_family": "Вы используете семейный план, управляемый",
"subscription_info_expired": "Срок действия вашей подписки истек, пожалуйста, <a>продлите</a>",
"subscription_info_renewal_cancelled": "Ваша подписка будет отменена в {{date, date}}",

View File

@ -153,10 +153,10 @@
"CURRENT_USAGE": "当前使用量是 <strong>{{usage}}</strong>",
"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}} 过期的<strong>免费</strong>计划",
"subscription_info_free": "",
"subscription_info_family": "您当前使用的计划由下列人员管理",
"subscription_info_expired": "您的订阅已过期,请 <a>续期</a>",
"subscription_info_renewal_cancelled": "您的订阅将于 {{date, date}} 取消",

View File

@ -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<Float32Array>;
/**
* 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<Float32Array | undefined>;
/**
* 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<Float32Array>;
/**
* 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<Float32Array>;
createMLWorker: () => void;
// - Watch
@ -574,6 +538,65 @@ export interface Electron {
clearPendingUploads: () => Promise<void>;
}
/**
* 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<Float32Array>;
/**
* 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<Float32Array | undefined>;
/**
* 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<Float32Array>;
/**
* 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<Float32Array>;
}
/**
* Errors that have special semantics on the web side.
*

View File

@ -28,7 +28,7 @@ export class ComlinkWorker<T extends new () => InstanceType<T>> {
/** The class (T) exposed by the web worker */
public remote: Promise<Remote<InstanceType<T>>>;
/** The web worker */
private worker: Worker;
public worker: Worker;
/** An arbitrary name associated with this ComlinkWorker for debugging. */
private name: string;

View File

@ -124,7 +124,7 @@ export const MLSettings: React.FC<MLSettingsProps> = ({
<Stack spacing={"4px"} py={"12px"}>
<Titlebar
onClose={onClose}
title={pt("ML search")}
title={pt("Face and magic search")}
onRootClose={onRootClose}
/>
{component}
@ -305,7 +305,7 @@ const ManageML: React.FC<ManageMLProps> = ({
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<ManageMLProps> = ({
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<ManageMLProps> = ({
justifyContent={"space-between"}
>
<Typography color="text.faint">
{pt("Status")}
{pt("Indexing")}
</Typography>
<Typography>{status}</Typography>
</Stack>

View File

@ -42,7 +42,7 @@ export const MLSettingsBeta: React.FC<MLSettingsBetaProps> = ({
<Stack spacing={"4px"} py={"12px"}>
<Titlebar
onClose={onClose}
title={pt("ML search")}
title={pt("Face and magic search")}
onRootClose={onRootClose}
/>

View File

@ -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<IndexableBlobs> =>
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<File> => {
if (typeof uploadItem == "string" || Array.isArray(uploadItem)) {
const { response, lastModifiedMs } = await readStream(

View File

@ -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<CLIPIndex> => ({
embedding: await computeEmbedding(image.data, electron),
});
const computeEmbedding = async (
imageData: ImageData,
electron: MLWorkerElectron,
electron: ElectronMLWorker,
): Promise<number[]> => {
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<Map<number, number> | undefined> => {
electron: ElectronMLWorker,
): Promise<CLIPMatches | undefined> => {
const t = await electron.computeCLIPTextEmbeddingIfAvailable(searchPhrase);
if (!t) return undefined;

View File

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

View File

@ -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<FaceIndex> => ({
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<Face[]> => {
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<YOLOFaceDetection[]> => {
const rect = ({ width, height }: Dimensions) => ({
x: 0,
@ -878,7 +878,7 @@ const mobileFaceNetEmbeddingSize = 192;
*/
const computeEmbeddings = async (
faceData: Float32Array,
electron: MLWorkerElectron,
electron: ElectronMLWorker,
): Promise<Float32Array[]> => {
const outputData = await electron.computeFaceEmbeddings(faceData);

View File

@ -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<typeof MLWorker> | undefined;
let _comlinkWorker: Promise<ComlinkWorker<typeof MLWorker>> | 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<typeof MLWorker>(
"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<MessagePort> => {
// 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<MessagePort>((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<CLIPMatches | undefined> =>
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.

View File

@ -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<Float32Array>;
computeFaceEmbeddings: (input: Float32Array) => Promise<Float32Array>;
computeCLIPImageEmbedding: (input: Float32Array) => Promise<Float32Array>;
}
/**
* 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<number, number>;

View File

@ -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<typeof setTimeout> | 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<ElectronMLWorker>(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<CLIPMatches | undefined> {
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<Promise<void> | 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<RawExifTags | undefined> => {
if (!originalImageBlob) return undefined;
try {
return await extractRawExif(originalImageBlob);
} catch (e) {
log.warn(`Ignoring error during Exif extraction for ${f}`, e);
return undefined;
}
};

View File

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