mirror of
https://github.com/ente-io/ente.git
synced 2025-08-11 00:42:41 +00:00
Merge branch 'main' into quick_links
This commit is contained in:
commit
2fd960eb0e
@ -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);
|
||||
}
|
||||
|
||||
|
@ -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"
|
||||
}
|
@ -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();
|
||||
}
|
||||
|
@ -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()
|
||||
|
@ -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
|
||||
|
203
auth/lib/ui/settings/lock_screen/custom_pin_keypad.dart
Normal file
203
auth/lib/ui/settings/lock_screen/custom_pin_keypad.dart
Normal 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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
143
auth/lib/ui/settings/lock_screen/lock_screen_auto_lock.dart
Normal file
143
auth/lib/ui/settings/lock_screen/lock_screen_auto_lock.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
@ -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)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
211
auth/lib/ui/settings/lock_screen/lock_screen_confirm_pin.dart
Normal file
211
auth/lib/ui/settings/lock_screen/lock_screen_confirm_pin.dart
Normal 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();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
368
auth/lib/ui/settings/lock_screen/lock_screen_options.dart
Normal file
368
auth/lib/ui/settings/lock_screen/lock_screen_options.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
250
auth/lib/ui/settings/lock_screen/lock_screen_password.dart
Normal file
250
auth/lib/ui/settings/lock_screen/lock_screen_password.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
284
auth/lib/ui/settings/lock_screen/lock_screen_pin.dart
Normal file
284
auth/lib/ui/settings/lock_screen/lock_screen_pin.dart
Normal 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);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -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,
|
||||
]);
|
||||
|
@ -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) {
|
||||
|
@ -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");
|
||||
}
|
||||
|
@ -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,
|
||||
),
|
||||
|
@ -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 {
|
||||
|
155
auth/lib/utils/lock_screen_settings.dart
Normal file
155
auth/lib/utils/lock_screen_settings.dart
Normal 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);
|
||||
}
|
||||
}
|
@ -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:
|
||||
|
@ -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
|
||||
|
@ -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",
|
||||
|
@ -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();
|
||||
|
@ -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.
|
||||
|
@ -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;
|
||||
};
|
@ -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;
|
||||
};
|
315
desktop/src/main/services/ml-worker.ts
Normal file
315
desktop/src/main/services/ml-worker.ts
Normal 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;
|
||||
};
|
@ -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);
|
||||
});
|
||||
};
|
||||
|
@ -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.
|
||||
|
42
desktop/src/main/utils/comlink.ts
Normal file
42
desktop/src/main/utils/comlink.ts
Normal 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),
|
||||
};
|
||||
};
|
39
desktop/src/main/utils/stream.ts
Normal file
39
desktop/src/main/utils/stream.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
};
|
@ -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
|
||||
|
||||
|
@ -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"
|
||||
|
@ -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?
|
||||
|
||||
|
BIN
mobile/assets/2.0x/active_subscription.png
Normal file
BIN
mobile/assets/2.0x/active_subscription.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 20 KiB |
BIN
mobile/assets/2.0x/popular_subscription.png
Normal file
BIN
mobile/assets/2.0x/popular_subscription.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 28 KiB |
BIN
mobile/assets/3.0x/active_subscription.png
Normal file
BIN
mobile/assets/3.0x/active_subscription.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 39 KiB |
BIN
mobile/assets/3.0x/popular_subscription.png
Normal file
BIN
mobile/assets/3.0x/popular_subscription.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 54 KiB |
BIN
mobile/assets/active_subscription.png
Normal file
BIN
mobile/assets/active_subscription.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 6.4 KiB |
BIN
mobile/assets/popular_subscription.png
Normal file
BIN
mobile/assets/popular_subscription.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 8.8 KiB |
@ -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 {
|
||||
|
@ -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];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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(
|
||||
|
@ -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
|
||||
|
@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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("");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -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");
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
|
@ -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:
|
||||
|
@ -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",
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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 */
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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,
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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;
|
||||
};
|
||||
|
@ -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}}",
|
||||
|
645
web/packages/base/locales/el-GR/translation.json
Normal file
645
web/packages/base/locales/el-GR/translation.json
Normal 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": ""
|
||||
}
|
@ -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}}",
|
||||
|
@ -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}}",
|
||||
|
@ -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}}",
|
||||
|
@ -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}}",
|
||||
|
@ -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}}",
|
||||
|
@ -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}}",
|
||||
|
@ -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}}",
|
||||
|
@ -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}}",
|
||||
|
@ -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}}",
|
||||
|
@ -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}} 取消",
|
||||
|
@ -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.
|
||||
*
|
||||
|
@ -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;
|
||||
|
||||
|
@ -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>
|
||||
|
@ -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}
|
||||
/>
|
||||
|
||||
|
@ -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(
|
||||
|
@ -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;
|
||||
|
||||
|
@ -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.
|
||||
|
@ -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);
|
||||
|
||||
|
@ -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.
|
||||
|
@ -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>;
|
||||
|
@ -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;
|
||||
}
|
||||
};
|
||||
|
@ -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;
|
||||
|
Loading…
x
Reference in New Issue
Block a user