Browse Source

Merge branch 'main' into refactor_redesign_file_info

ashilkn 2 years ago
parent
commit
86c96ccb10

+ 1 - 1
README.md

@@ -66,7 +66,7 @@ You can alternatively install the build from PlayStore or F-Droid.
 3. Pull in all submodules with `git submodule update --init --recursive`
 4. Enable repo git hooks `git config core.hooksPath hooks`
 5. Setup TensorFlowLite by executing `setup.sh`
-6. For Android, run `flutter build apk --release --flavor independent`
+6. For Android, [setup your keystore](https://docs.flutter.dev/deployment/android#create-an-upload-keystore) and run `flutter build apk --release --flavor independent`
 7. For iOS, run `flutter build ios`
 
 <br/>

+ 5 - 5
ios/Podfile.lock

@@ -108,7 +108,7 @@ PODS:
   - libwebp/mux (1.2.4):
     - libwebp/demux
   - libwebp/webp (1.2.4)
-  - local_auth (0.0.1):
+  - local_auth_ios (0.0.1):
     - Flutter
   - Mantle (2.2.0):
     - Mantle/extobjc (= 2.2.0)
@@ -195,7 +195,7 @@ DEPENDENCIES:
   - fluttertoast (from `.symlinks/plugins/fluttertoast/ios`)
   - image_editor_common (from `.symlinks/plugins/image_editor_common/ios`)
   - in_app_purchase_storekit (from `.symlinks/plugins/in_app_purchase_storekit/ios`)
-  - local_auth (from `.symlinks/plugins/local_auth/ios`)
+  - local_auth_ios (from `.symlinks/plugins/local_auth_ios/ios`)
   - media_extension (from `.symlinks/plugins/media_extension/ios`)
   - motionphoto (from `.symlinks/plugins/motionphoto/ios`)
   - move_to_background (from `.symlinks/plugins/move_to_background/ios`)
@@ -276,8 +276,8 @@ EXTERNAL SOURCES:
     :path: ".symlinks/plugins/image_editor_common/ios"
   in_app_purchase_storekit:
     :path: ".symlinks/plugins/in_app_purchase_storekit/ios"
-  local_auth:
-    :path: ".symlinks/plugins/local_auth/ios"
+  local_auth_ios:
+    :path: ".symlinks/plugins/local_auth_ios/ios"
   media_extension:
     :path: ".symlinks/plugins/media_extension/ios"
   motionphoto:
@@ -346,7 +346,7 @@ SPEC CHECKSUMS:
   image_editor_common: d6f6644ae4a6de80481e89fe6d0a8c49e30b4b43
   in_app_purchase_storekit: 6b297e2b5eab9fa3251a492d57301722e4132a71
   libwebp: f62cb61d0a484ba548448a4bd52aabf150ff6eef
-  local_auth: 1740f55d7af0a2e2a8684ce225fe79d8931e808c
+  local_auth_ios: c6cf091ded637a88f24f86a8875d8b0f526e2605
   Mantle: c5aa8794a29a022dfbbfc9799af95f477a69b62d
   media_extension: 6d30dc1431ebaa63f43c397c37917b1a0a597a4c
   motionphoto: d4a432b8c8f22fb3ad966258597c0103c9c5ff16

+ 2 - 2
ios/Runner.xcodeproj/project.pbxproj

@@ -290,7 +290,7 @@
 				"${BUILT_PRODUCTS_DIR}/image_editor_common/image_editor_common.framework",
 				"${BUILT_PRODUCTS_DIR}/in_app_purchase_storekit/in_app_purchase_storekit.framework",
 				"${BUILT_PRODUCTS_DIR}/libwebp/libwebp.framework",
-				"${BUILT_PRODUCTS_DIR}/local_auth/local_auth.framework",
+				"${BUILT_PRODUCTS_DIR}/local_auth_ios/local_auth_ios.framework",
 				"${BUILT_PRODUCTS_DIR}/media_extension/media_extension.framework",
 				"${BUILT_PRODUCTS_DIR}/motionphoto/motionphoto.framework",
 				"${BUILT_PRODUCTS_DIR}/move_to_background/move_to_background.framework",
@@ -346,7 +346,7 @@
 				"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/image_editor_common.framework",
 				"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/in_app_purchase_storekit.framework",
 				"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/libwebp.framework",
-				"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/local_auth.framework",
+				"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/local_auth_ios.framework",
 				"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/media_extension.framework",
 				"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/motionphoto.framework",
 				"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/move_to_background.framework",

+ 5 - 0
lib/events/sync_status_update_event.dart

@@ -1,6 +1,10 @@
+import "package:logging/logging.dart";
+
 import 'package:photos/events/event.dart';
 
 class SyncStatusUpdate extends Event {
+  static final _logger = Logger("SyncStatusUpdate");
+
   final SyncStatus status;
   final int? completed;
   final int? total;
@@ -18,6 +22,7 @@ class SyncStatusUpdate extends Event {
     this.reason = "",
     this.error,
   }) {
+    _logger.info("Creating sync status: " + status.toString());
     timestamp = DateTime.now().microsecondsSinceEpoch;
   }
 }

+ 6 - 1
lib/models/user_details.dart

@@ -9,6 +9,7 @@ class UserDetails {
   final String email;
   final int usage;
   final int fileCount;
+  final int storageBonus;
   final int sharedCollectionsCount;
   final Subscription subscription;
   final FamilyData? familyData;
@@ -17,6 +18,7 @@ class UserDetails {
     this.email,
     this.usage,
     this.fileCount,
+    this.storageBonus,
     this.sharedCollectionsCount,
     this.subscription,
     this.familyData,
@@ -50,7 +52,8 @@ class UserDetails {
   }
 
   int getTotalStorage() {
-    return isPartOfFamily() ? familyData!.storage : subscription.storage;
+    return (isPartOfFamily() ? familyData!.storage : subscription.storage) +
+        storageBonus;
   }
 
   factory UserDetails.fromMap(Map<String, dynamic> map) {
@@ -58,6 +61,7 @@ class UserDetails {
       map['email'] as String,
       map['usage'] as int,
       (map['fileCount'] ?? 0) as int,
+      (map['storageBonus'] ?? 0) as int,
       (map['sharedCollectionsCount'] ?? 0) as int,
       Subscription.fromMap(map['subscription']),
       FamilyData.fromMap(map['familyData']),
@@ -69,6 +73,7 @@ class UserDetails {
       'email': email,
       'usage': usage,
       'fileCount': fileCount,
+      'storageBonus': storageBonus,
       'sharedCollectionsCount': sharedCollectionsCount,
       'subscription': subscription.toMap(),
       'familyData': familyData?.toMap(),

+ 1 - 0
lib/services/collections_service.dart

@@ -141,6 +141,7 @@ class CollectionsService {
     for (final collection in collections) {
       _cacheCollectionAttributes(collection);
     }
+    _logger.info("Collections synced");
     watch.log("collection cache refresh");
     if (fetchedCollections.isNotEmpty) {
       Bus.instance.fire(

+ 10 - 1
lib/services/feature_flag_service.dart

@@ -14,6 +14,12 @@ class FeatureFlagService {
   static final FeatureFlagService instance =
       FeatureFlagService._privateConstructor();
   static const _featureFlagsKey = "feature_flags_key";
+  static final _internalUserIDs = const String.fromEnvironment(
+    "internal_user_ids",
+    defaultValue: "1,2,3,4,191",
+  ).split(",").map((element) {
+    return int.parse(element);
+  }).toSet();
 
   final _logger = Logger("FeatureFlagService");
   FeatureFlags? _featureFlags;
@@ -64,7 +70,10 @@ class FeatureFlagService {
 
   bool isInternalUserOrDebugBuild() {
     final String? email = Configuration.instance.getEmail();
-    return (email != null && email.endsWith("@ente.io")) || kDebugMode;
+    final userID = Configuration.instance.getUserID();
+    return (email != null && email.endsWith("@ente.io")) ||
+        _internalUserIDs.contains(userID) ||
+        kDebugMode;
   }
 
   Future<void> fetchFeatureFlags() async {

+ 45 - 30
lib/services/remote_sync_service.dart

@@ -23,6 +23,7 @@ import 'package:photos/models/file_type.dart';
 import 'package:photos/models/upload_strategy.dart';
 import 'package:photos/services/app_lifecycle_service.dart';
 import 'package:photos/services/collections_service.dart';
+import "package:photos/services/feature_flag_service.dart";
 import 'package:photos/services/ignored_files_service.dart';
 import 'package:photos/services/local_file_update_service.dart';
 import 'package:photos/services/sync_service.dart';
@@ -44,7 +45,7 @@ class RemoteSyncService {
   int _completedUploads = 0;
   late SharedPreferences _prefs;
   Completer<void>? _existingSync;
-  bool _existingSyncSilent = false;
+  bool _isExistingSyncSilent = false;
 
   static const kHasSyncedArchiveKey = "has_synced_archive";
   final String _isFirstRemoteSyncDone = "isFirstRemoteSyncDone";
@@ -84,13 +85,17 @@ class RemoteSyncService {
       _logger.info("Remote sync already in progress, skipping");
       // if current sync is silent but request sync is non-silent (demands UI
       // updates), update the syncSilently flag
-      if (_existingSyncSilent == true && silently == false) {
-        _existingSyncSilent = false;
+      if (_isExistingSyncSilent && !silently) {
+        _isExistingSyncSilent = false;
       }
       return _existingSync?.future;
     }
     _existingSync = Completer<void>();
-    _existingSyncSilent = silently;
+    _isExistingSyncSilent = silently;
+    _logger.info(
+      "Starting remote sync " +
+          (silently ? "silently" : " with status updates"),
+    );
 
     try {
       // use flag to decide if we should start marking files for upload before
@@ -115,18 +120,20 @@ class RemoteSyncService {
       }
       final filesToBeUploaded = await _getFilesToBeUploaded();
       final hasUploadedFiles = await _uploadFiles(filesToBeUploaded);
+      _logger.info("File upload complete");
       if (hasUploadedFiles) {
         await _pullDiff();
         _existingSync?.complete();
         _existingSync = null;
         await syncDeviceCollectionFilesForUpload();
         final hasMoreFilesToBackup = (await _getFilesToBeUploaded()).isNotEmpty;
+        _logger.info("hasMoreFilesToBackup?" + hasMoreFilesToBackup.toString());
         if (hasMoreFilesToBackup && !_shouldThrottleSync()) {
           // Skipping a resync to ensure that files that were ignored in this
           // session are not processed now
           sync();
         } else {
-          debugPrint("Fire backup completed event");
+          _logger.info("Fire backup completed event");
           Bus.instance.fire(SyncStatusUpdate(SyncStatus.completedBackup));
         }
       } else {
@@ -146,13 +153,17 @@ class RemoteSyncService {
         rethrow;
       } else {
         _logger.severe("Error executing remote sync ", e, s);
+        if (FeatureFlagService.instance.isInternalUserOrDebugBuild()) {
+          rethrow;
+        }
       }
     } finally {
-      _existingSyncSilent = false;
+      _isExistingSyncSilent = false;
     }
   }
 
   Future<void> _pullDiff() async {
+    _logger.info("Pulling remote diff");
     final isFirstSync = !_collectionsService.hasSyncedCollections();
     await _collectionsService.sync();
     // check and reset user's collection syncTime in past for older clients
@@ -179,6 +190,7 @@ class RemoteSyncService {
       );
       await _collectionsService.setCollectionSyncTime(c.id, c.updationTime);
     }
+    _logger.info("All updated collections synced");
   }
 
   Future<void> _resetAllCollectionsSyncTime() async {
@@ -193,7 +205,12 @@ class RemoteSyncService {
   }
 
   Future<void> _syncCollectionDiff(int collectionID, int sinceTime) async {
-    if (!_existingSyncSilent) {
+    _logger.info(
+      "Syncing collection #" +
+          collectionID.toString() +
+          (_isExistingSyncSilent ? " silently" : ""),
+    );
+    if (!_isExistingSyncSilent) {
       Bus.instance.fire(SyncStatusUpdate(SyncStatus.applyingRemoteDiff));
     }
     final diff =
@@ -252,10 +269,13 @@ class RemoteSyncService {
         collectionID,
         _collectionsService.getCollectionSyncTime(collectionID),
       );
+    } else {
+      _logger.info("Collection #" + collectionID.toString() + " synced");
     }
   }
 
   Future<void> syncDeviceCollectionFilesForUpload() async {
+    _logger.info("Syncing device collections to be uploaded");
     final int ownerID = _config.getUserID()!;
 
     final deviceCollections = await _db.getDeviceCollections();
@@ -279,15 +299,15 @@ class RemoteSyncService {
       if (localIDsToSync.isEmpty) {
         continue;
       }
-      await _createCollectionForDevicePath(deviceCollection);
-      if (deviceCollection.collectionID == -1) {
-        _logger.finest('DeviceCollection should not be -1 here');
+      final collectionID = await _getCollectionID(deviceCollection);
+      if (collectionID == null) {
+        _logger.warning('DeviceCollection was either deleted or missing');
         continue;
       }
 
       moreFilesMarkedForBackup = true;
       await _db.setCollectionIDForUnMappedLocalFiles(
-        deviceCollection.collectionID!,
+        collectionID,
         localIDsToSync,
       );
 
@@ -295,15 +315,13 @@ class RemoteSyncService {
       // the collection. This can happen when a user has marked a folder
       // for sync, then un-synced it and again tries to mark if for sync.
       final Set<String> existingMapping =
-          await _db.getLocalFileIDsForCollection(
-        deviceCollection.collectionID!,
-      );
+          await _db.getLocalFileIDsForCollection(collectionID);
       final Set<String> commonElements =
           localIDsToSync.intersection(existingMapping);
       if (commonElements.isNotEmpty) {
         debugPrint(
           "${commonElements.length} files already existing in "
-          "collection ${deviceCollection.collectionID} for ${deviceCollection.name}",
+          "collection $collectionID for ${deviceCollection.name}",
         );
         localIDsToSync.removeAll(commonElements);
       }
@@ -324,7 +342,7 @@ class RemoteSyncService {
           final String localID = existingFile.localID!;
           if (!fileFoundForLocalIDs.contains(localID)) {
             existingFile.generatedID = null;
-            existingFile.collectionID = deviceCollection.collectionID;
+            existingFile.collectionID = collectionID;
             existingFile.uploadedFileID = null;
             existingFile.ownerID = null;
             newFilesToInsert.add(existingFile);
@@ -414,26 +432,23 @@ class RemoteSyncService {
     }
   }
 
-  Future<void> _createCollectionForDevicePath(
-    DeviceCollection deviceCollection,
-  ) async {
-    int deviceCollectionID = deviceCollection.collectionID ?? -1;
-    if (deviceCollectionID != -1) {
-      final collectionByID =
-          _collectionsService.getCollectionByID(deviceCollectionID);
-      if (collectionByID == null || collectionByID.isDeleted) {
-        _logger.info(
-          "Collection $deviceCollectionID either deleted or missing "
+  Future<int?> _getCollectionID(DeviceCollection deviceCollection) async {
+    if (deviceCollection.collectionID != null) {
+      final collection =
+          _collectionsService.getCollectionByID(deviceCollection.collectionID!);
+      if (collection == null || collection.isDeleted) {
+        _logger.warning(
+          "Collection $deviceCollection.collectionID either deleted or missing "
           "for path ${deviceCollection.id}",
         );
-        deviceCollectionID = -1;
+        return null;
       }
-    }
-    if (deviceCollectionID == -1) {
+      return collection.id;
+    } else {
       final collection =
           await _collectionsService.getOrCreateForPath(deviceCollection.name);
       await _db.updateDeviceCollection(deviceCollection.id, collection.id);
-      deviceCollection.collectionID = collection.id;
+      return collection.id;
     }
   }
 

+ 166 - 1
lib/services/user_service.dart

@@ -1,10 +1,12 @@
 import 'dart:async';
 import 'dart:typed_data';
 
+import 'package:bip39/bip39.dart' as bip39;
 import 'package:dio/dio.dart';
 import 'package:flutter/material.dart';
 import 'package:logging/logging.dart';
 import 'package:photos/core/configuration.dart';
+import 'package:photos/core/constants.dart';
 import 'package:photos/core/event_bus.dart';
 import 'package:photos/core/network/network.dart';
 import 'package:photos/db/public_keys_db.dart';
@@ -23,7 +25,9 @@ import 'package:photos/ui/account/ott_verification_page.dart';
 import 'package:photos/ui/account/password_entry_page.dart';
 import 'package:photos/ui/account/password_reentry_page.dart';
 import 'package:photos/ui/account/two_factor_authentication_page.dart';
+import 'package:photos/ui/account/two_factor_recovery_page.dart';
 import 'package:photos/ui/account/two_factor_setup_page.dart';
+import 'package:photos/utils/crypto_util.dart';
 import 'package:photos/utils/dialog_util.dart';
 import 'package:photos/utils/navigation_util.dart';
 import 'package:photos/utils/toast_util.dart';
@@ -112,6 +116,13 @@ class UserService {
     }
   }
 
+  Future<void> sendFeedback(BuildContext context, String feedback) async {
+    await _dio.post(
+      _config.getHttpEndpoint() + "/anonymous/feedback",
+      data: {"feedback": feedback},
+    );
+  }
+
   // getPublicKey returns null value if email id is not
   // associated with another ente account
   Future<String?> getPublicKey(String email) async {
@@ -489,6 +500,147 @@ class UserService {
     }
   }
 
+  Future<void> recoverTwoFactor(BuildContext context, String sessionID) async {
+    final dialog = createProgressDialog(context, "Please wait...");
+    await dialog.show();
+    try {
+      final response = await _dio.get(
+        _config.getHttpEndpoint() + "/users/two-factor/recover",
+        queryParameters: {
+          "sessionID": sessionID,
+        },
+      );
+      if (response.statusCode == 200) {
+        Navigator.of(context).pushAndRemoveUntil(
+          MaterialPageRoute(
+            builder: (BuildContext context) {
+              return TwoFactorRecoveryPage(
+                sessionID,
+                response.data["encryptedSecret"],
+                response.data["secretDecryptionNonce"],
+              );
+            },
+          ),
+          (route) => route.isFirst,
+        );
+      }
+    } on DioError catch (e) {
+      _logger.severe(e);
+      if (e.response != null && e.response!.statusCode == 404) {
+        showToast(context, "Session expired");
+        Navigator.of(context).pushAndRemoveUntil(
+          MaterialPageRoute(
+            builder: (BuildContext context) {
+              return const LoginPage();
+            },
+          ),
+          (route) => route.isFirst,
+        );
+      } else {
+        showErrorDialog(
+          context,
+          "Oops",
+          "Something went wrong, please try again",
+        );
+      }
+    } catch (e) {
+      _logger.severe(e);
+      showErrorDialog(
+        context,
+        "Oops",
+        "Something went wrong, please try again",
+      );
+    } finally {
+      await dialog.hide();
+    }
+  }
+
+  Future<void> removeTwoFactor(
+    BuildContext context,
+    String sessionID,
+    String recoveryKey,
+    String encryptedSecret,
+    String secretDecryptionNonce,
+  ) async {
+    final dialog = createProgressDialog(context, "Please wait...");
+    await dialog.show();
+    String secret;
+    try {
+      if (recoveryKey.contains(' ')) {
+        if (recoveryKey.split(' ').length != mnemonicKeyWordCount) {
+          throw AssertionError(
+            'recovery code should have $mnemonicKeyWordCount words',
+          );
+        }
+        recoveryKey = bip39.mnemonicToEntropy(recoveryKey);
+      }
+      secret = CryptoUtil.bin2base64(
+        await CryptoUtil.decrypt(
+          CryptoUtil.base642bin(encryptedSecret),
+          CryptoUtil.hex2bin(recoveryKey.trim()),
+          CryptoUtil.base642bin(secretDecryptionNonce),
+        ),
+      );
+    } catch (e) {
+      await dialog.hide();
+      await showErrorDialog(
+        context,
+        "Incorrect recovery key",
+        "The recovery key you entered is incorrect",
+      );
+      return;
+    }
+    try {
+      final response = await _dio.post(
+        _config.getHttpEndpoint() + "/users/two-factor/remove",
+        data: {
+          "sessionID": sessionID,
+          "secret": secret,
+        },
+      );
+      if (response.statusCode == 200) {
+        showShortToast(context, "Two-factor authentication successfully reset");
+        await _saveConfiguration(response);
+        Navigator.of(context).pushAndRemoveUntil(
+          MaterialPageRoute(
+            builder: (BuildContext context) {
+              return const PasswordReentryPage();
+            },
+          ),
+          (route) => route.isFirst,
+        );
+      }
+    } on DioError catch (e) {
+      _logger.severe(e);
+      if (e.response != null && e.response!.statusCode == 404) {
+        showToast(context, "Session expired");
+        Navigator.of(context).pushAndRemoveUntil(
+          MaterialPageRoute(
+            builder: (BuildContext context) {
+              return const LoginPage();
+            },
+          ),
+          (route) => route.isFirst,
+        );
+      } else {
+        showErrorDialog(
+          context,
+          "Oops",
+          "Something went wrong, please try again",
+        );
+      }
+    } catch (e) {
+      _logger.severe(e);
+      showErrorDialog(
+        context,
+        "Oops",
+        "Something went wrong, please try again",
+      );
+    } finally {
+      await dialog.hide();
+    }
+  }
+
   Future<void> setupTwoFactor(BuildContext context, Completer completer) async {
     final dialog = createProgressDialog(context, "Please wait...");
     await dialog.show();
@@ -518,13 +670,26 @@ class UserService {
     String secret,
     String code,
   ) async {
+    Uint8List recoveryKey;
+    try {
+      recoveryKey = await getOrCreateRecoveryKey(context);
+    } catch (e) {
+      showGenericErrorDialog(context: context);
+      return false;
+    }
     final dialog = createProgressDialog(context, "Verifying...");
     await dialog.show();
+    final encryptionResult =
+        CryptoUtil.encryptSync(CryptoUtil.base642bin(secret), recoveryKey);
     try {
       await _enteDio.post(
         "/users/two-factor/enable",
         data: {
-          "code": code
+          "code": code,
+          "encryptedTwoFactorSecret":
+              CryptoUtil.bin2base64(encryptionResult.encryptedData!),
+          "twoFactorSecretDecryptionNonce":
+              CryptoUtil.bin2base64(encryptionResult.nonce!),
         },
       );
       await dialog.hide();

+ 21 - 0
lib/ui/account/delete_account_page.dart

@@ -1,6 +1,7 @@
 import 'dart:convert';
 
 import 'package:flutter/material.dart';
+import "package:logging/logging.dart";
 import 'package:photos/core/configuration.dart';
 import 'package:photos/models/delete_account.dart';
 import 'package:photos/services/local_authentication_service.dart';
@@ -157,7 +158,27 @@ class DeleteAccountPage extends StatelessWidget {
       if (choice.action != ButtonAction.first) {
         return;
       }
+
       Navigator.of(context).popUntil((route) => route.isFirst);
+      await showTextInputDialog(
+        context,
+        title: "Your account was deleted. Would you like to leave us a note?",
+        submitButtonLabel: "Send",
+        hintText: "Optional, as short as you like...",
+        alwaysShowSuccessState: true,
+        textCapitalization: TextCapitalization.words,
+        onSubmit: (String text) async {
+          // indicates user cancelled the rename request
+          if (text == "" || text.trim().isEmpty) {
+            return;
+          }
+          try {
+            await UserService.instance.sendFeedback(context, text);
+          } catch (e, s) {
+            Logger("Delete account").severe("Failed to send feedback", e, s);
+          }
+        },
+      );
     }
   }
 

+ 1 - 6
lib/ui/account/two_factor_authentication_page.dart

@@ -2,7 +2,6 @@ import 'package:flutter/material.dart';
 import 'package:flutter/services.dart';
 import 'package:photos/services/user_service.dart';
 import 'package:photos/ui/lifecycle_event_handler.dart';
-import "package:photos/utils/dialog_util.dart";
 import 'package:pinput/pin_put/pin_put.dart';
 
 class TwoFactorAuthenticationPage extends StatefulWidget {
@@ -124,11 +123,7 @@ class _TwoFactorAuthenticationPageState
         GestureDetector(
           behavior: HitTestBehavior.opaque,
           onTap: () {
-            showErrorDialog(
-              context,
-              "Contact support",
-              "Please drop an email to support@ente.io from your registered email address",
-            );
+            UserService.instance.recoverTwoFactor(context, widget.sessionID);
           },
           child: Container(
             padding: const EdgeInsets.all(10),

+ 110 - 0
lib/ui/account/two_factor_recovery_page.dart

@@ -0,0 +1,110 @@
+import 'dart:ui';
+
+import 'package:flutter/material.dart';
+import 'package:photos/services/user_service.dart';
+import 'package:photos/utils/dialog_util.dart';
+
+class TwoFactorRecoveryPage extends StatefulWidget {
+  final String sessionID;
+  final String encryptedSecret;
+  final String secretDecryptionNonce;
+
+  const TwoFactorRecoveryPage(
+    this.sessionID,
+    this.encryptedSecret,
+    this.secretDecryptionNonce, {
+    Key? key,
+  }) : super(key: key);
+
+  @override
+  State<TwoFactorRecoveryPage> createState() => _TwoFactorRecoveryPageState();
+}
+
+class _TwoFactorRecoveryPageState extends State<TwoFactorRecoveryPage> {
+  final _recoveryKey = TextEditingController();
+
+  @override
+  Widget build(BuildContext context) {
+    return Scaffold(
+      appBar: AppBar(
+        title: const Text(
+          "Recover account",
+          style: TextStyle(
+            fontSize: 18,
+          ),
+        ),
+      ),
+      body: Column(
+        crossAxisAlignment: CrossAxisAlignment.stretch,
+        mainAxisAlignment: MainAxisAlignment.center,
+        mainAxisSize: MainAxisSize.max,
+        children: [
+          Padding(
+            padding: const EdgeInsets.fromLTRB(60, 0, 60, 0),
+            child: TextFormField(
+              decoration: const InputDecoration(
+                hintText: "Enter your recovery key",
+                contentPadding: EdgeInsets.all(20),
+              ),
+              style: const TextStyle(
+                fontSize: 14,
+                fontFeatures: [FontFeature.tabularFigures()],
+              ),
+              controller: _recoveryKey,
+              autofocus: false,
+              autocorrect: false,
+              keyboardType: TextInputType.multiline,
+              maxLines: null,
+              onChanged: (_) {
+                setState(() {});
+              },
+            ),
+          ),
+          const Padding(padding: EdgeInsets.all(24)),
+          Container(
+            padding: const EdgeInsets.fromLTRB(80, 0, 80, 0),
+            width: double.infinity,
+            height: 64,
+            child: OutlinedButton(
+              onPressed: _recoveryKey.text.isNotEmpty
+                  ? () async {
+                      await UserService.instance.removeTwoFactor(
+                        context,
+                        widget.sessionID,
+                        _recoveryKey.text,
+                        widget.encryptedSecret,
+                        widget.secretDecryptionNonce,
+                      );
+                    }
+                  : null,
+              child: const Text("Recover"),
+            ),
+          ),
+          GestureDetector(
+            behavior: HitTestBehavior.translucent,
+            onTap: () {
+              showErrorDialog(
+                context,
+                "Contact support",
+                "Please drop an email to support@ente.io from your registered email address",
+              );
+            },
+            child: Container(
+              padding: const EdgeInsets.all(40),
+              child: Center(
+                child: Text(
+                  "No recovery key?",
+                  style: TextStyle(
+                    decoration: TextDecoration.underline,
+                    fontSize: 12,
+                    color: Colors.white.withOpacity(0.9),
+                  ),
+                ),
+              ),
+            ),
+          ),
+        ],
+      ),
+    );
+  }
+}

+ 0 - 1
lib/ui/collection_action_sheet.dart

@@ -153,7 +153,6 @@ class _CollectionActionSheetState extends State<CollectionActionSheet> {
                           child: TextInputWidget(
                             hintText: "Album name",
                             prefixIcon: Icons.search_rounded,
-                            autoFocus: true,
                             onChange: (value) {
                               setState(() {
                                 _searchQuery = value;

+ 4 - 0
lib/ui/home/status_bar_widget.dart

@@ -1,6 +1,7 @@
 import 'dart:async';
 
 import 'package:flutter/material.dart';
+import "package:logging/logging.dart";
 import 'package:photos/core/event_bus.dart';
 import 'package:photos/ente_theme_data.dart';
 import 'package:photos/events/notification_event.dart';
@@ -24,6 +25,8 @@ class StatusBarWidget extends StatefulWidget {
 }
 
 class _StatusBarWidgetState extends State<StatusBarWidget> {
+  static final _logger = Logger("StatusBarWidget");
+
   late StreamSubscription<SyncStatusUpdate> _subscription;
   late StreamSubscription<NotificationEvent> _notificationSubscription;
   bool _showStatus = false;
@@ -33,6 +36,7 @@ class _StatusBarWidgetState extends State<StatusBarWidget> {
   @override
   void initState() {
     _subscription = Bus.instance.on<SyncStatusUpdate>().listen((event) {
+      _logger.info("Received event " + event.toString());
       if (event.status == SyncStatus.error) {
         setState(() {
           _syncError = event.error;

+ 0 - 14
lib/utils/auth_util.dart

@@ -1,4 +1,3 @@
-import 'package:local_auth/auth_strings.dart';
 import 'package:local_auth/local_auth.dart';
 import 'package:logging/logging.dart';
 
@@ -7,18 +6,5 @@ Future<bool> requestAuthentication(String reason) async {
   await LocalAuthentication().stopAuthentication();
   return await LocalAuthentication().authenticate(
     localizedReason: reason,
-    androidAuthStrings: const AndroidAuthMessages(
-      biometricHint: "Verify identity",
-      biometricNotRecognized: "Not recognized, try again",
-      biometricRequiredTitle: "Biometric required",
-      biometricSuccess: "Successfully verified",
-      cancelButton: "Cancel",
-      deviceCredentialsRequiredTitle: "Device credentials required",
-      deviceCredentialsSetupDescription: "Device credentials required",
-      goToSettingsButton: "Go to settings",
-      goToSettingsDescription:
-          "Authentication is not setup on your device, go to Settings > Security to set it up",
-      signInTitle: "Authentication required",
-    ),
   );
 }

+ 34 - 2
pubspec.lock

@@ -904,10 +904,42 @@ packages:
     dependency: "direct main"
     description:
       name: local_auth
-      sha256: d3fece0749101725b03206f84a7dab7aaafb702dbbd09131ff8d8173259a9b19
+      sha256: "0cf238be2bfa51a6c9e7e9cfc11c05ea39f2a3a4d3e5bb255d0ebc917da24401"
       url: "https://pub.dev"
     source: hosted
-    version: "1.1.11"
+    version: "2.1.6"
+  local_auth_android:
+    dependency: transitive
+    description:
+      name: local_auth_android
+      sha256: "2ccfadbb6fbc63e6674ad58a350b06188829e62669d67a0c752c4e43cb88272a"
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.0.21"
+  local_auth_ios:
+    dependency: transitive
+    description:
+      name: local_auth_ios
+      sha256: "604078f6492fe7730fc5bb8e4f2cfe2bc287a9b499ea0ff30a29925fc1873728"
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.1.1"
+  local_auth_platform_interface:
+    dependency: transitive
+    description:
+      name: local_auth_platform_interface
+      sha256: "9e160d59ef0743e35f1b50f4fb84dc64f55676b1b8071e319ef35e7f3bc13367"
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.0.7"
+  local_auth_windows:
+    dependency: transitive
+    description:
+      name: local_auth_windows
+      sha256: bfe0deede77fb36faa62799977074f35ac096d7cafce0c29a44a173d2a2a4b94
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.0.7"
   logging:
     dependency: "direct main"
     description:

+ 1 - 1
pubspec.yaml

@@ -75,7 +75,7 @@ dependencies:
   intl: ^0.17.0
   like_button: ^2.0.2
   loading_animations: ^2.1.0
-  local_auth: ^1.1.5
+  local_auth: ^2.1.5
   logging: ^1.0.1
   lottie: ^1.2.2
   media_extension: ^1.0.1