Browse Source

Merge branch 'rewrite_device_sync' into rewrite_device_sync_remote

Neeraj Gupta 2 years ago
parent
commit
e9e57a6f75
36 changed files with 657 additions and 402 deletions
  1. BIN
      assets/2.0x/loading_photos_background.png
  2. BIN
      assets/2.0x/loading_photos_background_dark.png
  3. BIN
      assets/3.0x/loading_photos_background.png
  4. BIN
      assets/3.0x/loading_photos_background_dark.png
  5. BIN
      assets/loading_photos_background.png
  6. BIN
      assets/loading_photos_background_dark.png
  7. 2 0
      lib/core/errors.dart
  8. 1 1
      lib/db/device_files_db.dart
  9. 48 1
      lib/db/files_db.dart
  10. 1 1
      lib/models/collection_items.dart
  11. 0 14
      lib/models/device_collection.dart
  12. 15 1
      lib/models/duplicate_files.dart
  13. 7 0
      lib/services/billing_service.dart
  14. 4 0
      lib/services/collections_service.dart
  15. 68 0
      lib/services/local_authentication_service.dart
  16. 6 1
      lib/services/local_sync_service.dart
  17. 181 124
      lib/services/remote_sync_service.dart
  18. 26 30
      lib/ui/account/delete_account_page.dart
  19. 1 1
      lib/ui/backup_folder_selection_page.dart
  20. 1 1
      lib/ui/collections/device_folder_icon_widget.dart
  21. 1 1
      lib/ui/collections/device_folders_grid_view_widget.dart
  22. 1 11
      lib/ui/collections_gallery_widget.dart
  23. 4 0
      lib/ui/grant_permissions_widget.dart
  24. 1 2
      lib/ui/loading_photos_widget.dart
  25. 13 0
      lib/ui/payment/subscription_page.dart
  26. 52 62
      lib/ui/settings/account_section_widget.dart
  27. 32 42
      lib/ui/settings/security_section_widget.dart
  28. 1 1
      lib/ui/sharing/share_collection_widget.dart
  29. 162 90
      lib/ui/tools/deduplicate_page.dart
  30. 12 4
      lib/ui/tools/free_space_page.dart
  31. 7 6
      lib/ui/tools/lock_screen.dart
  32. 1 1
      lib/ui/viewer/gallery/device_folder_page.dart
  33. 1 1
      lib/ui/viewer/gallery/gallery_app_bar_widget.dart
  34. 6 4
      lib/utils/dialog_util.dart
  35. 1 1
      lib/utils/file_uploader.dart
  36. 1 1
      pubspec.yaml

BIN
assets/2.0x/loading_photos_background.png


BIN
assets/2.0x/loading_photos_background_dark.png


BIN
assets/3.0x/loading_photos_background.png


BIN
assets/3.0x/loading_photos_background_dark.png


BIN
assets/loading_photos_background.png


BIN
assets/loading_photos_background_dark.png


+ 2 - 0
lib/core/errors.dart

@@ -6,6 +6,8 @@ class InvalidFileUploadState extends AssertionError {
   InvalidFileUploadState(String message) : super(message);
 }
 
+class SubscriptionAlreadyClaimedError extends Error {}
+
 class WiFiUnavailableError extends Error {}
 
 class SyncStopRequestedError extends Error {}

+ 1 - 1
lib/db/device_files_db.dart

@@ -2,7 +2,7 @@ import 'package:flutter/foundation.dart';
 import 'package:logging/logging.dart';
 import 'package:photo_manager/photo_manager.dart';
 import 'package:photos/db/files_db.dart';
-import 'package:photos/models/device_folder.dart';
+import 'package:photos/models/device_collection.dart';
 import 'package:photos/models/file.dart';
 import 'package:photos/models/file_load_result.dart';
 import 'package:photos/services/local/local_sync_util.dart';

+ 48 - 1
lib/db/files_db.dart

@@ -896,7 +896,54 @@ class FilesDB {
     );
   }
 
+  /*
+    This method should only return localIDs which are not uploaded yet
+    and can be mapped to incoming remote entry
+   */
+  Future<List<File>> getUnlinkedLocalMatchesForRemoteFile(
+    int ownerID,
+    String localID,
+    FileType fileType, {
+    @required String title,
+    @required String deviceFolder,
+  }) async {
+    final db = await instance.database;
+    // on iOS, match using localID and fileType. title can either match or
+    // might be null based on how the file was imported
+    String whereClause = ''' ($columnOwnerID = ? OR $columnOwnerID IS NULL) AND 
+        $columnLocalID = ? AND $columnFileType = ? AND 
+        ($columnTitle=? OR $columnTitle IS NULL) ''';
+    List<Object> whereArgs = [
+      ownerID,
+      localID,
+      getInt(fileType),
+      title,
+    ];
+    if (Platform.isAndroid) {
+      whereClause = ''' ($columnOwnerID = ? OR $columnOwnerID IS NULL) AND 
+          $columnLocalID = ? AND $columnFileType = ? AND $columnTitle=? AND $columnDeviceFolder= ? 
+           ''';
+      whereArgs = [
+        ownerID,
+        localID,
+        getInt(fileType),
+        title,
+        deviceFolder,
+      ];
+    }
+
+    final rows = await db.query(
+      filesTable,
+      where: whereClause,
+      whereArgs: whereArgs,
+    );
+
+    return convertToFiles(rows);
+  }
+
   Future<List<File>> getMatchingFiles(
+    String localID,
+    FileType fileType,
     String title,
     String deviceFolder,
   ) async {
@@ -963,7 +1010,7 @@ class FilesDB {
   Future<int> updateLocalIDForUploaded(int uploadedID, String localID) async {
     final db = await instance.database;
     return await db.update(
-      table,
+      filesTable,
       {columnLocalID: localID},
       where: '$columnUploadedFileID = ? AND $columnLocalID IS NULL',
       whereArgs: [uploadedID],

+ 1 - 1
lib/models/collection_items.dart

@@ -1,5 +1,5 @@
 import 'package:photos/models/collection.dart';
-import 'package:photos/models/device_folder.dart';
+import 'package:photos/models/device_collection.dart';
 import 'package:photos/models/file.dart';
 
 class CollectionItems {

+ 0 - 14
lib/models/device_folder.dart → lib/models/device_collection.dart

@@ -1,21 +1,8 @@
 import 'package:photos/models/file.dart';
 
-class DeviceFolder {
-  final String name;
-  final String path;
-  final File thumbnail;
-
-  DeviceFolder(
-    this.name,
-    this.path,
-    this.thumbnail,
-  );
-}
-
 class DeviceCollection {
   final String id;
   final String name;
-  final String path;
   final String coverId;
   final int count;
   final bool shouldBackup;
@@ -25,7 +12,6 @@ class DeviceCollection {
   DeviceCollection(
     this.id,
     this.name, {
-    this.path,
     this.coverId,
     this.count,
     this.collectionID,

+ 15 - 1
lib/models/duplicate_files.dart

@@ -1,6 +1,7 @@
 import 'dart:convert';
 
 import 'package:photos/models/file.dart';
+import 'package:photos/services/collections_service.dart';
 
 class DuplicateFilesResponse {
   final List<DuplicateItems> duplicates;
@@ -43,9 +44,22 @@ class DuplicateItems {
 class DuplicateFiles {
   final List<File> files;
   final int size;
+  static final collectionsService = CollectionsService.instance;
 
-  DuplicateFiles(this.files, this.size);
+  DuplicateFiles(this.files, this.size) {
+    sortByCollectionName();
+  }
 
   @override
   String toString() => 'DuplicateFiles(files: $files, size: $size)';
+
+  sortByCollectionName() {
+    files.sort((first, second) {
+      final firstName =
+          collectionsService.getCollectionNameByID(first.collectionID);
+      final secondName =
+          collectionsService.getCollectionNameByID(second.collectionID);
+      return firstName.compareTo(secondName);
+    });
+  }
 }

+ 7 - 0
lib/services/billing_service.dart

@@ -6,6 +6,7 @@ import 'package:dio/dio.dart';
 import 'package:in_app_purchase/in_app_purchase.dart';
 import 'package:logging/logging.dart';
 import 'package:photos/core/configuration.dart';
+import 'package:photos/core/errors.dart';
 import 'package:photos/core/network.dart';
 import 'package:photos/models/billing_plan.dart';
 import 'package:photos/models/subscription.dart';
@@ -111,6 +112,12 @@ class BillingService {
         ),
       );
       return Subscription.fromMap(response.data["subscription"]);
+    } on DioError catch (e) {
+      if (e.response != null && e.response.statusCode == 409) {
+        throw SubscriptionAlreadyClaimedError();
+      } else {
+        rethrow;
+      }
     } catch (e, s) {
       _logger.severe(e, s);
       rethrow;

+ 4 - 0
lib/services/collections_service.dart

@@ -787,6 +787,10 @@ class CollectionsService {
     Bus.instance.fire(CollectionUpdatedEvent(toCollectionID, files));
   }
 
+  String getCollectionNameByID(int collectionID) {
+    return getCollectionByID(collectionID).name;
+  }
+
   void _validateMoveRequest(
     int toCollectionID,
     int fromCollectionID,

+ 68 - 0
lib/services/local_authentication_service.dart

@@ -0,0 +1,68 @@
+import 'package:flutter/material.dart';
+import 'package:local_auth/local_auth.dart';
+import 'package:photos/core/configuration.dart';
+import 'package:photos/ui/tools/app_lock.dart';
+import 'package:photos/utils/auth_util.dart';
+import 'package:photos/utils/dialog_util.dart';
+import 'package:photos/utils/toast_util.dart';
+
+class LocalAuthenticationService {
+  LocalAuthenticationService._privateConstructor();
+  static final LocalAuthenticationService instance =
+      LocalAuthenticationService._privateConstructor();
+
+  Future<bool> requestLocalAuthentication(
+    BuildContext context,
+    String infoMessage,
+  ) async {
+    if (await _isLocalAuthSupportedOnDevice()) {
+      AppLock.of(context).setEnabled(false);
+      final result = await requestAuthentication(infoMessage);
+      AppLock.of(context).setEnabled(
+        Configuration.instance.shouldShowLockScreen(),
+      );
+      if (!result) {
+        showToast(context, infoMessage);
+        return false;
+      } else {
+        return true;
+      }
+    }
+    return true;
+  }
+
+  Future<bool> requestLocalAuthForLockScreen(
+    BuildContext context,
+    bool shouldEnableLockScreen,
+    String infoMessage,
+    String errorDialogContent, [
+    String errorDialogTitle = "",
+  ]) async {
+    if (await LocalAuthentication().isDeviceSupported()) {
+      AppLock.of(context).disable();
+      final result = await requestAuthentication(
+        infoMessage,
+      );
+      if (result) {
+        AppLock.of(context).setEnabled(shouldEnableLockScreen);
+        await Configuration.instance
+            .setShouldShowLockScreen(shouldEnableLockScreen);
+        return true;
+      } else {
+        AppLock.of(context)
+            .setEnabled(Configuration.instance.shouldShowLockScreen());
+      }
+    } else {
+      showErrorDialog(
+        context,
+        errorDialogTitle,
+        errorDialogContent,
+      );
+    }
+    return false;
+  }
+
+  Future<bool> _isLocalAuthSupportedOnDevice() async {
+    return await LocalAuthentication().isDeviceSupported();
+  }
+}

+ 6 - 1
lib/services/local_sync_service.dart

@@ -12,6 +12,7 @@ import 'package:photos/db/device_files_db.dart';
 import 'package:photos/db/file_updation_db.dart';
 import 'package:photos/db/files_db.dart';
 import 'package:photos/db/ignored_files_db.dart';
+import 'package:photos/events/backup_folders_updated_event.dart';
 import 'package:photos/events/local_photos_updated_event.dart';
 import 'package:photos/events/sync_status_update_event.dart';
 import 'package:photos/models/file.dart';
@@ -136,10 +137,14 @@ class LocalSyncService {
   Future<bool> _refreshDeviceFolderCountAndCover() async {
     final List<Tuple2<AssetPathEntity, String>> result =
         await getDeviceFolderWithCountAndCoverID();
-    return await _db.updateDeviceCoverWithCount(
+    final bool hasUpdated = await _db.updateDeviceCoverWithCount(
       result,
       shouldBackup: Configuration.instance.hasSelectedAllFoldersForBackup(),
     );
+    if (hasUpdated) {
+      Bus.instance.fire(BackupFoldersUpdatedEvent());
+    }
+    return hasUpdated;
   }
 
   Future<bool> syncAll() async {

+ 181 - 124
lib/services/remote_sync_service.dart

@@ -8,13 +8,14 @@ import 'package:photos/core/configuration.dart';
 import 'package:photos/core/errors.dart';
 import 'package:photos/core/event_bus.dart';
 import 'package:photos/db/device_files_db.dart';
+import 'package:photos/db/file_updation_db.dart';
 import 'package:photos/db/files_db.dart';
 import 'package:photos/events/collection_updated_event.dart';
 import 'package:photos/events/files_updated_event.dart';
 import 'package:photos/events/force_reload_home_gallery_event.dart';
 import 'package:photos/events/local_photos_updated_event.dart';
 import 'package:photos/events/sync_status_update_event.dart';
-import 'package:photos/models/device_folder.dart';
+import 'package:photos/models/device_collection.dart';
 import 'package:photos/models/file.dart';
 import 'package:photos/models/file_type.dart';
 import 'package:photos/services/app_lifecycle_service.dart';
@@ -39,6 +40,7 @@ class RemoteSyncService {
   int _completedUploads = 0;
   SharedPreferences _prefs;
   Completer<void> _existingSync;
+  bool _existingSyncSilent = false;
 
   static const kHasSyncedArchiveKey = "has_synced_archive";
 
@@ -75,12 +77,18 @@ class RemoteSyncService {
     }
     if (_existingSync != null) {
       _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;
+      }
       return _existingSync.future;
     }
     _existingSync = Completer<void>();
+    _existingSyncSilent = silently;
 
     try {
-      await _pullDiff(silently);
+      await _pullDiff();
       // sync trash but consume error during initial launch.
       // this is to ensure that we don't pause upload due to any error during
       // the trash sync. Impact: We may end up re-uploading a file which was
@@ -96,7 +104,7 @@ class RemoteSyncService {
       }
       final hasUploadedFiles = await _uploadFiles(filesToBeUploaded);
       if (hasUploadedFiles) {
-        await _pullDiff(true);
+        await _pullDiff();
         _existingSync.complete();
         _existingSync = null;
         final hasMoreFilesToBackup = (await _getFilesToBeUploaded()).isNotEmpty;
@@ -125,33 +133,31 @@ class RemoteSyncService {
       } else {
         _logger.severe("Error executing remote sync ", e, s);
       }
+    } finally {
+      _existingSyncSilent = false;
     }
   }
 
-  Future<void> _pullDiff(bool silently) async {
+  Future<void> _pullDiff() async {
     final isFirstSync = !_collectionsService.hasSyncedCollections();
     await _collectionsService.sync();
-
-    if (isFirstSync || _hasReSynced()) {
-      await _syncUpdatedCollections(silently);
-    } else {
-      final syncSinceTime = _getSinceTimeForReSync();
-      await _resyncAllCollectionsSinceTime(syncSinceTime);
-    }
-    if (!_hasReSynced()) {
-      await _markReSyncAsDone();
+    // check and reset user's collection syncTime in past for older clients
+    if (isFirstSync) {
+      // not need reset syncTime, mark all flags as done if firstSync
+      await _markResetSyncTimeAsDone();
+    } else if (_shouldResetSyncTime()) {
+      _logger.warning('Resetting syncTime for for the client');
+      await _resetAllCollectionsSyncTime();
+      await _markResetSyncTimeAsDone();
     }
 
+    await _syncUpdatedCollections();
     unawaited(_localFileUpdateService.markUpdatedFilesForReUpload());
   }
 
-  Future<void> _syncUpdatedCollections(bool silently) async {
+  Future<void> _syncUpdatedCollections() async {
     final updatedCollections =
         await _collectionsService.getCollectionsToBeSynced();
-
-    if (updatedCollections.isNotEmpty && !silently) {
-      Bus.instance.fire(SyncStatusUpdate(SyncStatus.applyingRemoteDiff));
-    }
     for (final c in updatedCollections) {
       await _syncCollectionDiff(
         c.id,
@@ -161,19 +167,21 @@ class RemoteSyncService {
     }
   }
 
-  Future<void> _resyncAllCollectionsSinceTime(int sinceTime) async {
-    _logger.info('re-sync collections sinceTime: $sinceTime');
+  Future<void> _resetAllCollectionsSyncTime() async {
+    final resetSyncTime = _getSinceTimeForReSync();
+    _logger.info('re-setting all collections syncTime to: $resetSyncTime');
     final collections = _collectionsService.getActiveCollections();
     for (final c in collections) {
-      await _syncCollectionDiff(
-        c.id,
-        min(_collectionsService.getCollectionSyncTime(c.id), sinceTime),
-      );
-      await _collectionsService.setCollectionSyncTime(c.id, c.updationTime);
+      final int newSyncTime =
+          min(_collectionsService.getCollectionSyncTime(c.id), resetSyncTime);
+      await _collectionsService.setCollectionSyncTime(c.id, newSyncTime);
     }
   }
 
   Future<void> _syncCollectionDiff(int collectionID, int sinceTime) async {
+    if (!_existingSyncSilent) {
+      Bus.instance.fire(SyncStatusUpdate(SyncStatus.applyingRemoteDiff));
+    }
     final diff =
         await _diffFetcher.getEncryptedFilesDiff(collectionID, sinceTime);
     if (diff.deletedFiles.isNotEmpty) {
@@ -456,133 +464,182 @@ class RemoteSyncService {
     );
   }
 
+  /* _storeDiff maps each remoteDiff file to existing
+      entries in files table. When match is found, it compares both file to
+      perform relevant actions like
+      [1] Clear local cache when required (Both Shared and Owned files)
+      [2] Retain localID of remote file based on matching logic [Owned files]
+      [3] Refresh UI if visibility or creationTime has changed [Owned files]
+      [4] Schedule file update if the local file has changed since last time
+      [Owned files]
+    [Important Note: If given uploadedFileID and collectionID is already present
+     in files db, the generateID should already point to existing entry.
+     Known Issues:
+      [K1] Cached entry will not be cleared when if a file was edited and
+      moved to different collection as Vid/Image cache key is uploadedID.
+      [Existing]
+    ]
+   */
   Future _storeDiff(List<File> diff, int collectionID) async {
-    int existing = 0,
-        updated = 0,
-        remote = 0,
-        localButUpdatedOnRemote = 0,
-        localButAddedToNewCollectionOnRemote = 0;
-    bool hasAnyCreationTimeChanged = false;
-    final List<File> toBeInserted = [];
+    int sharedFileNew = 0,
+        sharedFileUpdated = 0,
+        localUploadedFromDevice = 0,
+        localButUpdatedOnDevice = 0,
+        remoteNewFile = 0;
     final int userID = Configuration.instance.getUserID();
-    for (File file in diff) {
-      final existingFiles = file.deviceFolder == null
-          ? null
-          : await _db.getMatchingFiles(file.title, file.deviceFolder);
-      if (existingFiles == null ||
-          existingFiles.isEmpty ||
-          userID != file.ownerID) {
-        // File uploaded from a different device or uploaded by different user
-        // Other rare possibilities : The local file is present on
-        // device but it's not imported in local db due to missing permission
-        // after reinstall (iOS selected file permissions or user revoking
-        // permissions, or issue/delay in importing devices files.
-        file.localID = null;
-        toBeInserted.add(file);
-        remote++;
-      } else {
-        // File exists in ente db with same title & device folder
-        // Note: The file.generatedID might be already set inside
-        // [DiffFetcher.getEncryptedFilesDiff]
-        // Try to find existing file with same localID as remote file with a fallback
-        // to finding any existing file with localID. This is needed to handle
-        // case when localID for a file changes and the file is uploaded again in
-        // the same collection
-        final fileWithLocalID = existingFiles.firstWhere(
-          (e) =>
-              file.localID != null &&
-              e.localID != null &&
-              e.localID == file.localID,
-          orElse: () => existingFiles.firstWhere(
-            (e) => e.localID != null,
-            orElse: () => null,
-          ),
-        );
-        if (fileWithLocalID != null) {
-          // File should ideally have the same localID
-          if (file.localID != null && file.localID != fileWithLocalID.localID) {
-            _logger.severe(
-              "unexpected mismatch in localIDs remote: ${file.toString()} and existing: ${fileWithLocalID.toString()}",
-            );
-          }
-          file.localID = fileWithLocalID.localID;
+    bool needsGalleryReload = false;
+    // this is required when same file is uploaded twice in the same
+    // collection. Without this check, if both remote files are part of same
+    // diff response, then we end up inserting one entry instead of two
+    // as we update the generatedID for remoteDiff to local file's genID
+    final Set<int> alreadyClaimedLocalFilesGenID = {};
+
+    final List<File> toBeInserted = [];
+    for (File remoteDiff in diff) {
+      // existingFile will be either set to existing collectionID+localID or
+      // to the unclaimed aka not already linked to any uploaded file.
+      File existingFile;
+      if (remoteDiff.generatedID != null) {
+        // Case [1] Check and clear local cache when uploadedFile already exist
+        existingFile = await _db.getFile(remoteDiff.generatedID);
+        if (_shouldClearCache(remoteDiff, existingFile)) {
+          await clearCache(remoteDiff);
+        }
+      }
+
+      /* If file is not owned by the user, no further processing is required
+      as Case [2,3,4] are only relevant to files owned by user
+       */
+      if (userID != remoteDiff.ownerID) {
+        if (existingFile == null) {
+          sharedFileNew++;
+          remoteDiff.localID = null;
         } else {
-          file.localID = null;
+          sharedFileUpdated++;
+          // if user has downloaded the file on the device, avoid removing the
+          // localID reference.
+          // [Todo-fix: Excluded shared file's localIDs during syncALL]
+          remoteDiff.localID = existingFile.localID;
         }
-        final bool wasUploadedOnAPreviousInstallation =
-            existingFiles.length == 1 && existingFiles[0].collectionID == null;
-        if (wasUploadedOnAPreviousInstallation) {
-          file.generatedID = existingFiles[0].generatedID;
-          if (file.modificationTime != existingFiles[0].modificationTime) {
-            // File was updated since the app was uninstalled
-            // mark it for re-upload
-            _logger.info(
-              "re-upload because file was updated since last installation: "
-              "remoteFile:  ${file.toString()}, localFile: ${existingFiles[0].toString()}",
-            );
-            file.modificationTime = existingFiles[0].modificationTime;
-            file.updationTime = null;
-            updated++;
-          } else {
-            existing++;
-          }
-          toBeInserted.add(file);
+        toBeInserted.add(remoteDiff);
+        // end processing for file here, move to next file now
+        break;
+      }
+
+      // If remoteDiff is not already synced (i.e. existingFile is null), check
+      // if the remoteFile was uploaded from this device.
+      // Note: DeviceFolder is ignored for iOS during matching
+      if (existingFile == null && remoteDiff.localID != null) {
+        final localFileEntries = await _db.getUnlinkedLocalMatchesForRemoteFile(
+          userID,
+          remoteDiff.localID,
+          remoteDiff.fileType,
+          title: remoteDiff.title,
+          deviceFolder: remoteDiff.deviceFolder,
+        );
+        if (localFileEntries.isEmpty) {
+          // set remote file's localID as null because corresponding local file
+          // does not exist [Case 2, do not retain localID of the remote file]
+          remoteDiff.localID = null;
         } else {
-          bool foundMatchingCollection = false;
-          for (final existingFile in existingFiles) {
-            if (file.collectionID == existingFile.collectionID &&
-                file.uploadedFileID == existingFile.uploadedFileID) {
-              // File was updated on remote
-              if (file.creationTime != existingFile.creationTime) {
-                hasAnyCreationTimeChanged = true;
-              }
-              foundMatchingCollection = true;
-              file.generatedID = existingFile.generatedID;
-              toBeInserted.add(file);
-              await clearCache(file);
-              localButUpdatedOnRemote++;
-              break;
-            }
+          // case 4: Check and schedule the file for update
+          final int maxModificationTime = localFileEntries
+              .map(
+                (e) => e.modificationTime ?? 0,
+              )
+              .reduce(max);
+          if (maxModificationTime > remoteDiff.modificationTime) {
+            localButUpdatedOnDevice++;
+            await FileUpdationDB.instance.insertMultiple(
+              [remoteDiff.localID],
+              FileUpdationDB.modificationTimeUpdated,
+            );
           }
-          if (!foundMatchingCollection) {
-            // Added to a new collection
-            toBeInserted.add(file);
-            localButAddedToNewCollectionOnRemote++;
+
+          localFileEntries.removeWhere(
+            (e) =>
+                e.uploadedFileID != null ||
+                alreadyClaimedLocalFilesGenID.contains(e.generatedID),
+          );
+
+          if (localFileEntries.isNotEmpty) {
+            // file uploaded from same device, replace the local file row by
+            // setting the generated ID of remoteFile to localFile generatedID
+            existingFile = localFileEntries.first;
+            localUploadedFromDevice++;
+            alreadyClaimedLocalFilesGenID.add(existingFile.generatedID);
+            remoteDiff.generatedID = existingFile.generatedID;
           }
         }
       }
+      if (existingFile != null &&
+          _shouldReloadHomeGallery(remoteDiff, existingFile)) {
+        needsGalleryReload = true;
+      } else {
+        remoteNewFile++;
+      }
+      toBeInserted.add(remoteDiff);
     }
     await _db.insertMultiple(toBeInserted);
     _logger.info(
       "Diff to be deduplicated was: " +
           diff.length.toString() +
           " out of which \n" +
-          existing.toString() +
+          localUploadedFromDevice.toString() +
           " was uploaded from device, \n" +
-          updated.toString() +
+          localButUpdatedOnDevice.toString() +
           " was uploaded from device, but has been updated since and should be reuploaded, \n" +
-          remote.toString() +
-          " was uploaded from remote, \n" +
-          localButUpdatedOnRemote.toString() +
-          " was uploaded from device but updated on remote, and \n" +
-          localButAddedToNewCollectionOnRemote.toString() +
-          " was uploaded from device but added to a new collection on remote.",
+          sharedFileNew.toString() +
+          " new sharedFiles, \n" +
+          sharedFileUpdated.toString() +
+          " updatedSharedFiles, and \n" +
+          remoteNewFile.toString() +
+          " remoteFiles seen first time",
     );
-    if (hasAnyCreationTimeChanged) {
+    if (needsGalleryReload) {
       Bus.instance.fire(ForceReloadHomeGalleryEvent());
     }
   }
 
+  bool _shouldClearCache(File remoteFile, File existingFile) {
+    if (remoteFile.hash != null && existingFile.hash != null) {
+      return remoteFile.hash != existingFile.hash;
+    }
+    return remoteFile.updationTime != (existingFile.updationTime ?? 0);
+  }
+
+  bool _shouldReloadHomeGallery(File remoteFile, File existingFile) {
+    int remoteCreationTime = remoteFile.creationTime;
+    if (remoteFile.pubMmdVersion > 0 &&
+        (remoteFile.pubMagicMetadata.editedTime ?? 0) != 0) {
+      remoteCreationTime = remoteFile.pubMagicMetadata.editedTime;
+    }
+    if (remoteCreationTime != existingFile.creationTime) {
+      return true;
+    }
+    if (existingFile.mMdVersion > 0 &&
+        remoteFile.mMdVersion != existingFile.mMdVersion &&
+        remoteFile.magicMetadata.visibility !=
+            existingFile.magicMetadata.visibility) {
+      return false;
+    }
+    return false;
+  }
+
   // return true if the client needs to re-sync the collections from previous
   // version
-  bool _hasReSynced() {
-    return _prefs.containsKey(kHasSyncedEditTime) &&
-        _prefs.containsKey(kHasSyncedArchiveKey);
+  bool _shouldResetSyncTime() {
+    return !_prefs.containsKey(kHasSyncedEditTime) ||
+        !_prefs.containsKey(kHasSyncedArchiveKey);
   }
 
-  Future<void> _markReSyncAsDone() async {
+  Future<void> _markResetSyncTimeAsDone() async {
     await _prefs.setBool(kHasSyncedArchiveKey, true);
     await _prefs.setBool(kHasSyncedEditTime, true);
+    // Check to avoid regression because of change or additions of keys
+    if (_shouldResetSyncTime()) {
+      throw Exception("_shouldResetSyncTime should return false now");
+    }
   }
 
   int _getSinceTimeForReSync() {

+ 26 - 30
lib/ui/account/delete_account_page.dart

@@ -4,14 +4,12 @@ import 'package:flutter/material.dart';
 import 'package:flutter_sodium/flutter_sodium.dart';
 import 'package:photos/core/configuration.dart';
 import 'package:photos/models/delete_account.dart';
+import 'package:photos/services/local_authentication_service.dart';
 import 'package:photos/services/user_service.dart';
 import 'package:photos/ui/common/dialogs.dart';
 import 'package:photos/ui/common/gradient_button.dart';
-import 'package:photos/ui/tools/app_lock.dart';
-import 'package:photos/utils/auth_util.dart';
 import 'package:photos/utils/crypto_util.dart';
 import 'package:photos/utils/email_util.dart';
-import 'package:photos/utils/toast_util.dart';
 
 class DeleteAccountPage extends StatelessWidget {
   const DeleteAccountPage({
@@ -144,36 +142,34 @@ class DeleteAccountPage extends StatelessWidget {
     BuildContext context,
     DeleteChallengeResponse response,
   ) async {
-    AppLock.of(context).setEnabled(false);
-    const String reason = "Please authenticate to initiate account deletion";
-    final result = await requestAuthentication(reason);
-    AppLock.of(context).setEnabled(
-      Configuration.instance.shouldShowLockScreen(),
-    );
-    if (!result) {
-      showToast(context, reason);
-      return;
-    }
-    final choice = await showChoiceDialog(
+    final hasAuthenticated =
+        await LocalAuthenticationService.instance.requestLocalAuthentication(
       context,
-      'Are you sure you want to delete your account?',
-      'Your uploaded data will be scheduled for deletion, and your account '
-          'will be permanently deleted. \n\nThis action is not reversible.',
-      firstAction: 'Cancel',
-      secondAction: 'Delete',
-      firstActionColor: Theme.of(context).colorScheme.onSurface,
-      secondActionColor: Colors.red,
+      "Please authenticate to initiate account deletion",
     );
-    if (choice != DialogUserChoice.secondChoice) {
-      return;
+
+    if (hasAuthenticated) {
+      final choice = await showChoiceDialog(
+        context,
+        'Are you sure you want to delete your account?',
+        'Your uploaded data will be scheduled for deletion, and your account '
+            'will be permanently deleted. \n\nThis action is not reversible.',
+        firstAction: 'Cancel',
+        secondAction: 'Delete',
+        firstActionColor: Theme.of(context).colorScheme.onSurface,
+        secondActionColor: Colors.red,
+      );
+      if (choice != DialogUserChoice.secondChoice) {
+        return;
+      }
+      final decryptChallenge = CryptoUtil.openSealSync(
+        Sodium.base642bin(response.encryptedChallenge),
+        Sodium.base642bin(Configuration.instance.getKeyAttributes().publicKey),
+        Configuration.instance.getSecretKey(),
+      );
+      final challengeResponseStr = utf8.decode(decryptChallenge);
+      await UserService.instance.deleteAccount(context, challengeResponseStr);
     }
-    final decryptChallenge = CryptoUtil.openSealSync(
-      Sodium.base642bin(response.encryptedChallenge),
-      Sodium.base642bin(Configuration.instance.getKeyAttributes().publicKey),
-      Configuration.instance.getSecretKey(),
-    );
-    final challengeResponseStr = utf8.decode(decryptChallenge);
-    await UserService.instance.deleteAccount(context, challengeResponseStr);
   }
 
   Future<void> _requestEmailForDeletion(BuildContext context) async {

+ 1 - 1
lib/ui/backup_folder_selection_page.dart

@@ -12,7 +12,7 @@ import 'package:photos/db/device_files_db.dart';
 import 'package:photos/db/files_db.dart';
 import 'package:photos/ente_theme_data.dart';
 import 'package:photos/events/backup_folders_updated_event.dart';
-import 'package:photos/models/device_folder.dart';
+import 'package:photos/models/device_collection.dart';
 import 'package:photos/models/file.dart';
 import 'package:photos/ui/common/loading_widget.dart';
 import 'package:photos/ui/viewer/file/thumbnail_widget.dart';

+ 1 - 1
lib/ui/collections/device_folder_icon_widget.dart

@@ -1,5 +1,5 @@
 import 'package:flutter/material.dart';
-import 'package:photos/models/device_folder.dart';
+import 'package:photos/models/device_collection.dart';
 import 'package:photos/ui/viewer/file/thumbnail_widget.dart';
 import 'package:photos/ui/viewer/gallery/device_folder_page.dart';
 import 'package:photos/utils/navigation_util.dart';

+ 1 - 1
lib/ui/collections/device_folders_grid_view_widget.dart

@@ -1,5 +1,5 @@
 import 'package:flutter/material.dart';
-import 'package:photos/models/device_folder.dart';
+import 'package:photos/models/device_collection.dart';
 import 'package:photos/ui/collections/device_folder_icon_widget.dart';
 import 'package:photos/ui/viewer/gallery/empte_state.dart';
 

+ 1 - 11
lib/ui/collections_gallery_widget.dart

@@ -12,7 +12,7 @@ import 'package:photos/events/collection_updated_event.dart';
 import 'package:photos/events/local_photos_updated_event.dart';
 import 'package:photos/events/user_logged_out_event.dart';
 import 'package:photos/models/collection_items.dart';
-import 'package:photos/models/device_folder.dart';
+import 'package:photos/models/device_collection.dart';
 import 'package:photos/services/collections_service.dart';
 import 'package:photos/ui/collections/device_folders_grid_view_widget.dart';
 import 'package:photos/ui/collections/ente_section_title.dart';
@@ -91,18 +91,8 @@ class _CollectionsGalleryWidgetState extends State<CollectionsGalleryWidget>
     final filesDB = FilesDB.instance;
     final collectionsService = CollectionsService.instance;
     final userID = Configuration.instance.getUserID();
-    final List<DeviceFolder> folders = [];
     final List<DeviceCollection> deviceCollections =
         await filesDB.getDeviceCollections(includeCoverThumbnail: true);
-    final latestLocalFiles = await filesDB.getLatestLocalFiles();
-    for (final file in latestLocalFiles) {
-      folders.add(DeviceFolder(file.deviceFolder, file.deviceFolder, file));
-    }
-    folders.sort(
-      (first, second) =>
-          second.thumbnail.creationTime.compareTo(first.thumbnail.creationTime),
-    );
-
     final List<CollectionWithThumbnail> collectionsWithThumbnail = [];
     final latestCollectionFiles =
         await collectionsService.getLatestCollectionFiles();

+ 4 - 0
lib/ui/grant_permissions_widget.dart

@@ -16,6 +16,9 @@ class GrantPermissionsWidget extends StatelessWidget {
           padding: const EdgeInsets.only(top: 20, bottom: 120),
           child: Column(
             children: [
+              const SizedBox(
+                height: 24,
+              ),
               Center(
                 child: Stack(
                   alignment: Alignment.center,
@@ -43,6 +46,7 @@ class GrantPermissionsWidget extends StatelessWidget {
                   ],
                 ),
               ),
+              const SizedBox(height: 36),
               Padding(
                 padding: const EdgeInsets.fromLTRB(40, 0, 40, 0),
                 child: RichText(

+ 1 - 2
lib/ui/loading_photos_widget.dart

@@ -103,10 +103,9 @@ class _LoadingPhotosWidgetState extends State<LoadingPhotosWidget> {
                             color: Colors.white.withOpacity(0.25),
                             colorBlendMode: BlendMode.modulate,
                           ),
-                    const SizedBox(height: 20),
                     Column(
                       children: [
-                        const SizedBox(height: 50),
+                        const SizedBox(height: 24),
                         Lottie.asset(
                           'assets/loadingGalleryLottie.json',
                           height: 400,

+ 13 - 0
lib/ui/payment/subscription_page.dart

@@ -4,6 +4,7 @@ import 'dart:io';
 import 'package:flutter/material.dart';
 import 'package:in_app_purchase/in_app_purchase.dart';
 import 'package:logging/logging.dart';
+import 'package:photos/core/errors.dart';
 import 'package:photos/core/event_bus.dart';
 import 'package:photos/events/subscription_purchased_event.dart';
 import 'package:photos/models/billing_plan.dart';
@@ -93,6 +94,18 @@ class _SubscriptionPageState extends State<SubscriptionPage> {
             if (widget.isOnboarding) {
               Navigator.of(context).popUntil((route) => route.isFirst);
             }
+          } on SubscriptionAlreadyClaimedError catch (e) {
+            _logger.warning("subscription is already claimed ", e);
+            await _dialog.hide();
+            final String title = "${Platform.isAndroid ? "Play" : "App"}"
+                "Store subscription";
+            final String id =
+                Platform.isAndroid ? "Google Play ID" : "Apple ID";
+            final String message = '''Your $id is already linked to another
+             ente account.\nIf you would like to use your $id with this 
+             account, please contact our support''';
+            showErrorDialog(context, title, message);
+            return;
           } catch (e) {
             _logger.warning("Could not complete payment ", e);
             await _dialog.hide();

+ 52 - 62
lib/ui/settings/account_section_widget.dart

@@ -1,7 +1,7 @@
 import 'package:expandable/expandable.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter_sodium/flutter_sodium.dart';
-import 'package:photos/core/configuration.dart';
+import 'package:photos/services/local_authentication_service.dart';
 import 'package:photos/services/user_service.dart';
 import 'package:photos/ui/account/change_email_dialog.dart';
 import 'package:photos/ui/account/password_entry_page.dart';
@@ -9,11 +9,8 @@ import 'package:photos/ui/account/recovery_key_page.dart';
 import 'package:photos/ui/settings/common_settings.dart';
 import 'package:photos/ui/settings/settings_section_title.dart';
 import 'package:photos/ui/settings/settings_text_item.dart';
-import 'package:photos/ui/tools/app_lock.dart';
-import 'package:photos/utils/auth_util.dart';
 import 'package:photos/utils/dialog_util.dart';
 import 'package:photos/utils/navigation_util.dart';
-import 'package:photos/utils/toast_util.dart';
 
 class AccountSectionWidget extends StatefulWidget {
   const AccountSectionWidget({Key key}) : super(key: key);
@@ -27,7 +24,7 @@ class AccountSectionWidgetState extends State<AccountSectionWidget> {
   Widget build(BuildContext context) {
     return ExpandablePanel(
       header: const SettingsSectionTitle("Account"),
-      collapsed: Container(),
+      collapsed: const SizedBox.shrink(),
       expanded: _getSectionOptions(context),
       theme: getExpandableTheme(context),
     );
@@ -39,32 +36,29 @@ class AccountSectionWidgetState extends State<AccountSectionWidget> {
         GestureDetector(
           behavior: HitTestBehavior.translucent,
           onTap: () async {
-            AppLock.of(context).setEnabled(false);
-            const String reason = "Please authenticate to view your recovery key";
-            final result = await requestAuthentication(reason);
-            AppLock.of(context)
-                .setEnabled(Configuration.instance.shouldShowLockScreen());
-            if (!result) {
-              showToast(context, reason);
-              return;
-            }
-
-            String recoveryKey;
-            try {
-              recoveryKey = await _getOrCreateRecoveryKey();
-            } catch (e) {
-              showGenericErrorDialog(context);
-              return;
-            }
-            routeToPage(
+            final hasAuthenticated = await LocalAuthenticationService.instance
+                .requestLocalAuthentication(
               context,
-              RecoveryKeyPage(
-                recoveryKey,
-                "OK",
-                showAppBar: true,
-                onDone: () {},
-              ),
+              "Please authenticate to view your recovery key",
             );
+            if (hasAuthenticated) {
+              String recoveryKey;
+              try {
+                recoveryKey = await _getOrCreateRecoveryKey();
+              } catch (e) {
+                showGenericErrorDialog(context);
+                return;
+              }
+              routeToPage(
+                context,
+                RecoveryKeyPage(
+                  recoveryKey,
+                  "OK",
+                  showAppBar: true,
+                  onDone: () {},
+                ),
+              );
+            }
           },
           child: const SettingsTextItem(
             text: "Recovery key",
@@ -75,23 +69,21 @@ class AccountSectionWidgetState extends State<AccountSectionWidget> {
         GestureDetector(
           behavior: HitTestBehavior.translucent,
           onTap: () async {
-            AppLock.of(context).setEnabled(false);
-            const String reason = "Please authenticate to change your email";
-            final result = await requestAuthentication(reason);
-            AppLock.of(context)
-                .setEnabled(Configuration.instance.shouldShowLockScreen());
-            if (!result) {
-              showToast(context, reason);
-              return;
-            }
-            showDialog(
-              context: context,
-              builder: (BuildContext context) {
-                return const ChangeEmailDialog();
-              },
-              barrierColor: Colors.black.withOpacity(0.85),
-              barrierDismissible: false,
+            final hasAuthenticated = await LocalAuthenticationService.instance
+                .requestLocalAuthentication(
+              context,
+              "Please authenticate to change your email",
             );
+            if (hasAuthenticated) {
+              showDialog(
+                context: context,
+                builder: (BuildContext context) {
+                  return const ChangeEmailDialog();
+                },
+                barrierColor: Colors.black.withOpacity(0.85),
+                barrierDismissible: false,
+              );
+            }
           },
           child: const SettingsTextItem(
             text: "Change email",
@@ -102,24 +94,22 @@ class AccountSectionWidgetState extends State<AccountSectionWidget> {
         GestureDetector(
           behavior: HitTestBehavior.translucent,
           onTap: () async {
-            AppLock.of(context).setEnabled(false);
-            const String reason = "Please authenticate to change your password";
-            final result = await requestAuthentication(reason);
-            AppLock.of(context)
-                .setEnabled(Configuration.instance.shouldShowLockScreen());
-            if (!result) {
-              showToast(context, reason);
-              return;
-            }
-            Navigator.of(context).push(
-              MaterialPageRoute(
-                builder: (BuildContext context) {
-                  return const PasswordEntryPage(
-                    mode: PasswordEntryMode.update,
-                  );
-                },
-              ),
+            final hasAuthenticated = await LocalAuthenticationService.instance
+                .requestLocalAuthentication(
+              context,
+              "Please authenticate to change your password",
             );
+            if (hasAuthenticated) {
+              Navigator.of(context).push(
+                MaterialPageRoute(
+                  builder: (BuildContext context) {
+                    return const PasswordEntryPage(
+                      mode: PasswordEntryMode.update,
+                    );
+                  },
+                ),
+              );
+            }
           },
           child: const SettingsTextItem(
             text: "Change password",

+ 32 - 42
lib/ui/settings/security_section_widget.dart

@@ -8,15 +8,13 @@ import 'package:photos/core/configuration.dart';
 import 'package:photos/core/event_bus.dart';
 import 'package:photos/ente_theme_data.dart';
 import 'package:photos/events/two_factor_status_change_event.dart';
+import 'package:photos/services/local_authentication_service.dart';
 import 'package:photos/services/user_service.dart';
 import 'package:photos/ui/account/sessions_page.dart';
 import 'package:photos/ui/common/loading_widget.dart';
 import 'package:photos/ui/settings/common_settings.dart';
 import 'package:photos/ui/settings/settings_section_title.dart';
 import 'package:photos/ui/settings/settings_text_item.dart';
-import 'package:photos/ui/tools/app_lock.dart';
-import 'package:photos/utils/auth_util.dart';
-import 'package:photos/utils/toast_util.dart';
 
 class SecuritySectionWidget extends StatefulWidget {
   const SecuritySectionWidget({Key key}) : super(key: key);
@@ -26,9 +24,6 @@ class SecuritySectionWidget extends StatefulWidget {
 }
 
 class _SecuritySectionWidgetState extends State<SecuritySectionWidget> {
-  static const kAuthToViewSessions =
-      "Please authenticate to view your active sessions";
-
   final _config = Configuration.instance;
 
   StreamSubscription<TwoFactorStatusChangeEvent> _twoFactorStatusChangeEvent;
@@ -82,21 +77,18 @@ class _SecuritySectionWidgetState extends State<SecuritySectionWidget> {
                       return Switch.adaptive(
                         value: snapshot.data,
                         onChanged: (value) async {
-                          AppLock.of(context).setEnabled(false);
-                          const String reason =
-                              "Please authenticate to configure two-factor authentication";
-                          final result = await requestAuthentication(reason);
-                          AppLock.of(context).setEnabled(
-                            Configuration.instance.shouldShowLockScreen(),
+                          final hasAuthenticated =
+                              await LocalAuthenticationService.instance
+                                  .requestLocalAuthentication(
+                            context,
+                            "Please authenticate to configure two-factor authentication",
                           );
-                          if (!result) {
-                            showToast(context, reason);
-                            return;
-                          }
-                          if (value) {
-                            UserService.instance.setupTwoFactor(context);
-                          } else {
-                            _disableTwoFactor();
+                          if (hasAuthenticated) {
+                            if (value) {
+                              UserService.instance.setupTwoFactor(context);
+                            } else {
+                              _disableTwoFactor();
+                            }
                           }
                         },
                       );
@@ -129,17 +121,16 @@ class _SecuritySectionWidgetState extends State<SecuritySectionWidget> {
             Switch.adaptive(
               value: _config.shouldShowLockScreen(),
               onChanged: (value) async {
-                AppLock.of(context).disable();
-                final result = await requestAuthentication(
+                final hasAuthenticated = await LocalAuthenticationService
+                    .instance
+                    .requestLocalAuthForLockScreen(
+                  context,
+                  value,
                   "Please authenticate to change lockscreen setting",
+                  "To enable lockscreen, please setup device passcode or screen lock in your system settings.",
                 );
-                if (result) {
-                  AppLock.of(context).setEnabled(value);
-                  _config.setShouldShowLockScreen(value);
+                if (hasAuthenticated) {
                   setState(() {});
-                } else {
-                  AppLock.of(context)
-                      .setEnabled(_config.shouldShowLockScreen());
                 }
               },
             ),
@@ -250,21 +241,20 @@ class _SecuritySectionWidgetState extends State<SecuritySectionWidget> {
       GestureDetector(
         behavior: HitTestBehavior.translucent,
         onTap: () async {
-          AppLock.of(context).setEnabled(false);
-          final result = await requestAuthentication(kAuthToViewSessions);
-          AppLock.of(context)
-              .setEnabled(Configuration.instance.shouldShowLockScreen());
-          if (!result) {
-            showToast(context, kAuthToViewSessions);
-            return;
-          }
-          Navigator.of(context).push(
-            MaterialPageRoute(
-              builder: (BuildContext context) {
-                return const SessionsPage();
-              },
-            ),
+          final hasAuthenticated = await LocalAuthenticationService.instance
+              .requestLocalAuthentication(
+            context,
+            "Please authenticate to view your active sessions",
           );
+          if (hasAuthenticated) {
+            Navigator.of(context).push(
+              MaterialPageRoute(
+                builder: (BuildContext context) {
+                  return const SessionsPage();
+                },
+              ),
+            );
+          }
         },
         child: const SettingsTextItem(
           text: "Active sessions",

+ 1 - 1
lib/ui/sharing/share_collection_widget.dart

@@ -14,7 +14,7 @@ import 'package:photos/db/public_keys_db.dart';
 import 'package:photos/ente_theme_data.dart';
 import 'package:photos/events/backup_folders_updated_event.dart';
 import 'package:photos/models/collection.dart';
-import 'package:photos/models/device_folder.dart';
+import 'package:photos/models/device_collection.dart';
 import 'package:photos/models/public_key.dart';
 import 'package:photos/services/collections_service.dart';
 import 'package:photos/services/feature_flag_service.dart';

+ 162 - 90
lib/ui/tools/deduplicate_page.dart

@@ -6,6 +6,7 @@ import 'package:photos/ente_theme_data.dart';
 import 'package:photos/events/user_details_changed_event.dart';
 import 'package:photos/models/duplicate_files.dart';
 import 'package:photos/models/file.dart';
+import 'package:photos/services/collections_service.dart';
 import 'package:photos/services/deduplication_service.dart';
 import 'package:photos/ui/viewer/file/detail_page.dart';
 import 'package:photos/ui/viewer/file/thumbnail_widget.dart';
@@ -25,27 +26,19 @@ class DeduplicatePage extends StatefulWidget {
 }
 
 class _DeduplicatePageState extends State<DeduplicatePage> {
-  static const kHeaderRowCount = 3;
-  static final kDeleteIconOverlay = Container(
-    decoration: BoxDecoration(
-      gradient: LinearGradient(
-        begin: Alignment.topCenter,
-        end: Alignment.bottomCenter,
-        colors: [
-          Colors.transparent,
-          Colors.black.withOpacity(0.6),
-        ],
-        stops: const [0.75, 1],
-      ),
-    ),
-    child: Align(
+  static const crossAxisCount = 4;
+  static const crossAxisSpacing = 4.0;
+  static const headerRowCount = 3;
+  static final selectedOverlay = Container(
+    color: Colors.black.withOpacity(0.4),
+    child: const Align(
       alignment: Alignment.bottomRight,
       child: Padding(
-        padding: const EdgeInsets.only(right: 8, bottom: 4),
+        padding: EdgeInsets.only(right: 4, bottom: 4),
         child: Icon(
-          Icons.delete_forever,
-          size: 18,
-          color: Colors.red[700],
+          Icons.check_circle,
+          size: 24,
+          color: Colors.white,
         ),
       ),
     ),
@@ -88,6 +81,47 @@ class _DeduplicatePageState extends State<DeduplicatePage> {
       appBar: AppBar(
         elevation: 0,
         title: const Text("Deduplicate Files"),
+        actions: <Widget>[
+          PopupMenuButton(
+            constraints: const BoxConstraints(minWidth: 180),
+            shape: const RoundedRectangleBorder(
+              borderRadius: BorderRadius.all(
+                Radius.circular(8),
+              ),
+            ),
+            onSelected: (value) {
+              setState(() {
+                _selectedFiles.clear();
+              });
+            },
+            offset: const Offset(0, 50),
+            itemBuilder: (BuildContext context) => [
+              PopupMenuItem(
+                value: true,
+                height: 32,
+                child: Row(
+                  children: [
+                    const Icon(
+                      Icons.remove_circle_outline,
+                      size: 20,
+                    ),
+                    const SizedBox(width: 12),
+                    Padding(
+                      padding: const EdgeInsets.only(bottom: 1),
+                      child: Text(
+                        "Deselect all",
+                        style: Theme.of(context)
+                            .textTheme
+                            .subtitle1
+                            .copyWith(fontWeight: FontWeight.w600),
+                      ),
+                    ),
+                  ],
+                ),
+              )
+            ],
+          )
+        ],
       ),
       body: _getBody(),
     );
@@ -130,18 +164,25 @@ class _DeduplicatePageState extends State<DeduplicatePage> {
                 }
               }
               return Padding(
-                padding: const EdgeInsets.only(top: 10, bottom: 10),
+                padding: const EdgeInsets.symmetric(vertical: 8),
                 child: _getGridView(
-                  _duplicates[index - kHeaderRowCount],
-                  index - kHeaderRowCount,
+                  _duplicates[index - headerRowCount],
+                  index - headerRowCount,
                 ),
               );
             },
-            itemCount: _duplicates.length + kHeaderRowCount,
+            itemCount: _duplicates.length + headerRowCount,
             shrinkWrap: true,
           ),
         ),
-        _selectedFiles.isEmpty ? Container() : _getDeleteButton(),
+        _selectedFiles.isEmpty
+            ? const SizedBox.shrink()
+            : Column(
+                children: [
+                  _getDeleteButton(),
+                  const SizedBox(height: crossAxisSpacing / 2),
+                ],
+              ),
       ],
     );
   }
@@ -282,40 +323,47 @@ class _DeduplicatePageState extends State<DeduplicatePage> {
     return SizedBox(
       width: double.infinity,
       child: SafeArea(
-        child: TextButton(
-          style: OutlinedButton.styleFrom(
-            backgroundColor: Colors.red[700],
-          ),
-          child: Column(
-            mainAxisAlignment: MainAxisAlignment.end,
-            children: [
-              const Padding(padding: EdgeInsets.all(2)),
-              Text(
-                text,
-                style: const TextStyle(
-                  fontWeight: FontWeight.bold,
-                  fontSize: 14,
-                  color: Colors.white,
+        child: Padding(
+          padding: const EdgeInsets.symmetric(horizontal: crossAxisSpacing / 2),
+          child: TextButton(
+            style: OutlinedButton.styleFrom(
+              backgroundColor:
+                  Theme.of(context).colorScheme.inverseBackgroundColor,
+            ),
+            child: Column(
+              mainAxisAlignment: MainAxisAlignment.end,
+              children: [
+                const Padding(padding: EdgeInsets.all(4)),
+                Text(
+                  text,
+                  style: TextStyle(
+                    fontWeight: FontWeight.bold,
+                    fontSize: 14,
+                    color: Theme.of(context).colorScheme.inverseTextColor,
+                  ),
+                  textAlign: TextAlign.center,
                 ),
-                textAlign: TextAlign.center,
-              ),
-              const Padding(padding: EdgeInsets.all(2)),
-              Text(
-                formatBytes(size),
-                style: TextStyle(
-                  color: Colors.white.withOpacity(0.7),
-                  fontSize: 12,
+                const Padding(padding: EdgeInsets.all(2)),
+                Text(
+                  formatBytes(size),
+                  style: TextStyle(
+                    color: Theme.of(context)
+                        .colorScheme
+                        .inverseTextColor
+                        .withOpacity(0.7),
+                    fontSize: 12,
+                  ),
                 ),
-              ),
-              const Padding(padding: EdgeInsets.all(2)),
-            ],
+                const Padding(padding: EdgeInsets.all(2)),
+              ],
+            ),
+            onPressed: () async {
+              await deleteFilesFromRemoteOnly(context, _selectedFiles.toList());
+              Bus.instance.fire(UserDetailsChangedEvent());
+              Navigator.of(context)
+                  .pop(DeduplicationResult(_selectedFiles.length, size));
+            },
           ),
-          onPressed: () async {
-            await deleteFilesFromRemoteOnly(context, _selectedFiles.toList());
-            Bus.instance.fire(UserDetailsChangedEvent());
-            Navigator.of(context)
-                .pop(DeduplicationResult(_selectedFiles.length, size));
-          },
         ),
       ),
     );
@@ -326,7 +374,7 @@ class _DeduplicatePageState extends State<DeduplicatePage> {
       crossAxisAlignment: CrossAxisAlignment.start,
       children: [
         Padding(
-          padding: const EdgeInsets.fromLTRB(16, 8, 4, 4),
+          padding: const EdgeInsets.fromLTRB(2, 4, 4, 12),
           child: Text(
             duplicates.files.length.toString() +
                 " files, " +
@@ -335,18 +383,23 @@ class _DeduplicatePageState extends State<DeduplicatePage> {
             style: Theme.of(context).textTheme.subtitle2,
           ),
         ),
-        GridView.builder(
-          shrinkWrap: true,
-          physics: const NeverScrollableScrollPhysics(),
-          // to disable GridView's scrolling
-          itemBuilder: (context, index) {
-            return _buildFile(context, duplicates.files[index], itemIndex);
-          },
-          itemCount: duplicates.files.length,
-          gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
-            crossAxisCount: 4,
+        Padding(
+          padding: const EdgeInsets.symmetric(horizontal: crossAxisSpacing / 2),
+          child: GridView.builder(
+            shrinkWrap: true,
+            physics: const NeverScrollableScrollPhysics(),
+            // to disable GridView's scrolling
+            itemBuilder: (context, index) {
+              return _buildFile(context, duplicates.files[index], itemIndex);
+            },
+            itemCount: duplicates.files.length,
+            gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
+              crossAxisCount: crossAxisCount,
+              crossAxisSpacing: crossAxisSpacing,
+              childAspectRatio: 0.75,
+            ),
+            padding: const EdgeInsets.all(0),
           ),
-          padding: const EdgeInsets.all(0),
         ),
       ],
     );
@@ -379,31 +432,50 @@ class _DeduplicatePageState extends State<DeduplicatePage> {
           forceCustomPageRoute: true,
         );
       },
-      child: Container(
-        margin: const EdgeInsets.all(2.0),
-        decoration: BoxDecoration(
-          border: _selectedFiles.contains(file)
-              ? Border.all(
-                  width: 3,
-                  color: Colors.red[700],
-                )
-              : null,
-        ),
-        child: Stack(
-          children: [
-            Hero(
-              tag: "deduplicate_" + file.tag(),
-              child: ThumbnailWidget(
-                file,
-                diskLoadDeferDuration: kThumbnailDiskLoadDeferDuration,
-                serverLoadDeferDuration: kThumbnailServerLoadDeferDuration,
-                shouldShowLivePhotoOverlay: true,
-                key: Key("deduplicate_" + file.tag()),
-              ),
+      child: Column(
+        crossAxisAlignment: CrossAxisAlignment.start,
+        children: [
+          SizedBox(
+            //the numerator will give the width of the screen excuding the whitespaces in the the grid row
+            height: (MediaQuery.of(context).size.width -
+                    (crossAxisSpacing * crossAxisCount)) /
+                crossAxisCount,
+            child: Stack(
+              children: [
+                Hero(
+                  tag: "deduplicate_" + file.tag(),
+                  child: ClipRRect(
+                    borderRadius: BorderRadius.circular(4),
+                    child: ThumbnailWidget(
+                      file,
+                      diskLoadDeferDuration: kThumbnailDiskLoadDeferDuration,
+                      serverLoadDeferDuration:
+                          kThumbnailServerLoadDeferDuration,
+                      shouldShowLivePhotoOverlay: true,
+                      key: Key("deduplicate_" + file.tag()),
+                    ),
+                  ),
+                ),
+                _selectedFiles.contains(file)
+                    ? ClipRRect(
+                        borderRadius: BorderRadius.circular(4),
+                        child: selectedOverlay,
+                      )
+                    : const SizedBox.shrink(),
+              ],
             ),
-            _selectedFiles.contains(file) ? kDeleteIconOverlay : Container(),
-          ],
-        ),
+          ),
+          const SizedBox(height: 6),
+          Padding(
+            padding: const EdgeInsets.only(right: 2),
+            child: Text(
+              CollectionsService.instance
+                  .getCollectionNameByID(file.collectionID),
+              style: Theme.of(context).textTheme.caption.copyWith(fontSize: 12),
+              overflow: TextOverflow.ellipsis,
+            ),
+          ),
+        ],
       ),
     );
   }

+ 12 - 4
lib/ui/tools/free_space_page.dart

@@ -53,6 +53,7 @@ class _FreeSpacePageState extends State<FreeSpacePage> {
       mainAxisAlignment: MainAxisAlignment.center,
       crossAxisAlignment: CrossAxisAlignment.center,
       children: [
+        const SizedBox(height: 24),
         Stack(
           alignment: Alignment.center,
           children: [
@@ -62,13 +63,20 @@ class _FreeSpacePageState extends State<FreeSpacePage> {
                     color: Colors.white.withOpacity(0.4),
                     colorBlendMode: BlendMode.modulate,
                   )
-                : Image.asset('assets/loading_photos_background_dark.png'),
-            Image.asset(
-              "assets/gallery_locked.png",
-              height: 160,
+                : Image.asset(
+                    'assets/loading_photos_background_dark.png',
+                    color: Colors.white.withOpacity(0.25),
+                  ),
+            Padding(
+              padding: const EdgeInsets.only(top: 16),
+              child: Image.asset(
+                "assets/gallery_locked.png",
+                height: 160,
+              ),
             ),
           ],
         ),
+        const SizedBox(height: 24),
         Padding(
           padding: const EdgeInsets.only(left: 36, right: 40),
           child: Row(

+ 7 - 6
lib/ui/tools/lock_screen.dart

@@ -31,18 +31,19 @@ class _LockScreenState extends State<LockScreen> {
             Stack(
               alignment: Alignment.center,
               children: [
-                Image.asset(
-                  MediaQuery.of(context).platformBrightness == Brightness.light
-                      ? 'assets/loading_photos_background.png'
-                      : 'assets/loading_photos_background_dark.png',
+                Opacity(
+                  opacity: 0.2,
+                  child: Image.asset('assets/loading_photos_background.png'),
                 ),
                 SizedBox(
-                  width: 172,
+                  width: 142,
                   child: GradientButton(
+                    text: "Unlock",
+                    iconData: Icons.lock_open_outlined,
+                    paddingValue: 6,
                     onTap: () async {
                       _showLockScreen();
                     },
-                    text: 'Unlock',
                   ),
                 ),
               ],

+ 1 - 1
lib/ui/viewer/gallery/device_folder_page.dart

@@ -7,7 +7,7 @@ import 'package:photos/ente_theme_data.dart';
 import 'package:photos/events/backup_folders_updated_event.dart';
 import 'package:photos/events/files_updated_event.dart';
 import 'package:photos/events/local_photos_updated_event.dart';
-import 'package:photos/models/device_folder.dart';
+import 'package:photos/models/device_collection.dart';
 import 'package:photos/models/gallery_type.dart';
 import 'package:photos/models/selected_files.dart';
 import 'package:photos/ui/viewer/gallery/gallery.dart';

+ 1 - 1
lib/ui/viewer/gallery/gallery_app_bar_widget.dart

@@ -8,7 +8,7 @@ import 'package:photos/db/files_db.dart';
 import 'package:photos/ente_theme_data.dart';
 import 'package:photos/events/subscription_purchased_event.dart';
 import 'package:photos/models/collection.dart';
-import 'package:photos/models/device_folder.dart';
+import 'package:photos/models/device_collection.dart';
 import 'package:photos/models/gallery_type.dart';
 import 'package:photos/models/magic_metadata.dart';
 import 'package:photos/models/selected_files.dart';

+ 6 - 4
lib/utils/dialog_util.dart

@@ -31,10 +31,12 @@ Future<dynamic> showErrorDialog(
 ) {
   final AlertDialog alert = AlertDialog(
     shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
-    title: Text(
-      title,
-      style: Theme.of(context).textTheme.headline6,
-    ),
+    title: title.isEmpty
+        ? const SizedBox.shrink()
+        : Text(
+            title,
+            style: Theme.of(context).textTheme.headline6,
+          ),
     content: Text(content),
     actions: [
       TextButton(

+ 1 - 1
lib/utils/file_uploader.dart

@@ -569,7 +569,7 @@ class FileUploader {
       orElse: () => null,
     );
     if (fileExistsButDifferentCollection != null) {
-      debugPrint(
+      _logger.fine(
         "fileExistsButDifferentCollection: \n toUpload  ${fileToUpload.tag()} "
         "\n existing: ${fileExistsButDifferentCollection.tag()}",
       );

+ 1 - 1
pubspec.yaml

@@ -11,7 +11,7 @@ description: ente photos application
 # In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion.
 # Read more about iOS versioning at
 # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
-version: 0.6.29+359
+version: 0.6.30+360
 
 environment:
   sdk: ">=2.10.0 <3.0.0"