diff --git a/auth/assets/custom-icons/_data/custom-icons.json b/auth/assets/custom-icons/_data/custom-icons.json index f5581daee8..e986cc7b61 100644 --- a/auth/assets/custom-icons/_data/custom-icons.json +++ b/auth/assets/custom-icons/_data/custom-icons.json @@ -1,8 +1,6 @@ { "icons": [ - { "title": "1xBet", - "altNames": ["1x", "1x bet", "1x-bet" - ] + { "title": "1xBet" }, { "title": "3Commas" @@ -32,7 +30,12 @@ "title": "bitget" }, { - "title": "Bitmart" + "titile":"bitget wallet", + "slug":"bitget_wallet" + }, + { + "title": "Bitmart", + "hex":"000000" }, { "title": "BitMEX" @@ -107,8 +110,7 @@ "title": "Crowdpear" }, { - "title": "crypto.com", - "altNames": ["crypto"] + "title": "crypto" }, { "title": "DCS", @@ -139,6 +141,10 @@ "title": "dus.net", "slug": "dusnet" }, + { + "title":"ecitizen kenya", + "slug":"ecitizen_kenya" + }, { "title": "ente", "hex": "1DB954" @@ -164,7 +170,7 @@ }, { "title": "GitHub", - "hex": "858585" + "hex": "000000" }, { "title": "GitLab" @@ -353,7 +359,7 @@ "title": "Odido" }, { "title": "okx", - "hex": "858585" }, + "hex": "000000" }, { "title": "Parsec" }, diff --git a/auth/assets/custom-icons/icons/1xBet.svg b/auth/assets/custom-icons/icons/1xBet.svg index 1ce135815c..3d812a13d3 100644 --- a/auth/assets/custom-icons/icons/1xBet.svg +++ b/auth/assets/custom-icons/icons/1xBet.svg @@ -1 +1 @@ -logoo \ No newline at end of file + diff --git a/auth/assets/custom-icons/icons/bitget_wallet.svg b/auth/assets/custom-icons/icons/bitget_wallet.svg new file mode 100644 index 0000000000..03146d21cc --- /dev/null +++ b/auth/assets/custom-icons/icons/bitget_wallet.svg @@ -0,0 +1 @@ + diff --git a/auth/assets/custom-icons/icons/crypto.com.svg b/auth/assets/custom-icons/icons/crypto.com.svg deleted file mode 100644 index 1a0e2d9720..0000000000 --- a/auth/assets/custom-icons/icons/crypto.com.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/auth/assets/custom-icons/icons/crypto.svg b/auth/assets/custom-icons/icons/crypto.svg new file mode 100644 index 0000000000..ba84d84882 --- /dev/null +++ b/auth/assets/custom-icons/icons/crypto.svg @@ -0,0 +1 @@ + diff --git a/auth/assets/custom-icons/icons/deriv.svg b/auth/assets/custom-icons/icons/deriv.svg index 41581a265d..ade78bdd5c 100644 --- a/auth/assets/custom-icons/icons/deriv.svg +++ b/auth/assets/custom-icons/icons/deriv.svg @@ -1 +1 @@ - + diff --git a/auth/assets/custom-icons/icons/ecitizen_kenya.svg b/auth/assets/custom-icons/icons/ecitizen_kenya.svg new file mode 100644 index 0000000000..90416e69df --- /dev/null +++ b/auth/assets/custom-icons/icons/ecitizen_kenya.svg @@ -0,0 +1 @@ + diff --git a/auth/lib/core/configuration.dart b/auth/lib/core/configuration.dart index 096fe91b2f..586a519b65 100644 --- a/auth/lib/core/configuration.dart +++ b/auth/lib/core/configuration.dart @@ -13,6 +13,7 @@ import 'package:ente_auth/models/key_attributes.dart'; import 'package:ente_auth/models/key_gen_result.dart'; import 'package:ente_auth/models/private_key_attributes.dart'; import 'package:ente_auth/store/authenticator_db.dart'; +import 'package:ente_auth/utils/lock_screen_settings.dart'; import 'package:ente_crypto_dart/ente_crypto_dart.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:logging/logging.dart'; @@ -140,6 +141,7 @@ class Configuration { iOptions: _secureStorageOptionsIOS, ); } + await LockScreenSettings.instance.removePinAndPassword(); await AuthenticatorDB.instance.clearTable(); _key = null; _cachedToken = null; @@ -469,7 +471,13 @@ class Configuration { await _preferences.setBool(hasOptedForOfflineModeKey, true); } - bool shouldShowLockScreen() { + Future shouldShowLockScreen() async { + final bool isPin = await LockScreenSettings.instance.isPinSet(); + final bool isPass = await LockScreenSettings.instance.isPasswordSet(); + return isPin || isPass || shouldShowSystemLockScreen(); + } + + bool shouldShowSystemLockScreen() { if (_preferences.containsKey(keyShouldShowLockScreen)) { return _preferences.getBool(keyShouldShowLockScreen)!; } else { @@ -477,7 +485,7 @@ class Configuration { } } - Future setShouldShowLockScreen(bool value) { + Future setSystemLockScreen(bool value) { return _preferences.setBool(keyShouldShowLockScreen, value); } diff --git a/auth/lib/l10n/arb/app_el.arb b/auth/lib/l10n/arb/app_el.arb index c0fcad78db..4242d10647 100644 --- a/auth/lib/l10n/arb/app_el.arb +++ b/auth/lib/l10n/arb/app_el.arb @@ -99,6 +99,12 @@ "selectFile": "Επιλέξτε αρχείο", "emailVerificationToggle": "Επαλήθευση διεύθυνσης ηλ. ταχυδρομείου", "emailVerificationEnableWarning": "Για να αποφύγετε να κλειδωθείτε έξω από τον λογαριασμό σας, φροντίστε να αποθηκεύσετε ένα αντίγραφο του 2FA του ηλ. ταχυδρομείου σας έξω από το Ente Auth πριν ενεργοποιήσετε την επαλήθευση μέσω ηλ. ταχυδρομείου.", + "authToChangeEmailVerificationSetting": "Παρακαλώ πραγματοποιήστε έλεγχο ταυτότητας για να αλλάξετε την επαλήθευση ηλ. ταχυδρομείου", + "authToViewYourRecoveryKey": "Παρακαλώ πραγματοποιήστε έλεγχο ταυτότητας για να δείτε το κλειδί ανάκτησης", + "authToChangeYourEmail": "Παρακαλώ πραγματοποιήστε έλεγχο ταυτότητας για να αλλάξετε τη διεύθυνση ηλ. ταχυδρομείου σας", + "authToChangeYourPassword": "Παρακαλώ πραγματοποιήστε έλεγχο ταυτότητας για να αλλάξετε τον κωδικό πρόσβασής σας", + "authToViewSecrets": "Παρακαλώ πραγματοποιήστε έλεγχο ταυτότητας για να δείτε τα μυστικά σας", + "authToInitiateSignIn": "Παρακαλώ πραγματοποιήστε έλεγχο ταυτότητας για να ξεκινήσετε την είσοδο για δημιουργία αντιγράφου ασφαλείας.", "ok": "Οκ", "cancel": "Ακύρωση", "yes": "Ναι", @@ -138,7 +144,7 @@ "verifyEmail": "Επιβεβαίωση διεύθυνσης ηλ. ταχυδρομείου", "enterCodeHint": "Εισάγετε τον 6ψήφιο κωδικό από \nτην εφαρμογή αυθεντικοποίησης", "lostDeviceTitle": "Χαμένη συσκευή;", - "twoFactorAuthTitle": "Ταυτοποίηση δύο παραγόντων", + "twoFactorAuthTitle": "Αυθεντικοποίηση δύο παραγόντων", "passkeyAuthTitle": "Επιβεβαίωση κλειδιού πρόσβασης", "verifyPasskey": "Επιβεβαίωση κλειδιού πρόσβασης", "recoverAccount": "Ανάκτηση λογαριασμού", @@ -161,6 +167,7 @@ "deleteAccountQuery": "Λυπόμαστε που σας βλέπουμε να φεύγετε. Αντιμετωπίζετε κάποιο πρόβλημα;", "yesSendFeedbackAction": "Ναι, αποστολή σχολίων", "noDeleteAccountAction": "Όχι, διαγραφή λογαριασμού", + "initiateAccountDeleteTitle": "Παρακαλώ πραγματοποιήστε έλεγχο ταυτότητας για να ξεκινήσετε τη διαγραφή λογαριασμού", "sendEmail": "Αποστολή μηνύματος ηλ. ταχυδρομείου", "createNewAccount": "Δημιουργία νέου λογαριασμού", "weakStrength": "Αδύναμος", @@ -174,10 +181,13 @@ "social": "Κοινωνικά", "security": "Ασφάλεια", "lockscreen": "Οθόνη κλειδώματος", + "authToChangeLockscreenSetting": "Παρακαλώ πραγματοποιήστε έλεγχο ταυτότητας για να αλλάξετε τις ρυθμίσεις οθόνης κλειδώματος", "lockScreenEnablePreSteps": "Για να ενεργοποιήσετε την οθόνη κλειδώματος, παρακαλώ ορίστε τον κωδικό πρόσβασης της συσκευής ή το κλείδωμα οθόνης στις ρυθμίσεις του συστήματός σας.", "viewActiveSessions": "Προβολή ενεργών συνεδριών", + "authToViewYourActiveSessions": "Παρακαλώ πραγματοποιήστε έλεγχο ταυτότητας για να δείτε τις ενεργές συνεδρίες σας", "searchHint": "Αναζήτηση...", "search": "Αναζήτηση", + "sorryUnableToGenCode": "Λυπούμαστε, δεν είναι δυνατή η δημιουργία κωδικού για το {issuerName}", "noResult": "Κανένα αποτέλεσμα", "addCode": "Προσθήκη κωδικού", "scanAQrCode": "Σαρώστε έναν κωδικό QR", @@ -237,11 +247,14 @@ "verifyingRecoveryKey": "Επαλήθευση κλειδιού ανάκτησης...", "recoveryKeyVerified": "Το κλειδί ανάκτησης επαληθεύτηκε", "recoveryKeySuccessBody": "Τέλεια! Το κλειδί ανάκτησης σας είναι έγκυρο. Σας ευχαριστούμε για την επαλήθευση.\n\nΠαρακαλώ θυμηθείτε να κρατήσετε το κλειδί ανάκτησης σας και σε αντίγραφο ασφαλείας.", + "invalidRecoveryKey": "Το κλειδί ανάκτησης που εισάγατε δεν είναι έγκυρο. Παρακαλώ βεβαιωθείτε ότι περιέχει 24 λέξεις και ελέγξτε την ορθογραφία της κάθε μίας.\n\nΑν εισαγάγατε έναν παλαιότερο κωδικό ανάκτησης, βεβαιωθείτε ότι έχει μήκος 64 χαρακτήρες, και ελέγξτε κάθε έναν από αυτούς.", "recreatePasswordTitle": "Επαναδημιουργία κωδικού πρόσβασης", + "recreatePasswordBody": "Η τρέχουσα συσκευή δεν είναι αρκετά ισχυρή για να επαληθεύσει τον κωδικό πρόσβασής σας, αλλά μπορούμε να τον αναδημιουργήσουμε με έναν τρόπο που λειτουργεί με όλες τις συσκευές.\n\nΠαρακαλούμε συνδεθείτε χρησιμοποιώντας το κλειδί ανάκτησης και αναδημιουργήστε τον κωδικό πρόσβασής σας (μπορείτε να χρησιμοποιήσετε ξανά τον ίδιο αν το επιθυμείτε).", "invalidKey": "Μη έγκυρο κλειδί", "tryAgain": "Προσπαθήστε ξανά", "viewRecoveryKey": "Προβολή κλειδιού ανάκτησης", "confirmRecoveryKey": "Επιβεβαίωση κλειδιού ανάκτησης", + "recoveryKeyVerifyReason": "Το κλειδί ανάκτησης σας είναι ο μόνος τρόπος για να ανακτήσετε τις φωτογραφίες σας εάν ξεχάσετε τον κωδικό πρόσβασής σας. Μπορείτε να βρείτε το κλειδί ανάκτησης σας στις Ρυθμίσεις > Λογαριασμός.\n\nΠαρακαλώ εισάγετε το κλειδί ανάκτησης σας εδώ για να βεβαιωθείτε ότι το έχετε αποθηκεύσει σωστά.", "confirmYourRecoveryKey": "Επιβεβαίωση κλειδιού ανάκτησης", "confirm": "Επιβεβαίωση", "emailYourLogs": "Στείλτε με μήνυμα ηλ. ταχυδομείου τα αρχεία καταγραφής σας", @@ -251,6 +264,8 @@ "enterYourRecoveryKey": "Εισάγετε το κλειδί ανάκτησης σας", "tempErrorContactSupportIfPersists": "Φαίνεται ότι κάτι πήγε στραβά. Παρακαλώ προσπαθήστε ξανά μετά από κάποιο χρονικό διάστημα. Αν το σφάλμα παραμένει, παρακαλούμε επικοινωνήστε με την ομάδα υποστήριξης μας.", "networkHostLookUpErr": "Δεν είναι δυνατή η σύνδεση με το Ente, ελέγξτε τις ρυθμίσεις του δικτύου σας και επικοινωνήστε με την υποστήριξη αν το σφάλμα παραμένει.", + "networkConnectionRefusedErr": "Δεν είναι δυνατή η σύνδεση με το Ente, παρακαλώ προσπαθήστε ξανά μετά από λίγο. Εάν το σφάλμα παραμένει, παρακαλούμε επικοινωνήστε με την υποστήριξη.", + "itLooksLikeSomethingWentWrongPleaseRetryAfterSome": "Φαίνεται ότι κάτι πήγε στραβά. Παρακαλώ προσπαθήστε ξανά μετά από λίγο. Αν το σφάλμα παραμένει, παρακαλούμε επικοινωνήστε με την ομάδα υποστήριξης μας.", "about": "Σχετικά με", "weAreOpenSource": "Είμαστε ανοιχτού κώδικα!", "privacy": "Ιδιωτικότητα", @@ -269,6 +284,7 @@ "@iUnderStand": { "description": "Text for the button to confirm the user understands the warning" }, + "authToExportCodes": "Παρακαλώ πραγματοποιήστε έλεγχο ταυτότητας για να εξάγετε τους κωδικού σας", "importSuccessTitle": "Ζήτω!", "importSuccessDesc": "Έχετε εισάγει {count} κωδικούς!", "@importSuccessDesc": { @@ -312,6 +328,9 @@ "incorrectCode": "Εσφαλμένος κωδικός", "sorryTheCodeYouveEnteredIsIncorrect": "Λυπούμαστε, ο κωδικός που εισαγάγατε είναι εσφαλμένος", "emailChangedTo": "Η διεύθυνση ηλ. ταχυδρομείου άλλαξε σε {newEmail}", + "authenticationFailedPleaseTryAgain": "Αποτυχία ελέγχου ταυτότητας, παρακαλώ προσπαθήστε ξανά", + "authenticationSuccessful": "Επιτυχής έλεγχος ταυτότητας!", + "twofactorAuthenticationSuccessfullyReset": "Η αυθεντικοποίηση δύο παραγόντων επαναφέρθηκε επιτυχώς", "incorrectRecoveryKey": "Εσφαλμένο κλειδί ανάκτησης", "theRecoveryKeyYouEnteredIsIncorrect": "Το κλειδί ανάκτησης που εισάγατε είναι εσφαλμένο", "enterPassword": "Εισάγετε κωδικό πρόσβασης", @@ -332,7 +351,11 @@ "focusOnSearchBar": "Εστίαση στην αναζήτηση κατά την εκκίνηση εφαρμογής", "confirmUpdatingkey": "Είστε σίγουροι ότι θέλετε να ενημερώσετε το μυστικό κλειδί;", "minimizeAppOnCopy": "Ελαχιστοποίηση εφαρμογής κατά την αντιγραφή", + "editCodeAuthMessage": "Πραγματοποιήστε έλεγχο ταυτότητας για να επεξεργαστείτε τον κωδικό", + "deleteCodeAuthMessage": "Πραγματοποιήστε έλεγχο ταυτότητας για να διαγράψετε τον κωδικό", + "showQRAuthMessage": "Πραγματοποιήστε έλεγχο ταυτότητας για να δείτε τον κωδικό QR", "confirmAccountDeleteTitle": "Επιβεβαίωση διαγραφής λογαριασμού", + "confirmAccountDeleteMessage": "Αυτός ο λογαριασμός είναι συνδεδεμένος με άλλες εφαρμογές Ente, εάν χρησιμοποιείτε κάποια.\n\nΤα δεδομένα σας, σε όλες τις εφαρμογές Ente, θα προγραμματιστούν για διαγραφή και ο λογαριασμός σας θα διαγραφεί οριστικά.", "androidBiometricHint": "Επαλήθευση ταυτότητας", "@androidBiometricHint": { "description": "Hint message advising the user how to authenticate with biometrics. It is used on Android side. Maximum 60 characters." @@ -349,6 +372,10 @@ "@androidCancelButton": { "description": "Message showed on a button that the user can click to leave the current dialog. It is used on Android side. Maximum 30 characters." }, + "androidSignInTitle": "Απαιτείται έλεγχος ταυτότητας", + "@androidSignInTitle": { + "description": "Message showed as a title in a dialog which indicates the user that they need to scan biometric to continue. It is used on Android side. Maximum 60 characters." + }, "androidBiometricRequiredTitle": "Απαιτούνται βιομετρικά", "@androidBiometricRequiredTitle": { "description": "Message showed as a title in a dialog which indicates the user has not set up biometric authentication on their device. It is used on Android side. Maximum 60 characters." @@ -365,6 +392,18 @@ "@goToSettings": { "description": "Message showed on a button that the user can click to go to settings pages from the current dialog. It is used on both Android and iOS side. Maximum 30 characters." }, + "androidGoToSettingsDescription": "Η βιομετρική πιστοποίηση δεν έχει ρυθμιστεί στη συσκευή σας. Μεταβείτε στις 'Ρυθμίσεις > Ασφάλεια' για να προσθέσετε βιομετρική ταυτοποίηση.", + "@androidGoToSettingsDescription": { + "description": "Message advising the user to go to the settings and configure biometric on their device. It shows in a dialog on Android side." + }, + "iOSLockOut": "Η βιομετρική ταυτοποίηση είναι απενεργοποιημένη. Παρακαλώ κλειδώστε και ξεκλειδώστε την οθόνη σας για να την ενεργοποιήσετε.", + "@iOSLockOut": { + "description": "Message advising the user to re-enable biometrics on their device. It shows in a dialog on iOS side." + }, + "iOSGoToSettingsDescription": "Η βιομετρική ταυτοποίηση δεν έχει ρυθμιστεί στη συσκευή σας. Παρακαλώ ενεργοποιήστε το Touch ID ή το Face ID στο τηλέφωνό σας.", + "@iOSGoToSettingsDescription": { + "description": "Message advising the user to go to the settings and configure Biometrics for their device. It shows in a dialog on iOS side." + }, "iOSOkButton": "ΟΚ", "@iOSOkButton": { "description": "Message showed on a button that the user can click to leave the current dialog. It is used on iOS side. Maximum 30 characters." @@ -406,5 +445,15 @@ "updateNotAvailable": "Μη διαθέσιμη ενημέρωση", "viewRawCodes": "Προβολή ακατέργαστων κωδικών", "rawCodes": "Ακατέργαστοι κωδικοί", - "rawCodeData": "Δεδομένα ακατέργαστων κωδικών" + "rawCodeData": "Δεδομένα ακατέργαστων κωδικών", + "appLock": "Κλείδωμα εφαρμογής", + "noSystemLockFound": "Δεν βρέθηκε κλείδωμα συστήματος", + "autoLock": "Αυτόματο κλείδωμα", + "immediately": "Άμεσα", + "next": "Επόμενο", + "tooManyIncorrectAttempts": "Πάρα πολλές εσφαλμένες προσπάθειες", + "tapToUnlock": "Πατήστε για ξεκλείδωμα", + "setNewPassword": "Ορίστε νέο κωδικό πρόσβασης", + "hideContent": "Απόκρυψη περιεχομένου", + "setNewPin": "Ορίστε νέο PIN" } \ No newline at end of file diff --git a/auth/lib/l10n/arb/app_en.arb b/auth/lib/l10n/arb/app_en.arb index 1ef825f3f8..94698bf0b2 100644 --- a/auth/lib/l10n/arb/app_en.arb +++ b/auth/lib/l10n/arb/app_en.arb @@ -421,9 +421,9 @@ "waitingForVerification": "Waiting for verification...", "passkey": "Passkey", "passKeyPendingVerification": "Verification is still pending", - "loginSessionExpired" : "Session expired", + "loginSessionExpired": "Session expired", "loginSessionExpiredDetails": "Your session has expired. Please login again.", - "developerSettingsWarning":"Are you sure that you want to modify Developer settings?", + "developerSettingsWarning": "Are you sure that you want to modify Developer settings?", "developerSettings": "Developer settings", "serverEndpoint": "Server endpoint", "invalidEndpoint": "Invalid endpoint", @@ -445,5 +445,25 @@ "updateNotAvailable": "Update not available", "viewRawCodes": "View raw codes", "rawCodes": "Raw codes", - "rawCodeData": "Raw code data" + "rawCodeData": "Raw code data", + "appLock": "App lock", + "noSystemLockFound": "No system lock found", + "toEnableAppLockPleaseSetupDevicePasscodeOrScreen": "To enable app lock, please setup device passcode or screen lock in your system settings.", + "autoLock": "Auto lock", + "immediately": "Immediately", + "reEnterPassword": "Re-enter password", + "reEnterPin": "Re-enter PIN", + "next": "Next", + "tooManyIncorrectAttempts": "Too many incorrect attempts", + "tapToUnlock": "Tap to unlock", + "setNewPassword": "Set new password", + "deviceLock": "Device lock", + "hideContent": "Hide content", + "hideContentDescriptionAndroid": "Hides app content in the app switcher and disables screenshots", + "hideContentDescriptioniOS": "Hides app content in the app switcher", + "autoLockFeatureDescription": "Time after which the app locks after being put in the background", + "appLockDescription": "Choose between your device's default lock screen and a custom lock screen with a PIN or password.", + "pinLock": "Pin lock", + "enterPin": "Enter PIN", + "setNewPin": "Set new PIN" } \ No newline at end of file diff --git a/auth/lib/l10n/arb/app_he.arb b/auth/lib/l10n/arb/app_he.arb index 8f22e1e82c..96cdda9b52 100644 --- a/auth/lib/l10n/arb/app_he.arb +++ b/auth/lib/l10n/arb/app_he.arb @@ -1,5 +1,6 @@ { "account": "חשבון", + "unlock": "בטל נעילה", "recoveryKey": "מפתח שחזור", "counterAppBarTitle": "מונה", "@counterAppBarTitle": { @@ -105,12 +106,15 @@ "copied": "הועתק", "pleaseTryAgain": "אנא נסה שנית", "existingUser": "משתמש קיים", + "newUser": "חדש בente", "delete": "למחוק", "enterYourPasswordHint": "הכנס סיסמא", "forgotPassword": "שכחתי סיסמה", "oops": "אופס", "suggestFeatures": "הציעו מאפיינים", "faq": "שאלות נפוצות", + "faq_q_1": "כמה מאובטח ente Auth?", + "faq_a_1": "כל הקודים שאתה מגבה דרך ente מאוחסנים מקצה לקצה בהצפנה. הכוונה שרק אתה יכול לגשת לקודים שלך. האפליקציות שלנו הם מפותחות דרך קוד פתוח והקריפטוגרפיה שלנו מבוקרת חיצונית.", "faq_q_2": "האם ישנה אפשרות להשתמש בקודים שלי במחשב?", "faq_a_2": "אתה יכול לגשת לקודים שלך ברשת ב- auth.ente.io.", "faq_q_3": "איך אפשר למחוק קודים?", @@ -183,6 +187,8 @@ "recoveryKeySaveDescription": "אנחנו לא מאחסנים את המפתח הזה, אנא שמור את המפתח 24 מילים הזה במקום בטוח.", "doThisLater": "עשה זאת מאוחר יותר", "saveKey": "שמור מפתח", + "save": "שמור", + "send": "שלח", "back": "חזרה", "createAccount": "צור חשבון", "passwordStrength": "חוזק הסיסמא: {passwordStrengthValue}", @@ -247,6 +253,7 @@ "privacy": "פרטיות", "terms": "תנאים", "checkForUpdates": "בדוק אם קיימים עדכונים", + "checkStatus": "בדוק סטטוס", "downloadUpdate": "הורד", "criticalUpdateAvailable": "עדכון חשוב זמין", "updateAvailable": "עדכון זמין", @@ -327,5 +334,8 @@ "minimizeAppOnCopy": "מזער אפליקציה בהעתקה", "editCodeAuthMessage": "אמת כדי לערוך קוד", "deleteCodeAuthMessage": "אמת כדי למחוק קוד", - "showQRAuthMessage": "אמת כדי להראות קוד QR" + "showQRAuthMessage": "אמת כדי להראות קוד QR", + "confirmAccountDeleteTitle": "אישור מחיקת חשבון", + "noInternetConnection": "אין חיבור לאינטרנט", + "pleaseCheckYourInternetConnectionAndTryAgain": "אנא בדוק את חיבור האינטרנט שלך ונסה שוב." } \ No newline at end of file diff --git a/auth/lib/l10n/arb/app_it.arb b/auth/lib/l10n/arb/app_it.arb index 470ff0aedc..0170ecdac1 100644 --- a/auth/lib/l10n/arb/app_it.arb +++ b/auth/lib/l10n/arb/app_it.arb @@ -445,5 +445,25 @@ "updateNotAvailable": "Aggiornamento non disponibile", "viewRawCodes": "Visualizza codici raw", "rawCodes": "Codici raw", - "rawCodeData": "Dati codice raw" + "rawCodeData": "Dati codice raw", + "appLock": "Blocco app", + "noSystemLockFound": "Nessun blocco di sistema trovato", + "toEnableAppLockPleaseSetupDevicePasscodeOrScreen": "Per abilitare il blocco dell'app, configura il codice di accesso del dispositivo o il blocco schermo nelle impostazioni di sistema.", + "autoLock": "Blocco automatico", + "immediately": "Immediatamente", + "reEnterPassword": "Reinserisci la password", + "reEnterPin": "Reinserisci il PIN", + "next": "Successivo", + "tooManyIncorrectAttempts": "Troppi tentativi errati", + "tapToUnlock": "Tocca per sbloccare", + "setNewPassword": "Imposta una nuova password", + "deviceLock": "Blocco del dispositivo", + "hideContent": "Nascondi il contenuto", + "hideContentDescriptionAndroid": "Nasconde il contenuto nel selettore delle app e disabilita gli screenshot", + "hideContentDescriptioniOS": "Nasconde il contenuto nel selettore delle app", + "autoLockFeatureDescription": "Tempo dopo il quale l'applicazione si blocca dopo essere stata messa in background", + "appLockDescription": "Scegli tra la schermata di blocco predefinita del dispositivo e una schermata di blocco personalizzata con PIN o password.", + "pinLock": "Blocco con PIN", + "enterPin": "Inserisci PIN", + "setNewPin": "Imposta un nuovo PIN" } \ No newline at end of file diff --git a/auth/lib/l10n/arb/app_pl.arb b/auth/lib/l10n/arb/app_pl.arb index b7e9881de8..6b9b390ede 100644 --- a/auth/lib/l10n/arb/app_pl.arb +++ b/auth/lib/l10n/arb/app_pl.arb @@ -445,5 +445,25 @@ "updateNotAvailable": "Aktualizacja jest niedostępna", "viewRawCodes": "Zobacz surowe kody", "rawCodes": "Surowe kody", - "rawCodeData": "Dane surowego kodu" + "rawCodeData": "Dane surowego kodu", + "appLock": "Blokada dostępu do aplikacji", + "noSystemLockFound": "Nie znaleziono blokady systemowej", + "toEnableAppLockPleaseSetupDevicePasscodeOrScreen": "Aby włączyć blokadę aplikacji, należy skonfigurować hasło urządzenia lub blokadę ekranu w ustawieniach systemu.", + "autoLock": "Automatyczna blokada", + "immediately": "Natychmiast", + "reEnterPassword": "Wprowadź ponownie hasło", + "reEnterPin": "Wprowadź ponownie kod PIN", + "next": "Dalej", + "tooManyIncorrectAttempts": "Zbyt wiele błędnych prób", + "tapToUnlock": "Naciśnij, aby odblokować", + "setNewPassword": "Ustaw nowe hasło", + "deviceLock": "Blokada urządzenia", + "hideContent": "Ukryj zawartość", + "hideContentDescriptionAndroid": "Ukrywa zawartość aplikacji w przełączniku aplikacji i wyłącza zrzuty ekranu", + "hideContentDescriptioniOS": "Ukrywa zawartość aplikacji w przełączniku aplikacji", + "autoLockFeatureDescription": "Czas, po którym aplikacja blokuje się po umieszczeniu jej w tle", + "appLockDescription": "Wybierz między domyślnym ekranem blokady urządzenia a niestandardowym ekranem blokady z kodem PIN lub hasłem.", + "pinLock": "Blokada PIN", + "enterPin": "Wprowadź kod PIN", + "setNewPin": "Ustaw nowy kod PIN" } \ No newline at end of file diff --git a/auth/lib/l10n/arb/app_pt.arb b/auth/lib/l10n/arb/app_pt.arb index 8737ee8590..e252721a46 100644 --- a/auth/lib/l10n/arb/app_pt.arb +++ b/auth/lib/l10n/arb/app_pt.arb @@ -445,5 +445,25 @@ "updateNotAvailable": "Atualização indisponível", "viewRawCodes": "Ver códigos brutos", "rawCodes": "Código bruto", - "rawCodeData": "Dado do código bruto" + "rawCodeData": "Dado do código bruto", + "appLock": "Bloqueio de app", + "noSystemLockFound": "Nenhum bloqueio de sistema encontrado", + "toEnableAppLockPleaseSetupDevicePasscodeOrScreen": "Para ativar o bloqueio de app, por favor ative um método de autenticação nas configurações do sistema do seu dispositivo.", + "autoLock": "Bloqueio automático", + "immediately": "Imediatamente", + "reEnterPassword": "Reinserir senha", + "reEnterPin": "Reinserir PIN", + "next": "Próximo", + "tooManyIncorrectAttempts": "Muitas tentativas incorretas", + "tapToUnlock": "Toque para desbloquear", + "setNewPassword": "Defina nova senha", + "deviceLock": "Bloqueio do dispositivo", + "hideContent": "Ocultar conteúdo", + "hideContentDescriptionAndroid": "Oculta o conteúdo do app no seletor de apps e desativa as capturas de tela", + "hideContentDescriptioniOS": "Oculta o conteúdo do seletor de apps", + "autoLockFeatureDescription": "Tempo após o qual o app bloqueia depois de ser colocado em segundo plano", + "appLockDescription": "Escolha entre a tela de bloqueio padrão do seu dispositivo e uma tela de bloqueio personalizada com PIN ou senha.", + "pinLock": "Bloqueio PIN", + "enterPin": "Insira o PIN", + "setNewPin": "Definir novo PIN" } \ No newline at end of file diff --git a/auth/lib/l10n/arb/app_sk.arb b/auth/lib/l10n/arb/app_sk.arb new file mode 100644 index 0000000000..b32cdda7d3 --- /dev/null +++ b/auth/lib/l10n/arb/app_sk.arb @@ -0,0 +1,194 @@ +{ + "account": "Konto", + "unlock": "Odomknúť", + "recoveryKey": "Kľúč pre obnovenie", + "counterAppBarTitle": "Počítadlo", + "@counterAppBarTitle": { + "description": "Text shown in the AppBar of the Counter Page" + }, + "onBoardingGetStarted": "Poďme na to", + "importScanQrCode": "Naskenovať QR kód", + "qrCode": "QR kód", + "incorrectDetails": "Chybné údaje", + "pleaseVerifyDetails": "Prosím, skontrolujte svoje údaje a skúste to znova", + "codeIssuerHint": "Vydavateľ", + "codeSecretKeyHint": "Tajný kľúč", + "codeAccountHint": "Konto (ucet@domena.com)", + "accountKeyType": "Typ kľúča", + "sessionExpired": "Relácia vypršala", + "@sessionExpired": { + "description": "Title of the dialog when the users current session is invalid/expired" + }, + "pleaseLoginAgain": "Prosím, prihláste sa znova", + "loggingOut": "Odhlasovanie...", + "timeBasedKeyType": "Na základe času (TOTP)", + "counterBasedKeyType": "Na základe počítadla (HOTP)", + "saveAction": "Uložiť", + "nextTotpTitle": "ďalej", + "deleteCodeTitle": "Odstrániť položku?", + "deleteCodeMessage": "Naozaj chcete odstrániť položku? Táto akcia je nezvratná.", + "viewLogsAction": "Zobraziť logy", + "sendLogsDescription": "Toto odošle logy, ktoré nám pomôžu vyriešiť váš problém. Aj keď prijímame preventívne opatrenia, aby sme zabezpečili, že sa citlivé informácie neukladajú do logov, odporúčame vám, aby ste si ich pred zdieľaním pozreli.", + "preparingLogsTitle": "Príprava logov...", + "emailLogsTitle": "Odoslať logy emailom", + "emailLogsMessage": "Prosím, pošlite logy na adresu {email}", + "@emailLogsMessage": { + "placeholders": { + "email": { + "type": "String" + } + } + }, + "copyEmailAction": "Skopírovať e-mail", + "exportLogsAction": "Exportovať logy", + "reportABug": "Nahlásiť chybu", + "crashAndErrorReporting": "Hlásenie zlyhaní a chýb", + "reportBug": "Nahlásiť chybu", + "contactSupport": "Kontaktovať podporu", + "rateUsOnStore": "Ohodnoťte nás cez {storeName}", + "blog": "Blog", + "merchandise": "Merchandise", + "pleaseWait": "Prosím počkajte...", + "generatingEncryptionKeysTitle": "Generovanie šifrovacích kľúčov...", + "useRecoveryKey": "Použiť kľúč na obnovenie", + "incorrectPasswordTitle": "Nesprávne heslo", + "welcomeBack": "Vitajte späť!", + "madeWithLoveAtPrefix": "vyrobené so ❤️ v ", + "supportDiscount": "Použite kód \"AUTH\" pre získanie 10% zľavy na prvý rok", + "changeEmail": "Zmeniť e-mail", + "changePassword": "Zmeniť heslo", + "data": "Údaje", + "importCodes": "Importovať kódy", + "importTypePlainText": "Obyčajný text", + "importTypeEnteEncrypted": "Šifrovaný Ente export", + "passwordForDecryptingExport": "Heslo na rozšifrovanie exportu", + "passwordEmptyError": "Heslo nemôže byť prázdne", + "importFromApp": "Importovať kódy z {appName}", + "importGoogleAuthGuide": "Exportujte svoje účty z aplikácie Google Authenticator pomocou QR kódu zvolením možnosti „Preniesť účty“. Následne, naskenujte QR kód pomocou iného zariadenia.\n\nTip: Na odfotenie QR kódu môžete použiť webovú kameru laptopu.", + "importSelectJsonFile": "Vybrať JSON súbor", + "importSelectAppExport": "Vybrať export súbor aplikácie {appName}", + "importEnteEncGuide": "Vyberte zašifrovaný JSON export súbor aplikácie Ente", + "importRaivoGuide": "Použite možnosť \"Exportovať OTP kódy do archívu Zip\" v nastaveniach služby Raivo.\n\nExtrahujte súbor zip a naimportujte JSON súbor.", + "importBitwardenGuide": "Použite možnosť „Exportovať trezor“ v službe Bitwarden Tools a importujte nezašifrovaný JSON súbor.", + "importAegisGuide": "Použite možnosť \"Exportovať trezor\" v nastaveniach služby Aegis.\n\nAk je váš trezor zašifrovaný, na dešifrovanie budete musieť zadať heslo trezoru.", + "import2FasGuide": "Použite možnosť \"Nastavenia->Záloha -Export\" v službe 2FAS.\n\nAk je vaša záloha zašifrovaná, na dešifrovanie budete musieť zadať heslo", + "importLastpassGuide": "Použite možnosť \"Preniesť účty\" v nastaveniach služby Lastpass Authenticator a stlačte \"Exportovať účty do súboru\". Importujte stiahnutý JSON súbor.", + "exportCodes": "Exportovať kódy", + "importLabel": "Importovať", + "selectFile": "Vybrať súbor", + "emailVerificationToggle": "Overenie e-mailovej adresy", + "ok": "Ok", + "cancel": "Zrušiť", + "yes": "Áno", + "no": "Nie", + "email": "Email", + "support": "Podpora", + "general": "Všeobecné", + "settings": "Nastavenia", + "copied": "Skopírované", + "pleaseTryAgain": "Prosím, skúste to znova", + "existingUser": "Existujúci užívateľ", + "newUser": "Nový v Ente", + "delete": "Odstrániť", + "enterYourPasswordHint": "Zadajte vaše heslo", + "forgotPassword": "Zabudnuté heslo", + "oops": "Ups", + "suggestFeatures": "Navrhnúť funkcionalitu", + "faq": "Často kladené otázky", + "faq_q_1": "Ako bezpečné je Auth?", + "faq_a_1": "Všetky kódy, ktoré zálohujete cez Auth, sú ukladané zabezpečené end-to-end šifrovaním. To znamená, že k svojim kódom máte prístup iba vy. Naše aplikácie sú open source a na nami používanej kryptografii prebehol externý audit.", + "faq_q_2": "Môžem pristupovať k svojim kódom cez počítač?", + "faq_a_2": "K svojim kódom sa môžete dostať cez web auth.ente.io.", + "faq_q_3": "Ako môžem odstrániť svoje kódy?", + "faq_a_3": "Kód môžete odstrániť potiahnutím prsta doľava na danej položke.", + "faq_q_4": "Ako môžem podporiť tento projekt?", + "faq_a_4": "Vývoj tohto projektu môžete podporiť zakúpením predplatného našej aplikácie Photos na ente.io.", + "faq_q_5": "Ako môžem nastaviť FaceID v Auth?", + "leaveFamily": "Opustiť rodinku", + "leaveFamilyMessage": "Ste si istý, že chcete opustiť rodinku?", + "inFamilyPlanMessage": "Ste prihlásený k rodinke!", + "scan": "Skenovať", + "scanACode": "Skenovať kód", + "verify": "Overiť", + "verifyEmail": "Overiť email", + "verifyPasskey": "Overiť passkey", + "recoverAccount": "Obnoviť konto", + "enterRecoveryKeyHint": "Vložte váš kód pre obnovenie", + "recover": "Obnoviť", + "contactSupportViaEmailMessage": "Pošlite e-mail na adresu {email} z vašej registrovanej e-mailovej adresy", + "@contactSupportViaEmailMessage": { + "placeholders": { + "email": { + "type": "String" + } + } + }, + "invalidQRCode": "Neplatný QR kód", + "noRecoveryKeyTitle": "Nemáte kľúč pre obnovenie?", + "enterEmailHint": "Zadajte vašu emailovú adresu", + "invalidEmailTitle": "Neplatná emailová adresa", + "invalidEmailMessage": "Zadajte platnú e-mailovú adresu.", + "deleteAccount": "Odstrániť konto", + "deleteAccountQuery": "Bude nám tu bez vás smutno. Vyskytol sa nejaký problém?", + "yesSendFeedbackAction": "Áno, odoslať spätnú väzbu", + "noDeleteAccountAction": "Nie, odstrániť účet", + "initiateAccountDeleteTitle": "Pre odstránenie účtu sa musíte overiť", + "sendEmail": "Odoslať email", + "createNewAccount": "Vytvoriť nové konto", + "weakStrength": "Slabé", + "strongStrength": "Silné", + "moderateStrength": "Mierne", + "confirmPassword": "Potvrdiť heslo", + "close": "Zatvoriť", + "oopsSomethingWentWrong": "Ajáj, vyskytla sa chyba.", + "selectLanguage": "Vybrať jazyk", + "language": "Jazyk", + "social": "Sociálne siete", + "security": "Zabezpečenie", + "lockscreen": "Uzamknutá obrazovka", + "authToChangeLockscreenSetting": "Pre zmenu nastavenia uzamknutej obrazovky sa musíte overiť", + "lockScreenEnablePreSteps": "Pre povolenie uzamknutia obrazovky, nastavte prístupový kód zariadenia alebo zámok obrazovky v nastaveniach systému.", + "viewActiveSessions": "Zobraziť aktívne relácie", + "authToViewYourActiveSessions": "Pre zobrazenie vašich aktívnych relácii sa musíte overiť", + "searchHint": "Hľadať...", + "search": "Hľadať", + "sorryUnableToGenCode": "Ospravedlňujeme sa, nie je možné vygenerovať kód pre {issuerName}", + "noResult": "Žiadny výsledok", + "addCode": "Pridať kód", + "scanAQrCode": "Naskenovať QR kód", + "enterDetailsManually": "Zadajte údaje manuálne", + "edit": "Upraviť", + "copiedToClipboard": "Skopírované do schránky", + "copiedNextToClipboard": "Skopírovaný následujúci kód do schránky", + "error": "Chyba", + "recoveryKeyCopiedToClipboard": "Skopírovaný kód pre obnovenie do schránky", + "recoveryKeyOnForgotPassword": "Ak zabudnete heslo, jediným spôsobom, ako môžete obnoviť svoje údaje, je tento kľúč.", + "recoveryKeySaveDescription": "My tento kľúč neuchovávame, uložte si tento kľúč obsahujúci 24 slov na bezpečnom mieste.", + "doThisLater": "Urobiť to neskôr", + "saveKey": "Uložiť kľúč", + "save": "Uložiť", + "send": "Odoslať", + "about": "O aplikácii", + "weAreOpenSource": "We are open source!", + "privacy": "Ochrana osobných údajov", + "terms": "Podmienky používania", + "checkForUpdates": "Zistiť dostupnosť aktualizácií", + "checkStatus": "Overiť stav", + "downloadUpdate": "Stiahnuť", + "criticalUpdateAvailable": "K dispozícii je kritická aktualizácia", + "updateAvailable": "K dispozícii je aktualizácia", + "update": "Aktualizovať", + "checking": "Kontrolovanie...", + "youAreOnTheLatestVersion": "Používate najnovšiu verziu", + "warning": "Upozornenie", + "iUnderStand": "Rozumiem", + "@iUnderStand": { + "description": "Text for the button to confirm the user understands the warning" + }, + "importSuccessTitle": "Jéj!", + "sorry": "Ospravedlňujeme sa", + "importFailureDesc": "Vybraný súbor nie je možné spracovať.\nAk potrebujete pomoc, napíšte na adresu support@ente.io!", + "pendingSyncs": "Upozornenie", + "tapToEnterCode": "Klepnutím zadajte kód", + "resendEmail": "Znovu odoslať email" +} \ No newline at end of file diff --git a/auth/lib/l10n/arb/app_sv.arb b/auth/lib/l10n/arb/app_sv.arb index a3adcbe686..e47c1b8c0e 100644 --- a/auth/lib/l10n/arb/app_sv.arb +++ b/auth/lib/l10n/arb/app_sv.arb @@ -163,5 +163,16 @@ }, "noInternetConnection": "Ingen internetanslutning", "pleaseCheckYourInternetConnectionAndTryAgain": "Kontrollera din internetanslutning och försök igen.", - "loginSessionExpiredDetails": "Din session har upphört. Logga in igen." + "loginSessionExpiredDetails": "Din session har upphört. Logga in igen.", + "immediately": "Omedelbart", + "reEnterPassword": "Ange lösenord igen", + "reEnterPin": "Ange PIN-kod igen", + "next": "Nästa", + "tooManyIncorrectAttempts": "För många felaktiga försök", + "tapToUnlock": "Tryck för att låsa upp", + "setNewPassword": "Ange nytt lösenord", + "deviceLock": "Enhetslås", + "hideContent": "Dölj innehåll", + "enterPin": "Ange PIN-kod", + "setNewPin": "Ange ny PIN-kod" } \ No newline at end of file diff --git a/auth/lib/l10n/arb/app_tr.arb b/auth/lib/l10n/arb/app_tr.arb index fd2cac27f9..6dae512ba4 100644 --- a/auth/lib/l10n/arb/app_tr.arb +++ b/auth/lib/l10n/arb/app_tr.arb @@ -263,6 +263,8 @@ "exportLogs": "Günlüğü dışa aktar", "enterYourRecoveryKey": "Kurtarma anahtarınızı girin", "tempErrorContactSupportIfPersists": "Bir şeyler ters gitmiş gibi görünüyor. Lütfen bir süre sonra tekrar deneyin. Hata devam ederse, lütfen destek ekibimizle iletişime geçin.", + "networkHostLookUpErr": "Ente'ye bağlanılamıyor, lütfen ağ ayarlarınızı kontrol edin ve hata devam ederse desteğe başvurun.", + "networkConnectionRefusedErr": "Ente'ye bağlanılamıyor, lütfen daha sonra tekrar deneyin. Hata devam ederse, lütfen desteğe başvurun.", "itLooksLikeSomethingWentWrongPleaseRetryAfterSome": "Bir şeyler ters gitmiş gibi görünüyor. Lütfen bir süre sonra tekrar deneyin. Hata devam ederse, lütfen destek ekibimizle iletişime geçin.", "about": "Hakkında", "weAreOpenSource": "Biz açık kaynağız!", @@ -440,5 +442,8 @@ "deleteTagTitle": "Etiket silinsin mi?", "deleteTagMessage": "Bu etiketi silmek istediğinizden emin misiniz? Bu işlem geri alınamaz.", "somethingWentWrongParsingCode": "{x} kodu ayrıştıramadık.", - "updateNotAvailable": "Güncelleme mevcut değil" + "updateNotAvailable": "Güncelleme mevcut değil", + "viewRawCodes": "Ham kodları gör", + "rawCodes": "Ham kodlar", + "rawCodeData": "Ham kod verisi" } \ No newline at end of file diff --git a/auth/lib/l10n/arb/app_zh.arb b/auth/lib/l10n/arb/app_zh.arb index e5ce8b5e88..ac8d9e5d7a 100644 --- a/auth/lib/l10n/arb/app_zh.arb +++ b/auth/lib/l10n/arb/app_zh.arb @@ -445,5 +445,25 @@ "updateNotAvailable": "更新不可用", "viewRawCodes": "查看原始代码", "rawCodes": "原始代码", - "rawCodeData": "原始代码数据" + "rawCodeData": "原始代码数据", + "appLock": "应用锁", + "noSystemLockFound": "未找到系统锁", + "toEnableAppLockPleaseSetupDevicePasscodeOrScreen": "要启用应用锁,请在系统设置中设置设备密码或屏幕锁定。", + "autoLock": "自动锁定", + "immediately": "立即", + "reEnterPassword": "再次输入密码", + "reEnterPin": "再次输入 PIN 码", + "next": "下一步", + "tooManyIncorrectAttempts": "错误的尝试次数过多", + "tapToUnlock": "点击解锁", + "setNewPassword": "设置新密码", + "deviceLock": "设备锁", + "hideContent": "隐藏内容", + "hideContentDescriptionAndroid": "在应用切换器中隐藏应用内容并禁用屏幕截图", + "hideContentDescriptioniOS": "在应用切换器中隐藏应用内容", + "autoLockFeatureDescription": "应用程序进入后台后锁定的时间", + "appLockDescription": "在设备的默认锁定屏幕和带有 PIN 或密码的自定义锁定屏幕之间进行选择。", + "pinLock": "Pin 锁定", + "enterPin": "输入 PIN 码", + "setNewPin": "设置新 PIN 码" } \ No newline at end of file diff --git a/auth/lib/main.dart b/auth/lib/main.dart index 003c432c08..452f2a90f8 100644 --- a/auth/lib/main.dart +++ b/auth/lib/main.dart @@ -23,17 +23,15 @@ import 'package:ente_auth/store/code_store.dart'; import 'package:ente_auth/ui/tools/app_lock.dart'; import 'package:ente_auth/ui/tools/lock_screen.dart'; import 'package:ente_auth/ui/utils/icon_utils.dart'; +import 'package:ente_auth/utils/lock_screen_settings.dart'; import 'package:ente_auth/utils/platform_util.dart'; import 'package:ente_auth/utils/window_protocol_handler.dart'; import 'package:ente_crypto_dart/ente_crypto_dart.dart'; import 'package:flutter/foundation.dart'; import "package:flutter/material.dart"; -import 'package:flutter/scheduler.dart'; -import 'package:flutter/services.dart'; import 'package:flutter_displaymode/flutter_displaymode.dart'; import 'package:logging/logging.dart'; import 'package:path_provider/path_provider.dart'; -import 'package:privacy_screen/privacy_screen.dart'; import 'package:tray_manager/tray_manager.dart'; import 'package:window_manager/window_manager.dart'; @@ -85,7 +83,6 @@ void main() async { } await _runInForeground(); - await _setupPrivacyScreen(); if (Platform.isAndroid) { FlutterDisplayMode.setHighRefreshRate().ignore(); } @@ -115,7 +112,7 @@ Future _runInForeground() async { AppLock( builder: (args) => App(locale: locale), lockScreen: const LockScreen(), - enabled: Configuration.instance.shouldShowLockScreen(), + enabled: await Configuration.instance.shouldShowLockScreen(), locale: locale, lightTheme: lightThemeData, darkTheme: darkThemeData, @@ -174,24 +171,5 @@ Future _init(bool bool, {String? via}) async { await NotificationService.instance.init(); await UpdateService.instance.init(); await IconUtils.instance.init(); -} - -Future _setupPrivacyScreen() async { - if (!PlatformUtil.isMobile() || kDebugMode) return; - final brightness = - SchedulerBinding.instance.platformDispatcher.platformBrightness; - bool isInDarkMode = brightness == Brightness.dark; - await PrivacyScreen.instance.enable( - iosOptions: const PrivacyIosOptions( - enablePrivacy: true, - privacyImageName: "LaunchImage", - lockTrigger: IosLockTrigger.didEnterBackground, - ), - androidOptions: const PrivacyAndroidOptions( - enableSecure: true, - ), - backgroundColor: isInDarkMode ? Colors.black : Colors.white, - blurEffect: - isInDarkMode ? PrivacyBlurEffect.dark : PrivacyBlurEffect.extraLight, - ); + await LockScreenSettings.instance.init(); } diff --git a/auth/lib/services/local_authentication_service.dart b/auth/lib/services/local_authentication_service.dart index 44c2a758a7..f47ed693cd 100644 --- a/auth/lib/services/local_authentication_service.dart +++ b/auth/lib/services/local_authentication_service.dart @@ -1,6 +1,8 @@ import 'dart:io'; import 'package:ente_auth/core/configuration.dart'; +import 'package:ente_auth/ui/settings/lock_screen/lock_screen_password.dart'; +import 'package:ente_auth/ui/settings/lock_screen/lock_screen_pin.dart'; import 'package:ente_auth/ui/tools/app_lock.dart'; import 'package:ente_auth/utils/auth_util.dart'; import 'package:ente_auth/utils/dialog_util.dart'; @@ -21,11 +23,15 @@ class LocalAuthenticationService { BuildContext context, String infoMessage, ) async { - if (await _isLocalAuthSupportedOnDevice()) { + if (await isLocalAuthSupportedOnDevice()) { AppLock.of(context)!.setEnabled(false); - final result = await requestAuthentication(context, infoMessage); + final result = await requestAuthentication( + context, + infoMessage, + isAuthenticatingForInAppChange: true, + ); AppLock.of(context)!.setEnabled( - Configuration.instance.shouldShowLockScreen(), + await Configuration.instance.shouldShowLockScreen(), ); if (!result) { showToast(context, infoMessage); @@ -37,6 +43,50 @@ class LocalAuthenticationService { return true; } + Future requestEnteAuthForLockScreen( + BuildContext context, + String? savedPin, + String? savedPassword, { + bool isAuthenticatingOnAppLaunch = false, + bool isAuthenticatingForInAppChange = false, + }) async { + if (savedPassword != null) { + final result = await Navigator.of(context).push( + MaterialPageRoute( + builder: (BuildContext context) { + return LockScreenPassword( + isChangingLockScreenSettings: true, + isAuthenticatingForInAppChange: isAuthenticatingForInAppChange, + isAuthenticatingOnAppLaunch: isAuthenticatingOnAppLaunch, + authPass: savedPassword, + ); + }, + ), + ); + if (result) { + return true; + } + } + if (savedPin != null) { + final result = await Navigator.of(context).push( + MaterialPageRoute( + builder: (BuildContext context) { + return LockScreenPin( + isChangingLockScreenSettings: true, + isAuthenticatingForInAppChange: isAuthenticatingForInAppChange, + isAuthenticatingOnAppLaunch: isAuthenticatingOnAppLaunch, + authPin: savedPin, + ); + }, + ), + ); + if (result) { + return true; + } + } + return false; + } + Future requestLocalAuthForLockScreen( BuildContext context, bool shouldEnableLockScreen, @@ -44,7 +94,7 @@ class LocalAuthenticationService { String errorDialogContent, [ String errorDialogTitle = "", ]) async { - if (await _isLocalAuthSupportedOnDevice()) { + if (await isLocalAuthSupportedOnDevice()) { AppLock.of(context)!.disable(); final result = await requestAuthentication( context, @@ -53,11 +103,11 @@ class LocalAuthenticationService { if (result) { AppLock.of(context)!.setEnabled(shouldEnableLockScreen); await Configuration.instance - .setShouldShowLockScreen(shouldEnableLockScreen); + .setSystemLockScreen(shouldEnableLockScreen); return true; } else { AppLock.of(context)! - .setEnabled(Configuration.instance.shouldShowLockScreen()); + .setEnabled(await Configuration.instance.shouldShowLockScreen()); } } else { // ignore: unawaited_futures @@ -70,7 +120,7 @@ class LocalAuthenticationService { return false; } - Future _isLocalAuthSupportedOnDevice() async { + Future isLocalAuthSupportedOnDevice() async { try { return Platform.isMacOS || Platform.isLinux ? await FlutterLocalAuthentication().canAuthenticate() diff --git a/auth/lib/ui/components/text_input_widget.dart b/auth/lib/ui/components/text_input_widget.dart index 7737978329..2a06d3b71a 100644 --- a/auth/lib/ui/components/text_input_widget.dart +++ b/auth/lib/ui/components/text_input_widget.dart @@ -6,6 +6,7 @@ import 'package:ente_auth/ui/components/separators.dart'; import 'package:ente_auth/utils/debouncer.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:logging/logging.dart'; class TextInputWidget extends StatefulWidget { final String? label; @@ -58,6 +59,7 @@ class TextInputWidget extends StatefulWidget { } class _TextInputWidgetState extends State { + final _logger = Logger("TextInputWidget"); ExecutionState executionState = ExecutionState.idle; final _textController = TextEditingController(); final _debouncer = Debouncer(const Duration(milliseconds: 300)); @@ -66,7 +68,7 @@ class _TextInputWidgetState extends State { ///This is to pass if the TextInputWidget is in a dialog and an error is ///thrown in executing onSubmit by passing it as arg in Navigator.pop() Exception? _exception; - + bool _incorrectPassword = false; @override void initState() { widget.submitNotifier?.addListener(_onSubmit); @@ -138,7 +140,11 @@ class _TextInputWidgetState extends State { borderSide: BorderSide.none, ), focusedBorder: OutlineInputBorder( - borderSide: BorderSide(color: colorScheme.strokeFaint), + borderSide: BorderSide( + color: _incorrectPassword + ? const Color.fromRGBO(245, 42, 42, 1) + : colorScheme.strokeFaint, + ), borderRadius: BorderRadius.circular(8), ), suffixIcon: Padding( @@ -233,6 +239,10 @@ class _TextInputWidgetState extends State { executionState = ExecutionState.error; _debouncer.cancelDebounce(); _exception = e as Exception; + if (e.toString().contains("Incorrect password")) { + _logger.warning("Incorrect password"); + _surfaceWrongPasswordState(); + } if (!widget.popNavAfterSubmission) { rethrow; } @@ -306,6 +316,20 @@ class _TextInputWidgetState extends State { void _popNavigatorStack(BuildContext context, {Exception? e}) { Navigator.of(context).canPop() ? Navigator.of(context).pop(e) : null; } + + void _surfaceWrongPasswordState() { + setState(() { + _incorrectPassword = true; + HapticFeedback.vibrate(); + Future.delayed(const Duration(seconds: 1), () { + if (mounted) { + setState(() { + _incorrectPassword = false; + }); + } + }); + }); + } } //todo: Add clear and custom icon for suffic icon diff --git a/auth/lib/ui/settings/lock_screen/custom_pin_keypad.dart b/auth/lib/ui/settings/lock_screen/custom_pin_keypad.dart new file mode 100644 index 0000000000..3bdf091f16 --- /dev/null +++ b/auth/lib/ui/settings/lock_screen/custom_pin_keypad.dart @@ -0,0 +1,203 @@ +import "package:ente_auth/theme/ente_theme.dart"; +import "package:flutter/material.dart"; + +class CustomPinKeypad extends StatelessWidget { + final TextEditingController controller; + const CustomPinKeypad({required this.controller, super.key}); + + @override + Widget build(BuildContext context) { + return SafeArea( + child: Container( + padding: const EdgeInsets.all(2), + child: Column( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Container( + color: getEnteColorScheme(context).strokeFainter, + child: Column( + children: [ + Row( + children: [ + _Button( + text: '', + number: '1', + onTap: () { + _onKeyTap('1'); + }, + ), + _Button( + text: "ABC", + number: '2', + onTap: () { + _onKeyTap('2'); + }, + ), + _Button( + text: "DEF", + number: '3', + onTap: () { + _onKeyTap('3'); + }, + ), + ], + ), + Row( + children: [ + _Button( + number: '4', + text: "GHI", + onTap: () { + _onKeyTap('4'); + }, + ), + _Button( + number: '5', + text: 'JKL', + onTap: () { + _onKeyTap('5'); + }, + ), + _Button( + number: '6', + text: 'MNO', + onTap: () { + _onKeyTap('6'); + }, + ), + ], + ), + Row( + children: [ + _Button( + number: '7', + text: 'PQRS', + onTap: () { + _onKeyTap('7'); + }, + ), + _Button( + number: '8', + text: 'TUV', + onTap: () { + _onKeyTap('8'); + }, + ), + _Button( + number: '9', + text: 'WXYZ', + onTap: () { + _onKeyTap('9'); + }, + ), + ], + ), + Row( + children: [ + const _Button( + number: '', + text: '', + muteButton: true, + onTap: null, + ), + _Button( + number: '0', + text: '', + onTap: () { + _onKeyTap('0'); + }, + ), + _Button( + number: '', + text: '', + icon: const Icon(Icons.backspace_outlined), + onTap: () { + _onBackspace(); + }, + ), + ], + ), + ], + ), + ), + ], + ), + ), + ); + } + + void _onKeyTap(String number) { + controller.text += number; + return; + } + + void _onBackspace() { + if (controller.text.isNotEmpty) { + controller.text = + controller.text.substring(0, controller.text.length - 1); + } + return; + } +} + +class _Button extends StatelessWidget { + final String number; + final String text; + final VoidCallback? onTap; + final bool muteButton; + final Widget? icon; + const _Button({ + required this.number, + required this.text, + this.muteButton = false, + required this.onTap, + this.icon, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = getEnteColorScheme(context); + final textTheme = getEnteTextTheme(context); + return Expanded( + child: GestureDetector( + onTap: onTap, + child: Container( + margin: const EdgeInsets.all(4), + decoration: BoxDecoration( + shape: BoxShape.rectangle, + borderRadius: BorderRadius.circular(6), + color: muteButton + ? colorScheme.fillFaintPressed + : icon == null + ? colorScheme.backgroundElevated2 + : null, + ), + child: Center( + child: muteButton + ? const SizedBox.shrink() + : icon != null + ? Container( + child: icon, + ) + : Container( + padding: const EdgeInsets.all(4), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + number, + style: textTheme.h3, + ), + Text( + text, + style: textTheme.tinyBold, + ), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/auth/lib/ui/settings/lock_screen/lock_screen_auto_lock.dart b/auth/lib/ui/settings/lock_screen/lock_screen_auto_lock.dart new file mode 100644 index 0000000000..869bb1e40a --- /dev/null +++ b/auth/lib/ui/settings/lock_screen/lock_screen_auto_lock.dart @@ -0,0 +1,143 @@ +import 'package:ente_auth/l10n/l10n.dart'; +import 'package:ente_auth/theme/ente_theme.dart'; +import 'package:ente_auth/ui/components/captioned_text_widget.dart'; +import 'package:ente_auth/ui/components/divider_widget.dart'; +import 'package:ente_auth/ui/components/menu_item_widget.dart'; +import 'package:ente_auth/ui/components/separators.dart'; +import 'package:ente_auth/ui/components/title_bar_title_widget.dart'; +import 'package:ente_auth/ui/components/title_bar_widget.dart'; +import 'package:ente_auth/utils/lock_screen_settings.dart'; +import 'package:flutter/material.dart'; + +class LockScreenAutoLock extends StatefulWidget { + const LockScreenAutoLock({super.key}); + + @override + State createState() => _LockScreenAutoLockState(); +} + +class _LockScreenAutoLockState extends State { + @override + Widget build(BuildContext context) { + return Scaffold( + body: CustomScrollView( + primary: false, + slivers: [ + TitleBarWidget( + flexibleSpaceTitle: TitleBarTitleWidget( + title: context.l10n.autoLock, + ), + ), + SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) { + return const Padding( + padding: EdgeInsets.symmetric( + horizontal: 16, + vertical: 20, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Column( + children: [ + ClipRRect( + borderRadius: BorderRadius.all(Radius.circular(8)), + child: AutoLockItems(), + ), + ], + ), + ], + ), + ); + }, + childCount: 1, + ), + ), + ], + ), + ); + } +} + +class AutoLockItems extends StatefulWidget { + const AutoLockItems({super.key}); + + @override + State createState() => _AutoLockItemsState(); +} + +class _AutoLockItemsState extends State { + final autoLockDurations = LockScreenSettings.instance.autoLockDurations; + List items = []; + Duration currentAutoLockTime = const Duration(seconds: 5); + + @override + void initState() { + for (Duration autoLockDuration in autoLockDurations) { + if (autoLockDuration.inMilliseconds == + LockScreenSettings.instance.getAutoLockTime()) { + currentAutoLockTime = autoLockDuration; + break; + } + } + super.initState(); + } + + @override + Widget build(BuildContext context) { + items.clear(); + for (Duration autoLockDuration in autoLockDurations) { + items.add( + _menuItemForPicker(autoLockDuration), + ); + } + items = addSeparators( + items, + DividerWidget( + dividerType: DividerType.menuNoIcon, + bgColor: getEnteColorScheme(context).fillFaint, + ), + ); + return Column( + mainAxisSize: MainAxisSize.min, + children: items, + ); + } + + Widget _menuItemForPicker(Duration autoLockTime) { + return MenuItemWidget( + key: ValueKey(autoLockTime), + menuItemColor: getEnteColorScheme(context).fillFaint, + captionedTextWidget: CaptionedTextWidget( + title: _formatTime(autoLockTime), + ), + trailingIcon: currentAutoLockTime == autoLockTime ? Icons.check : null, + alignCaptionedTextToLeft: true, + isTopBorderRadiusRemoved: true, + isBottomBorderRadiusRemoved: true, + showOnlyLoadingState: true, + onTap: () async { + await LockScreenSettings.instance.setAutoLockTime(autoLockTime).then( + (value) => { + setState(() { + currentAutoLockTime = autoLockTime; + }), + }, + ); + }, + ); + } + + String _formatTime(Duration duration) { + if (duration.inHours != 0) { + return "${duration.inHours}hr"; + } else if (duration.inMinutes != 0) { + return "${duration.inMinutes}m"; + } else if (duration.inSeconds != 0) { + return "${duration.inSeconds}s"; + } else { + return context.l10n.immediately; + } + } +} diff --git a/auth/lib/ui/settings/lock_screen/lock_screen_confirm_password.dart b/auth/lib/ui/settings/lock_screen/lock_screen_confirm_password.dart new file mode 100644 index 0000000000..dee6fd2db9 --- /dev/null +++ b/auth/lib/ui/settings/lock_screen/lock_screen_confirm_password.dart @@ -0,0 +1,186 @@ +import "package:ente_auth/l10n/l10n.dart"; +import "package:ente_auth/theme/ente_theme.dart"; +import "package:ente_auth/ui/common/dynamic_fab.dart"; +import "package:ente_auth/ui/components/buttons/icon_button_widget.dart"; +import "package:ente_auth/ui/components/text_input_widget.dart"; +import "package:ente_auth/utils/lock_screen_settings.dart"; +import "package:flutter/material.dart"; +import "package:flutter/services.dart"; + +class LockScreenConfirmPassword extends StatefulWidget { + const LockScreenConfirmPassword({ + super.key, + required this.password, + }); + final String password; + + @override + State createState() => + _LockScreenConfirmPasswordState(); +} + +class _LockScreenConfirmPasswordState extends State { + final _confirmPasswordController = TextEditingController(text: null); + final LockScreenSettings _lockscreenSetting = LockScreenSettings.instance; + final _focusNode = FocusNode(); + final _isFormValid = ValueNotifier(false); + final _submitNotifier = ValueNotifier(false); + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) async { + _focusNode.requestFocus(); + }); + } + + @override + void dispose() { + _submitNotifier.dispose(); + _focusNode.dispose(); + _isFormValid.dispose(); + _confirmPasswordController.dispose(); + super.dispose(); + } + + Future _confirmPasswordMatch() async { + if (widget.password == _confirmPasswordController.text) { + await _lockscreenSetting.setPassword(_confirmPasswordController.text); + + Navigator.of(context).pop(true); + Navigator.of(context).pop(true); + return; + } + await HapticFeedback.vibrate(); + throw Exception("Incorrect password"); + } + + @override + Widget build(BuildContext context) { + final colorTheme = getEnteColorScheme(context); + final textTheme = getEnteTextTheme(context); + final isKeypadOpen = MediaQuery.viewInsetsOf(context).bottom > 100; + + FloatingActionButtonLocation? fabLocation() { + if (isKeypadOpen) { + return null; + } else { + return FloatingActionButtonLocation.centerFloat; + } + } + + return Scaffold( + resizeToAvoidBottomInset: isKeypadOpen, + appBar: AppBar( + elevation: 0, + leading: IconButton( + onPressed: () { + FocusScope.of(context).unfocus(); + Navigator.of(context).pop(); + }, + icon: Icon( + Icons.arrow_back, + color: colorTheme.textBase, + ), + ), + ), + floatingActionButton: ValueListenableBuilder( + valueListenable: _isFormValid, + builder: (context, isFormValid, child) { + return DynamicFAB( + isKeypadOpen: isKeypadOpen, + buttonText: context.l10n.confirm, + isFormValid: isFormValid, + onPressedFunction: () async { + _submitNotifier.value = !_submitNotifier.value; + }, + ); + }, + ), + floatingActionButtonLocation: fabLocation(), + floatingActionButtonAnimator: NoScalingAnimation(), + body: SingleChildScrollView( + child: Center( + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + SizedBox( + height: 120, + width: 120, + child: Stack( + alignment: Alignment.center, + children: [ + Container( + width: 82, + height: 82, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: LinearGradient( + colors: [ + Colors.grey.shade500.withOpacity(0.2), + Colors.grey.shade50.withOpacity(0.1), + Colors.grey.shade400.withOpacity(0.2), + Colors.grey.shade300.withOpacity(0.4), + ], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + ), + ), + child: Padding( + padding: const EdgeInsets.all(1.0), + child: Container( + decoration: BoxDecoration( + shape: BoxShape.circle, + color: colorTheme.backgroundBase, + ), + ), + ), + ), + SizedBox( + height: 75, + width: 75, + child: CircularProgressIndicator( + color: colorTheme.fillFaintPressed, + value: 1, + strokeWidth: 1.5, + ), + ), + IconButtonWidget( + icon: Icons.lock, + iconButtonType: IconButtonType.primary, + iconColor: colorTheme.textBase, + ), + ], + ), + ), + Text( + context.l10n.reEnterPassword, + style: textTheme.bodyBold, + ), + const Padding(padding: EdgeInsets.all(12)), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: TextInputWidget( + hintText: context.l10n.confirmPassword, + autoFocus: true, + textCapitalization: TextCapitalization.none, + isPasswordInput: true, + shouldSurfaceExecutionStates: false, + onChange: (p0) { + _confirmPasswordController.text = p0; + _isFormValid.value = + _confirmPasswordController.text.isNotEmpty; + }, + onSubmit: (p0) { + return _confirmPasswordMatch(); + }, + submitNotifier: _submitNotifier, + ), + ), + const Padding(padding: EdgeInsets.all(12)), + ], + ), + ), + ), + ); + } +} diff --git a/auth/lib/ui/settings/lock_screen/lock_screen_confirm_pin.dart b/auth/lib/ui/settings/lock_screen/lock_screen_confirm_pin.dart new file mode 100644 index 0000000000..5ed7c48796 --- /dev/null +++ b/auth/lib/ui/settings/lock_screen/lock_screen_confirm_pin.dart @@ -0,0 +1,211 @@ +import "dart:io"; + +import "package:ente_auth/l10n/l10n.dart"; +import "package:ente_auth/theme/ente_theme.dart"; +import "package:ente_auth/ui/settings/lock_screen/custom_pin_keypad.dart"; +import "package:ente_auth/utils/lock_screen_settings.dart"; +import "package:flutter/material.dart"; +import "package:flutter/services.dart"; +import "package:pinput/pinput.dart"; + +class LockScreenConfirmPin extends StatefulWidget { + const LockScreenConfirmPin({super.key, required this.pin}); + final String pin; + @override + State createState() => _LockScreenConfirmPinState(); +} + +class _LockScreenConfirmPinState extends State { + final _confirmPinController = TextEditingController(text: null); + bool isConfirmPinValid = false; + bool isPlatformDesktop = false; + final LockScreenSettings _lockscreenSetting = LockScreenSettings.instance; + final _pinPutDecoration = PinTheme( + height: 48, + width: 48, + padding: const EdgeInsets.only(top: 6.0), + decoration: BoxDecoration( + border: Border.all(color: const Color.fromRGBO(45, 194, 98, 1.0)), + borderRadius: BorderRadius.circular(15.0), + ), + ); + + @override + void initState() { + super.initState(); + isPlatformDesktop = + Platform.isLinux || Platform.isMacOS || Platform.isWindows; + } + + @override + void dispose() { + super.dispose(); + _confirmPinController.dispose(); + } + + Future _confirmPinMatch() async { + if (widget.pin == _confirmPinController.text) { + await _lockscreenSetting.setPin(_confirmPinController.text); + + Navigator.of(context).pop(true); + Navigator.of(context).pop(true); + return; + } + setState(() { + isConfirmPinValid = true; + }); + await HapticFeedback.vibrate(); + await Future.delayed(const Duration(milliseconds: 75)); + _confirmPinController.clear(); + setState(() { + isConfirmPinValid = false; + }); + } + + @override + Widget build(BuildContext context) { + final colorTheme = getEnteColorScheme(context); + final textTheme = getEnteTextTheme(context); + return Scaffold( + appBar: AppBar( + elevation: 0, + leading: IconButton( + onPressed: () { + Navigator.of(context).pop(false); + }, + icon: Icon( + Icons.arrow_back, + color: colorTheme.textBase, + ), + ), + ), + floatingActionButton: isPlatformDesktop + ? null + : CustomPinKeypad(controller: _confirmPinController), + floatingActionButtonLocation: FloatingActionButtonLocation.centerDocked, + body: SingleChildScrollView( + child: _getBody(colorTheme, textTheme), + ), + ); + } + + Widget _getBody(colorTheme, textTheme) { + return Center( + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + SizedBox( + height: 120, + width: 120, + child: Stack( + alignment: Alignment.center, + children: [ + Container( + width: 82, + height: 82, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: LinearGradient( + colors: [ + Colors.grey.shade500.withOpacity(0.2), + Colors.grey.shade50.withOpacity(0.1), + Colors.grey.shade400.withOpacity(0.2), + Colors.grey.shade300.withOpacity(0.4), + ], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + ), + ), + child: Padding( + padding: const EdgeInsets.all(1.0), + child: Container( + decoration: BoxDecoration( + shape: BoxShape.circle, + color: colorTheme.backgroundBase, + ), + ), + ), + ), + SizedBox( + height: 75, + width: 75, + child: ValueListenableBuilder( + valueListenable: _confirmPinController, + builder: (context, value, child) { + return TweenAnimationBuilder( + tween: Tween( + begin: 0, + end: _confirmPinController.text.length / 4, + ), + curve: Curves.ease, + duration: const Duration(milliseconds: 250), + builder: (context, value, _) => + CircularProgressIndicator( + backgroundColor: colorTheme.fillFaintPressed, + value: value, + color: colorTheme.primary400, + strokeWidth: 1.5, + ), + ); + }, + ), + ), + Icon( + Icons.lock, + color: colorTheme.textBase, + size: 30, + ), + ], + ), + ), + Text( + context.l10n.reEnterPin, + style: textTheme.bodyBold, + ), + const Padding(padding: EdgeInsets.all(12)), + Pinput( + length: 4, + showCursor: false, + useNativeKeyboard: isPlatformDesktop, + autofocus: true, + controller: _confirmPinController, + defaultPinTheme: _pinPutDecoration, + submittedPinTheme: _pinPutDecoration.copyWith( + textStyle: textTheme.h3Bold, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10.0), + border: Border.all( + color: colorTheme.fillBase, + ), + ), + ), + followingPinTheme: _pinPutDecoration.copyWith( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10.0), + border: Border.all( + color: colorTheme.fillMuted, + ), + ), + ), + focusedPinTheme: _pinPutDecoration, + errorPinTheme: _pinPutDecoration.copyWith( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10.0), + border: Border.all( + color: colorTheme.warning400, + ), + ), + ), + errorText: '', + obscureText: true, + obscuringCharacter: '*', + forceErrorState: isConfirmPinValid, + onCompleted: (value) async { + await _confirmPinMatch(); + }, + ), + ], + ), + ); + } +} diff --git a/auth/lib/ui/settings/lock_screen/lock_screen_options.dart b/auth/lib/ui/settings/lock_screen/lock_screen_options.dart new file mode 100644 index 0000000000..2c600a0d1d --- /dev/null +++ b/auth/lib/ui/settings/lock_screen/lock_screen_options.dart @@ -0,0 +1,368 @@ +import "dart:async"; +import "dart:io"; + +import "package:ente_auth/core/configuration.dart"; +import "package:ente_auth/l10n/l10n.dart"; +import "package:ente_auth/theme/ente_theme.dart"; +import "package:ente_auth/ui/components/captioned_text_widget.dart"; +import "package:ente_auth/ui/components/divider_widget.dart"; +import "package:ente_auth/ui/components/menu_item_widget.dart"; +import "package:ente_auth/ui/components/title_bar_title_widget.dart"; +import "package:ente_auth/ui/components/title_bar_widget.dart"; +import "package:ente_auth/ui/components/toggle_switch_widget.dart"; +import "package:ente_auth/ui/settings/lock_screen/lock_screen_auto_lock.dart"; +import "package:ente_auth/ui/settings/lock_screen/lock_screen_password.dart"; +import "package:ente_auth/ui/settings/lock_screen/lock_screen_pin.dart"; +import "package:ente_auth/ui/tools/app_lock.dart"; +import "package:ente_auth/utils/lock_screen_settings.dart"; +import "package:ente_auth/utils/navigation_util.dart"; +import "package:ente_auth/utils/platform_util.dart"; +import "package:flutter/material.dart"; + +class LockScreenOptions extends StatefulWidget { + const LockScreenOptions({super.key}); + + @override + State createState() => _LockScreenOptionsState(); +} + +class _LockScreenOptionsState extends State { + final Configuration _configuration = Configuration.instance; + final LockScreenSettings _lockscreenSetting = LockScreenSettings.instance; + late bool appLock; + bool isPinEnabled = false; + bool isPasswordEnabled = false; + late int autoLockTimeInMilliseconds; + late bool hideAppContent; + + @override + void initState() { + super.initState(); + hideAppContent = _lockscreenSetting.getShouldHideAppContent(); + autoLockTimeInMilliseconds = _lockscreenSetting.getAutoLockTime(); + _initializeSettings(); + appLock = isPinEnabled || + isPasswordEnabled || + _configuration.shouldShowSystemLockScreen(); + } + + Future _initializeSettings() async { + final bool passwordEnabled = await _lockscreenSetting.isPasswordSet(); + final bool pinEnabled = await _lockscreenSetting.isPinSet(); + final bool shouldHideAppContent = + _lockscreenSetting.getShouldHideAppContent(); + setState(() { + isPasswordEnabled = passwordEnabled; + isPinEnabled = pinEnabled; + hideAppContent = shouldHideAppContent; + }); + } + + Future _deviceLock() async { + await _lockscreenSetting.removePinAndPassword(); + await _initializeSettings(); + } + + Future _pinLock() async { + final bool result = await Navigator.of(context).push( + MaterialPageRoute( + builder: (BuildContext context) { + return const LockScreenPin(); + }, + ), + ); + setState(() { + _initializeSettings(); + if (result) { + appLock = isPinEnabled || + isPasswordEnabled || + _configuration.shouldShowSystemLockScreen(); + } + }); + } + + Future _passwordLock() async { + final bool result = await Navigator.of(context).push( + MaterialPageRoute( + builder: (BuildContext context) { + return const LockScreenPassword(); + }, + ), + ); + setState(() { + _initializeSettings(); + if (result) { + appLock = isPinEnabled || + isPasswordEnabled || + _configuration.shouldShowSystemLockScreen(); + } + }); + } + + Future _onToggleSwitch() async { + AppLock.of(context)!.setEnabled(!appLock); + await _configuration.setSystemLockScreen(!appLock); + await _lockscreenSetting.removePinAndPassword(); + if (PlatformUtil.isMobile()) { + await _lockscreenSetting.setHideAppContent(!appLock); + } + setState(() { + _initializeSettings(); + appLock = !appLock; + hideAppContent = _lockscreenSetting.getShouldHideAppContent(); + }); + } + + Future _onAutoLock() async { + await routeToPage( + context, + const LockScreenAutoLock(), + ).then( + (value) { + setState(() { + autoLockTimeInMilliseconds = _lockscreenSetting.getAutoLockTime(); + }); + }, + ); + } + + Future _onHideContent() async { + setState(() { + hideAppContent = !hideAppContent; + }); + await _lockscreenSetting.setHideAppContent(hideAppContent); + } + + String _formatTime(Duration duration) { + if (duration.inHours != 0) { + return "in ${duration.inHours} hour${duration.inHours > 1 ? 's' : ''}"; + } else if (duration.inMinutes != 0) { + return "in ${duration.inMinutes} minute${duration.inMinutes > 1 ? 's' : ''}"; + } else if (duration.inSeconds != 0) { + return "in ${duration.inSeconds} second${duration.inSeconds > 1 ? 's' : ''}"; + } else { + return context.l10n.immediately; + } + } + + @override + Widget build(BuildContext context) { + final colorTheme = getEnteColorScheme(context); + final textTheme = getEnteTextTheme(context); + return Scaffold( + body: CustomScrollView( + primary: false, + slivers: [ + TitleBarWidget( + flexibleSpaceTitle: TitleBarTitleWidget( + title: context.l10n.appLock, + ), + ), + SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 20), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MenuItemWidget( + captionedTextWidget: CaptionedTextWidget( + title: context.l10n.appLock, + ), + alignCaptionedTextToLeft: true, + singleBorderRadius: 8, + menuItemColor: colorTheme.fillFaint, + trailingWidget: ToggleSwitchWidget( + value: () => appLock, + onChanged: () => _onToggleSwitch(), + ), + ), + AnimatedSwitcher( + duration: const Duration(milliseconds: 210), + switchInCurve: Curves.easeOut, + switchOutCurve: Curves.easeIn, + child: !appLock + ? Padding( + padding: const EdgeInsets.only( + top: 14, + left: 14, + right: 12, + ), + child: Text( + context.l10n.appLockDescription, + style: textTheme.miniFaint, + textAlign: TextAlign.left, + ), + ) + : const SizedBox(), + ), + const Padding( + padding: EdgeInsets.only(top: 24), + ), + ], + ), + AnimatedSwitcher( + duration: const Duration(milliseconds: 210), + switchInCurve: Curves.easeOut, + switchOutCurve: Curves.easeIn, + child: appLock + ? Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MenuItemWidget( + captionedTextWidget: CaptionedTextWidget( + title: context.l10n.deviceLock, + ), + surfaceExecutionStates: false, + alignCaptionedTextToLeft: true, + isTopBorderRadiusRemoved: false, + isBottomBorderRadiusRemoved: true, + menuItemColor: colorTheme.fillFaint, + trailingIcon: + !(isPasswordEnabled || isPinEnabled) + ? Icons.check + : null, + trailingIconColor: colorTheme.textBase, + onTap: () => _deviceLock(), + ), + DividerWidget( + dividerType: DividerType.menuNoIcon, + bgColor: colorTheme.fillFaint, + ), + MenuItemWidget( + captionedTextWidget: CaptionedTextWidget( + title: context.l10n.pinLock, + ), + surfaceExecutionStates: false, + alignCaptionedTextToLeft: true, + isTopBorderRadiusRemoved: true, + isBottomBorderRadiusRemoved: true, + menuItemColor: colorTheme.fillFaint, + trailingIcon: + isPinEnabled ? Icons.check : null, + trailingIconColor: colorTheme.textBase, + onTap: () => _pinLock(), + ), + DividerWidget( + dividerType: DividerType.menuNoIcon, + bgColor: colorTheme.fillFaint, + ), + MenuItemWidget( + captionedTextWidget: CaptionedTextWidget( + title: context.l10n.password, + ), + surfaceExecutionStates: false, + alignCaptionedTextToLeft: true, + isTopBorderRadiusRemoved: true, + isBottomBorderRadiusRemoved: false, + menuItemColor: colorTheme.fillFaint, + trailingIcon: isPasswordEnabled + ? Icons.check + : null, + trailingIconColor: colorTheme.textBase, + onTap: () => _passwordLock(), + ), + const SizedBox( + height: 24, + ), + PlatformUtil.isMobile() + ? MenuItemWidget( + captionedTextWidget: + CaptionedTextWidget( + title: context.l10n.autoLock, + subTitle: _formatTime( + Duration( + milliseconds: + autoLockTimeInMilliseconds, + ), + ), + ), + surfaceExecutionStates: false, + alignCaptionedTextToLeft: true, + singleBorderRadius: 8, + menuItemColor: colorTheme.fillFaint, + trailingIconColor: + colorTheme.textBase, + onTap: () => _onAutoLock(), + ) + : const SizedBox.shrink(), + PlatformUtil.isMobile() + ? Padding( + padding: const EdgeInsets.only( + top: 14, + left: 14, + right: 12, + ), + child: Text( + context.l10n + .autoLockFeatureDescription, + style: textTheme.miniFaint, + textAlign: TextAlign.left, + ), + ) + : const SizedBox.shrink(), + PlatformUtil.isMobile() + ? Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + const SizedBox(height: 24), + MenuItemWidget( + captionedTextWidget: + CaptionedTextWidget( + title: + context.l10n.hideContent, + ), + alignCaptionedTextToLeft: true, + singleBorderRadius: 8, + menuItemColor: + colorTheme.fillFaint, + trailingWidget: + ToggleSwitchWidget( + value: () => hideAppContent, + onChanged: () => + _onHideContent(), + ), + ), + Padding( + padding: const EdgeInsets.only( + top: 14, + left: 14, + right: 12, + ), + child: Text( + Platform.isAndroid + ? context.l10n + .hideContentDescriptionAndroid + : context.l10n + .hideContentDescriptioniOS, + style: textTheme.miniFaint, + textAlign: TextAlign.left, + ), + ), + ], + ) + : const SizedBox.shrink(), + ], + ) + : const SizedBox.shrink(), + ), + ], + ), + ), + ); + }, + childCount: 1, + ), + ), + ], + ), + ); + } +} diff --git a/auth/lib/ui/settings/lock_screen/lock_screen_password.dart b/auth/lib/ui/settings/lock_screen/lock_screen_password.dart new file mode 100644 index 0000000000..43a103523a --- /dev/null +++ b/auth/lib/ui/settings/lock_screen/lock_screen_password.dart @@ -0,0 +1,250 @@ +import "dart:convert"; + +import "package:ente_auth/l10n/l10n.dart"; +import "package:ente_auth/theme/ente_theme.dart"; +import "package:ente_auth/ui/common/dynamic_fab.dart"; +import "package:ente_auth/ui/components/buttons/icon_button_widget.dart"; +import "package:ente_auth/ui/components/text_input_widget.dart"; +import "package:ente_auth/ui/settings/lock_screen/lock_screen_confirm_password.dart"; +import "package:ente_auth/ui/settings/lock_screen/lock_screen_options.dart"; +import "package:ente_auth/utils/lock_screen_settings.dart"; +import "package:ente_crypto_dart/ente_crypto_dart.dart"; +import "package:flutter/material.dart"; +import "package:flutter/services.dart"; + +/// [isChangingLockScreenSettings] Authentication required for changing lock screen settings. +/// Set to true when the app requires the user to authenticate before allowing +/// changes to the lock screen settings. + +/// [isAuthenticatingOnAppLaunch] Authentication required on app launch. +/// Set to true when the app requires the user to authenticate immediately upon opening. + +/// [isAuthenticatingForInAppChange] Authentication required for in-app changes (e.g., email, password). +/// Set to true when the app requires the to authenticate for sensitive actions like email, password changes. + +class LockScreenPassword extends StatefulWidget { + const LockScreenPassword({ + super.key, + this.isChangingLockScreenSettings = false, + this.isAuthenticatingOnAppLaunch = false, + this.isAuthenticatingForInAppChange = false, + this.authPass, + }); + + final bool isChangingLockScreenSettings; + final bool isAuthenticatingOnAppLaunch; + final bool isAuthenticatingForInAppChange; + final String? authPass; + @override + State createState() => _LockScreenPasswordState(); +} + +class _LockScreenPasswordState extends State { + final _passwordController = TextEditingController(text: null); + final _focusNode = FocusNode(); + final _isFormValid = ValueNotifier(false); + final _submitNotifier = ValueNotifier(false); + int invalidAttemptsCount = 0; + + final _lockscreenSetting = LockScreenSettings.instance; + @override + void initState() { + super.initState(); + invalidAttemptsCount = _lockscreenSetting.getInvalidAttemptCount(); + WidgetsBinding.instance.addPostFrameCallback((_) async { + _focusNode.requestFocus(); + }); + } + + @override + void dispose() { + super.dispose(); + _submitNotifier.dispose(); + _focusNode.dispose(); + _isFormValid.dispose(); + _passwordController.dispose(); + } + + @override + Widget build(BuildContext context) { + final colorTheme = getEnteColorScheme(context); + final textTheme = getEnteTextTheme(context); + final isKeypadOpen = MediaQuery.viewInsetsOf(context).bottom > 100; + + FloatingActionButtonLocation? fabLocation() { + if (isKeypadOpen) { + return null; + } else { + return FloatingActionButtonLocation.centerFloat; + } + } + + return Scaffold( + resizeToAvoidBottomInset: isKeypadOpen, + appBar: AppBar( + elevation: 0, + leading: IconButton( + onPressed: () { + FocusScope.of(context).unfocus(); + Navigator.of(context).pop(false); + }, + icon: Icon( + Icons.arrow_back, + color: colorTheme.textBase, + ), + ), + ), + floatingActionButton: ValueListenableBuilder( + valueListenable: _isFormValid, + builder: (context, isFormValid, child) { + return DynamicFAB( + isKeypadOpen: isKeypadOpen, + buttonText: context.l10n.next, + isFormValid: isFormValid, + onPressedFunction: () async { + _submitNotifier.value = !_submitNotifier.value; + }, + ); + }, + ), + floatingActionButtonLocation: fabLocation(), + floatingActionButtonAnimator: NoScalingAnimation(), + body: SingleChildScrollView( + child: Center( + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + SizedBox( + height: 120, + width: 120, + child: Stack( + alignment: Alignment.center, + children: [ + Container( + width: 82, + height: 82, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: LinearGradient( + colors: [ + Colors.grey.shade500.withOpacity(0.2), + Colors.grey.shade50.withOpacity(0.1), + Colors.grey.shade400.withOpacity(0.2), + Colors.grey.shade300.withOpacity(0.4), + ], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + ), + ), + child: Padding( + padding: const EdgeInsets.all(1.0), + child: Container( + decoration: BoxDecoration( + shape: BoxShape.circle, + color: colorTheme.backgroundBase, + ), + ), + ), + ), + SizedBox( + height: 75, + width: 75, + child: CircularProgressIndicator( + color: colorTheme.fillFaintPressed, + value: 1, + strokeWidth: 1.5, + ), + ), + IconButtonWidget( + icon: Icons.lock, + iconButtonType: IconButtonType.primary, + iconColor: colorTheme.textBase, + ), + ], + ), + ), + Text( + widget.isChangingLockScreenSettings + ? context.l10n.enterPassword + : context.l10n.setNewPassword, + textAlign: TextAlign.center, + style: textTheme.bodyBold, + ), + const Padding(padding: EdgeInsets.all(12)), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: TextInputWidget( + hintText: context.l10n.password, + autoFocus: true, + textCapitalization: TextCapitalization.none, + isPasswordInput: true, + shouldSurfaceExecutionStates: false, + onChange: (p0) { + _passwordController.text = p0; + _isFormValid.value = _passwordController.text.isNotEmpty; + }, + onSubmit: (p0) { + return _confirmPassword(); + }, + submitNotifier: _submitNotifier, + ), + ), + const Padding(padding: EdgeInsets.all(12)), + ], + ), + ), + ), + ); + } + + Future _confirmPasswordAuth(String inputtedPassword) async { + final Uint8List? salt = await _lockscreenSetting.getSalt(); + final hash = cryptoPwHash( + utf8.encode(inputtedPassword), + salt!, + sodium.crypto.pwhash.memLimitInteractive, + sodium.crypto.pwhash.opsLimitSensitive, + sodium, + ); + if (widget.authPass == base64Encode(hash)) { + await _lockscreenSetting.setInvalidAttemptCount(0); + + widget.isAuthenticatingOnAppLaunch || + widget.isAuthenticatingForInAppChange + ? Navigator.of(context).pop(true) + : Navigator.of(context).pushReplacement( + MaterialPageRoute( + builder: (context) => const LockScreenOptions(), + ), + ); + return true; + } else { + if (widget.isAuthenticatingOnAppLaunch) { + invalidAttemptsCount++; + await _lockscreenSetting.setInvalidAttemptCount(invalidAttemptsCount); + if (invalidAttemptsCount > 4) { + Navigator.of(context).pop(false); + } + } + + await HapticFeedback.vibrate(); + throw Exception("Incorrect password"); + } + } + + Future _confirmPassword() async { + if (widget.isChangingLockScreenSettings) { + await _confirmPasswordAuth(_passwordController.text); + return; + } else { + await Navigator.of(context).push( + MaterialPageRoute( + builder: (BuildContext context) => LockScreenConfirmPassword( + password: _passwordController.text, + ), + ), + ); + _passwordController.clear(); + } + } +} diff --git a/auth/lib/ui/settings/lock_screen/lock_screen_pin.dart b/auth/lib/ui/settings/lock_screen/lock_screen_pin.dart new file mode 100644 index 0000000000..8cd4509f51 --- /dev/null +++ b/auth/lib/ui/settings/lock_screen/lock_screen_pin.dart @@ -0,0 +1,284 @@ +import "dart:convert"; +import "dart:io"; + +import "package:ente_auth/l10n/l10n.dart"; +import "package:ente_auth/theme/colors.dart"; +import "package:ente_auth/theme/ente_theme.dart"; +import "package:ente_auth/theme/text_style.dart"; +import "package:ente_auth/ui/settings/lock_screen/custom_pin_keypad.dart"; +import "package:ente_auth/ui/settings/lock_screen/lock_screen_confirm_pin.dart"; +import "package:ente_auth/ui/settings/lock_screen/lock_screen_options.dart"; +import "package:ente_auth/utils/lock_screen_settings.dart"; +import "package:ente_crypto_dart/ente_crypto_dart.dart"; +import "package:flutter/material.dart"; +import "package:flutter/services.dart"; +import 'package:pinput/pinput.dart'; + +/// [isChangingLockScreenSettings] Authentication required for changing lock screen settings. +/// Set to true when the app requires the user to authenticate before allowing +/// changes to the lock screen settings. + +/// [isAuthenticatingOnAppLaunch] Authentication required on app launch. +/// Set to true when the app requires the user to authenticate immediately upon opening. + +/// [isAuthenticatingForInAppChange] Authentication required for in-app changes (e.g., email, password). +/// Set to true when the app requires the to authenticate for sensitive actions like email, password changes. + +class LockScreenPin extends StatefulWidget { + const LockScreenPin({ + super.key, + this.isChangingLockScreenSettings = false, + this.isAuthenticatingOnAppLaunch = false, + this.isAuthenticatingForInAppChange = false, + this.authPin, + }); + + final bool isAuthenticatingOnAppLaunch; + final bool isChangingLockScreenSettings; + final bool isAuthenticatingForInAppChange; + final String? authPin; + @override + State createState() => _LockScreenPinState(); +} + +class _LockScreenPinState extends State { + final _pinController = TextEditingController(text: null); + + final LockScreenSettings _lockscreenSetting = LockScreenSettings.instance; + bool isPinValid = false; + int invalidAttemptsCount = 0; + bool isPlatformDesktop = false; + @override + void initState() { + super.initState(); + isPlatformDesktop = + Platform.isLinux || Platform.isMacOS || Platform.isWindows; + invalidAttemptsCount = _lockscreenSetting.getInvalidAttemptCount(); + } + + @override + void dispose() { + super.dispose(); + _pinController.dispose(); + } + + Future confirmPinAuth(String inputtedPin) async { + final Uint8List? salt = await _lockscreenSetting.getSalt(); + final hash = cryptoPwHash( + utf8.encode(inputtedPin), + salt!, + sodium.crypto.pwhash.memLimitInteractive, + sodium.crypto.pwhash.opsLimitSensitive, + sodium, + ); + if (widget.authPin == base64Encode(hash)) { + invalidAttemptsCount = 0; + await _lockscreenSetting.setInvalidAttemptCount(0); + widget.isAuthenticatingOnAppLaunch || + widget.isAuthenticatingForInAppChange + ? Navigator.of(context).pop(true) + : Navigator.of(context).pushReplacement( + MaterialPageRoute( + builder: (context) => const LockScreenOptions(), + ), + ); + return true; + } else { + setState(() { + isPinValid = true; + }); + await HapticFeedback.vibrate(); + await Future.delayed(const Duration(milliseconds: 75)); + _pinController.clear(); + setState(() { + isPinValid = false; + }); + + if (widget.isAuthenticatingOnAppLaunch) { + invalidAttemptsCount++; + await _lockscreenSetting.setInvalidAttemptCount(invalidAttemptsCount); + if (invalidAttemptsCount > 4) { + Navigator.of(context).pop(false); + } + } + return false; + } + } + + Future _confirmPin(String inputtedPin) async { + if (widget.isChangingLockScreenSettings) { + await confirmPinAuth(inputtedPin); + return; + } else { + await Navigator.of(context).push( + MaterialPageRoute( + builder: (BuildContext context) => + LockScreenConfirmPin(pin: inputtedPin), + ), + ); + _pinController.clear(); + } + } + + final _pinPutDecoration = PinTheme( + height: 48, + width: 48, + padding: const EdgeInsets.only(top: 6.0), + decoration: BoxDecoration( + border: Border.all(color: const Color.fromRGBO(45, 194, 98, 1.0)), + borderRadius: BorderRadius.circular(15.0), + ), + ); + + @override + Widget build(BuildContext context) { + final colorTheme = getEnteColorScheme(context); + final textTheme = getEnteTextTheme(context); + return Scaffold( + appBar: AppBar( + elevation: 0, + leading: IconButton( + onPressed: () { + Navigator.of(context).pop(false); + }, + icon: Icon( + Icons.arrow_back, + color: colorTheme.textBase, + ), + ), + ), + floatingActionButton: isPlatformDesktop + ? null + : CustomPinKeypad(controller: _pinController), + floatingActionButtonLocation: FloatingActionButtonLocation.centerDocked, + body: SingleChildScrollView( + child: _getBody(colorTheme, textTheme), + ), + ); + } + + Widget _getBody( + EnteColorScheme colorTheme, + EnteTextTheme textTheme, + ) { + return Center( + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + SizedBox( + height: 120, + width: 120, + child: Stack( + alignment: Alignment.center, + children: [ + Container( + width: 82, + height: 82, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: LinearGradient( + colors: [ + Colors.grey.shade500.withOpacity(0.2), + Colors.grey.shade50.withOpacity(0.1), + Colors.grey.shade400.withOpacity(0.2), + Colors.grey.shade300.withOpacity(0.4), + ], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + ), + ), + child: Padding( + padding: const EdgeInsets.all(1.0), + child: Container( + decoration: BoxDecoration( + shape: BoxShape.circle, + color: colorTheme.backgroundBase, + ), + ), + ), + ), + SizedBox( + height: 75, + width: 75, + child: ValueListenableBuilder( + valueListenable: _pinController, + builder: (context, value, child) { + return TweenAnimationBuilder( + tween: Tween( + begin: 0, + end: _pinController.text.length / 4, + ), + curve: Curves.ease, + duration: const Duration(milliseconds: 250), + builder: (context, value, _) => + CircularProgressIndicator( + backgroundColor: colorTheme.fillFaintPressed, + value: value, + color: colorTheme.primary400, + strokeWidth: 1.5, + ), + ); + }, + ), + ), + Icon( + Icons.lock, + color: colorTheme.textBase, + size: 30, + ), + ], + ), + ), + Text( + widget.isChangingLockScreenSettings + ? context.l10n.enterPin + : context.l10n.setNewPin, + style: textTheme.bodyBold, + ), + const Padding(padding: EdgeInsets.all(12)), + Pinput( + length: 4, + showCursor: false, + useNativeKeyboard: isPlatformDesktop, + controller: _pinController, + autofocus: true, + defaultPinTheme: _pinPutDecoration, + submittedPinTheme: _pinPutDecoration.copyWith( + textStyle: textTheme.h3Bold, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10.0), + border: Border.all( + color: colorTheme.fillBase, + ), + ), + ), + followingPinTheme: _pinPutDecoration.copyWith( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10.0), + border: Border.all( + color: colorTheme.fillMuted, + ), + ), + ), + focusedPinTheme: _pinPutDecoration, + errorPinTheme: _pinPutDecoration.copyWith( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10.0), + border: Border.all( + color: colorTheme.warning400, + ), + ), + ), + forceErrorState: isPinValid, + obscureText: true, + obscuringCharacter: '*', + errorText: '', + onCompleted: (value) async { + await _confirmPin(_pinController.text); + }, + ), + ], + ), + ); + } +} diff --git a/auth/lib/ui/settings/security_section_widget.dart b/auth/lib/ui/settings/security_section_widget.dart index 678a4ddfa1..9ba4289a36 100644 --- a/auth/lib/ui/settings/security_section_widget.dart +++ b/auth/lib/ui/settings/security_section_widget.dart @@ -15,6 +15,8 @@ import 'package:ente_auth/ui/components/expandable_menu_item_widget.dart'; import 'package:ente_auth/ui/components/menu_item_widget.dart'; import 'package:ente_auth/ui/components/toggle_switch_widget.dart'; import 'package:ente_auth/ui/settings/common_settings.dart'; +import 'package:ente_auth/ui/settings/lock_screen/lock_screen_options.dart'; +import 'package:ente_auth/utils/auth_util.dart'; import 'package:ente_auth/utils/dialog_util.dart'; import 'package:ente_auth/utils/navigation_util.dart'; import 'package:ente_auth/utils/platform_util.dart'; @@ -66,16 +68,6 @@ class _SecuritySectionWidgetState extends State { UserService.instance.getUserDetailsV2().ignore(); } children.addAll([ - sectionOptionSpacing, - MenuItemWidget( - captionedTextWidget: CaptionedTextWidget( - title: l10n.passkey, - ), - pressedColor: getEnteColorScheme(context).fillFaint, - trailingIcon: Icons.chevron_right_outlined, - trailingIconIsMuted: true, - onTap: () async => await onPasskeyClick(context), - ), sectionOptionSpacing, MenuItemWidget( captionedTextWidget: CaptionedTextWidget( @@ -102,6 +94,16 @@ class _SecuritySectionWidgetState extends State { ), ), sectionOptionSpacing, + MenuItemWidget( + captionedTextWidget: CaptionedTextWidget( + title: l10n.passkey, + ), + pressedColor: getEnteColorScheme(context).fillFaint, + trailingIcon: Icons.chevron_right_outlined, + trailingIconIsMuted: true, + onTap: () async => await onPasskeyClick(context), + ), + sectionOptionSpacing, MenuItemWidget( captionedTextWidget: CaptionedTextWidget( title: context.l10n.viewActiveSessions, @@ -133,26 +135,38 @@ class _SecuritySectionWidgetState extends State { children.add(sectionOptionSpacing); } children.addAll([ + sectionOptionSpacing, MenuItemWidget( captionedTextWidget: CaptionedTextWidget( - title: l10n.lockscreen, + title: context.l10n.appLock, ), - trailingWidget: ToggleSwitchWidget( - value: () => _config.shouldShowLockScreen(), - onChanged: () async { - final hasAuthenticated = await LocalAuthenticationService.instance - .requestLocalAuthForLockScreen( + surfaceExecutionStates: false, + trailingIcon: Icons.chevron_right_outlined, + trailingIconIsMuted: true, + onTap: () async { + if (await LocalAuthenticationService.instance + .isLocalAuthSupportedOnDevice()) { + final bool result = await requestAuthentication( context, - !_config.shouldShowLockScreen(), context.l10n.authToChangeLockscreenSetting, - context.l10n.lockScreenEnablePreSteps, ); - if (hasAuthenticated) { - FocusScope.of(context).requestFocus(); - setState(() {}); + if (result) { + await Navigator.of(context).push( + MaterialPageRoute( + builder: (BuildContext context) { + return const LockScreenOptions(); + }, + ), + ); } - }, - ), + } else { + await showErrorDialog( + context, + context.l10n.noSystemLockFound, + context.l10n.toEnableAppLockPleaseSetupDevicePasscodeOrScreen, + ); + } + }, ), sectionOptionSpacing, ]); diff --git a/auth/lib/ui/tools/app_lock.dart b/auth/lib/ui/tools/app_lock.dart index df55f81164..b4bad2d1ff 100644 --- a/auth/lib/ui/tools/app_lock.dart +++ b/auth/lib/ui/tools/app_lock.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:ente_auth/locale.dart'; +import 'package:ente_auth/utils/lock_screen_settings.dart'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; @@ -83,8 +84,12 @@ class _AppLockState extends State with WidgetsBindingObserver { if (state == AppLifecycleState.paused && (!this._isLocked && this._didUnlockForAppLaunch)) { - this._backgroundLockLatencyTimer = - Timer(this.widget.backgroundLockLatency, () => this.showLockScreen()); + this._backgroundLockLatencyTimer = Timer( + Duration( + milliseconds: LockScreenSettings.instance.getAutoLockTime(), + ), + () => this.showLockScreen(), + ); } if (state == AppLifecycleState.resumed) { diff --git a/auth/lib/ui/tools/lock_screen.dart b/auth/lib/ui/tools/lock_screen.dart index b6e2126e1d..a77ba6d155 100644 --- a/auth/lib/ui/tools/lock_screen.dart +++ b/auth/lib/ui/tools/lock_screen.dart @@ -1,10 +1,17 @@ import 'dart:io'; +import 'dart:math'; +import 'package:ente_auth/core/configuration.dart'; import 'package:ente_auth/l10n/l10n.dart'; -import 'package:ente_auth/ui/common/gradient_button.dart'; +import 'package:ente_auth/services/user_service.dart'; +import 'package:ente_auth/theme/ente_theme.dart'; import 'package:ente_auth/ui/tools/app_lock.dart'; import 'package:ente_auth/utils/auth_util.dart'; +import 'package:ente_auth/utils/dialog_util.dart'; +import 'package:ente_auth/utils/lock_screen_settings.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:flutter_animate/flutter_animate.dart'; import 'package:logging/logging.dart'; class LockScreen extends StatefulWidget { @@ -20,11 +27,17 @@ class _LockScreenState extends State with WidgetsBindingObserver { bool _hasPlacedAppInBackground = false; bool _hasAuthenticationFailed = false; int? lastAuthenticatingTime; - + bool isTimerRunning = false; + int lockedTimeInSeconds = 0; + int invalidAttemptCount = 0; + int remainingTimeInSeconds = 0; + final _lockscreenSetting = LockScreenSettings.instance; + late Brightness _platformBrightness; @override void initState() { _logger.info("initiatingState"); super.initState(); + invalidAttemptCount = _lockscreenSetting.getInvalidAttemptCount(); WidgetsBinding.instance.addObserver(this); WidgetsBinding.instance.addPostFrameCallback((timeStamp) { if (isNonMobileIOSDevice()) { @@ -33,37 +46,145 @@ class _LockScreenState extends State with WidgetsBindingObserver { } _showLockScreen(source: "postFrameInit"); }); + _platformBrightness = + SchedulerBinding.instance.platformDispatcher.platformBrightness; } @override Widget build(BuildContext context) { + final colorTheme = getEnteColorScheme(context); + final textTheme = getEnteTextTheme(context); return Scaffold( - body: Center( - child: Column( - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - Stack( - alignment: Alignment.center, + appBar: AppBar( + elevation: 0, + leading: IconButton( + icon: const Icon(Icons.logout_outlined), + color: Theme.of(context).iconTheme.color, + onPressed: () { + _onLogoutTapped(context); + }, + ), + ), + body: GestureDetector( + onTap: () { + isTimerRunning ? null : _showLockScreen(source: "tap"); + }, + child: Container( + decoration: BoxDecoration( + image: DecorationImage( + opacity: _platformBrightness == Brightness.light ? 0.08 : 0.12, + image: const ExactAssetImage( + 'assets/loading_photos_background.png', + ), + fit: BoxFit.cover, + ), + ), + child: Center( + child: Column( children: [ - Opacity( - opacity: 0.2, - child: Image.asset('assets/loading_photos_background.png'), - ), + const Spacer(), SizedBox( - width: 180, - child: GradientButton( - text: context.l10n.unlock, - iconData: Icons.lock_open_outlined, - onTap: () async { - // ignore: unawaited_futures - _showLockScreen(source: "tapUnlock"); - }, + height: 120, + width: 120, + child: Stack( + alignment: Alignment.center, + children: [ + Container( + width: 82, + height: 82, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: LinearGradient( + colors: [ + Colors.grey.shade500.withOpacity(0.2), + Colors.grey.shade50.withOpacity(0.1), + Colors.grey.shade400.withOpacity(0.2), + Colors.grey.shade300.withOpacity(0.4), + ], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + ), + ), + child: Padding( + padding: const EdgeInsets.all(1.0), + child: Container( + decoration: BoxDecoration( + shape: BoxShape.circle, + color: colorTheme.backgroundBase, + ), + ), + ), + ), + SizedBox( + height: 75, + width: 75, + child: TweenAnimationBuilder( + tween: Tween( + begin: isTimerRunning ? 0 : 1, + end: isTimerRunning + ? _getFractionOfTimeElapsed() + : 1, + ), + duration: const Duration(seconds: 1), + builder: (context, value, _) => + CircularProgressIndicator( + backgroundColor: colorTheme.fillFaintPressed, + value: value, + color: colorTheme.primary400, + strokeWidth: 1.5, + ), + ), + ), + Icon( + Icons.lock, + size: 30, + color: colorTheme.textBase, + ), + ], ), ), + const Spacer(), + isTimerRunning + ? Stack( + alignment: Alignment.center, + children: [ + Text( + context.l10n.tooManyIncorrectAttempts, + style: textTheme.small, + ) + .animate( + delay: const Duration(milliseconds: 2000), + ) + .fadeOut( + duration: 400.ms, + curve: Curves.easeInOutCirc, + ), + Text( + _formatTime(remainingTimeInSeconds), + style: textTheme.small, + ) + .animate( + delay: const Duration(milliseconds: 2250), + ) + .fadeIn( + duration: 400.ms, + curve: Curves.easeInOutCirc, + ), + ], + ) + : GestureDetector( + onTap: () => _showLockScreen(source: "tap"), + child: Text( + context.l10n.tapToUnlock, + style: textTheme.small, + ), + ), + const Padding( + padding: EdgeInsets.only(bottom: 24), + ), ], ), - ], + ), ), ), ); @@ -77,6 +198,18 @@ class _LockScreenState extends State with WidgetsBindingObserver { return shortestSide > 600 ? true : false; } + void _onLogoutTapped(BuildContext context) { + showChoiceActionSheet( + context, + title: context.l10n.areYouSureYouWantToLogout, + firstButtonLabel: context.l10n.yesLogout, + isCritical: true, + firstButtonOnTap: () async { + await UserService.instance.logout(context); + }, + ); + } + @override void didChangeAppLifecycleState(AppLifecycleState state) { _logger.info(state.toString()); @@ -90,10 +223,17 @@ class _LockScreenState extends State with WidgetsBindingObserver { if (!_hasAuthenticationFailed && !didAuthInLast5Seconds) { // Show the lock screen again only if the app is resuming from the // background, and not when the lock screen was explicitly dismissed - Future.delayed( - Duration.zero, - () => _showLockScreen(source: "lifeCycle"), - ); + if (_lockscreenSetting.getlastInvalidAttemptTime() > + DateTime.now().millisecondsSinceEpoch && + !_isShowingLockScreen) { + final int time = (_lockscreenSetting.getlastInvalidAttemptTime() - + DateTime.now().millisecondsSinceEpoch) ~/ + 1000; + Future.delayed(Duration.zero, () { + startLockTimer(time); + _showLockScreen(source: "lifeCycle"); + }); + } } else { _hasAuthenticationFailed = false; // Reset failure state } @@ -115,24 +255,112 @@ class _LockScreenState extends State with WidgetsBindingObserver { super.dispose(); } + Future startLockTimer(int timeInSeconds) async { + if (isTimerRunning) { + return; + } + + setState(() { + isTimerRunning = true; + remainingTimeInSeconds = timeInSeconds; + }); + + while (remainingTimeInSeconds > 0) { + await Future.delayed(const Duration(seconds: 1)); + setState(() { + remainingTimeInSeconds--; + }); + } + + setState(() { + isTimerRunning = false; + }); + } + + double _getFractionOfTimeElapsed() { + final int totalLockedTime = + lockedTimeInSeconds = pow(2, invalidAttemptCount - 5).toInt() * 30; + if (remainingTimeInSeconds == 0) return 1; + + return 1 - remainingTimeInSeconds / totalLockedTime; + } + + String _formatTime(int seconds) { + final int hours = seconds ~/ 3600; + final int minutes = (seconds % 3600) ~/ 60; + final int remainingSeconds = seconds % 60; + + if (hours > 0) { + return "${hours}h ${minutes}m"; + } else if (minutes > 0) { + return "${minutes}m ${remainingSeconds}s"; + } else { + return "${remainingSeconds}s"; + } + } + + Future _autoLogoutOnMaxInvalidAttempts() async { + _logger.info("Auto logout on max invalid attempts"); + Navigator.of(context, rootNavigator: true).pop('dialog'); + Navigator.of(context).popUntil((route) => route.isFirst); + final dialog = createProgressDialog(context, "Logging out ..."); + await dialog.show(); + await Configuration.instance.logout(); + await dialog.hide(); + } + Future _showLockScreen({String source = ''}) async { - final int id = DateTime.now().millisecondsSinceEpoch; - _logger.info("Showing lock screen $source $id"); + final int currentTimestamp = DateTime.now().millisecondsSinceEpoch; + _logger.info("Showing lock screen $source $currentTimestamp"); try { + if (currentTimestamp < _lockscreenSetting.getlastInvalidAttemptTime() && + !_isShowingLockScreen) { + final int remainingTime = + (_lockscreenSetting.getlastInvalidAttemptTime() - + currentTimestamp) ~/ + 1000; + + await startLockTimer(remainingTime); + } _isShowingLockScreen = true; - final result = await requestAuthentication( - context, - context.l10n.authToViewSecrets, - ); - _logger.finest("LockScreen Result $result $id"); + final result = isTimerRunning + ? false + : await requestAuthentication( + context, + context.l10n.authToViewSecrets, + isOpeningApp: true, + ); + _logger.finest("LockScreen Result $result $currentTimestamp"); _isShowingLockScreen = false; if (result) { lastAuthenticatingTime = DateTime.now().millisecondsSinceEpoch; AppLock.of(context)!.didUnlock(); + await _lockscreenSetting.setInvalidAttemptCount(0); + setState(() { + lockedTimeInSeconds = 15; + isTimerRunning = false; + }); } else { if (!_hasPlacedAppInBackground) { // Treat this as a failure only if user did not explicitly // put the app in background + if (_lockscreenSetting.getInvalidAttemptCount() > 4 && + invalidAttemptCount != + _lockscreenSetting.getInvalidAttemptCount()) { + invalidAttemptCount = _lockscreenSetting.getInvalidAttemptCount(); + + if (invalidAttemptCount > 9) { + await _autoLogoutOnMaxInvalidAttempts(); + return; + } + + lockedTimeInSeconds = pow(2, invalidAttemptCount - 5).toInt() * 30; + await _lockscreenSetting.setLastInvalidAttemptTime( + DateTime.now().millisecondsSinceEpoch + + lockedTimeInSeconds * 1000, + ); + await startLockTimer(lockedTimeInSeconds); + } _hasAuthenticationFailed = true; _logger.info("Authentication failed"); } diff --git a/auth/lib/ui/two_factor_authentication_page.dart b/auth/lib/ui/two_factor_authentication_page.dart index 068f4255d0..86dfa503e9 100644 --- a/auth/lib/ui/two_factor_authentication_page.dart +++ b/auth/lib/ui/two_factor_authentication_page.dart @@ -4,7 +4,7 @@ import 'package:ente_auth/services/user_service.dart'; import 'package:ente_auth/ui/lifecycle_event_handler.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:pinput/pin_put/pin_put.dart'; +import 'package:pinput/pinput.dart'; class TwoFactorAuthenticationPage extends StatefulWidget { final String sessionID; @@ -19,9 +19,13 @@ class TwoFactorAuthenticationPage extends StatefulWidget { class _TwoFactorAuthenticationPageState extends State { final _pinController = TextEditingController(); - final _pinPutDecoration = BoxDecoration( - border: Border.all(color: const Color.fromRGBO(45, 194, 98, 1.0)), - borderRadius: BorderRadius.circular(15.0), + final _pinPutDecoration = PinTheme( + height: 45, + width: 45, + decoration: BoxDecoration( + border: Border.all(color: const Color.fromRGBO(45, 194, 98, 1.0)), + borderRadius: BorderRadius.circular(15.0), + ), ); String _code = ""; late LifecycleEventHandler _lifecycleEventHandler; @@ -79,9 +83,9 @@ class _TwoFactorAuthenticationPageState const Padding(padding: EdgeInsets.all(32)), Padding( padding: const EdgeInsets.fromLTRB(40, 0, 40, 0), - child: PinPut( - fieldsCount: 6, - onSubmit: (String code) { + child: Pinput( + length: 6, + onCompleted: (String code) { _verifyTwoFactorCode(code); }, onChanged: (String pin) { @@ -90,20 +94,22 @@ class _TwoFactorAuthenticationPageState }); }, controller: _pinController, - submittedFieldDecoration: _pinPutDecoration.copyWith( - borderRadius: BorderRadius.circular(20.0), - ), - selectedFieldDecoration: _pinPutDecoration, - followingFieldDecoration: _pinPutDecoration.copyWith( - borderRadius: BorderRadius.circular(5.0), - border: Border.all( - color: const Color.fromRGBO(45, 194, 98, 0.5), + submittedPinTheme: _pinPutDecoration.copyWith( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20.0), + border: Border.all( + color: const Color.fromRGBO(45, 194, 98, 0.5), + ), ), ), - inputDecoration: const InputDecoration( - focusedBorder: InputBorder.none, - border: InputBorder.none, - counterText: '', + defaultPinTheme: _pinPutDecoration, + followingPinTheme: _pinPutDecoration.copyWith( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10.0), + border: Border.all( + color: const Color.fromRGBO(45, 194, 98, 0.5), + ), + ), ), autofocus: true, ), diff --git a/auth/lib/utils/auth_util.dart b/auth/lib/utils/auth_util.dart index c2d2f5afa0..df11211c92 100644 --- a/auth/lib/utils/auth_util.dart +++ b/auth/lib/utils/auth_util.dart @@ -1,6 +1,8 @@ import 'dart:io'; import 'package:ente_auth/l10n/l10n.dart'; +import 'package:ente_auth/services/local_authentication_service.dart'; +import 'package:ente_auth/utils/lock_screen_settings.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter_local_authentication/flutter_local_authentication.dart'; import 'package:local_auth/local_auth.dart'; @@ -8,8 +10,26 @@ import 'package:local_auth_android/local_auth_android.dart'; import 'package:local_auth_darwin/types/auth_messages_ios.dart'; import 'package:logging/logging.dart'; -Future requestAuthentication(BuildContext context, String reason) async { +Future requestAuthentication( + BuildContext context, + String reason, { + bool isOpeningApp = false, + bool isAuthenticatingForInAppChange = false, +}) async { Logger("AuthUtil").info("Requesting authentication"); + + final String? savedPin = await LockScreenSettings.instance.getPin(); + final String? savedPassword = await LockScreenSettings.instance.getPassword(); + if (savedPassword != null || savedPin != null) { + return await LocalAuthenticationService.instance + .requestEnteAuthForLockScreen( + context, + savedPin, + savedPassword, + isAuthenticatingOnAppLaunch: isOpeningApp, + isAuthenticatingForInAppChange: isAuthenticatingForInAppChange, + ); + } if (Platform.isMacOS || Platform.isLinux) { return await FlutterLocalAuthentication().authenticate(); } else { diff --git a/auth/lib/utils/lock_screen_settings.dart b/auth/lib/utils/lock_screen_settings.dart new file mode 100644 index 0000000000..b857bcc9f5 --- /dev/null +++ b/auth/lib/utils/lock_screen_settings.dart @@ -0,0 +1,155 @@ +import "dart:convert"; +import "dart:typed_data"; + +import "package:ente_crypto_dart/ente_crypto_dart.dart"; +import "package:flutter_secure_storage/flutter_secure_storage.dart"; +import "package:privacy_screen/privacy_screen.dart"; +import "package:shared_preferences/shared_preferences.dart"; + +class LockScreenSettings { + LockScreenSettings._privateConstructor(); + + static final LockScreenSettings instance = + LockScreenSettings._privateConstructor(); + static const password = "ls_password"; + static const pin = "ls_pin"; + static const saltKey = "ls_salt"; + static const keyInvalidAttempts = "ls_invalid_attempts"; + static const lastInvalidAttemptTime = "ls_last_invalid_attempt_time"; + static const autoLockTime = "ls_auto_lock_time"; + static const keyHideAppContent = "ls_hide_app_content"; + final List autoLockDurations = const [ + Duration(seconds: 0), + Duration(seconds: 5), + Duration(seconds: 15), + Duration(minutes: 1), + Duration(minutes: 5), + Duration(minutes: 30), + ]; + + late SharedPreferences _preferences; + late FlutterSecureStorage _secureStorage; + + Future init() async { + _secureStorage = const FlutterSecureStorage(); + _preferences = await SharedPreferences.getInstance(); + + ///Workaround for privacyScreen not working when app is killed and opened. + await setHideAppContent(getShouldHideAppContent()); + } + + Future setHideAppContent(bool hideContent) async { + !hideContent + ? PrivacyScreen.instance.disable() + : await PrivacyScreen.instance.enable( + iosOptions: const PrivacyIosOptions( + enablePrivacy: true, + ), + androidOptions: const PrivacyAndroidOptions( + enableSecure: true, + ), + blurEffect: PrivacyBlurEffect.extraLight, + ); + await _preferences.setBool(keyHideAppContent, hideContent); + } + + bool getShouldHideAppContent() { + return _preferences.getBool(keyHideAppContent) ?? true; + } + + Future setAutoLockTime(Duration duration) async { + await _preferences.setInt(autoLockTime, duration.inMilliseconds); + } + + int getAutoLockTime() { + return _preferences.getInt(autoLockTime) ?? 5000; + } + + Future setLastInvalidAttemptTime(int time) async { + await _preferences.setInt(lastInvalidAttemptTime, time); + } + + int getlastInvalidAttemptTime() { + return _preferences.getInt(lastInvalidAttemptTime) ?? 0; + } + + int getInvalidAttemptCount() { + return _preferences.getInt(keyInvalidAttempts) ?? 0; + } + + Future setInvalidAttemptCount(int count) async { + await _preferences.setInt(keyInvalidAttempts, count); + } + + static Uint8List _generateSalt() { + return sodium.randombytes.buf(sodium.crypto.pwhash.saltBytes); + } + + Future setPin(String userPin) async { + await _secureStorage.delete(key: saltKey); + final salt = _generateSalt(); + + final hash = cryptoPwHash( + utf8.encode(userPin), + salt, + sodium.crypto.pwhash.memLimitInteractive, + sodium.crypto.pwhash.opsLimitSensitive, + sodium, + ); + final String saltPin = base64Encode(salt); + final String hashedPin = base64Encode(hash); + + await _secureStorage.write(key: saltKey, value: saltPin); + await _secureStorage.write(key: pin, value: hashedPin); + await _secureStorage.delete(key: password); + + return; + } + + Future getSalt() async { + final String? salt = await _secureStorage.read(key: saltKey); + if (salt == null) return null; + return base64Decode(salt); + } + + Future getPin() async { + return _secureStorage.read(key: pin); + } + + Future setPassword(String pass) async { + await _secureStorage.delete(key: saltKey); + final salt = _generateSalt(); + + final hash = cryptoPwHash( + utf8.encode(pass), + salt, + sodium.crypto.pwhash.memLimitInteractive, + sodium.crypto.pwhash.opsLimitSensitive, + sodium, + ); + + await _secureStorage.write(key: saltKey, value: base64Encode(salt)); + await _secureStorage.write(key: password, value: base64Encode(hash)); + await _secureStorage.delete(key: pin); + + return; + } + + Future getPassword() async { + return _secureStorage.read(key: password); + } + + Future removePinAndPassword() async { + await _secureStorage.delete(key: saltKey); + await _secureStorage.delete(key: pin); + await _secureStorage.delete(key: password); + } + + Future isPinSet() async { + return await _secureStorage.containsKey(key: pin); + } + + Future isPasswordSet() async { + return await _secureStorage.containsKey(key: password); + } +} diff --git a/auth/pubspec.lock b/auth/pubspec.lock index 99f3295f5f..12908fc253 100644 --- a/auth/pubspec.lock +++ b/auth/pubspec.lock @@ -440,6 +440,14 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_animate: + dependency: "direct main" + description: + name: flutter_animate + sha256: "7c8a6594a9252dad30cc2ef16e33270b6248c4dedc3b3d06c86c4f3f4dc05ae5" + url: "https://pub.dev" + source: hosted + version: "4.5.0" flutter_bloc: dependency: "direct main" description: @@ -639,6 +647,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.2" + flutter_shaders: + dependency: transitive + description: + name: flutter_shaders + sha256: "02750b545c01ff4d8e9bbe8f27a7731aa3778402506c67daa1de7f5fc3f4befe" + url: "https://pub.dev" + source: hosted + version: "0.1.2" flutter_slidable: dependency: "direct main" description: @@ -1133,10 +1149,10 @@ packages: dependency: "direct main" description: name: pinput - sha256: "27eb69042f75755bdb6544f6e79a50a6ed09d6e97e2d75c8421744df1e392949" + sha256: "7bf9aa7d0eeb3da9f7d49d2087c7bc7d36cd277d2e94cc31c6da52e1ebb048d0" url: "https://pub.dev" source: hosted - version: "1.2.2" + version: "5.0.0" platform: dependency: transitive description: @@ -1575,6 +1591,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.2" + universal_platform: + dependency: transitive + description: + name: universal_platform + sha256: "64e16458a0ea9b99260ceb5467a214c1f298d647c659af1bff6d3bf82536b1ec" + url: "https://pub.dev" + source: hosted + version: "1.1.0" url_launcher: dependency: "direct main" description: diff --git a/auth/pubspec.yaml b/auth/pubspec.yaml index 1ef7e0c2ef..9638b840b2 100644 --- a/auth/pubspec.yaml +++ b/auth/pubspec.yaml @@ -1,6 +1,6 @@ name: ente_auth description: ente two-factor authenticator -version: 3.0.18+318 +version: 3.1.0+320 publish_to: none environment: @@ -41,6 +41,7 @@ dependencies: fk_user_agent: ^2.1.0 flutter: sdk: flutter + flutter_animate: ^4.1.0 flutter_bloc: ^8.0.1 flutter_context_menu: ^0.1.3 flutter_displaymode: ^0.6.0 @@ -77,7 +78,7 @@ dependencies: password_strength: ^0.2.0 path: ^1.8.3 path_provider: ^2.0.11 - pinput: ^1.2.2 + pinput: ^5.0.0 pointycastle: ^3.7.3 privacy_screen: ^0.0.6 protobuf: ^3.0.0 diff --git a/cli/pkg/secrets/secret.go b/cli/pkg/secrets/secret.go index 1ebd13a1d6..82df5c7ea1 100644 --- a/cli/pkg/secrets/secret.go +++ b/cli/pkg/secrets/secret.go @@ -26,10 +26,15 @@ const ( func GetOrCreateClISecret() []byte { // get password secret, err := keyring.Get(secretService, secretUser) + if err != nil { if !errors.Is(err, keyring.ErrNotFound) { + + if secretsFile := os.Getenv("ENTE_CLI_SECRETS_PATH"); secretsFile != "" { + return GetSecretFromSecretText(secretsFile) + } if IsRunningInContainer() { - return GetSecretFromSecretText() + return GetSecretFromSecretText(fmt.Sprintf("%s.secret.txt", constants.CliDataPath)) } else { log.Fatal(fmt.Errorf("error getting password from keyring: %w", err)) } @@ -51,9 +56,7 @@ func GetOrCreateClISecret() []byte { // GetSecretFromSecretText reads the scecret from the secret text file. // If the file does not exist, it will be created and write random 32 byte secret to it. -func GetSecretFromSecretText() []byte { - // Define the path to the secret text file - secretFilePath := fmt.Sprintf("%s.secret.txt", constants.CliDataPath) +func GetSecretFromSecretText(secretFilePath string) []byte { // Check if file exists _, err := os.Stat(secretFilePath) diff --git a/desktop/package.json b/desktop/package.json index 453bb931fa..c3da2c3591 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -28,6 +28,7 @@ "auto-launch": "^5.0", "chokidar": "^3.6", "clip-bpe-js": "^0.0.6", + "comlink": "^4.4.1", "compare-versions": "^6.1", "electron-log": "^5.1", "electron-store": "^8.2", diff --git a/desktop/src/main.ts b/desktop/src/main.ts index de969e3cf7..4ebe565bca 100644 --- a/desktop/src/main.ts +++ b/desktop/src/main.ts @@ -21,6 +21,7 @@ import { attachFSWatchIPCHandlers, attachIPCHandlers, attachLogoutIPCHandler, + attachMainWindowIPCHandlers, } from "./main/ipc"; import log, { initLogging } from "./main/log"; import { createApplicationMenu, createTrayContextMenu } from "./main/menu"; @@ -121,6 +122,7 @@ const main = () => { // Setup IPC and streams. const watcher = createWatcher(mainWindow); attachIPCHandlers(); + attachMainWindowIPCHandlers(mainWindow); attachFSWatchIPCHandlers(watcher); attachLogoutIPCHandler(watcher); registerStreamProtocol(); diff --git a/desktop/src/main/ipc.ts b/desktop/src/main/ipc.ts index 641ce9963d..6c4020d6ee 100644 --- a/desktop/src/main/ipc.ts +++ b/desktop/src/main/ipc.ts @@ -9,6 +9,7 @@ */ import type { FSWatcher } from "chokidar"; +import type { BrowserWindow } from "electron"; import { ipcMain } from "electron/main"; import type { CollectionMapping, @@ -42,11 +43,7 @@ import { } from "./services/fs"; import { convertToJPEG, generateImageThumbnail } from "./services/image"; import { logout } from "./services/logout"; -import { - computeCLIPImageEmbedding, - computeCLIPTextEmbeddingIfAvailable, -} from "./services/ml-clip"; -import { computeFaceEmbeddings, detectFaces } from "./services/ml-face"; +import { createMLWorker } from "./services/ml"; import { encryptionKey, lastShownChangelogVersion, @@ -184,24 +181,6 @@ export const attachIPCHandlers = () => { ) => ffmpegExec(command, dataOrPathOrZipItem, outputFileExtension), ); - // - ML - - ipcMain.handle("computeCLIPImageEmbedding", (_, input: Float32Array) => - computeCLIPImageEmbedding(input), - ); - - ipcMain.handle("computeCLIPTextEmbeddingIfAvailable", (_, text: string) => - computeCLIPTextEmbeddingIfAvailable(text), - ); - - ipcMain.handle("detectFaces", (_, input: Float32Array) => - detectFaces(input), - ); - - ipcMain.handle("computeFaceEmbeddings", (_, input: Float32Array) => - computeFaceEmbeddings(input), - ); - // - Upload ipcMain.handle("listZipItems", (_, zipPath: string) => @@ -231,6 +210,16 @@ export const attachIPCHandlers = () => { ipcMain.handle("clearPendingUploads", () => clearPendingUploads()); }; +/** + * A subset of {@link attachIPCHandlers} for functions that need a reference to + * the main window to do their thing. + */ +export const attachMainWindowIPCHandlers = (mainWindow: BrowserWindow) => { + // - ML + + ipcMain.on("createMLWorker", () => createMLWorker(mainWindow)); +}; + /** * Sibling of {@link attachIPCHandlers} that attaches handlers specific to the * watch folder functionality. diff --git a/desktop/src/main/services/ml-clip.ts b/desktop/src/main/services/ml-clip.ts deleted file mode 100644 index cea1d667b5..0000000000 --- a/desktop/src/main/services/ml-clip.ts +++ /dev/null @@ -1,68 +0,0 @@ -/** - * @file Compute CLIP embeddings for images and text. - * - * The embeddings are computed using ONNX runtime, with CLIP as the model. - */ - -import Tokenizer from "clip-bpe-js"; -import * as ort from "onnxruntime-node"; -import log from "../log"; -import { ensure, wait } from "../utils/common"; -import { makeCachedInferenceSession } from "./ml"; - -const cachedCLIPImageSession = makeCachedInferenceSession( - "clip-image-vit-32-float32.onnx", - 351468764 /* 335.2 MB */, -); - -export const computeCLIPImageEmbedding = async (input: Float32Array) => { - const session = await cachedCLIPImageSession(); - const t = Date.now(); - const feeds = { - input: new ort.Tensor("float32", input, [1, 3, 224, 224]), - }; - const results = await session.run(feeds); - log.debug(() => `ONNX/CLIP image embedding took ${Date.now() - t} ms`); - /* Need these model specific casts to type the result */ - return ensure(results.output).data as Float32Array; -}; - -const cachedCLIPTextSession = makeCachedInferenceSession( - "clip-text-vit-32-uint8.onnx", - 64173509 /* 61.2 MB */, -); - -let _tokenizer: Tokenizer | undefined; -const getTokenizer = () => { - if (!_tokenizer) _tokenizer = new Tokenizer(); - return _tokenizer; -}; - -export const computeCLIPTextEmbeddingIfAvailable = async (text: string) => { - const sessionOrSkip = await Promise.race([ - cachedCLIPTextSession(), - // Wait for a tick to get the session promise to resolved the first time - // this code runs on each app start (and the model has been downloaded). - wait(0).then(() => 1), - ]); - - // Don't wait for the download to complete. - if (typeof sessionOrSkip == "number") { - log.info( - "Ignoring CLIP text embedding request because model download is pending", - ); - return undefined; - } - - const session = sessionOrSkip; - const t = Date.now(); - const tokenizer = getTokenizer(); - const tokenizedText = Int32Array.from(tokenizer.encodeForCLIP(text)); - const feeds = { - input: new ort.Tensor("int32", tokenizedText, [1, 77]), - }; - - const results = await session.run(feeds); - log.debug(() => `ONNX/CLIP text embedding took ${Date.now() - t} ms`); - return ensure(results.output).data as Float32Array; -}; diff --git a/desktop/src/main/services/ml-face.ts b/desktop/src/main/services/ml-face.ts deleted file mode 100644 index 33c09efaa2..0000000000 --- a/desktop/src/main/services/ml-face.ts +++ /dev/null @@ -1,53 +0,0 @@ -/** - * @file Various face recognition related tasks. - * - * - Face detection with the YOLO model. - * - Face embedding with the MobileFaceNet model. - * - * The runtime used is ONNX. - */ - -import * as ort from "onnxruntime-node"; -import log from "../log"; -import { ensure } from "../utils/common"; -import { makeCachedInferenceSession } from "./ml"; - -const cachedFaceDetectionSession = makeCachedInferenceSession( - "yolov5s_face_640_640_dynamic.onnx", - 30762872 /* 29.3 MB */, -); - -export const detectFaces = async (input: Float32Array) => { - const session = await cachedFaceDetectionSession(); - const t = Date.now(); - const feeds = { - input: new ort.Tensor("float32", input, [1, 3, 640, 640]), - }; - const results = await session.run(feeds); - log.debug(() => `ONNX/YOLO face detection took ${Date.now() - t} ms`); - return ensure(results.output).data; -}; - -const cachedFaceEmbeddingSession = makeCachedInferenceSession( - "mobilefacenet_opset15.onnx", - 5286998 /* 5 MB */, -); - -export const computeFaceEmbeddings = async (input: Float32Array) => { - // Dimension of each face (alias) - const mobileFaceNetFaceSize = 112; - // Smaller alias - const z = mobileFaceNetFaceSize; - // Size of each face's data in the batch - const n = Math.round(input.length / (z * z * 3)); - const inputTensor = new ort.Tensor("float32", input, [n, z, z, 3]); - - const session = await cachedFaceEmbeddingSession(); - const t = Date.now(); - const feeds = { img_inputs: inputTensor }; - const results = await session.run(feeds); - log.debug(() => `ONNX/MFNT face embedding took ${Date.now() - t} ms`); - /* Need these model specific casts to extract and type the result */ - return (results.embeddings as unknown as Record) - .cpuData as Float32Array; -}; diff --git a/desktop/src/main/services/ml-worker.ts b/desktop/src/main/services/ml-worker.ts new file mode 100644 index 0000000000..f4b9221f64 --- /dev/null +++ b/desktop/src/main/services/ml-worker.ts @@ -0,0 +1,315 @@ +/** + * @file ML related tasks. This code runs in a utility process. + * + * The ML runtime we use for inference is [ONNX](https://onnxruntime.ai). Models + * for various tasks are not shipped with the app but are downloaded on demand. + */ + +// See [Note: Using Electron APIs in UtilityProcess] about what we can and +// cannot import. + +import Tokenizer from "clip-bpe-js"; +import { expose } from "comlink"; +import { net } from "electron/main"; +import { existsSync } from "fs"; +import fs from "node:fs/promises"; +import path from "node:path"; +import * as ort from "onnxruntime-node"; +import { messagePortMainEndpoint } from "../utils/comlink"; +import { ensure, wait } from "../utils/common"; +import { writeStream } from "../utils/stream"; + +/** + * We cannot do + * + * import log from "../log"; + * + * because that requires the Electron APIs that are not available to a utility + * process (See: [Note: Using Electron APIs in UtilityProcess]). But even if + * that were to work, logging will still be problematic since we'd try opening + * the log file from two different Node.js processes (this one, and the main + * one), and I didn't find any indication in the electron-log repository that + * the log file's integrity would be maintained in such cases. + * + * So instead we create this proxy log object that uses `process.parentPort` to + * transport the logs over to the main process. + */ +const log = { + /** + * Unlike the real {@link log.error}, this accepts only the first string + * argument, not the second optional error one. + */ + errorString: (s: string) => mainProcess("log.errorString", s), + info: (...ms: unknown[]) => mainProcess("log.info", ms), + /** + * Unlike the real {@link log.debug}, this is (a) eagerly evaluated, and (b) + * accepts only strings. + */ + debugString: (s: string) => mainProcess("log.debugString", s), +}; + +/** + * Send a message to the main process using a barebones RPC protocol. + */ +const mainProcess = (method: string, param: unknown) => + process.parentPort.postMessage({ method, p: param }); + +log.debugString(`Started ML worker process`); + +process.parentPort.once("message", (e) => { + // Initialize ourselves with the data we got from our parent. + parseInitData(e.data); + // Expose an instance of `ElectronMLWorker` on the port we got from our + // parent. + expose( + { + computeCLIPImageEmbedding, + computeCLIPTextEmbeddingIfAvailable, + detectFaces, + computeFaceEmbeddings, + }, + messagePortMainEndpoint(ensure(e.ports[0])), + ); +}); + +/** + * We cannot access Electron's {@link app} object within a utility process, so + * we pass the value of `app.getPath("userData")` during initialization, and it + * can be subsequently retrieved from here. + */ +let _userDataPath: string | undefined; + +/** Equivalent to app.getPath("userData") */ +const userDataPath = () => ensure(_userDataPath); + +const parseInitData = (data: unknown) => { + if ( + data && + typeof data == "object" && + "userDataPath" in data && + typeof data.userDataPath == "string" + ) { + _userDataPath = data.userDataPath; + } else { + log.errorString("Unparseable initialization data"); + } +}; + +/** + * Return a function that can be used to trigger a download of the specified + * model, and the creating of an ONNX inference session initialized using it. + * + * Multiple parallel calls to the returned function are fine, it ensures that + * the the model will be downloaded and the session created using it only once. + * All pending calls to it meanwhile will just await on the same promise. + * + * And once the promise is resolved, the create ONNX inference session will be + * cached, so subsequent calls to the returned function will just reuse the same + * session. + * + * {@link makeCachedInferenceSession} can itself be called anytime, it doesn't + * actively trigger a download until the returned function is called. + * + * @param modelName The name of the model to download. + * + * @param modelByteSize The size in bytes that we expect the model to have. If + * the size of the downloaded model does not match the expected size, then we + * will redownload it. + * + * @returns a function. calling that function returns a promise to an ONNX + * session. + */ +const makeCachedInferenceSession = ( + modelName: string, + modelByteSize: number, +) => { + let session: Promise | undefined; + + const download = () => + modelPathDownloadingIfNeeded(modelName, modelByteSize); + + const createSession = (modelPath: string) => + createInferenceSession(modelPath); + + const cachedInferenceSession = () => { + if (!session) session = download().then(createSession); + return session; + }; + + return cachedInferenceSession; +}; + +/** + * Download the model named {@link modelName} if we don't already have it. + * + * Also verify that the size of the model we get matches {@expectedByteSize} (if + * not, redownload it). + * + * @returns the path to the model on the local machine. + */ +const modelPathDownloadingIfNeeded = async ( + modelName: string, + expectedByteSize: number, +) => { + const modelPath = modelSavePath(modelName); + + if (!existsSync(modelPath)) { + log.info("CLIP image model not found, downloading"); + await downloadModel(modelPath, modelName); + } else { + const size = (await fs.stat(modelPath)).size; + if (size !== expectedByteSize) { + log.errorString( + `The size ${size} of model ${modelName} does not match the expected size, downloading again`, + ); + await downloadModel(modelPath, modelName); + } + } + + return modelPath; +}; + +/** Return the path where the given {@link modelName} is meant to be saved */ +const modelSavePath = (modelName: string) => + path.join(userDataPath(), "models", modelName); + +const downloadModel = async (saveLocation: string, name: string) => { + // `mkdir -p` the directory where we want to save the model. + const saveDir = path.dirname(saveLocation); + await fs.mkdir(saveDir, { recursive: true }); + // Download. + log.info(`Downloading ML model from ${name}`); + const url = `https://models.ente.io/${name}`; + const res = await net.fetch(url); + if (!res.ok) throw new Error(`Failed to fetch ${url}: HTTP ${res.status}`); + const body = res.body; + if (!body) throw new Error(`Received an null response for ${url}`); + // Save. + await writeStream(saveLocation, body); + log.info(`Downloaded CLIP model ${name}`); +}; + +/** + * Create an ONNX {@link InferenceSession} with some defaults. + */ +const createInferenceSession = async (modelPath: string) => { + return await ort.InferenceSession.create(modelPath, { + // Restrict the number of threads to 1. + intraOpNumThreads: 1, + // Be more conservative with RAM usage. + enableCpuMemArena: false, + }); +}; + +const cachedCLIPImageSession = makeCachedInferenceSession( + "clip-image-vit-32-float32.onnx", + 351468764 /* 335.2 MB */, +); + +/** + * Compute CLIP embeddings for an image. + * + * The embeddings are computed using ONNX runtime, with CLIP as the model. + */ +export const computeCLIPImageEmbedding = async (input: Float32Array) => { + const session = await cachedCLIPImageSession(); + const feeds = { + input: new ort.Tensor("float32", input, [1, 3, 224, 224]), + }; + const t = Date.now(); + const results = await session.run(feeds); + log.debugString(`ONNX/CLIP image embedding took ${Date.now() - t} ms`); + /* Need these model specific casts to type the result */ + return ensure(results.output).data as Float32Array; +}; + +const cachedCLIPTextSession = makeCachedInferenceSession( + "clip-text-vit-32-uint8.onnx", + 64173509 /* 61.2 MB */, +); + +let _tokenizer: Tokenizer | undefined; +const getTokenizer = () => { + if (!_tokenizer) _tokenizer = new Tokenizer(); + return _tokenizer; +}; + +/** + * Compute CLIP embeddings for an text snippet. + * + * The embeddings are computed using ONNX runtime, with CLIP as the model. + */ +export const computeCLIPTextEmbeddingIfAvailable = async (text: string) => { + const sessionOrSkip = await Promise.race([ + cachedCLIPTextSession(), + // Wait for a tick to get the session promise to resolved the first time + // this code runs on each app start (and the model has been downloaded). + wait(0).then(() => 1), + ]); + + // Don't wait for the download to complete. + if (typeof sessionOrSkip == "number") { + log.info( + "Ignoring CLIP text embedding request because model download is pending", + ); + return undefined; + } + + const session = sessionOrSkip; + const tokenizer = getTokenizer(); + const tokenizedText = Int32Array.from(tokenizer.encodeForCLIP(text)); + const feeds = { + input: new ort.Tensor("int32", tokenizedText, [1, 77]), + }; + + const t = Date.now(); + const results = await session.run(feeds); + log.debugString(`ONNX/CLIP text embedding took ${Date.now() - t} ms`); + return ensure(results.output).data as Float32Array; +}; + +const cachedFaceDetectionSession = makeCachedInferenceSession( + "yolov5s_face_640_640_dynamic.onnx", + 30762872 /* 29.3 MB */, +); + +/** + * Face detection with the YOLO model and ONNX runtime. + */ +export const detectFaces = async (input: Float32Array) => { + const session = await cachedFaceDetectionSession(); + const feeds = { + input: new ort.Tensor("float32", input, [1, 3, 640, 640]), + }; + const t = Date.now(); + const results = await session.run(feeds); + log.debugString(`ONNX/YOLO face detection took ${Date.now() - t} ms`); + return ensure(results.output).data; +}; + +const cachedFaceEmbeddingSession = makeCachedInferenceSession( + "mobilefacenet_opset15.onnx", + 5286998 /* 5 MB */, +); + +/** + * Face embedding with the MobileFaceNet model and ONNX runtime. + */ +export const computeFaceEmbeddings = async (input: Float32Array) => { + // Dimension of each face (alias) + const mobileFaceNetFaceSize = 112; + // Smaller alias + const z = mobileFaceNetFaceSize; + // Size of each face's data in the batch + const n = Math.round(input.length / (z * z * 3)); + const inputTensor = new ort.Tensor("float32", input, [n, z, z, 3]); + + const session = await cachedFaceEmbeddingSession(); + const feeds = { img_inputs: inputTensor }; + const t = Date.now(); + const results = await session.run(feeds); + log.debugString(`ONNX/MFNT face embedding took ${Date.now() - t} ms`); + /* Need these model specific casts to extract and type the result */ + return (results.embeddings as unknown as Record) + .cpuData as Float32Array; +}; diff --git a/desktop/src/main/services/ml.ts b/desktop/src/main/services/ml.ts index 55bb8d79c2..cc1ae5764c 100644 --- a/desktop/src/main/services/ml.ts +++ b/desktop/src/main/services/ml.ts @@ -1,126 +1,147 @@ /** - * @file ML related functionality, generic layer. - * - * @see also `ml-clip.ts`, `ml-face.ts`. - * - * The ML runtime we use for inference is [ONNX](https://onnxruntime.ai). Models - * for various tasks are not shipped with the app but are downloaded on demand. - * - * The primary reason for doing these tasks in the Node.js layer is so that we - * can use the binary ONNX runtime which is 10-20x faster than the WASM based - * web one. + * @file ML related functionality. This code runs in the main process. */ -import { app, net } from "electron/main"; -import { existsSync } from "fs"; -import fs from "node:fs/promises"; +import { + MessageChannelMain, + type BrowserWindow, + type UtilityProcess, +} from "electron"; +import { app, utilityProcess } from "electron/main"; import path from "node:path"; -import * as ort from "onnxruntime-node"; import log from "../log"; -import { writeStream } from "../stream"; + +/** The active ML worker (utility) process, if any. */ +let _child: UtilityProcess | undefined; /** - * Return a function that can be used to trigger a download of the specified - * model, and the creating of an ONNX inference session initialized using it. + * Create a new ML worker process, terminating the older ones (if any). + * + * [Note: ML IPC] + * + * The primary reason for doing ML tasks in the Node.js layer is so that we can + * use the binary ONNX runtime, which is 10-20x faster than the WASM one that + * can be used directly on the web layer. + * + * For this to work, the main and renderer process need to communicate with each + * other. Further, in the web layer the ML indexing runs in a web worker (so as + * to not get in the way of the main thread). So the communication has 2 hops: + * + * Node.js main <-> Renderer main <-> Renderer web worker * - * 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. + * 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. * - * 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 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 * - * {@link makeCachedInferenceSession} can itself be called anytime, it doesn't - * actively trigger a download until the returned function is called. + * It would seem that this introduces another hop in our IPC * - * @param modelName The name of the model to download. + * Node.js utility process <-> Node.js main <-> ... * - * @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. + * 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 * - * @returns a function. calling that function returns a promise to an ONNX - * session. + * As a brief summary, a MessagePort is a web feature that allows two contexts + * to communicate. A pair of message ports is called a message channel. The cool + * thing about these is that we can pass these ports themselves over IPC. + * + * > One caveat here is that the message ports can only be passed using the + * > `postMessage` APIs, not the usual send/invoke APIs. + * + * So we + * + * 1. In the utility process create a message channel. + * 2. Spawn a utility process, and send one port of the pair to it. + * 3. Send the other port of the pair to the renderer. + * + * The renderer will forward that port to the web worker that is coordinating + * the ML indexing on the web layer. Thereafter, the utility process and web + * worker can directly talk to each other! + * + * Node.js utility process <-> Renderer web worker + * + * The RPC protocol is handled using comlink on both ends. The port itself needs + * to be relayed using `postMessage`. */ -export const makeCachedInferenceSession = ( - modelName: string, - modelByteSize: number, -) => { - let session: Promise | undefined; +export const createMLWorker = (window: BrowserWindow) => { + if (_child) { + log.debug(() => "Terminating previous ML worker process"); + _child.kill(); + _child = undefined; + } + + const { port1, port2 } = new MessageChannelMain(); - const download = () => - modelPathDownloadingIfNeeded(modelName, modelByteSize); + const child = utilityProcess.fork(path.join(__dirname, "ml-worker.js")); + const userDataPath = app.getPath("userData"); + child.postMessage({ userDataPath }, [port1]); - const createSession = (modelPath: string) => - createInferenceSession(modelPath); + window.webContents.postMessage("createMLWorker/port", undefined, [port2]); - const cachedInferenceSession = () => { - if (!session) session = download().then(createSession); - return session; - }; + handleMessagesFromUtilityProcess(child); - return cachedInferenceSession; + _child = child; }; /** - * Download the model named {@link modelName} if we don't already have it. + * Handle messages posted from the utility process. * - * Also verify that the size of the model we get matches {@expectedByteSize} (if - * not, redownload it). + * [Note: Using Electron APIs in UtilityProcess] * - * @returns the path to the model on the local machine. + * 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 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); +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; + } } - } - - return modelPath; -}; - -/** Return the path where the given {@link modelName} is meant to be saved */ -const modelSavePath = (modelName: string) => - path.join(app.getPath("userData"), "models", modelName); - -const downloadModel = async (saveLocation: string, name: string) => { - // `mkdir -p` the directory where we want to save the model. - const saveDir = path.dirname(saveLocation); - await fs.mkdir(saveDir, { recursive: true }); - // Download. - log.info(`Downloading ML model from ${name}`); - const url = `https://models.ente.io/${name}`; - const res = await net.fetch(url); - if (!res.ok) throw new Error(`Failed to fetch ${url}: HTTP ${res.status}`); - const body = res.body; - if (!body) throw new Error(`Received an null response for ${url}`); - // Save. - await writeStream(saveLocation, body); - log.info(`Downloaded CLIP model ${name}`); -}; - -/** - * Crete an ONNX {@link InferenceSession} with some defaults. - */ -const createInferenceSession = async (modelPath: string) => { - return await ort.InferenceSession.create(modelPath, { - // Restrict the number of threads to 1. - intraOpNumThreads: 1, - // Be more conservative with RAM usage. - enableCpuMemArena: false, + log.info("Ignoring unknown message from ML worker", m); }); }; diff --git a/desktop/src/main/stream.ts b/desktop/src/main/stream.ts index 749c94f491..d32eecc627 100644 --- a/desktop/src/main/stream.ts +++ b/desktop/src/main/stream.ts @@ -3,7 +3,6 @@ */ import { net, protocol } from "electron/main"; import { randomUUID } from "node:crypto"; -import { createWriteStream, existsSync } from "node:fs"; import fs from "node:fs/promises"; import { Readable } from "node:stream"; import { ReadableStream } from "node:stream/web"; @@ -12,6 +11,7 @@ import log from "./log"; import { ffmpegConvertToMP4 } from "./services/ffmpeg"; import { markClosableZip, openZip } from "./services/zip"; import { ensure } from "./utils/common"; +import { writeStream } from "./utils/stream"; import { deleteTempFile, deleteTempFileIgnoringErrors, @@ -142,6 +142,7 @@ const handleReadZip = async (zipPath: string, entryName: string) => { // https://github.com/antelle/node-stream-zip/blob/master/node_stream_zip.js const modifiedMs = entry.time; + // @ts-expect-error [Note: Node and web stream type mismatch] return new Response(webReadableStream, { headers: { // We don't know the exact type, but it doesn't really matter, just @@ -159,39 +160,6 @@ const handleWrite = async (path: string, request: Request) => { return new Response("", { status: 200 }); }; -/** - * Write a (web) ReadableStream to a file at the given {@link filePath}. - * - * The returned promise resolves when the write completes. - * - * @param filePath The local file system path where the file should be written. - * - * @param readableStream A web - * [ReadableStream](https://developer.mozilla.org/en-US/docs/Web/API/ReadableStream). - */ -export const writeStream = (filePath: string, readableStream: ReadableStream) => - writeNodeStream(filePath, Readable.fromWeb(readableStream)); - -const writeNodeStream = async (filePath: string, fileStream: Readable) => { - const writeable = createWriteStream(filePath); - - fileStream.on("error", (err) => { - writeable.destroy(err); // Close the writable stream with an error - }); - - fileStream.pipe(writeable); - - await new Promise((resolve, reject) => { - writeable.on("finish", resolve); - writeable.on("error", (err) => { - if (existsSync(filePath)) { - void fs.unlink(filePath); - } - reject(err); - }); - }); -}; - /** * A map from token to file paths for convert-to-mp4 requests that we have * received. diff --git a/desktop/src/main/utils/comlink.ts b/desktop/src/main/utils/comlink.ts new file mode 100644 index 0000000000..d2006e795b --- /dev/null +++ b/desktop/src/main/utils/comlink.ts @@ -0,0 +1,42 @@ +import type { Endpoint } from "comlink"; +import type { MessagePortMain } from "electron"; + +/** + * An adaptation of the `nodeEndpoint` function from comlink suitable for use in + * TypeScript with an Electron utility process. + * + * This is an adaption of the following function from comlink: + * https://github.com/GoogleChromeLabs/comlink/blob/main/src/node-adapter.ts + * + * It has been modified (somewhat hackily) to be useful with an Electron + * MessagePortMain instead of a Node.js worker_thread. Only things that we + * currently need have been made to work as you can see by the abundant type + * casts. Caveat emptor. + */ +export const messagePortMainEndpoint = (mp: MessagePortMain): Endpoint => { + type NL = EventListenerOrEventListenerObject; + type EL = (data: Electron.MessageEvent) => void; + const listeners = new WeakMap(); + return { + postMessage: (message, transfer) => { + mp.postMessage(message, transfer as unknown as MessagePortMain[]); + }, + addEventListener: (_, eh) => { + const l: EL = (data) => + "handleEvent" in eh + ? eh.handleEvent({ data } as MessageEvent) + : eh(data as unknown as MessageEvent); + mp.on("message", (data) => { + l(data); + }); + listeners.set(eh, l); + }, + removeEventListener: (_, eh) => { + const l = listeners.get(eh); + if (!l) return; + mp.off("message", l); + listeners.delete(eh); + }, + start: mp.start.bind(mp), + }; +}; diff --git a/desktop/src/main/utils/stream.ts b/desktop/src/main/utils/stream.ts new file mode 100644 index 0000000000..f5a98de0f7 --- /dev/null +++ b/desktop/src/main/utils/stream.ts @@ -0,0 +1,39 @@ +import { createWriteStream, existsSync } from "node:fs"; +import fs from "node:fs/promises"; +import { Readable } from "node:stream"; + +/** + * Write a (web) ReadableStream to a file at the given {@link filePath}. + * + * The returned promise resolves when the write completes. + * + * @param filePath The local file system path where the file should be written. + * + * @param readableStream A web + * [ReadableStream](https://developer.mozilla.org/en-US/docs/Web/API/ReadableStream). + * + */ +export const writeStream = ( + filePath: string, + readableStream: unknown /*ReadableStream*/, // @ts-expect-error [Note: Node and web stream type mismatch] +) => writeNodeStream(filePath, Readable.fromWeb(readableStream)); + +const writeNodeStream = async (filePath: string, fileStream: Readable) => { + const writeable = createWriteStream(filePath); + + fileStream.on("error", (err) => { + writeable.destroy(err); // Close the writable stream with an error + }); + + fileStream.pipe(writeable); + + await new Promise((resolve, reject) => { + writeable.on("finish", resolve); + writeable.on("error", (err) => { + if (existsSync(filePath)) { + void fs.unlink(filePath); + } + reject(err); + }); + }); +}; diff --git a/desktop/src/preload.ts b/desktop/src/preload.ts index 72f20a2802..8472e91ff0 100644 --- a/desktop/src/preload.ts +++ b/desktop/src/preload.ts @@ -36,10 +36,33 @@ * - [main] desktop/src/main/ipc.ts contains impl */ +// This code runs in the (isolated) web layer. Contrary to the impression given +// by the Electron docs (as of 2024), the window object is actually available to +// the preload script, and it is necessary for legitimate uses too. +// +// > The isolated world is connected to the DOM just the same is the main world, +// > it is just the JS contexts that are separated. +// > +// > https://github.com/electron/electron/issues/27024#issuecomment-745618327 +// +// Adding this reference here tells TypeScript that DOM typings (in particular, +// window) should be introduced in the ambient scope. +// +// [Note: Node and web stream type mismatch] +// +// Unfortunately, adding this reference causes the ReadableStream typings to +// break since lib.dom.d.ts adds its own incompatible definitions of +// ReadableStream to the global scope. +// +// https://github.com/DefinitelyTyped/DefinitelyTyped/discussions/68407 + +/// + import { contextBridge, ipcRenderer, webUtils } from "electron/renderer"; // While we can't import other code, we can import types since they're just // needed when compiling and will not be needed or looked around for at runtime. +import type { IpcRendererEvent } from "electron"; import type { AppUpdate, CollectionMapping, @@ -48,6 +71,19 @@ import type { ZipItem, } from "./types/ipc"; +// - Infrastructure + +// We need to wait until the renderer is ready before sending ports via +// postMessage, and this promise comes handy in such cases. We create the +// promise at the top level so that it is guaranteed to be registered before the +// load event is fired. +// +// See: https://www.electronjs.org/docs/latest/tutorial/message-ports + +const windowLoaded = new Promise((resolve) => { + window.onload = resolve; +}); + // - General const appVersion = () => ipcRenderer.invoke("appVersion"); @@ -163,17 +199,17 @@ const ffmpegExec = ( // - ML -const computeCLIPImageEmbedding = (input: Float32Array) => - ipcRenderer.invoke("computeCLIPImageEmbedding", input); - -const computeCLIPTextEmbeddingIfAvailable = (text: string) => - ipcRenderer.invoke("computeCLIPTextEmbeddingIfAvailable", text); - -const detectFaces = (input: Float32Array) => - ipcRenderer.invoke("detectFaces", input); - -const computeFaceEmbeddings = (input: Float32Array) => - ipcRenderer.invoke("computeFaceEmbeddings", input); +const createMLWorker = () => { + const l = (event: IpcRendererEvent) => { + void windowLoaded.then(() => { + // "*"" is the origin to send to. + window.postMessage("createMLWorker/port", "*", event.ports); + ipcRenderer.off("createMLWorker/port", l); + }); + }; + ipcRenderer.on("createMLWorker/port", l); + ipcRenderer.send("createMLWorker"); +}; // - Watch @@ -227,25 +263,13 @@ const watchRemoveListeners = () => { // - Upload -const pathForFile = (file: File) => { - const path = webUtils.getPathForFile(file); - // The path that we get back from `webUtils.getPathForFile` on Windows uses - // "/" as the path separator. Convert them to POSIX separators. - // - // Note that we do not have access to the path or the os module in the - // preload script, thus this hand rolled transformation. - - // However that makes TypeScript fidgety since we it cannot find navigator, - // as we haven't included "lib": ["dom"] in our tsconfig to avoid making DOM - // APIs available to our main Node.js code. We could create a separate - // tsconfig just for the preload script, but for now let's go with a cast. - // - // @ts-expect-error navigator is not defined. - const platform = (navigator as { platform: string }).platform; - return platform.toLowerCase().includes("win") - ? path.split("\\").join("/") - : path; -}; +// The path that we get back from `webUtils.getPathForFile` on Windows uses "\" +// as the path separator. Convert them to POSIX separators. + +const pathForFile = + process.platform == "win32" + ? (file: File) => webUtils.getPathForFile(file).replace(/\\/g, "/") + : (file: File) => webUtils.getPathForFile(file); const listZipItems = (zipPath: string) => ipcRenderer.invoke("listZipItems", zipPath); @@ -293,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". @@ -351,10 +378,7 @@ contextBridge.exposeInMainWorld("electron", { // - ML - computeCLIPImageEmbedding, - computeCLIPTextEmbeddingIfAvailable, - detectFaces, - computeFaceEmbeddings, + createMLWorker, // - Watch diff --git a/desktop/yarn.lock b/desktop/yarn.lock index 5feaf65f6f..afbe850a91 100644 --- a/desktop/yarn.lock +++ b/desktop/yarn.lock @@ -968,6 +968,11 @@ combined-stream@^1.0.8: dependencies: delayed-stream "~1.0.0" +comlink@^4.4.1: + version "4.4.1" + resolved "https://registry.yarnpkg.com/comlink/-/comlink-4.4.1.tgz#e568b8e86410b809e8600eb2cf40c189371ef981" + integrity sha512-+1dlx0aY5Jo1vHy/tSsIGpSkN4tS9rZSW8FIhG0JH/crs9wwweswIo/POr451r7bZww3hFbPAKnTpimzL/mm4Q== + commander@^5.0.0: version "5.1.0" resolved "https://registry.yarnpkg.com/commander/-/commander-5.1.0.tgz#46abbd1652f8e059bddaef99bbdcb2ad9cf179ae" diff --git a/docs/docs/.vitepress/sidebar.ts b/docs/docs/.vitepress/sidebar.ts index 7b53b40ade..11a2c30119 100644 --- a/docs/docs/.vitepress/sidebar.ts +++ b/docs/docs/.vitepress/sidebar.ts @@ -160,6 +160,10 @@ export const sidebar = [ text: "Enteception", link: "/auth/faq/enteception/", }, + { + text: "Privacy disclosure", + link: "/auth/faq/privacy-disclosure/", + }, ], }, { diff --git a/docs/docs/auth/faq/privacy-disclosure/appstore-privacy-disclosure.png b/docs/docs/auth/faq/privacy-disclosure/appstore-privacy-disclosure.png new file mode 100644 index 0000000000..0676266d80 Binary files /dev/null and b/docs/docs/auth/faq/privacy-disclosure/appstore-privacy-disclosure.png differ diff --git a/docs/docs/auth/faq/privacy-disclosure/index.md b/docs/docs/auth/faq/privacy-disclosure/index.md new file mode 100644 index 0000000000..5b1a990836 --- /dev/null +++ b/docs/docs/auth/faq/privacy-disclosure/index.md @@ -0,0 +1,52 @@ +--- +title: Apple's app privacy disclosure +description: Breakdown of the app privacy disclosure submitted to AppStore +--- + +# Apple's app privacy disclosure + +Here is a breakdown of the types of data that are being collected as per +AppStore's privacy disclosure. + +
+ +![Privacy disclosure submitted to +AppStore](appstore-privacy-disclosure.png){width=620px} + +
+ +## Data Linked to You + +> [!NOTE] +> +> Only if you choose to create an account to backup your codes are the following +> details collected. + +### Contact Info +This is your email address, used for account creation and communication. + +### User Content +This are your 2FA secrets, end-to-end encrypted with a key that only you have +access to. + +### Identifiers +This is your user ID generated by our server during sign up. + +## Data Not Linked to You + +> [!NOTE] +> +> Only if you opt-in to **Crash reporting** are the following details collected. + +### Diagnostics +These are anonymized error reports and other diagnostics data that make it easier +for us to detect and fix any issues. + +--- + +## Summary + +Ente Auth collects no data by default. + +For more details, please refer to our [full privacy +policy](https://ente.io/privacy). diff --git a/docs/docs/photos/faq/subscription.md b/docs/docs/photos/faq/subscription.md index 53cc634710..814249cbf2 100644 --- a/docs/docs/photos/faq/subscription.md +++ b/docs/docs/photos/faq/subscription.md @@ -152,10 +152,7 @@ you can gain more value out of a single subscription. ## Is there a forever-free plan? -Sorry, since we're building a business that does not involve monetization of -user data, we have to charge to remain sustainable. - -We do offer a generous free trial for you to experience the product. +Yes, we offer 5 GB of storage for free. ## Will I need to pay for Ente Auth after my Ente Photos free plan expires? diff --git a/mobile/README.md b/mobile/README.md index bd254b00ba..418338759e 100644 --- a/mobile/README.md +++ b/mobile/README.md @@ -5,7 +5,7 @@ client app. This is where Ente started. This is what had the [first commit](https://github.com/ente-io/ente/commit/a8cdc811fd20ca4289d8e779c97f08ef5d276e37). commit a8cdc811fd20ca4289d8e779c97f08ef5d276e37 - Author: Vishnu Mohandas + Author: Vishnu Mohandas Date: Wed Mar 25 01:29:36 2020 +0530 Hello world diff --git a/mobile/assets/2.0x/active_subscription.png b/mobile/assets/2.0x/active_subscription.png new file mode 100644 index 0000000000..8175b5ea6a Binary files /dev/null and b/mobile/assets/2.0x/active_subscription.png differ diff --git a/mobile/assets/2.0x/popular_subscription.png b/mobile/assets/2.0x/popular_subscription.png new file mode 100644 index 0000000000..c609e539c5 Binary files /dev/null and b/mobile/assets/2.0x/popular_subscription.png differ diff --git a/mobile/assets/3.0x/active_subscription.png b/mobile/assets/3.0x/active_subscription.png new file mode 100644 index 0000000000..7207692c80 Binary files /dev/null and b/mobile/assets/3.0x/active_subscription.png differ diff --git a/mobile/assets/3.0x/popular_subscription.png b/mobile/assets/3.0x/popular_subscription.png new file mode 100644 index 0000000000..8a15496ab5 Binary files /dev/null and b/mobile/assets/3.0x/popular_subscription.png differ diff --git a/mobile/assets/active_subscription.png b/mobile/assets/active_subscription.png new file mode 100644 index 0000000000..2b6c028fac Binary files /dev/null and b/mobile/assets/active_subscription.png differ diff --git a/mobile/assets/popular_subscription.png b/mobile/assets/popular_subscription.png new file mode 100644 index 0000000000..3308b82879 Binary files /dev/null and b/mobile/assets/popular_subscription.png differ diff --git a/mobile/fastlane/metadata/android/pl/full_description.txt b/mobile/fastlane/metadata/android/pl/full_description.txt index f0cf1b3426..5b7d567555 100644 --- a/mobile/fastlane/metadata/android/pl/full_description.txt +++ b/mobile/fastlane/metadata/android/pl/full_description.txt @@ -1,4 +1,4 @@ -Ente to prosta aplikacja do tworzenia kopii zapasowej Twoich zdjęć i filmów. +Ente to prosta aplikacja do tworzenia kopii zapasowych i udostępniania Twoich zdjęć i wideo. Jeśli szukałeś/aś przyjaznej dla prywatności alternatywy do Google Photos to dotarłeś/aś do właściwego miejsca. Dzięki Ente są one przechowywane w pełni zaszyfrowane (e2ee). Oznacza to, że tylko Ty możesz je oglądać. @@ -6,7 +6,7 @@ Mamy otwarto-źródłowe aplikacje na wszystkich platformach, a Twoje zdjęcia b Ente sprawia, że udostępnianie twoich albumów swoim bliskim również jest proste, nawet jeśli nie są w Ente. Możesz udostępniać publicznie widoczne linki, gdzie mogą oglądać twój album i współpracować, dodając do niego zdjęcia, nawet bez konta lub aplikacji. -Twoje zaszyfrowane dane są przechowywane w wielu lokalizacjach, między innymi w schronie przeciwatomowym w Paryżu. Poważnie podchodzimy do kwestii zachowania pamięci i ułatwiamy zadbanie o to, by wspomnienia przetrwały dłużej niż ty sam. +Twoje zaszyfrowane dane są przechowywane w wielu lokalizacjach, między innymi w schronie przeciwatomowym w Paryżu. Poważnie podchodzimy do kwestii zachowania pamięci i ułatwiamy zadbanie o to, by Twoje wspomnienia przetrwały dłużej niż ty sam/a. Jesteśmy tutaj, aby zrobić najbezpieczniejszą aplikację na zdjęcia, dołącz do naszej podróży! diff --git a/mobile/fastlane/metadata/ios/pl/description.txt b/mobile/fastlane/metadata/ios/pl/description.txt index be266c9043..d8ec964e8e 100644 --- a/mobile/fastlane/metadata/ios/pl/description.txt +++ b/mobile/fastlane/metadata/ios/pl/description.txt @@ -1,4 +1,4 @@ -Ente to prosta aplikacja do automatycznego tworzenia kopii zapasowych oraz porządkowania zdjęć i filmów. +Ente to prosta aplikacja do automatycznego tworzenia kopii zapasowych oraz porządkowania Twoich zdjęć i wideo. Jeśli szukałeś/aś przyjaznej dla prywatności alternatywy, aby zachować swoje wspomnienia, to dotarłeś/aś do właściwego miejsca. Dzięki Ente są one przechowywane w pełni zaszyfrowane (e2ee). Oznacza to, że tylko Ty możesz je oglądać. @@ -6,7 +6,7 @@ Mamy aplikacje na wszystkich platformach, a Twoje zdjęcia będą płynnie synch Ente ułatwia również udostępnianie albumów najbliższym. Możesz udostępniać je bezpośrednio innym użytkownikom Ente, w pełni zaszyfrowane; lub za pomocą publicznie dostępnych linków. -Twoje zaszyfrowane dane są przechowywane w wielu lokalizacjach, między innymi w schronie przeciwatomowym w Paryżu. Poważnie podchodzimy do kwestii zachowania pamięci i ułatwiamy zadbanie o to, by wspomnienia przetrwały dłużej niż ty sam. +Twoje zaszyfrowane dane są przechowywane w wielu lokalizacjach, między innymi w schronie przeciwatomowym w Paryżu. Poważnie podchodzimy do kwestii zachowania pamięci i ułatwiamy zadbanie o to, by Twoje wspomnienia przetrwały dłużej niż ty sam/a. Naszym celem jest stworzenie najbezpieczniejszej aplikacji do zdjęć, dołącz do nas! diff --git a/mobile/fastlane/metadata/ios/pl/name.txt b/mobile/fastlane/metadata/ios/pl/name.txt index 273ed63c73..959217db8e 100644 --- a/mobile/fastlane/metadata/ios/pl/name.txt +++ b/mobile/fastlane/metadata/ios/pl/name.txt @@ -1 +1 @@ -Ente Zdjęcia +Zdjęcia Ente diff --git a/mobile/fastlane/metadata/playstore/pl/full_description.txt b/mobile/fastlane/metadata/playstore/pl/full_description.txt index 7a44ee9f86..30f205b17a 100644 --- a/mobile/fastlane/metadata/playstore/pl/full_description.txt +++ b/mobile/fastlane/metadata/playstore/pl/full_description.txt @@ -1,4 +1,4 @@ -Ente to prosta aplikacja do automatycznego tworzenia kopii zapasowych oraz porządkowania zdjęć i filmów. +Ente to prosta aplikacja do automatycznego tworzenia kopii zapasowych oraz porządkowania zdjęć i wideo. Jeśli szukałeś/aś przyjaznej dla prywatności alternatywy, aby zachować swoje wspomnienia, to dotarłeś/aś do właściwego miejsca. Dzięki Ente są one przechowywane w pełni zaszyfrowane (e2ee). Oznacza to, że tylko Ty możesz je oglądać. @@ -6,7 +6,7 @@ Mamy otwarto-źródłowe aplikacje na wszystkich platformach, a Twoje zdjęcia b Ente ułatwia również udostępnianie albumów najbliższym. Możesz udostępniać je bezpośrednio innym użytkownikom Ente, w pełni zaszyfrowane; lub za pomocą publicznie dostępnych linków. -Twoje zaszyfrowane dane są przechowywane w wielu lokalizacjach, między innymi w schronie przeciwatomowym w Paryżu. Poważnie podchodzimy do kwestii zachowania pamięci i ułatwiamy zadbanie o to, by wspomnienia przetrwały dłużej niż ty sam. +Twoje zaszyfrowane dane są przechowywane w wielu lokalizacjach, między innymi w schronie przeciwatomowym w Paryżu. Poważnie podchodzimy do kwestii zachowania pamięci i ułatwiamy zadbanie o to, by Twoje wspomnienia przetrwały dłużej, niż ty sam/a. Jesteśmy tutaj, aby zrobić najbezpieczniejszą aplikację na zdjęcia, dołącz do naszej podróży! diff --git a/mobile/fastlane/metadata/playstore/pl/short_description.txt b/mobile/fastlane/metadata/playstore/pl/short_description.txt index bcf6059280..b224c68512 100644 --- a/mobile/fastlane/metadata/playstore/pl/short_description.txt +++ b/mobile/fastlane/metadata/playstore/pl/short_description.txt @@ -1 +1 @@ -Szyfrowane przechowywanie zdjęć - twórz kopie zapasowe, organizuj i udostępniaj swoje zdjęcia i filmy \ No newline at end of file +Szyfrowane przechowywanie zdjęć - twórz kopie zapasowe, organizuj i udostępniaj swoje zdjęcia i wideo \ No newline at end of file diff --git a/mobile/fastlane/metadata/playstore/pl/title.txt b/mobile/fastlane/metadata/playstore/pl/title.txt index 4d811fef73..5bcea9edad 100644 --- a/mobile/fastlane/metadata/playstore/pl/title.txt +++ b/mobile/fastlane/metadata/playstore/pl/title.txt @@ -1 +1 @@ -Ente Zdjęcia \ No newline at end of file +Zdjęcia Ente \ No newline at end of file diff --git a/mobile/lib/app.dart b/mobile/lib/app.dart index e7e299ed94..e1e76869e5 100644 --- a/mobile/lib/app.dart +++ b/mobile/lib/app.dart @@ -32,8 +32,8 @@ class EnteApp extends StatefulWidget { this.killBackgroundTask, this.locale, this.savedThemeMode, { - Key? key, - }) : super(key: key); + super.key, + }); static void setLocale(BuildContext context, Locale newLocale) { final state = context.findAncestorStateOfType<_EnteAppState>()!; diff --git a/mobile/lib/db/files_db.dart b/mobile/lib/db/files_db.dart index 7c6b7947db..2d57374f64 100644 --- a/mobile/lib/db/files_db.dart +++ b/mobile/lib/db/files_db.dart @@ -703,7 +703,6 @@ class FilesDB { final db = await instance.sqliteAsyncDB; final results = await db.getAll(query, args); - _logger.info("message"); stopWatch.log('queryDone'); final files = convertToFiles(results); stopWatch.log('convertDone'); @@ -1178,7 +1177,7 @@ class FilesDB { omitCollectionId: true, ); await db.execute( - 'UPDATE $filesTable' + 'UPDATE $filesTable ' 'SET $updateAssignments WHERE $columnUploadedFileID = ?', parameterSet, ); diff --git a/mobile/lib/generated/intl/messages_cs.dart b/mobile/lib/generated/intl/messages_cs.dart index 3bf46dc587..33d30fa6e3 100644 --- a/mobile/lib/generated/intl/messages_cs.dart +++ b/mobile/lib/generated/intl/messages_cs.dart @@ -60,6 +60,11 @@ class MessageLookup extends MessageLookupByLibrary { MessageLookupByLibrary.simpleMessage("Face recognition"), "fileTypes": MessageLookupByLibrary.simpleMessage("File types"), "foundFaces": MessageLookupByLibrary.simpleMessage("Found faces"), + "hideContent": MessageLookupByLibrary.simpleMessage("Hide content"), + "hideContentDescriptionAndroid": MessageLookupByLibrary.simpleMessage( + "Hides app content in the app switcher and disables screenshots"), + "hideContentDescriptionIos": MessageLookupByLibrary.simpleMessage( + "Hides app content in the app switcher"), "immediately": MessageLookupByLibrary.simpleMessage("Immediately"), "indexingIsPaused": MessageLookupByLibrary.simpleMessage( "Indexing is paused, will automatically resume when device is ready"), @@ -75,15 +80,23 @@ class MessageLookup extends MessageLookupByLibrary { "moveToHiddenAlbum": MessageLookupByLibrary.simpleMessage("Move to hidden album"), "next": MessageLookupByLibrary.simpleMessage("Next"), + "noQuickLinksSelected": + MessageLookupByLibrary.simpleMessage("No quick links selected"), "noSystemLockFound": MessageLookupByLibrary.simpleMessage("No system lock found"), "passwordLock": MessageLookupByLibrary.simpleMessage("Password lock"), + "passwordStrengthInfo": MessageLookupByLibrary.simpleMessage( + "Password strength is calculated considering the length of the password, used characters, and whether or not the password appears in the top 10,000 most used passwords"), "pinLock": MessageLookupByLibrary.simpleMessage("PIN lock"), + "pleaseSelectQuickLinksToRemove": MessageLookupByLibrary.simpleMessage( + "Please select quick links to remove"), "reenterPassword": MessageLookupByLibrary.simpleMessage("Re-enter password"), "reenterPin": MessageLookupByLibrary.simpleMessage("Re-enter PIN"), "removePersonLabel": MessageLookupByLibrary.simpleMessage("Remove person label"), + "removePublicLinks": + MessageLookupByLibrary.simpleMessage("Remove public links"), "search": MessageLookupByLibrary.simpleMessage("Search"), "selectALocation": MessageLookupByLibrary.simpleMessage("Select a location"), @@ -95,6 +108,9 @@ class MessageLookup extends MessageLookupByLibrary { "swipeLockEnablePreSteps": MessageLookupByLibrary.simpleMessage( "To enable swipe lock, please setup device passcode or screen lock in your system settings."), "tapToUnlock": MessageLookupByLibrary.simpleMessage("Tap to unlock"), + "thisWillRemovePublicLinksOfAllSelectedQuickLinks": + MessageLookupByLibrary.simpleMessage( + "This will remove public links of all selected quick links."), "toEnableAppLockPleaseSetupDevicePasscodeOrScreen": MessageLookupByLibrary.simpleMessage( "To enable app lock, please setup device passcode or screen lock in your system settings."), diff --git a/mobile/lib/generated/intl/messages_de.dart b/mobile/lib/generated/intl/messages_de.dart index 8cb4daf49a..01a89b268e 100644 --- a/mobile/lib/generated/intl/messages_de.dart +++ b/mobile/lib/generated/intl/messages_de.dart @@ -45,7 +45,7 @@ class MessageLookup extends MessageLookupByLibrary { "${freeAmount} ${storageUnit} frei"; static String m9(paymentProvider) => - "Bitte kündigen Sie Ihr aktuelles Abo über ${paymentProvider} zuerst"; + "Bitte kündige dein aktuelles Abo über ${paymentProvider} zuerst"; static String m10(user) => "Der Nutzer \"${user}\" wird keine weiteren Fotos zum Album hinzufügen können.\n\nJedoch kann er weiterhin vorhandene Bilder, welche durch ihn hinzugefügt worden sind, wieder entfernen"; @@ -65,7 +65,7 @@ class MessageLookup extends MessageLookupByLibrary { "Bitte kontaktiere ${familyAdminEmail} um dein Abo zu verwalten"; static String m14(provider) => - "Bitte kontaktieren Sie uns über support@ente.io, um Ihr ${provider} Abo zu verwalten."; + "Bitte kontaktiere uns über support@ente.io, um dein ${provider} Abo zu verwalten."; static String m15(endpoint) => "Verbunden mit ${endpoint}"; @@ -189,7 +189,7 @@ class MessageLookup extends MessageLookupByLibrary { "${usedAmount} ${usedStorageUnit} von ${totalAmount} ${totalStorageUnit} verwendet"; static String m60(id) => - "Ihr ${id} ist bereits mit einem anderen Ente-Konto verknüpft.\nWenn Sie Ihre ${id} mit diesem Konto verwenden möchten, kontaktieren Sie bitte unseren Support"; + "Dein ${id} ist bereits mit einem anderen Ente-Konto verknüpft.\nWenn du deine ${id} mit diesem Konto verwenden möchtest, kontaktiere bitte unseren Support"; static String m61(endDate) => "Ihr Abo endet am ${endDate}"; @@ -282,7 +282,7 @@ class MessageLookup extends MessageLookupByLibrary { "allMemoriesPreserved": MessageLookupByLibrary.simpleMessage( "Alle Erinnerungsstücke gesichert"), "allowAddPhotosDescription": MessageLookupByLibrary.simpleMessage( - "Erlaube Nutzern mit diesem Link ebenfalls Fotos zu diesem geteilten Album hinzuzufügen."), + "Erlaube Nutzern, mit diesem Link ebenfalls Fotos zu diesem geteilten Album hinzuzufügen."), "allowAddingPhotos": MessageLookupByLibrary.simpleMessage( "Hinzufügen von Fotos erlauben"), "allowDownloads": @@ -313,7 +313,7 @@ class MessageLookup extends MessageLookupByLibrary { "Authentifizierung erforderlich"), "appLock": MessageLookupByLibrary.simpleMessage("App-Sperre"), "appLockDescription": MessageLookupByLibrary.simpleMessage( - "Choose between your device\\\'s default lock screen and a custom lock screen with a PIN or password."), + "Wähle zwischen dem Standard-Sperrbildschirm deines Gerätes und einem eigenen Sperrbildschirm mit PIN oder Passwort."), "appVersion": m7, "appleId": MessageLookupByLibrary.simpleMessage("Apple ID"), "apply": MessageLookupByLibrary.simpleMessage("Anwenden"), @@ -331,15 +331,15 @@ class MessageLookup extends MessageLookupByLibrary { "Bist du sicher, dass du kündigen willst?"), "areYouSureYouWantToChangeYourPlan": MessageLookupByLibrary.simpleMessage( - "Sind Sie sicher, dass Sie Ihren Tarif ändern möchten?"), + "Bist du sicher, dass du deinen Tarif ändern möchtest?"), "areYouSureYouWantToExit": MessageLookupByLibrary.simpleMessage( "Möchtest du Vorgang wirklich abbrechen?"), "areYouSureYouWantToLogout": MessageLookupByLibrary.simpleMessage( - "Sind sie sicher, dass Sie sich abmelden wollen?"), + "Bist Du sicher, dass du dich abmelden möchtest?"), "areYouSureYouWantToRenew": MessageLookupByLibrary.simpleMessage( "Bist du sicher, dass du verlängern möchtest?"), "askCancelReason": MessageLookupByLibrary.simpleMessage( - "Ihr Abonnement wurde gekündigt. Möchten Sie uns den Grund mitteilen?"), + "Dein Abonnement wurde gekündigt. Möchtest du uns den Grund mitteilen?"), "askDeleteReason": MessageLookupByLibrary.simpleMessage( "Was ist der Hauptgrund für die Löschung deines Kontos?"), "askYourLovedOnesToShare": MessageLookupByLibrary.simpleMessage( @@ -378,9 +378,10 @@ class MessageLookup extends MessageLookupByLibrary { "Verfügbare Cast-Geräte werden hier angezeigt."), "autoCastiOSPermission": MessageLookupByLibrary.simpleMessage( "Stelle sicher, dass die Ente-App auf das lokale Netzwerk zugreifen darf. Das kannst du in den Einstellungen unter \"Datenschutz\"."), - "autoLock": MessageLookupByLibrary.simpleMessage("Auto lock"), + "autoLock": + MessageLookupByLibrary.simpleMessage("Automatisches Sperren"), "autoLockFeatureDescription": MessageLookupByLibrary.simpleMessage( - "Time after which the app locks after being put in the background"), + "Zeit, nach der die App gesperrt wird, nachdem sie in den Hintergrund verschoben wurde"), "autoLogoutMessage": MessageLookupByLibrary.simpleMessage( "Aufgrund technischer Störungen wurden Sie abgemeldet. Wir entschuldigen uns für die Unannehmlichkeiten."), "autoPair": @@ -501,7 +502,7 @@ class MessageLookup extends MessageLookupByLibrary { "confirmRecoveryKey": MessageLookupByLibrary.simpleMessage( "Wiederherstellungsschlüssel bestätigen"), "confirmYourRecoveryKey": MessageLookupByLibrary.simpleMessage( - "Bestätigen Sie ihren Wiederherstellungsschlüssel"), + "Bestätige deinen Wiederherstellungsschlüssel"), "connectToDevice": MessageLookupByLibrary.simpleMessage("Mit Gerät verbinden"), "contactFamilyAdmin": m13, @@ -735,7 +736,7 @@ class MessageLookup extends MessageLookupByLibrary { "existingUser": MessageLookupByLibrary.simpleMessage("Existierender Benutzer"), "expiredLinkInfo": MessageLookupByLibrary.simpleMessage( - "Dieser Link ist abgelaufen. Bitte wählen Sie ein neues Ablaufdatum oder deaktivieren Sie das Ablaufdatum des Links."), + "Dieser Link ist abgelaufen. Bitte wähle ein neues Ablaufdatum oder deaktiviere das Ablaufdatum des Links."), "exportLogs": MessageLookupByLibrary.simpleMessage("Protokolle exportieren"), "exportYourData": @@ -831,6 +832,12 @@ class MessageLookup extends MessageLookupByLibrary { "help": MessageLookupByLibrary.simpleMessage("Hilfe"), "hidden": MessageLookupByLibrary.simpleMessage("Versteckt"), "hide": MessageLookupByLibrary.simpleMessage("Ausblenden"), + "hideContent": + MessageLookupByLibrary.simpleMessage("Inhalte verstecken"), + "hideContentDescriptionAndroid": MessageLookupByLibrary.simpleMessage( + "Versteckt Inhalte der App beim Wechseln zwischen Apps und deaktiviert Screenshots"), + "hideContentDescriptionIos": MessageLookupByLibrary.simpleMessage( + "Versteckt Inhalte der App beim Wechseln zwischen Apps"), "hiding": MessageLookupByLibrary.simpleMessage("Verstecken..."), "hostedAtOsmFrance": MessageLookupByLibrary.simpleMessage("Gehostet bei OSM France"), @@ -846,7 +853,7 @@ class MessageLookup extends MessageLookupByLibrary { "ignoreUpdate": MessageLookupByLibrary.simpleMessage("Ignorieren"), "ignoredFolderUploadReason": MessageLookupByLibrary.simpleMessage( "Ein paar Dateien in diesem Album werden nicht hochgeladen, weil sie in der Vergangenheit schonmal aus Ente gelöscht wurden."), - "immediately": MessageLookupByLibrary.simpleMessage("Immediately"), + "immediately": MessageLookupByLibrary.simpleMessage("Sofort"), "importing": MessageLookupByLibrary.simpleMessage("Importiert...."), "incorrectCode": MessageLookupByLibrary.simpleMessage("Falscher Code"), "incorrectPasswordTitle": @@ -874,7 +881,7 @@ class MessageLookup extends MessageLookupByLibrary { "invalidKey": MessageLookupByLibrary.simpleMessage("Ungültiger Schlüssel"), "invalidRecoveryKey": MessageLookupByLibrary.simpleMessage( - "Der von Ihnen eingegebene Wiederherstellungsschlüssel ist nicht gültig. Bitte stellen Sie sicher das aus 24 Wörtern zusammen gesetzt ist und jedes dieser Worte richtig geschrieben wurde.\n\nSollten Sie den Wiederherstellungscode eingegeben haben, stellen Sie bitte sicher, dass dieser 64 Worte lang ist und ebenfall richtig geschrieben wurde."), + "Der eingegebene Wiederherstellungsschlüssel ist nicht gültig. Bitte stelle sicher, dass er aus 24 Wörtern zusammengesetzt ist und jedes dieser Worte richtig geschrieben wurde.\n\nSolltest du den Wiederherstellungscode eingegeben haben, stelle bitte sicher, dass dieser 64 Zeichen lang ist und ebenfalls richtig geschrieben wurde."), "invite": MessageLookupByLibrary.simpleMessage("Einladen"), "inviteToEnte": MessageLookupByLibrary.simpleMessage("Zu Ente einladen"), @@ -911,7 +918,7 @@ class MessageLookup extends MessageLookupByLibrary { "lightTheme": MessageLookupByLibrary.simpleMessage("Hell"), "linkCopiedToClipboard": MessageLookupByLibrary.simpleMessage( "Link in Zwischenablage kopiert"), - "linkDeviceLimit": MessageLookupByLibrary.simpleMessage("Geräte Limit"), + "linkDeviceLimit": MessageLookupByLibrary.simpleMessage("Geräte-Limit"), "linkEnabled": MessageLookupByLibrary.simpleMessage("Aktiviert"), "linkExpired": MessageLookupByLibrary.simpleMessage("Abgelaufen"), "linkExpiresOn": m33, @@ -994,7 +1001,7 @@ class MessageLookup extends MessageLookupByLibrary { "merchandise": MessageLookupByLibrary.simpleMessage("Merchandise"), "mlFunctions": MessageLookupByLibrary.simpleMessage("ML functions"), "mlIndexingDescription": MessageLookupByLibrary.simpleMessage( - "Bitte beachten Sie, dass Machine Learning zu einem höheren Bandbreiten- und Batterieverbrauch führt, bis alle Elemente indiziert sind."), + "Bitte beachte, dass Machine Learning zu einem höheren Bandbreiten- und Batterieverbrauch führt, bis alle Elemente indiziert sind."), "mobileWebDesktop": MessageLookupByLibrary.simpleMessage("Mobil, Web, Desktop"), "moderateStrength": MessageLookupByLibrary.simpleMessage("Mittel"), @@ -1045,6 +1052,8 @@ class MessageLookup extends MessageLookupByLibrary { "Momentan werden keine Fotos gesichert"), "noPhotosFoundHere": MessageLookupByLibrary.simpleMessage("Keine Fotos gefunden"), + "noQuickLinksSelected": + MessageLookupByLibrary.simpleMessage("No quick links selected"), "noRecoveryKey": MessageLookupByLibrary.simpleMessage( "Kein Wiederherstellungs-Schlüssel?"), "noRecoveryKeyNoDecryption": MessageLookupByLibrary.simpleMessage( @@ -1078,7 +1087,7 @@ class MessageLookup extends MessageLookupByLibrary { "optionalAsShortAsYouLike": MessageLookupByLibrary.simpleMessage( "Bei Bedarf auch so kurz wie Sie wollen..."), "orPickAnExistingOne": MessageLookupByLibrary.simpleMessage( - "Oder eine Vorherige auswählen"), + "Oder eine vorherige auswählen"), "pair": MessageLookupByLibrary.simpleMessage("Koppeln"), "pairWithPin": MessageLookupByLibrary.simpleMessage("Mit PIN verbinden"), @@ -1094,6 +1103,8 @@ class MessageLookup extends MessageLookupByLibrary { "Passwort erfolgreich geändert"), "passwordLock": MessageLookupByLibrary.simpleMessage("Passwort Sperre"), "passwordStrength": m38, + "passwordStrengthInfo": MessageLookupByLibrary.simpleMessage( + "Die Berechnung der Stärke des Passworts basiert auf dessen Länge, den verwendeten Zeichen, und ob es in den 10.000 am häufigsten verwendeten Passwörtern vorkommt"), "passwordWarning": MessageLookupByLibrary.simpleMessage( "Wir speichern dieses Passwort nicht. Wenn du es vergisst, können wir deine Daten nicht entschlüsseln"), "paymentDetails": @@ -1148,6 +1159,8 @@ class MessageLookup extends MessageLookupByLibrary { "Bitte erteile die nötigen Berechtigungen"), "pleaseLoginAgain": MessageLookupByLibrary.simpleMessage("Bitte logge dich erneut ein"), + "pleaseSelectQuickLinksToRemove": MessageLookupByLibrary.simpleMessage( + "Please select quick links to remove"), "pleaseSendTheLogsTo": m42, "pleaseTryAgain": MessageLookupByLibrary.simpleMessage("Bitte versuche es erneut"), @@ -1203,7 +1216,7 @@ class MessageLookup extends MessageLookupByLibrary { "recoveryKeyVerified": MessageLookupByLibrary.simpleMessage( "Wiederherstellungs-Schlüssel überprüft"), "recoveryKeyVerifyReason": MessageLookupByLibrary.simpleMessage( - "Ihr Wiederherstellungsschlüssel ist die einzige Möglichkeit Ihre Fotos wieder herzustellen, sollten Sie Ihr Passwort vergessen haben. Sie können diesen unter \"Einstellungen\" und dann \"Konto\" wieder finden.\n\nBitte geben Sie unten Ihren Wiederherstellungsschlüssel ein um sicher zu stellen, dass Sie ihn korrekt hinterlegt haben."), + "Dein Wiederherstellungsschlüssel ist die einzige Möglichkeit, deine Fotos wiederherzustellen, solltest du dein Passwort vergessen haben. Du kannst diesen unter \"Einstellungen\" und dann \"Konto\" wiederfinden.\n\nBitte gib unten deinen Wiederherstellungsschlüssel ein, um sicherzustellen, dass du ihn korrekt hinterlegt hast."), "recoverySuccessful": MessageLookupByLibrary.simpleMessage( "Wiederherstellung erfolgreich!"), "recreatePasswordBody": MessageLookupByLibrary.simpleMessage( @@ -1220,7 +1233,7 @@ class MessageLookup extends MessageLookupByLibrary { "referralStep1": MessageLookupByLibrary.simpleMessage( "1. Gib diesen Code an deine Freunde"), "referralStep2": MessageLookupByLibrary.simpleMessage( - "2. Sie schließen ein bezahltes Abo ab"), + "2. Du schließt ein bezahltes Abo ab"), "referralStep3": m44, "referrals": MessageLookupByLibrary.simpleMessage("Weiterempfehlungen"), "referralsAreCurrentlyPaused": MessageLookupByLibrary.simpleMessage( @@ -1254,6 +1267,8 @@ class MessageLookup extends MessageLookupByLibrary { MessageLookupByLibrary.simpleMessage("Personenetikett entfernen"), "removePublicLink": MessageLookupByLibrary.simpleMessage("Öffentlichen Link entfernen"), + "removePublicLinks": + MessageLookupByLibrary.simpleMessage("Remove public links"), "removeShareItemsWarning": MessageLookupByLibrary.simpleMessage( "Einige der Elemente, die du entfernst, wurden von anderen Nutzern hinzugefügt und du wirst den Zugriff auf sie verlieren"), "removeWithQuestionMark": @@ -1500,7 +1515,7 @@ class MessageLookup extends MessageLookupByLibrary { MessageLookupByLibrary.simpleMessage("Verbesserung vorschlagen"), "support": MessageLookupByLibrary.simpleMessage("Support"), "swipeLockEnablePreSteps": MessageLookupByLibrary.simpleMessage( - "To enable swipe lock, please setup device passcode or screen lock in your system settings."), + "Um das Sperren beim Wischen zu aktivieren, richte bitte einen Gerätepasscode oder eine Bildschirmsperre in den Systemeinstellungen ein."), "syncProgress": m62, "syncStopped": MessageLookupByLibrary.simpleMessage("Synchronisierung angehalten"), @@ -1556,6 +1571,9 @@ class MessageLookup extends MessageLookupByLibrary { "Dadurch wirst du von folgendem Gerät abgemeldet:"), "thisWillLogYouOutOfThisDevice": MessageLookupByLibrary.simpleMessage( "Dadurch wirst du von diesem Gerät abgemeldet!"), + "thisWillRemovePublicLinksOfAllSelectedQuickLinks": + MessageLookupByLibrary.simpleMessage( + "This will remove public links of all selected quick links."), "toEnableAppLockPleaseSetupDevicePasscodeOrScreen": MessageLookupByLibrary.simpleMessage( "Um die App-Sperre zu aktivieren, konfigurieren Sie bitte den Gerätepasscode oder die Bildschirmsperre in Ihren Systemeinstellungen."), @@ -1664,7 +1682,7 @@ class MessageLookup extends MessageLookupByLibrary { "Wiederherstellungsschlüssel anzeigen"), "viewer": MessageLookupByLibrary.simpleMessage("Zuschauer"), "visitWebToManage": MessageLookupByLibrary.simpleMessage( - "Bitte rufen Sie \"web.ente.io\" auf um ihr Abo zu verwalten"), + "Bitte rufe \"web.ente.io\" auf, um dein Abo zu verwalten"), "waitingForVerification": MessageLookupByLibrary.simpleMessage("Warte auf Bestätigung..."), "waitingForWifi": @@ -1691,11 +1709,11 @@ class MessageLookup extends MessageLookupByLibrary { "yesLogout": MessageLookupByLibrary.simpleMessage("Ja, ausloggen"), "yesRemove": MessageLookupByLibrary.simpleMessage("Ja, entfernen"), "yesRenew": MessageLookupByLibrary.simpleMessage("Ja, erneuern"), - "you": MessageLookupByLibrary.simpleMessage("Sie"), + "you": MessageLookupByLibrary.simpleMessage("Du"), "youAreOnAFamilyPlan": MessageLookupByLibrary.simpleMessage("Du bist im Familien-Tarif!"), "youAreOnTheLatestVersion": MessageLookupByLibrary.simpleMessage( - "Sie sind auf der neuesten Version"), + "Du bist auf der neuesten Version"), "youCanAtMaxDoubleYourStorage": MessageLookupByLibrary.simpleMessage( "* Du kannst deinen Speicher maximal verdoppeln"), "youCanManageYourLinksInTheShareTab": diff --git a/mobile/lib/generated/intl/messages_en.dart b/mobile/lib/generated/intl/messages_en.dart index 1cfc23346b..b761b6bb07 100644 --- a/mobile/lib/generated/intl/messages_en.dart +++ b/mobile/lib/generated/intl/messages_en.dart @@ -800,6 +800,11 @@ class MessageLookup extends MessageLookupByLibrary { "help": MessageLookupByLibrary.simpleMessage("Help"), "hidden": MessageLookupByLibrary.simpleMessage("Hidden"), "hide": MessageLookupByLibrary.simpleMessage("Hide"), + "hideContent": MessageLookupByLibrary.simpleMessage("Hide content"), + "hideContentDescriptionAndroid": MessageLookupByLibrary.simpleMessage( + "Hides app content in the app switcher and disables screenshots"), + "hideContentDescriptionIos": MessageLookupByLibrary.simpleMessage( + "Hides app content in the app switcher"), "hiding": MessageLookupByLibrary.simpleMessage("Hiding..."), "hostedAtOsmFrance": MessageLookupByLibrary.simpleMessage("Hosted at OSM France"), @@ -1003,6 +1008,8 @@ class MessageLookup extends MessageLookupByLibrary { "No photos are being backed up right now"), "noPhotosFoundHere": MessageLookupByLibrary.simpleMessage("No photos found here"), + "noQuickLinksSelected": + MessageLookupByLibrary.simpleMessage("No quick links selected"), "noRecoveryKey": MessageLookupByLibrary.simpleMessage("No recovery key?"), "noRecoveryKeyNoDecryption": MessageLookupByLibrary.simpleMessage( @@ -1050,6 +1057,8 @@ class MessageLookup extends MessageLookupByLibrary { "Password changed successfully"), "passwordLock": MessageLookupByLibrary.simpleMessage("Password lock"), "passwordStrength": m38, + "passwordStrengthInfo": MessageLookupByLibrary.simpleMessage( + "Password strength is calculated considering the length of the password, used characters, and whether or not the password appears in the top 10,000 most used passwords"), "passwordWarning": MessageLookupByLibrary.simpleMessage( "We don\'t store this password, so if you forget, we cannot decrypt your data"), "paymentDetails": @@ -1100,6 +1109,8 @@ class MessageLookup extends MessageLookupByLibrary { MessageLookupByLibrary.simpleMessage("Please grant permissions"), "pleaseLoginAgain": MessageLookupByLibrary.simpleMessage("Please login again"), + "pleaseSelectQuickLinksToRemove": MessageLookupByLibrary.simpleMessage( + "Please select quick links to remove"), "pleaseSendTheLogsTo": m42, "pleaseTryAgain": MessageLookupByLibrary.simpleMessage("Please try again"), @@ -1191,7 +1202,7 @@ class MessageLookup extends MessageLookupByLibrary { "removeFromAlbumTitle": MessageLookupByLibrary.simpleMessage("Remove from album?"), "removeFromFavorite": - MessageLookupByLibrary.simpleMessage("Remove from favorite"), + MessageLookupByLibrary.simpleMessage("Remove from favorites"), "removeLink": MessageLookupByLibrary.simpleMessage("Remove link"), "removeParticipant": MessageLookupByLibrary.simpleMessage("Remove participant"), @@ -1200,6 +1211,8 @@ class MessageLookup extends MessageLookupByLibrary { MessageLookupByLibrary.simpleMessage("Remove person label"), "removePublicLink": MessageLookupByLibrary.simpleMessage("Remove public link"), + "removePublicLinks": + MessageLookupByLibrary.simpleMessage("Remove public links"), "removeShareItemsWarning": MessageLookupByLibrary.simpleMessage( "Some of the items you are removing were added by other people, and you will lose access to them"), "removeWithQuestionMark": @@ -1488,6 +1501,9 @@ class MessageLookup extends MessageLookupByLibrary { "This will log you out of the following device:"), "thisWillLogYouOutOfThisDevice": MessageLookupByLibrary.simpleMessage( "This will log you out of this device!"), + "thisWillRemovePublicLinksOfAllSelectedQuickLinks": + MessageLookupByLibrary.simpleMessage( + "This will remove public links of all selected quick links."), "toEnableAppLockPleaseSetupDevicePasscodeOrScreen": MessageLookupByLibrary.simpleMessage( "To enable app lock, please setup device passcode or screen lock in your system settings."), diff --git a/mobile/lib/generated/intl/messages_es.dart b/mobile/lib/generated/intl/messages_es.dart index f29019e783..c1eb4a4e91 100644 --- a/mobile/lib/generated/intl/messages_es.dart +++ b/mobile/lib/generated/intl/messages_es.dart @@ -834,6 +834,11 @@ class MessageLookup extends MessageLookupByLibrary { "help": MessageLookupByLibrary.simpleMessage("Ayuda"), "hidden": MessageLookupByLibrary.simpleMessage("Oculto"), "hide": MessageLookupByLibrary.simpleMessage("Ocultar"), + "hideContent": MessageLookupByLibrary.simpleMessage("Hide content"), + "hideContentDescriptionAndroid": MessageLookupByLibrary.simpleMessage( + "Hides app content in the app switcher and disables screenshots"), + "hideContentDescriptionIos": MessageLookupByLibrary.simpleMessage( + "Hides app content in the app switcher"), "hiding": MessageLookupByLibrary.simpleMessage("Ocultando..."), "hostedAtOsmFrance": MessageLookupByLibrary.simpleMessage("Alojado en OSM France"), @@ -1052,6 +1057,8 @@ class MessageLookup extends MessageLookupByLibrary { "No se están respaldando fotos ahora mismo"), "noPhotosFoundHere": MessageLookupByLibrary.simpleMessage( "No se encontró ninguna foto aquí"), + "noQuickLinksSelected": + MessageLookupByLibrary.simpleMessage("No quick links selected"), "noRecoveryKey": MessageLookupByLibrary.simpleMessage("¿Sin clave de recuperación?"), "noRecoveryKeyNoDecryption": MessageLookupByLibrary.simpleMessage( @@ -1100,6 +1107,8 @@ class MessageLookup extends MessageLookupByLibrary { "passwordLock": MessageLookupByLibrary.simpleMessage("Bloqueo por contraseña"), "passwordStrength": m38, + "passwordStrengthInfo": MessageLookupByLibrary.simpleMessage( + "Password strength is calculated considering the length of the password, used characters, and whether or not the password appears in the top 10,000 most used passwords"), "passwordWarning": MessageLookupByLibrary.simpleMessage( "No almacenamos esta contraseña, así que si la olvidas, no podemos descifrar tus datos"), "paymentDetails": @@ -1153,6 +1162,8 @@ class MessageLookup extends MessageLookupByLibrary { MessageLookupByLibrary.simpleMessage("Por favor, concede permiso"), "pleaseLoginAgain": MessageLookupByLibrary.simpleMessage( "Por favor, vuelve a iniciar sesión"), + "pleaseSelectQuickLinksToRemove": MessageLookupByLibrary.simpleMessage( + "Please select quick links to remove"), "pleaseSendTheLogsTo": m42, "pleaseTryAgain": MessageLookupByLibrary.simpleMessage( "Por favor, inténtalo nuevamente"), @@ -1257,6 +1268,8 @@ class MessageLookup extends MessageLookupByLibrary { "Eliminar etiqueta de persona"), "removePublicLink": MessageLookupByLibrary.simpleMessage("Quitar enlace público"), + "removePublicLinks": + MessageLookupByLibrary.simpleMessage("Remove public links"), "removeShareItemsWarning": MessageLookupByLibrary.simpleMessage( "Algunos de los elementos que estás eliminando fueron añadidos por otras personas, y perderás el acceso a ellos"), "removeWithQuestionMark": @@ -1564,6 +1577,9 @@ class MessageLookup extends MessageLookupByLibrary { "Esto cerrará la sesión del siguiente dispositivo:"), "thisWillLogYouOutOfThisDevice": MessageLookupByLibrary.simpleMessage( "¡Esto cerrará la sesión de este dispositivo!"), + "thisWillRemovePublicLinksOfAllSelectedQuickLinks": + MessageLookupByLibrary.simpleMessage( + "This will remove public links of all selected quick links."), "toEnableAppLockPleaseSetupDevicePasscodeOrScreen": MessageLookupByLibrary.simpleMessage( "To enable app lock, please setup device passcode or screen lock in your system settings."), diff --git a/mobile/lib/generated/intl/messages_fr.dart b/mobile/lib/generated/intl/messages_fr.dart index 42cf282a6b..2abc949bed 100644 --- a/mobile/lib/generated/intl/messages_fr.dart +++ b/mobile/lib/generated/intl/messages_fr.dart @@ -781,6 +781,11 @@ class MessageLookup extends MessageLookupByLibrary { "Comment avez-vous entendu parler de Ente? (facultatif)"), "hidden": MessageLookupByLibrary.simpleMessage("Masqué"), "hide": MessageLookupByLibrary.simpleMessage("Masquer"), + "hideContent": MessageLookupByLibrary.simpleMessage("Hide content"), + "hideContentDescriptionAndroid": MessageLookupByLibrary.simpleMessage( + "Hides app content in the app switcher and disables screenshots"), + "hideContentDescriptionIos": MessageLookupByLibrary.simpleMessage( + "Hides app content in the app switcher"), "hiding": MessageLookupByLibrary.simpleMessage("Masquage en cours..."), "hostedAtOsmFrance": MessageLookupByLibrary.simpleMessage("Hébergé chez OSM France"), @@ -971,6 +976,8 @@ class MessageLookup extends MessageLookupByLibrary { "Aucune photo en cours de sauvegarde"), "noPhotosFoundHere": MessageLookupByLibrary.simpleMessage("Aucune photo trouvée"), + "noQuickLinksSelected": + MessageLookupByLibrary.simpleMessage("No quick links selected"), "noRecoveryKey": MessageLookupByLibrary.simpleMessage("Aucune clé de récupération?"), "noRecoveryKeyNoDecryption": MessageLookupByLibrary.simpleMessage( @@ -1010,6 +1017,8 @@ class MessageLookup extends MessageLookupByLibrary { "passwordLock": MessageLookupByLibrary.simpleMessage("Mot de passe verrou"), "passwordStrength": m38, + "passwordStrengthInfo": MessageLookupByLibrary.simpleMessage( + "Password strength is calculated considering the length of the password, used characters, and whether or not the password appears in the top 10,000 most used passwords"), "passwordWarning": MessageLookupByLibrary.simpleMessage( "Nous ne stockons pas ce mot de passe, donc si vous l\'oubliez, nous ne pouvons pas déchiffrer vos données"), "paymentDetails": @@ -1054,6 +1063,8 @@ class MessageLookup extends MessageLookupByLibrary { "Veuillez accorder la permission"), "pleaseLoginAgain": MessageLookupByLibrary.simpleMessage("Veuillez vous reconnecter"), + "pleaseSelectQuickLinksToRemove": MessageLookupByLibrary.simpleMessage( + "Please select quick links to remove"), "pleaseSendTheLogsTo": m42, "pleaseTryAgain": MessageLookupByLibrary.simpleMessage("Veuillez réessayer"), @@ -1155,6 +1166,8 @@ class MessageLookup extends MessageLookupByLibrary { MessageLookupByLibrary.simpleMessage("Remove person label"), "removePublicLink": MessageLookupByLibrary.simpleMessage("Supprimer le lien public"), + "removePublicLinks": + MessageLookupByLibrary.simpleMessage("Remove public links"), "removeShareItemsWarning": MessageLookupByLibrary.simpleMessage( "Certains des éléments que vous êtes en train de retirer ont été ajoutés par d\'autres personnes, vous perdrez l\'accès vers ces éléments"), "removeWithQuestionMark": @@ -1447,6 +1460,9 @@ class MessageLookup extends MessageLookupByLibrary { "Cela vous déconnectera de l\'appareil suivant :"), "thisWillLogYouOutOfThisDevice": MessageLookupByLibrary.simpleMessage( "Cela vous déconnectera de cet appareil !"), + "thisWillRemovePublicLinksOfAllSelectedQuickLinks": + MessageLookupByLibrary.simpleMessage( + "This will remove public links of all selected quick links."), "toEnableAppLockPleaseSetupDevicePasscodeOrScreen": MessageLookupByLibrary.simpleMessage( "To enable app lock, please setup device passcode or screen lock in your system settings."), diff --git a/mobile/lib/generated/intl/messages_it.dart b/mobile/lib/generated/intl/messages_it.dart index ea66638d9e..9ca443791e 100644 --- a/mobile/lib/generated/intl/messages_it.dart +++ b/mobile/lib/generated/intl/messages_it.dart @@ -751,6 +751,11 @@ class MessageLookup extends MessageLookupByLibrary { "Come hai sentito parlare di Ente? (opzionale)"), "hidden": MessageLookupByLibrary.simpleMessage("Nascosti"), "hide": MessageLookupByLibrary.simpleMessage("Nascondi"), + "hideContent": MessageLookupByLibrary.simpleMessage("Hide content"), + "hideContentDescriptionAndroid": MessageLookupByLibrary.simpleMessage( + "Hides app content in the app switcher and disables screenshots"), + "hideContentDescriptionIos": MessageLookupByLibrary.simpleMessage( + "Hides app content in the app switcher"), "hiding": MessageLookupByLibrary.simpleMessage("Nascondendo..."), "hostedAtOsmFrance": MessageLookupByLibrary.simpleMessage("Ospitato presso OSM France"), @@ -938,6 +943,8 @@ class MessageLookup extends MessageLookupByLibrary { "Il backup delle foto attualmente non viene eseguito"), "noPhotosFoundHere": MessageLookupByLibrary.simpleMessage("Nessuna foto trovata"), + "noQuickLinksSelected": + MessageLookupByLibrary.simpleMessage("No quick links selected"), "noRecoveryKey": MessageLookupByLibrary.simpleMessage("Nessuna chiave di recupero?"), "noRecoveryKeyNoDecryption": MessageLookupByLibrary.simpleMessage( @@ -977,6 +984,8 @@ class MessageLookup extends MessageLookupByLibrary { "passwordLock": MessageLookupByLibrary.simpleMessage("Blocco con password"), "passwordStrength": m38, + "passwordStrengthInfo": MessageLookupByLibrary.simpleMessage( + "Password strength is calculated considering the length of the password, used characters, and whether or not the password appears in the top 10,000 most used passwords"), "passwordWarning": MessageLookupByLibrary.simpleMessage( "Noi non memorizziamo la tua password, quindi se te la dimentichi, non possiamo decriptare i tuoi dati"), "paymentDetails": @@ -1018,6 +1027,8 @@ class MessageLookup extends MessageLookupByLibrary { MessageLookupByLibrary.simpleMessage("Concedi i permessi"), "pleaseLoginAgain": MessageLookupByLibrary.simpleMessage( "Effettua nuovamente l\'accesso"), + "pleaseSelectQuickLinksToRemove": MessageLookupByLibrary.simpleMessage( + "Please select quick links to remove"), "pleaseSendTheLogsTo": m42, "pleaseTryAgain": MessageLookupByLibrary.simpleMessage("Riprova"), "pleaseVerifyTheCodeYouHaveEntered": @@ -1116,6 +1127,8 @@ class MessageLookup extends MessageLookupByLibrary { MessageLookupByLibrary.simpleMessage("Remove person label"), "removePublicLink": MessageLookupByLibrary.simpleMessage("Rimuovi link pubblico"), + "removePublicLinks": + MessageLookupByLibrary.simpleMessage("Remove public links"), "removeShareItemsWarning": MessageLookupByLibrary.simpleMessage( "Alcuni degli elementi che stai rimuovendo sono stati aggiunti da altre persone e ne perderai l\'accesso"), "removeWithQuestionMark": @@ -1376,6 +1389,9 @@ class MessageLookup extends MessageLookupByLibrary { "Verrai disconnesso dai seguenti dispositivi:"), "thisWillLogYouOutOfThisDevice": MessageLookupByLibrary.simpleMessage( "Verrai disconnesso dal tuo dispositivo!"), + "thisWillRemovePublicLinksOfAllSelectedQuickLinks": + MessageLookupByLibrary.simpleMessage( + "This will remove public links of all selected quick links."), "toEnableAppLockPleaseSetupDevicePasscodeOrScreen": MessageLookupByLibrary.simpleMessage( "To enable app lock, please setup device passcode or screen lock in your system settings."), diff --git a/mobile/lib/generated/intl/messages_ko.dart b/mobile/lib/generated/intl/messages_ko.dart index 143e80b3de..66afb5bdd1 100644 --- a/mobile/lib/generated/intl/messages_ko.dart +++ b/mobile/lib/generated/intl/messages_ko.dart @@ -60,6 +60,11 @@ class MessageLookup extends MessageLookupByLibrary { MessageLookupByLibrary.simpleMessage("Face recognition"), "fileTypes": MessageLookupByLibrary.simpleMessage("File types"), "foundFaces": MessageLookupByLibrary.simpleMessage("Found faces"), + "hideContent": MessageLookupByLibrary.simpleMessage("Hide content"), + "hideContentDescriptionAndroid": MessageLookupByLibrary.simpleMessage( + "Hides app content in the app switcher and disables screenshots"), + "hideContentDescriptionIos": MessageLookupByLibrary.simpleMessage( + "Hides app content in the app switcher"), "immediately": MessageLookupByLibrary.simpleMessage("Immediately"), "indexingIsPaused": MessageLookupByLibrary.simpleMessage( "Indexing is paused, will automatically resume when device is ready"), @@ -75,15 +80,23 @@ class MessageLookup extends MessageLookupByLibrary { "moveToHiddenAlbum": MessageLookupByLibrary.simpleMessage("Move to hidden album"), "next": MessageLookupByLibrary.simpleMessage("Next"), + "noQuickLinksSelected": + MessageLookupByLibrary.simpleMessage("No quick links selected"), "noSystemLockFound": MessageLookupByLibrary.simpleMessage("No system lock found"), "passwordLock": MessageLookupByLibrary.simpleMessage("Password lock"), + "passwordStrengthInfo": MessageLookupByLibrary.simpleMessage( + "Password strength is calculated considering the length of the password, used characters, and whether or not the password appears in the top 10,000 most used passwords"), "pinLock": MessageLookupByLibrary.simpleMessage("PIN lock"), + "pleaseSelectQuickLinksToRemove": MessageLookupByLibrary.simpleMessage( + "Please select quick links to remove"), "reenterPassword": MessageLookupByLibrary.simpleMessage("Re-enter password"), "reenterPin": MessageLookupByLibrary.simpleMessage("Re-enter PIN"), "removePersonLabel": MessageLookupByLibrary.simpleMessage("Remove person label"), + "removePublicLinks": + MessageLookupByLibrary.simpleMessage("Remove public links"), "search": MessageLookupByLibrary.simpleMessage("Search"), "selectALocation": MessageLookupByLibrary.simpleMessage("Select a location"), @@ -95,6 +108,9 @@ class MessageLookup extends MessageLookupByLibrary { "swipeLockEnablePreSteps": MessageLookupByLibrary.simpleMessage( "To enable swipe lock, please setup device passcode or screen lock in your system settings."), "tapToUnlock": MessageLookupByLibrary.simpleMessage("Tap to unlock"), + "thisWillRemovePublicLinksOfAllSelectedQuickLinks": + MessageLookupByLibrary.simpleMessage( + "This will remove public links of all selected quick links."), "toEnableAppLockPleaseSetupDevicePasscodeOrScreen": MessageLookupByLibrary.simpleMessage( "To enable app lock, please setup device passcode or screen lock in your system settings."), diff --git a/mobile/lib/generated/intl/messages_nl.dart b/mobile/lib/generated/intl/messages_nl.dart index 8154bc4deb..d6994f9aed 100644 --- a/mobile/lib/generated/intl/messages_nl.dart +++ b/mobile/lib/generated/intl/messages_nl.dart @@ -312,7 +312,7 @@ class MessageLookup extends MessageLookupByLibrary { MessageLookupByLibrary.simpleMessage("Verificatie vereist"), "appLock": MessageLookupByLibrary.simpleMessage("App-vergrendeling"), "appLockDescription": MessageLookupByLibrary.simpleMessage( - "Choose between your device\\\'s default lock screen and a custom lock screen with a PIN or password."), + "Kies tussen het standaard vergrendelingsscherm van uw apparaat en een aangepast vergrendelingsscherm met een pincode of wachtwoord."), "appVersion": m7, "appleId": MessageLookupByLibrary.simpleMessage("Apple ID"), "apply": MessageLookupByLibrary.simpleMessage("Toepassen"), @@ -378,9 +378,10 @@ class MessageLookup extends MessageLookupByLibrary { "Je zult de beschikbare Cast apparaten hier zien."), "autoCastiOSPermission": MessageLookupByLibrary.simpleMessage( "Zorg ervoor dat lokale netwerkrechten zijn ingeschakeld voor de Ente Photos app, in Instellingen."), - "autoLock": MessageLookupByLibrary.simpleMessage("Auto lock"), + "autoLock": + MessageLookupByLibrary.simpleMessage("Automatische vergrendeling"), "autoLockFeatureDescription": MessageLookupByLibrary.simpleMessage( - "Time after which the app locks after being put in the background"), + "Tijd waarna de app wordt vergrendeld wanneer deze in achtergrond-modus is gezet"), "autoLogoutMessage": MessageLookupByLibrary.simpleMessage( "Door een technische storing bent u uitgelogd. Onze excuses voor het ongemak."), "autoPair": @@ -834,6 +835,11 @@ class MessageLookup extends MessageLookupByLibrary { "help": MessageLookupByLibrary.simpleMessage("Hulp"), "hidden": MessageLookupByLibrary.simpleMessage("Verborgen"), "hide": MessageLookupByLibrary.simpleMessage("Verbergen"), + "hideContent": MessageLookupByLibrary.simpleMessage("Inhoud verbergen"), + "hideContentDescriptionAndroid": MessageLookupByLibrary.simpleMessage( + "Verbergt app-inhoud in de app-schakelaar en schakelt schermopnamen uit"), + "hideContentDescriptionIos": MessageLookupByLibrary.simpleMessage( + "Verbergt de inhoud van de app in de app-schakelaar"), "hiding": MessageLookupByLibrary.simpleMessage("Verbergen..."), "hostedAtOsmFrance": MessageLookupByLibrary.simpleMessage("Gehost bij OSM France"), @@ -848,7 +854,7 @@ class MessageLookup extends MessageLookupByLibrary { "ignoreUpdate": MessageLookupByLibrary.simpleMessage("Negeren"), "ignoredFolderUploadReason": MessageLookupByLibrary.simpleMessage( "Sommige bestanden in dit album worden genegeerd voor uploaden omdat ze eerder van Ente zijn verwijderd."), - "immediately": MessageLookupByLibrary.simpleMessage("Immediately"), + "immediately": MessageLookupByLibrary.simpleMessage("Onmiddellijk"), "importing": MessageLookupByLibrary.simpleMessage("Importeren...."), "incorrectCode": MessageLookupByLibrary.simpleMessage("Onjuiste code"), "incorrectPasswordTitle": @@ -1047,6 +1053,8 @@ class MessageLookup extends MessageLookupByLibrary { "Er worden momenteel geen foto\'s geback-upt"), "noPhotosFoundHere": MessageLookupByLibrary.simpleMessage("Geen foto\'s gevonden hier"), + "noQuickLinksSelected": + MessageLookupByLibrary.simpleMessage("No quick links selected"), "noRecoveryKey": MessageLookupByLibrary.simpleMessage("Geen herstelcode?"), "noRecoveryKeyNoDecryption": MessageLookupByLibrary.simpleMessage( @@ -1084,6 +1092,7 @@ class MessageLookup extends MessageLookupByLibrary { "pairWithPin": MessageLookupByLibrary.simpleMessage("Koppelen met PIN"), "pairingComplete": MessageLookupByLibrary.simpleMessage("Koppeling voltooid"), + "panorama": MessageLookupByLibrary.simpleMessage("Panorama"), "passKeyPendingVerification": MessageLookupByLibrary.simpleMessage( "Verificatie is nog in behandeling"), "passkey": MessageLookupByLibrary.simpleMessage("Passkey"), @@ -1094,6 +1103,8 @@ class MessageLookup extends MessageLookupByLibrary { "Wachtwoord succesvol aangepast"), "passwordLock": MessageLookupByLibrary.simpleMessage("Wachtwoord slot"), "passwordStrength": m38, + "passwordStrengthInfo": MessageLookupByLibrary.simpleMessage( + "De wachtwoordsterkte wordt berekend aan de hand van de lengte van het wachtwoord, de gebruikte tekens en of het wachtwoord al dan niet in de top 10.000 van meest gebruikte wachtwoorden staat"), "passwordWarning": MessageLookupByLibrary.simpleMessage( "Wij slaan dit wachtwoord niet op, dus als je het vergeet, kunnen we je gegevens niet ontsleutelen"), "paymentDetails": @@ -1149,6 +1160,8 @@ class MessageLookup extends MessageLookupByLibrary { "Geef alstublieft toestemming"), "pleaseLoginAgain": MessageLookupByLibrary.simpleMessage("Log opnieuw in"), + "pleaseSelectQuickLinksToRemove": MessageLookupByLibrary.simpleMessage( + "Please select quick links to remove"), "pleaseSendTheLogsTo": m42, "pleaseTryAgain": MessageLookupByLibrary.simpleMessage("Probeer het nog eens"), @@ -1252,6 +1265,8 @@ class MessageLookup extends MessageLookupByLibrary { MessageLookupByLibrary.simpleMessage("Verwijder persoonslabel"), "removePublicLink": MessageLookupByLibrary.simpleMessage("Verwijder publieke link"), + "removePublicLinks": + MessageLookupByLibrary.simpleMessage("Remove public links"), "removeShareItemsWarning": MessageLookupByLibrary.simpleMessage( "Sommige van de items die je verwijdert zijn door andere mensen toegevoegd, en je verliest de toegang daartoe"), "removeWithQuestionMark": @@ -1497,7 +1512,7 @@ class MessageLookup extends MessageLookupByLibrary { MessageLookupByLibrary.simpleMessage("Features voorstellen"), "support": MessageLookupByLibrary.simpleMessage("Ondersteuning"), "swipeLockEnablePreSteps": MessageLookupByLibrary.simpleMessage( - "To enable swipe lock, please setup device passcode or screen lock in your system settings."), + "Om swipe-vergrendeling in te schakelen, stelt u de toegangscode van het apparaat of schermvergrendeling in uw systeeminstellingen in."), "syncProgress": m62, "syncStopped": MessageLookupByLibrary.simpleMessage("Synchronisatie gestopt"), @@ -1552,6 +1567,9 @@ class MessageLookup extends MessageLookupByLibrary { "Dit zal je uitloggen van het volgende apparaat:"), "thisWillLogYouOutOfThisDevice": MessageLookupByLibrary.simpleMessage( "Dit zal je uitloggen van dit apparaat!"), + "thisWillRemovePublicLinksOfAllSelectedQuickLinks": + MessageLookupByLibrary.simpleMessage( + "This will remove public links of all selected quick links."), "toEnableAppLockPleaseSetupDevicePasscodeOrScreen": MessageLookupByLibrary.simpleMessage( "Om vergrendelscherm in te schakelen, moet u een toegangscode of schermvergrendeling instellen in uw systeeminstellingen."), @@ -1641,6 +1659,7 @@ class MessageLookup extends MessageLookupByLibrary { "verifying": MessageLookupByLibrary.simpleMessage("Verifiëren..."), "verifyingRecoveryKey": MessageLookupByLibrary.simpleMessage( "Herstelsleutel verifiëren..."), + "videoInfo": MessageLookupByLibrary.simpleMessage("Video-info"), "videoSmallCase": MessageLookupByLibrary.simpleMessage("video"), "videos": MessageLookupByLibrary.simpleMessage("Video\'s"), "viewActiveSessions": diff --git a/mobile/lib/generated/intl/messages_no.dart b/mobile/lib/generated/intl/messages_no.dart index 0423933ed4..c64b2dda85 100644 --- a/mobile/lib/generated/intl/messages_no.dart +++ b/mobile/lib/generated/intl/messages_no.dart @@ -78,6 +78,11 @@ class MessageLookup extends MessageLookupByLibrary { "feedback": MessageLookupByLibrary.simpleMessage("Tilbakemelding"), "fileTypes": MessageLookupByLibrary.simpleMessage("File types"), "foundFaces": MessageLookupByLibrary.simpleMessage("Found faces"), + "hideContent": MessageLookupByLibrary.simpleMessage("Hide content"), + "hideContentDescriptionAndroid": MessageLookupByLibrary.simpleMessage( + "Hides app content in the app switcher and disables screenshots"), + "hideContentDescriptionIos": MessageLookupByLibrary.simpleMessage( + "Hides app content in the app switcher"), "immediately": MessageLookupByLibrary.simpleMessage("Immediately"), "indexingIsPaused": MessageLookupByLibrary.simpleMessage( "Indexing is paused, will automatically resume when device is ready"), @@ -97,15 +102,23 @@ class MessageLookup extends MessageLookupByLibrary { "moveToHiddenAlbum": MessageLookupByLibrary.simpleMessage("Move to hidden album"), "next": MessageLookupByLibrary.simpleMessage("Next"), + "noQuickLinksSelected": + MessageLookupByLibrary.simpleMessage("No quick links selected"), "noSystemLockFound": MessageLookupByLibrary.simpleMessage("No system lock found"), "passwordLock": MessageLookupByLibrary.simpleMessage("Password lock"), + "passwordStrengthInfo": MessageLookupByLibrary.simpleMessage( + "Password strength is calculated considering the length of the password, used characters, and whether or not the password appears in the top 10,000 most used passwords"), "pinLock": MessageLookupByLibrary.simpleMessage("PIN lock"), + "pleaseSelectQuickLinksToRemove": MessageLookupByLibrary.simpleMessage( + "Please select quick links to remove"), "reenterPassword": MessageLookupByLibrary.simpleMessage("Re-enter password"), "reenterPin": MessageLookupByLibrary.simpleMessage("Re-enter PIN"), "removePersonLabel": MessageLookupByLibrary.simpleMessage("Remove person label"), + "removePublicLinks": + MessageLookupByLibrary.simpleMessage("Remove public links"), "search": MessageLookupByLibrary.simpleMessage("Search"), "selectALocation": MessageLookupByLibrary.simpleMessage("Select a location"), @@ -117,6 +130,9 @@ class MessageLookup extends MessageLookupByLibrary { "swipeLockEnablePreSteps": MessageLookupByLibrary.simpleMessage( "To enable swipe lock, please setup device passcode or screen lock in your system settings."), "tapToUnlock": MessageLookupByLibrary.simpleMessage("Tap to unlock"), + "thisWillRemovePublicLinksOfAllSelectedQuickLinks": + MessageLookupByLibrary.simpleMessage( + "This will remove public links of all selected quick links."), "toEnableAppLockPleaseSetupDevicePasscodeOrScreen": MessageLookupByLibrary.simpleMessage( "To enable app lock, please setup device passcode or screen lock in your system settings."), diff --git a/mobile/lib/generated/intl/messages_pl.dart b/mobile/lib/generated/intl/messages_pl.dart index 364b73e858..b5dcda8681 100644 --- a/mobile/lib/generated/intl/messages_pl.dart +++ b/mobile/lib/generated/intl/messages_pl.dart @@ -312,7 +312,7 @@ class MessageLookup extends MessageLookupByLibrary { "appLock": MessageLookupByLibrary.simpleMessage( "Blokada dostępu do aplikacji"), "appLockDescription": MessageLookupByLibrary.simpleMessage( - "Choose between your device\\\'s default lock screen and a custom lock screen with a PIN or password."), + "Wybierz między domyślnym ekranem blokady urządzenia a niestandardowym ekranem blokady z kodem PIN lub hasłem."), "appVersion": m7, "appleId": MessageLookupByLibrary.simpleMessage("Apple ID"), "apply": MessageLookupByLibrary.simpleMessage("Użyj"), @@ -377,9 +377,10 @@ class MessageLookup extends MessageLookupByLibrary { "Tutaj zobaczysz dostępne urządzenia Cast."), "autoCastiOSPermission": MessageLookupByLibrary.simpleMessage( "Upewnij się, że uprawnienia sieci lokalnej są włączone dla aplikacji Ente Zdjęcia w Ustawieniach."), - "autoLock": MessageLookupByLibrary.simpleMessage("Auto lock"), + "autoLock": + MessageLookupByLibrary.simpleMessage("Automatyczna blokada"), "autoLockFeatureDescription": MessageLookupByLibrary.simpleMessage( - "Time after which the app locks after being put in the background"), + "Czas, po którym aplikacja blokuje się po umieszczeniu jej w tle"), "autoLogoutMessage": MessageLookupByLibrary.simpleMessage( "Z powodu technicznego błędu, zostałeś wylogowany. Przepraszamy za niedogodności."), "autoPair": @@ -824,6 +825,11 @@ class MessageLookup extends MessageLookupByLibrary { "help": MessageLookupByLibrary.simpleMessage("Pomoc"), "hidden": MessageLookupByLibrary.simpleMessage("Ukryte"), "hide": MessageLookupByLibrary.simpleMessage("Ukryj"), + "hideContent": MessageLookupByLibrary.simpleMessage("Ukryj zawartość"), + "hideContentDescriptionAndroid": MessageLookupByLibrary.simpleMessage( + "Ukrywa zawartość aplikacji w przełączniku aplikacji i wyłącza zrzuty ekranu"), + "hideContentDescriptionIos": MessageLookupByLibrary.simpleMessage( + "Ukrywa zawartość aplikacji w przełączniku aplikacji"), "hiding": MessageLookupByLibrary.simpleMessage("Ukrywanie..."), "hostedAtOsmFrance": MessageLookupByLibrary.simpleMessage("Hostowane w OSM Francja"), @@ -838,7 +844,7 @@ class MessageLookup extends MessageLookupByLibrary { "ignoreUpdate": MessageLookupByLibrary.simpleMessage("Ignoruj"), "ignoredFolderUploadReason": MessageLookupByLibrary.simpleMessage( "Niektóre pliki w tym albumie są ignorowane podczas przesyłania, ponieważ zostały wcześniej usunięte z Ente."), - "immediately": MessageLookupByLibrary.simpleMessage("Immediately"), + "immediately": MessageLookupByLibrary.simpleMessage("Natychmiast"), "importing": MessageLookupByLibrary.simpleMessage("Importowanie...."), "incorrectCode": MessageLookupByLibrary.simpleMessage("Nieprawidłowy kod"), @@ -910,7 +916,8 @@ class MessageLookup extends MessageLookupByLibrary { "linkExpiry": MessageLookupByLibrary.simpleMessage("Wygaśnięcie linku"), "linkHasExpired": MessageLookupByLibrary.simpleMessage("Link wygasł"), "linkNeverExpires": MessageLookupByLibrary.simpleMessage("Nigdy"), - "livePhotos": MessageLookupByLibrary.simpleMessage("Zdjęcia Live"), + "livePhotos": + MessageLookupByLibrary.simpleMessage("Zdjęcia Live Photo"), "loadMessage1": MessageLookupByLibrary.simpleMessage( "Możesz udostępnić swoją subskrypcję swojej rodzinie"), "loadMessage2": MessageLookupByLibrary.simpleMessage( @@ -1038,6 +1045,8 @@ class MessageLookup extends MessageLookupByLibrary { "W tej chwili nie wykonuje się kopii zapasowej zdjęć"), "noPhotosFoundHere": MessageLookupByLibrary.simpleMessage("Nie znaleziono tutaj zdjęć"), + "noQuickLinksSelected": + MessageLookupByLibrary.simpleMessage("No quick links selected"), "noRecoveryKey": MessageLookupByLibrary.simpleMessage("Brak klucza odzyskiwania?"), "noRecoveryKeyNoDecryption": MessageLookupByLibrary.simpleMessage( @@ -1087,6 +1096,8 @@ class MessageLookup extends MessageLookupByLibrary { "Hasło zostało pomyślnie zmienione"), "passwordLock": MessageLookupByLibrary.simpleMessage("Blokada hasłem"), "passwordStrength": m38, + "passwordStrengthInfo": MessageLookupByLibrary.simpleMessage( + "Siła hasła jest obliczana, biorąc pod uwagę długość hasła, użyte znaki, i czy hasło pojawi się w 10 000 najczęściej używanych haseł"), "passwordWarning": MessageLookupByLibrary.simpleMessage( "Nie przechowujemy tego hasła, więc jeśli go zapomnisz, nie będziemy w stanie odszyfrować Twoich danych"), "paymentDetails": @@ -1141,6 +1152,8 @@ class MessageLookup extends MessageLookupByLibrary { MessageLookupByLibrary.simpleMessage("Proszę przyznać uprawnienia"), "pleaseLoginAgain": MessageLookupByLibrary.simpleMessage("Zaloguj się ponownie"), + "pleaseSelectQuickLinksToRemove": MessageLookupByLibrary.simpleMessage( + "Please select quick links to remove"), "pleaseSendTheLogsTo": m42, "pleaseTryAgain": MessageLookupByLibrary.simpleMessage("Spróbuj ponownie"), @@ -1243,6 +1256,8 @@ class MessageLookup extends MessageLookupByLibrary { MessageLookupByLibrary.simpleMessage("Usuń etykietę osoby"), "removePublicLink": MessageLookupByLibrary.simpleMessage("Usuń link publiczny"), + "removePublicLinks": + MessageLookupByLibrary.simpleMessage("Remove public links"), "removeShareItemsWarning": MessageLookupByLibrary.simpleMessage( "Niektóre z usuwanych elementów zostały dodane przez inne osoby i utracisz do nich dostęp"), "removeWithQuestionMark": @@ -1489,7 +1504,7 @@ class MessageLookup extends MessageLookupByLibrary { MessageLookupByLibrary.simpleMessage("Zaproponuj funkcje"), "support": MessageLookupByLibrary.simpleMessage("Wsparcie techniczne"), "swipeLockEnablePreSteps": MessageLookupByLibrary.simpleMessage( - "To enable swipe lock, please setup device passcode or screen lock in your system settings."), + "Aby włączyć blokadę aplikacji, należy skonfigurować hasło urządzenia lub blokadę ekranu w ustawieniach Twojego systemu."), "syncProgress": m62, "syncStopped": MessageLookupByLibrary.simpleMessage("Synchronizacja zatrzymana"), @@ -1545,6 +1560,9 @@ class MessageLookup extends MessageLookupByLibrary { "To wyloguje Cię z tego urządzenia:"), "thisWillLogYouOutOfThisDevice": MessageLookupByLibrary.simpleMessage( "To wyloguje Cię z tego urządzenia!"), + "thisWillRemovePublicLinksOfAllSelectedQuickLinks": + MessageLookupByLibrary.simpleMessage( + "This will remove public links of all selected quick links."), "toEnableAppLockPleaseSetupDevicePasscodeOrScreen": MessageLookupByLibrary.simpleMessage( "Aby włączyć blokadę aplikacji, należy skonfigurować hasło urządzenia lub blokadę ekranu w ustawieniach systemu."), diff --git a/mobile/lib/generated/intl/messages_pt.dart b/mobile/lib/generated/intl/messages_pt.dart index e1d4cc0f84..759186362c 100644 --- a/mobile/lib/generated/intl/messages_pt.dart +++ b/mobile/lib/generated/intl/messages_pt.dart @@ -166,7 +166,7 @@ class MessageLookup extends MessageLookupByLibrary { "Ei, você pode confirmar que este é seu ID de verificação do Ente.io? ${verificationID}"; static String m52(referralCode, referralStorageInGB) => - "Código de referência do ente: ${referralCode} \n\nAplique em Configurações → Geral → Indicações para obter ${referralStorageInGB} GB gratuitamente após a sua inscrição em um plano pago\n\nhttps://ente.io"; + "Código de indicação do Ente: ${referralCode} \n\nAplique em Configurações → Geral → Indicações para obter ${referralStorageInGB} GB gratuitamente após a sua inscrição em um plano pago\n\nhttps://ente.io"; static String m53(numberOfPeople) => "${Intl.plural(numberOfPeople, zero: 'Compartilhe com pessoas específicas', one: 'Compartilhado com 1 pessoa', other: 'Compartilhado com ${numberOfPeople} pessoas')}"; @@ -310,7 +310,7 @@ class MessageLookup extends MessageLookupByLibrary { MessageLookupByLibrary.simpleMessage("Autenticação necessária"), "appLock": MessageLookupByLibrary.simpleMessage("Bloqueio de app"), "appLockDescription": MessageLookupByLibrary.simpleMessage( - "Choose between your device\\\'s default lock screen and a custom lock screen with a PIN or password."), + "Escolha entre a tela de bloqueio padrão do seu dispositivo e uma tela de bloqueio personalizada com PIN ou senha."), "appVersion": m7, "appleId": MessageLookupByLibrary.simpleMessage("ID da Apple"), "apply": MessageLookupByLibrary.simpleMessage("Aplicar"), @@ -376,9 +376,9 @@ class MessageLookup extends MessageLookupByLibrary { "Você verá dispositivos disponíveis para transmitir aqui."), "autoCastiOSPermission": MessageLookupByLibrary.simpleMessage( "Certifique-se de que as permissões de Rede local estão ativadas para o aplicativo de Fotos Ente, em Configurações."), - "autoLock": MessageLookupByLibrary.simpleMessage("Auto lock"), + "autoLock": MessageLookupByLibrary.simpleMessage("Bloqueio automático"), "autoLockFeatureDescription": MessageLookupByLibrary.simpleMessage( - "Time after which the app locks after being put in the background"), + "Tempo após o qual o app bloqueia depois de ser colocado em segundo plano"), "autoLogoutMessage": MessageLookupByLibrary.simpleMessage( "Devido a erros técnicos, você foi desconectado. Pedimos desculpas pelo inconveniente."), "autoPair": @@ -615,7 +615,7 @@ class MessageLookup extends MessageLookupByLibrary { "deviceFilesAutoUploading": MessageLookupByLibrary.simpleMessage( "Arquivos adicionados a este álbum do dispositivo serão automaticamente enviados para o Ente."), "deviceLock": - MessageLookupByLibrary.simpleMessage("Bloqueio de dispositivo"), + MessageLookupByLibrary.simpleMessage("Bloqueio do dispositivo"), "deviceLockExplanation": MessageLookupByLibrary.simpleMessage( "Desative o bloqueio de tela do dispositivo quando o Ente estiver em primeiro plano e houver um backup em andamento. Isso normalmente não é necessário, mas pode ajudar nos envios grandes e importações iniciais de grandes bibliotecas a serem concluídos mais rapidamente."), "deviceNotFound": @@ -667,9 +667,9 @@ class MessageLookup extends MessageLookupByLibrary { "emailChangedTo": m22, "emailNoEnteAccount": m23, "emailVerificationToggle": - MessageLookupByLibrary.simpleMessage("Verificação de e-mail"), + MessageLookupByLibrary.simpleMessage("Verificação por e-mail"), "emailYourLogs": - MessageLookupByLibrary.simpleMessage("Enviar por email seus logs"), + MessageLookupByLibrary.simpleMessage("Enviar logs por e-mail"), "empty": MessageLookupByLibrary.simpleMessage("Esvaziar"), "emptyTrash": MessageLookupByLibrary.simpleMessage("Esvaziar a lixeira?"), @@ -711,7 +711,7 @@ class MessageLookup extends MessageLookupByLibrary { MessageLookupByLibrary.simpleMessage("Inserir nome da pessoa"), "enterPin": MessageLookupByLibrary.simpleMessage("Insira o PIN"), "enterReferralCode": MessageLookupByLibrary.simpleMessage( - "Insira o código de referência"), + "Insira o código de indicação"), "enterThe6digitCodeFromnyourAuthenticatorApp": MessageLookupByLibrary.simpleMessage( "Digite o código de 6 dígitos de\nseu aplicativo autenticador"), @@ -746,7 +746,7 @@ class MessageLookup extends MessageLookupByLibrary { "failedToFetchOriginalForEdit": MessageLookupByLibrary.simpleMessage( "Falha ao obter original para edição"), "failedToFetchReferralDetails": MessageLookupByLibrary.simpleMessage( - "Não foi possível buscar os detalhes de referência. Por favor, tente novamente mais tarde."), + "Não foi possível buscar os detalhes de indicação. Por favor, tente novamente mais tarde."), "failedToLoadAlbums": MessageLookupByLibrary.simpleMessage("Falha ao carregar álbuns"), "failedToRenew": @@ -824,6 +824,11 @@ class MessageLookup extends MessageLookupByLibrary { "help": MessageLookupByLibrary.simpleMessage("Ajuda"), "hidden": MessageLookupByLibrary.simpleMessage("Oculto"), "hide": MessageLookupByLibrary.simpleMessage("Ocultar"), + "hideContent": MessageLookupByLibrary.simpleMessage("Ocultar conteúdo"), + "hideContentDescriptionAndroid": MessageLookupByLibrary.simpleMessage( + "Oculta o conteúdo do app no seletor de apps e desativa as capturas de tela"), + "hideContentDescriptionIos": MessageLookupByLibrary.simpleMessage( + "Oculta o conteúdo do seletor de apps"), "hiding": MessageLookupByLibrary.simpleMessage("Ocultando..."), "hostedAtOsmFrance": MessageLookupByLibrary.simpleMessage("Hospedado na OSM France"), @@ -838,7 +843,7 @@ class MessageLookup extends MessageLookupByLibrary { "ignoreUpdate": MessageLookupByLibrary.simpleMessage("Ignorar"), "ignoredFolderUploadReason": MessageLookupByLibrary.simpleMessage( "Alguns arquivos neste álbum são ignorados do envio porque eles tinham sido anteriormente excluídos do Ente."), - "immediately": MessageLookupByLibrary.simpleMessage("Immediately"), + "immediately": MessageLookupByLibrary.simpleMessage("Imediatamente"), "importing": MessageLookupByLibrary.simpleMessage("Importando...."), "incorrectCode": MessageLookupByLibrary.simpleMessage("Código incorreto"), @@ -946,14 +951,14 @@ class MessageLookup extends MessageLookupByLibrary { "locations": MessageLookupByLibrary.simpleMessage("Locais"), "lockButtonLabel": MessageLookupByLibrary.simpleMessage("Bloquear"), "lockscreen": MessageLookupByLibrary.simpleMessage("Tela de bloqueio"), - "logInLabel": MessageLookupByLibrary.simpleMessage("Login"), + "logInLabel": MessageLookupByLibrary.simpleMessage("Entrar"), "loggingOut": MessageLookupByLibrary.simpleMessage("Desconectando..."), "loginSessionExpired": MessageLookupByLibrary.simpleMessage("Sessão expirada"), "loginSessionExpiredDetails": MessageLookupByLibrary.simpleMessage( "Sua sessão expirou. Por favor, entre novamente."), "loginTerms": MessageLookupByLibrary.simpleMessage( - "Ao clicar em login, eu concordo com os termos de serviço e a política de privacidade"), + "Ao clicar em entrar, eu concordo com os termos de serviço e a política de privacidade"), "logout": MessageLookupByLibrary.simpleMessage("Encerrar sessão"), "logsDialogBody": MessageLookupByLibrary.simpleMessage( "Isso enviará através dos logs para nos ajudar a depurar o seu problema. Por favor, note que nomes de arquivos serão incluídos para ajudar a rastrear problemas com arquivos específicos."), @@ -1037,6 +1042,8 @@ class MessageLookup extends MessageLookupByLibrary { "No momento não há backup de fotos sendo feito"), "noPhotosFoundHere": MessageLookupByLibrary.simpleMessage( "Nenhuma foto encontrada aqui"), + "noQuickLinksSelected": + MessageLookupByLibrary.simpleMessage("No quick links selected"), "noRecoveryKey": MessageLookupByLibrary.simpleMessage( "Nenhuma chave de recuperação?"), "noRecoveryKeyNoDecryption": MessageLookupByLibrary.simpleMessage( @@ -1074,6 +1081,7 @@ class MessageLookup extends MessageLookupByLibrary { "pairWithPin": MessageLookupByLibrary.simpleMessage("Parear com PIN"), "pairingComplete": MessageLookupByLibrary.simpleMessage("Pareamento concluído"), + "panorama": MessageLookupByLibrary.simpleMessage("Panorama"), "passKeyPendingVerification": MessageLookupByLibrary.simpleMessage( "A verificação ainda está pendente"), "passkey": MessageLookupByLibrary.simpleMessage("Chave de acesso"), @@ -1083,8 +1091,10 @@ class MessageLookup extends MessageLookupByLibrary { "passwordChangedSuccessfully": MessageLookupByLibrary.simpleMessage("Senha alterada com sucesso"), "passwordLock": - MessageLookupByLibrary.simpleMessage("Bloqueio de senha"), + MessageLookupByLibrary.simpleMessage("Bloqueio por senha"), "passwordStrength": m38, + "passwordStrengthInfo": MessageLookupByLibrary.simpleMessage( + "Força da senha é calculada considerando o comprimento da senha, caracteres usados, e se a senha aparece ou não nas 10.000 senhas mais usadas"), "passwordWarning": MessageLookupByLibrary.simpleMessage( "Nós não salvamos essa senha, se você esquecer nós não poderemos descriptografar seus dados"), "paymentDetails": @@ -1137,7 +1147,9 @@ class MessageLookup extends MessageLookupByLibrary { "pleaseGrantPermissions": MessageLookupByLibrary.simpleMessage( "Por favor, conceda as permissões"), "pleaseLoginAgain": MessageLookupByLibrary.simpleMessage( - "Por favor, faça login novamente"), + "Por favor, inicie sessão novamente"), + "pleaseSelectQuickLinksToRemove": MessageLookupByLibrary.simpleMessage( + "Please select quick links to remove"), "pleaseSendTheLogsTo": m42, "pleaseTryAgain": MessageLookupByLibrary.simpleMessage("Por favor, tente novamente"), @@ -1197,7 +1209,7 @@ class MessageLookup extends MessageLookupByLibrary { "recoverySuccessful": MessageLookupByLibrary.simpleMessage("Recuperação bem sucedida!"), "recreatePasswordBody": MessageLookupByLibrary.simpleMessage( - "O dispositivo atual não é poderoso o suficiente para verificar sua senha, mas podemos regenerar de uma forma que funcione com todos os dispositivos.\n\nPor favor, faça o login usando sua chave de recuperação e recrie sua senha (você pode usar o mesmo novamente se desejar)."), + "O dispositivo atual não é poderoso o suficiente para verificar sua senha, mas podemos regenerar de uma forma que funcione com todos os dispositivos.\n\nPor favor, inicie sessão usando sua chave de recuperação e recrie sua senha (você pode usar o mesmo novamente se desejar)."), "recreatePasswordTitle": MessageLookupByLibrary.simpleMessage("Redefinir senha"), "reddit": MessageLookupByLibrary.simpleMessage("Reddit"), @@ -1209,11 +1221,11 @@ class MessageLookup extends MessageLookupByLibrary { "referralStep1": MessageLookupByLibrary.simpleMessage( "Envie esse código aos seus amigos"), "referralStep2": MessageLookupByLibrary.simpleMessage( - "2. Eles se inscreveram para um plano pago"), + "2. Eles se inscrevem em um plano pago"), "referralStep3": m44, - "referrals": MessageLookupByLibrary.simpleMessage("Referências"), + "referrals": MessageLookupByLibrary.simpleMessage("Indicações"), "referralsAreCurrentlyPaused": MessageLookupByLibrary.simpleMessage( - "Referências estão atualmente pausadas"), + "Indicações estão atualmente pausadas"), "remindToEmptyDeviceTrash": MessageLookupByLibrary.simpleMessage( "Também vazio \"Excluído Recentemente\" de \"Configurações\" -> \"Armazenamento\" para reivindicar o espaço livre"), "remindToEmptyEnteTrash": MessageLookupByLibrary.simpleMessage( @@ -1241,6 +1253,8 @@ class MessageLookup extends MessageLookupByLibrary { MessageLookupByLibrary.simpleMessage("Remover etiqueta da pessoa"), "removePublicLink": MessageLookupByLibrary.simpleMessage("Remover link público"), + "removePublicLinks": + MessageLookupByLibrary.simpleMessage("Remove public links"), "removeShareItemsWarning": MessageLookupByLibrary.simpleMessage( "Alguns dos itens que você está removendo foram adicionados por outras pessoas, e você perderá o acesso a eles"), "removeWithQuestionMark": @@ -1422,7 +1436,7 @@ class MessageLookup extends MessageLookupByLibrary { "Eu concordo com os termos de serviço e a política de privacidade"), "singleFileDeleteFromDevice": m55, "singleFileDeleteHighlight": MessageLookupByLibrary.simpleMessage( - "Será excluído de todos os álbuns."), + "Ele será excluído de todos os álbuns."), "singleFileInBothLocalAndRemote": m56, "singleFileInRemoteOnly": m57, "skip": MessageLookupByLibrary.simpleMessage("Pular"), @@ -1452,7 +1466,7 @@ class MessageLookup extends MessageLookupByLibrary { "Desculpe, o código que você inseriu está incorreto"), "sorryWeCouldNotGenerateSecureKeysOnThisDevicennplease": MessageLookupByLibrary.simpleMessage( - "Desculpe, não foi possível gerar chaves seguras neste dispositivo.\n\npor favor, faça o login com um dispositivo diferente."), + "Desculpe, não foi possível gerar chaves seguras neste dispositivo.\n\npor favor, inicie sessão com um dispositivo diferente."), "sortAlbumsBy": MessageLookupByLibrary.simpleMessage("Ordenar por"), "sortNewestFirst": MessageLookupByLibrary.simpleMessage("Mais recentes primeiro"), @@ -1493,7 +1507,7 @@ class MessageLookup extends MessageLookupByLibrary { MessageLookupByLibrary.simpleMessage("Sugerir recurso"), "support": MessageLookupByLibrary.simpleMessage("Suporte"), "swipeLockEnablePreSteps": MessageLookupByLibrary.simpleMessage( - "To enable swipe lock, please setup device passcode or screen lock in your system settings."), + "Para ativar o bloqueio por deslizar, por favor ative um método de autenticação nas configurações do sistema do seu dispositivo."), "syncProgress": m62, "syncStopped": MessageLookupByLibrary.simpleMessage("Sincronização interrompida"), @@ -1525,7 +1539,7 @@ class MessageLookup extends MessageLookupByLibrary { "Estes itens serão excluídos do seu dispositivo."), "theyAlsoGetXGb": m63, "theyWillBeDeletedFromAllAlbums": MessageLookupByLibrary.simpleMessage( - "Eles(a) serão excluídos(as) de todos os álbuns."), + "Eles serão excluídos de todos os álbuns."), "thisActionCannotBeUndone": MessageLookupByLibrary.simpleMessage( "Esta ação não pode ser desfeita"), "thisAlbumAlreadyHDACollaborativeLink": @@ -1547,6 +1561,9 @@ class MessageLookup extends MessageLookupByLibrary { "Isso fará com que você saia do seguinte dispositivo:"), "thisWillLogYouOutOfThisDevice": MessageLookupByLibrary.simpleMessage( "Isso fará com que você saia deste dispositivo!"), + "thisWillRemovePublicLinksOfAllSelectedQuickLinks": + MessageLookupByLibrary.simpleMessage( + "This will remove public links of all selected quick links."), "toEnableAppLockPleaseSetupDevicePasscodeOrScreen": MessageLookupByLibrary.simpleMessage( "Para ativar o bloqueio de app, por favor ative um método de autenticação nas configurações do sistema do seu dispositivo."), @@ -1632,6 +1649,8 @@ class MessageLookup extends MessageLookupByLibrary { "verifying": MessageLookupByLibrary.simpleMessage("Verificando..."), "verifyingRecoveryKey": MessageLookupByLibrary.simpleMessage( "Verificando chave de recuperação..."), + "videoInfo": + MessageLookupByLibrary.simpleMessage("Informação de Vídeo"), "videoSmallCase": MessageLookupByLibrary.simpleMessage("vídeo"), "videos": MessageLookupByLibrary.simpleMessage("Vídeos"), "viewActiveSessions": diff --git a/mobile/lib/generated/intl/messages_ru.dart b/mobile/lib/generated/intl/messages_ru.dart index 3f8000c325..d40dc04957 100644 --- a/mobile/lib/generated/intl/messages_ru.dart +++ b/mobile/lib/generated/intl/messages_ru.dart @@ -818,6 +818,11 @@ class MessageLookup extends MessageLookupByLibrary { "help": MessageLookupByLibrary.simpleMessage("помощь"), "hidden": MessageLookupByLibrary.simpleMessage("Скрыто"), "hide": MessageLookupByLibrary.simpleMessage("Скрыть"), + "hideContent": MessageLookupByLibrary.simpleMessage("Hide content"), + "hideContentDescriptionAndroid": MessageLookupByLibrary.simpleMessage( + "Hides app content in the app switcher and disables screenshots"), + "hideContentDescriptionIos": MessageLookupByLibrary.simpleMessage( + "Hides app content in the app switcher"), "hiding": MessageLookupByLibrary.simpleMessage("Скрытие..."), "hostedAtOsmFrance": MessageLookupByLibrary.simpleMessage("Размещено на OSM France"), @@ -1037,6 +1042,8 @@ class MessageLookup extends MessageLookupByLibrary { "На данный момент резервных копий нет"), "noPhotosFoundHere": MessageLookupByLibrary.simpleMessage("Здесь нет фотографий"), + "noQuickLinksSelected": + MessageLookupByLibrary.simpleMessage("No quick links selected"), "noRecoveryKey": MessageLookupByLibrary.simpleMessage("Нет ключа восстановления?"), "noRecoveryKeyNoDecryption": MessageLookupByLibrary.simpleMessage( @@ -1085,6 +1092,8 @@ class MessageLookup extends MessageLookupByLibrary { "passwordLock": MessageLookupByLibrary.simpleMessage("Блокировка паролем"), "passwordStrength": m38, + "passwordStrengthInfo": MessageLookupByLibrary.simpleMessage( + "Password strength is calculated considering the length of the password, used characters, and whether or not the password appears in the top 10,000 most used passwords"), "passwordWarning": MessageLookupByLibrary.simpleMessage( "Мы не храним этот пароль, поэтому если вы забудете его, мы не сможем расшифровать ваши данные"), "paymentDetails": @@ -1138,6 +1147,8 @@ class MessageLookup extends MessageLookupByLibrary { MessageLookupByLibrary.simpleMessage("Предоставьте разрешение"), "pleaseLoginAgain": MessageLookupByLibrary.simpleMessage("Пожалуйста, войдите снова"), + "pleaseSelectQuickLinksToRemove": MessageLookupByLibrary.simpleMessage( + "Please select quick links to remove"), "pleaseSendTheLogsTo": m42, "pleaseTryAgain": MessageLookupByLibrary.simpleMessage( "Пожалуйста, попробуйте ещё раз"), @@ -1242,6 +1253,8 @@ class MessageLookup extends MessageLookupByLibrary { MessageLookupByLibrary.simpleMessage("Удалить метку человека"), "removePublicLink": MessageLookupByLibrary.simpleMessage("Удалить публичную ссылку"), + "removePublicLinks": + MessageLookupByLibrary.simpleMessage("Remove public links"), "removeShareItemsWarning": MessageLookupByLibrary.simpleMessage( "Некоторые элементы, которые вы удаляете, были добавлены другими людьми, и вы потеряете к ним доступ"), "removeWithQuestionMark": @@ -1545,6 +1558,9 @@ class MessageLookup extends MessageLookupByLibrary { "Вы выйдете из списка следующих устройств:"), "thisWillLogYouOutOfThisDevice": MessageLookupByLibrary.simpleMessage( "Совершив это действие, Вы выйдете из своей учетной записи!"), + "thisWillRemovePublicLinksOfAllSelectedQuickLinks": + MessageLookupByLibrary.simpleMessage( + "This will remove public links of all selected quick links."), "toEnableAppLockPleaseSetupDevicePasscodeOrScreen": MessageLookupByLibrary.simpleMessage( "To enable app lock, please setup device passcode or screen lock in your system settings."), diff --git a/mobile/lib/generated/intl/messages_tr.dart b/mobile/lib/generated/intl/messages_tr.dart index 70d51801a1..dbfa3d4f83 100644 --- a/mobile/lib/generated/intl/messages_tr.dart +++ b/mobile/lib/generated/intl/messages_tr.dart @@ -820,6 +820,11 @@ class MessageLookup extends MessageLookupByLibrary { "help": MessageLookupByLibrary.simpleMessage("Yardım"), "hidden": MessageLookupByLibrary.simpleMessage("Gizle"), "hide": MessageLookupByLibrary.simpleMessage("Gizle"), + "hideContent": MessageLookupByLibrary.simpleMessage("Hide content"), + "hideContentDescriptionAndroid": MessageLookupByLibrary.simpleMessage( + "Hides app content in the app switcher and disables screenshots"), + "hideContentDescriptionIos": MessageLookupByLibrary.simpleMessage( + "Hides app content in the app switcher"), "hiding": MessageLookupByLibrary.simpleMessage("Gizleniyor..."), "hostedAtOsmFrance": MessageLookupByLibrary.simpleMessage("OSM Fransa\'da ağırlandı"), @@ -1033,6 +1038,8 @@ class MessageLookup extends MessageLookupByLibrary { "Şu anda hiçbir fotoğraf yedeklenmiyor"), "noPhotosFoundHere": MessageLookupByLibrary.simpleMessage("Burada fotoğraf bulunamadı"), + "noQuickLinksSelected": + MessageLookupByLibrary.simpleMessage("No quick links selected"), "noRecoveryKey": MessageLookupByLibrary.simpleMessage("Kurtarma kodunuz yok mu?"), "noRecoveryKeyNoDecryption": MessageLookupByLibrary.simpleMessage( @@ -1079,6 +1086,8 @@ class MessageLookup extends MessageLookupByLibrary { "Şifreniz başarılı bir şekilde değiştirildi"), "passwordLock": MessageLookupByLibrary.simpleMessage("Sifre kilidi"), "passwordStrength": m38, + "passwordStrengthInfo": MessageLookupByLibrary.simpleMessage( + "Password strength is calculated considering the length of the password, used characters, and whether or not the password appears in the top 10,000 most used passwords"), "passwordWarning": MessageLookupByLibrary.simpleMessage( "Şifrelerinizi saklamıyoruz, bu yüzden unutursanız, verilerinizi deşifre edemeyiz"), "paymentDetails": @@ -1131,6 +1140,8 @@ class MessageLookup extends MessageLookupByLibrary { MessageLookupByLibrary.simpleMessage("Lütfen izin ver"), "pleaseLoginAgain": MessageLookupByLibrary.simpleMessage("Lütfen tekrar giriş yapın"), + "pleaseSelectQuickLinksToRemove": MessageLookupByLibrary.simpleMessage( + "Please select quick links to remove"), "pleaseSendTheLogsTo": m42, "pleaseTryAgain": MessageLookupByLibrary.simpleMessage("Lütfen tekrar deneyiniz"), @@ -1234,6 +1245,8 @@ class MessageLookup extends MessageLookupByLibrary { MessageLookupByLibrary.simpleMessage("Remove person label"), "removePublicLink": MessageLookupByLibrary.simpleMessage("Herkese açık link oluştur"), + "removePublicLinks": + MessageLookupByLibrary.simpleMessage("Remove public links"), "removeShareItemsWarning": MessageLookupByLibrary.simpleMessage( "Kaldırdığınız öğelerden bazıları başkaları tarafından eklenmiştir ve bunlara erişiminizi kaybedeceksiniz"), "removeWithQuestionMark": @@ -1532,6 +1545,9 @@ class MessageLookup extends MessageLookupByLibrary { "Bu, sizi aşağıdaki cihazdan çıkış yapacak:"), "thisWillLogYouOutOfThisDevice": MessageLookupByLibrary.simpleMessage( "Bu cihazdaki oturumunuz kapatılacak!"), + "thisWillRemovePublicLinksOfAllSelectedQuickLinks": + MessageLookupByLibrary.simpleMessage( + "This will remove public links of all selected quick links."), "toEnableAppLockPleaseSetupDevicePasscodeOrScreen": MessageLookupByLibrary.simpleMessage( "To enable app lock, please setup device passcode or screen lock in your system settings."), diff --git a/mobile/lib/generated/intl/messages_zh.dart b/mobile/lib/generated/intl/messages_zh.dart index 95fa87bcca..1c6515e8c6 100644 --- a/mobile/lib/generated/intl/messages_zh.dart +++ b/mobile/lib/generated/intl/messages_zh.dart @@ -272,7 +272,7 @@ class MessageLookup extends MessageLookupByLibrary { "androidSignInTitle": MessageLookupByLibrary.simpleMessage("需要身份验证"), "appLock": MessageLookupByLibrary.simpleMessage("应用锁"), "appLockDescription": MessageLookupByLibrary.simpleMessage( - "Choose between your device\\\'s default lock screen and a custom lock screen with a PIN or password."), + "在设备的默认锁定屏幕和带有 PIN 或密码的自定义锁定屏幕之间进行选择。"), "appVersion": m7, "appleId": MessageLookupByLibrary.simpleMessage("Apple ID"), "apply": MessageLookupByLibrary.simpleMessage("应用"), @@ -330,9 +330,9 @@ class MessageLookup extends MessageLookupByLibrary { MessageLookupByLibrary.simpleMessage("您将在此处看到可用的 Cast 设备。"), "autoCastiOSPermission": MessageLookupByLibrary.simpleMessage( "请确保已在“设置”中为 Ente Photos 应用打开本地网络权限。"), - "autoLock": MessageLookupByLibrary.simpleMessage("Auto lock"), - "autoLockFeatureDescription": MessageLookupByLibrary.simpleMessage( - "Time after which the app locks after being put in the background"), + "autoLock": MessageLookupByLibrary.simpleMessage("自动锁定"), + "autoLockFeatureDescription": + MessageLookupByLibrary.simpleMessage("应用程序进入后台后锁定的时间"), "autoLogoutMessage": MessageLookupByLibrary.simpleMessage( "由于技术故障,您已退出登录。对于由此造成的不便,我们深表歉意。"), "autoPair": MessageLookupByLibrary.simpleMessage("自动配对"), @@ -676,6 +676,11 @@ class MessageLookup extends MessageLookupByLibrary { "help": MessageLookupByLibrary.simpleMessage("帮助"), "hidden": MessageLookupByLibrary.simpleMessage("已隐藏"), "hide": MessageLookupByLibrary.simpleMessage("隐藏"), + "hideContent": MessageLookupByLibrary.simpleMessage("隐藏内容"), + "hideContentDescriptionAndroid": + MessageLookupByLibrary.simpleMessage("在应用切换器中隐藏应用内容并禁用屏幕截图"), + "hideContentDescriptionIos": + MessageLookupByLibrary.simpleMessage("在应用切换器中隐藏应用内容"), "hiding": MessageLookupByLibrary.simpleMessage("正在隐藏..."), "hostedAtOsmFrance": MessageLookupByLibrary.simpleMessage("法国 OSM 主办"), "howItWorks": MessageLookupByLibrary.simpleMessage("工作原理"), @@ -689,7 +694,7 @@ class MessageLookup extends MessageLookupByLibrary { "ignoreUpdate": MessageLookupByLibrary.simpleMessage("忽略"), "ignoredFolderUploadReason": MessageLookupByLibrary.simpleMessage( "此相册中的某些文件在上传时会被忽略,因为它们之前已从 Ente 中删除。"), - "immediately": MessageLookupByLibrary.simpleMessage("Immediately"), + "immediately": MessageLookupByLibrary.simpleMessage("立即"), "importing": MessageLookupByLibrary.simpleMessage("正在导入..."), "incorrectCode": MessageLookupByLibrary.simpleMessage("代码错误"), "incorrectPasswordTitle": MessageLookupByLibrary.simpleMessage("密码错误"), @@ -853,6 +858,8 @@ class MessageLookup extends MessageLookupByLibrary { "noPhotosAreBeingBackedUpRightNow": MessageLookupByLibrary.simpleMessage("目前没有照片正在备份"), "noPhotosFoundHere": MessageLookupByLibrary.simpleMessage("这里没有找到照片"), + "noQuickLinksSelected": + MessageLookupByLibrary.simpleMessage("No quick links selected"), "noRecoveryKey": MessageLookupByLibrary.simpleMessage("没有恢复密钥吗?"), "noRecoveryKeyNoDecryption": MessageLookupByLibrary.simpleMessage( "由于我们端到端加密协议的性质,如果没有您的密码或恢复密钥,您的数据将无法解密"), @@ -894,6 +901,8 @@ class MessageLookup extends MessageLookupByLibrary { MessageLookupByLibrary.simpleMessage("密码修改成功"), "passwordLock": MessageLookupByLibrary.simpleMessage("密码锁"), "passwordStrength": m38, + "passwordStrengthInfo": MessageLookupByLibrary.simpleMessage( + "密码强度的计算考虑了密码的长度、使用的字符以及密码是否出现在最常用的 10,000 个密码中"), "passwordWarning": MessageLookupByLibrary.simpleMessage( "我们不储存这个密码,所以如果忘记, 我们将无法解密您的数据"), "paymentDetails": MessageLookupByLibrary.simpleMessage("付款明细"), @@ -933,6 +942,8 @@ class MessageLookup extends MessageLookupByLibrary { "pleaseEmailUsAt": m41, "pleaseGrantPermissions": MessageLookupByLibrary.simpleMessage("请授予权限"), "pleaseLoginAgain": MessageLookupByLibrary.simpleMessage("请重新登录"), + "pleaseSelectQuickLinksToRemove": MessageLookupByLibrary.simpleMessage( + "Please select quick links to remove"), "pleaseSendTheLogsTo": m42, "pleaseTryAgain": MessageLookupByLibrary.simpleMessage("请重试"), "pleaseVerifyTheCodeYouHaveEntered": @@ -1010,6 +1021,8 @@ class MessageLookup extends MessageLookupByLibrary { "removeParticipantBody": m45, "removePersonLabel": MessageLookupByLibrary.simpleMessage("移除人物标签"), "removePublicLink": MessageLookupByLibrary.simpleMessage("删除公开链接"), + "removePublicLinks": + MessageLookupByLibrary.simpleMessage("Remove public links"), "removeShareItemsWarning": MessageLookupByLibrary.simpleMessage("您要删除的某些项目是由其他人添加的,您将无法访问它们"), "removeWithQuestionMark": MessageLookupByLibrary.simpleMessage("要移除吗?"), @@ -1201,8 +1214,8 @@ class MessageLookup extends MessageLookupByLibrary { "successfullyUnhid": MessageLookupByLibrary.simpleMessage("已成功取消隐藏"), "suggestFeatures": MessageLookupByLibrary.simpleMessage("建议新功能"), "support": MessageLookupByLibrary.simpleMessage("支持"), - "swipeLockEnablePreSteps": MessageLookupByLibrary.simpleMessage( - "To enable swipe lock, please setup device passcode or screen lock in your system settings."), + "swipeLockEnablePreSteps": + MessageLookupByLibrary.simpleMessage("要启用滑动锁定,请在系统设置中设置设备密码或屏幕锁。"), "syncProgress": m62, "syncStopped": MessageLookupByLibrary.simpleMessage("同步已停止"), "syncing": MessageLookupByLibrary.simpleMessage("正在同步···"), @@ -1248,6 +1261,9 @@ class MessageLookup extends MessageLookupByLibrary { MessageLookupByLibrary.simpleMessage("这将使您在以下设备中退出登录:"), "thisWillLogYouOutOfThisDevice": MessageLookupByLibrary.simpleMessage("这将使您在此设备上退出登录!"), + "thisWillRemovePublicLinksOfAllSelectedQuickLinks": + MessageLookupByLibrary.simpleMessage( + "This will remove public links of all selected quick links."), "toEnableAppLockPleaseSetupDevicePasscodeOrScreen": MessageLookupByLibrary.simpleMessage("要启用应用锁,请在系统设置中设置设备密码或屏幕锁定。"), "toHideAPhotoOrVideo": MessageLookupByLibrary.simpleMessage("隐藏照片或视频"), diff --git a/mobile/lib/generated/l10n.dart b/mobile/lib/generated/l10n.dart index 563dec0669..aa8a943c79 100644 --- a/mobile/lib/generated/l10n.dart +++ b/mobile/lib/generated/l10n.dart @@ -5157,10 +5157,10 @@ class S { ); } - /// `Remove from favorite` + /// `Remove from favorites` String get removeFromFavorite { return Intl.message( - 'Remove from favorite', + 'Remove from favorites', name: 'removeFromFavorite', desc: '', args: [], @@ -9174,6 +9174,86 @@ class S { args: [], ); } + + /// `Hide content` + String get hideContent { + return Intl.message( + 'Hide content', + name: 'hideContent', + desc: '', + args: [], + ); + } + + /// `Hides app content in the app switcher and disables screenshots` + String get hideContentDescriptionAndroid { + return Intl.message( + 'Hides app content in the app switcher and disables screenshots', + name: 'hideContentDescriptionAndroid', + desc: '', + args: [], + ); + } + + /// `Hides app content in the app switcher` + String get hideContentDescriptionIos { + return Intl.message( + 'Hides app content in the app switcher', + name: 'hideContentDescriptionIos', + desc: '', + args: [], + ); + } + + /// `Password strength is calculated considering the length of the password, used characters, and whether or not the password appears in the top 10,000 most used passwords` + String get passwordStrengthInfo { + return Intl.message( + 'Password strength is calculated considering the length of the password, used characters, and whether or not the password appears in the top 10,000 most used passwords', + name: 'passwordStrengthInfo', + desc: '', + args: [], + ); + } + + /// `No quick links selected` + String get noQuickLinksSelected { + return Intl.message( + 'No quick links selected', + name: 'noQuickLinksSelected', + desc: '', + args: [], + ); + } + + /// `Please select quick links to remove` + String get pleaseSelectQuickLinksToRemove { + return Intl.message( + 'Please select quick links to remove', + name: 'pleaseSelectQuickLinksToRemove', + desc: '', + args: [], + ); + } + + /// `Remove public links` + String get removePublicLinks { + return Intl.message( + 'Remove public links', + name: 'removePublicLinks', + desc: '', + args: [], + ); + } + + /// `This will remove public links of all selected quick links.` + String get thisWillRemovePublicLinksOfAllSelectedQuickLinks { + return Intl.message( + 'This will remove public links of all selected quick links.', + name: 'thisWillRemovePublicLinksOfAllSelectedQuickLinks', + desc: '', + args: [], + ); + } } class AppLocalizationDelegate extends LocalizationsDelegate { diff --git a/mobile/lib/l10n/intl_cs.arb b/mobile/lib/l10n/intl_cs.arb index b6774421dd..1446c96bc3 100644 --- a/mobile/lib/l10n/intl_cs.arb +++ b/mobile/lib/l10n/intl_cs.arb @@ -45,5 +45,13 @@ "swipeLockEnablePreSteps": "To enable swipe lock, please setup device passcode or screen lock in your system settings.", "autoLock": "Auto lock", "immediately": "Immediately", - "autoLockFeatureDescription": "Time after which the app locks after being put in the background" + "autoLockFeatureDescription": "Time after which the app locks after being put in the background", + "hideContent": "Hide content", + "hideContentDescriptionAndroid": "Hides app content in the app switcher and disables screenshots", + "hideContentDescriptionIos": "Hides app content in the app switcher", + "passwordStrengthInfo": "Password strength is calculated considering the length of the password, used characters, and whether or not the password appears in the top 10,000 most used passwords", + "noQuickLinksSelected": "No quick links selected", + "pleaseSelectQuickLinksToRemove": "Please select quick links to remove", + "removePublicLinks": "Remove public links", + "thisWillRemovePublicLinksOfAllSelectedQuickLinks": "This will remove public links of all selected quick links." } \ No newline at end of file diff --git a/mobile/lib/l10n/intl_de.arb b/mobile/lib/l10n/intl_de.arb index 225e819836..54e1bb88a6 100644 --- a/mobile/lib/l10n/intl_de.arb +++ b/mobile/lib/l10n/intl_de.arb @@ -145,24 +145,24 @@ "verifyingRecoveryKey": "Wiederherstellungs-Schlüssel wird überprüft...", "recoveryKeyVerified": "Wiederherstellungs-Schlüssel überprüft", "recoveryKeySuccessBody": "Sehr gut! Dein Wiederherstellungsschlüssel ist gültig. Vielen Dank für die Verifizierung.\n\nBitte vergiss nicht eine Kopie des Wiederherstellungsschlüssels sicher aufzubewahren.", - "invalidRecoveryKey": "Der von Ihnen eingegebene Wiederherstellungsschlüssel ist nicht gültig. Bitte stellen Sie sicher das aus 24 Wörtern zusammen gesetzt ist und jedes dieser Worte richtig geschrieben wurde.\n\nSollten Sie den Wiederherstellungscode eingegeben haben, stellen Sie bitte sicher, dass dieser 64 Worte lang ist und ebenfall richtig geschrieben wurde.", + "invalidRecoveryKey": "Der eingegebene Wiederherstellungsschlüssel ist nicht gültig. Bitte stelle sicher, dass er aus 24 Wörtern zusammengesetzt ist und jedes dieser Worte richtig geschrieben wurde.\n\nSolltest du den Wiederherstellungscode eingegeben haben, stelle bitte sicher, dass dieser 64 Zeichen lang ist und ebenfalls richtig geschrieben wurde.", "invalidKey": "Ungültiger Schlüssel", "tryAgain": "Erneut versuchen", "viewRecoveryKey": "Wiederherstellungsschlüssel anzeigen", "confirmRecoveryKey": "Wiederherstellungsschlüssel bestätigen", - "recoveryKeyVerifyReason": "Ihr Wiederherstellungsschlüssel ist die einzige Möglichkeit Ihre Fotos wieder herzustellen, sollten Sie Ihr Passwort vergessen haben. Sie können diesen unter \"Einstellungen\" und dann \"Konto\" wieder finden.\n\nBitte geben Sie unten Ihren Wiederherstellungsschlüssel ein um sicher zu stellen, dass Sie ihn korrekt hinterlegt haben.", - "confirmYourRecoveryKey": "Bestätigen Sie ihren Wiederherstellungsschlüssel", + "recoveryKeyVerifyReason": "Dein Wiederherstellungsschlüssel ist die einzige Möglichkeit, deine Fotos wiederherzustellen, solltest du dein Passwort vergessen haben. Du kannst diesen unter \"Einstellungen\" und dann \"Konto\" wiederfinden.\n\nBitte gib unten deinen Wiederherstellungsschlüssel ein, um sicherzustellen, dass du ihn korrekt hinterlegt hast.", + "confirmYourRecoveryKey": "Bestätige deinen Wiederherstellungsschlüssel", "addViewer": "Album teilen", "addCollaborator": "Bearbeiter hinzufügen", "addANewEmail": "Neue E-Mail-Adresse hinzufügen", - "orPickAnExistingOne": "Oder eine Vorherige auswählen", + "orPickAnExistingOne": "Oder eine vorherige auswählen", "collaboratorsCanAddPhotosAndVideosToTheSharedAlbum": "Bearbeiter können Fotos & Videos zu dem geteilten Album hinzufügen.", "enterEmail": "E-Mail eingeben", "albumOwner": "Besitzer", "@albumOwner": { "description": "Role of the album owner" }, - "you": "Sie", + "you": "Du", "collaborator": "Bearbeiter", "addMore": "Mehr hinzufügen", "@addMore": { @@ -183,12 +183,12 @@ "@allowAddingPhotos": { "description": "Switch button to enable uploading photos to a public link" }, - "allowAddPhotosDescription": "Erlaube Nutzern mit diesem Link ebenfalls Fotos zu diesem geteilten Album hinzuzufügen.", + "allowAddPhotosDescription": "Erlaube Nutzern, mit diesem Link ebenfalls Fotos zu diesem geteilten Album hinzuzufügen.", "passwordLock": "Passwort Sperre", "disableDownloadWarningTitle": "Bitte beachten Sie:", "disableDownloadWarningBody": "Zuschauer können weiterhin Screenshots oder mit anderen externen Programmen Kopien der Bilder machen.", "allowDownloads": "Downloads erlauben", - "linkDeviceLimit": "Geräte Limit", + "linkDeviceLimit": "Geräte-Limit", "noDeviceLimit": "Keins", "@noDeviceLimit": { "description": "Text to indicate that there is limit on number of devices" @@ -197,7 +197,7 @@ "linkExpired": "Abgelaufen", "linkEnabled": "Aktiviert", "linkNeverExpires": "Niemals", - "expiredLinkInfo": "Dieser Link ist abgelaufen. Bitte wählen Sie ein neues Ablaufdatum oder deaktivieren Sie das Ablaufdatum des Links.", + "expiredLinkInfo": "Dieser Link ist abgelaufen. Bitte wähle ein neues Ablaufdatum oder deaktiviere das Ablaufdatum des Links.", "setAPassword": "Passwort setzen", "lockButtonLabel": "Sperren", "enterPassword": "Passwort eingeben", @@ -287,7 +287,7 @@ "inviteYourFriends": "Lade deine Freunde ein", "failedToFetchReferralDetails": "Die Weiterempfehlungs-Details können nicht abgerufen werden. Bitte versuche es später erneut.", "referralStep1": "1. Gib diesen Code an deine Freunde", - "referralStep2": "2. Sie schließen ein bezahltes Abo ab", + "referralStep2": "2. Du schließt ein bezahltes Abo ab", "referralStep3": "3. Ihr beide erhaltet {storageInGB} GB* kostenlos", "referralsAreCurrentlyPaused": "Einlösungen sind derzeit pausiert", "youCanAtMaxDoubleYourStorage": "* Du kannst deinen Speicher maximal verdoppeln", @@ -410,7 +410,7 @@ "manageDeviceStorage": "Gerätespeicher verwalten", "machineLearning": "Maschinelles Lernen", "magicSearch": "Magische Suche", - "mlIndexingDescription": "Bitte beachten Sie, dass Machine Learning zu einem höheren Bandbreiten- und Batterieverbrauch führt, bis alle Elemente indiziert sind.", + "mlIndexingDescription": "Bitte beachte, dass Machine Learning zu einem höheren Bandbreiten- und Batterieverbrauch führt, bis alle Elemente indiziert sind.", "loadingModel": "Lade Modelle herunter...", "waitingForWifi": "Warte auf WLAN...", "status": "Status", @@ -454,18 +454,18 @@ "checkForUpdates": "Nach Aktualisierungen suchen", "checkStatus": "Status überprüfen", "checking": "Wird geprüft...", - "youAreOnTheLatestVersion": "Sie sind auf der neuesten Version", + "youAreOnTheLatestVersion": "Du bist auf der neuesten Version", "account": "Konto", "manageSubscription": "Abonnement verwalten", "authToChangeYourEmail": "Bitte authentifizieren, um deine E-Mail-Adresse zu ändern", "changePassword": "Passwort ändern", "authToChangeYourPassword": "Bitte authentifizieren, um das Passwort zu ändern", "emailVerificationToggle": "E-Mail-Verifizierung", - "authToChangeEmailVerificationSetting": "Bitte Authentifizieren um die E-Mail Bestätigung zu ändern", + "authToChangeEmailVerificationSetting": "Bitte authentifizieren, um die E-Mail-Bestätigung zu ändern", "exportYourData": "Daten exportieren", "logout": "Ausloggen", "authToInitiateAccountDeletion": "Bitte authentifizieren, um die Löschung des Kontos einzuleiten", - "areYouSureYouWantToLogout": "Sind sie sicher, dass Sie sich abmelden wollen?", + "areYouSureYouWantToLogout": "Bist Du sicher, dass du dich abmelden möchtest?", "yesLogout": "Ja, ausloggen", "aNewVersionOfEnteIsAvailable": "Eine neue Version von Ente ist verfügbar.", "update": "Updaten", @@ -574,12 +574,12 @@ "freeTrialValidTill": "Kostenlose Demo verfügbar bis zum {endDate}", "validTill": "Gültig bis {endDate}", "addOnValidTill": "Dein {storageAmount} Add-on ist gültig bis {endDate}", - "playStoreFreeTrialValidTill": "Kostenlose Testversion gültig bis {endDate}.\nSie können anschließend ein bezahltes Paket auswählen.", - "subWillBeCancelledOn": "Ihr Abo endet am {endDate}", + "playStoreFreeTrialValidTill": "Kostenlose Testversion gültig bis {endDate}.\nDu kannst anschließend ein bezahltes Paket auswählen.", + "subWillBeCancelledOn": "Dein Abo endet am {endDate}", "subscription": "Abonnement", "paymentDetails": "Zahlungsdetails", "manageFamily": "Familiengruppe verwalten", - "contactToManageSubscription": "Bitte kontaktieren Sie uns über support@ente.io, um Ihr {provider} Abo zu verwalten.", + "contactToManageSubscription": "Bitte kontaktiere uns über support@ente.io, um dein {provider} Abo zu verwalten.", "renewSubscription": "Abonnement erneuern", "cancelSubscription": "Abonnement kündigen", "areYouSureYouWantToRenew": "Bist du sicher, dass du verlängern möchtest?", @@ -600,9 +600,9 @@ "type": "text" }, "confirmPlanChange": "Aboänderungen bestätigen", - "areYouSureYouWantToChangeYourPlan": "Sind Sie sicher, dass Sie Ihren Tarif ändern möchten?", - "youCannotDowngradeToThisPlan": "Sie können nicht auf diesen Tarif wechseln", - "cancelOtherSubscription": "Bitte kündigen Sie Ihr aktuelles Abo über {paymentProvider} zuerst", + "areYouSureYouWantToChangeYourPlan": "Bist du sicher, dass du deinen Tarif ändern möchtest?", + "youCannotDowngradeToThisPlan": "Du kannst nicht auf diesen Tarif wechseln", + "cancelOtherSubscription": "Bitte kündige dein aktuelles Abo über {paymentProvider} zuerst", "@cancelOtherSubscription": { "description": "The text to display when the user has an existing subscription from a different payment provider", "type": "text", @@ -613,20 +613,20 @@ } } }, - "optionalAsShortAsYouLike": "Bei Bedarf auch so kurz wie Sie wollen...", + "optionalAsShortAsYouLike": "Bei Bedarf auch so kurz wie du willst...", "send": "Absenden", - "askCancelReason": "Ihr Abonnement wurde gekündigt. Möchten Sie uns den Grund mitteilen?", + "askCancelReason": "Dein Abonnement wurde gekündigt. Möchtest du uns den Grund mitteilen?", "thankYouForSubscribing": "Danke fürs Abonnieren!", - "yourPurchaseWasSuccessful": "Ihr Einkauf war erfolgreich!", - "yourPlanWasSuccessfullyUpgraded": "Ihr Abo wurde erfolgreich aufgestuft", - "yourPlanWasSuccessfullyDowngraded": "Ihr Tarif wurde erfolgreich heruntergestuft", + "yourPurchaseWasSuccessful": "Dein Einkauf war erfolgreich", + "yourPlanWasSuccessfullyUpgraded": "Dein Abo wurde erfolgreich hochgestuft", + "yourPlanWasSuccessfullyDowngraded": "Dein Tarif wurde erfolgreich heruntergestuft", "yourSubscriptionWasUpdatedSuccessfully": "Dein Abonnement wurde erfolgreich aktualisiert.", "googlePlayId": "Google Play ID", "appleId": "Apple ID", "playstoreSubscription": "PlayStore Abo", "appstoreSubscription": "AppStore Abo", - "subAlreadyLinkedErrMessage": "Ihr {id} ist bereits mit einem anderen Ente-Konto verknüpft.\nWenn Sie Ihre {id} mit diesem Konto verwenden möchten, kontaktieren Sie bitte unseren Support", - "visitWebToManage": "Bitte rufen Sie \"web.ente.io\" auf um ihr Abo zu verwalten", + "subAlreadyLinkedErrMessage": "Dein {id} ist bereits mit einem anderen Ente-Konto verknüpft.\nWenn du deine {id} mit diesem Konto verwenden möchtest, kontaktiere bitte unseren Support", + "visitWebToManage": "Bitte rufe \"web.ente.io\" auf, um dein Abo zu verwalten", "couldNotUpdateSubscription": "Abo konnte nicht aktualisiert werden", "pleaseContactSupportAndWeWillBeHappyToHelp": "Bitte kontaktieren Sie uns über support@ente.io wo wir Ihnen gerne weiterhelfen.", "paymentFailed": "Zahlung fehlgeschlagen", @@ -672,7 +672,7 @@ "mobileWebDesktop": "Mobil, Web, Desktop", "newToEnte": "Neu bei Ente", "pleaseLoginAgain": "Bitte logge dich erneut ein", - "autoLogoutMessage": "Aufgrund technischer Störungen wurden Sie abgemeldet. Wir entschuldigen uns für die Unannehmlichkeiten.", + "autoLogoutMessage": "Du wurdest aufgrund technischer Störungen abgemeldet. Wir entschuldigen uns für die Unannehmlichkeiten.", "yourSubscriptionHasExpired": "Dein Abonnement ist abgelaufen", "storageLimitExceeded": "Speichergrenze überschritten", "upgrade": "Upgrade", @@ -688,7 +688,7 @@ "grantPermission": "Zugriff gewähren", "privateSharing": "Privates Teilen", "shareOnlyWithThePeopleYouWant": "Teile mit ausgewählten Personen", - "usePublicLinksForPeopleNotOnEnte": "Verwenden Sie öffentliche Links für Personen, die kein Ente-Konto haben", + "usePublicLinksForPeopleNotOnEnte": "Verwende öffentliche Links für Personen, die kein Ente-Konto haben", "allowPeopleToAddPhotos": "Erlaube anderen das Hinzufügen von Fotos", "shareAnAlbumNow": "Teile jetzt ein Album", "collectEventPhotos": "Gemeinsam Event-Fotos sammeln", @@ -729,7 +729,7 @@ "permanentlyDelete": "Dauerhaft löschen", "canOnlyCreateLinkForFilesOwnedByYou": "Sie können nur Links für Dateien erstellen, die Ihnen gehören", "publicLinkCreated": "Öffentlicher Link erstellt", - "youCanManageYourLinksInTheShareTab": "Sie können Ihre Links im \"Teilen\"-Tab verwalten.", + "youCanManageYourLinksInTheShareTab": "Du kannst deine Links im \"Teilen\"-Tab verwalten.", "linkCopiedToClipboard": "Link in Zwischenablage kopiert", "restore": "Wiederherstellen", "@restore": { @@ -775,7 +775,7 @@ "movedSuccessfullyTo": "Erfolgreich zu {albumName} hinzugefügt", "thisAlbumAlreadyHDACollaborativeLink": "Dieses Album hat bereits einen kollaborativen Link", "collaborativeLinkCreatedFor": "Kollaborativer Link für {albumName} erstellt", - "askYourLovedOnesToShare": "Bitte deine Liebsten ums teilen", + "askYourLovedOnesToShare": "Bitte deine Liebsten ums Teilen", "invite": "Einladen", "shareYourFirstAlbum": "Teile dein erstes Album", "sharedWith": "Geteilt mit {emailIDs}", @@ -1291,10 +1291,18 @@ "toEnableAppLockPleaseSetupDevicePasscodeOrScreen": "Um die App-Sperre zu aktivieren, konfigurieren Sie bitte den Gerätepasscode oder die Bildschirmsperre in Ihren Systemeinstellungen.", "tapToUnlock": "Zum Entsperren antippen", "tooManyIncorrectAttempts": "Zu viele fehlerhafte Versuche", - "appLockDescription": "Choose between your device\\'s default lock screen and a custom lock screen with a PIN or password.", - "swipeLockEnablePreSteps": "To enable swipe lock, please setup device passcode or screen lock in your system settings.", - "autoLock": "Auto lock", - "immediately": "Immediately", - "autoLockFeatureDescription": "Time after which the app locks after being put in the background", - "videoInfo": "Video-Informationen" + "videoInfo": "Video-Informationen", + "appLockDescription": "Wähle zwischen dem Standard-Sperrbildschirm deines Gerätes und einem eigenen Sperrbildschirm mit PIN oder Passwort.", + "swipeLockEnablePreSteps": "Um die Sperre für die Wischfunktion zu aktivieren, richte bitte einen Gerätepasscode oder eine Bildschirmsperre in den Systemeinstellungen ein.", + "autoLock": "Automatisches Sperren", + "immediately": "Sofort", + "autoLockFeatureDescription": "Zeit, nach der die App gesperrt wird, nachdem sie in den Hintergrund verschoben wurde", + "hideContent": "Inhalte verstecken", + "hideContentDescriptionAndroid": "Versteckt Inhalte der App beim Wechseln zwischen Apps und deaktiviert Screenshots", + "hideContentDescriptionIos": "Versteckt Inhalte der App beim Wechseln zwischen Apps", + "passwordStrengthInfo": "Die Berechnung der Stärke des Passworts basiert auf dessen Länge, den verwendeten Zeichen, und ob es in den 10.000 am häufigsten verwendeten Passwörtern vorkommt", + "noQuickLinksSelected": "Keine schnellen Links ausgewählt", + "pleaseSelectQuickLinksToRemove": "Bitte wähle die zu entfernenden schnellen Links", + "removePublicLinks": "Öffentliche Links entfernen", + "thisWillRemovePublicLinksOfAllSelectedQuickLinks": "Hiermit werden die öffentlichen Links aller ausgewählten schnellen Links entfernt." } \ No newline at end of file diff --git a/mobile/lib/l10n/intl_en.arb b/mobile/lib/l10n/intl_en.arb index 3eab1cc491..3951455bc5 100644 --- a/mobile/lib/l10n/intl_en.arb +++ b/mobile/lib/l10n/intl_en.arb @@ -740,7 +740,7 @@ "unhide": "Unhide", "unarchive": "Unarchive", "favorite": "Favorite", - "removeFromFavorite": "Remove from favorite", + "removeFromFavorite": "Remove from favorites", "shareLink": "Share link", "createCollage": "Create collage", "saveCollage": "Save collage", @@ -1283,5 +1283,13 @@ "swipeLockEnablePreSteps": "To enable swipe lock, please setup device passcode or screen lock in your system settings.", "autoLock": "Auto lock", "immediately": "Immediately", - "autoLockFeatureDescription": "Time after which the app locks after being put in the background" + "autoLockFeatureDescription": "Time after which the app locks after being put in the background", + "hideContent": "Hide content", + "hideContentDescriptionAndroid": "Hides app content in the app switcher and disables screenshots", + "hideContentDescriptionIos": "Hides app content in the app switcher", + "passwordStrengthInfo": "Password strength is calculated considering the length of the password, used characters, and whether or not the password appears in the top 10,000 most used passwords", + "noQuickLinksSelected": "No quick links selected", + "pleaseSelectQuickLinksToRemove": "Please select quick links to remove", + "removePublicLinks": "Remove public links", + "thisWillRemovePublicLinksOfAllSelectedQuickLinks": "This will remove public links of all selected quick links." } \ No newline at end of file diff --git a/mobile/lib/l10n/intl_es.arb b/mobile/lib/l10n/intl_es.arb index 14f14aabb5..37190ce139 100644 --- a/mobile/lib/l10n/intl_es.arb +++ b/mobile/lib/l10n/intl_es.arb @@ -1270,5 +1270,13 @@ "swipeLockEnablePreSteps": "To enable swipe lock, please setup device passcode or screen lock in your system settings.", "autoLock": "Auto lock", "immediately": "Immediately", - "autoLockFeatureDescription": "Time after which the app locks after being put in the background" + "autoLockFeatureDescription": "Time after which the app locks after being put in the background", + "hideContent": "Hide content", + "hideContentDescriptionAndroid": "Hides app content in the app switcher and disables screenshots", + "hideContentDescriptionIos": "Hides app content in the app switcher", + "passwordStrengthInfo": "Password strength is calculated considering the length of the password, used characters, and whether or not the password appears in the top 10,000 most used passwords", + "noQuickLinksSelected": "No quick links selected", + "pleaseSelectQuickLinksToRemove": "Please select quick links to remove", + "removePublicLinks": "Remove public links", + "thisWillRemovePublicLinksOfAllSelectedQuickLinks": "This will remove public links of all selected quick links." } \ No newline at end of file diff --git a/mobile/lib/l10n/intl_fr.arb b/mobile/lib/l10n/intl_fr.arb index d883920453..c2152563a9 100644 --- a/mobile/lib/l10n/intl_fr.arb +++ b/mobile/lib/l10n/intl_fr.arb @@ -1187,5 +1187,13 @@ "swipeLockEnablePreSteps": "To enable swipe lock, please setup device passcode or screen lock in your system settings.", "autoLock": "Auto lock", "immediately": "Immediately", - "autoLockFeatureDescription": "Time after which the app locks after being put in the background" + "autoLockFeatureDescription": "Time after which the app locks after being put in the background", + "hideContent": "Hide content", + "hideContentDescriptionAndroid": "Hides app content in the app switcher and disables screenshots", + "hideContentDescriptionIos": "Hides app content in the app switcher", + "passwordStrengthInfo": "Password strength is calculated considering the length of the password, used characters, and whether or not the password appears in the top 10,000 most used passwords", + "noQuickLinksSelected": "No quick links selected", + "pleaseSelectQuickLinksToRemove": "Please select quick links to remove", + "removePublicLinks": "Remove public links", + "thisWillRemovePublicLinksOfAllSelectedQuickLinks": "This will remove public links of all selected quick links." } \ No newline at end of file diff --git a/mobile/lib/l10n/intl_it.arb b/mobile/lib/l10n/intl_it.arb index 99432fea64..14af74ac40 100644 --- a/mobile/lib/l10n/intl_it.arb +++ b/mobile/lib/l10n/intl_it.arb @@ -1149,5 +1149,13 @@ "swipeLockEnablePreSteps": "To enable swipe lock, please setup device passcode or screen lock in your system settings.", "autoLock": "Auto lock", "immediately": "Immediately", - "autoLockFeatureDescription": "Time after which the app locks after being put in the background" + "autoLockFeatureDescription": "Time after which the app locks after being put in the background", + "hideContent": "Hide content", + "hideContentDescriptionAndroid": "Hides app content in the app switcher and disables screenshots", + "hideContentDescriptionIos": "Hides app content in the app switcher", + "passwordStrengthInfo": "Password strength is calculated considering the length of the password, used characters, and whether or not the password appears in the top 10,000 most used passwords", + "noQuickLinksSelected": "No quick links selected", + "pleaseSelectQuickLinksToRemove": "Please select quick links to remove", + "removePublicLinks": "Remove public links", + "thisWillRemovePublicLinksOfAllSelectedQuickLinks": "This will remove public links of all selected quick links." } \ No newline at end of file diff --git a/mobile/lib/l10n/intl_ko.arb b/mobile/lib/l10n/intl_ko.arb index 0d15e1c0b2..7171364de9 100644 --- a/mobile/lib/l10n/intl_ko.arb +++ b/mobile/lib/l10n/intl_ko.arb @@ -46,5 +46,13 @@ "swipeLockEnablePreSteps": "To enable swipe lock, please setup device passcode or screen lock in your system settings.", "autoLock": "Auto lock", "immediately": "Immediately", - "autoLockFeatureDescription": "Time after which the app locks after being put in the background" + "autoLockFeatureDescription": "Time after which the app locks after being put in the background", + "hideContent": "Hide content", + "hideContentDescriptionAndroid": "Hides app content in the app switcher and disables screenshots", + "hideContentDescriptionIos": "Hides app content in the app switcher", + "passwordStrengthInfo": "Password strength is calculated considering the length of the password, used characters, and whether or not the password appears in the top 10,000 most used passwords", + "noQuickLinksSelected": "No quick links selected", + "pleaseSelectQuickLinksToRemove": "Please select quick links to remove", + "removePublicLinks": "Remove public links", + "thisWillRemovePublicLinksOfAllSelectedQuickLinks": "This will remove public links of all selected quick links." } \ No newline at end of file diff --git a/mobile/lib/l10n/intl_nl.arb b/mobile/lib/l10n/intl_nl.arb index 7ed85fa58b..863f5e3e9c 100644 --- a/mobile/lib/l10n/intl_nl.arb +++ b/mobile/lib/l10n/intl_nl.arb @@ -1282,6 +1282,7 @@ } } }, + "panorama": "Panorama", "reenterPassword": "Wachtwoord opnieuw invoeren", "reenterPin": "PIN opnieuw invoeren", "deviceLock": "Apparaat vergrendeld", @@ -1295,9 +1296,18 @@ "toEnableAppLockPleaseSetupDevicePasscodeOrScreen": "Om vergrendelscherm in te schakelen, moet u een toegangscode of schermvergrendeling instellen in uw systeeminstellingen.", "tapToUnlock": "Tik om te ontgrendelen", "tooManyIncorrectAttempts": "Te veel onjuiste pogingen", - "appLockDescription": "Choose between your device\\'s default lock screen and a custom lock screen with a PIN or password.", - "swipeLockEnablePreSteps": "To enable swipe lock, please setup device passcode or screen lock in your system settings.", - "autoLock": "Auto lock", - "immediately": "Immediately", - "autoLockFeatureDescription": "Time after which the app locks after being put in the background" + "videoInfo": "Video-info", + "appLockDescription": "Kies tussen het standaard vergrendelingsscherm van uw apparaat en een aangepast vergrendelingsscherm met een pincode of wachtwoord.", + "swipeLockEnablePreSteps": "Om swipe-vergrendeling in te schakelen, stelt u de toegangscode van het apparaat of schermvergrendeling in uw systeeminstellingen in.", + "autoLock": "Automatische vergrendeling", + "immediately": "Onmiddellijk", + "autoLockFeatureDescription": "Tijd waarna de app wordt vergrendeld wanneer deze in achtergrond-modus is gezet", + "hideContent": "Inhoud verbergen", + "hideContentDescriptionAndroid": "Verbergt app-inhoud in de app-schakelaar en schakelt schermopnamen uit", + "hideContentDescriptionIos": "Verbergt de inhoud van de app in de app-schakelaar", + "passwordStrengthInfo": "De wachtwoordsterkte wordt berekend aan de hand van de lengte van het wachtwoord, de gebruikte tekens en of het wachtwoord al dan niet in de top 10.000 van meest gebruikte wachtwoorden staat", + "noQuickLinksSelected": "No quick links selected", + "pleaseSelectQuickLinksToRemove": "Please select quick links to remove", + "removePublicLinks": "Remove public links", + "thisWillRemovePublicLinksOfAllSelectedQuickLinks": "This will remove public links of all selected quick links." } \ No newline at end of file diff --git a/mobile/lib/l10n/intl_no.arb b/mobile/lib/l10n/intl_no.arb index 614a2a0620..ea20287722 100644 --- a/mobile/lib/l10n/intl_no.arb +++ b/mobile/lib/l10n/intl_no.arb @@ -60,5 +60,13 @@ "swipeLockEnablePreSteps": "To enable swipe lock, please setup device passcode or screen lock in your system settings.", "autoLock": "Auto lock", "immediately": "Immediately", - "autoLockFeatureDescription": "Time after which the app locks after being put in the background" + "autoLockFeatureDescription": "Time after which the app locks after being put in the background", + "hideContent": "Hide content", + "hideContentDescriptionAndroid": "Hides app content in the app switcher and disables screenshots", + "hideContentDescriptionIos": "Hides app content in the app switcher", + "passwordStrengthInfo": "Password strength is calculated considering the length of the password, used characters, and whether or not the password appears in the top 10,000 most used passwords", + "noQuickLinksSelected": "No quick links selected", + "pleaseSelectQuickLinksToRemove": "Please select quick links to remove", + "removePublicLinks": "Remove public links", + "thisWillRemovePublicLinksOfAllSelectedQuickLinks": "This will remove public links of all selected quick links." } \ No newline at end of file diff --git a/mobile/lib/l10n/intl_pl.arb b/mobile/lib/l10n/intl_pl.arb index 164ee9447e..63e3c47879 100644 --- a/mobile/lib/l10n/intl_pl.arb +++ b/mobile/lib/l10n/intl_pl.arb @@ -1158,7 +1158,7 @@ "upto50OffUntil4thDec": "Do 50% zniżki, do 4 grudnia.", "photos": "Zdjęcia", "videos": "Wideo", - "livePhotos": "Zdjęcia Live", + "livePhotos": "Zdjęcia Live Photo", "searchHint1": "Szybkie wyszukiwanie na urządzeniu", "searchHint2": "Daty zdjęć, opisy", "searchHint3": "Albumy, nazwy plików i typy", @@ -1277,10 +1277,18 @@ "toEnableAppLockPleaseSetupDevicePasscodeOrScreen": "Aby włączyć blokadę aplikacji, należy skonfigurować hasło urządzenia lub blokadę ekranu w ustawieniach systemu.", "tapToUnlock": "Naciśnij, aby odblokować", "tooManyIncorrectAttempts": "Zbyt wiele błędnych prób", - "appLockDescription": "Choose between your device\\'s default lock screen and a custom lock screen with a PIN or password.", - "swipeLockEnablePreSteps": "To enable swipe lock, please setup device passcode or screen lock in your system settings.", - "autoLock": "Auto lock", - "immediately": "Immediately", - "autoLockFeatureDescription": "Time after which the app locks after being put in the background", - "videoInfo": "Informacje Wideo" + "videoInfo": "Informacje Wideo", + "appLockDescription": "Wybierz między domyślnym ekranem blokady urządzenia a niestandardowym ekranem blokady z kodem PIN lub hasłem.", + "swipeLockEnablePreSteps": "Aby włączyć blokadę aplikacji, należy skonfigurować hasło urządzenia lub blokadę ekranu w ustawieniach Twojego systemu.", + "autoLock": "Automatyczna blokada", + "immediately": "Natychmiast", + "autoLockFeatureDescription": "Czas, po którym aplikacja blokuje się po umieszczeniu jej w tle", + "hideContent": "Ukryj zawartość", + "hideContentDescriptionAndroid": "Ukrywa zawartość aplikacji w przełączniku aplikacji i wyłącza zrzuty ekranu", + "hideContentDescriptionIos": "Ukrywa zawartość aplikacji w przełączniku aplikacji", + "passwordStrengthInfo": "Siła hasła jest obliczana, biorąc pod uwagę długość hasła, użyte znaki, i czy hasło pojawi się w 10 000 najczęściej używanych haseł", + "noQuickLinksSelected": "Nie wybrano żadnych szybkich linków", + "pleaseSelectQuickLinksToRemove": "Prosimy wybrać szybkie linki do usunięcia", + "removePublicLinks": "Usuń linki publiczne", + "thisWillRemovePublicLinksOfAllSelectedQuickLinks": "Spowoduje to usunięcie publicznych linków wszystkich zaznaczonych szybkich linków." } \ No newline at end of file diff --git a/mobile/lib/l10n/intl_pt.arb b/mobile/lib/l10n/intl_pt.arb index 3604029613..04babbc492 100644 --- a/mobile/lib/l10n/intl_pt.arb +++ b/mobile/lib/l10n/intl_pt.arb @@ -91,15 +91,15 @@ "pleaseWait": "Por favor, aguarde...", "continueLabel": "Continuar", "insecureDevice": "Dispositivo inseguro", - "sorryWeCouldNotGenerateSecureKeysOnThisDevicennplease": "Desculpe, não foi possível gerar chaves seguras neste dispositivo.\n\npor favor, faça o login com um dispositivo diferente.", + "sorryWeCouldNotGenerateSecureKeysOnThisDevicennplease": "Desculpe, não foi possível gerar chaves seguras neste dispositivo.\n\npor favor, inicie sessão com um dispositivo diferente.", "howItWorks": "Como funciona", "encryption": "Criptografia", "ackPasswordLostWarning": "Eu entendo que se eu perder minha senha, posso perder meus dados, já que meus dados são criptografados de ponta a ponta.", "privacyPolicyTitle": "Política de Privacidade", "termsOfServicesTitle": "Termos", "signUpTerms": "Eu concordo com os termos de serviço e a política de privacidade", - "logInLabel": "Login", - "loginTerms": "Ao clicar em login, eu concordo com os termos de serviço e a política de privacidade", + "logInLabel": "Entrar", + "loginTerms": "Ao clicar em entrar, eu concordo com os termos de serviço e a política de privacidade", "changeEmail": "Mudar e-mail", "enterYourPassword": "Insira sua senha", "welcomeBack": "Bem-vindo de volta!", @@ -108,7 +108,7 @@ "pleaseTryAgain": "Por favor, tente novamente", "recreatePasswordTitle": "Redefinir senha", "useRecoveryKey": "Usar chave de recuperação", - "recreatePasswordBody": "O dispositivo atual não é poderoso o suficiente para verificar sua senha, mas podemos regenerar de uma forma que funcione com todos os dispositivos.\n\nPor favor, faça o login usando sua chave de recuperação e recrie sua senha (você pode usar o mesmo novamente se desejar).", + "recreatePasswordBody": "O dispositivo atual não é poderoso o suficiente para verificar sua senha, mas podemos regenerar de uma forma que funcione com todos os dispositivos.\n\nPor favor, inicie sessão usando sua chave de recuperação e recrie sua senha (você pode usar o mesmo novamente se desejar).", "verifyPassword": "Verificar senha", "recoveryKey": "Chave de recuperação", "recoveryKeyOnForgotPassword": "Caso você esqueça sua senha, a única maneira de recuperar seus dados é com essa chave.", @@ -184,7 +184,7 @@ "description": "Switch button to enable uploading photos to a public link" }, "allowAddPhotosDescription": "Permita que as pessoas com o link também adicionem fotos ao álbum compartilhado.", - "passwordLock": "Bloqueio de senha", + "passwordLock": "Bloqueio por senha", "disableDownloadWarningTitle": "Observe", "disableDownloadWarningBody": "Os espectadores ainda podem tirar screenshots ou salvar uma cópia de suas fotos usando ferramentas externas", "allowDownloads": "Permitir downloads", @@ -271,7 +271,7 @@ "enterCodeDescription": "Digite o código fornecido pelo seu amigo para reivindicar o armazenamento gratuito para vocês dois", "apply": "Aplicar", "failedToApplyCode": "Falha ao aplicar o código", - "enterReferralCode": "Insira o código de referência", + "enterReferralCode": "Insira o código de indicação", "codeAppliedPageTitle": "Código aplicado", "storageInGB": "{storageAmountInGB} GB", "claimed": "Reivindicado", @@ -282,14 +282,14 @@ "claimMore": "Reivindique mais!", "theyAlsoGetXGb": "Eles também recebem {storageAmountInGB} GB", "freeStorageOnReferralSuccess": "{storageAmountInGB} GB cada vez que alguém se inscrever para um plano pago e aplica o seu código", - "shareTextReferralCode": "Código de referência do ente: {referralCode} \n\nAplique em Configurações → Geral → Indicações para obter {referralStorageInGB} GB gratuitamente após a sua inscrição em um plano pago\n\nhttps://ente.io", + "shareTextReferralCode": "Código de indicação do Ente: {referralCode} \n\nAplique em Configurações → Geral → Indicações para obter {referralStorageInGB} GB gratuitamente após a sua inscrição em um plano pago\n\nhttps://ente.io", "claimFreeStorage": "Reivindicar armazenamento gratuito", "inviteYourFriends": "Convide seus amigos", - "failedToFetchReferralDetails": "Não foi possível buscar os detalhes de referência. Por favor, tente novamente mais tarde.", + "failedToFetchReferralDetails": "Não foi possível buscar os detalhes de indicação. Por favor, tente novamente mais tarde.", "referralStep1": "Envie esse código aos seus amigos", - "referralStep2": "2. Eles se inscreveram para um plano pago", + "referralStep2": "2. Eles se inscrevem em um plano pago", "referralStep3": "3. Ambos ganham {storageInGB} GB* grátis", - "referralsAreCurrentlyPaused": "Referências estão atualmente pausadas", + "referralsAreCurrentlyPaused": "Indicações estão atualmente pausadas", "youCanAtMaxDoubleYourStorage": "* Você pode duplicar seu armazenamento no máximo", "claimedStorageSoFar": "{isFamilyMember, select,true {Sua família reeinvindicou {storageAmountInGb} GB até agora}false {Você reeinvindicou {storageAmountInGb} GB até agora}other {Você reeinvindicou {storageAmountInGb} GB até agora}}", "@claimedStorageSoFar": { @@ -350,7 +350,7 @@ "uncategorized": "Sem categoria", "videoSmallCase": "vídeo", "photoSmallCase": "foto", - "singleFileDeleteHighlight": "Será excluído de todos os álbuns.", + "singleFileDeleteHighlight": "Ele será excluído de todos os álbuns.", "singleFileInBothLocalAndRemote": "Este(a) {fileType} está tanto no Ente quanto no seu dispositivo.", "singleFileInRemoteOnly": "Este(a) {fileType} será excluído(a) do Ente.", "singleFileDeleteFromDevice": "Este(a) {fileType} será excluído(a) do seu dispositivo.", @@ -460,7 +460,7 @@ "authToChangeYourEmail": "Por favor, autentique-se para alterar seu e-mail", "changePassword": "Mude sua senha", "authToChangeYourPassword": "Por favor, autentique-se para alterar sua senha", - "emailVerificationToggle": "Verificação de e-mail", + "emailVerificationToggle": "Verificação por e-mail", "authToChangeEmailVerificationSetting": "Por favor, autentique-se para alterar seu e-mail", "exportYourData": "Exportar seus dados", "logout": "Encerrar sessão", @@ -521,7 +521,7 @@ } }, "familyPlans": "Plano familiar", - "referrals": "Referências", + "referrals": "Indicações", "notifications": "Notificações", "sharedPhotoNotifications": "Novas fotos compartilhadas", "sharedPhotoNotificationsExplanation": "Receber notificações quando alguém adicionar uma foto em um álbum compartilhado que você faz parte", @@ -671,7 +671,7 @@ "androidIosWebDesktop": "Android, iOS, Web, Desktop", "mobileWebDesktop": "Mobile, Web, Desktop", "newToEnte": "Novo no Ente", - "pleaseLoginAgain": "Por favor, faça login novamente", + "pleaseLoginAgain": "Por favor, inicie sessão novamente", "autoLogoutMessage": "Devido a erros técnicos, você foi desconectado. Pedimos desculpas pelo inconveniente.", "yourSubscriptionHasExpired": "A sua assinatura expirou", "storageLimitExceeded": "Limite de armazenamento excedido", @@ -951,7 +951,7 @@ "couldNotFreeUpSpace": "Não foi possível liberar espaço", "permanentlyDeleteFromDevice": "Excluir permanentemente do dispositivo?", "someOfTheFilesYouAreTryingToDeleteAre": "Alguns dos arquivos que você está tentando excluir só estão disponíveis no seu dispositivo e não podem ser recuperados se forem excluídos", - "theyWillBeDeletedFromAllAlbums": "Eles(a) serão excluídos(as) de todos os álbuns.", + "theyWillBeDeletedFromAllAlbums": "Eles serão excluídos de todos os álbuns.", "someItemsAreInBothEnteAndYourDevice": "Alguns itens estão tanto no Ente quanto no seu dispositivo.", "selectedItemsWillBeDeletedFromAllAlbumsAndMoved": "Os itens selecionados serão excluídos de todos os álbuns e movidos para a lixeira.", "theseItemsWillBeDeletedFromYourDevice": "Estes itens serão excluídos do seu dispositivo.", @@ -971,7 +971,7 @@ "viewLogs": "Ver logs", "logsDialogBody": "Isso enviará através dos logs para nos ajudar a depurar o seu problema. Por favor, note que nomes de arquivos serão incluídos para ajudar a rastrear problemas com arquivos específicos.", "preparingLogs": "Preparando logs...", - "emailYourLogs": "Enviar por email seus logs", + "emailYourLogs": "Enviar logs por e-mail", "pleaseSendTheLogsTo": "Por favor, envie os logs para \n{toEmail}", "copyEmailAddress": "Copiar endereço de e-mail", "exportLogs": "Exportar logs", @@ -1277,9 +1277,10 @@ } } }, + "panorama": "Panorama", "reenterPassword": "Reinserir senha", "reenterPin": "Reinserir PIN", - "deviceLock": "Bloqueio de dispositivo", + "deviceLock": "Bloqueio do dispositivo", "pinLock": "Bloqueio PIN", "next": "Próximo", "setNewPassword": "Defina nova senha", @@ -1290,9 +1291,18 @@ "toEnableAppLockPleaseSetupDevicePasscodeOrScreen": "Para ativar o bloqueio de app, por favor ative um método de autenticação nas configurações do sistema do seu dispositivo.", "tapToUnlock": "Toque para desbloquear", "tooManyIncorrectAttempts": "Muitas tentativas incorretas", - "appLockDescription": "Choose between your device\\'s default lock screen and a custom lock screen with a PIN or password.", - "swipeLockEnablePreSteps": "To enable swipe lock, please setup device passcode or screen lock in your system settings.", - "autoLock": "Auto lock", - "immediately": "Immediately", - "autoLockFeatureDescription": "Time after which the app locks after being put in the background" + "videoInfo": "Informação de Vídeo", + "appLockDescription": "Escolha entre a tela de bloqueio padrão do seu dispositivo e uma tela de bloqueio personalizada com PIN ou senha.", + "swipeLockEnablePreSteps": "Para ativar o bloqueio por deslizar, por favor ative um método de autenticação nas configurações do sistema do seu dispositivo.", + "autoLock": "Bloqueio automático", + "immediately": "Imediatamente", + "autoLockFeatureDescription": "Tempo após o qual o app bloqueia depois de ser colocado em segundo plano", + "hideContent": "Ocultar conteúdo", + "hideContentDescriptionAndroid": "Oculta o conteúdo do app no seletor de apps e desativa as capturas de tela", + "hideContentDescriptionIos": "Oculta o conteúdo do seletor de apps", + "passwordStrengthInfo": "Força da senha é calculada considerando o comprimento da senha, caracteres usados, e se a senha aparece ou não nas 10.000 senhas mais usadas", + "noQuickLinksSelected": "Nenhum link rápido selecionado", + "pleaseSelectQuickLinksToRemove": "Selecione links rápidos para remover", + "removePublicLinks": "Remover link público", + "thisWillRemovePublicLinksOfAllSelectedQuickLinks": "Isto removerá links públicos de todos os links rápidos selecionados." } \ No newline at end of file diff --git a/mobile/lib/l10n/intl_ru.arb b/mobile/lib/l10n/intl_ru.arb index 2af8c3cc22..35d71a867e 100644 --- a/mobile/lib/l10n/intl_ru.arb +++ b/mobile/lib/l10n/intl_ru.arb @@ -1269,5 +1269,13 @@ "swipeLockEnablePreSteps": "To enable swipe lock, please setup device passcode or screen lock in your system settings.", "autoLock": "Auto lock", "immediately": "Immediately", - "autoLockFeatureDescription": "Time after which the app locks after being put in the background" + "autoLockFeatureDescription": "Time after which the app locks after being put in the background", + "hideContent": "Hide content", + "hideContentDescriptionAndroid": "Hides app content in the app switcher and disables screenshots", + "hideContentDescriptionIos": "Hides app content in the app switcher", + "passwordStrengthInfo": "Password strength is calculated considering the length of the password, used characters, and whether or not the password appears in the top 10,000 most used passwords", + "noQuickLinksSelected": "No quick links selected", + "pleaseSelectQuickLinksToRemove": "Please select quick links to remove", + "removePublicLinks": "Remove public links", + "thisWillRemovePublicLinksOfAllSelectedQuickLinks": "This will remove public links of all selected quick links." } \ No newline at end of file diff --git a/mobile/lib/l10n/intl_tr.arb b/mobile/lib/l10n/intl_tr.arb index 05ad9a5189..86b8dc2777 100644 --- a/mobile/lib/l10n/intl_tr.arb +++ b/mobile/lib/l10n/intl_tr.arb @@ -1279,5 +1279,13 @@ "swipeLockEnablePreSteps": "To enable swipe lock, please setup device passcode or screen lock in your system settings.", "autoLock": "Auto lock", "immediately": "Immediately", - "autoLockFeatureDescription": "Time after which the app locks after being put in the background" + "autoLockFeatureDescription": "Time after which the app locks after being put in the background", + "hideContent": "Hide content", + "hideContentDescriptionAndroid": "Hides app content in the app switcher and disables screenshots", + "hideContentDescriptionIos": "Hides app content in the app switcher", + "passwordStrengthInfo": "Password strength is calculated considering the length of the password, used characters, and whether or not the password appears in the top 10,000 most used passwords", + "noQuickLinksSelected": "No quick links selected", + "pleaseSelectQuickLinksToRemove": "Please select quick links to remove", + "removePublicLinks": "Remove public links", + "thisWillRemovePublicLinksOfAllSelectedQuickLinks": "This will remove public links of all selected quick links." } \ No newline at end of file diff --git a/mobile/lib/l10n/intl_zh.arb b/mobile/lib/l10n/intl_zh.arb index 3eb0cd70aa..4356ee40ef 100644 --- a/mobile/lib/l10n/intl_zh.arb +++ b/mobile/lib/l10n/intl_zh.arb @@ -1206,7 +1206,7 @@ "playOnTv": "在电视上播放相册", "pair": "配对", "deviceNotFound": "未发现设备", - "castInstruction": "在您要配对的设备上访问 cast.ente.io。\n输入下面的代码即可在电视上播放相册。", + "castInstruction": "在您要配对的设备上访问 cast.ente.io。\n在下框中输入代码即可在电视上播放相册。", "deviceCodeHint": "输入代码", "joinDiscord": "加入 Discord", "locations": "位置", @@ -1291,10 +1291,18 @@ "toEnableAppLockPleaseSetupDevicePasscodeOrScreen": "要启用应用锁,请在系统设置中设置设备密码或屏幕锁定。", "tapToUnlock": "点击解锁", "tooManyIncorrectAttempts": "错误尝试次数过多", - "appLockDescription": "Choose between your device\\'s default lock screen and a custom lock screen with a PIN or password.", - "swipeLockEnablePreSteps": "To enable swipe lock, please setup device passcode or screen lock in your system settings.", - "autoLock": "Auto lock", - "immediately": "Immediately", - "autoLockFeatureDescription": "Time after which the app locks after being put in the background", - "videoInfo": "视频详情" + "videoInfo": "视频详情", + "appLockDescription": "在设备的默认锁定屏幕和带有 PIN 或密码的自定义锁定屏幕之间进行选择。", + "swipeLockEnablePreSteps": "要启用滑动锁定,请在系统设置中设置设备密码或屏幕锁。", + "autoLock": "自动锁定", + "immediately": "立即", + "autoLockFeatureDescription": "应用程序进入后台后锁定的时间", + "hideContent": "隐藏内容", + "hideContentDescriptionAndroid": "在应用切换器中隐藏应用内容并禁用屏幕截图", + "hideContentDescriptionIos": "在应用切换器中隐藏应用内容", + "passwordStrengthInfo": "密码强度的计算考虑了密码的长度、使用的字符以及密码是否出现在最常用的 10,000 个密码中", + "noQuickLinksSelected": "未选择快速链接", + "pleaseSelectQuickLinksToRemove": "请选择要删除的快速链接", + "removePublicLinks": "删除公开链接", + "thisWillRemovePublicLinksOfAllSelectedQuickLinks": "这将删除所有选定的快速链接的公共链接。" } \ No newline at end of file diff --git a/mobile/lib/models/ffmpeg/ffprobe_keys.dart b/mobile/lib/models/ffmpeg/ffprobe_keys.dart index 081fe0ff7e..55c26c29f1 100644 --- a/mobile/lib/models/ffmpeg/ffprobe_keys.dart +++ b/mobile/lib/models/ffmpeg/ffprobe_keys.dart @@ -28,7 +28,7 @@ class FFProbeKeys { static const date = 'date'; static const disposition = 'disposition'; static const duration = 'duration'; - static const quickTimeLocation ="com.apple.quicktime.location.ISO6709"; + static const quickTimeLocation = "com.apple.quicktime.location.ISO6709"; static const durationMicros = 'duration_us'; static const encoder = 'encoder'; static const extraDataSize = 'extradata_size'; @@ -70,6 +70,9 @@ 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'; + static const sideDataType = 'side_data_type'; } class MediaStreamTypes { @@ -81,3 +84,16 @@ class MediaStreamTypes { static const unknown = 'unknown'; static const video = 'video'; } + +enum SideDataType { + displayMatrix; + + getString() { + switch (this) { + case SideDataType.displayMatrix: + return 'Display Matrix'; + default: + assert(false, 'Unknown side data type: $this'); + } + } +} diff --git a/mobile/lib/models/ffmpeg/ffprobe_props.dart b/mobile/lib/models/ffmpeg/ffprobe_props.dart index 027d0377ee..545a39c5ed 100644 --- a/mobile/lib/models/ffmpeg/ffprobe_props.dart +++ b/mobile/lib/models/ffmpeg/ffprobe_props.dart @@ -12,32 +12,68 @@ import "package:photos/models/ffmpeg/mp4.dart"; import "package:photos/models/location/location.dart"; class FFProbeProps { - Map? prodData; + Map? propData; Location? location; DateTime? creationTimeUTC; String? bitrate; String? majorBrand; String? fps; - String? codecWidth; - String? codecHeight; + String? _width; + String? _height; + int? _rotation; // dot separated bitrate, fps, codecWidth, codecHeight. Ignore null value String get videoInfo { final List info = []; if (bitrate != null) info.add('$bitrate'); if (fps != null) info.add('ƒ/$fps'); - if (codecWidth != null && codecHeight != null) { - info.add('$codecWidth x $codecHeight'); + if (_width != null && _height != null) { + info.add('$_width x $_height'); } return info.join(' * '); } + int? get width { + if (_width == null || _height == null) return null; + final intWidth = int.tryParse(_width!); + if (_rotation == null) { + return intWidth; + } else { + if ((_rotation! ~/ 90).isEven) { + return intWidth; + } else { + return int.tryParse(_height!); + } + } + } + + int? get height { + if (_width == null || _height == null) return null; + final intHeight = int.tryParse(_height!); + if (_rotation == null) { + return intHeight; + } else { + if ((_rotation! ~/ 90).isEven) { + return intHeight; + } else { + return int.tryParse(_width!); + } + } + } + + double? get aspectRatio { + if (width == null || height == null || height == 0 || width == 0) { + return null; + } + return width! / height!; + } + // toString() method @override String toString() { final buffer = StringBuffer(); - for (final key in prodData!.keys) { - final value = prodData![key]; + for (final key in propData!.keys) { + final value = propData![key]; if (value != null) { buffer.writeln('$key: $value'); } @@ -131,12 +167,41 @@ class FFProbeProps { if (key == FFProbeKeys.rFrameRate) { result.fps = _formatFPS(stream[key]); parsedData[key] = result.fps; - } else if (key == FFProbeKeys.codedWidth) { - result.codecWidth = stream[key].toString(); - parsedData[key] = result.codecWidth; + } + //TODO: Use `height` and `width` instead of `codedHeight` and `codedWidth` + //for better accuracy. `height' and `width` will give the video's "visual" + //height and width. + else if (key == FFProbeKeys.codedWidth) { + final width = stream[key]; + if (width != null && width != 0) { + result._width = width.toString(); + parsedData[key] = result._width; + } } else if (key == FFProbeKeys.codedHeight) { - result.codecHeight = stream[key].toString(); - parsedData[key] = result.codecHeight; + final height = stream[key]; + if (height != null && height != 0) { + result._height = height.toString(); + parsedData[key] = result._height; + } + } else if (key == FFProbeKeys.width) { + final width = stream[key]; + if (width != null && width != 0) { + result._width = width.toString(); + parsedData[key] = result._width; + } + } else if (key == FFProbeKeys.height) { + final height = stream[key]; + if (height != null && height != 0) { + result._height = height.toString(); + parsedData[key] = result._height; + } + } else if (key == FFProbeKeys.sideDataList) { + for (Map sideData in stream[key]) { + if (sideData["side_data_type"] == "Display Matrix") { + result._rotation = sideData[FFProbeKeys.rotation]; + parsedData[FFProbeKeys.rotation] = result._rotation; + } + } } } } @@ -144,7 +209,7 @@ class FFProbeProps { newStreams.add(metadata); } parsedData["streams"] = newStreams; - result.prodData = parsedData; + result.propData = parsedData; return result; } diff --git a/mobile/lib/models/subscription.dart b/mobile/lib/models/subscription.dart index 51fca19e3a..50735a7c48 100644 --- a/mobile/lib/models/subscription.dart +++ b/mobile/lib/models/subscription.dart @@ -1,6 +1,7 @@ import 'dart:convert'; const freeProductID = "free"; +const popularProductIDs = ["200gb_yearly", "200gb_monthly"]; const stripe = "stripe"; const appStore = "appstore"; const playStore = "playstore"; @@ -47,6 +48,10 @@ class Subscription { return 'year' == period; } + bool isFreePlan() { + return productID == freeProductID; + } + static fromMap(Map? map) { if (map == null) return null; return Subscription( diff --git a/mobile/lib/theme/colors.dart b/mobile/lib/theme/colors.dart index 694106e398..be8ed8e7e4 100644 --- a/mobile/lib/theme/colors.dart +++ b/mobile/lib/theme/colors.dart @@ -26,6 +26,7 @@ class EnteColorScheme { final Color fillMuted; final Color fillFaint; final Color fillFaintPressed; + final Color fillBaseGrey; // Stroke Colors final Color strokeBase; @@ -74,6 +75,7 @@ class EnteColorScheme { this.fillMuted, this.fillFaint, this.fillFaintPressed, + this.fillBaseGrey, this.strokeBase, this.strokeMuted, this.strokeFaint, @@ -114,6 +116,7 @@ const EnteColorScheme lightScheme = EnteColorScheme( fillMutedLight, fillFaintLight, fillFaintPressedLight, + fillBaseGreyLight, strokeBaseLight, strokeMutedLight, strokeFaintLight, @@ -142,6 +145,7 @@ const EnteColorScheme darkScheme = EnteColorScheme( fillMutedDark, fillFaintDark, fillFaintPressedDark, + fillBaseGreyDark, strokeBaseDark, strokeMutedDark, strokeFaintDark, @@ -189,6 +193,7 @@ const Color fillStrongLight = Color.fromRGBO(0, 0, 0, 0.24); const Color fillMutedLight = Color.fromRGBO(0, 0, 0, 0.12); const Color fillFaintLight = Color.fromRGBO(0, 0, 0, 0.04); const Color fillFaintPressedLight = Color.fromRGBO(0, 0, 0, 0.08); +const Color fillBaseGreyLight = Color.fromRGBO(242, 242, 242, 1); const Color fillBaseDark = Color.fromRGBO(255, 255, 255, 1); const Color fillBasePressedDark = Color.fromRGBO(255, 255, 255, 0.9); @@ -196,6 +201,7 @@ const Color fillStrongDark = Color.fromRGBO(255, 255, 255, 0.32); const Color fillMutedDark = Color.fromRGBO(255, 255, 255, 0.16); const Color fillFaintDark = Color.fromRGBO(255, 255, 255, 0.12); const Color fillFaintPressedDark = Color.fromRGBO(255, 255, 255, 0.06); +const Color fillBaseGreyDark = Color.fromRGBO(66, 66, 66, 1); // Stroke Colors const Color strokeBaseLight = Color.fromRGBO(0, 0, 0, 1); @@ -216,7 +222,6 @@ const Color blurStrokePressedDark = Color.fromRGBO(255, 255, 255, 0.50); // Other colors const Color tabIconLight = Color.fromRGBO(0, 0, 0, 0.85); - const Color tabIconDark = Color.fromRGBO(255, 255, 255, 0.80); // Fixed Colors diff --git a/mobile/lib/ui/account/email_entry_page.dart b/mobile/lib/ui/account/email_entry_page.dart index 143f593ef3..6488e20d41 100644 --- a/mobile/lib/ui/account/email_entry_page.dart +++ b/mobile/lib/ui/account/email_entry_page.dart @@ -9,12 +9,13 @@ import 'package:photos/services/user_service.dart'; import "package:photos/theme/ente_theme.dart"; import 'package:photos/ui/common/dynamic_fab.dart'; import 'package:photos/ui/common/web_page.dart'; +import "package:photos/utils/dialog_util.dart"; import "package:photos/utils/toast_util.dart"; import 'package:step_progress_indicator/step_progress_indicator.dart'; import "package:styled_text/styled_text.dart"; class EmailEntryPage extends StatefulWidget { - const EmailEntryPage({Key? key}) : super(key: key); + const EmailEntryPage({super.key}); @override State createState() => _EmailEntryPageState(); @@ -49,18 +50,25 @@ class _EmailEntryPageState extends State { @override void initState() { - _email = _config.getEmail(); - _password1FocusNode.addListener(() { - setState(() { - _password1InFocus = _password1FocusNode.hasFocus; - }); - }); - _password2FocusNode.addListener(() { - setState(() { - _password2InFocus = _password2FocusNode.hasFocus; - }); - }); super.initState(); + _email = _config.getEmail(); + _password1FocusNode.addListener( + _password1FocusListener, + ); + _password2FocusNode.addListener( + _password2FocusListener, + ); + } + + @override + void dispose() { + _password1FocusNode.removeListener(_password1FocusListener); + _password2FocusNode.removeListener(_password2FocusListener); + _password1FocusNode.dispose(); + _password2FocusNode.dispose(); + _passwordController1.dispose(); + _passwordController2.dispose(); + super.dispose(); } @override @@ -164,7 +172,6 @@ class _EmailEntryPageState extends State { suffixIcon: _emailIsValid ? Icon( Icons.check, - size: 20, color: Theme.of(context) .inputDecorationTheme .focusedBorder! @@ -309,7 +316,7 @@ class _EmailEntryPageState extends State { onChanged: (cnfPassword) { setState(() { _cnfPassword = cnfPassword; - if (_password != null || _password != '') { + if (_password != null && _password != '') { _passwordsMatch = _password == _cnfPassword; } }); @@ -317,16 +324,38 @@ class _EmailEntryPageState extends State { ), ), Opacity( - opacity: (_password != '') && _password1InFocus ? 1 : 0, + opacity: (_password != null && _password != '') ? 1 : 0, child: Padding( - padding: - const EdgeInsets.symmetric(horizontal: 24, vertical: 8), - child: Text( - S.of(context).passwordStrength(passwordStrengthText), - style: TextStyle( - color: passwordStrengthColor, - fontWeight: FontWeight.w500, - fontSize: 12, + padding: const EdgeInsets.symmetric( + horizontal: 24, + vertical: 8, + ), + child: GestureDetector( + onTap: () { + showInfoDialog( + context, + body: S.of(context).passwordStrengthInfo, + ); + }, + child: Row( + children: [ + Text( + S + .of(context) + .passwordStrength(passwordStrengthText), + style: TextStyle( + color: passwordStrengthColor, + fontWeight: FontWeight.w500, + fontSize: 12, + ), + ), + const SizedBox(width: 8), + Icon( + Icons.info_outline, + size: 16, + color: getEnteColorScheme(context).fillStrong, + ), + ], ), ), ), @@ -523,6 +552,18 @@ class _EmailEntryPageState extends State { ); } + void _password1FocusListener() { + setState(() { + _password1InFocus = _password1FocusNode.hasFocus; + }); + } + + void _password2FocusListener() { + setState(() { + _password2InFocus = _password2FocusNode.hasFocus; + }); + } + bool _isFormValid() { return _emailIsValid && _passwordsMatch && diff --git a/mobile/lib/ui/payment/skip_subscription_widget.dart b/mobile/lib/ui/payment/skip_subscription_widget.dart deleted file mode 100644 index c2949a238a..0000000000 --- a/mobile/lib/ui/payment/skip_subscription_widget.dart +++ /dev/null @@ -1,55 +0,0 @@ -import "dart:async"; - -import 'package:flutter/material.dart'; -import 'package:photos/core/event_bus.dart'; -import 'package:photos/events/subscription_purchased_event.dart'; -import "package:photos/generated/l10n.dart"; -import 'package:photos/models/billing_plan.dart'; -import 'package:photos/models/subscription.dart'; -import 'package:photos/services/billing_service.dart'; -import "package:photos/ui/tabs/home_widget.dart"; - -class SkipSubscriptionWidget extends StatelessWidget { - const SkipSubscriptionWidget({ - Key? key, - required this.freePlan, - }) : super(key: key); - - final FreePlan freePlan; - - @override - Widget build(BuildContext context) { - return Container( - width: double.infinity, - height: 64, - margin: const EdgeInsets.fromLTRB(0, 30, 0, 0), - padding: const EdgeInsets.fromLTRB(20, 0, 20, 0), - child: OutlinedButton( - style: Theme.of(context).outlinedButtonTheme.style?.copyWith( - textStyle: MaterialStateProperty.resolveWith( - (Set states) { - return Theme.of(context).textTheme.titleMedium!; - }, - ), - ), - onPressed: () async { - Bus.instance.fire(SubscriptionPurchasedEvent()); - // ignore: unawaited_futures - Navigator.of(context).pushAndRemoveUntil( - MaterialPageRoute( - builder: (BuildContext context) { - return const HomeWidget(); - }, - ), - (route) => false, - ); - unawaited( - BillingService.instance - .verifySubscription(freeProductID, "", paymentProvider: "ente"), - ); - }, - child: Text(S.of(context).continueOnFreeTrial), - ), - ); - } -} diff --git a/mobile/lib/ui/payment/store_subscription_page.dart b/mobile/lib/ui/payment/store_subscription_page.dart index 3925bf0177..fc7fa5adeb 100644 --- a/mobile/lib/ui/payment/store_subscription_page.dart +++ b/mobile/lib/ui/payment/store_subscription_page.dart @@ -1,7 +1,6 @@ import 'dart:async'; import 'dart:io'; -import "package:flutter/cupertino.dart"; import "package:flutter/foundation.dart"; import 'package:flutter/material.dart'; import 'package:in_app_purchase/in_app_purchase.dart'; @@ -14,19 +13,20 @@ import 'package:photos/models/billing_plan.dart'; import 'package:photos/models/subscription.dart'; import 'package:photos/models/user_details.dart'; import 'package:photos/services/billing_service.dart'; -import "package:photos/services/update_service.dart"; import 'package:photos/services/user_service.dart'; import "package:photos/theme/colors.dart"; import "package:photos/theme/ente_theme.dart"; import 'package:photos/ui/common/loading_widget.dart'; import 'package:photos/ui/common/progress_dialog.dart'; import "package:photos/ui/components/captioned_text_widget.dart"; +import "package:photos/ui/components/divider_widget.dart"; import "package:photos/ui/components/menu_item_widget/menu_item_widget.dart"; +import "package:photos/ui/components/title_bar_title_widget.dart"; import 'package:photos/ui/payment/child_subscription_widget.dart'; -import 'package:photos/ui/payment/skip_subscription_widget.dart'; import 'package:photos/ui/payment/subscription_common_widgets.dart'; import 'package:photos/ui/payment/subscription_plan_widget.dart'; import "package:photos/ui/payment/view_add_on_widget.dart"; +import "package:photos/ui/tabs/home_widget.dart"; import "package:photos/utils/data_util.dart"; import 'package:photos/utils/dialog_util.dart'; import 'package:photos/utils/toast_util.dart'; @@ -37,8 +37,8 @@ class StoreSubscriptionPage extends StatefulWidget { const StoreSubscriptionPage({ this.isOnboarding = false, - Key? key, - }) : super(key: key); + super.key, + }); @override State createState() => _StoreSubscriptionPageState(); @@ -69,9 +69,9 @@ class _StoreSubscriptionPageState extends State { @override void initState() { + super.initState(); _billingService.setIsOnSubscriptionPage(true); _setupPurchaseUpdateStreamListener(); - super.initState(); } void _setupPurchaseUpdateStreamListener() { @@ -155,20 +155,42 @@ class _StoreSubscriptionPageState extends State { @override Widget build(BuildContext context) { + final textTheme = getEnteTextTheme(context); colorScheme = getEnteColorScheme(context); if (!_isLoading) { _isLoading = true; _fetchSubData(); } _dialog = createProgressDialog(context, S.of(context).pleaseWait); - final appBar = AppBar( - title: widget.isOnboarding - ? null - : Text("${S.of(context).subscription}${kDebugMode ? ' Store' : ''}"), - ); return Scaffold( - appBar: appBar, - body: _getBody(), + appBar: AppBar(), + body: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TitleBarTitleWidget( + title: widget.isOnboarding + ? "Select your plan" + : "${S.of(context).subscription}${kDebugMode ? ' Store' : ''}", + ), + _isFreePlanUser() || !_hasLoadedData + ? const SizedBox.shrink() + : Text( + convertBytesToReadableFormat( + _userDetails.getTotalStorage(), + ), + style: textTheme.smallMuted, + ), + ], + ), + ), + Expanded(child: _getBody()), + ], + ), ); } @@ -233,6 +255,17 @@ class _StoreSubscriptionPageState extends State { ), ); + if (hasYearlyPlans) { + widgets.add( + SubscriptionToggle( + onToggle: (p0) { + showYearlyPlan = p0; + _filterStorePlansForUi(); + }, + ), + ); + } + widgets.addAll([ Column( mainAxisAlignment: MainAxisAlignment.center, @@ -240,13 +273,9 @@ class _StoreSubscriptionPageState extends State { ? _getStripePlanWidgets() : _getMobilePlanWidgets(), ), - const Padding(padding: EdgeInsets.all(8)), + const Padding(padding: EdgeInsets.all(4)), ]); - if (hasYearlyPlans) { - widgets.add(_showSubscriptionToggle()); - } - if (_currentSubscription != null) { widgets.add( ValidityWidget( @@ -254,15 +283,11 @@ class _StoreSubscriptionPageState extends State { bonusData: _userDetails.bonusData, ), ); - } - - if (_currentSubscription!.productID == freeProductID) { - if (widget.isOnboarding) { - widgets.add(SkipSubscriptionWidget(freePlan: _freePlan)); - } - widgets.add( - SubFaqWidget(isOnboarding: widget.isOnboarding), - ); + widgets.add(const DividerWidget(dividerType: DividerType.bottomBar)); + widgets.add(const SizedBox(height: 20)); + } else { + widgets.add(const DividerWidget(dividerType: DividerType.bottomBar)); + const SizedBox(height: 56); } if (_hasActiveSubscription && @@ -285,7 +310,7 @@ class _StoreSubscriptionPageState extends State { padding: const EdgeInsets.fromLTRB(16, 40, 16, 4), child: MenuItemWidget( captionedTextWidget: CaptionedTextWidget( - title: S.of(context).paymentDetails, + title: "Manage payment method", ), menuItemColor: colorScheme.fillFaint, trailingWidget: Icon( @@ -302,10 +327,15 @@ class _StoreSubscriptionPageState extends State { ); } } + + widgets.add( + SubFaqWidget(isOnboarding: widget.isOnboarding), + ); + if (!widget.isOnboarding) { widgets.add( Padding( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 0), + padding: const EdgeInsets.fromLTRB(16, 2, 16, 2), child: MenuItemWidget( captionedTextWidget: CaptionedTextWidget( title: _isFreePlanUser() @@ -328,8 +358,10 @@ class _StoreSubscriptionPageState extends State { ), ); widgets.add(ViewAddOnButton(_userDetails.bonusData)); - widgets.add(const SizedBox(height: 80)); } + + widgets.add(const SizedBox(height: 80)); + return SingleChildScrollView( child: Column( mainAxisAlignment: MainAxisAlignment.spaceEvenly, @@ -385,64 +417,6 @@ class _StoreSubscriptionPageState extends State { setState(() {}); } - Widget _showSubscriptionToggle() { - return Container( - padding: const EdgeInsets.only(left: 8, right: 8, top: 2, bottom: 2), - margin: const EdgeInsets.only(bottom: 6), - child: Column( - children: [ - RepaintBoundary( - child: SizedBox( - width: 250, - child: Row( - mainAxisSize: MainAxisSize.max, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Expanded( - child: SegmentedButton( - style: SegmentedButton.styleFrom( - selectedBackgroundColor: - getEnteColorScheme(context).fillMuted, - selectedForegroundColor: - getEnteColorScheme(context).textBase, - side: BorderSide( - color: getEnteColorScheme(context).strokeMuted, - width: 1, - ), - ), - segments: >[ - ButtonSegment( - label: Text(S.of(context).monthly), - value: false, - ), - ButtonSegment( - label: Text(S.of(context).yearly), - value: true, - ), - ], - selected: {showYearlyPlan}, - onSelectionChanged: (p0) { - showYearlyPlan = p0.first; - _filterStorePlansForUi(); - }, - ), - ), - ], - ), - ), - ), - _isFreePlanUser() && !UpdateService.instance.isPlayStoreFlavor() - ? Text( - S.of(context).twoMonthsFreeOnYearlyPlans, - style: getEnteTextTheme(context).miniMuted, - ) - : const SizedBox.shrink(), - const Padding(padding: EdgeInsets.all(8)), - ], - ), - ); - } - List _getStripePlanWidgets() { final List planWidgets = []; bool foundActivePlan = false; @@ -457,10 +431,27 @@ class _StoreSubscriptionPageState extends State { foundActivePlan = true; } planWidgets.add( - Material( - color: Colors.transparent, - child: InkWell( - onTap: () async { + GestureDetector( + onTap: () async { + if (widget.isOnboarding && plan.id == freeProductID) { + Bus.instance.fire(SubscriptionPurchasedEvent()); + // ignore: unawaited_futures + Navigator.of(context).pushAndRemoveUntil( + MaterialPageRoute( + builder: (BuildContext context) { + return const HomeWidget(); + }, + ), + (route) => false, + ); + unawaited( + BillingService.instance.verifySubscription( + freeProductID, + "", + paymentProvider: "ente", + ), + ); + } else { if (isActive) { return; } @@ -470,13 +461,15 @@ class _StoreSubscriptionPageState extends State { S.of(context).sorry, S.of(context).visitWebToManage, ); - }, - child: SubscriptionPlanWidget( - storage: plan.storage, - price: plan.price, - period: plan.period, - isActive: isActive && !_hideCurrentPlanSelection, - ), + } + }, + child: SubscriptionPlanWidget( + storage: plan.storage, + price: plan.price, + period: plan.period, + isActive: isActive && !_hideCurrentPlanSelection, + isPopular: _isPopularPlan(plan), + isOnboarding: widget.isOnboarding, ), ), ); @@ -494,11 +487,35 @@ class _StoreSubscriptionPageState extends State { _currentSubscription!.productID == freeProductID) { foundActivePlan = true; planWidgets.add( - SubscriptionPlanWidget( - storage: _freePlan.storage, - price: S.of(context).freeTrial, - period: "", - isActive: true, + GestureDetector( + onTap: () { + if (_currentSubscription!.isFreePlan() && widget.isOnboarding) { + Bus.instance.fire(SubscriptionPurchasedEvent()); + // ignore: unawaited_futures + Navigator.of(context).pushAndRemoveUntil( + MaterialPageRoute( + builder: (BuildContext context) { + return const HomeWidget(); + }, + ), + (route) => false, + ); + unawaited( + BillingService.instance.verifySubscription( + freeProductID, + "", + paymentProvider: "ente", + ), + ); + } + }, + child: SubscriptionPlanWidget( + storage: _freePlan.storage, + price: "", + period: S.of(context).freeTrial, + isActive: true, + isOnboarding: widget.isOnboarding, + ), ), ); } @@ -510,71 +527,71 @@ class _StoreSubscriptionPageState extends State { foundActivePlan = true; } planWidgets.add( - Material( - child: InkWell( - onTap: () async { - if (isActive) { - return; - } - final int addOnBonus = - _userDetails.bonusData?.totalAddOnBonus() ?? 0; - if (_userDetails.getFamilyOrPersonalUsage() > - (plan.storage + addOnBonus)) { - _logger.warning( - " familyUsage ${convertBytesToReadableFormat(_userDetails.getFamilyOrPersonalUsage())}" - " plan storage ${convertBytesToReadableFormat(plan.storage)} " - "addOnBonus ${convertBytesToReadableFormat(addOnBonus)}," - "overshooting by ${convertBytesToReadableFormat(_userDetails.getFamilyOrPersonalUsage() - (plan.storage + addOnBonus))}", - ); - // ignore: unawaited_futures - showErrorDialog( - context, - S.of(context).sorry, - S.of(context).youCannotDowngradeToThisPlan, - ); - return; - } - await _dialog.show(); - final ProductDetailsResponse response = - await InAppPurchase.instance.queryProductDetails({productID}); - if (response.notFoundIDs.isNotEmpty) { - final errMsg = "Could not find products: " + - response.notFoundIDs.toString(); - _logger.severe(errMsg); - await _dialog.hide(); - await showGenericErrorDialog( - context: context, - error: Exception(errMsg), - ); - return; - } - final isCrossGradingOnAndroid = Platform.isAndroid && - _hasActiveSubscription && - _currentSubscription!.productID != freeProductID && - _currentSubscription!.productID != plan.androidID; - if (isCrossGradingOnAndroid) { - await _dialog.hide(); - // ignore: unawaited_futures - showErrorDialog( - context, - S.of(context).couldNotUpdateSubscription, - S.of(context).pleaseContactSupportAndWeWillBeHappyToHelp, - ); - return; - } else { - await InAppPurchase.instance.buyNonConsumable( - purchaseParam: PurchaseParam( - productDetails: response.productDetails[0], - ), - ); - } - }, - child: SubscriptionPlanWidget( - storage: plan.storage, - price: plan.price, - period: plan.period, - isActive: isActive, - ), + GestureDetector( + onTap: () async { + if (isActive) { + return; + } + final int addOnBonus = + _userDetails.bonusData?.totalAddOnBonus() ?? 0; + if (_userDetails.getFamilyOrPersonalUsage() > + (plan.storage + addOnBonus)) { + _logger.warning( + " familyUsage ${convertBytesToReadableFormat(_userDetails.getFamilyOrPersonalUsage())}" + " plan storage ${convertBytesToReadableFormat(plan.storage)} " + "addOnBonus ${convertBytesToReadableFormat(addOnBonus)}," + "overshooting by ${convertBytesToReadableFormat(_userDetails.getFamilyOrPersonalUsage() - (plan.storage + addOnBonus))}", + ); + // ignore: unawaited_futures + showErrorDialog( + context, + S.of(context).sorry, + S.of(context).youCannotDowngradeToThisPlan, + ); + return; + } + await _dialog.show(); + final ProductDetailsResponse response = + await InAppPurchase.instance.queryProductDetails({productID}); + if (response.notFoundIDs.isNotEmpty) { + final errMsg = + "Could not find products: " + response.notFoundIDs.toString(); + _logger.severe(errMsg); + await _dialog.hide(); + await showGenericErrorDialog( + context: context, + error: Exception(errMsg), + ); + return; + } + final isCrossGradingOnAndroid = Platform.isAndroid && + _hasActiveSubscription && + _currentSubscription!.productID != freeProductID && + _currentSubscription!.productID != plan.androidID; + if (isCrossGradingOnAndroid) { + await _dialog.hide(); + // ignore: unawaited_futures + showErrorDialog( + context, + S.of(context).couldNotUpdateSubscription, + S.of(context).pleaseContactSupportAndWeWillBeHappyToHelp, + ); + return; + } else { + await InAppPurchase.instance.buyNonConsumable( + purchaseParam: PurchaseParam( + productDetails: response.productDetails[0], + ), + ); + } + }, + child: SubscriptionPlanWidget( + storage: plan.storage, + price: plan.price, + period: plan.period, + isActive: isActive, + isPopular: _isPopularPlan(plan), + isOnboarding: widget.isOnboarding, ), ), ); @@ -594,17 +611,40 @@ class _StoreSubscriptionPageState extends State { } planWidgets.insert( activePlanIndex, - Material( - child: InkWell( - onTap: () {}, - child: SubscriptionPlanWidget( - storage: _currentSubscription!.storage, - price: _currentSubscription!.price, - period: _currentSubscription!.period, - isActive: true, - ), + GestureDetector( + onTap: () { + if (_currentSubscription!.isFreePlan() & widget.isOnboarding) { + Bus.instance.fire(SubscriptionPurchasedEvent()); + // ignore: unawaited_futures + Navigator.of(context).pushAndRemoveUntil( + MaterialPageRoute( + builder: (BuildContext context) { + return const HomeWidget(); + }, + ), + (route) => false, + ); + unawaited( + BillingService.instance.verifySubscription( + freeProductID, + "", + paymentProvider: "ente", + ), + ); + } + }, + child: SubscriptionPlanWidget( + storage: _currentSubscription!.storage, + price: _currentSubscription!.price, + period: _currentSubscription!.period, + isActive: true, + isOnboarding: widget.isOnboarding, ), ), ); } + + bool _isPopularPlan(BillingPlan plan) { + return popularProductIDs.contains(plan.id); + } } diff --git a/mobile/lib/ui/payment/stripe_subscription_page.dart b/mobile/lib/ui/payment/stripe_subscription_page.dart index 31694f174b..7ab1a0f9cc 100644 --- a/mobile/lib/ui/payment/stripe_subscription_page.dart +++ b/mobile/lib/ui/payment/stripe_subscription_page.dart @@ -1,32 +1,32 @@ import 'dart:async'; -import "package:flutter/cupertino.dart"; -import "package:flutter/foundation.dart"; import 'package:flutter/material.dart'; import "package:logging/logging.dart"; +import "package:photos/core/event_bus.dart"; import 'package:photos/ente_theme_data.dart'; +import "package:photos/events/subscription_purchased_event.dart"; import "package:photos/generated/l10n.dart"; import 'package:photos/models/billing_plan.dart'; import 'package:photos/models/subscription.dart'; import 'package:photos/models/user_details.dart'; import 'package:photos/services/billing_service.dart'; -import "package:photos/services/update_service.dart"; import 'package:photos/services/user_service.dart'; import "package:photos/theme/colors.dart"; import 'package:photos/theme/ente_theme.dart'; -import 'package:photos/ui/common/bottom_shadow.dart'; import 'package:photos/ui/common/loading_widget.dart'; import 'package:photos/ui/common/progress_dialog.dart'; import 'package:photos/ui/common/web_page.dart'; import 'package:photos/ui/components/buttons/button_widget.dart'; import "package:photos/ui/components/captioned_text_widget.dart"; +import "package:photos/ui/components/divider_widget.dart"; import "package:photos/ui/components/menu_item_widget/menu_item_widget.dart"; +import "package:photos/ui/components/title_bar_title_widget.dart"; import 'package:photos/ui/payment/child_subscription_widget.dart'; import 'package:photos/ui/payment/payment_web_page.dart'; -import 'package:photos/ui/payment/skip_subscription_widget.dart'; import 'package:photos/ui/payment/subscription_common_widgets.dart'; import 'package:photos/ui/payment/subscription_plan_widget.dart'; import "package:photos/ui/payment/view_add_on_widget.dart"; +import "package:photos/ui/tabs/home_widget.dart"; import "package:photos/utils/data_util.dart"; import 'package:photos/utils/dialog_util.dart'; import 'package:photos/utils/toast_util.dart'; @@ -38,8 +38,8 @@ class StripeSubscriptionPage extends StatefulWidget { const StripeSubscriptionPage({ this.isOnboarding = false, - Key? key, - }) : super(key: key); + super.key, + }); @override State createState() => _StripeSubscriptionPageState(); @@ -64,11 +64,6 @@ class _StripeSubscriptionPageState extends State { EnteColorScheme colorScheme = darkScheme; final Logger logger = Logger("StripeSubscriptionPage"); - @override - void initState() { - super.initState(); - } - Future _fetchSub() async { return _userService .getUserDetailsV2(memoryCount: false) @@ -127,59 +122,65 @@ class _StripeSubscriptionPageState extends State { } } - @override - void dispose() { - super.dispose(); - } - @override Widget build(BuildContext context) { colorScheme = getEnteColorScheme(context); - final appBar = PreferredSize( - preferredSize: const Size(double.infinity, 60), - child: Container( - decoration: BoxDecoration( - boxShadow: [ - BoxShadow( - color: Theme.of(context).colorScheme.background, - blurRadius: 16, - offset: const Offset(0, 8), - ), - ], - ), - child: widget.isOnboarding - ? AppBar( - elevation: 0, - title: Hero( - tag: "subscription", - child: StepProgressIndicator( - totalSteps: 4, - currentStep: 4, - selectedColor: - Theme.of(context).colorScheme.greenAlternative, - roundedEdges: const Radius.circular(10), - unselectedColor: Theme.of(context) - .colorScheme - .stepProgressUnselectedColor, - ), + final textTheme = getEnteTextTheme(context); + + return Scaffold( + 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( - elevation: 0, - title: Text("${S.of(context).subscription}${kDebugMode ? ' ' - 'Stripe' : ''}"), ), - ), - ); - return Scaffold( - appBar: appBar, - body: Stack( - alignment: Alignment.bottomCenter, + ) + : AppBar( + scrolledUnderElevation: 0, + toolbarHeight: 48, + leadingWidth: 48, + leading: GestureDetector( + onTap: () { + Navigator.pop(context); + }, + child: const Icon( + Icons.arrow_back_outlined, + ), + ), + ), + body: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - _getBody(), - const BottomShadowWidget( - offsetDy: 40, + Padding( + padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TitleBarTitleWidget( + title: + widget.isOnboarding ? "Select your plan" : "Subscription", + ), + _isFreePlanUser() || !_hasLoadedData + ? const SizedBox.shrink() + : Text( + convertBytesToReadableFormat( + _userDetails.getTotalStorage(), + ), + style: textTheme.smallMuted, + ), + ], + ), ), + Expanded(child: _getBody()), ], ), ); @@ -211,6 +212,15 @@ class _StripeSubscriptionPageState extends State { ), ); + widgets.add( + SubscriptionToggle( + onToggle: (p0) { + _showYearlyPlan = p0; + _filterStripeForUI(); + }, + ), + ); + widgets.addAll([ Column( mainAxisAlignment: MainAxisAlignment.center, @@ -219,8 +229,6 @@ class _StripeSubscriptionPageState extends State { const Padding(padding: EdgeInsets.all(4)), ]); - widgets.add(_showSubscriptionToggle()); - if (_currentSubscription != null) { widgets.add( ValidityWidget( @@ -228,29 +236,26 @@ class _StripeSubscriptionPageState extends State { bonusData: _userDetails.bonusData, ), ); + widgets.add(const DividerWidget(dividerType: DividerType.bottomBar)); + widgets.add(const SizedBox(height: 20)); + } else { + widgets.add(const DividerWidget(dividerType: DividerType.bottomBar)); + const SizedBox(height: 56); } if (_currentSubscription!.productID == freeProductID) { - if (widget.isOnboarding) { - widgets.add(SkipSubscriptionWidget(freePlan: _freePlan)); - } widgets.add( SubFaqWidget(isOnboarding: widget.isOnboarding), ); } - // only active subscription can be renewed/canceled - if (_hasActiveSubscription && _isStripeSubscriber) { - widgets.add(_stripeRenewOrCancelButton()); - } - - if (_currentSubscription!.productID != freeProductID) { + if (!widget.isOnboarding) { widgets.add( Padding( - padding: const EdgeInsets.fromLTRB(16, 40, 16, 4), + padding: const EdgeInsets.fromLTRB(16, 2, 16, 2), child: MenuItemWidget( captionedTextWidget: CaptionedTextWidget( - title: S.of(context).paymentDetails, + title: S.of(context).manageFamily, ), menuItemColor: colorScheme.fillFaint, trailingWidget: Icon( @@ -260,20 +265,22 @@ class _StripeSubscriptionPageState extends State { singleBorderRadius: 4, alignCaptionedTextToLeft: true, onTap: () async { - _redirectToPaymentPortal(); + // ignore: unawaited_futures + _billingService.launchFamilyPortal(context, _userDetails); }, ), ), ); + widgets.add(ViewAddOnButton(_userDetails.bonusData)); } - if (!widget.isOnboarding) { + if (_currentSubscription!.productID != freeProductID) { 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, + title: "Manage payment method", ), menuItemColor: colorScheme.fillFaint, trailingWidget: Icon( @@ -283,16 +290,25 @@ class _StripeSubscriptionPageState extends State { singleBorderRadius: 4, alignCaptionedTextToLeft: true, onTap: () async { - // ignore: unawaited_futures - _billingService.launchFamilyPortal(context, _userDetails); + _redirectToPaymentPortal(); }, ), ), ); - widgets.add(ViewAddOnButton(_userDetails.bonusData)); - widgets.add(const SizedBox(height: 80)); } + // only active subscription can be renewed/canceled + if (_hasActiveSubscription && _isStripeSubscriber) { + widgets.add( + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 2), + child: _stripeRenewOrCancelButton(), + ), + ); + } + + widgets.add(const SizedBox(height: 80)); + return SingleChildScrollView( child: Column( mainAxisAlignment: MainAxisAlignment.spaceEvenly, @@ -360,16 +376,20 @@ class _StripeSubscriptionPageState extends State { final String title = isRenewCancelled ? S.of(context).renewSubscription : S.of(context).cancelSubscription; - return TextButton( - child: Text( - title, - style: TextStyle( - color: (isRenewCancelled - ? colorScheme.primary700 - : colorScheme.textMuted), - ), + return MenuItemWidget( + captionedTextWidget: CaptionedTextWidget( + title: title, + ), + alwaysShowSuccessState: false, + surfaceExecutionStates: false, + menuItemColor: colorScheme.fillFaint, + trailingWidget: Icon( + Icons.chevron_right_outlined, + color: colorScheme.strokeBase, ), - onPressed: () async { + singleBorderRadius: 4, + alignCaptionedTextToLeft: true, + onTap: () async { bool confirmAction = false; if (isRenewCancelled) { final choice = await showChoiceDialog( @@ -452,9 +472,27 @@ class _StripeSubscriptionPageState extends State { foundActivePlan = true; } planWidgets.add( - Material( - child: InkWell( - onTap: () async { + GestureDetector( + onTap: () async { + if (widget.isOnboarding && plan.id == freeProductID) { + Bus.instance.fire(SubscriptionPurchasedEvent()); + // ignore: unawaited_futures + Navigator.of(context).pushAndRemoveUntil( + MaterialPageRoute( + builder: (BuildContext context) { + return const HomeWidget(); + }, + ), + (route) => false, + ); + unawaited( + BillingService.instance.verifySubscription( + freeProductID, + "", + paymentProvider: "ente", + ), + ); + } else { if (isActive) { return; } @@ -515,13 +553,15 @@ class _StripeSubscriptionPageState extends State { }, ), ).then((value) => onWebPaymentGoBack(value)); - }, - child: SubscriptionPlanWidget( - storage: plan.storage, - price: plan.price, - period: plan.period, - isActive: isActive && !_hideCurrentPlanSelection, - ), + } + }, + child: SubscriptionPlanWidget( + storage: plan.storage, + price: plan.price, + period: plan.period, + isActive: isActive && !_hideCurrentPlanSelection, + isPopular: _isPopularPlan(plan), + isOnboarding: widget.isOnboarding, ), ), ); @@ -537,67 +577,14 @@ class _StripeSubscriptionPageState extends State { freeProductID == _currentSubscription!.productID; } - Widget _showSubscriptionToggle() { - return Container( - padding: const EdgeInsets.only(left: 8, right: 8, top: 2, bottom: 2), - margin: const EdgeInsets.only(bottom: 6), - child: Column( - children: [ - RepaintBoundary( - child: SizedBox( - width: 250, - child: Row( - mainAxisSize: MainAxisSize.max, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Expanded( - child: SegmentedButton( - style: SegmentedButton.styleFrom( - selectedBackgroundColor: - getEnteColorScheme(context).fillMuted, - selectedForegroundColor: - getEnteColorScheme(context).textBase, - side: BorderSide( - color: getEnteColorScheme(context).strokeMuted, - width: 1, - ), - ), - segments: >[ - ButtonSegment( - label: Text(S.of(context).monthly), - value: false, - ), - ButtonSegment( - label: Text(S.of(context).yearly), - value: true, - ), - ], - selected: {_showYearlyPlan}, - onSelectionChanged: (p0) { - _showYearlyPlan = p0.first; - _filterStripeForUI(); - }, - ), - ), - ], - ), - ), - ), - _isFreePlanUser() && !UpdateService.instance.isPlayStoreFlavor() - ? Text( - S.of(context).twoMonthsFreeOnYearlyPlans, - style: getEnteTextTheme(context).miniMuted, - ) - : const SizedBox.shrink(), - const Padding(padding: EdgeInsets.all(8)), - ], - ), - ); + bool _isPopularPlan(BillingPlan plan) { + return popularProductIDs.contains(plan.id); } void _addCurrentPlanWidget(List planWidgets) { // don't add current plan if it's monthly plan but UI is showing yearly plans // and vice versa. + if (_showYearlyPlan != _currentSubscription!.isYearlyPlan() && _currentSubscription!.productID != freeProductID) { return; @@ -610,15 +597,34 @@ class _StripeSubscriptionPageState extends State { } planWidgets.insert( activePlanIndex, - Material( - child: InkWell( - onTap: () {}, - child: SubscriptionPlanWidget( - storage: _currentSubscription!.storage, - price: _currentSubscription!.price, - period: _currentSubscription!.period, - isActive: _currentSubscription!.isValid(), - ), + GestureDetector( + onTap: () { + if (_currentSubscription!.isFreePlan() && widget.isOnboarding) { + Bus.instance.fire(SubscriptionPurchasedEvent()); + // ignore: unawaited_futures + Navigator.of(context).pushAndRemoveUntil( + MaterialPageRoute( + builder: (BuildContext context) { + return const HomeWidget(); + }, + ), + (route) => false, + ); + unawaited( + BillingService.instance.verifySubscription( + freeProductID, + "", + paymentProvider: "ente", + ), + ); + } + }, + child: SubscriptionPlanWidget( + storage: _currentSubscription!.storage, + price: _currentSubscription!.price, + period: _currentSubscription!.period, + isActive: _currentSubscription!.isValid(), + isOnboarding: widget.isOnboarding, ), ), ); diff --git a/mobile/lib/ui/payment/subscription_common_widgets.dart b/mobile/lib/ui/payment/subscription_common_widgets.dart index 6d3cf66594..5a1c95c563 100644 --- a/mobile/lib/ui/payment/subscription_common_widgets.dart +++ b/mobile/lib/ui/payment/subscription_common_widgets.dart @@ -4,7 +4,6 @@ import 'package:photos/ente_theme_data.dart'; import "package:photos/generated/l10n.dart"; import "package:photos/models/api/storage_bonus/bonus.dart"; import 'package:photos/models/subscription.dart'; -import "package:photos/services/update_service.dart"; import "package:photos/theme/ente_theme.dart"; import "package:photos/ui/components/captioned_text_widget.dart"; import "package:photos/ui/components/menu_item_widget/menu_item_widget.dart"; @@ -16,10 +15,10 @@ class SubscriptionHeaderWidget extends StatefulWidget { final int? currentUsage; const SubscriptionHeaderWidget({ - Key? key, + super.key, this.isOnboarding, this.currentUsage, - }) : super(key: key); + }); @override State createState() { @@ -30,51 +29,34 @@ class SubscriptionHeaderWidget extends StatefulWidget { class _SubscriptionHeaderWidgetState extends State { @override Widget build(BuildContext context) { + final textTheme = getEnteTextTheme(context); + final colorScheme = getEnteColorScheme(context); if (widget.isOnboarding!) { return Padding( - padding: const EdgeInsets.fromLTRB(20, 20, 20, 24), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - S.of(context).selectYourPlan, - style: Theme.of(context).textTheme.headlineMedium, - ), - const SizedBox(height: 10), - Text( - S.of(context).enteSubscriptionPitch, - style: Theme.of(context).textTheme.bodySmall, - ), - const SizedBox(height: 4), - Text( - S.of(context).enteSubscriptionShareWithFamily, - style: Theme.of(context).textTheme.bodySmall, - ), - ], + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Text( + S.of(context).enteSubscriptionPitch, + style: getEnteTextTheme(context).smallFaint, ), ); } else { - return SizedBox( - height: 72, - width: double.infinity, - child: Padding( - padding: const EdgeInsets.all(24.0), - child: RichText( - text: TextSpan( - children: [ - TextSpan( - text: S.of(context).currentUsageIs, - style: Theme.of(context).textTheme.titleMedium, - ), - TextSpan( - text: formatBytes(widget.currentUsage!), - style: Theme.of(context) - .textTheme - .titleMedium! - .copyWith(fontWeight: FontWeight.bold), + 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, ), - ], - ), + ), + ], ), ), ); @@ -86,15 +68,17 @@ class ValidityWidget extends StatelessWidget { final Subscription? currentSubscription; final BonusData? bonusData; - const ValidityWidget({Key? key, this.currentSubscription, this.bonusData}) - : super(key: key); + const ValidityWidget({super.key, this.currentSubscription, this.bonusData}); @override Widget build(BuildContext context) { - if (currentSubscription == null) { - return const SizedBox.shrink(); - } final List addOnBonus = bonusData?.getAddOnBonuses() ?? []; + if (currentSubscription == null || + (currentSubscription!.isFreePlan() && addOnBonus.isEmpty)) { + return const SizedBox( + height: 56, + ); + } final bool isFreeTrialSub = currentSubscription!.productID == freeProductID; bool hideSubValidityView = false; if (isFreeTrialSub && addOnBonus.isNotEmpty) { @@ -109,11 +93,7 @@ class ValidityWidget extends StatelessWidget { ); var message = S.of(context).renewsOn(endDate); - if (isFreeTrialSub) { - message = UpdateService.instance.isPlayStoreFlavor() - ? S.of(context).playStoreFreeTrialValidTill(endDate) - : S.of(context).freeTrialValidTill(endDate); - } else if (currentSubscription!.attributes?.isCancelled ?? false) { + if (currentSubscription!.attributes?.isCancelled ?? false) { message = S.of(context).subWillBeCancelledOn(endDate); if (addOnBonus.isNotEmpty) { hideSubValidityView = true; @@ -121,15 +101,21 @@ class ValidityWidget extends StatelessWidget { } return Padding( - padding: const EdgeInsets.only(top: 0), + padding: const EdgeInsets.fromLTRB(16, 16, 16, 16), child: Column( children: [ if (!hideSubValidityView) - Text( - message, - style: Theme.of(context).textTheme.bodySmall, - textAlign: TextAlign.center, + Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Text( + message, + style: getEnteTextTheme(context).body.copyWith( + fontWeight: FontWeight.w600, + ), + textAlign: TextAlign.center, + ), ), + const SizedBox(height: 8), if (addOnBonus.isNotEmpty) ...addOnBonus.map((bonus) => AddOnBonusValidity(bonus)).toList(), ], @@ -151,10 +137,10 @@ class AddOnBonusValidity extends StatelessWidget { ); final String storage = convertBytesToReadableFormat(bonus.storage); return Padding( - padding: const EdgeInsets.only(top: 8, bottom: 8), + padding: const EdgeInsets.only(top: 4, bottom: 4), child: Text( S.of(context).addOnValidTill(storage, endDate), - style: Theme.of(context).textTheme.bodySmall, + style: getEnteTextTheme(context).smallFaint, textAlign: TextAlign.center, ), ); @@ -170,7 +156,7 @@ class SubFaqWidget extends StatelessWidget { Widget build(BuildContext context) { final colorScheme = getEnteColorScheme(context); return Padding( - padding: EdgeInsets.fromLTRB(16, 40, 16, isOnboarding ? 40 : 4), + padding: const EdgeInsets.fromLTRB(16, 2, 16, 2), child: MenuItemWidget( captionedTextWidget: CaptionedTextWidget( title: S.of(context).faqs, @@ -197,3 +183,120 @@ class SubFaqWidget extends StatelessWidget { ); } } + +class SubscriptionToggle extends StatefulWidget { + final Function(bool) onToggle; + const SubscriptionToggle({required this.onToggle, super.key}); + + @override + State createState() => _SubscriptionToggleState(); +} + +class _SubscriptionToggleState extends State { + bool _isYearly = true; + @override + Widget build(BuildContext context) { + const borderPadding = 2.5; + const spaceBetweenButtons = 4.0; + final textTheme = getEnteTextTheme(context); + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 32), + child: LayoutBuilder( + builder: (context, constrains) { + final widthOfButton = (constrains.maxWidth - + (borderPadding * 2) - + spaceBetweenButtons) / + 2; + return Container( + decoration: BoxDecoration( + color: getEnteColorScheme(context).fillBaseGrey, + borderRadius: BorderRadius.circular(50), + ), + padding: const EdgeInsets.symmetric( + vertical: borderPadding, + horizontal: borderPadding, + ), + width: double.infinity, + child: Stack( + children: [ + Row( + children: [ + GestureDetector( + onTap: () { + setIsYearly(false); + }, + behavior: HitTestBehavior.opaque, + child: Container( + padding: const EdgeInsets.symmetric( + vertical: 8, + ), + width: widthOfButton, + child: Center( + child: Text( + "Monthly", + style: textTheme.bodyFaint, + ), + ), + ), + ), + const SizedBox(width: spaceBetweenButtons), + GestureDetector( + onTap: () { + setIsYearly(true); + }, + behavior: HitTestBehavior.opaque, + child: Container( + padding: const EdgeInsets.symmetric( + vertical: 8, + ), + width: widthOfButton, + child: Center( + child: Text( + "Yearly", + style: textTheme.bodyFaint, + ), + ), + ), + ), + ], + ), + AnimatedPositioned( + duration: const Duration(milliseconds: 350), + curve: Curves.easeInOutQuart, + left: _isYearly ? widthOfButton + spaceBetweenButtons : 0, + child: Container( + padding: const EdgeInsets.symmetric( + vertical: 8, + ), + width: widthOfButton, + decoration: BoxDecoration( + color: getEnteColorScheme(context).backgroundBase, + borderRadius: BorderRadius.circular(50), + ), + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 350), + switchInCurve: Curves.easeInOutExpo, + switchOutCurve: Curves.easeInOutExpo, + child: Text( + key: ValueKey(_isYearly), + _isYearly ? "Yearly" : "Monthly", + style: textTheme.body, + ), + ), + ), + ), + ], + ), + ); + }, + ), + ); + } + + setIsYearly(bool isYearly) { + setState(() { + _isYearly = isYearly; + }); + widget.onToggle(isYearly); + } +} diff --git a/mobile/lib/ui/payment/subscription_plan_widget.dart b/mobile/lib/ui/payment/subscription_plan_widget.dart index 00d8769fe8..185c4e0462 100644 --- a/mobile/lib/ui/payment/subscription_plan_widget.dart +++ b/mobile/lib/ui/payment/subscription_plan_widget.dart @@ -1,73 +1,127 @@ +import "package:flutter/foundation.dart"; import 'package:flutter/material.dart'; -import "package:photos/generated/l10n.dart"; +import "package:flutter/scheduler.dart"; +import "package:flutter_animate/flutter_animate.dart"; +import "package:photos/theme/colors.dart"; +import "package:photos/theme/ente_theme.dart"; import 'package:photos/utils/data_util.dart'; -class SubscriptionPlanWidget extends StatelessWidget { +class SubscriptionPlanWidget extends StatefulWidget { const SubscriptionPlanWidget({ - Key? key, + super.key, required this.storage, required this.price, required this.period, + required this.isOnboarding, this.isActive = false, - }) : super(key: key); + this.isPopular = false, + }); final int storage; final String price; final String period; final bool isActive; + final bool isPopular; + final bool isOnboarding; - String _displayPrice(BuildContext context) { - // todo: l10n pricing part - final result = price + (period.isNotEmpty ? " / " + period : ""); - return price.isNotEmpty ? result : S.of(context).freeTrial; + @override + State createState() => _SubscriptionPlanWidgetState(); +} + +class _SubscriptionPlanWidgetState extends State { + late final PlatformDispatcher _platformDispatcher; + + @override + void initState() { + super.initState(); + _platformDispatcher = SchedulerBinding.instance.platformDispatcher; } @override Widget build(BuildContext context) { - final Color textColor = isActive ? Colors.white : Colors.black; - return Container( - width: double.infinity, - color: Theme.of(context).colorScheme.onPrimary, - padding: EdgeInsets.symmetric(horizontal: isActive ? 8 : 16, vertical: 4), + final brightness = _platformDispatcher.platformBrightness; + final numAndUnit = convertBytesToNumberAndUnit(widget.storage); + final String storageValue = numAndUnit.$1.toString(); + final String storageUnit = numAndUnit.$2; + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), child: Container( decoration: BoxDecoration( - color: isActive - ? const Color(0xFF22763F) - : const Color.fromRGBO(240, 240, 240, 1.0), - gradient: isActive - ? const LinearGradient( - begin: Alignment.centerLeft, - end: Alignment.centerRight, - colors: [ - Color(0xFF2CD267), - Color(0xFF1DB954), - ], + color: backgroundElevated2Light, + borderRadius: BorderRadius.circular(8), + border: widget.isActive + ? Border.all( + color: getEnteColorScheme(context).primary700, + width: brightness == Brightness.dark ? 1.5 : 1, + strokeAlign: BorderSide.strokeAlignInside, ) : null, + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.08), + offset: const Offset(0, 4), + blurRadius: 4, + ), + ], ), - // color: Colors.yellow, - padding: - EdgeInsets.symmetric(horizontal: isActive ? 22 : 20, vertical: 18), - child: Column( + child: Stack( children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - convertBytesToReadableFormat(storage), - style: Theme.of(context) - .textTheme - .titleLarge! - .copyWith(color: textColor), - ), - Text( - _displayPrice(context), - style: Theme.of(context).textTheme.titleLarge!.copyWith( - color: textColor, - fontWeight: FontWeight.normal, + widget.isActive && !widget.isOnboarding + ? Positioned( + top: 0, + right: 0, + child: ClipRRect( + borderRadius: const BorderRadius.only( + topRight: Radius.circular(8), + ), + child: Image.asset( + "assets/active_subscription.png", ), - ), - ], + ), + ) + : widget.isPopular + ? ClipRRect( + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(8), + ), + child: Image.asset( + "assets/popular_subscription.png", + ), + ) + : const SizedBox.shrink(), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + RichText( + text: TextSpan( + children: [ + TextSpan( + text: storageValue, + style: const TextStyle( + fontSize: 40, + fontWeight: FontWeight.w600, + color: textBaseLight, + ), + ), + WidgetSpan( + child: Transform.translate( + offset: const Offset(2, -16), + child: Text( + storageUnit, + style: getEnteTextTheme(context).h3.copyWith( + color: textMutedLight, + ), + ), + ), + ), + ], + ), + ), + _Price(price: widget.price, period: widget.period), + ], + ), ), ], ), @@ -75,3 +129,57 @@ class SubscriptionPlanWidget extends StatelessWidget { ); } } + +class _Price extends StatelessWidget { + final String price; + final String period; + const _Price({required this.price, required this.period}); + + @override + Widget build(BuildContext context) { + final textTheme = getEnteTextTheme(context); + if (price.isEmpty) { + return Text( + "Free", + style: textTheme.largeBold.copyWith(color: textBaseLight), + ); + } + if (period == "month") { + return Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + price + ' / ' + 'month', + style: textTheme.largeBold.copyWith(color: textBaseLight), + ) + .animate(delay: const Duration(milliseconds: 100)) + .fadeIn(duration: const Duration(milliseconds: 250)), + ], + ); + } else if (period == "year") { + final currencySymbol = price[0]; + final priceWithoutCurrency = price.substring(1); + final priceDouble = double.parse(priceWithoutCurrency); + final pricePerMonth = priceDouble / 12; + final pricePerMonthString = pricePerMonth.toStringAsFixed(2); + return Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + currencySymbol + pricePerMonthString + ' / ' + 'month', + style: textTheme.largeBold.copyWith(color: textBaseLight), + ), + Text( + price + " / " + "yr", + style: textTheme.small.copyWith(color: textFaintLight), + ), + ], + ) + .animate(delay: const Duration(milliseconds: 100)) + .fadeIn(duration: const Duration(milliseconds: 250)); + } else { + assert(false, "Invalid period: $period"); + return const Text(""); + } + } +} diff --git a/mobile/lib/ui/payment/view_add_on_widget.dart b/mobile/lib/ui/payment/view_add_on_widget.dart index 18c5c10d92..5dd392d46e 100644 --- a/mobile/lib/ui/payment/view_add_on_widget.dart +++ b/mobile/lib/ui/payment/view_add_on_widget.dart @@ -20,7 +20,7 @@ class ViewAddOnButton extends StatelessWidget { } final EnteColorScheme colorScheme = getEnteColorScheme(context); return Padding( - padding: const EdgeInsets.fromLTRB(16, 4, 16, 0), + padding: const EdgeInsets.fromLTRB(16, 2, 16, 2), child: MenuItemWidget( captionedTextWidget: CaptionedTextWidget( title: S.of(context).viewAddOnButton, diff --git a/mobile/lib/ui/settings/lock_screen/lock_screen_options.dart b/mobile/lib/ui/settings/lock_screen/lock_screen_options.dart index 7b9ba31ac5..cbda333080 100644 --- a/mobile/lib/ui/settings/lock_screen/lock_screen_options.dart +++ b/mobile/lib/ui/settings/lock_screen/lock_screen_options.dart @@ -1,3 +1,5 @@ +import "dart:io"; + import "package:flutter/material.dart"; import "package:photos/core/configuration.dart"; import "package:photos/generated/l10n.dart"; @@ -29,9 +31,12 @@ class _LockScreenOptionsState extends State { 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 || @@ -42,9 +47,12 @@ class _LockScreenOptionsState extends State { Future _initializeSettings() async { final bool passwordEnabled = await _lockscreenSetting.isPasswordSet(); final bool pinEnabled = await _lockscreenSetting.isPinSet(); + final bool shouldShowAppContent = + _lockscreenSetting.getShouldHideAppContent(); setState(() { isPasswordEnabled = passwordEnabled; isPinEnabled = pinEnabled; + hideAppContent = shouldShowAppContent; }); } @@ -104,12 +112,24 @@ class _LockScreenOptionsState extends State { AppLock.of(context)!.setEnabled(!appLock); await _configuration.setSystemLockScreen(!appLock); await _lockscreenSetting.removePinAndPassword(); + if (appLock == true) { + await _lockscreenSetting.setHideAppContent(false); + } setState(() { _initializeSettings(); appLock = !appLock; }); } + Future _tapHideContent() 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' : ''}"; @@ -159,114 +179,166 @@ class _LockScreenOptionsState extends State { onChanged: () => _onToggleSwitch(), ), ), - !appLock - ? Padding( - padding: const EdgeInsets.only( - top: 14, - left: 14, - right: 12, - ), - child: Text( - S.of(context).appLockDescription, - style: textTheme.miniFaint, - textAlign: TextAlign.left, - ), - ) - : const SizedBox(), + AnimatedSwitcher( + duration: const Duration(milliseconds: 210), + switchInCurve: Curves.easeOut, + switchOutCurve: Curves.easeIn, + child: !appLock + ? Padding( + padding: const EdgeInsets.only( + top: 14, + left: 14, + right: 12, + ), + child: Text( + S.of(context).appLockDescription, + style: textTheme.miniFaint, + textAlign: TextAlign.left, + ), + ) + : const SizedBox(), + ), const Padding( padding: EdgeInsets.only(top: 24), ), ], ), - appLock - ? Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - MenuItemWidget( - captionedTextWidget: CaptionedTextWidget( - title: S.of(context).deviceLock, + AnimatedSwitcher( + duration: const Duration(milliseconds: 210), + switchInCurve: Curves.easeOut, + switchOutCurve: Curves.easeIn, + child: appLock + ? Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MenuItemWidget( + captionedTextWidget: CaptionedTextWidget( + title: S.of(context).deviceLock, + ), + surfaceExecutionStates: false, + alignCaptionedTextToLeft: true, + isTopBorderRadiusRemoved: false, + isBottomBorderRadiusRemoved: true, + menuItemColor: colorTheme.fillFaint, + trailingIcon: + !(isPasswordEnabled || isPinEnabled) + ? Icons.check + : null, + trailingIconColor: colorTheme.tabIcon, + onTap: () => _deviceLock(), ), - alignCaptionedTextToLeft: true, - isTopBorderRadiusRemoved: false, - isBottomBorderRadiusRemoved: true, - menuItemColor: colorTheme.fillFaint, - trailingIcon: - !(isPasswordEnabled || isPinEnabled) - ? Icons.check - : null, - trailingIconColor: colorTheme.tabIcon, - onTap: () => _deviceLock(), - ), - DividerWidget( - dividerType: DividerType.menuNoIcon, - bgColor: colorTheme.fillFaint, - ), - MenuItemWidget( - captionedTextWidget: CaptionedTextWidget( - title: S.of(context).pinLock, + DividerWidget( + dividerType: DividerType.menuNoIcon, + bgColor: colorTheme.fillFaint, ), - alignCaptionedTextToLeft: true, - isTopBorderRadiusRemoved: true, - isBottomBorderRadiusRemoved: true, - menuItemColor: colorTheme.fillFaint, - trailingIcon: - isPinEnabled ? Icons.check : null, - trailingIconColor: colorTheme.tabIcon, - onTap: () => _pinLock(), - ), - DividerWidget( - dividerType: DividerType.menuNoIcon, - bgColor: colorTheme.fillFaint, - ), - MenuItemWidget( - captionedTextWidget: CaptionedTextWidget( - title: S.of(context).passwordLock, + MenuItemWidget( + captionedTextWidget: CaptionedTextWidget( + title: S.of(context).pinLock, + ), + surfaceExecutionStates: false, + alignCaptionedTextToLeft: true, + isTopBorderRadiusRemoved: true, + isBottomBorderRadiusRemoved: true, + menuItemColor: colorTheme.fillFaint, + trailingIcon: + isPinEnabled ? Icons.check : null, + trailingIconColor: colorTheme.tabIcon, + onTap: () => _pinLock(), + ), + DividerWidget( + dividerType: DividerType.menuNoIcon, + bgColor: colorTheme.fillFaint, + ), + MenuItemWidget( + captionedTextWidget: CaptionedTextWidget( + title: S.of(context).passwordLock, + ), + surfaceExecutionStates: false, + alignCaptionedTextToLeft: true, + isTopBorderRadiusRemoved: true, + isBottomBorderRadiusRemoved: false, + menuItemColor: colorTheme.fillFaint, + trailingIcon: isPasswordEnabled + ? Icons.check + : null, + trailingIconColor: colorTheme.tabIcon, + onTap: () => _passwordLock(), ), - alignCaptionedTextToLeft: true, - isTopBorderRadiusRemoved: true, - isBottomBorderRadiusRemoved: false, - menuItemColor: colorTheme.fillFaint, - trailingIcon: - isPasswordEnabled ? Icons.check : null, - trailingIconColor: colorTheme.tabIcon, - onTap: () => _passwordLock(), - ), - const SizedBox( - height: 24, - ), - MenuItemWidget( - captionedTextWidget: CaptionedTextWidget( - title: S.of(context).autoLock, - subTitle: _formatTime( - Duration( - milliseconds: - autoLockTimeInMilliseconds, + const SizedBox( + height: 24, + ), + MenuItemWidget( + captionedTextWidget: CaptionedTextWidget( + title: S.of(context).autoLock, + subTitle: _formatTime( + Duration( + milliseconds: + autoLockTimeInMilliseconds, + ), ), ), + surfaceExecutionStates: false, + trailingIcon: + Icons.chevron_right_outlined, + trailingIconIsMuted: true, + alignCaptionedTextToLeft: true, + singleBorderRadius: 8, + menuItemColor: colorTheme.fillFaint, + trailingIconColor: colorTheme.tabIcon, + onTap: () => _onAutolock(), + ), + Padding( + padding: const EdgeInsets.only( + top: 14, + left: 14, + right: 12, + ), + child: Text( + S + .of(context) + .autoLockFeatureDescription, + style: textTheme.miniFaint, + textAlign: TextAlign.left, + ), ), - trailingIcon: Icons.chevron_right_outlined, - trailingIconIsMuted: true, - alignCaptionedTextToLeft: true, - singleBorderRadius: 8, - menuItemColor: colorTheme.fillFaint, - trailingIconColor: colorTheme.tabIcon, - onTap: () => _onAutolock(), - ), - Padding( - padding: const EdgeInsets.only( - top: 14, - left: 14, - right: 12, + const SizedBox( + height: 24, ), - child: Text( - S.of(context).autoLockFeatureDescription, - style: textTheme.miniFaint, - textAlign: TextAlign.left, + MenuItemWidget( + captionedTextWidget: CaptionedTextWidget( + title: S.of(context).hideContent, + ), + alignCaptionedTextToLeft: true, + singleBorderRadius: 8, + menuItemColor: colorTheme.fillFaint, + trailingWidget: ToggleSwitchWidget( + value: () => hideAppContent, + onChanged: () => _tapHideContent(), + ), + trailingIconColor: colorTheme.tabIcon, ), - ), - ], - ) - : Container(), + Padding( + padding: const EdgeInsets.only( + top: 14, + left: 14, + right: 12, + ), + child: Text( + Platform.isAndroid + ? S + .of(context) + .hideContentDescriptionAndroid + : S + .of(context) + .hideContentDescriptionIos, + style: textTheme.miniFaint, + textAlign: TextAlign.left, + ), + ), + ], + ) + : Container(), + ), ], ), ), diff --git a/mobile/lib/ui/settings/security_section_widget.dart b/mobile/lib/ui/settings/security_section_widget.dart index 4e51799a1a..a2ffe4f1ff 100644 --- a/mobile/lib/ui/settings/security_section_widget.dart +++ b/mobile/lib/ui/settings/security_section_widget.dart @@ -103,16 +103,6 @@ class _SecuritySectionWidgetState extends State { ), ), sectionOptionSpacing, - MenuItemWidget( - captionedTextWidget: CaptionedTextWidget( - title: context.l10n.passkey, - ), - pressedColor: getEnteColorScheme(context).fillFaint, - trailingIcon: Icons.chevron_right_outlined, - trailingIconIsMuted: true, - onTap: () async => await onPasskeyClick(context), - ), - sectionOptionSpacing, MenuItemWidget( captionedTextWidget: CaptionedTextWidget( title: S.of(context).emailVerificationToggle, @@ -135,6 +125,16 @@ class _SecuritySectionWidgetState extends State { ), ), sectionOptionSpacing, + MenuItemWidget( + captionedTextWidget: CaptionedTextWidget( + title: context.l10n.passkey, + ), + pressedColor: getEnteColorScheme(context).fillFaint, + trailingIcon: Icons.chevron_right_outlined, + trailingIconIsMuted: true, + onTap: () async => await onPasskeyClick(context), + ), + sectionOptionSpacing, ], ); } @@ -145,6 +145,7 @@ class _SecuritySectionWidgetState extends State { ), trailingIcon: Icons.chevron_right_outlined, trailingIconIsMuted: true, + surfaceExecutionStates: false, onTap: () async { if (await LocalAuthentication().isDeviceSupported()) { final bool result = await requestAuthentication( diff --git a/mobile/lib/ui/sharing/manage_links_widget.dart b/mobile/lib/ui/sharing/manage_links_widget.dart index 3478d1864f..6c50e80008 100644 --- a/mobile/lib/ui/sharing/manage_links_widget.dart +++ b/mobile/lib/ui/sharing/manage_links_widget.dart @@ -28,7 +28,7 @@ import 'package:photos/utils/toast_util.dart'; class ManageSharedLinkWidget extends StatefulWidget { final Collection? collection; - const ManageSharedLinkWidget({Key? key, this.collection}) : super(key: key); + const ManageSharedLinkWidget({super.key, this.collection}); @override State createState() => _ManageSharedLinkWidgetState(); @@ -37,6 +37,7 @@ class ManageSharedLinkWidget extends StatefulWidget { class _ManageSharedLinkWidgetState extends State { final CollectionActions sharingActions = CollectionActions(CollectionsService.instance); + final GlobalKey sendLinkButtonKey = GlobalKey(); @override void initState() { @@ -271,6 +272,7 @@ class _ManageSharedLinkWidgetState extends State { ), if (!url.isExpired) MenuItemWidget( + key: sendLinkButtonKey, captionedTextWidget: CaptionedTextWidget( title: S.of(context).sendLink, makeTextBold: true, @@ -279,7 +281,12 @@ class _ManageSharedLinkWidgetState extends State { menuItemColor: getEnteColorScheme(context).fillFaint, onTap: () async { // ignore: unawaited_futures - shareText(urlValue); + await shareAlbumLinkWithPlaceholder( + context, + widget.collection!, + urlValue, + sendLinkButtonKey, + ); }, isTopBorderRadiusRemoved: true, ), diff --git a/mobile/lib/ui/sharing/share_collection_page.dart b/mobile/lib/ui/sharing/share_collection_page.dart index 24e701159c..26e19b33eb 100644 --- a/mobile/lib/ui/sharing/share_collection_page.dart +++ b/mobile/lib/ui/sharing/share_collection_page.dart @@ -26,7 +26,7 @@ import 'package:photos/utils/toast_util.dart'; class ShareCollectionPage extends StatefulWidget { final Collection collection; - const ShareCollectionPage(this.collection, {Key? key}) : super(key: key); + const ShareCollectionPage(this.collection, {super.key}); @override State createState() => _ShareCollectionPageState(); @@ -36,6 +36,7 @@ class _ShareCollectionPageState extends State { late List _sharees; final CollectionActions collectionActions = CollectionActions(CollectionsService.instance); + final GlobalKey sendLinkButtonKey = GlobalKey(); Future _navigateToManageUser() async { if (_sharees.length == 1) { @@ -186,6 +187,7 @@ class _ShareCollectionPageState extends State { bgColor: getEnteColorScheme(context).fillFaint, ), MenuItemWidget( + key: sendLinkButtonKey, captionedTextWidget: CaptionedTextWidget( title: S.of(context).sendLink, makeTextBold: true, @@ -194,7 +196,12 @@ class _ShareCollectionPageState extends State { menuItemColor: getEnteColorScheme(context).fillFaint, onTap: () async { // ignore: unawaited_futures - shareText(url); + await shareAlbumLinkWithPlaceholder( + context, + widget.collection, + url, + sendLinkButtonKey, + ); }, isTopBorderRadiusRemoved: true, isBottomBorderRadiusRemoved: true, diff --git a/mobile/lib/ui/tabs/shared/all_quick_links_page.dart b/mobile/lib/ui/tabs/shared/all_quick_links_page.dart index 3a42ad0b61..c489621a99 100644 --- a/mobile/lib/ui/tabs/shared/all_quick_links_page.dart +++ b/mobile/lib/ui/tabs/shared/all_quick_links_page.dart @@ -1,10 +1,21 @@ import "package:flutter/material.dart"; +import "package:flutter/services.dart"; import "package:photos/generated/l10n.dart"; import "package:photos/models/collection/collection.dart"; +import "package:photos/models/collection/collection_items.dart"; +import "package:photos/services/collections_service.dart"; +import "package:photos/theme/ente_theme.dart"; +import "package:photos/ui/actions/collection/collection_sharing_actions.dart"; +import "package:photos/ui/components/action_sheet_widget.dart"; +import "package:photos/ui/components/buttons/button_widget.dart"; +import "package:photos/ui/components/models/button_type.dart"; import "package:photos/ui/components/title_bar_title_widget.dart"; import "package:photos/ui/tabs/shared/quick_link_album_item.dart"; +import "package:photos/ui/viewer/gallery/collection_page.dart"; +import "package:photos/utils/dialog_util.dart"; +import "package:photos/utils/navigation_util.dart"; -class AllQuickLinksPage extends StatelessWidget { +class AllQuickLinksPage extends StatefulWidget { final List quickLinks; final String titleHeroTag; const AllQuickLinksPage({ @@ -13,8 +24,98 @@ class AllQuickLinksPage extends StatelessWidget { super.key, }); + @override + State createState() => _AllQuickLinksPageState(); +} + +class _AllQuickLinksPageState extends State { + List selectedQuickLinks = []; + bool isAnyQuickLinkSelected = false; + static const heroTagPrefix = "outgoing_collection"; + + Future _navigateToCollectionPage(Collection c) async { + final thumbnail = await CollectionsService.instance.getCover(c); + final page = CollectionPage( + CollectionWithThumbnail( + c, + thumbnail, + ), + tagPrefix: heroTagPrefix, + ); + // ignore: unawaited_futures + routeToPage(context, page); + } + + Future _toggleQuickLinkSelection(Collection c) async { + if (selectedQuickLinks.contains(c)) { + selectedQuickLinks.remove(c); + } else { + selectedQuickLinks.isEmpty ? await HapticFeedback.vibrate() : null; + selectedQuickLinks.add(c); + } + setState(() { + isAnyQuickLinkSelected = selectedQuickLinks.isNotEmpty; + }); + } + + Future _removeQuickLinks() async { + if (selectedQuickLinks.isEmpty) { + await showErrorDialog( + context, + S.of(context).noQuickLinksSelected, + S.of(context).pleaseSelectQuickLinksToRemove, + ); + return true; + } + final actionResult = await showActionSheet( + context: context, + buttons: [ + ButtonWidget( + buttonType: ButtonType.critical, + isInAlert: true, + shouldStickToDarkTheme: true, + buttonAction: ButtonAction.first, + shouldSurfaceExecutionStates: true, + labelText: S.of(context).yesRemove, + onTap: () async { + for (var selectedQuickLink in selectedQuickLinks) { + await CollectionActions(CollectionsService.instance) + .trashCollectionKeepingPhotos(selectedQuickLink, context); + widget.quickLinks.remove(selectedQuickLink); + } + setState(() { + selectedQuickLinks.clear(); + isAnyQuickLinkSelected = false; + }); + }, + ), + ButtonWidget( + buttonType: ButtonType.secondary, + buttonAction: ButtonAction.cancel, + isInAlert: true, + shouldStickToDarkTheme: true, + labelText: S.of(context).cancel, + ), + ], + title: S.of(context).removePublicLinks, + body: S.of(context).thisWillRemovePublicLinksOfAllSelectedQuickLinks, + ); + if (actionResult?.action != null) { + if (actionResult!.action == ButtonAction.error) { + await showGenericErrorDialog( + context: context, + error: actionResult.exception, + ); + } + return actionResult.action == ButtonAction.first; + } else { + return false; + } + } + @override Widget build(BuildContext context) { + final colorScheme = getEnteColorScheme(context); return Scaffold( appBar: AppBar( toolbarHeight: 48, @@ -27,6 +128,17 @@ class AllQuickLinksPage extends StatelessWidget { Icons.arrow_back_outlined, ), ), + actions: [ + IconButton( + onPressed: () async { + await _removeQuickLinks(); + }, + icon: Icon( + Icons.remove_circle_outline_outlined, + color: colorScheme.blurStrokeBase, + ), + ), + ], ), body: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -38,9 +150,9 @@ class AllQuickLinksPage extends StatelessWidget { children: [ TitleBarTitleWidget( title: S.of(context).quickLinks, - heroTag: titleHeroTag, + heroTag: widget.titleHeroTag, ), - Text(quickLinks.length.toString()), + Text(widget.quickLinks.length.toString()), ], ), ), @@ -52,12 +164,28 @@ class AllQuickLinksPage extends StatelessWidget { ), child: ListView.separated( itemBuilder: (context, index) { - return QuickLinkAlbumItem(c: quickLinks[index]); + return GestureDetector( + onTap: () { + isAnyQuickLinkSelected + ? _toggleQuickLinkSelection(widget.quickLinks[index]) + : _navigateToCollectionPage(widget.quickLinks[index]); + }, + onLongPress: () { + isAnyQuickLinkSelected + ? _navigateToCollectionPage(widget.quickLinks[index]) + : _toggleQuickLinkSelection(widget.quickLinks[index]); + }, + behavior: HitTestBehavior.opaque, + child: QuickLinkAlbumItem( + c: widget.quickLinks[index], + selectedQuickLinks: selectedQuickLinks, + ), + ); }, separatorBuilder: (context, index) { return const SizedBox(height: 10); }, - itemCount: quickLinks.length, + itemCount: widget.quickLinks.length, physics: const BouncingScrollPhysics(), ), ), diff --git a/mobile/lib/ui/tabs/shared/quick_link_album_item.dart b/mobile/lib/ui/tabs/shared/quick_link_album_item.dart index 9debde5e84..5c6b7c4c5f 100644 --- a/mobile/lib/ui/tabs/shared/quick_link_album_item.dart +++ b/mobile/lib/ui/tabs/shared/quick_link_album_item.dart @@ -1,7 +1,6 @@ import "package:flutter/material.dart"; import "package:photos/generated/l10n.dart"; import 'package:photos/models/collection/collection.dart'; -import 'package:photos/models/collection/collection_items.dart'; import 'package:photos/models/file/file.dart'; import "package:photos/services/collections_service.dart"; import "package:photos/theme/ente_theme.dart"; @@ -9,149 +8,157 @@ import "package:photos/ui/common/loading_widget.dart"; import "package:photos/ui/components/buttons/icon_button_widget.dart"; import "package:photos/ui/viewer/file/no_thumbnail_widget.dart"; import "package:photos/ui/viewer/file/thumbnail_widget.dart"; -import "package:photos/ui/viewer/gallery/collection_page.dart"; -import "package:photos/utils/navigation_util.dart"; class QuickLinkAlbumItem extends StatelessWidget { final Collection c; static const heroTagPrefix = "outgoing_collection"; + final List selectedQuickLinks; - const QuickLinkAlbumItem({super.key, required this.c}); + const QuickLinkAlbumItem({ + super.key, + required this.c, + this.selectedQuickLinks = const [], + }); @override Widget build(BuildContext context) { + final bool isSelected = selectedQuickLinks.contains(c); final colorScheme = getEnteColorScheme(context); final textTheme = getEnteTextTheme(context); - return GestureDetector( - behavior: HitTestBehavior.opaque, - child: Container( - decoration: BoxDecoration( - border: Border.all(color: colorScheme.strokeFainter), - borderRadius: const BorderRadius.all( - Radius.circular(2), - ), + + return AnimatedContainer( + curve: Curves.easeOut, + duration: const Duration(milliseconds: 200), + decoration: BoxDecoration( + border: Border.all( + color: + isSelected ? colorScheme.strokeMuted : colorScheme.strokeFainter, + ), + borderRadius: const BorderRadius.all( + Radius.circular(2), ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Flexible( - flex: 6, - child: Row( - children: [ - SizedBox( - width: 60, - height: 60, - child: FutureBuilder( - future: CollectionsService.instance.getCover(c), - builder: (context, snapshot) { - if (snapshot.hasData) { - final String heroTag = - heroTagPrefix + snapshot.data!.tag; - return Hero( - tag: heroTag, - child: ClipRRect( - borderRadius: const BorderRadius.horizontal( - left: Radius.circular(2), - ), - child: ThumbnailWidget( - snapshot.data!, - key: ValueKey(heroTag), - ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Flexible( + flex: 6, + child: Row( + children: [ + SizedBox( + width: 60, + height: 60, + child: FutureBuilder( + future: CollectionsService.instance.getCover(c), + builder: (context, snapshot) { + if (snapshot.hasData) { + final String heroTag = + heroTagPrefix + snapshot.data!.tag; + return Hero( + tag: heroTag, + child: ClipRRect( + borderRadius: const BorderRadius.horizontal( + left: Radius.circular(2), + ), + child: ThumbnailWidget( + snapshot.data!, + key: ValueKey(heroTag), ), - ); - } else { - return const NoThumbnailWidget(); - } - }, - ), - ), - const SizedBox(width: 12), - Flexible( - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 4), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - c.displayName, - overflow: TextOverflow.ellipsis, - ), - const SizedBox( - height: 2, ), - FutureBuilder( - future: CollectionsService.instance.getFileCount(c), - builder: (context, snapshot) { - if (!snapshot.hasError) { - if (!snapshot.hasData) { - return Row( - children: [ - EnteLoadingWidget( - size: 10, - color: colorScheme.strokeMuted, - ), - ], - ); - } - final noOfMemories = snapshot.data; - + ); + } else { + return const NoThumbnailWidget(); + } + }, + ), + ), + const SizedBox(width: 12), + Flexible( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + c.displayName, + overflow: TextOverflow.ellipsis, + ), + const SizedBox( + height: 2, + ), + FutureBuilder( + future: CollectionsService.instance.getFileCount(c), + builder: (context, snapshot) { + if (!snapshot.hasError) { + if (!snapshot.hasData) { return Row( children: [ - Text( - noOfMemories.toString() + " \u2022 ", - style: textTheme.smallMuted, + EnteLoadingWidget( + size: 10, + color: colorScheme.strokeMuted, ), - c.hasLink - ? (c.publicURLs!.first!.isExpired - ? Icon( - Icons.link_outlined, - color: colorScheme.warning500, - size: 22, - ) - : Icon( - Icons.link_outlined, - color: colorScheme.strokeMuted, - size: 22, - )) - : const SizedBox.shrink(), ], ); - } else if (snapshot.hasError) { - return Text(S.of(context).somethingWentWrong); - } else { - return const EnteLoadingWidget(size: 10); } - }, - ), - ], - ), + final noOfMemories = snapshot.data; + + return Row( + children: [ + Text( + noOfMemories.toString() + " \u2022 ", + style: textTheme.smallMuted, + ), + c.hasLink + ? (c.publicURLs!.first!.isExpired + ? Icon( + Icons.link_outlined, + color: colorScheme.warning500, + size: 22, + ) + : Icon( + Icons.link_outlined, + color: colorScheme.strokeMuted, + size: 22, + )) + : const SizedBox.shrink(), + ], + ); + } else if (snapshot.hasError) { + return Text(S.of(context).somethingWentWrong); + } else { + return const EnteLoadingWidget(size: 10); + } + }, + ), + ], ), ), - ], - ), + ), + ], ), - const Flexible( - flex: 1, - child: IconButtonWidget( - icon: Icons.chevron_right_outlined, - iconButtonType: IconButtonType.secondary, - ), + ), + Flexible( + flex: 1, + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + switchInCurve: Curves.easeOut, + switchOutCurve: Curves.easeIn, + child: isSelected + ? IconButtonWidget( + key: const ValueKey("selected"), + icon: Icons.check_circle_rounded, + iconButtonType: IconButtonType.secondary, + iconColor: colorScheme.blurStrokeBase, + ) + : const IconButtonWidget( + key: ValueKey("unselected"), + icon: Icons.chevron_right_outlined, + iconButtonType: IconButtonType.secondary, + ), ), - ], - ), - ), - onTap: () async { - final thumbnail = await CollectionsService.instance.getCover(c); - final page = CollectionPage( - CollectionWithThumbnail( - c, - thumbnail, ), - tagPrefix: heroTagPrefix, - ); - // ignore: unawaited_futures - routeToPage(context, page); - }, + ], + ), ); } } diff --git a/mobile/lib/ui/tabs/shared_collections_tab.dart b/mobile/lib/ui/tabs/shared_collections_tab.dart index a0d78192ac..2994a293c2 100644 --- a/mobile/lib/ui/tabs/shared_collections_tab.dart +++ b/mobile/lib/ui/tabs/shared_collections_tab.dart @@ -21,12 +21,13 @@ import 'package:photos/ui/tabs/section_title.dart'; import "package:photos/ui/tabs/shared/all_quick_links_page.dart"; import "package:photos/ui/tabs/shared/empty_state.dart"; import "package:photos/ui/tabs/shared/quick_link_album_item.dart"; +import "package:photos/ui/viewer/gallery/collection_page.dart"; import "package:photos/utils/debouncer.dart"; import "package:photos/utils/navigation_util.dart"; import "package:photos/utils/share_util.dart"; class SharedCollectionsTab extends StatefulWidget { - const SharedCollectionsTab({Key? key}) : super(key: key); + const SharedCollectionsTab({super.key}); @override State createState() => _SharedCollectionsTabState(); @@ -43,6 +44,7 @@ class _SharedCollectionsTabState extends State const Duration(seconds: 2), executionInterval: const Duration(seconds: 5), ); + static const heroTagPrefix = "outgoing_collection"; @override void initState() { @@ -99,7 +101,7 @@ class _SharedCollectionsTabState extends State Widget _getSharedCollectionsGallery(SharedCollections collections) { const maxThumbnailWidth = 160.0; - const maxQuickLinks = 6; + const maxQuickLinks = 4; final numberOfQuickLinks = collections.quickLinks.length; const quickLinkTitleHeroTag = "quick_link_title"; final SectionTitle sharedWithYou = @@ -262,8 +264,24 @@ class _SharedCollectionsTabState extends State ), physics: const NeverScrollableScrollPhysics(), itemBuilder: (context, index) { - return QuickLinkAlbumItem( - c: collections.quickLinks[index], + return GestureDetector( + onTap: () async { + final thumbnail = await CollectionsService + .instance + .getCover(collections.quickLinks[index]); + final page = CollectionPage( + CollectionWithThumbnail( + collections.quickLinks[index], + thumbnail, + ), + tagPrefix: heroTagPrefix, + ); + // ignore: unawaited_futures + routeToPage(context, page); + }, + child: QuickLinkAlbumItem( + c: collections.quickLinks[index], + ), ); }, separatorBuilder: (context, index) { diff --git a/mobile/lib/ui/viewer/file/file_app_bar.dart b/mobile/lib/ui/viewer/file/file_app_bar.dart index df4f425767..e67795888f 100644 --- a/mobile/lib/ui/viewer/file/file_app_bar.dart +++ b/mobile/lib/ui/viewer/file/file_app_bar.dart @@ -123,6 +123,7 @@ class FileAppBarState extends State { switchInCurve: Curves.easeInOut, switchOutCurve: Curves.easeInOut, child: AppBar( + clipBehavior: Clip.none, key: ValueKey(_isFileSwipeLocked), iconTheme: const IconThemeData( color: Colors.white, diff --git a/mobile/lib/ui/viewer/file/file_details_widget.dart b/mobile/lib/ui/viewer/file/file_details_widget.dart index 2a0036b7d2..98bf342012 100644 --- a/mobile/lib/ui/viewer/file/file_details_widget.dart +++ b/mobile/lib/ui/viewer/file/file_details_widget.dart @@ -127,7 +127,7 @@ class _FileDetailsWidgetState extends State { _videoMetadataNotifier.value = properties; if (kDebugMode) { log("videoCustomProps ${properties.toString()}"); - log("PropData ${properties?.prodData.toString()}"); + log("PropData ${properties?.propData.toString()}"); } setState(() {}); } diff --git a/mobile/lib/ui/viewer/file/video_exif_dialog.dart b/mobile/lib/ui/viewer/file/video_exif_dialog.dart index 8fd713c832..ba650d469f 100644 --- a/mobile/lib/ui/viewer/file/video_exif_dialog.dart +++ b/mobile/lib/ui/viewer/file/video_exif_dialog.dart @@ -1,12 +1,13 @@ import 'package:flutter/material.dart'; import "package:photos/l10n/l10n.dart"; import "package:photos/models/ffmpeg/ffprobe_keys.dart"; +import "package:photos/models/ffmpeg/ffprobe_props.dart"; import "package:photos/theme/ente_theme.dart"; class VideoExifDialog extends StatelessWidget { - final Map probeData; + final FFProbeProps props; - const VideoExifDialog({Key? key, required this.probeData}) : super(key: key); + const VideoExifDialog({Key? key, required this.props}) : super(key: key); @override Widget build(BuildContext context) { @@ -48,23 +49,23 @@ class VideoExifDialog extends StatelessWidget { context.l10n.videoInfo, style: getEnteTextTheme(context).large, ), - _buildInfoRow(context, 'Creation Time', probeData, 'creation_time'), - _buildInfoRow(context, 'Duration', probeData, 'duration'), - _buildInfoRow(context, context.l10n.location, probeData, 'location'), - _buildInfoRow(context, 'Bitrate', probeData, 'bitrate'), - _buildInfoRow(context, 'Frame Rate', probeData, FFProbeKeys.rFrameRate), - _buildInfoRow(context, 'Width', probeData, FFProbeKeys.codedWidth), - _buildInfoRow(context, 'Height', probeData, FFProbeKeys.codedHeight), - _buildInfoRow(context, 'Model', probeData, 'com.apple.quicktime.model'), - _buildInfoRow(context, 'OS', probeData, 'com.apple.quicktime.software'), - _buildInfoRow(context, 'Major Brand', probeData, 'major_brand'), - _buildInfoRow(context, 'Format', probeData, 'format'), + _buildInfoRow(context, 'Creation Time', props, 'creation_time'), + _buildInfoRow(context, 'Duration', props, 'duration'), + _buildInfoRow(context, context.l10n.location, props, 'location'), + _buildInfoRow(context, 'Bitrate', props, 'bitrate'), + _buildInfoRow(context, 'Frame Rate', props, FFProbeKeys.rFrameRate), + _buildInfoRow(context, 'Width', props, null), + _buildInfoRow(context, 'Height', props, null), + _buildInfoRow(context, 'Model', props, 'com.apple.quicktime.model'), + _buildInfoRow(context, 'OS', props, 'com.apple.quicktime.software'), + _buildInfoRow(context, 'Major Brand', props, 'major_brand'), + _buildInfoRow(context, 'Format', props, 'format'), ], ); } Widget _buildStreamsList(BuildContext context) { - final List streams = probeData['streams']; + final List streams = props.propData!['streams']; final List> data = []; for (final stream in streams) { final Map streamData = {}; @@ -113,7 +114,12 @@ class VideoExifDialog extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: stream.entries .map( - (entry) => _buildInfoRow(context, entry.key, stream, entry.key), + (entry) => _buildInfoRow( + context, + entry.key, + FFProbeProps()..propData = stream, + entry.key, + ), ) .toList(), ), @@ -124,15 +130,24 @@ class VideoExifDialog extends StatelessWidget { Widget _buildInfoRow( BuildContext context, String rowName, - Map data, - String dataKey, + FFProbeProps data, + String? dataKey, ) { + final propData = data.propData; rowName = rowName.replaceAll('_', ' '); rowName = rowName[0].toUpperCase() + rowName.substring(1); try { - final value = data[dataKey]; + dynamic value; + + if (rowName == 'Width' || rowName == 'Height') { + rowName == 'Width' ? value = data.width : value = data.height; + } else { + value = propData![dataKey]; + } + if (value == null) { - return Container(); // Return an empty container if there's no data for the key. + return const SizedBox + .shrink(); // Return an empty container if there's no data for the key. } return Padding( padding: const EdgeInsets.symmetric(vertical: 4.0), diff --git a/mobile/lib/ui/viewer/file/zoomable_image.dart b/mobile/lib/ui/viewer/file/zoomable_image.dart index c6c7ba003d..4df004b1a2 100644 --- a/mobile/lib/ui/viewer/file/zoomable_image.dart +++ b/mobile/lib/ui/viewer/file/zoomable_image.dart @@ -14,8 +14,6 @@ import 'package:photos/events/files_updated_event.dart'; import 'package:photos/events/local_photos_updated_event.dart'; import "package:photos/models/file/extensions/file_props.dart"; import 'package:photos/models/file/file.dart'; -import "package:photos/models/metadata/file_magic.dart"; -import "package:photos/services/file_magic_service.dart"; import "package:photos/ui/actions/file/file_actions.dart"; import 'package:photos/ui/common/loading_widget.dart'; import 'package:photos/utils/file_util.dart'; @@ -31,12 +29,12 @@ class ZoomableImage extends StatefulWidget { const ZoomableImage( this.photo, { - Key? key, + super.key, this.shouldDisableScroll, required this.tagPrefix, this.backgroundDecoration, this.shouldCover = false, - }) : super(key: key); + }); @override State createState() => _ZoomableImageState(); @@ -359,29 +357,6 @@ class _ZoomableImageState extends State { if (finalImageInfo == null && canUpdateMetadata && !_photo.hasDimensions) { finalImageInfo = await getImageInfo(finalImageProvider); } - if (finalImageInfo != null && canUpdateMetadata) { - _updateAspectRatioIfNeeded(_photo, finalImageInfo).ignore(); - } - } - - // Fallback logic to finish back fill and update aspect - // ratio if needed. - Future _updateAspectRatioIfNeeded( - EnteFile enteFile, - ImageInfo imageInfo, - ) async { - final int h = imageInfo.image.height, w = imageInfo.image.width; - if (h != enteFile.height || w != enteFile.width) { - final logMessage = - 'Updating aspect ratio for from ${enteFile.height}x${enteFile.width} to ${h}x$w'; - _logger.info(logMessage); - await FileMagicService.instance.updatePublicMagicMetadata([ - enteFile, - ], { - heightKey: h, - widthKey: w, - }); - } } bool _isGIF() => _photo.displayName.toLowerCase().endsWith(".gif"); diff --git a/mobile/lib/ui/viewer/file_details/video_exif_item.dart b/mobile/lib/ui/viewer/file_details/video_exif_item.dart index aa9512d662..a3d51d6c9a 100644 --- a/mobile/lib/ui/viewer/file_details/video_exif_item.dart +++ b/mobile/lib/ui/viewer/file_details/video_exif_item.dart @@ -35,8 +35,7 @@ class _VideoProbeInfoState extends State { return InfoItemWidget( leadingIcon: Icons.text_snippet_outlined, title: S.of(context).videoInfo, - subtitleSection: - _exifButton(context, widget.file, widget.props?.prodData), + subtitleSection: _exifButton(context, widget.file, widget.props), onTap: _onTap, ); } @@ -44,20 +43,20 @@ class _VideoProbeInfoState extends State { Future> _exifButton( BuildContext context, EnteFile file, - Map? exif, + FFProbeProps? props, ) async { late final String label; late final VoidCallback? onTap; - if (exif == null) { + if (props?.propData == null) { label = S.of(context).loadingExifData; onTap = null; - } else if (exif.isNotEmpty) { + } else if (props!.propData!.isNotEmpty) { label = "${widget.props?.videoInfo ?? ''} .."; onTap = () => showBarModalBottomSheet( context: context, builder: (BuildContext context) { return VideoExifDialog( - probeData: exif, + props: props, ); }, shape: const RoundedRectangleBorder( diff --git a/mobile/lib/ui/viewer/gallery/gallery_app_bar_widget.dart b/mobile/lib/ui/viewer/gallery/gallery_app_bar_widget.dart index 5edd31984a..4535ce7c86 100644 --- a/mobile/lib/ui/viewer/gallery/gallery_app_bar_widget.dart +++ b/mobile/lib/ui/viewer/gallery/gallery_app_bar_widget.dart @@ -314,7 +314,7 @@ class _GalleryAppBarWidgetState extends State { icon: Icon( isQuickLink && (widget.collection!.hasLink) ? Icons.link_outlined - : Icons.people_outlined, + : Icons.adaptive.share, ), onPressed: () async { await _showShareCollectionDialog(); diff --git a/mobile/lib/utils/data_util.dart b/mobile/lib/utils/data_util.dart index 0f3feca736..48a0bbf79a 100644 --- a/mobile/lib/utils/data_util.dart +++ b/mobile/lib/utils/data_util.dart @@ -11,6 +11,15 @@ String convertBytesToReadableFormat(int bytes) { return bytes.toString() + " " + storageUnits[storageUnitIndex]; } +(int, String) convertBytesToNumberAndUnit(int bytes) { + int storageUnitIndex = 0; + while (bytes >= 1024 && storageUnitIndex < storageUnits.length - 1) { + storageUnitIndex++; + bytes = (bytes / 1024).round(); + } + return (bytes, storageUnits[storageUnitIndex]); +} + String formatBytes(int bytes, [int decimals = 2]) { if (bytes == 0) return '0 bytes'; const k = 1024; diff --git a/mobile/lib/utils/dialog_util.dart b/mobile/lib/utils/dialog_util.dart index d57a6990a8..c79b6e5c9e 100644 --- a/mobile/lib/utils/dialog_util.dart +++ b/mobile/lib/utils/dialog_util.dart @@ -17,6 +17,31 @@ import "package:photos/utils/email_util.dart"; typedef DialogBuilder = DialogWidget Function(BuildContext context); +///Will return null if dismissed by tapping outside +Future showInfoDialog( + BuildContext context, { + String title = "", + String? body, + IconData icon = Icons.info_outline_rounded, + bool isDismissable = true, +}) async { + return showDialogWidget( + context: context, + title: title, + body: body, + icon: icon, + isDismissible: isDismissable, + buttons: [ + ButtonWidget( + buttonType: ButtonType.secondary, + labelText: S.of(context).ok, + isInAlert: true, + buttonAction: ButtonAction.first, + ), + ], + ); +} + ///Will return null if dismissed by tapping outside Future showErrorDialog( BuildContext context, diff --git a/mobile/lib/utils/lock_screen_settings.dart b/mobile/lib/utils/lock_screen_settings.dart index d8d5cc511f..7349632f88 100644 --- a/mobile/lib/utils/lock_screen_settings.dart +++ b/mobile/lib/utils/lock_screen_settings.dart @@ -4,6 +4,7 @@ import "package:flutter/foundation.dart"; import "package:flutter_secure_storage/flutter_secure_storage.dart"; import "package:flutter_sodium/flutter_sodium.dart"; import "package:photos/utils/crypto_util.dart"; +import "package:privacy_screen/privacy_screen.dart"; import "package:shared_preferences/shared_preferences.dart"; class LockScreenSettings { @@ -16,8 +17,8 @@ class LockScreenSettings { static const saltKey = "ls_salt"; static const keyInvalidAttempts = "ls_invalid_attempts"; static const lastInvalidAttemptTime = "ls_last_invalid_attempt_time"; + static const keyHideAppContent = "ls_hide_app_content"; static const autoLockTime = "ls_auto_lock_time"; - late FlutterSecureStorage _secureStorage; late SharedPreferences _preferences; static const List autoLockDurations = [ @@ -31,6 +32,29 @@ class LockScreenSettings { void init(SharedPreferences prefs) async { _secureStorage = const FlutterSecureStorage(); _preferences = prefs; + + ///Workaround for privacyScreen not working when app is killed and opened. + await setHideAppContent(getShouldHideAppContent()); + } + + Future setHideAppContent(bool hideContent) async { + !hideContent + ? await PrivacyScreen.instance.disable() + : await PrivacyScreen.instance.enable( + iosOptions: const PrivacyIosOptions( + enablePrivacy: true, + lockTrigger: IosLockTrigger.didEnterBackground, + ), + androidOptions: const PrivacyAndroidOptions( + enableSecure: true, + ), + blurEffect: PrivacyBlurEffect.extraLight, + ); + await _preferences.setBool(keyHideAppContent, hideContent); + } + + bool getShouldHideAppContent() { + return _preferences.getBool(keyHideAppContent) ?? false; } Future setAutoLockTime(Duration duration) async { @@ -103,11 +127,8 @@ class LockScreenSettings { "memLimit": Sodium.cryptoPwhashMemlimitInteractive, }); - final String saltPassword = base64Encode(salt); - final String hashPassword = base64Encode(hash); - - await _secureStorage.write(key: saltKey, value: saltPassword); - await _secureStorage.write(key: password, value: hashPassword); + await _secureStorage.write(key: saltKey, value: base64Encode(salt)); + await _secureStorage.write(key: password, value: base64Encode(hash)); await _secureStorage.delete(key: pin); return; diff --git a/mobile/lib/utils/share_util.dart b/mobile/lib/utils/share_util.dart index ab39fedf11..c39d1733b9 100644 --- a/mobile/lib/utils/share_util.dart +++ b/mobile/lib/utils/share_util.dart @@ -8,13 +8,18 @@ import 'package:path/path.dart'; import "package:photo_manager/photo_manager.dart"; import 'package:photos/core/configuration.dart'; import 'package:photos/core/constants.dart'; +import "package:photos/db/files_db.dart"; +import "package:photos/generated/l10n.dart"; +import "package:photos/models/collection/collection.dart"; import 'package:photos/models/file/file.dart'; import 'package:photos/models/file/file_type.dart'; +import "package:photos/ui/sharing/show_images_prevew.dart"; import 'package:photos/utils/date_time_util.dart'; import 'package:photos/utils/dialog_util.dart'; import 'package:photos/utils/exif_util.dart'; import 'package:photos/utils/file_util.dart'; import 'package:receive_sharing_intent/receive_sharing_intent.dart'; +import "package:screenshot/screenshot.dart"; import 'package:share_plus/share_plus.dart'; import "package:uuid/uuid.dart"; @@ -248,6 +253,49 @@ Future shareImageAndUrl( ); } +Future shareAlbumLinkWithPlaceholder( + BuildContext context, + Collection collection, + String url, + GlobalKey key, +) async { + final ScreenshotController screenshotController = ScreenshotController(); + final List filesInCollection = + (await FilesDB.instance.getFilesInCollection( + collection.id, + galleryLoadStartTime, + galleryLoadEndTime, + )) + .files; + + final dialog = createProgressDialog( + context, + S.of(context).creatingLink, + isDismissible: true, + ); + await dialog.show(); + + if (filesInCollection.isEmpty) { + await dialog.hide(); + await shareText(url); + return; + } else { + final placeholderBytes = await _createAlbumPlaceholder( + filesInCollection, + screenshotController, + context, + ); + await dialog.hide(); + + await shareImageAndUrl( + placeholderBytes, + url, + context: context, + key: key, + ); + } +} + /// required for ipad https://github.com/flutter/flutter/issues/47220#issuecomment-608453383 /// This returns the position of the share button if context and key are not null /// and if not, it returns a default position so that the share sheet on iPad has @@ -261,3 +309,21 @@ Rect _sharePosOrigin(BuildContext? context, GlobalKey? key) { } return rect; } + +Future _createAlbumPlaceholder( + List files, + ScreenshotController screenshotController, + BuildContext context, +) async { + final Widget imageWidget = LinkPlaceholder( + files: files, + ); + final double pixelRatio = MediaQuery.devicePixelRatioOf(context); + final bytesOfImageToWidget = await screenshotController.captureFromWidget( + imageWidget, + pixelRatio: pixelRatio, + targetSize: MediaQuery.sizeOf(context), + delay: const Duration(milliseconds: 300), + ); + return bytesOfImageToWidget; +} diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 79b9e4ecf3..78643ca674 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -1903,6 +1903,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.5.1" + privacy_screen: + dependency: "direct main" + description: + name: privacy_screen + sha256: b80297d2726d96e8a8341149e81a415302755f02d3af7c05c820d9e191bbfbee + url: "https://pub.dev" + source: hosted + version: "0.0.6" process: dependency: transitive description: diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 3edf090b5f..92c5af51ed 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -12,7 +12,7 @@ description: ente photos application # Read more about iOS versioning at # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html -version: 0.9.14+914 +version: 0.9.16+916 publish_to: none environment: @@ -142,6 +142,7 @@ dependencies: pinput: ^5.0.0 pointycastle: ^3.7.3 pool: ^1.5.1 + privacy_screen: ^0.0.6 protobuf: ^3.1.0 provider: ^6.0.0 quiver: ^3.0.1 diff --git a/server/cmd/museum/main.go b/server/cmd/museum/main.go index 9258fa9b77..0a2d815c99 100644 --- a/server/cmd/museum/main.go +++ b/server/cmd/museum/main.go @@ -353,7 +353,7 @@ func main() { p := ginprometheus.NewPrometheus("museum") p.ReqCntURLLabelMappingFn = urlSanitizer - p.Use(server) + server.Use(p.HandlerFunc()) // note: the recover middleware must be in the last server.Use(requestid.New(), middleware.Logger(urlSanitizer), cors(), gzip.Gzip(gzip.DefaultCompression), middleware.PanicRecover()) @@ -621,6 +621,7 @@ func main() { DiscordController: discordController, HashingKey: hashingKeyBytes, PasskeyController: passkeyCtrl, + StorageBonusCtl: storageBonusCtrl, } adminAPI.POST("/mail", adminHandler.SendMail) adminAPI.POST("/mail/subscribe", adminHandler.SubscribeMail) @@ -628,6 +629,7 @@ func main() { adminAPI.GET("/users", adminHandler.GetUsers) adminAPI.GET("/user", adminHandler.GetUser) adminAPI.POST("/user/disable-2fa", adminHandler.DisableTwoFactor) + adminAPI.POST("/user/update-referral", adminHandler.UpdateReferral) adminAPI.POST("/user/disable-passkeys", adminHandler.RemovePasskeys) adminAPI.POST("/user/close-family", adminHandler.CloseFamily) adminAPI.PUT("/user/change-email", adminHandler.ChangeEmail) diff --git a/server/configurations/local.yaml b/server/configurations/local.yaml index f392663c0e..15d841dcba 100644 --- a/server/configurations/local.yaml +++ b/server/configurations/local.yaml @@ -91,6 +91,9 @@ db: # as ENTE_DB_USER and ENTE_DB_PASSWORD. user: password: + # This can be used to provide parameters that are appended verbatim to the + # generated DSN used for connecting to the DB. + # extra: # Map of data centers # diff --git a/server/ente/admin.go b/server/ente/admin.go index c56e48e5bd..5434ea4553 100644 --- a/server/ente/admin.go +++ b/server/ente/admin.go @@ -18,6 +18,11 @@ type DisableTwoFactorRequest struct { UserID int64 `json:"userID" binding:"required"` } +type UpdateReferralCodeRequest struct { + UserID int64 `json:"userID" binding:"required"` + Code string `json:"code" binding:"required"` +} + type AdminOpsForUserRequest struct { UserID int64 `json:"userID" binding:"required"` } diff --git a/server/ente/embedding.go b/server/ente/embedding.go index 4388219b80..cbbbb5574a 100644 --- a/server/ente/embedding.go +++ b/server/ente/embedding.go @@ -55,6 +55,9 @@ const ( OnnxClip Model = "onnx-clip" GgmlClip Model = "ggml-clip" + // Derived inference from a file, including metadata are stored as this type + Derived = "derived" + // FileMlClipFace is a model for face embeddings, it is used in request validation. FileMlClipFace Model = "file-ml-clip-face" ) diff --git a/server/ente/userentity/entity.go b/server/ente/userentity/entity.go index 71baa3ae9e..fc39c0f474 100644 --- a/server/ente/userentity/entity.go +++ b/server/ente/userentity/entity.go @@ -9,6 +9,8 @@ type EntityType string const ( Location EntityType = "location" Person EntityType = "person" + // PersonV2 is a new version of Person entity, where the data is gzipped before encryption + PersonV2 EntityType = "person_v2" ) type EntityKey struct { diff --git a/server/migrations/88_embedding_derived_type.down.sql b/server/migrations/88_embedding_derived_type.down.sql new file mode 100644 index 0000000000..4ba280517a --- /dev/null +++ b/server/migrations/88_embedding_derived_type.down.sql @@ -0,0 +1 @@ +-- diff --git a/server/migrations/88_embedding_derived_type.up.sql b/server/migrations/88_embedding_derived_type.up.sql new file mode 100644 index 0000000000..780689094d --- /dev/null +++ b/server/migrations/88_embedding_derived_type.up.sql @@ -0,0 +1 @@ +ALTER TYPE model ADD VALUE IF NOT EXISTS 'derived'; diff --git a/server/pkg/api/admin.go b/server/pkg/api/admin.go index 0b6ac18ef9..aa76906142 100644 --- a/server/pkg/api/admin.go +++ b/server/pkg/api/admin.go @@ -16,6 +16,7 @@ import ( "github.com/ente-io/museum/pkg/controller" "github.com/ente-io/museum/pkg/controller/discord" + storagebonusCtrl "github.com/ente-io/museum/pkg/controller/storagebonus" "github.com/ente-io/museum/pkg/controller/user" "github.com/ente-io/museum/pkg/utils/auth" "github.com/ente-io/museum/pkg/utils/time" @@ -50,6 +51,7 @@ type AdminHandler struct { DiscordController *discord.DiscordController HashingKey []byte PasskeyController *controller.PasskeyController + StorageBonusCtl *storagebonusCtrl.Controller } // Duration for which an admin's token is considered valid @@ -233,6 +235,23 @@ func (h *AdminHandler) DisableTwoFactor(c *gin.Context) { c.JSON(http.StatusOK, gin.H{}) } +func (h *AdminHandler) UpdateReferral(c *gin.Context) { + var request ente.UpdateReferralCodeRequest + if err := c.ShouldBindJSON(&request); err != nil { + handler.Error(c, stacktrace.Propagate(ente.ErrBadRequest, "Bad request %s", err.Error())) + return + } + go h.DiscordController.NotifyAdminAction( + fmt.Sprintf("Admin (%d) updating referral code for %d to %s", auth.GetUserID(c.Request.Header), request.UserID, request.Code)) + err := h.StorageBonusCtl.UpdateReferralCode(c, request.UserID, request.Code) + if err != nil { + logrus.WithError(err).Error("Failed to disable 2FA") + handler.Error(c, stacktrace.Propagate(err, "")) + return + } + c.JSON(http.StatusOK, gin.H{}) +} + // RemovePasskeys is an admin API request to disable passkey 2FA for a user account by removing its passkeys. // This is used when we get a user request to reset their passkeys 2FA when they might've lost access to their devices or synced stores. We verify their identity out of band. // BY DEFAULT, IF THE USER HAS TOTP BASED 2FA ENABLED, REMOVING PASSKEYS WILL NOT DISABLE TOTP 2FA. diff --git a/server/pkg/api/collection.go b/server/pkg/api/collection.go index 65c38bc613..94918bcf9d 100644 --- a/server/pkg/api/collection.go +++ b/server/pkg/api/collection.go @@ -129,8 +129,6 @@ func (h *CollectionHandler) ShareURL(c *gin.Context) { handler.Error(c, stacktrace.Propagate(err, "")) return } - // todo:[2/Sep/23] change device limit to 0 once both web and mobile clients are updated - request.DeviceLimit = controller.DeviceLimitThreshold response, err := h.Controller.ShareURL(c, auth.GetUserID(c.Request.Header), request) if err != nil { handler.Error(c, stacktrace.Propagate(err, "")) diff --git a/server/pkg/controller/embedding/controller.go b/server/pkg/controller/embedding/controller.go index aadb938038..190f21d40a 100644 --- a/server/pkg/controller/embedding/controller.go +++ b/server/pkg/controller/embedding/controller.go @@ -110,7 +110,7 @@ func (c *Controller) InsertOrUpdate(ctx *gin.Context, req ente.InsertOrUpdateEmb Version: version, EncryptedEmbedding: req.EncryptedEmbedding, DecryptionHeader: req.DecryptionHeader, - Client: network.GetPrettyUA(ctx.GetHeader("User-Agent")) + "/" + ctx.GetHeader("X-Client-Version"), + Client: network.GetClientInfo(ctx), } size, uploadErr := c.uploadObject(obj, c.getObjectKey(userID, req.FileID, req.Model), c.derivedStorageDataCenter) if uploadErr != nil { diff --git a/server/pkg/controller/family/family.go b/server/pkg/controller/family/family.go index 7f94b9cad2..67b4ccded0 100644 --- a/server/pkg/controller/family/family.go +++ b/server/pkg/controller/family/family.go @@ -82,7 +82,7 @@ func (c *Controller) FetchMembersForAdminID(ctx context.Context, familyAdminID i if adminSubExpiryTime < time.Microseconds() { adminUsableBonus = bonus.GetUsableBonus(0) } else { - adminUsableBonus = bonus.GetUsableBonus(adminSubExpiryTime) + adminUsableBonus = bonus.GetUsableBonus(adminSubStorage) } return ente.FamilyMemberResponse{ diff --git a/server/pkg/controller/file.go b/server/pkg/controller/file.go index b5bad99c33..c001089d4d 100644 --- a/server/pkg/controller/file.go +++ b/server/pkg/controller/file.go @@ -341,7 +341,6 @@ func (c *FileController) CleanUpStaleCollectionFiles(userID int64, fileID int64) err = c.TrashRepository.CleanUpDeletedFilesFromCollection(context.Background(), fileIDs, userID) if err != nil { logger.WithError(err).Error("Failed to clean up stale files from collection") - } } @@ -383,10 +382,14 @@ func (c *FileController) getSignedURLForType(ctx *gin.Context, fileID int64, obj return c.getHotDcSignedUrl(s3Object.ObjectKey) } +// ignore lint unused inspection func isCliRequest(ctx *gin.Context) bool { + // todo: (neeraj) remove this short-circuit after wasabi migration + return false // check if user-agent contains go-resty - userAgent := ctx.Request.Header.Get("User-Agent") - return strings.Contains(userAgent, "go-resty") + //userAgent := ctx.Request.Header.Get("User-Agent") + //return strings.Contains(userAgent, "go-resty") + } // getWasabiSignedUrlIfAvailable returns a signed URL for the given fileID and objectType. It prefers wasabi over b2 diff --git a/server/pkg/controller/storagebonus/referral.go b/server/pkg/controller/storagebonus/referral.go index 5bdd951f8d..4cf04e706f 100644 --- a/server/pkg/controller/storagebonus/referral.go +++ b/server/pkg/controller/storagebonus/referral.go @@ -4,6 +4,7 @@ import ( "database/sql" "errors" "github.com/ente-io/museum/pkg/utils/random" + "strings" "github.com/ente-io/museum/ente" entity "github.com/ente-io/museum/ente/storagebonus" @@ -131,3 +132,18 @@ func (c *Controller) GetOrCreateReferralCode(ctx *gin.Context, userID int64) (*s } return referralCode, nil } + +func (c *Controller) UpdateReferralCode(ctx *gin.Context, userID int64, code string) error { + code = strings.ToUpper(code) + if !random.IsAlphanumeric(code) { + return stacktrace.Propagate(ente.NewBadRequestWithMessage("code is not alphanumeric"), "") + } + if len(code) < 4 || len(code) > 8 { + return stacktrace.Propagate(ente.NewBadRequestWithMessage("code length should be between 4 and 8"), "") + } + err := c.StorageBonus.AddNewCode(ctx, userID, code) + if err != nil { + return stacktrace.Propagate(err, "failed to update referral code") + } + return nil +} diff --git a/server/pkg/middleware/access_token.go b/server/pkg/middleware/access_token.go index 638a0895f0..c1ca120167 100644 --- a/server/pkg/middleware/access_token.go +++ b/server/pkg/middleware/access_token.go @@ -126,9 +126,7 @@ func (m *AccessTokenMiddleware) isDeviceLimitReached(ctx context.Context, if network.IsCFWorkerIP(ip) { return false, nil } - if collectionSummary.DeviceLimit <= 0 { // no device limit was added - return false, nil - } + sharedID := collectionSummary.ID hasAccessedInPast, err := m.PublicCollectionRepo.AccessedInPast(ctx, sharedID, ip, ua) if err != nil { @@ -156,7 +154,7 @@ func (m *AccessTokenMiddleware) isDeviceLimitReached(ctx context.Context, } } - if count >= deviceLimit { + if deviceLimit > 0 && count >= deviceLimit { return true, nil } err = m.PublicCollectionRepo.RecordAccessHistory(ctx, sharedID, ip, ua) diff --git a/server/pkg/repo/storagebonus/referral_codes.go b/server/pkg/repo/storagebonus/referral_codes.go index cd4dbe7556..d3f49ec3d0 100644 --- a/server/pkg/repo/storagebonus/referral_codes.go +++ b/server/pkg/repo/storagebonus/referral_codes.go @@ -3,11 +3,18 @@ package storagebonus import ( "context" "database/sql" + "fmt" + "github.com/ente-io/museum/ente" + "net/http" entity "github.com/ente-io/museum/ente/storagebonus" "github.com/ente-io/stacktrace" ) +const ( + maxReferralChangeAllowed = 3 +) + // Add context as first parameter in all methods in this file // GetCode returns the storagebonus code for the given userID @@ -33,7 +40,20 @@ func (r *Repository) InsertCode(ctx context.Context, userID int64, code string) // Note: This method is not being used in the initial MVP as we don't allow user to change the storagebonus // code func (r *Repository) AddNewCode(ctx context.Context, userID int64, code string) error { - _, err := r.DB.ExecContext(ctx, "UPDATE referral_codes SET is_active = FALSE WHERE user_id = $1", userID) + // check current referral code count + var count int + err := r.DB.QueryRowContext(ctx, "SELECT COALESCE(COUNT(*),0) FROM referral_codes WHERE user_id = $1", userID).Scan(&count) + if err != nil { + return stacktrace.Propagate(err, "failed to get storagebonus code count for user %d", userID) + } + if count > maxReferralChangeAllowed { + return stacktrace.Propagate(&ente.ApiError{ + Code: "REFERRAL_CHANGE_LIMIT_REACHED", + Message: fmt.Sprintf("max referral code change limit %d reached", maxReferralChangeAllowed), + HttpStatusCode: http.StatusTooManyRequests, + }, "max referral code change limit reached for user %d", userID) + } + _, err = r.DB.ExecContext(ctx, "UPDATE referral_codes SET is_active = FALSE WHERE user_id = $1", userID) if err != nil { return stacktrace.Propagate(err, "failed to update storagebonus code for user %d", userID) } diff --git a/server/pkg/utils/billing/billing.go b/server/pkg/utils/billing/billing.go index 88be8be14d..d5f5f57057 100644 --- a/server/pkg/utils/billing/billing.go +++ b/server/pkg/utils/billing/billing.go @@ -122,14 +122,14 @@ func GetFreePlan() ente.FreePlan { func GetActivePlanIDs() []string { return []string{ - "50gb_monthly", - "200gb_monthly", - "500gb_monthly", - "2000gb_monthly", - "50gb_yearly", - "200gb_yearly", - "500gb_yearly", - "2000gb_yearly", + "50gb_monthly_v4", + "200gb_monthly_v4", + "1000gb_monthly_v4", + "2000gb_monthly_v4", + "50gb_yearly_v4", + "200gb_yearly_v4", + "1000gb_yearly_v4", + "2000gb_yearly_v4", } } diff --git a/server/pkg/utils/config/config.go b/server/pkg/utils/config/config.go index a12381e455..1bbd3707ac 100644 --- a/server/pkg/utils/config/config.go +++ b/server/pkg/utils/config/config.go @@ -103,13 +103,14 @@ func doesFileExist(path string) (bool, error) { func GetPGInfo() string { return fmt.Sprintf("host=%s port=%d user=%s "+ - "password=%s dbname=%s sslmode=%s", + "password=%s dbname=%s sslmode=%s %s", viper.GetString("db.host"), viper.GetInt("db.port"), viper.GetString("db.user"), viper.GetString("db.password"), viper.GetString("db.name"), - viper.GetString("db.sslmode")) + viper.GetString("db.sslmode"), + viper.GetString("db.extra")) } func IsLocalEnvironment() bool { diff --git a/server/pkg/utils/network/network.go b/server/pkg/utils/network/network.go index 6ca6de49a2..5b881044b1 100644 --- a/server/pkg/utils/network/network.go +++ b/server/pkg/utils/network/network.go @@ -34,3 +34,13 @@ func GetPrettyUA(ua string) string { } return parsedUA.UserAgent.Family + ", " + parsedUA.Os.ToString() } + +// GetClientInfo returns the client package and version from the request headers +func GetClientInfo(gin *gin.Context) string { + client := gin.GetHeader("X-Client-Package") + version := gin.GetHeader("X-Client-Version") + if version == "" { + return client + } + return client + "/" + version +} diff --git a/server/pkg/utils/random/generate.go b/server/pkg/utils/random/generate.go index 75a811c8e1..b3b8c50ab5 100644 --- a/server/pkg/utils/random/generate.go +++ b/server/pkg/utils/random/generate.go @@ -4,6 +4,7 @@ import ( "fmt" "github.com/ente-io/museum/pkg/utils/auth" "github.com/ente-io/stacktrace" + "unicode" ) func GenerateSixDigitOtp() (string, error) { @@ -40,3 +41,12 @@ func GenerateAlphaNumString(length int) (string, error) { } return string(result), nil } + +func IsAlphanumeric(s string) bool { + for _, r := range s { + if !unicode.IsLetter(r) && !unicode.IsDigit(r) { + return false + } + } + return true +} diff --git a/web/apps/accounts/src/services/passkey.ts b/web/apps/accounts/src/services/passkey.ts index 2dfecaa3b8..f470b1d1cc 100644 --- a/web/apps/accounts/src/services/passkey.ts +++ b/web/apps/accounts/src/services/passkey.ts @@ -169,7 +169,7 @@ const beginPasskeyRegistration = async (token: string) => { // binary data. // // Binary data in the returned `PublicKeyCredentialCreationOptions` are - // serialized as a "URLEncodedBase64", which is a URL-encoded Base64 string + // serialized as a "URLEncodedBase64", which is a URL-encoded base64 string // without any padding. The library is following the WebAuthn recommendation // when it does this: // diff --git a/web/apps/auth/src/services/remote.ts b/web/apps/auth/src/services/remote.ts index f9061110d9..758bd31b66 100644 --- a/web/apps/auth/src/services/remote.ts +++ b/web/apps/auth/src/services/remote.ts @@ -1,5 +1,6 @@ import log from "@/base/log"; import { apiURL } from "@/base/origins"; +import { ensureString } from "@/utils/ensure"; import ComlinkCryptoWorker from "@ente/shared/crypto"; import { ApiError, CustomError } from "@ente/shared/error"; import HTTPService from "@ente/shared/network/HTTPService"; @@ -34,7 +35,10 @@ export const getAuthCodes = async (): Promise => { entity.header, authenticatorKey, ); - return codeFromURIString(entity.id, decryptedCode); + return codeFromURIString( + entity.id, + ensureString(decryptedCode), + ); } catch (e) { log.error(`Failed to parse codeID ${entity.id}`, e); return undefined; diff --git a/web/apps/cast/src/services/detect-type.ts b/web/apps/cast/src/services/detect-type.ts index 23469997fa..ca3b93ef2c 100644 --- a/web/apps/cast/src/services/detect-type.ts +++ b/web/apps/cast/src/services/detect-type.ts @@ -1,6 +1,6 @@ import { lowercaseExtension } from "@/base/file"; import { KnownFileTypeInfos } from "@/media/file-type"; -import FileType from "file-type"; +import FileTypeDetect from "file-type"; /** * Try to deduce the MIME type for the given {@link file}. Return the MIME type @@ -17,7 +17,7 @@ export const detectMediaMIMEType = async (file: File) => { const chunkSizeForTypeDetection = 4100; const fileChunk = file.slice(0, chunkSizeForTypeDetection); const chunk = new Uint8Array(await fileChunk.arrayBuffer()); - const result = await FileType.fromBuffer(chunk); + const result = await FileTypeDetect.fromBuffer(chunk); const mime = result?.mime; if (mime) { diff --git a/web/apps/cast/src/services/pair.ts b/web/apps/cast/src/services/pair.ts index b5646698cc..287122c456 100644 --- a/web/apps/cast/src/services/pair.ts +++ b/web/apps/cast/src/services/pair.ts @@ -82,7 +82,8 @@ export const register = async (): Promise => { // Register keypair with museum to get a pairing code. let pairingCode: string | undefined; - // TODO: eslint has fixed this spurious warning, but we're not on the latest + // [TODO: spurious while(true) eslint warning]. + // eslint has fixed this spurious warning, but we're not on the latest // version yet, so add a disable. // https://github.com/eslint/eslint/pull/18286 /* eslint-disable no-constant-condition */ diff --git a/web/apps/cast/src/services/render.ts b/web/apps/cast/src/services/render.ts index 0a352ad4cb..d512b039ef 100644 --- a/web/apps/cast/src/services/render.ts +++ b/web/apps/cast/src/services/render.ts @@ -8,7 +8,7 @@ import { nameAndExtension } from "@/base/file"; import log from "@/base/log"; import { apiURL, customAPIOrigin } from "@/base/origins"; -import { FILE_TYPE } from "@/media/file-type"; +import { FileType } from "@/media/file-type"; import { isHEICExtension, needsJPEGConversion } from "@/media/formats"; import { heicToJPEG } from "@/media/heic-convert"; import { decodeLivePhoto } from "@/media/live-photo"; @@ -212,6 +212,7 @@ const decryptEnteFile = async ( if (magicMetadata?.data) { fileMagicMetadata = { ...encryptedFile.magicMetadata, + // @ts-expect-error TODO: Need to use zod here. data: await worker.decryptMetadata( magicMetadata.data, magicMetadata.header, @@ -222,6 +223,7 @@ const decryptEnteFile = async ( if (pubMagicMetadata?.data) { filePubMagicMetadata = { ...pubMagicMetadata, + // @ts-expect-error TODO: Need to use zod here. data: await worker.decryptMetadata( pubMagicMetadata.data, pubMagicMetadata.header, @@ -237,9 +239,11 @@ const decryptEnteFile = async ( pubMagicMetadata: filePubMagicMetadata, }; if (file.pubMagicMetadata?.data.editedTime) { + // @ts-expect-error TODO: Need to use zod here. file.metadata.creationTime = file.pubMagicMetadata.data.editedTime; } if (file.pubMagicMetadata?.data.editedName) { + // @ts-expect-error TODO: Need to use zod here. file.metadata.title = file.pubMagicMetadata.data.editedName; } // @ts-expect-error TODO: The core types need to be updated to allow the @@ -268,7 +272,7 @@ const isFileEligible = (file: EnteFile) => { const isImageOrLivePhoto = (file: EnteFile) => { const fileType = file.metadata.fileType; - return fileType == FILE_TYPE.IMAGE || fileType == FILE_TYPE.LIVE_PHOTO; + return fileType == FileType.image || fileType == FileType.livePhoto; }; /** @@ -289,7 +293,7 @@ const renderableImageBlob = async (castToken: string, file: EnteFile) => { let blob = await downloadFile(castToken, file, shouldUseThumbnail); let fileName = file.metadata.title; - if (!shouldUseThumbnail && file.metadata.fileType == FILE_TYPE.LIVE_PHOTO) { + if (!shouldUseThumbnail && file.metadata.fileType == FileType.livePhoto) { const { imageData, imageFileName } = await decodeLivePhoto( fileName, blob, diff --git a/web/apps/cast/src/types/file/index.ts b/web/apps/cast/src/types/file/index.ts index c1bb5304a8..e5a40fb676 100644 --- a/web/apps/cast/src/types/file/index.ts +++ b/web/apps/cast/src/types/file/index.ts @@ -1,8 +1,8 @@ -import type { Metadata } from "@/media/types/file"; +import type { Metadata } from "@/media/file-metadata"; +import { ItemVisibility } from "@/media/file-metadata"; import type { EncryptedMagicMetadata, MagicMetadataCore, - VISIBILITY_STATE, } from "@/new/photos/types/magicMetadata"; export interface MetadataFileAttributes { @@ -65,7 +65,7 @@ export interface EnteFile } export interface FileMagicMetadataProps { - visibility?: VISIBILITY_STATE; + visibility?: ItemVisibility; filePaths?: string[]; } diff --git a/web/apps/cast/src/types/magicMetadata/index.ts b/web/apps/cast/src/types/magicMetadata/index.ts index cc01eea84c..b96e700aff 100644 --- a/web/apps/cast/src/types/magicMetadata/index.ts +++ b/web/apps/cast/src/types/magicMetadata/index.ts @@ -7,12 +7,6 @@ export interface MagicMetadataCore { export type EncryptedMagicMetadata = MagicMetadataCore; -export enum VISIBILITY_STATE { - VISIBLE = 0, - ARCHIVED = 1, - HIDDEN = 2, -} - export enum SUB_TYPE { DEFAULT = 0, DEFAULT_HIDDEN = 1, diff --git a/web/apps/photos/package.json b/web/apps/photos/package.json index 78f850a3c5..c2b0d85da9 100644 --- a/web/apps/photos/package.json +++ b/web/apps/photos/package.json @@ -7,15 +7,13 @@ "@/base": "*", "@/media": "*", "@/new": "*", - "@date-io/date-fns": "^2.14.0", "@ente/eslint-config": "*", "@ente/shared": "*", - "@mui/x-date-pickers": "^5.0.0-alpha.6", "@stripe/stripe-js": "^1.13.2", + "@xmldom/xmldom": "^0.8.10", "bip39": "^3.0.4", "bs58": "^5.0.0", "chrono-node": "^2.2.6", - "date-fns": "^2", "debounce": "^2.0.0", "exifr": "^7.1.3", "exifreader": "^4", diff --git a/web/apps/photos/public/images/delete-account/1x.png b/web/apps/photos/public/images/delete-account/1x.png deleted file mode 100644 index b8288cee8e..0000000000 Binary files a/web/apps/photos/public/images/delete-account/1x.png and /dev/null differ diff --git a/web/apps/photos/public/images/delete-account/2x.png b/web/apps/photos/public/images/delete-account/2x.png deleted file mode 100644 index 31c9014bc6..0000000000 Binary files a/web/apps/photos/public/images/delete-account/2x.png and /dev/null differ diff --git a/web/apps/photos/public/images/delete-account/3x.png b/web/apps/photos/public/images/delete-account/3x.png deleted file mode 100644 index 7be1e70ded..0000000000 Binary files a/web/apps/photos/public/images/delete-account/3x.png and /dev/null differ diff --git a/web/apps/photos/src/components/Badge.tsx b/web/apps/photos/src/components/Badge.tsx deleted file mode 100644 index a3aca884a1..0000000000 --- a/web/apps/photos/src/components/Badge.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { Box, styled } from "@mui/material"; -import { CSSProperties } from "@mui/material/styles/createTypography"; - -export const Badge = styled(Box)(({ theme }) => ({ - borderRadius: theme.shape.borderRadius, - padding: "2px 4px", - backgroundColor: theme.colors.black.muted, - backdropFilter: `blur(${theme.colors.blur.muted})`, - color: theme.colors.white.base, - textTransform: "uppercase", - ...(theme.typography.tiny as CSSProperties), -})); diff --git a/web/apps/photos/src/components/Collections/CollectionOptions/index.tsx b/web/apps/photos/src/components/Collections/CollectionOptions/index.tsx index b779e50288..a08a7b38cd 100644 --- a/web/apps/photos/src/components/Collections/CollectionOptions/index.tsx +++ b/web/apps/photos/src/components/Collections/CollectionOptions/index.tsx @@ -1,5 +1,5 @@ import log from "@/base/log"; -import { VISIBILITY_STATE } from "@/new/photos/types/magicMetadata"; +import { ItemVisibility } from "@/media/file-metadata"; import { HorizontalFlex } from "@ente/shared/components/Container"; import OverflowMenu from "@ente/shared/components/OverflowMenu/menu"; import MoreHoriz from "@mui/icons-material/MoreHoriz"; @@ -209,11 +209,11 @@ const CollectionOptions = (props: CollectionOptionsProps) => { }; const archiveCollection = () => { - changeCollectionVisibility(activeCollection, VISIBILITY_STATE.ARCHIVED); + changeCollectionVisibility(activeCollection, ItemVisibility.archived); }; const unArchiveCollection = () => { - changeCollectionVisibility(activeCollection, VISIBILITY_STATE.VISIBLE); + changeCollectionVisibility(activeCollection, ItemVisibility.visible); }; const downloadCollection = () => { @@ -335,14 +335,14 @@ const CollectionOptions = (props: CollectionOptionsProps) => { const hideAlbum = async () => { await changeCollectionVisibility( activeCollection, - VISIBILITY_STATE.HIDDEN, + ItemVisibility.hidden, ); setActiveCollectionID(ALL_SECTION); }; const unHideAlbum = async () => { await changeCollectionVisibility( activeCollection, - VISIBILITY_STATE.VISIBLE, + ItemVisibility.visible, ); setActiveCollectionID(HIDDEN_ITEMS_SECTION); }; diff --git a/web/apps/photos/src/components/DeleteAccountModal.tsx b/web/apps/photos/src/components/DeleteAccountModal.tsx index 43d312b6cb..40a5214454 100644 --- a/web/apps/photos/src/components/DeleteAccountModal.tsx +++ b/web/apps/photos/src/components/DeleteAccountModal.tsx @@ -7,10 +7,9 @@ import { Formik, type FormikHelpers } from "formik"; import { t } from "i18next"; import { AppContext } from "pages/_app"; import { GalleryContext } from "pages/gallery"; -import { useContext, useEffect, useRef, useState } from "react"; +import { useContext, useRef, useState } from "react"; import { Trans } from "react-i18next"; import { deleteAccount, getAccountDeleteChallenge } from "services/userService"; -import { preloadImage } from "utils/common"; import { decryptDeleteAccountChallenge } from "utils/crypto"; import * as Yup from "yup"; import { CheckboxInput } from "./CheckboxInput"; @@ -39,10 +38,6 @@ const DeleteAccountModal = ({ open, onClose }: Iprops) => { const isMobile = useMediaQuery("(max-width: 428px)"); - useEffect(() => { - preloadImage("/images/delete-account"); - }, []); - const somethingWentWrong = () => setDialogBoxAttributesV2({ title: t("ERROR"), diff --git a/web/apps/photos/src/components/EnteDateTimePicker.tsx b/web/apps/photos/src/components/EnteDateTimePicker.tsx deleted file mode 100644 index e53ed65b98..0000000000 --- a/web/apps/photos/src/components/EnteDateTimePicker.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import { useState } from "react"; - -import { - LocalizationProvider, - MobileDateTimePicker, -} from "@mui/x-date-pickers"; -import { AdapterDateFns } from "@mui/x-date-pickers/AdapterDateFns"; - -const MIN_EDITED_CREATION_TIME = new Date(1800, 0, 1); -const MAX_EDITED_CREATION_TIME = new Date(); - -interface Props { - initialValue?: Date; - disabled?: boolean; - label?: string; - onSubmit: (date: Date) => void; - onClose?: () => void; -} - -const EnteDateTimePicker = ({ - initialValue, - disabled, - onSubmit, - onClose, -}: Props) => { - const [open, setOpen] = useState(true); - const [value, setValue] = useState(initialValue ?? new Date()); - - const handleClose = () => { - setOpen(false); - onClose?.(); - }; - return ( - - setOpen(true)} - maxDateTime={MAX_EDITED_CREATION_TIME} - minDateTime={MIN_EDITED_CREATION_TIME} - disabled={disabled} - onAccept={onSubmit} - DialogProps={{ - sx: { - zIndex: "1502", - ".MuiPickersToolbar-penIconButton": { - display: "none", - }, - ".MuiDialog-paper": { width: "320px" }, - ".MuiClockPicker-root": { - position: "relative", - minHeight: "292px", - }, - ".PrivatePickersSlideTransition-root": { - minHeight: "200px", - }, - }, - }} - renderInput={() => <>} - /> - - ); -}; - -export default EnteDateTimePicker; diff --git a/web/apps/photos/src/components/FixCreationTime.tsx b/web/apps/photos/src/components/FixCreationTime.tsx index 42aac282bb..836da7924a 100644 --- a/web/apps/photos/src/components/FixCreationTime.tsx +++ b/web/apps/photos/src/components/FixCreationTime.tsx @@ -1,4 +1,11 @@ +import log from "@/base/log"; +import type { ParsedMetadataDate } from "@/media/file-metadata"; +import { FileType } from "@/media/file-type"; +import { PhotoDateTimePicker } from "@/new/photos/components/PhotoDateTimePicker"; +import downloadManager from "@/new/photos/services/download"; +import { extractExifDates } from "@/new/photos/services/exif"; import { EnteFile } from "@/new/photos/types/file"; +import { fileLogID } from "@/new/photos/utils/file"; import DialogBox from "@ente/shared/components/DialogBox/"; import { Button, @@ -14,41 +21,42 @@ import { useFormik } from "formik"; import { t } from "i18next"; import { GalleryContext } from "pages/gallery"; import React, { useContext, useEffect, useState } from "react"; -import { updateCreationTimeWithExif } from "services/fix-exif"; -import EnteDateTimePicker from "./EnteDateTimePicker"; - -export interface FixCreationTimeAttributes { - files: EnteFile[]; -} +import { + changeFileCreationTime, + updateExistingFilePubMetadata, +} from "utils/file"; -type Step = "running" | "completed" | "completed-with-errors"; +/** The current state of the fixing process. */ +type Status = "running" | "completed" | "completed-with-errors"; export type FixOption = | "date-time-original" | "date-time-digitized" | "metadata-date" - | "custom-time"; + | "custom"; interface FormValues { option: FixOption; - /** - * Date.toISOString() - * - * Formik doesn't have native support for JS dates, so we instead keep the - * corresponding date's ISO string representation as the form state. - */ - customTimeString: string; + /* Only valid when {@link option} is "custom-time". */ + customDate: ParsedMetadataDate | undefined; +} + +export interface FixCreationTimeAttributes { + files: EnteFile[]; } interface FixCreationTimeProps { isOpen: boolean; - show: () => void; hide: () => void; attributes: FixCreationTimeAttributes; } -const FixCreationTime: React.FC = (props) => { - const [step, setStep] = useState(); +const FixCreationTime: React.FC = ({ + isOpen, + hide, + attributes, +}) => { + const [status, setStatus] = useState(); const [progressTracker, setProgressTracker] = useState({ current: 0, total: 0, @@ -58,39 +66,36 @@ const FixCreationTime: React.FC = (props) => { useEffect(() => { // TODO (MR): Not sure why this is needed - if (props.attributes && props.isOpen && step !== "running") { - setStep(undefined); - } - }, [props.isOpen]); + if (attributes && isOpen && status !== "running") setStatus(undefined); + }, [isOpen]); const onSubmit = async (values: FormValues) => { - console.log({ values }); - setStep("running"); - const completedWithErrors = await updateCreationTimeWithExif( - props.attributes.files, + setStatus("running"); + const completedWithErrors = await updateFiles( + attributes.files, values.option, - new Date(values.customTimeString), + values.customDate, setProgressTracker, ); - setStep(completedWithErrors ? "completed-with-errors" : "completed"); + setStatus(completedWithErrors ? "completed-with-errors" : "completed"); await galleryContext.syncWithRemote(); }; const title = - step === "running" + status == "running" ? t("FIX_CREATION_TIME_IN_PROGRESS") : t("FIX_CREATION_TIME"); - const message = messageForStep(step); + const message = messageForStatus(status); - if (!props.attributes) { + if (!attributes) { return <>; } return (
= (props) => { marginBottom: "10px", display: "flex", flexDirection: "column", - ...(step === "running" ? { alignItems: "center" } : {}), + ...(status == "running" ? { alignItems: "center" } : {}), }} > {message &&
{message}
} - - {step === "running" && ( - - )} - - + {status === "running" && } +
); @@ -115,7 +116,7 @@ const FixCreationTime: React.FC = (props) => { export default FixCreationTime; -const messageForStep = (step?: Step) => { +const messageForStatus = (step?: Status) => { switch (step) { case undefined: return undefined; @@ -128,21 +129,51 @@ const messageForStep = (step?: Step) => { } }; +const Progress = ({ progressTracker }) => { + const progress = Math.round( + (progressTracker.current * 100) / progressTracker.total, + ); + return ( + <> +
+ + {" "} + {progressTracker.current} / {progressTracker.total}{" "} + {" "} + + {" "} + {t("CREATION_TIME_UPDATED")} + +
+
+ +
+ + ); +}; + interface OptionsFormProps { - step?: Step; - onSubmit: (values: FormValues) => void | Promise; + step?: Status; + onSubmit: (values: FormValues) => Promise; hide: () => void; } const OptionsForm: React.FC = ({ step, onSubmit, hide }) => { - const { values, handleChange, handleSubmit } = useFormik({ - initialValues: { - option: "date-time-original", - customTimeString: new Date().toISOString(), - }, - validateOnBlur: false, - onSubmit, - }); + const { values, handleChange, setValues, handleSubmit } = + useFormik({ + initialValues: { + option: "date-time-original", + customDate: undefined, + }, + validateOnBlur: false, + onSubmit, + }); return ( <> @@ -154,7 +185,11 @@ const OptionsForm: React.FC = ({ step, onSubmit, hide }) => { {t("UPDATE_CREATION_TIME_NOT_STARTED")} - + } @@ -171,17 +206,15 @@ const OptionsForm: React.FC = ({ step, onSubmit, hide }) => { label={t("METADATA_DATE")} /> } label={t("CUSTOM_TIME")} /> - {values.option === "custom-time" && ( - - handleChange("customTimeString")( - d.toISOString(), - ) + {values.option == "custom" && ( + + setValues({ option: "custom", customDate }) } /> )} @@ -195,7 +228,7 @@ const OptionsForm: React.FC = ({ step, onSubmit, hide }) => { const Footer = ({ step, startFix, ...props }) => { return ( - step !== "running" && ( + step != "running" && (
{ justifyContent: "space-around", }} > - {(step === undefined || step === "completed-with-errors") && ( + {(!step || step == "completed-with-errors") && ( )} - {step === "completed" && ( + {step == "completed" && ( )} - {(step === undefined || step === "completed-with-errors") && ( + {(!step || step == "completed-with-errors") && ( <>
@@ -234,31 +267,88 @@ const Footer = ({ step, startFix, ...props }) => { ); }; -const FixCreationTimeRunning = ({ progressTracker }) => { - const progress = Math.round( - (progressTracker.current * 100) / progressTracker.total, - ); - return ( - <> -
- - {" "} - {progressTracker.current} / {progressTracker.total}{" "} - {" "} - - {" "} - {t("CREATION_TIME_UPDATED")} - -
-
- -
- - ); +type SetProgressTracker = React.Dispatch< + React.SetStateAction<{ + current: number; + total: number; + }> +>; + +const updateFiles = async ( + enteFiles: EnteFile[], + fixOption: FixOption, + customDate: ParsedMetadataDate, + setProgressTracker: SetProgressTracker, +) => { + setProgressTracker({ current: 0, total: enteFiles.length }); + let hadErrors = false; + for (const [i, enteFile] of enteFiles.entries()) { + try { + await updateEnteFileDate(enteFile, fixOption, customDate); + } catch (e) { + log.error(`Failed to update date of ${fileLogID(enteFile)}`, e); + hadErrors = true; + } finally { + setProgressTracker({ current: i + 1, total: enteFiles.length }); + } + } + return hadErrors; +}; + +/** + * Update the date associated with a given {@link enteFile}. + * + * This is generally treated as the creation date of the underlying asset + * (photo, video, live photo) that this file stores. + * + * - For images, this function allows us to update this date from the Exif and + * other metadata embedded in the file. + * + * - For all types of files (including images), this function allows us to + * update this date to an explicitly provided value. + * + * If an Exif-involving {@link fixOption} is passed for an non-image file, then + * that file is just skipped over. Similarly, if an Exif-involving + * {@link fixOption} is provided, but the given underlying image for the given + * {@link enteFile} does not have a corresponding Exif (or related) value, then + * that file is skipped. + * + * Note that metadata associated with an {@link EnteFile} is immutable, and we + * instead modify the mutable metadata section associated with the file. See + * [Note: Metadatum] for more details. + */ +export const updateEnteFileDate = async ( + enteFile: EnteFile, + fixOption: FixOption, + customDate: ParsedMetadataDate, +) => { + let newDate: ParsedMetadataDate | undefined; + if (fixOption === "custom") { + newDate = customDate; + } else if (enteFile.metadata.fileType == FileType.image) { + const stream = await downloadManager.getFile(enteFile); + const blob = await new Response(stream).blob(); + const file = new File([blob], enteFile.metadata.title); + const { DateTimeOriginal, DateTimeDigitized, MetadataDate, DateTime } = + await extractExifDates(file); + switch (fixOption) { + case "date-time-original": + newDate = DateTimeOriginal ?? DateTime; + break; + case "date-time-digitized": + newDate = DateTimeDigitized; + break; + case "metadata-date": + newDate = MetadataDate; + break; + } + } + + if (newDate && newDate.timestamp !== enteFile.metadata.creationTime) { + const updatedFile = await changeFileCreationTime( + enteFile, + newDate.timestamp, + ); + updateExistingFilePubMetadata(enteFile, updatedFile); + } }; diff --git a/web/apps/photos/src/components/MemberSubscriptionManage.tsx b/web/apps/photos/src/components/MemberSubscriptionManage.tsx index f06fd0be23..7ffbee1648 100644 --- a/web/apps/photos/src/components/MemberSubscriptionManage.tsx +++ b/web/apps/photos/src/components/MemberSubscriptionManage.tsx @@ -7,19 +7,14 @@ import DialogTitleWithCloseButton from "@ente/shared/components/DialogBox/TitleW import { Box, Button, Dialog, DialogContent, Typography } from "@mui/material"; import { t } from "i18next"; import { AppContext } from "pages/_app"; -import { useContext, useEffect } from "react"; +import { useContext } from "react"; import billingService from "services/billingService"; -import { preloadImage } from "utils/common"; import { getFamilyPlanAdmin } from "utils/user/family"; export function MemberSubscriptionManage({ open, userDetails, onClose }) { const { setDialogMessage } = useContext(AppContext); const fullScreen = useIsMobileWidth(); - useEffect(() => { - preloadImage("/images/family-plan"); - }, []); - async function onLeaveFamilyClick() { try { await billingService.leaveFamily(); diff --git a/web/apps/photos/src/components/PhotoFrame.tsx b/web/apps/photos/src/components/PhotoFrame.tsx index b8f5e333b7..c88d9dde1b 100644 --- a/web/apps/photos/src/components/PhotoFrame.tsx +++ b/web/apps/photos/src/components/PhotoFrame.tsx @@ -1,5 +1,5 @@ import log from "@/base/log"; -import { FILE_TYPE } from "@/media/file-type"; +import { FileType } from "@/media/file-type"; import DownloadManager from "@/new/photos/services/download"; import type { LivePhotoSourceURL, SourceURLs } from "@/new/photos/types/file"; import { EnteFile } from "@/new/photos/types/file"; @@ -360,7 +360,7 @@ const PhotoFrame = ({ log.info(`[${item.id}] new file src request`); fetching[item.id] = true; const srcURLs = await DownloadManager.getFileForPreview(item); - if (item.metadata.fileType === FILE_TYPE.LIVE_PHOTO) { + if (item.metadata.fileType === FileType.livePhoto) { const srcImgURL = srcURLs.url as LivePhotoSourceURL; const imageURL = await srcImgURL.image(); @@ -453,8 +453,8 @@ const PhotoFrame = ({ item: EnteFile, ) => { if ( - item.metadata.fileType !== FILE_TYPE.VIDEO && - item.metadata.fileType !== FILE_TYPE.LIVE_PHOTO + item.metadata.fileType !== FileType.video && + item.metadata.fileType !== FileType.livePhoto ) { log.error("getConvertedVideo called for non video file"); return; diff --git a/web/apps/photos/src/components/PhotoList/index.tsx b/web/apps/photos/src/components/PhotoList/index.tsx index 1f4b0bfb06..fec632b416 100644 --- a/web/apps/photos/src/components/PhotoList/index.tsx +++ b/web/apps/photos/src/components/PhotoList/index.tsx @@ -550,14 +550,14 @@ export function PhotoList({ ), b: ( ), }} @@ -579,13 +579,22 @@ export function PhotoList({ span={columns} hasReferral={!!publicCollectionGalleryContext.referralCode} > + {/* Make the entire area tappable, otherwise it is hard to + get at on mobile devices. */} - - {t("SHARED_USING")}{" "} - - ente.io - - + + + {t("SHARED_USING")}{" "} + + ente.io + + + {publicCollectionGalleryContext.referralCode ? ( void; - filename: string; - onInfoClose: () => void; -}) { - const { exif, open, onClose, filename, onInfoClose } = props; - - if (!exif) { - return <>; - } - const handleRootClose = () => { - onClose(); - onInfoClose(); - }; - - return ( - - - } - /> - - {[...Object.entries(exif)] - .sort((a, b) => a[0].localeCompare(b[0])) - .map(([key, value]) => - value ? ( - - - {key} - - - {parseExifValue(value)} - - - ) : ( - - ), - )} - - - ); -} diff --git a/web/apps/photos/src/components/PhotoViewer/FileInfo/RenderCreationTime.tsx b/web/apps/photos/src/components/PhotoViewer/FileInfo/RenderCreationTime.tsx index 83d7554ac3..636ecc8b8b 100644 --- a/web/apps/photos/src/components/PhotoViewer/FileInfo/RenderCreationTime.tsx +++ b/web/apps/photos/src/components/PhotoViewer/FileInfo/RenderCreationTime.tsx @@ -1,9 +1,10 @@ import log from "@/base/log"; +import type { ParsedMetadataDate } from "@/media/file-metadata"; +import { PhotoDateTimePicker } from "@/new/photos/components/PhotoDateTimePicker"; import { EnteFile } from "@/new/photos/types/file"; import { FlexWrapper } from "@ente/shared/components/Container"; import { formatDate, formatTime } from "@ente/shared/time/format"; import CalendarTodayIcon from "@mui/icons-material/CalendarToday"; -import EnteDateTimePicker from "components/EnteDateTimePicker"; import { useState } from "react"; import { changeFileCreationTime, @@ -27,11 +28,11 @@ export function RenderCreationTime({ const openEditMode = () => setIsInEditMode(true); const closeEditMode = () => setIsInEditMode(false); - const saveEdits = async (pickedTime: Date) => { + const saveEdits = async (pickedTime: ParsedMetadataDate) => { try { setLoading(true); if (isInEditMode && file) { - const unixTimeInMicroSec = pickedTime.getTime() * 1000; + const unixTimeInMicroSec = pickedTime.timestamp; if (unixTimeInMicroSec === file?.metadata.creationTime) { closeEditMode(); return; @@ -63,10 +64,10 @@ export function RenderCreationTime({ hideEditOption={shouldDisableEdits || isInEditMode} /> {isInEditMode && ( - )} diff --git a/web/apps/photos/src/components/PhotoViewer/FileInfo/RenderFileName.tsx b/web/apps/photos/src/components/PhotoViewer/FileInfo/RenderFileName.tsx deleted file mode 100644 index e9443c84c1..0000000000 --- a/web/apps/photos/src/components/PhotoViewer/FileInfo/RenderFileName.tsx +++ /dev/null @@ -1,113 +0,0 @@ -import { nameAndExtension } from "@/base/file"; -import log from "@/base/log"; -import { FILE_TYPE } from "@/media/file-type"; -import { EnteFile } from "@/new/photos/types/file"; -import { formattedByteSize } from "@/new/photos/utils/units"; -import { FlexWrapper } from "@ente/shared/components/Container"; -import PhotoOutlined from "@mui/icons-material/PhotoOutlined"; -import VideocamOutlined from "@mui/icons-material/VideocamOutlined"; -import Box from "@mui/material/Box"; -import { useEffect, useState } from "react"; -import { changeFileName, updateExistingFilePubMetadata } from "utils/file"; -import { FileNameEditDialog } from "./FileNameEditDialog"; -import InfoItem from "./InfoItem"; - -const getFileTitle = (filename, extension) => { - if (extension) { - return filename + "." + extension; - } else { - return filename; - } -}; - -const getCaption = (file: EnteFile, parsedExifData) => { - const megaPixels = parsedExifData?.["megaPixels"]; - const resolution = parsedExifData?.["resolution"]; - const fileSize = file.info?.fileSize; - - const captionParts = []; - if (megaPixels) { - captionParts.push(megaPixels); - } - if (resolution) { - captionParts.push(resolution); - } - if (fileSize) { - captionParts.push(formattedByteSize(fileSize)); - } - return ( - - {captionParts.map((caption) => ( - {caption} - ))} - - ); -}; - -export function RenderFileName({ - parsedExifData, - shouldDisableEdits, - file, - scheduleUpdate, -}: { - parsedExifData: Record; - shouldDisableEdits: boolean; - file: EnteFile; - scheduleUpdate: () => void; -}) { - const [isInEditMode, setIsInEditMode] = useState(false); - const openEditMode = () => setIsInEditMode(true); - const closeEditMode = () => setIsInEditMode(false); - const [filename, setFilename] = useState(); - const [extension, setExtension] = useState(); - - useEffect(() => { - const [filename, extension] = nameAndExtension(file.metadata.title); - setFilename(filename); - setExtension(extension); - }, [file]); - - const saveEdits = async (newFilename: string) => { - try { - if (file) { - if (filename === newFilename) { - closeEditMode(); - return; - } - setFilename(newFilename); - const newTitle = getFileTitle(newFilename, extension); - const updatedFile = await changeFileName(file, newTitle); - updateExistingFilePubMetadata(file, updatedFile); - scheduleUpdate(); - } - } catch (e) { - log.error("failed to update file name", e); - throw e; - } - }; - - return ( - <> - - ) : ( - - ) - } - title={getFileTitle(filename, extension)} - caption={getCaption(file, parsedExifData)} - openEditor={openEditMode} - hideEditOption={shouldDisableEdits || isInEditMode} - /> - - - ); -} diff --git a/web/apps/photos/src/components/PhotoViewer/FileInfo/index.tsx b/web/apps/photos/src/components/PhotoViewer/FileInfo/index.tsx index f029d19952..8c1bb6321f 100644 --- a/web/apps/photos/src/components/PhotoViewer/FileInfo/index.tsx +++ b/web/apps/photos/src/components/PhotoViewer/FileInfo/index.tsx @@ -1,8 +1,14 @@ import { EnteDrawer } from "@/base/components/EnteDrawer"; import { Titlebar } from "@/base/components/Titlebar"; +import { nameAndExtension } from "@/base/file"; +import type { ParsedMetadata } from "@/media/file-metadata"; +import { FileType } from "@/media/file-type"; import { UnidentifiedFaces } from "@/new/photos/components/PeopleList"; +import { photoSwipeZIndex } from "@/new/photos/components/PhotoViewer"; +import { tagNumericValue, type RawExifTags } from "@/new/photos/services/exif"; import { isMLEnabled } from "@/new/photos/services/ml"; import { EnteFile } from "@/new/photos/types/file"; +import { formattedByteSize } from "@/new/photos/utils/units"; import CopyButton from "@ente/shared/components/CodeBlock/CopyButton"; import { FlexWrapper } from "@ente/shared/components/Container"; import EnteSpinner from "@ente/shared/components/EnteSpinner"; @@ -11,72 +17,55 @@ import BackupOutlined from "@mui/icons-material/BackupOutlined"; import CameraOutlined from "@mui/icons-material/CameraOutlined"; import FolderOutlined from "@mui/icons-material/FolderOutlined"; import LocationOnOutlined from "@mui/icons-material/LocationOnOutlined"; +import PhotoOutlined from "@mui/icons-material/PhotoOutlined"; import TextSnippetOutlined from "@mui/icons-material/TextSnippetOutlined"; -import { Box, DialogProps, Link, Stack, styled } from "@mui/material"; +import VideocamOutlined from "@mui/icons-material/VideocamOutlined"; +import { + Box, + DialogProps, + Link, + Stack, + styled, + Typography, +} from "@mui/material"; import { Chip } from "components/Chip"; import LinkButton from "components/pages/gallery/LinkButton"; import { t } from "i18next"; import { AppContext } from "pages/_app"; import { GalleryContext } from "pages/gallery"; -import { useContext, useEffect, useMemo, useState } from "react"; -import { getEXIFLocation } from "services/exif"; +import React, { useContext, useEffect, useMemo, useState } from "react"; +import { changeFileName, updateExistingFilePubMetadata } from "utils/file"; import { PublicCollectionGalleryContext } from "utils/publicCollectionGallery"; import { getMapDisableConfirmationDialog, getMapEnableConfirmationDialog, } from "utils/ui"; -import { ExifData } from "./ExifData"; +import { FileNameEditDialog } from "./FileNameEditDialog"; import InfoItem from "./InfoItem"; import MapBox from "./MapBox"; import { RenderCaption } from "./RenderCaption"; import { RenderCreationTime } from "./RenderCreationTime"; -import { RenderFileName } from "./RenderFileName"; -export const FileInfoSidebar = styled((props: DialogProps) => ( - -))({ - zIndex: 1501, - "& .MuiPaper-root": { - padding: 8, - }, -}); +export interface FileInfoExif { + tags: RawExifTags | undefined; + parsed: ParsedMetadata | undefined; +} -interface Iprops { - shouldDisableEdits?: boolean; +interface FileInfoProps { showInfo: boolean; handleCloseInfo: () => void; - file: EnteFile; - exif: any; + closePhotoViewer: () => void; + file: EnteFile | undefined; + exif: FileInfoExif | undefined; + shouldDisableEdits?: boolean; scheduleUpdate: () => void; refreshPhotoswipe: () => void; fileToCollectionsMap?: Map; collectionNameMap?: Map; showCollectionChips: boolean; - closePhotoViewer: () => void; } -function BasicDeviceCamera({ - parsedExifData, -}: { - parsedExifData: Record; -}) { - return ( - - {parsedExifData["fNumber"]} - {parsedExifData["exposureTime"]} - {parsedExifData["ISO"]} - - ); -} - -function getOpenStreetMapLink(location: { - latitude: number; - longitude: number; -}) { - return `https://www.openstreetmap.org/?mlat=${location.latitude}&mlon=${location.longitude}#map=15/${location.latitude}/${location.longitude}`; -} - -export function FileInfo({ +export const FileInfo: React.FC = ({ shouldDisableEdits, showInfo, handleCloseInfo, @@ -88,18 +77,16 @@ export function FileInfo({ collectionNameMap, showCollectionChips, closePhotoViewer, -}: Iprops) { - const appContext = useContext(AppContext); +}) => { + const { mapEnabled, updateMapEnabled, setDialogBoxAttributesV2 } = + useContext(AppContext); const galleryContext = useContext(GalleryContext); const publicCollectionGalleryContext = useContext( PublicCollectionGalleryContext, ); - const [parsedExifData, setParsedExifData] = useState>(); - const [showExif, setShowExif] = useState(false); - - const openExif = () => setShowExif(true); - const closeExif = () => setShowExif(false); + const [exifInfo, setExifInfo] = useState(); + const [openRawExif, setOpenRawExif] = useState(false); const location = useMemo(() => { if (file && file.metadata) { @@ -113,60 +100,17 @@ export function FileInfo({ }; } } - if (exif) { - const exifLocation = getEXIFLocation(exif); - if ( - (exifLocation.latitude || exifLocation.latitude === 0) && - !(exifLocation.longitude === 0 && exifLocation.latitude === 0) - ) { - return exifLocation; - } - } - return null; + return exif?.parsed?.location; }, [file, exif]); useEffect(() => { - if (!exif) { - setParsedExifData({}); - return; - } - const parsedExifData = {}; - if (exif["fNumber"]) { - parsedExifData["fNumber"] = `f/${Math.ceil(exif["FNumber"])}`; - } else if (exif["ApertureValue"] && exif["FocalLength"]) { - parsedExifData["fNumber"] = `f/${Math.ceil( - exif["FocalLength"] / exif["ApertureValue"], - )}`; - } - const imageWidth = exif["ImageWidth"] ?? exif["ExifImageWidth"]; - const imageHeight = exif["ImageHeight"] ?? exif["ExifImageHeight"]; - if (imageWidth && imageHeight) { - parsedExifData["resolution"] = `${imageWidth} x ${imageHeight}`; - const megaPixels = Math.round((imageWidth * imageHeight) / 1000000); - if (megaPixels) { - parsedExifData["megaPixels"] = `${Math.round( - (imageWidth * imageHeight) / 1000000, - )}MP`; - } - } - if (exif["Make"] && exif["Model"]) { - parsedExifData["takenOnDevice"] = - `${exif["Make"]} ${exif["Model"]}`; - } - if (exif["ExposureTime"]) { - parsedExifData["exposureTime"] = `1/${ - 1 / parseFloat(exif["ExposureTime"]) - }`; - } - if (exif["ISO"]) { - parsedExifData["ISO"] = `ISO${exif["ISO"]}`; - } - setParsedExifData(parsedExifData); + setExifInfo(parseExifInfo(exif)); }, [exif]); if (!file) { return <>; } + const onCollectionChipClick = (collectionID) => { galleryContext.setActiveCollectionID(collectionID); galleryContext.setIsInSearchMode(false); @@ -174,17 +118,13 @@ export function FileInfo({ }; const openEnableMapConfirmationDialog = () => - appContext.setDialogBoxAttributesV2( - getMapEnableConfirmationDialog(() => - appContext.updateMapEnabled(true), - ), + setDialogBoxAttributesV2( + getMapEnableConfirmationDialog(() => updateMapEnabled(true)), ); const openDisableMapConfirmationDialog = () => - appContext.setDialogBoxAttributesV2( - getMapDisableConfirmationDialog(() => - appContext.updateMapEnabled(false), - ), + setDialogBoxAttributesV2( + getMapDisableConfirmationDialog(() => updateMapEnabled(false)), ); return ( @@ -192,32 +132,33 @@ export function FileInfo({ - {parsedExifData && parsedExifData["takenOnDevice"] && ( + + {exifInfo?.takenOnDevice && ( } - title={parsedExifData["takenOnDevice"]} + title={exifInfo?.takenOnDevice} caption={ - + } hideEditOption /> @@ -229,11 +170,12 @@ export function FileInfo({ icon={} title={t("LOCATION")} caption={ - !appContext.mapEnabled || + !mapEnabled || publicCollectionGalleryContext.accessedThroughSharedURL ? ( {t("SHOW_ON_MAP")} @@ -264,7 +206,7 @@ export function FileInfo({ {!publicCollectionGalleryContext.accessedThroughSharedURL && ( } title={t("DETAILS")} caption={ - typeof exif === "undefined" ? ( - - ) : exif !== null ? ( + !exif ? ( + + ) : !exif.tags ? ( + t("no_exif") + ) : ( setOpenRawExif(true)} sx={{ textDecoration: "none", color: "text.muted", @@ -289,8 +233,6 @@ export function FileInfo({ > {t("view_exif")} - ) : ( - t("no_exif") ) } hideEditOption @@ -336,13 +278,275 @@ export function FileInfo({ )} - setOpenRawExif(false)} onInfoClose={handleCloseInfo} - filename={file.metadata.title} + tags={exif?.tags} + fileName={file.metadata.title} /> ); +}; + +/** + * Some immediate fields of interest, in the form that we want to display on the + * info panel for a file. + */ +type ExifInfo = Required & { + resolution?: string; + megaPixels?: string; + takenOnDevice?: string; + fNumber?: string; + exposureTime?: string; + iso?: string; +}; + +const parseExifInfo = ( + fileInfoExif: FileInfoExif | undefined, +): ExifInfo | undefined => { + if (!fileInfoExif || !fileInfoExif.tags || !fileInfoExif.parsed) + return undefined; + + const info: ExifInfo = { ...fileInfoExif }; + + const { width, height } = fileInfoExif.parsed; + if (width && height) { + info.resolution = `${width} x ${height}`; + const mp = Math.round((width * height) / 1000000); + if (mp) info.megaPixels = `${mp}MP`; + } + + const { tags } = fileInfoExif; + const { exif } = tags; + + if (exif) { + if (exif.Make && exif.Model) + info["takenOnDevice"] = + `${exif.Make.description} ${exif.Model.description}`; + + if (exif.FNumber) + info.fNumber = exif.FNumber.description; /* e.g. "f/16" */ + + if (exif.ExposureTime) + info["exposureTime"] = exif.ExposureTime.description; /* "1/10" */ + + if (exif.ISOSpeedRatings) + info.iso = `ISO${tagNumericValue(exif.ISOSpeedRatings)}`; + } + return info; +}; + +const FileInfoSidebar = styled((props: DialogProps) => ( + +))({ + zIndex: photoSwipeZIndex + 1, + "& .MuiPaper-root": { + padding: 8, + }, +}); + +interface RenderFileNameProps { + file: EnteFile; + shouldDisableEdits: boolean; + exifInfo: ExifInfo | undefined; + scheduleUpdate: () => void; +} + +const RenderFileName: React.FC = ({ + file, + shouldDisableEdits, + exifInfo, + scheduleUpdate, +}) => { + const [isInEditMode, setIsInEditMode] = useState(false); + const openEditMode = () => setIsInEditMode(true); + const closeEditMode = () => setIsInEditMode(false); + const [fileName, setFileName] = useState(); + const [extension, setExtension] = useState(); + + useEffect(() => { + const [filename, extension] = nameAndExtension(file.metadata.title); + setFileName(filename); + setExtension(extension); + }, [file]); + + const saveEdits = async (newFilename: string) => { + if (!file) return; + if (fileName === newFilename) { + closeEditMode(); + return; + } + setFileName(newFilename); + const newTitle = [newFilename, extension].join("."); + const updatedFile = await changeFileName(file, newTitle); + updateExistingFilePubMetadata(file, updatedFile); + scheduleUpdate(); + }; + + return ( + <> + + ) : ( + + ) + } + title={[fileName, extension].join(".")} + caption={getCaption(file, exifInfo)} + openEditor={openEditMode} + hideEditOption={shouldDisableEdits || isInEditMode} + /> + + + ); +}; + +const getCaption = (file: EnteFile, exifInfo: ExifInfo | undefined) => { + const megaPixels = exifInfo?.megaPixels; + const resolution = exifInfo?.resolution; + const fileSize = file.info?.fileSize; + + const captionParts = []; + if (megaPixels) { + captionParts.push(megaPixels); + } + if (resolution) { + captionParts.push(resolution); + } + if (fileSize) { + captionParts.push(formattedByteSize(fileSize)); + } + return ( + + {captionParts.map((caption) => ( + {caption} + ))} + + ); +}; + +const BasicDeviceCamera: React.FC<{ parsedExif: ExifInfo }> = ({ + parsedExif, +}) => { + return ( + + {parsedExif.fNumber} + {parsedExif.exposureTime} + {parsedExif.iso} + + ); +}; + +const getOpenStreetMapLink = (location: { + latitude: number; + longitude: number; +}) => + `https://www.openstreetmap.org/?mlat=${location.latitude}&mlon=${location.longitude}#map=15/${location.latitude}/${location.longitude}`; + +interface RawExifProps { + open: boolean; + onClose: () => void; + onInfoClose: () => void; + tags: RawExifTags | undefined; + fileName: string; } + +const RawExif: React.FC = ({ + open, + onClose, + onInfoClose, + tags, + fileName, +}) => { + if (!tags) { + return <>; + } + + const handleRootClose = () => { + onClose(); + onInfoClose(); + }; + + const items: (readonly [string, string, string, string])[] = Object.entries( + tags, + ) + .map(([namespace, namespaceTags]) => { + return Object.entries(namespaceTags).map(([tagName, tag]) => { + const key = `${namespace}:${tagName}`; + let description = "<...>"; + if (typeof tag == "string") { + description = tag; + } else if (typeof tag == "number") { + description = `${tag}`; + } else if ( + tag && + typeof tag == "object" && + "description" in tag + ) { + description = tag.description; + } + return [key, namespace, tagName, description] as const; + }); + }) + .flat() + .filter(([, , , description]) => description); + + return ( + + + } + /> + + {items.map(([key, namespace, tagName, description]) => ( + + + + {tagName} + + + {namespace} + + + + {description} + + + ))} + + + ); +}; + +const ExifItem = styled(Box)` + padding-left: 8px; + padding-right: 8px; + display: flex; + flex-direction: column; + gap: 4px; +`; diff --git a/web/apps/photos/src/components/PhotoViewer/index.tsx b/web/apps/photos/src/components/PhotoViewer/index.tsx index 1c16a7a773..8e8a02d3bc 100644 --- a/web/apps/photos/src/components/PhotoViewer/index.tsx +++ b/web/apps/photos/src/components/PhotoViewer/index.tsx @@ -15,11 +15,12 @@ import { import { isDesktop } from "@/base/app"; import { lowercaseExtension } from "@/base/file"; -import { FILE_TYPE } from "@/media/file-type"; +import { FileType } from "@/media/file-type"; import { isHEICExtension, needsJPEGConversion } from "@/media/formats"; import downloadManager from "@/new/photos/services/download"; +import { extractRawExif, parseExif } from "@/new/photos/services/exif"; import type { LoadedLivePhotoSourceURL } from "@/new/photos/types/file"; -import { detectFileTypeInfo } from "@/new/photos/utils/detect-type"; +import { fileLogID } from "@/new/photos/utils/file"; import { FlexWrapper } from "@ente/shared/components/Container"; import EnteSpinner from "@ente/shared/components/EnteSpinner"; import AlbumOutlined from "@mui/icons-material/AlbumOutlined"; @@ -46,14 +47,12 @@ import { t } from "i18next"; import isElectron from "is-electron"; import { AppContext } from "pages/_app"; import { GalleryContext } from "pages/gallery"; -import { getParsedExifData } from "services/exif"; import { trashFiles } from "services/fileService"; import { SetFilesDownloadProgressAttributesCreator } from "types/gallery"; -import { isClipboardItemPresent } from "utils/common"; import { pauseVideo, playVideo } from "utils/photoFrame"; import { PublicCollectionGalleryContext } from "utils/publicCollectionGallery"; import { getTrashFileMessage } from "utils/ui"; -import { FileInfo } from "./FileInfo"; +import { FileInfo, type FileInfoExif } from "./FileInfo"; import ImageEditorOverlay from "./ImageEditorOverlay"; import CircularProgressWithLabel from "./styledComponents/CircularProgressWithLabel"; import { ConversionFailedNotification } from "./styledComponents/ConversionFailedNotification"; @@ -108,10 +107,13 @@ function PhotoViewer(props: Iprops) { useState>(); const [isFav, setIsFav] = useState(false); const [showInfo, setShowInfo] = useState(false); - const [exif, setExif] = useState<{ - key: string; - value: Record; - }>(); + const [exif, setExif] = useState< + | { + key: string; + value: FileInfoExif | undefined; + } + | undefined + >(); const exifCopy = useRef(null); const [livePhotoBtnOptions, setLivePhotoBtnOptions] = useState( defaultLivePhotoDefaultOptions, @@ -123,7 +125,10 @@ function PhotoViewer(props: Iprops) { const needUpdate = useRef(false); const exifExtractionInProgress = useRef(null); - const shouldShowCopyOption = useMemo(() => isClipboardItemPresent(), []); + const shouldShowCopyOption = useMemo( + () => typeof ClipboardItem != "undefined", + [], + ); const [showImageEditorOverlay, setShowImageEditorOverlay] = useState(false); @@ -235,7 +240,7 @@ function PhotoViewer(props: Iprops) { if (!isOpen) return; const item = items[photoSwipe?.getCurrentIndex()]; if (!item) return; - if (item.metadata.fileType === FILE_TYPE.LIVE_PHOTO) { + if (item.metadata.fileType === FileType.livePhoto) { const getVideoAndImage = () => { const video = document.getElementById( `live-photo-video-${item.id}`, @@ -306,25 +311,24 @@ function PhotoViewer(props: Iprops) { } function updateExif(file: EnteFile) { - if (file.metadata.fileType === FILE_TYPE.VIDEO) { - setExif({ key: file.src, value: null }); + if (file.metadata.fileType === FileType.video) { + setExif({ + key: file.src, + value: { tags: undefined, parsed: undefined }, + }); return; } - if (!file.isSourceLoaded || file.conversionFailed) { + if (!file || !file.isSourceLoaded || file.conversionFailed) { return; } - if (!file || !exifCopy?.current?.value === null) { - return; - } const key = - file.metadata.fileType === FILE_TYPE.IMAGE + file.metadata.fileType === FileType.image ? file.src : (file.srcURLs.url as LoadedLivePhotoSourceURL).image; - if (exifCopy?.current?.key === key) { - return; - } + if (exifCopy?.current?.key === key) return; + setExif({ key, value: undefined }); checkExifAvailable(file); } @@ -332,8 +336,8 @@ function PhotoViewer(props: Iprops) { function updateShowConvertBtn(file: EnteFile) { const shouldShowConvertBtn = isElectron() && - (file.metadata.fileType === FILE_TYPE.VIDEO || - file.metadata.fileType === FILE_TYPE.LIVE_PHOTO) && + (file.metadata.fileType === FileType.video || + file.metadata.fileType === FileType.livePhoto) && !file.isConverted && file.isSourceLoaded && !file.conversionFailed; @@ -361,12 +365,12 @@ function PhotoViewer(props: Iprops) { } } setShowEditButton( - file.metadata.fileType === FILE_TYPE.IMAGE && isSupported, + file.metadata.fileType === FileType.image && isSupported, ); } function updateShowZoomButton(file: EnteFile) { - setShowZoomButton(file.metadata.fileType === FILE_TYPE.IMAGE); + setShowZoomButton(file.metadata.fileType === FileType.image); } const openPhotoSwipe = () => { @@ -585,45 +589,30 @@ function PhotoViewer(props: Iprops) { } }; - const checkExifAvailable = async (file: EnteFile) => { + const checkExifAvailable = async (enteFile: EnteFile) => { + if (exifExtractionInProgress.current === enteFile.src) return; + try { - if (exifExtractionInProgress.current === file.src) { - return; - } - try { - exifExtractionInProgress.current = file.src; - let fileObject: File; - if (file.metadata.fileType === FILE_TYPE.IMAGE) { - fileObject = await getFileFromURL( - file.src as string, - file.metadata.title, - ); - } else { - const url = (file.srcURLs.url as LoadedLivePhotoSourceURL) - .image; - fileObject = await getFileFromURL(url, file.metadata.title); - } - const fileTypeInfo = await detectFileTypeInfo(fileObject); - const exifData = await getParsedExifData( - fileObject, - fileTypeInfo, - ); - if (exifExtractionInProgress.current === file.src) { - if (exifData) { - setExif({ key: file.src, value: exifData }); - } else { - setExif({ key: file.src, value: null }); - } - } - } finally { - exifExtractionInProgress.current = null; + exifExtractionInProgress.current = enteFile.src; + const file = await getFileFromURL( + enteFile.metadata.fileType === FileType.image + ? (enteFile.src as string) + : (enteFile.srcURLs.url as LoadedLivePhotoSourceURL).image, + enteFile.metadata.title, + ); + const tags = await extractRawExif(file); + const parsed = parseExif(tags); + if (exifExtractionInProgress.current === enteFile.src) { + setExif({ key: enteFile.src, value: { tags, parsed } }); } } catch (e) { - setExif({ key: file.src, value: null }); - log.error( - `checkExifAvailable failed for file ${file.metadata.title}`, - e, - ); + log.error(`Failed to extract Exif from ${fileLogID(enteFile)}`, e); + setExif({ + key: enteFile.src, + value: { tags: undefined, parsed: undefined }, + }); + } finally { + exifExtractionInProgress.current = null; } }; @@ -941,21 +930,21 @@ function PhotoViewer(props: Iprops) {
{ }, })} > - {props.fileType !== FILE_TYPE.VIDEO ? ( + {props.fileType !== FileType.video ? ( ) : ( diff --git a/web/apps/photos/src/components/Search/SearchBar/searchInput/index.tsx b/web/apps/photos/src/components/Search/SearchBar/searchInput/index.tsx index 0f21564877..9f493ba9c7 100644 --- a/web/apps/photos/src/components/Search/SearchBar/searchInput/index.tsx +++ b/web/apps/photos/src/components/Search/SearchBar/searchInput/index.tsx @@ -1,4 +1,4 @@ -import { FILE_TYPE } from "@/media/file-type"; +import { FileType } from "@/media/file-type"; import { isMLEnabled } from "@/new/photos/services/ml"; import type { Person } from "@/new/photos/services/ml/people"; import { EnteFile } from "@/new/photos/types/file"; @@ -149,7 +149,7 @@ export default function SearchInput(props: Iprops) { search = { person: selectedOption.value as Person }; break; case SuggestionType.FILE_TYPE: - search = { fileType: selectedOption.value as FILE_TYPE }; + search = { fileType: selectedOption.value as FileType }; break; case SuggestionType.CLIP: search = { clip: selectedOption.value as ClipSearchScores }; diff --git a/web/apps/photos/src/components/Sidebar/AdvancedSettings.tsx b/web/apps/photos/src/components/Sidebar/AdvancedSettings.tsx index 5fdcd23852..8980c8ed5f 100644 --- a/web/apps/photos/src/components/Sidebar/AdvancedSettings.tsx +++ b/web/apps/photos/src/components/Sidebar/AdvancedSettings.tsx @@ -82,7 +82,7 @@ export default function AdvancedSettings({ open, onClose, onRootClose }) { } onClick={() => setOpenMLSettings(true)} - label={pt("ML search")} + label={pt("Face and magic search")} /> diff --git a/web/apps/photos/src/components/Sidebar/MapSetting.tsx b/web/apps/photos/src/components/Sidebar/MapSetting.tsx index 0de7895f22..29acee238c 100644 --- a/web/apps/photos/src/components/Sidebar/MapSetting.tsx +++ b/web/apps/photos/src/components/Sidebar/MapSetting.tsx @@ -174,6 +174,7 @@ function EnableMap({ onClose, enableMap, onRootClose }) { a: ( ), diff --git a/web/apps/photos/src/components/Sidebar/Preferences.tsx b/web/apps/photos/src/components/Sidebar/Preferences.tsx index 38cccf45de..000a1e44cc 100644 --- a/web/apps/photos/src/components/Sidebar/Preferences.tsx +++ b/web/apps/photos/src/components/Sidebar/Preferences.tsx @@ -85,14 +85,9 @@ export default function Preferences({ open, onClose, onRootClose }) { } onClick={() => setOpenMLSettings(true)} - label={pt("ML search")} + label={pt("Face and magic search")} /> - )} diff --git a/web/apps/photos/src/components/Sidebar/index.tsx b/web/apps/photos/src/components/Sidebar/index.tsx index c6fd7a91c8..ccf921c854 100644 --- a/web/apps/photos/src/components/Sidebar/index.tsx +++ b/web/apps/photos/src/components/Sidebar/index.tsx @@ -40,7 +40,6 @@ import DeleteAccountModal from "components/DeleteAccountModal"; import TwoFactorModal from "components/TwoFactor/Modal"; import { WatchFolder } from "components/WatchFolder"; import LinkButton from "components/pages/gallery/LinkButton"; -import { NoStyleAnchor } from "components/pages/sharedAlbum/GoToEnte"; import { ARCHIVE_SECTION, DUMMY_UNCATEGORIZED_COLLECTION, @@ -289,14 +288,7 @@ const SubscriptionStatus: React.FC = ({ if (!hasAddOnBonus(userDetails.bonusData)) { if (isSubscriptionActive(userDetails.subscription)) { if (isOnFreePlan(userDetails.subscription)) { - message = ( - - ); + message = t("subscription_info_free"); } else if (isSubscriptionCancelled(userDetails.subscription)) { message = t("subscription_info_renewal_cancelled", { date: userDetails.subscription?.expiryTime, @@ -614,11 +606,7 @@ const HelpSection: React.FC = () => { - - {t("SUPPORT")} - - + {t("SUPPORT")} } variant="secondary" /> diff --git a/web/apps/photos/src/components/Upload/UploadTypeSelector.tsx b/web/apps/photos/src/components/Upload/UploadTypeSelector.tsx index c3b4d38452..c1bdc43f5f 100644 --- a/web/apps/photos/src/components/Upload/UploadTypeSelector.tsx +++ b/web/apps/photos/src/components/Upload/UploadTypeSelector.tsx @@ -1,3 +1,4 @@ +import { useIsTouchscreen } from "@/base/hooks"; import { FocusVisibleButton } from "@/new/photos/components/FocusVisibleButton"; import DialogTitleWithCloseButton, { DialogTitleWithCloseButtonSm, @@ -10,8 +11,7 @@ import { default as FileUploadIcon } from "@mui/icons-material/ImageOutlined"; import { default as FolderUploadIcon } from "@mui/icons-material/PermMediaOutlined"; import { Box, Dialog, Link, Stack, Typography } from "@mui/material"; import { t } from "i18next"; -import React, { useContext, useEffect, useRef, useState } from "react"; -import { isMobileOrTable } from "utils/common/deviceDetection"; +import React, { useContext, useEffect, useState } from "react"; import { PublicCollectionGalleryContext } from "utils/publicCollectionGallery"; export type UploadTypeSelectorIntent = "upload" | "import" | "collect"; @@ -49,12 +49,14 @@ export const UploadTypeSelector: React.FC = ({ PublicCollectionGalleryContext, ); - const directlyShowUploadFiles = useRef(isMobileOrTable()); + // Directly show the file selector for the public albums app on likely + // mobile devices. + const directlyShowUploadFiles = useIsTouchscreen(); useEffect(() => { if ( open && - directlyShowUploadFiles.current && + directlyShowUploadFiles && publicCollectionGalleryContext.accessedThroughSharedURL ) { uploadFiles(); diff --git a/web/apps/photos/src/components/Upload/Uploader.tsx b/web/apps/photos/src/components/Upload/Uploader.tsx index 312dbbd0f8..7cbdb08984 100644 --- a/web/apps/photos/src/components/Upload/Uploader.tsx +++ b/web/apps/photos/src/components/Upload/Uploader.tsx @@ -880,8 +880,11 @@ function getImportSuggestion( return DEFAULT_IMPORT_SUGGESTION; } - const getCharCount = (str: string) => (str.match(/\//g) ?? []).length; - paths.sort((path1, path2) => getCharCount(path1) - getCharCount(path2)); + const separatorCounts = new Map( + paths.map((s) => [s, s.match(/\//g)?.length ?? 0]), + ); + const separatorCount = (s: string) => ensure(separatorCounts.get(s)); + paths.sort((path1, path2) => separatorCount(path1) - separatorCount(path2)); const firstPath = paths[0]; const lastPath = paths[paths.length - 1]; diff --git a/web/apps/photos/src/components/pages/gallery/PlanSelector.tsx b/web/apps/photos/src/components/pages/gallery/PlanSelector.tsx new file mode 100644 index 0000000000..3879d3667a --- /dev/null +++ b/web/apps/photos/src/components/pages/gallery/PlanSelector.tsx @@ -0,0 +1,794 @@ +import log from "@/base/log"; +import { bytesInGB, formattedStorageByteSize } from "@/new/photos/utils/units"; +import { + FlexWrapper, + FluidContainer, + SpaceBetweenFlex, +} from "@ente/shared/components/Container"; +import ArrowForward from "@mui/icons-material/ArrowForward"; +import ChevronRight from "@mui/icons-material/ChevronRight"; +import Close from "@mui/icons-material/Close"; +import Done from "@mui/icons-material/Done"; +import { + Button, + ButtonProps, + Dialog, + IconButton, + Link, + Stack, + styled, + ToggleButton, + ToggleButtonGroup, + useMediaQuery, + useTheme, +} from "@mui/material"; +import Box from "@mui/material/Box"; +import Typography from "@mui/material/Typography"; +import { PLAN_PERIOD } from "constants/gallery"; +import { t } from "i18next"; +import { AppContext } from "pages/_app"; +import { GalleryContext } from "pages/gallery"; +import { useContext, useEffect, useMemo, useState } from "react"; +import { Trans } from "react-i18next"; +import billingService, { type PlansResponse } from "services/billingService"; +import { Plan, Subscription } from "types/billing"; +import { SetLoading } from "types/gallery"; +import { BonusData } from "types/user"; +import { + activateSubscription, + cancelSubscription, + getLocalUserSubscription, + hasAddOnBonus, + hasMobileSubscription, + hasPaidSubscription, + hasStripeSubscription, + isOnFreePlan, + isPopularPlan, + isSubscriptionActive, + isSubscriptionCancelled, + isUserSubscribedPlan, + manageFamilyMethod, + planForSubscription, + updatePaymentMethod, + updateSubscription, +} from "utils/billing"; +import { getLocalUserDetails } from "utils/user"; +import { getTotalFamilyUsage, isPartOfFamily } from "utils/user/family"; + +interface PlanSelectorProps { + modalView: boolean; + closeModal: any; + setLoading: SetLoading; +} + +function PlanSelector(props: PlanSelectorProps) { + const fullScreen = useMediaQuery(useTheme().breakpoints.down("sm")); + + if (!props.modalView) { + return <>; + } + + return ( + ({ + width: { sm: "391px" }, + p: 1, + [theme.breakpoints.down(360)]: { p: 0 }, + }), + }} + > + + + ); +} + +export default PlanSelector; + +interface PlanSelectorCardProps { + closeModal: any; + setLoading: SetLoading; +} + +function PlanSelectorCard(props: PlanSelectorCardProps) { + const subscription = useMemo(() => getLocalUserSubscription(), []); + const [plansResponse, setPlansResponse] = useState< + PlansResponse | undefined + >(); + + const [planPeriod, setPlanPeriod] = useState( + subscription?.period || PLAN_PERIOD.MONTH, + ); + const galleryContext = useContext(GalleryContext); + const appContext = useContext(AppContext); + const bonusData = useMemo(() => { + const userDetails = getLocalUserDetails(); + if (!userDetails) { + return null; + } + return userDetails.bonusData; + }, []); + + const usage = useMemo(() => { + const userDetails = getLocalUserDetails(); + if (!userDetails) { + return 0; + } + return isPartOfFamily(userDetails.familyData) + ? getTotalFamilyUsage(userDetails.familyData) + : userDetails.usage; + }, []); + + const togglePeriod = () => { + setPlanPeriod((prevPeriod) => + prevPeriod === PLAN_PERIOD.MONTH + ? PLAN_PERIOD.YEAR + : PLAN_PERIOD.MONTH, + ); + }; + function onReopenClick() { + appContext.closeMessageDialog(); + galleryContext.showPlanSelectorModal(); + } + useEffect(() => { + const main = async () => { + try { + props.setLoading(true); + const response = await billingService.getPlans(); + const { plans } = response; + if (isSubscriptionActive(subscription)) { + const planNotListed = + plans.filter((plan) => + isUserSubscribedPlan(plan, subscription), + ).length === 0; + if ( + subscription && + !isOnFreePlan(subscription) && + planNotListed + ) { + plans.push(planForSubscription(subscription)); + } + } + setPlansResponse(response); + } catch (e) { + log.error("plan selector modal open failed", e); + props.closeModal(); + appContext.setDialogMessage({ + title: t("OPEN_PLAN_SELECTOR_MODAL_FAILED"), + content: t("UNKNOWN_ERROR"), + close: { text: t("CLOSE"), variant: "secondary" }, + proceed: { + text: t("REOPEN_PLAN_SELECTOR_MODAL"), + variant: "accent", + action: onReopenClick, + }, + }); + } finally { + props.setLoading(false); + } + }; + main(); + }, []); + + async function onPlanSelect(plan: Plan) { + if ( + !hasPaidSubscription(subscription) && + !isSubscriptionCancelled(subscription) + ) { + try { + props.setLoading(true); + await billingService.buySubscription(plan.stripeID); + } catch (e) { + props.setLoading(false); + appContext.setDialogMessage({ + title: t("ERROR"), + content: t("SUBSCRIPTION_PURCHASE_FAILED"), + close: { variant: "critical" }, + }); + } + } else if (hasStripeSubscription(subscription)) { + appContext.setDialogMessage({ + title: t("update_subscription_title"), + content: t("UPDATE_SUBSCRIPTION_MESSAGE"), + proceed: { + text: t("UPDATE_SUBSCRIPTION"), + action: updateSubscription.bind( + null, + plan, + appContext.setDialogMessage, + props.setLoading, + props.closeModal, + ), + variant: "accent", + }, + close: { text: t("cancel") }, + }); + } else if (hasMobileSubscription(subscription)) { + appContext.setDialogMessage({ + title: t("CANCEL_SUBSCRIPTION_ON_MOBILE"), + content: t("CANCEL_SUBSCRIPTION_ON_MOBILE_MESSAGE"), + close: { variant: "secondary" }, + }); + } else { + appContext.setDialogMessage({ + title: t("MANAGE_PLAN"), + content: ( + , + }} + values={{ emailID: "support@ente.io" }} + /> + ), + close: { variant: "secondary" }, + }); + } + } + + const { closeModal, setLoading } = props; + + const commonCardData = { + subscription, + bonusData, + closeModal, + planPeriod, + togglePeriod, + setLoading, + }; + + const plansList = ( + + ); + + return ( + <> + + {hasPaidSubscription(subscription) ? ( + + {plansList} + + ) : ( + + {plansList} + + )} + + + ); +} + +function FreeSubscriptionPlanSelectorCard({ + children, + subscription, + bonusData, + closeModal, + setLoading, + planPeriod, + togglePeriod, +}) { + return ( + <> + + {t("CHOOSE_PLAN")} + + + + + + + + {t("TWO_MONTHS_FREE")} + + + {children} + {hasAddOnBonus(bonusData) && ( + + )} + {hasAddOnBonus(bonusData) && ( + + )} + + + + ); +} + +function PaidSubscriptionPlanSelectorCard({ + children, + subscription, + bonusData, + closeModal, + usage, + planPeriod, + togglePeriod, + setLoading, +}) { + return ( + <> + + + + + {t("SUBSCRIPTION")} + + + {bytesInGB(subscription.storage, 2)}{" "} + {t("storage_unit.gb")} + + + + + + + + + + + + + + + + `1px solid ${theme.palette.divider}`} + p={1.5} + borderRadius={(theme) => `${theme.shape.borderRadius}px`} + > + + + + {t("TWO_MONTHS_FREE")} + + + {children} + + + + + {!isSubscriptionCancelled(subscription) + ? t("subscription_status_renewal_active", { + date: subscription.expiryTime, + }) + : t("subscription_status_renewal_cancelled", { + date: subscription.expiryTime, + })} + + {hasAddOnBonus(bonusData) && ( + + )} + + + + + + ); +} + +function PeriodToggler({ planPeriod, togglePeriod }) { + const handleChange = (_, newPlanPeriod: PLAN_PERIOD) => { + if (newPlanPeriod !== null && newPlanPeriod !== planPeriod) { + togglePeriod(); + } + }; + + return ( + + + {t("MONTHLY")} + + + {t("YEARLY")} + + + ); +} + +const CustomToggleButton = styled(ToggleButton)(({ theme }) => ({ + textTransform: "none", + padding: "12px 16px", + borderRadius: "4px", + backgroundColor: theme.colors.fill.faint, + border: `1px solid transparent`, + color: theme.colors.text.faint, + "&.Mui-selected": { + backgroundColor: theme.colors.accent.A500, + color: theme.colors.text.base, + }, + "&.Mui-selected:hover": { + backgroundColor: theme.colors.accent.A500, + color: theme.colors.text.base, + }, + width: "97.433px", +})); + +interface PlansProps { + plansResponse: PlansResponse | undefined; + planPeriod: PLAN_PERIOD; + subscription: Subscription; + bonusData?: BonusData; + onPlanSelect: (plan: Plan) => void; + closeModal: () => void; +} + +const Plans = ({ + plansResponse, + planPeriod, + subscription, + bonusData, + onPlanSelect, + closeModal, +}: PlansProps) => { + const { freePlan, plans } = plansResponse ?? {}; + return ( + + {plans + ?.filter((plan) => plan.period === planPeriod) + ?.map((plan) => ( + + ))} + {!hasPaidSubscription(subscription) && + !hasAddOnBonus(bonusData) && + freePlan && ( + + )} + + ); +}; + +interface PlanRowProps { + plan: Plan; + subscription: Subscription; + onPlanSelect: (plan: Plan) => void; + disabled: boolean; + popular: boolean; +} + +function PlanRow({ + plan, + subscription, + onPlanSelect, + disabled, + popular, +}: PlanRowProps) { + const handleClick = () => { + !isUserSubscribedPlan(plan, subscription) && onPlanSelect(plan); + }; + + const PlanButton = disabled ? DisabledPlanButton : ActivePlanButton; + + return ( + + + + {bytesInGB(plan.storage)} + + + + {t("storage_unit.gb")} + + {popular && !hasPaidSubscription(subscription) && ( + {t("POPULAR")} + )} + + + + + + + {plan.price}{" "} + {" "} + + {`/ ${ + plan.period === PLAN_PERIOD.MONTH + ? t("MONTH_SHORT") + : t("YEAR") + }`} + + + + + + ); +} + +const PlanRowContainer = styled(FlexWrapper)(() => ({ + background: + "linear-gradient(268.22deg, rgba(256, 256, 256, 0.08) -3.72%, rgba(256, 256, 256, 0) 85.73%)", +})); + +const TopAlignedFluidContainer = styled(FluidContainer)` + align-items: flex-start; +`; + +const DisabledPlanButton = styled((props: ButtonProps) => ( + +); diff --git a/web/apps/photos/src/components/pages/gallery/PlanSelector/card.tsx b/web/apps/photos/src/components/pages/gallery/PlanSelector/card.tsx deleted file mode 100644 index cb2e08368d..0000000000 --- a/web/apps/photos/src/components/pages/gallery/PlanSelector/card.tsx +++ /dev/null @@ -1,355 +0,0 @@ -import log from "@/base/log"; -import { bytesInGB } from "@/new/photos/utils/units"; -import { SpaceBetweenFlex } from "@ente/shared/components/Container"; -import Close from "@mui/icons-material/Close"; -import { IconButton, Link, Stack } from "@mui/material"; -import Box from "@mui/material/Box"; -import Typography from "@mui/material/Typography"; -import { PLAN_PERIOD } from "constants/gallery"; -import { t } from "i18next"; -import { AppContext } from "pages/_app"; -import { GalleryContext } from "pages/gallery"; -import { useContext, useEffect, useMemo, useState } from "react"; -import { Trans } from "react-i18next"; -import billingService, { type PlansResponse } from "services/billingService"; -import { Plan } from "types/billing"; -import { SetLoading } from "types/gallery"; -import { - getLocalUserSubscription, - hasAddOnBonus, - hasMobileSubscription, - hasPaidSubscription, - hasStripeSubscription, - isOnFreePlan, - isSubscriptionActive, - isSubscriptionCancelled, - isUserSubscribedPlan, - planForSubscription, - updateSubscription, -} from "utils/billing"; -import { getLocalUserDetails } from "utils/user"; -import { getTotalFamilyUsage, isPartOfFamily } from "utils/user/family"; -import { ManageSubscription } from "./manageSubscription"; -import { PeriodToggler } from "./periodToggler"; -import Plans from "./plans"; -import { BFAddOnRow } from "./plans/BfAddOnRow"; - -interface Props { - closeModal: any; - setLoading: SetLoading; -} - -function PlanSelectorCard(props: Props) { - const subscription = useMemo(() => getLocalUserSubscription(), []); - const [plansResponse, setPlansResponse] = useState< - PlansResponse | undefined - >(); - - const [planPeriod, setPlanPeriod] = useState( - subscription?.period || PLAN_PERIOD.MONTH, - ); - const galleryContext = useContext(GalleryContext); - const appContext = useContext(AppContext); - const bonusData = useMemo(() => { - const userDetails = getLocalUserDetails(); - if (!userDetails) { - return null; - } - return userDetails.bonusData; - }, []); - - const usage = useMemo(() => { - const userDetails = getLocalUserDetails(); - if (!userDetails) { - return 0; - } - return isPartOfFamily(userDetails.familyData) - ? getTotalFamilyUsage(userDetails.familyData) - : userDetails.usage; - }, []); - - const togglePeriod = () => { - setPlanPeriod((prevPeriod) => - prevPeriod === PLAN_PERIOD.MONTH - ? PLAN_PERIOD.YEAR - : PLAN_PERIOD.MONTH, - ); - }; - function onReopenClick() { - appContext.closeMessageDialog(); - galleryContext.showPlanSelectorModal(); - } - useEffect(() => { - const main = async () => { - try { - props.setLoading(true); - const response = await billingService.getPlans(); - const { plans } = response; - if (isSubscriptionActive(subscription)) { - const planNotListed = - plans.filter((plan) => - isUserSubscribedPlan(plan, subscription), - ).length === 0; - if ( - subscription && - !isOnFreePlan(subscription) && - planNotListed - ) { - plans.push(planForSubscription(subscription)); - } - } - setPlansResponse(response); - } catch (e) { - log.error("plan selector modal open failed", e); - props.closeModal(); - appContext.setDialogMessage({ - title: t("OPEN_PLAN_SELECTOR_MODAL_FAILED"), - content: t("UNKNOWN_ERROR"), - close: { text: t("CLOSE"), variant: "secondary" }, - proceed: { - text: t("REOPEN_PLAN_SELECTOR_MODAL"), - variant: "accent", - action: onReopenClick, - }, - }); - } finally { - props.setLoading(false); - } - }; - main(); - }, []); - - async function onPlanSelect(plan: Plan) { - if ( - !hasPaidSubscription(subscription) && - !isSubscriptionCancelled(subscription) - ) { - try { - props.setLoading(true); - await billingService.buySubscription(plan.stripeID); - } catch (e) { - props.setLoading(false); - appContext.setDialogMessage({ - title: t("ERROR"), - content: t("SUBSCRIPTION_PURCHASE_FAILED"), - close: { variant: "critical" }, - }); - } - } else if (hasStripeSubscription(subscription)) { - appContext.setDialogMessage({ - title: t("update_subscription_title"), - content: t("UPDATE_SUBSCRIPTION_MESSAGE"), - proceed: { - text: t("UPDATE_SUBSCRIPTION"), - action: updateSubscription.bind( - null, - plan, - appContext.setDialogMessage, - props.setLoading, - props.closeModal, - ), - variant: "accent", - }, - close: { text: t("cancel") }, - }); - } else if (hasMobileSubscription(subscription)) { - appContext.setDialogMessage({ - title: t("CANCEL_SUBSCRIPTION_ON_MOBILE"), - content: t("CANCEL_SUBSCRIPTION_ON_MOBILE_MESSAGE"), - close: { variant: "secondary" }, - }); - } else { - appContext.setDialogMessage({ - title: t("MANAGE_PLAN"), - content: ( - , - }} - values={{ emailID: "support@ente.io" }} - /> - ), - close: { variant: "secondary" }, - }); - } - } - - const { closeModal, setLoading } = props; - - const commonCardData = { - subscription, - bonusData, - closeModal, - planPeriod, - togglePeriod, - setLoading, - }; - - const plansList = ( - - ); - - return ( - <> - - {hasPaidSubscription(subscription) ? ( - - {plansList} - - ) : ( - - {plansList} - - )} - - - ); -} - -export default PlanSelectorCard; - -function FreeSubscriptionPlanSelectorCard({ - children, - subscription, - bonusData, - closeModal, - setLoading, - planPeriod, - togglePeriod, -}) { - return ( - <> - - {t("CHOOSE_PLAN")} - - - - - - - - {t("TWO_MONTHS_FREE")} - - - {children} - {hasAddOnBonus(bonusData) && ( - - )} - {hasAddOnBonus(bonusData) && ( - - )} - - - - ); -} - -function PaidSubscriptionPlanSelectorCard({ - children, - subscription, - bonusData, - closeModal, - usage, - planPeriod, - togglePeriod, - setLoading, -}) { - return ( - <> - - - - - {t("SUBSCRIPTION")} - - - {bytesInGB(subscription.storage, 2)}{" "} - {t("storage_unit.gb")} - - - - - - - - - - - - - - - - `1px solid ${theme.palette.divider}`} - p={1.5} - borderRadius={(theme) => `${theme.shape.borderRadius}px`} - > - - - - {t("TWO_MONTHS_FREE")} - - - {children} - - - - - {!isSubscriptionCancelled(subscription) - ? t("subscription_status_renewal_active", { - date: subscription.expiryTime, - }) - : t("subscription_status_renewal_cancelled", { - date: subscription.expiryTime, - })} - - {hasAddOnBonus(bonusData) && ( - - )} - - - - - - ); -} diff --git a/web/apps/photos/src/components/pages/gallery/PlanSelector/index.tsx b/web/apps/photos/src/components/pages/gallery/PlanSelector/index.tsx deleted file mode 100644 index 28c5a1cc9d..0000000000 --- a/web/apps/photos/src/components/pages/gallery/PlanSelector/index.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { Dialog, useMediaQuery, useTheme } from "@mui/material"; -import { SetLoading } from "types/gallery"; -import PlanSelectorCard from "./card"; - -interface Props { - modalView: boolean; - closeModal: any; - setLoading: SetLoading; -} - -function PlanSelector(props: Props) { - const fullScreen = useMediaQuery(useTheme().breakpoints.down("sm")); - - if (!props.modalView) { - return <>; - } - - return ( - ({ - width: { sm: "391px" }, - p: 1, - [theme.breakpoints.down(360)]: { p: 0 }, - }), - }} - > - - - ); -} - -export default PlanSelector; diff --git a/web/apps/photos/src/components/pages/gallery/PlanSelector/manageSubscription/button.tsx b/web/apps/photos/src/components/pages/gallery/PlanSelector/manageSubscription/button.tsx deleted file mode 100644 index f1aca561ff..0000000000 --- a/web/apps/photos/src/components/pages/gallery/PlanSelector/manageSubscription/button.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import { FluidContainer } from "@ente/shared/components/Container"; -import ChevronRight from "@mui/icons-material/ChevronRight"; -import { Button, ButtonProps } from "@mui/material"; - -const ManageSubscriptionButton = ({ children, ...props }: ButtonProps) => ( - -); - -export default ManageSubscriptionButton; diff --git a/web/apps/photos/src/components/pages/gallery/PlanSelector/manageSubscription/index.tsx b/web/apps/photos/src/components/pages/gallery/PlanSelector/manageSubscription/index.tsx deleted file mode 100644 index dde76f89bd..0000000000 --- a/web/apps/photos/src/components/pages/gallery/PlanSelector/manageSubscription/index.tsx +++ /dev/null @@ -1,137 +0,0 @@ -import { Stack } from "@mui/material"; -import { t } from "i18next"; -import { AppContext } from "pages/_app"; -import { useContext } from "react"; -import { Trans } from "react-i18next"; -import { Subscription } from "types/billing"; -import { SetLoading } from "types/gallery"; -import { BonusData } from "types/user"; -import { - activateSubscription, - cancelSubscription, - hasAddOnBonus, - hasStripeSubscription, - isSubscriptionCancelled, - manageFamilyMethod, - updatePaymentMethod, -} from "utils/billing"; -import ManageSubscriptionButton from "./button"; - -interface Iprops { - subscription: Subscription; - bonusData?: BonusData; - closeModal: () => void; - setLoading: SetLoading; -} - -export function ManageSubscription({ - subscription, - bonusData, - closeModal, - setLoading, -}: Iprops) { - const appContext = useContext(AppContext); - const openFamilyPortal = () => - manageFamilyMethod(appContext.setDialogMessage, setLoading); - - return ( - - {hasStripeSubscription(subscription) && ( - - )} - - {t("MANAGE_FAMILY_PORTAL")} - - - ); -} - -function StripeSubscriptionOptions({ - subscription, - bonusData, - setLoading, - closeModal, -}: Iprops) { - const appContext = useContext(AppContext); - - const confirmReactivation = () => - appContext.setDialogMessage({ - title: t("REACTIVATE_SUBSCRIPTION"), - content: t("REACTIVATE_SUBSCRIPTION_MESSAGE", { - date: subscription.expiryTime, - }), - proceed: { - text: t("REACTIVATE_SUBSCRIPTION"), - action: activateSubscription.bind( - null, - appContext.setDialogMessage, - closeModal, - setLoading, - ), - variant: "accent", - }, - close: { - text: t("cancel"), - }, - }); - const confirmCancel = () => - appContext.setDialogMessage({ - title: t("CANCEL_SUBSCRIPTION"), - content: hasAddOnBonus(bonusData) ? ( - - ) : ( - - ), - proceed: { - text: t("CANCEL_SUBSCRIPTION"), - action: cancelSubscription.bind( - null, - appContext.setDialogMessage, - closeModal, - setLoading, - ), - variant: "critical", - }, - close: { - text: t("NEVERMIND"), - }, - }); - const openManagementPortal = updatePaymentMethod.bind( - null, - appContext.setDialogMessage, - setLoading, - ); - return ( - <> - {isSubscriptionCancelled(subscription) ? ( - - {t("REACTIVATE_SUBSCRIPTION")} - - ) : ( - - {t("CANCEL_SUBSCRIPTION")} - - )} - - {t("MANAGEMENT_PORTAL")} - - - ); -} diff --git a/web/apps/photos/src/components/pages/gallery/PlanSelector/periodToggler.tsx b/web/apps/photos/src/components/pages/gallery/PlanSelector/periodToggler.tsx deleted file mode 100644 index 1faf74b343..0000000000 --- a/web/apps/photos/src/components/pages/gallery/PlanSelector/periodToggler.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import { styled, ToggleButton, ToggleButtonGroup } from "@mui/material"; -import { PLAN_PERIOD } from "constants/gallery"; -import { t } from "i18next"; - -const CustomToggleButton = styled(ToggleButton)(({ theme }) => ({ - textTransform: "none", - padding: "12px 16px", - borderRadius: "4px", - backgroundColor: theme.colors.fill.faint, - border: `1px solid transparent`, - color: theme.colors.text.faint, - "&.Mui-selected": { - backgroundColor: theme.colors.accent.A500, - color: theme.colors.text.base, - }, - "&.Mui-selected:hover": { - backgroundColor: theme.colors.accent.A500, - color: theme.colors.text.base, - }, - width: "97.433px", -})); - -export function PeriodToggler({ planPeriod, togglePeriod }) { - const handleChange = (_, newPlanPeriod: PLAN_PERIOD) => { - if (newPlanPeriod !== null && newPlanPeriod !== planPeriod) { - togglePeriod(); - } - }; - - return ( - - - {t("MONTHLY")} - - - {t("YEARLY")} - - - ); -} diff --git a/web/apps/photos/src/components/pages/gallery/PlanSelector/plans/BfAddOnRow.tsx b/web/apps/photos/src/components/pages/gallery/PlanSelector/plans/BfAddOnRow.tsx deleted file mode 100644 index 6484ae2156..0000000000 --- a/web/apps/photos/src/components/pages/gallery/PlanSelector/plans/BfAddOnRow.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { SpaceBetweenFlex } from "@ente/shared/components/Container"; -import { Box, styled, Typography } from "@mui/material"; - -import { formattedStorageByteSize } from "@/new/photos/utils/units"; -import { Trans } from "react-i18next"; - -const RowContainer = styled(SpaceBetweenFlex)(({ theme }) => ({ - // gap: theme.spacing(1.5), - padding: theme.spacing(1, 0), - cursor: "pointer", - "&:hover .endIcon": { - backgroundColor: "rgba(255,255,255,0.08)", - }, -})); -export function BFAddOnRow({ bonusData, closeModal }) { - return ( - <> - {bonusData.storageBonuses.map((bonus) => { - if (bonus.type.startsWith("ADD_ON")) { - return ( - - - - - - - - ); - } - return null; - })} - - ); -} diff --git a/web/apps/photos/src/components/pages/gallery/PlanSelector/plans/button.tsx b/web/apps/photos/src/components/pages/gallery/PlanSelector/plans/button.tsx deleted file mode 100644 index f6ac71ac10..0000000000 --- a/web/apps/photos/src/components/pages/gallery/PlanSelector/plans/button.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import { SpaceBetweenFlex } from "@ente/shared/components/Container"; -import ChevronRight from "@mui/icons-material/ChevronRight"; -import Done from "@mui/icons-material/Done"; -import { Box, Button } from "@mui/material"; -import { t } from "i18next"; -export function PlanIconButton({ - current, - onClick, -}: { - current: boolean; - onClick: () => void; -}) { - return ( - - {current ? ( - - ) : ( - - )} - - ); -} - -function CurrentPlanTileButton() { - return ( - - ); -} - -function NormalPlanTileButton({ onClick }) { - return ( - - ); -} diff --git a/web/apps/photos/src/components/pages/gallery/PlanSelector/plans/index.tsx b/web/apps/photos/src/components/pages/gallery/PlanSelector/plans/index.tsx deleted file mode 100644 index 3489d3f03d..0000000000 --- a/web/apps/photos/src/components/pages/gallery/PlanSelector/plans/index.tsx +++ /dev/null @@ -1,94 +0,0 @@ -import { formattedStorageByteSize } from "@/new/photos/utils/units"; -import { SpaceBetweenFlex } from "@ente/shared/components/Container"; -import ArrowForward from "@mui/icons-material/ArrowForward"; -import { Box, IconButton, Stack, Typography, styled } from "@mui/material"; -import { PLAN_PERIOD } from "constants/gallery"; -import { t } from "i18next"; -import type { PlansResponse } from "services/billingService"; -import { Plan, Subscription } from "types/billing"; -import { BonusData } from "types/user"; -import { - hasAddOnBonus, - hasPaidSubscription, - isPopularPlan, - isUserSubscribedPlan, -} from "utils/billing"; -import { PlanRow } from "./planRow"; - -interface Iprops { - plansResponse: PlansResponse | undefined; - planPeriod: PLAN_PERIOD; - subscription: Subscription; - bonusData?: BonusData; - onPlanSelect: (plan: Plan) => void; - closeModal: () => void; -} - -const Plans = ({ - plansResponse, - planPeriod, - subscription, - bonusData, - onPlanSelect, - closeModal, -}: Iprops) => { - const { freePlan, plans } = plansResponse ?? {}; - return ( - - {plans - ?.filter((plan) => plan.period === planPeriod) - ?.map((plan) => ( - - ))} - {!hasPaidSubscription(subscription) && - !hasAddOnBonus(bonusData) && - freePlan && ( - - )} - - ); -}; - -export default Plans; - -interface FreePlanRowProps { - storage: number; - closeModal: () => void; -} - -const FreePlanRow: React.FC = ({ closeModal, storage }) => { - return ( - - - {t("free_plan_option")} - - {t("free_plan_description", { - storage: formattedStorageByteSize(storage), - })} - - - - - - - ); -}; - -const FreePlanRow_ = styled(SpaceBetweenFlex)(({ theme }) => ({ - gap: theme.spacing(1.5), - padding: theme.spacing(1.5, 1), - cursor: "pointer", - "&:hover .endIcon": { - backgroundColor: "rgba(255,255,255,0.08)", - }, -})); diff --git a/web/apps/photos/src/components/pages/gallery/PlanSelector/plans/planRow.tsx b/web/apps/photos/src/components/pages/gallery/PlanSelector/plans/planRow.tsx deleted file mode 100644 index 9701baf01a..0000000000 --- a/web/apps/photos/src/components/pages/gallery/PlanSelector/plans/planRow.tsx +++ /dev/null @@ -1,103 +0,0 @@ -import { bytesInGB } from "@/new/photos/utils/units"; -import { FlexWrapper, FluidContainer } from "@ente/shared/components/Container"; -import ArrowForward from "@mui/icons-material/ArrowForward"; -import Done from "@mui/icons-material/Done"; -import { Box, Button, ButtonProps, Typography, styled } from "@mui/material"; -import { Badge } from "components/Badge"; -import { PLAN_PERIOD } from "constants/gallery"; -import { t } from "i18next"; -import { Plan, Subscription } from "types/billing"; -import { hasPaidSubscription, isUserSubscribedPlan } from "utils/billing"; - -interface Iprops { - plan: Plan; - subscription: Subscription; - onPlanSelect: (plan: Plan) => void; - disabled: boolean; - popular: boolean; -} - -const PlanRowContainer = styled(FlexWrapper)(() => ({ - background: - "linear-gradient(268.22deg, rgba(256, 256, 256, 0.08) -3.72%, rgba(256, 256, 256, 0) 85.73%)", -})); - -const TopAlignedFluidContainer = styled(FluidContainer)` - align-items: flex-start; -`; - -const DisabledPlanButton = styled((props: ButtonProps) => ( - - ); -} - -export default GoToEnte; diff --git a/web/apps/photos/src/components/pages/sharedAlbum/Navbar.tsx b/web/apps/photos/src/components/pages/sharedAlbum/Navbar.tsx index 18abdc436e..9c45de76d7 100644 --- a/web/apps/photos/src/components/pages/sharedAlbum/Navbar.tsx +++ b/web/apps/photos/src/components/pages/sharedAlbum/Navbar.tsx @@ -1,10 +1,10 @@ import { NavbarBase } from "@/base/components/Navbar"; +import { useIsTouchscreen } from "@/base/hooks"; import { FluidContainer } from "@ente/shared/components/Container"; import AddPhotoAlternateOutlined from "@mui/icons-material/AddPhotoAlternateOutlined"; -import { Box } from "@mui/material"; +import { Box, Button } from "@mui/material"; import UploadButton from "components/Upload/UploadButton"; import { t } from "i18next"; -import GoToEnte from "./GoToEnte"; export default function SharedAlbumNavbar({ showUploadButton, openUploader }) { return ( @@ -57,3 +57,14 @@ const Ente: React.FC = () => { ); }; + +const GoToEnte: React.FC = () => { + // Touchscreen devices are overwhemingly likely to be Android or iOS. + const isTouchscreen = useIsTouchscreen(); + + return ( + + ); +}; diff --git a/web/apps/photos/src/pages/gallery/index.tsx b/web/apps/photos/src/pages/gallery/index.tsx index fcb31027cd..1c72be1727 100644 --- a/web/apps/photos/src/pages/gallery/index.tsx +++ b/web/apps/photos/src/pages/gallery/index.tsx @@ -119,7 +119,6 @@ import { splitNormalAndHiddenCollections, } from "utils/collection"; import ComlinkSearchWorker from "utils/comlink/ComlinkSearchWorker"; -import { preloadImage } from "utils/common"; import { FILE_OPS_TYPE, constructFileToCollectionMap, @@ -1066,7 +1065,6 @@ export default function Gallery() { setFixCreationTimeView(false)} - show={() => setFixCreationTimeView(true)} attributes={fixCreationTimeAttributes} /> { + const srcset = []; + for (let i = 1; i <= 3; i++) srcset.push(`${imgBasePath}/${i}x.png ${i}x`); + new Image().srcset = srcset.join(","); +}; + const mergeMaps = (map1: Map, map2: Map) => { const mergedMap = new Map(map1); map2.forEach((value, key) => { diff --git a/web/apps/photos/src/services/collectionService.ts b/web/apps/photos/src/services/collectionService.ts index 336742b0f2..e98ee93bde 100644 --- a/web/apps/photos/src/services/collectionService.ts +++ b/web/apps/photos/src/services/collectionService.ts @@ -1,13 +1,14 @@ import log from "@/base/log"; import { apiURL } from "@/base/origins"; +import { ItemVisibility } from "@/media/file-metadata"; import { getLocalFiles } from "@/new/photos/services/files"; import { EnteFile } from "@/new/photos/types/file"; import { EncryptedMagicMetadata, SUB_TYPE, UpdateMagicMetadataRequest, - VISIBILITY_STATE, } from "@/new/photos/types/magicMetadata"; +import { batch } from "@/utils/array"; import ComlinkCryptoWorker from "@ente/shared/crypto"; import { CustomError } from "@ente/shared/error"; import HTTPService from "@ente/shared/network/HTTPService"; @@ -62,7 +63,6 @@ import { isSharedOnlyViaLink, isValidMoveTarget, } from "utils/collection"; -import { batch } from "utils/common"; import { getUniqueFiles, groupFilesBasedOnCollectionID, @@ -426,7 +426,7 @@ const createCollection = async ( let encryptedMagicMetadata: EncryptedMagicMetadata; if (magicMetadataProps) { const magicMetadata = await updateMagicMetadata(magicMetadataProps); - const { file: encryptedMagicMetadataProps } = + const encryptedMagicMetadataProps = await cryptoWorker.encryptMetadata( magicMetadataProps, collectionKey, @@ -434,8 +434,8 @@ const createCollection = async ( encryptedMagicMetadata = { ...magicMetadata, - data: encryptedMagicMetadataProps.encryptedData, - header: encryptedMagicMetadataProps.decryptionHeader, + data: encryptedMagicMetadataProps.encryptedDataB64, + header: encryptedMagicMetadataProps.decryptionHeaderB64, }; } const newCollection: EncryptedCollection = { @@ -799,18 +799,19 @@ export const updateCollectionMagicMetadata = async ( const cryptoWorker = await ComlinkCryptoWorker.getInstance(); - const { file: encryptedMagicMetadata } = await cryptoWorker.encryptMetadata( - updatedMagicMetadata.data, - collection.key, - ); + const { encryptedDataB64, decryptionHeaderB64 } = + await cryptoWorker.encryptMetadata( + updatedMagicMetadata.data, + collection.key, + ); const reqBody: UpdateMagicMetadataRequest = { id: collection.id, magicMetadata: { version: updatedMagicMetadata.version, count: updatedMagicMetadata.count, - data: encryptedMagicMetadata.encryptedData, - header: encryptedMagicMetadata.decryptionHeader, + data: encryptedDataB64, + header: decryptionHeaderB64, }, }; @@ -843,18 +844,19 @@ export const updateSharedCollectionMagicMetadata = async ( const cryptoWorker = await ComlinkCryptoWorker.getInstance(); - const { file: encryptedMagicMetadata } = await cryptoWorker.encryptMetadata( - updatedMagicMetadata.data, - collection.key, - ); + const { encryptedDataB64, decryptionHeaderB64 } = + await cryptoWorker.encryptMetadata( + updatedMagicMetadata.data, + collection.key, + ); const reqBody: UpdateMagicMetadataRequest = { id: collection.id, magicMetadata: { version: updatedMagicMetadata.version, count: updatedMagicMetadata.count, - data: encryptedMagicMetadata.encryptedData, - header: encryptedMagicMetadata.decryptionHeader, + data: encryptedDataB64, + header: decryptionHeaderB64, }, }; @@ -887,18 +889,19 @@ export const updatePublicCollectionMagicMetadata = async ( const cryptoWorker = await ComlinkCryptoWorker.getInstance(); - const { file: encryptedMagicMetadata } = await cryptoWorker.encryptMetadata( - updatedPublicMagicMetadata.data, - collection.key, - ); + const { encryptedDataB64, decryptionHeaderB64 } = + await cryptoWorker.encryptMetadata( + updatedPublicMagicMetadata.data, + collection.key, + ); const reqBody: UpdateMagicMetadataRequest = { id: collection.id, magicMetadata: { version: updatedPublicMagicMetadata.version, count: updatedPublicMagicMetadata.count, - data: encryptedMagicMetadata.encryptedData, - header: encryptedMagicMetadata.decryptionHeader, + data: encryptedDataB64, + header: decryptionHeaderB64, }, }; @@ -1372,7 +1375,7 @@ export async function getDefaultHiddenCollection(): Promise { export function createHiddenCollection() { return createCollection(HIDDEN_COLLECTION_NAME, CollectionType.album, { subType: SUB_TYPE.DEFAULT_HIDDEN, - visibility: VISIBILITY_STATE.HIDDEN, + visibility: ItemVisibility.hidden, }); } diff --git a/web/apps/photos/src/services/deduplicationService.ts b/web/apps/photos/src/services/deduplicationService.ts index 20a558c5e2..3fecc6c018 100644 --- a/web/apps/photos/src/services/deduplicationService.ts +++ b/web/apps/photos/src/services/deduplicationService.ts @@ -1,8 +1,8 @@ import log from "@/base/log"; import { apiURL } from "@/base/origins"; import { hasFileHash } from "@/media/file"; -import { FILE_TYPE } from "@/media/file-type"; -import type { Metadata } from "@/media/types/file"; +import type { Metadata } from "@/media/file-metadata"; +import { FileType } from "@/media/file-type"; import { EnteFile } from "@/new/photos/types/file"; import HTTPService from "@ente/shared/network/HTTPService"; import { getToken } from "@ente/shared/storage/localStorage/helpers"; @@ -174,7 +174,7 @@ async function sortDuplicateFiles( } function areFileHashesSame(firstFile: Metadata, secondFile: Metadata) { - if (firstFile.fileType === FILE_TYPE.LIVE_PHOTO) { + if (firstFile.fileType === FileType.livePhoto) { return ( firstFile.imageHash === secondFile.imageHash && firstFile.videoHash === secondFile.videoHash diff --git a/web/apps/photos/src/services/entityService.ts b/web/apps/photos/src/services/entityService.ts index 1e418aa2c6..401819b110 100644 --- a/web/apps/photos/src/services/entityService.ts +++ b/web/apps/photos/src/services/entityService.ts @@ -143,6 +143,7 @@ const syncEntity = async (type: EntityType): Promise> => { } const entityKey = await getEntityKey(type); + // @ts-expect-error TODO: Need to use zod here. const newDecryptedEntities: Array> = await Promise.all( response.diff.map(async (entity: EncryptedEntity) => { if (entity.isDeleted) { diff --git a/web/apps/photos/src/services/export/index.ts b/web/apps/photos/src/services/export/index.ts index 7efb6556cc..4af1792bb7 100644 --- a/web/apps/photos/src/services/export/index.ts +++ b/web/apps/photos/src/services/export/index.ts @@ -1,8 +1,8 @@ import { ensureElectron } from "@/base/electron"; import log from "@/base/log"; -import { FILE_TYPE } from "@/media/file-type"; +import type { Metadata } from "@/media/file-metadata"; +import { FileType } from "@/media/file-type"; import { decodeLivePhoto } from "@/media/live-photo"; -import type { Metadata } from "@/media/types/file"; import downloadManager from "@/new/photos/services/download"; import { updateExifIfNeededAndPossible } from "@/new/photos/services/exif-update"; import { @@ -975,7 +975,7 @@ class ExportService { file, originalFileStream, ); - if (file.metadata.fileType === FILE_TYPE.LIVE_PHOTO) { + if (file.metadata.fileType === FileType.livePhoto) { await this.exportLivePhoto( exportDir, fileUID, diff --git a/web/apps/photos/src/services/export/migration.ts b/web/apps/photos/src/services/export/migration.ts index 978319c48f..9c1c011e32 100644 --- a/web/apps/photos/src/services/export/migration.ts +++ b/web/apps/photos/src/services/export/migration.ts @@ -1,7 +1,7 @@ import { ensureElectron } from "@/base/electron"; import { nameAndExtension } from "@/base/file"; import log from "@/base/log"; -import { FILE_TYPE } from "@/media/file-type"; +import { FileType } from "@/media/file-type"; import { decodeLivePhoto } from "@/media/live-photo"; import downloadManager from "@/new/photos/services/download"; import { exportMetadataDirectoryName } from "@/new/photos/services/export"; @@ -312,7 +312,7 @@ async function getFileExportNamesFromExportedFiles( /* For Live Photos we need to download the file to get the image and video name */ - if (file.metadata.fileType === FILE_TYPE.LIVE_PHOTO) { + if (file.metadata.fileType === FileType.livePhoto) { const fileStream = await downloadManager.getFile(file); const fileBlob = await new Response(fileStream).blob(); const { imageFileName, videoFileName } = await decodeLivePhoto( diff --git a/web/apps/photos/src/services/fileService.ts b/web/apps/photos/src/services/fileService.ts index deb9bda779..d2d4e8c49a 100644 --- a/web/apps/photos/src/services/fileService.ts +++ b/web/apps/photos/src/services/fileService.ts @@ -10,6 +10,7 @@ import { } from "@/new/photos/types/file"; import { BulkUpdateMagicMetadataRequest } from "@/new/photos/types/magicMetadata"; import { mergeMetadata } from "@/new/photos/utils/file"; +import { batch } from "@/utils/array"; import ComlinkCryptoWorker from "@ente/shared/crypto"; import HTTPService from "@ente/shared/network/HTTPService"; import { getToken } from "@ente/shared/storage/localStorage/helpers"; @@ -17,7 +18,6 @@ import { REQUEST_BATCH_SIZE } from "constants/api"; import exportService from "services/export"; import { Collection } from "types/collection"; import { SetFiles } from "types/gallery"; -import { batch } from "utils/common"; import { decryptFile, getLatestVersionFiles, sortFiles } from "utils/file"; import { getCollectionLastSyncTime, @@ -191,7 +191,7 @@ export const updateFileMagicMetadata = async ( file, updatedMagicMetadata, } of fileWithUpdatedMagicMetadataList) { - const { file: encryptedMagicMetadata } = + const { encryptedDataB64, decryptionHeaderB64 } = await cryptoWorker.encryptMetadata( updatedMagicMetadata.data, file.key, @@ -201,8 +201,8 @@ export const updateFileMagicMetadata = async ( magicMetadata: { version: updatedMagicMetadata.version, count: updatedMagicMetadata.count, - data: encryptedMagicMetadata.encryptedData, - header: encryptedMagicMetadata.decryptionHeader, + data: encryptedDataB64, + header: decryptionHeaderB64, }, }); } @@ -236,20 +236,20 @@ export const updateFilePublicMagicMetadata = async ( const cryptoWorker = await ComlinkCryptoWorker.getInstance(); for (const { file, - updatedPublicMagicMetadata: updatePublicMagicMetadata, + updatedPublicMagicMetadata, } of fileWithUpdatedPublicMagicMetadataList) { - const { file: encryptedPubMagicMetadata } = + const { encryptedDataB64, decryptionHeaderB64 } = await cryptoWorker.encryptMetadata( - updatePublicMagicMetadata.data, + updatedPublicMagicMetadata.data, file.key, ); reqBody.metadataList.push({ id: file.id, magicMetadata: { - version: updatePublicMagicMetadata.version, - count: updatePublicMagicMetadata.count, - data: encryptedPubMagicMetadata.encryptedData, - header: encryptedPubMagicMetadata.decryptionHeader, + version: updatedPublicMagicMetadata.version, + count: updatedPublicMagicMetadata.count, + data: encryptedDataB64, + header: decryptionHeaderB64, }, }); } diff --git a/web/apps/photos/src/services/fix-exif.ts b/web/apps/photos/src/services/fix-exif.ts deleted file mode 100644 index a0a8539eb8..0000000000 --- a/web/apps/photos/src/services/fix-exif.ts +++ /dev/null @@ -1,105 +0,0 @@ -import log from "@/base/log"; -import { FILE_TYPE } from "@/media/file-type"; -import downloadManager from "@/new/photos/services/download"; -import { EnteFile } from "@/new/photos/types/file"; -import { detectFileTypeInfo } from "@/new/photos/utils/detect-type"; -import { validateAndGetCreationUnixTimeInMicroSeconds } from "@ente/shared/time"; -import type { FixOption } from "components/FixCreationTime"; -import { - changeFileCreationTime, - updateExistingFilePubMetadata, -} from "utils/file"; -import { getParsedExifData } from "./exif"; - -const EXIF_TIME_TAGS = [ - "DateTimeOriginal", - "CreateDate", - "ModifyDate", - "DateCreated", - "MetadataDate", -]; - -export type SetProgressTracker = React.Dispatch< - React.SetStateAction<{ - current: number; - total: number; - }> ->; - -export async function updateCreationTimeWithExif( - filesToBeUpdated: EnteFile[], - fixOption: FixOption, - customTime: Date, - setProgressTracker: SetProgressTracker, -) { - let completedWithError = false; - try { - if (filesToBeUpdated.length === 0) { - return completedWithError; - } - setProgressTracker({ current: 0, total: filesToBeUpdated.length }); - for (const [index, file] of filesToBeUpdated.entries()) { - try { - let correctCreationTime: number; - if (fixOption === "custom-time") { - correctCreationTime = customTime.getTime() * 1000; - } else { - if (file.metadata.fileType !== FILE_TYPE.IMAGE) { - continue; - } - const fileStream = await downloadManager.getFile(file); - const fileBlob = await new Response(fileStream).blob(); - const fileObject = new File( - [fileBlob], - file.metadata.title, - ); - const fileTypeInfo = await detectFileTypeInfo(fileObject); - const exifData = await getParsedExifData( - fileObject, - fileTypeInfo, - EXIF_TIME_TAGS, - ); - if (fixOption === "date-time-original") { - correctCreationTime = - validateAndGetCreationUnixTimeInMicroSeconds( - exifData?.DateTimeOriginal ?? - exifData?.DateCreated, - ); - } else if (fixOption === "date-time-digitized") { - correctCreationTime = - validateAndGetCreationUnixTimeInMicroSeconds( - exifData?.CreateDate, - ); - } else if (fixOption === "metadata-date") { - correctCreationTime = - validateAndGetCreationUnixTimeInMicroSeconds( - exifData?.MetadataDate, - ); - } - } - if ( - correctCreationTime && - correctCreationTime !== file.metadata.creationTime - ) { - const updatedFile = await changeFileCreationTime( - file, - correctCreationTime, - ); - updateExistingFilePubMetadata(file, updatedFile); - } - } catch (e) { - log.error("failed to updated a CreationTime With Exif", e); - completedWithError = true; - } finally { - setProgressTracker({ - current: index + 1, - total: filesToBeUpdated.length, - }); - } - } - } catch (e) { - log.error("update CreationTime With Exif failed", e); - completedWithError = true; - } - return completedWithError; -} diff --git a/web/apps/photos/src/services/logout.ts b/web/apps/photos/src/services/logout.ts index 9722757689..ab4b1aa6c5 100644 --- a/web/apps/photos/src/services/logout.ts +++ b/web/apps/photos/src/services/logout.ts @@ -22,7 +22,7 @@ export const photosLogout = async () => { // See: [Note: Caching IDB instances in separate execution contexts]. try { - terminateMLWorker(); + await terminateMLWorker(); } catch (e) { ignoreError("face", e); } diff --git a/web/apps/photos/src/services/searchService.ts b/web/apps/photos/src/services/searchService.ts index 94587d40b1..750a1fb186 100644 --- a/web/apps/photos/src/services/searchService.ts +++ b/web/apps/photos/src/services/searchService.ts @@ -1,13 +1,12 @@ import { isDesktop } from "@/base/app"; -import { ensureElectron } from "@/base/electron"; import log from "@/base/log"; -import { FILE_TYPE } from "@/media/file-type"; +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"; @@ -95,17 +94,17 @@ function getFileTypeSuggestion(searchPhrase: string): Suggestion[] { return [ { label: t("IMAGE"), - value: FILE_TYPE.IMAGE, + value: FileType.image, type: SuggestionType.FILE_TYPE, }, { label: t("VIDEO"), - value: FILE_TYPE.VIDEO, + value: FileType.video, type: SuggestionType.FILE_TYPE, }, { label: t("LIVE_PHOTO"), - value: FILE_TYPE.LIVE_PHOTO, + value: FileType.livePhoto, type: SuggestionType.FILE_TYPE, }, ].filter((suggestion) => @@ -374,7 +373,7 @@ const searchClip = async ( searchPhrase: string, ): Promise => { if (!isMLEnabled()) return undefined; - const matches = await clipMatches(searchPhrase, ensureElectron()); + const matches = await clipMatches(searchPhrase); log.debug(() => ["clip/scores", matches]); return matches; }; @@ -407,7 +406,7 @@ function convertSuggestionToSearchQuery(option: Suggestion): Search { return { person: option.value as Person }; case SuggestionType.FILE_TYPE: - return { fileType: option.value as FILE_TYPE }; + return { fileType: option.value as FileType }; case SuggestionType.CLIP: return { clip: option.value as ClipSearchScores }; diff --git a/web/apps/photos/src/services/upload/thumbnail.ts b/web/apps/photos/src/services/upload/thumbnail.ts index 5045a3c2b2..a768a04622 100644 --- a/web/apps/photos/src/services/upload/thumbnail.ts +++ b/web/apps/photos/src/services/upload/thumbnail.ts @@ -1,6 +1,6 @@ import log from "@/base/log"; import { type Electron } from "@/base/types/ipc"; -import { FILE_TYPE, type FileTypeInfo } from "@/media/file-type"; +import { FileType, type FileTypeInfo } from "@/media/file-type"; import { heicToJPEG } from "@/media/heic-convert"; import { scaledImageDimensions } from "@/media/image"; import * as ffmpeg from "@/new/photos/services/ffmpeg"; @@ -52,7 +52,7 @@ export const generateThumbnailWeb = async ( blob: Blob, fileTypeInfo: FileTypeInfo, ): Promise => - fileTypeInfo.fileType === FILE_TYPE.IMAGE + fileTypeInfo.fileType === FileType.image ? await generateImageThumbnailWeb(blob, fileTypeInfo) : await generateVideoThumbnailWeb(blob); @@ -193,7 +193,7 @@ export const generateThumbnailNative = async ( desktopUploadItem: DesktopUploadItem, fileTypeInfo: FileTypeInfo, ): Promise => - fileTypeInfo.fileType === FILE_TYPE.IMAGE + fileTypeInfo.fileType === FileType.image ? await electron.generateImageThumbnail( toDataOrPathOrZipEntry(desktopUploadItem), maxThumbnailDimension, diff --git a/web/apps/photos/src/services/upload/uploadManager.ts b/web/apps/photos/src/services/upload/uploadManager.ts index 53846150b9..571ce34b9a 100644 --- a/web/apps/photos/src/services/upload/uploadManager.ts +++ b/web/apps/photos/src/services/upload/uploadManager.ts @@ -3,7 +3,7 @@ import { lowercaseExtension, nameAndExtension } from "@/base/file"; import log from "@/base/log"; import type { Electron } from "@/base/types/ipc"; import { ComlinkWorker } from "@/base/worker/comlink-worker"; -import { FILE_TYPE } from "@/media/file-type"; +import { FileType } from "@/media/file-type"; import { potentialFileTypeFromExtension } from "@/media/live-photo"; import { getLocalFiles } from "@/new/photos/services/files"; import { indexNewUpload } from "@/new/photos/services/ml"; @@ -865,7 +865,7 @@ const clusterLivePhotos = async ( }; if (await areLivePhotoAssets(fa, ga)) { const [image, video] = - fFileType == FILE_TYPE.IMAGE ? [f, g] : [g, f]; + fFileType == FileType.image ? [f, g] : [g, f]; result.push({ localID: f.localID, collectionID: f.collectionID, @@ -896,7 +896,7 @@ const clusterLivePhotos = async ( interface PotentialLivePhotoAsset { fileName: string; - fileType: FILE_TYPE; + fileType: FileType; collectionID: number; uploadItem: UploadItem; } @@ -912,7 +912,7 @@ const areLivePhotoAssets = async ( let fPrunedName: string; let gPrunedName: string; - if (f.fileType == FILE_TYPE.IMAGE && g.fileType == FILE_TYPE.VIDEO) { + if (f.fileType == FileType.image && g.fileType == FileType.video) { fPrunedName = removePotentialLivePhotoSuffix( fName, // A Google Live Photo image file can have video extension appended @@ -923,7 +923,7 @@ const areLivePhotoAssets = async ( gExt ? `.${gExt}` : undefined, ); gPrunedName = removePotentialLivePhotoSuffix(gName); - } else if (f.fileType == FILE_TYPE.VIDEO && g.fileType == FILE_TYPE.IMAGE) { + } else if (f.fileType == FileType.video && g.fileType == FileType.image) { fPrunedName = removePotentialLivePhotoSuffix(fName); gPrunedName = removePotentialLivePhotoSuffix( gName, diff --git a/web/apps/photos/src/services/upload/uploadService.ts b/web/apps/photos/src/services/upload/uploadService.ts index 53301f4aaa..8511136352 100644 --- a/web/apps/photos/src/services/upload/uploadService.ts +++ b/web/apps/photos/src/services/upload/uploadService.ts @@ -3,9 +3,10 @@ import { basename } from "@/base/file"; import log from "@/base/log"; import { CustomErrorMessage } from "@/base/types/ipc"; import { hasFileHash } from "@/media/file"; -import { FILE_TYPE, type FileTypeInfo } from "@/media/file-type"; +import type { Metadata } from "@/media/file-metadata"; +import { FileType, type FileTypeInfo } from "@/media/file-type"; import { encodeLivePhoto } from "@/media/live-photo"; -import type { Metadata } from "@/media/types/file"; +import { cmpNewLib, extractExif, wipNewLib } from "@/new/photos/services/exif"; import * as ffmpeg from "@/new/photos/services/ffmpeg"; import type { UploadItem } from "@/new/photos/services/upload/types"; import { @@ -30,9 +31,9 @@ import { DedicatedCryptoWorker } from "@ente/shared/crypto/internal/crypto.worke import type { B64EncryptionResult } from "@ente/shared/crypto/internal/libsodium"; import { ENCRYPTION_CHUNK_SIZE } from "@ente/shared/crypto/internal/libsodium"; import { CustomError, handleUploadError } from "@ente/shared/error"; +import { parseImageMetadata } from "@ente/shared/utils/exif-old"; import type { Remote } from "comlink"; import { addToCollection } from "services/collectionService"; -import { parseImageMetadata } from "services/exif"; import { PublicUploadProps, type LivePhotoAssets, @@ -244,6 +245,11 @@ interface LocalFileAttributes< decryptionHeader: string; } +interface EncryptedMetadata { + encryptedDataB64: string; + decryptionHeaderB64: string; +} + interface EncryptionResult< T extends string | Uint8Array | EncryptedFileStream, > { @@ -254,7 +260,7 @@ interface EncryptionResult< interface ProcessedFile { file: LocalFileAttributes; thumbnail: LocalFileAttributes; - metadata: LocalFileAttributes; + metadata: EncryptedMetadata; pubMagicMetadata: EncryptedMagicMetadata; localID: number; } @@ -614,7 +620,7 @@ const readLivePhotoDetails = async ({ image, video }: LivePhotoAssets) => { return { fileTypeInfo: { - fileType: FILE_TYPE.LIVE_PHOTO, + fileType: FileType.livePhoto, extension: `${img.fileTypeInfo.extension}+${vid.fileTypeInfo.extension}`, imageType: img.fileTypeInfo.extension, videoType: vid.fileTypeInfo.extension, @@ -703,7 +709,7 @@ const extractLivePhotoMetadata = async ( worker: Remote, ) => { const imageFileTypeInfo: FileTypeInfo = { - fileType: FILE_TYPE.IMAGE, + fileType: FileType.image, extension: fileTypeInfo.imageType, }; const { metadata: imageMetadata, publicMagicMetadata } = @@ -722,7 +728,7 @@ const extractLivePhotoMetadata = async ( metadata: { ...imageMetadata, title: uploadItemFileName(livePhotoAssets.image), - fileType: FILE_TYPE.LIVE_PHOTO, + fileType: FileType.livePhoto, imageHash: imageMetadata.hash, videoHash: videoHash, hash: undefined, @@ -743,14 +749,14 @@ const extractImageOrVideoMetadata = async ( const { fileType } = fileTypeInfo; let extractedMetadata: ParsedExtractedMetadata; - if (fileType === FILE_TYPE.IMAGE) { + if (fileType === FileType.image) { extractedMetadata = (await tryExtractImageMetadata( uploadItem, fileTypeInfo, lastModifiedMs, )) ?? NULL_EXTRACTED_METADATA; - } else if (fileType === FILE_TYPE.VIDEO) { + } else if (fileType === FileType.video) { extractedMetadata = (await tryExtractVideoMetadata(uploadItem)) ?? NULL_EXTRACTED_METADATA; @@ -808,9 +814,9 @@ async function tryExtractImageMetadata( ): Promise { let file: File; if (typeof uploadItem == "string" || Array.isArray(uploadItem)) { - // The library we use for extracting Exif from images, exifr, doesn't - // support streams. But unlike videos, for images it is reasonable to - // read the entire stream into memory here. + // The library we use for extracting Exif from images, ExifReader, + // doesn't support streams. But unlike videos, for images it is + // reasonable to read the entire stream into memory here. const { response } = await readStream(ensureElectron(), uploadItem); const path = typeof uploadItem == "string" ? uploadItem : uploadItem[1]; file = new File([await response.arrayBuffer()], basename(path), { @@ -823,7 +829,12 @@ async function tryExtractImageMetadata( } try { - return await parseImageMetadata(file, fileTypeInfo); + const oldLib = await parseImageMetadata(file, fileTypeInfo); + if (await wipNewLib()) { + const newLib = await extractExif(file); + cmpNewLib(oldLib, newLib); + } + return oldLib; } catch (e) { log.error(`Failed to extract image metadata for ${uploadItem}`, e); return undefined; @@ -874,7 +885,7 @@ const areFilesSameHash = (f: Metadata, g: Metadata) => { if (f.fileType !== g.fileType || f.title !== g.title) { return false; } - if (f.fileType === FILE_TYPE.LIVE_PHOTO) { + if (f.fileType === FileType.livePhoto) { return f.imageHash === g.imageHash && f.videoHash === g.videoHash; } else { return f.hash === g.hash; @@ -930,7 +941,7 @@ const readLivePhoto = async ( livePhotoAssets.image, { extension: fileTypeInfo.imageType, - fileType: FILE_TYPE.IMAGE, + fileType: FileType.image, }, await readUploadItem(livePhotoAssets.image), ); @@ -1009,7 +1020,7 @@ const withThumbnail = async ( const electron = globalThis.electron; const notAvailable = - fileTypeInfo.fileType == FILE_TYPE.IMAGE && + fileTypeInfo.fileType == FileType.image && moduleState.isNativeImageThumbnailGenerationNotAvailable; // 1. Native thumbnail generation using items's (effective) path. @@ -1056,7 +1067,7 @@ const withThumbnail = async ( // go (i.e. not in a streaming manner). This is risky for videos of // unbounded sizes, so we can only apply this fallback for images. - if (fileTypeInfo.fileType == FILE_TYPE.IMAGE) { + if (fileTypeInfo.fileType == FileType.image) { const data = await readEntireStream(fileStream.stream); blob = new Blob([data]); @@ -1113,25 +1124,31 @@ const encryptFile = async ( worker, ); - const { file: encryptedThumbnail } = await worker.encryptThumbnail( - file.thumbnail, - fileKey, - ); + const { + encryptedData: thumbEncryptedData, + decryptionHeaderB64: thumbDecryptionHeader, + } = await worker.encryptThumbnail(file.thumbnail, fileKey); + const encryptedThumbnail = { + encryptedData: thumbEncryptedData, + decryptionHeader: thumbDecryptionHeader, + }; - const { file: encryptedMetadata } = await worker.encryptMetadata( + const encryptedMetadata = await worker.encryptMetadata( file.metadata, fileKey, ); let encryptedPubMagicMetadata: EncryptedMagicMetadata; if (file.pubMagicMetadata) { - const { file: encryptedPubMagicMetadataData } = - await worker.encryptMetadata(file.pubMagicMetadata.data, fileKey); + const encryptedPubMagicMetadataData = await worker.encryptMetadata( + file.pubMagicMetadata.data, + fileKey, + ); encryptedPubMagicMetadata = { version: file.pubMagicMetadata.version, count: file.pubMagicMetadata.count, - data: encryptedPubMagicMetadataData.encryptedData, - header: encryptedPubMagicMetadataData.decryptionHeader, + data: encryptedPubMagicMetadataData.encryptedDataB64, + header: encryptedPubMagicMetadataData.decryptionHeaderB64, }; } @@ -1261,7 +1278,10 @@ const uploadToBucket = async ( decryptionHeader: file.thumbnail.decryptionHeader, objectKey: thumbnailObjectKey, }, - metadata: file.metadata, + metadata: { + encryptedData: file.metadata.encryptedDataB64, + decryptionHeader: file.metadata.decryptionHeaderB64, + }, pubMagicMetadata: file.pubMagicMetadata, }; return backupedFile; diff --git a/web/apps/photos/src/types/collection/index.ts b/web/apps/photos/src/types/collection/index.ts index 1321451b2e..4997cd73de 100644 --- a/web/apps/photos/src/types/collection/index.ts +++ b/web/apps/photos/src/types/collection/index.ts @@ -1,9 +1,9 @@ +import { ItemVisibility } from "@/media/file-metadata"; import { EnteFile } from "@/new/photos/types/file"; import { EncryptedMagicMetadata, MagicMetadataCore, SUB_TYPE, - VISIBILITY_STATE, } from "@/new/photos/types/magicMetadata"; import { CollectionSummaryType, CollectionType } from "constants/collection"; @@ -119,7 +119,7 @@ export interface RemoveFromCollectionRequest { } export interface CollectionMagicMetadataProps { - visibility?: VISIBILITY_STATE; + visibility?: ItemVisibility; subType?: SUB_TYPE; order?: number; } @@ -128,7 +128,7 @@ export type CollectionMagicMetadata = MagicMetadataCore; export interface CollectionShareeMetadataProps { - visibility?: VISIBILITY_STATE; + visibility?: ItemVisibility; } export type CollectionShareeMagicMetadata = MagicMetadataCore; diff --git a/web/apps/photos/src/types/search/index.ts b/web/apps/photos/src/types/search/index.ts index 130471ae76..fdb054f7f5 100644 --- a/web/apps/photos/src/types/search/index.ts +++ b/web/apps/photos/src/types/search/index.ts @@ -1,4 +1,4 @@ -import { FILE_TYPE } from "@/media/file-type"; +import { FileType } from "@/media/file-type"; import type { MLStatus } from "@/new/photos/services/ml"; import type { Person } from "@/new/photos/services/ml/people"; import { EnteFile } from "@/new/photos/types/file"; @@ -34,7 +34,7 @@ export interface Suggestion { | MLStatus | LocationTagData | City - | FILE_TYPE + | FileType | ClipSearchScores; hide?: boolean; } @@ -46,7 +46,7 @@ export type Search = { collection?: number; files?: number[]; person?: Person; - fileType?: FILE_TYPE; + fileType?: FileType; clip?: ClipSearchScores; }; diff --git a/web/apps/photos/src/utils/collection/index.ts b/web/apps/photos/src/utils/collection/index.ts index 1510f49e69..b3393da69f 100644 --- a/web/apps/photos/src/utils/collection/index.ts +++ b/web/apps/photos/src/utils/collection/index.ts @@ -1,8 +1,9 @@ import { ensureElectron } from "@/base/electron"; import log from "@/base/log"; +import { ItemVisibility } from "@/media/file-metadata"; import { getAllLocalFiles, getLocalFiles } from "@/new/photos/services/files"; import { EnteFile } from "@/new/photos/types/file"; -import { SUB_TYPE, VISIBILITY_STATE } from "@/new/photos/types/magicMetadata"; +import { SUB_TYPE } from "@/new/photos/types/magicMetadata"; import { safeDirectoryName } from "@/new/photos/utils/native-fs"; import { CustomError } from "@ente/shared/error"; import { LS_KEYS, getData } from "@ente/shared/storage/localStorage"; @@ -229,7 +230,7 @@ export const shareExpiryOptions = () => [ export const changeCollectionVisibility = async ( collection: Collection, - visibility: VISIBILITY_STATE, + visibility: ItemVisibility, ) => { try { const updatedMagicMetadataProps: CollectionMagicMetadataProps = { @@ -418,7 +419,7 @@ export const isDefaultHiddenCollection = (collection: Collection) => collection.magicMetadata?.data.subType === SUB_TYPE.DEFAULT_HIDDEN; export const isHiddenCollection = (collection: Collection) => - collection.magicMetadata?.data.visibility === VISIBILITY_STATE.HIDDEN; + collection.magicMetadata?.data.visibility === ItemVisibility.hidden; export const isQuickLinkCollection = (collection: Collection) => collection.magicMetadata?.data.subType === SUB_TYPE.QUICK_LINK_COLLECTION; diff --git a/web/apps/photos/src/utils/common/deviceDetection.ts b/web/apps/photos/src/utils/common/deviceDetection.ts deleted file mode 100644 index 7ce55bba7d..0000000000 --- a/web/apps/photos/src/utils/common/deviceDetection.ts +++ /dev/null @@ -1,69 +0,0 @@ -export enum OS { - WP = "wp", - ANDROID = "android", - IOS = "ios", - UNKNOWN = "unknown", - WINDOWS = "windows", - MAC = "mac", - LINUX = "linux", -} - -declare global { - interface Window { - opera: any; - MSStream: any; - } -} - -export const getDeviceOS = () => { - let userAgent = ""; - if ( - typeof window !== "undefined" && - typeof window.navigator !== "undefined" - ) { - userAgent = navigator.userAgent || navigator.vendor || window.opera; - } - // Windows Phone must come first because its UA also contains "Android" - if (/windows phone/i.test(userAgent)) { - return OS.WP; - } - - if (/android/i.test(userAgent)) { - return OS.ANDROID; - } - - // iOS detection from: http://stackoverflow.com/a/9039885/177710 - if (/(iPad|iPhone|iPod)/g.test(userAgent) && !window.MSStream) { - return OS.IOS; - } - - // credit: https://github.com/MikeKovarik/platform-detect/blob/master/os.mjs - if (userAgent.includes("Windows")) { - return OS.WINDOWS; - } - if (userAgent.includes("Macintosh")) { - return OS.MAC; - } - // Linux must be last - if (userAgent.includes("Linux")) { - return OS.LINUX; - } - - return OS.UNKNOWN; -}; - -export const isMobileOrTable = () => { - let check = false; - (function (a) { - if ( - /(android|bb\d+|meego).+mobile|avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od)|iris|kindle|lge |maemo|midp|mmp|mobile.+firefox|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|series(4|6)0|symbian|treo|up\.(browser|link)|vodafone|wap|windows ce|xda|xiino|android|ipad|playbook|silk/i.test( - a, - ) || - /1207|6310|6590|3gso|4thp|50[1-6]i|770s|802s|a wa|abac|ac(er|oo|s-)|ai(ko|rn)|al(av|ca|co)|amoi|an(ex|ny|yw)|aptu|ar(ch|go)|as(te|us)|attw|au(di|-m|r |s )|avan|be(ck|ll|nq)|bi(lb|rd)|bl(ac|az)|br(e|v)w|bumb|bw-(n|u)|c55\/|capi|ccwa|cdm-|cell|chtm|cldc|cmd-|co(mp|nd)|craw|da(it|ll|ng)|dbte|dc-s|devi|dica|dmob|do(c|p)o|ds(12|-d)|el(49|ai)|em(l2|ul)|er(ic|k0)|esl8|ez([4-7]0|os|wa|ze)|fetc|fly(-|_)|g1 u|g560|gene|gf-5|g-mo|go(\.w|od)|gr(ad|un)|haie|hcit|hd-(m|p|t)|hei-|hi(pt|ta)|hp( i|ip)|hs-c|ht(c(-| |_|a|g|p|s|t)|tp)|hu(aw|tc)|i-(20|go|ma)|i230|iac( |-|\/)|ibro|idea|ig01|ikom|im1k|inno|ipaq|iris|ja(t|v)a|jbro|jemu|jigs|kddi|keji|kgt( |\/)|klon|kpt |kwc-|kyo(c|k)|le(no|xi)|lg( g|\/(k|l|u)|50|54|-[a-w])|libw|lynx|m1-w|m3ga|m50\/|ma(te|ui|xo)|mc(01|21|ca)|m-cr|me(rc|ri)|mi(o8|oa|ts)|mmef|mo(01|02|bi|de|do|t(-| |o|v)|zz)|mt(50|p1|v )|mwbp|mywa|n10[0-2]|n20[2-3]|n30(0|2)|n50(0|2|5)|n7(0(0|1)|10)|ne((c|m)-|on|tf|wf|wg|wt)|nok(6|i)|nzph|o2im|op(ti|wv)|oran|owg1|p800|pan(a|d|t)|pdxg|pg(13|-([1-8]|c))|phil|pire|pl(ay|uc)|pn-2|po(ck|rt|se)|prox|psio|pt-g|qa-a|qc(07|12|21|32|60|-[2-7]|i-)|qtek|r380|r600|raks|rim9|ro(ve|zo)|s55\/|sa(ge|ma|mm|ms|ny|va)|sc(01|h-|oo|p-)|sdk\/|se(c(-|0|1)|47|mc|nd|ri)|sgh-|shar|sie(-|m)|sk-0|sl(45|id)|sm(al|ar|b3|it|t5)|so(ft|ny)|sp(01|h-|v-|v )|sy(01|mb)|t2(18|50)|t6(00|10|18)|ta(gt|lk)|tcl-|tdg-|tel(i|m)|tim-|t-mo|to(pl|sh)|ts(70|m-|m3|m5)|tx-9|up(\.b|g1|si)|utst|v400|v750|veri|vi(rg|te)|vk(40|5[0-3]|-v)|vm40|voda|vulc|vx(52|53|60|61|70|80|81|83|85|98)|w3c(-| )|webc|whit|wi(g |nc|nw)|wmlb|wonu|x700|yas-|your|zeto|zte-/i.test( - a.substr(0, 4), - ) - ) - check = true; - })(navigator.userAgent || navigator.vendor || window.opera); - return check; -}; diff --git a/web/apps/photos/src/utils/common/index.ts b/web/apps/photos/src/utils/common/index.ts deleted file mode 100644 index 91628f98c4..0000000000 --- a/web/apps/photos/src/utils/common/index.ts +++ /dev/null @@ -1,19 +0,0 @@ -export const preloadImage = (imgBasePath: string) => { - const srcSet = []; - for (let i = 1; i <= 3; i++) { - srcSet.push(`${imgBasePath}/${i}x.png ${i}x`); - } - new Image().srcset = srcSet.join(","); -}; - -export function isClipboardItemPresent() { - return typeof ClipboardItem !== "undefined"; -} - -export function batch(arr: T[], batchSize: number): T[][] { - const batches: T[][] = []; - for (let i = 0; i < arr.length; i += batchSize) { - batches.push(arr.slice(i, i + batchSize)); - } - return batches; -} diff --git a/web/apps/photos/src/utils/file/index.ts b/web/apps/photos/src/utils/file/index.ts index fe4587e605..71978ee24c 100644 --- a/web/apps/photos/src/utils/file/index.ts +++ b/web/apps/photos/src/utils/file/index.ts @@ -1,6 +1,7 @@ import log from "@/base/log"; import { type Electron } from "@/base/types/ipc"; -import { FILE_TYPE } from "@/media/file-type"; +import { ItemVisibility } from "@/media/file-metadata"; +import { FileType } from "@/media/file-type"; import { decodeLivePhoto } from "@/media/live-photo"; import DownloadManager from "@/new/photos/services/download"; import { updateExifIfNeededAndPossible } from "@/new/photos/services/exif-update"; @@ -13,7 +14,6 @@ import { FilePublicMagicMetadataProps, FileWithUpdatedMagicMetadata, } from "@/new/photos/types/file"; -import { VISIBILITY_STATE } from "@/new/photos/types/magicMetadata"; import { detectFileTypeInfo } from "@/new/photos/utils/detect-type"; import { mergeMetadata } from "@/new/photos/utils/file"; import { safeFileName } from "@/new/photos/utils/native-fs"; @@ -53,7 +53,7 @@ export async function downloadFile(file: EnteFile) { let fileBlob = await new Response( await DownloadManager.getFile(file), ).blob(); - if (file.metadata.fileType === FILE_TYPE.LIVE_PHOTO) { + if (file.metadata.fileType === FileType.livePhoto) { const { imageFileName, imageData, videoFileName, videoData } = await decodeLivePhoto(file.metadata.title, fileBlob); const image = new File([imageData], imageFileName); @@ -177,6 +177,7 @@ export async function decryptFile( return { ...restFileProps, key: fileKey, + // @ts-expect-error TODO: Need to use zod here. metadata: fileMetadata, magicMetadata: fileMagicMetadata, pubMagicMetadata: filePubMagicMetadata, @@ -189,7 +190,7 @@ export async function decryptFile( export async function changeFilesVisibility( files: EnteFile[], - visibility: VISIBILITY_STATE, + visibility: ItemVisibility, ): Promise { const fileWithUpdatedMagicMetadataList: FileWithUpdatedMagicMetadata[] = []; for (const file of files) { @@ -450,7 +451,7 @@ async function downloadFileDesktop( const stream = await DownloadManager.getFile(file); const updatedStream = await updateExifIfNeededAndPossible(file, stream); - if (file.metadata.fileType === FILE_TYPE.LIVE_PHOTO) { + if (file.metadata.fileType === FileType.livePhoto) { const fileBlob = await new Response(updatedStream).blob(); const { imageFileName, imageData, videoFileName, videoData } = await decodeLivePhoto(file.metadata.title, fileBlob); @@ -495,8 +496,8 @@ async function downloadFileDesktop( } } -export const isImageOrVideo = (fileType: FILE_TYPE) => - [FILE_TYPE.IMAGE, FILE_TYPE.VIDEO].includes(fileType); +export const isImageOrVideo = (fileType: FileType) => + [FileType.image, FileType.video].includes(fileType); export const getArchivedFiles = (files: EnteFile[]) => { return files.filter(isArchivedFile).map((file) => file.id); @@ -657,10 +658,10 @@ export const handleFileOps = async ( fixTimeHelper(files, setFixCreationTimeAttributes); break; case FILE_OPS_TYPE.ARCHIVE: - await changeFilesVisibility(files, VISIBILITY_STATE.ARCHIVED); + await changeFilesVisibility(files, ItemVisibility.archived); break; case FILE_OPS_TYPE.UNARCHIVE: - await changeFilesVisibility(files, VISIBILITY_STATE.VISIBLE); + await changeFilesVisibility(files, ItemVisibility.visible); break; } }; diff --git a/web/apps/photos/src/utils/magicMetadata/index.ts b/web/apps/photos/src/utils/magicMetadata/index.ts index 8d94a574f9..2d80b486d2 100644 --- a/web/apps/photos/src/utils/magicMetadata/index.ts +++ b/web/apps/photos/src/utils/magicMetadata/index.ts @@ -1,8 +1,6 @@ +import { ItemVisibility } from "@/media/file-metadata"; import { EnteFile } from "@/new/photos/types/file"; -import { - MagicMetadataCore, - VISIBILITY_STATE, -} from "@/new/photos/types/magicMetadata"; +import { MagicMetadataCore } from "@/new/photos/types/magicMetadata"; import ComlinkCryptoWorker from "@ente/shared/crypto"; import { Collection } from "types/collection"; @@ -10,7 +8,7 @@ export function isArchivedFile(item: EnteFile): boolean { if (!item || !item.magicMetadata || !item.magicMetadata.data) { return false; } - return item.magicMetadata.data.visibility === VISIBILITY_STATE.ARCHIVED; + return item.magicMetadata.data.visibility === ItemVisibility.archived; } export function isArchivedCollection(item: Collection): boolean { @@ -19,13 +17,12 @@ export function isArchivedCollection(item: Collection): boolean { } if (item.magicMetadata && item.magicMetadata.data) { - return item.magicMetadata.data.visibility === VISIBILITY_STATE.ARCHIVED; + return item.magicMetadata.data.visibility === ItemVisibility.archived; } if (item.sharedMagicMetadata && item.sharedMagicMetadata.data) { return ( - item.sharedMagicMetadata.data.visibility === - VISIBILITY_STATE.ARCHIVED + item.sharedMagicMetadata.data.visibility === ItemVisibility.archived ); } return false; @@ -56,6 +53,7 @@ export async function updateMagicMetadata( } if (typeof originalMagicMetadata?.data === "string") { + // @ts-expect-error TODO: Need to use zod here. originalMagicMetadata.data = await cryptoWorker.decryptMetadata( originalMagicMetadata.data, originalMagicMetadata.header, diff --git a/web/apps/photos/src/utils/photoFrame/index.ts b/web/apps/photos/src/utils/photoFrame/index.ts index 4a7f589a81..fdf3c8855a 100644 --- a/web/apps/photos/src/utils/photoFrame/index.ts +++ b/web/apps/photos/src/utils/photoFrame/index.ts @@ -1,5 +1,5 @@ import log from "@/base/log"; -import { FILE_TYPE } from "@/media/file-type"; +import { FileType } from "@/media/file-type"; import type { LivePhotoSourceURL, SourceURLs } from "@/new/photos/types/file"; import { EnteFile } from "@/new/photos/types/file"; import { SetSelectedState } from "types/gallery"; @@ -30,7 +30,7 @@ export function updateFileMsrcProps(file: EnteFile, url: string) { file.isSourceLoaded = false; file.conversionFailed = false; file.isConverted = false; - if (file.metadata.fileType === FILE_TYPE.IMAGE) { + if (file.metadata.fileType === FileType.image) { file.src = url; } else { file.html = ` @@ -50,7 +50,7 @@ export async function updateFileSrcProps( file.w = window.innerWidth; file.h = window.innerHeight; file.isSourceLoaded = - file.metadata.fileType === FILE_TYPE.LIVE_PHOTO + file.metadata.fileType === FileType.livePhoto ? srcURLs.type === "livePhoto" : true; file.isConverted = !isOriginal; @@ -61,7 +61,7 @@ export async function updateFileSrcProps( return; } - if (file.metadata.fileType === FILE_TYPE.VIDEO) { + if (file.metadata.fileType === FileType.video) { file.html = `