Sfoglia il codice sorgente

Merge pull request #100 from ente-io/trash_api

Trash
Neeraj Gupta 3 anni fa
parent
commit
99102d882d

+ 4 - 0
lib/core/configuration.dart

@@ -9,8 +9,10 @@ import 'package:path_provider/path_provider.dart';
 import 'package:photos/core/event_bus.dart';
 import 'package:photos/db/collections_db.dart';
 import 'package:photos/db/files_db.dart';
+import 'package:photos/db/ignored_files_db.dart';
 import 'package:photos/db/memories_db.dart';
 import 'package:photos/db/public_keys_db.dart';
+import 'package:photos/db/trash_db.dart';
 import 'package:photos/db/upload_locks_db.dart';
 import 'package:photos/events/user_logged_out_event.dart';
 import 'package:photos/models/key_attributes.dart';
@@ -133,6 +135,8 @@ class Configuration {
     await MemoriesDB.instance.clearTable();
     await PublicKeysDB.instance.clearTable();
     await UploadLocksDB.instance.clearTable();
+    await IgnoredFilesDB.instance.clearTable();
+    await TrashDB.instance.clearTable();
     CollectionsService.instance.clearCache();
     FavoritesService.instance.clearCache();
     MemoriesService.instance.clearCache();

+ 21 - 0
lib/db/collections_db.dart

@@ -11,6 +11,8 @@ class CollectionsDB {
   static final _databaseName = "ente.collections.db";
   static final table = 'collections';
   static final tempTable = 'temp_collections';
+  static final _sqlBoolTrue = 1;
+  static final _sqlBoolFalse = 0;
 
   static final columnID = 'collection_id';
   static final columnOwner = 'owner';
@@ -25,18 +27,21 @@ class CollectionsDB {
   static final columnVersion = 'version';
   static final columnSharees = 'sharees';
   static final columnUpdationTime = 'updation_time';
+  static final columnIsDeleted = 'is_deleted';
 
   static final intitialScript = [...createTable(table)];
   static final migrationScripts = [
     ...alterNameToAllowNULL(),
     ...addEncryptedName(),
     ...addVersion(),
+    ...addIsDeleted(),
   ];
 
   final dbConfig = MigrationConfig(
       initializationScript: intitialScript, migrationScripts: migrationScripts);
 
   CollectionsDB._privateConstructor();
+
   static final CollectionsDB instance = CollectionsDB._privateConstructor();
 
   static Future<Database> _dbFuture;
@@ -113,6 +118,15 @@ class CollectionsDB {
     ];
   }
 
+  static List<String> addIsDeleted() {
+    return [
+      '''
+        ALTER TABLE $table
+        ADD COLUMN $columnIsDeleted INTEGER DEFAULT $_sqlBoolFalse;
+      '''
+    ];
+  }
+
   Future<List<dynamic>> insert(List<Collection> collections) async {
     final db = await instance.database;
     var batch = db.batch();
@@ -172,6 +186,11 @@ class CollectionsDB {
     row[columnSharees] =
         json.encode(collection.sharees?.map((x) => x?.toMap())?.toList());
     row[columnUpdationTime] = collection.updationTime;
+    if (collection.isDeleted ?? false) {
+      row[columnIsDeleted] = _sqlBoolTrue;
+    } else {
+      row[columnIsDeleted] = _sqlBoolTrue;
+    }
     return row;
   }
 
@@ -193,6 +212,8 @@ class CollectionsDB {
       List<User>.from((json.decode(row[columnSharees]) as List)
           .map((x) => User.fromMap(x))),
       int.parse(row[columnUpdationTime]),
+      // default to False is columnIsDeleted is not set
+      isDeleted: (row[columnIsDeleted] ?? _sqlBoolFalse) == _sqlBoolTrue,
     );
   }
 }

+ 15 - 1
lib/db/files_db.dart

@@ -6,9 +6,9 @@ import 'package:path_provider/path_provider.dart';
 import 'package:photos/models/backup_status.dart';
 import 'package:photos/models/file.dart';
 import 'package:photos/models/file_load_result.dart';
-import 'package:photos/models/magic_metadata.dart';
 import 'package:photos/models/file_type.dart';
 import 'package:photos/models/location.dart';
+import 'package:photos/models/magic_metadata.dart';
 import 'package:sqflite/sqflite.dart';
 import 'package:sqflite_migration/sqflite_migration.dart';
 
@@ -771,6 +771,20 @@ class FilesDB {
     return _convertToFiles(results);
   }
 
+  Future<int> deleteUnSyncedLocalFiles(List<String> localIDs) async {
+    String inParam = "";
+    for (final localID in localIDs) {
+      inParam += "'" + localID + "',";
+    }
+    inParam = inParam.substring(0, inParam.length - 1);
+    final db = await instance.database;
+    return db.delete(
+      table,
+      where:
+          '($columnUploadedFileID is NULL OR $columnUploadedFileID = -1 ) AND $columnLocalID IN ($inParam)',
+    );
+  }
+
   Future<int> deleteFromCollection(int uploadedFileID, int collectionID) async {
     final db = await instance.database;
     return db.delete(

+ 133 - 0
lib/db/ignored_files_db.dart

@@ -0,0 +1,133 @@
+import 'dart:io';
+import 'package:path/path.dart';
+import 'package:logging/logging.dart';
+import 'package:path_provider/path_provider.dart';
+import 'package:photos/models/ignored_file.dart';
+import 'package:sqflite/sqflite.dart';
+
+// Keeps track of localIDs which should be not uploaded to ente without
+// user's intervention.
+// Common use case:
+// when a user deletes a file just from ente on current or different device.
+class IgnoredFilesDB {
+  static final _databaseName = "ente.ignored_files.db";
+  static final _databaseVersion = 1;
+  static final Logger _logger = Logger("IgnoredFilesDB");
+  static final tableName = 'ignored_files';
+
+  static final columnLocalID = 'local_id';
+  static final columnTitle = 'title';
+  static final columnReason = 'reason';
+
+  Future _onCreate(Database db, int version) async {
+    await db.execute('''
+        CREATE TABLE $tableName (
+          $columnLocalID TEXT NOT NULL,
+          $columnTitle TEXT NOT NULL,
+          $columnReason TEXT DEFAULT $kIgnoreReasonTrash,
+          UNIQUE($columnLocalID, $columnTitle)
+        );
+      CREATE INDEX IF NOT EXISTS local_id_index ON $tableName($columnLocalID);
+      ''');
+  }
+
+  IgnoredFilesDB._privateConstructor();
+
+  static final IgnoredFilesDB instance = IgnoredFilesDB._privateConstructor();
+
+  // only have a single app-wide reference to the database
+  static Future<Database> _dbFuture;
+
+  Future<Database> get database async {
+    // lazily instantiate the db the first time it is accessed
+    _dbFuture ??= _initDatabase();
+    return _dbFuture;
+  }
+
+  // this opens the database (and creates it if it doesn't exist)
+  Future<Database> _initDatabase() async {
+    Directory documentsDirectory = await getApplicationDocumentsDirectory();
+    String path = join(documentsDirectory.path, _databaseName);
+    return await openDatabase(
+      path,
+      version: _databaseVersion,
+      onCreate: _onCreate,
+    );
+  }
+
+  Future<void> clearTable() async {
+    final db = await instance.database;
+    await db.delete(tableName);
+  }
+
+  Future<void> insertMultiple(List<IgnoredFile> ignoredFiles) async {
+    final startTime = DateTime.now();
+    final db = await instance.database;
+    var batch = db.batch();
+    int batchCounter = 0;
+    for (IgnoredFile file in ignoredFiles) {
+      if (batchCounter == 400) {
+        await batch.commit(noResult: true);
+        batch = db.batch();
+        batchCounter = 0;
+      }
+      batch.insert(
+        tableName,
+        _getRowForIgnoredFile(file),
+        conflictAlgorithm: ConflictAlgorithm.replace,
+      );
+      batchCounter++;
+    }
+    await batch.commit(noResult: true);
+    final endTime = DateTime.now();
+    final duration = Duration(
+        microseconds:
+            endTime.microsecondsSinceEpoch - startTime.microsecondsSinceEpoch);
+    _logger.info("Batch insert of " +
+        ignoredFiles.length.toString() +
+        " took " +
+        duration.inMilliseconds.toString() +
+        "ms.");
+  }
+
+  Future<int> insert(IgnoredFile ignoredFile) async {
+    final db = await instance.database;
+    return db.insert(
+      tableName,
+      _getRowForIgnoredFile(ignoredFile),
+      conflictAlgorithm: ConflictAlgorithm.replace,
+    );
+  }
+
+  // return map of localID to set of titles associated with the given localIDs
+  // Note: localIDs can easily clash across devices for Android, so we should
+  // always compare both localID & title in Android before ignoring the file for upload.
+  // iOS: localID is usually UUID and the title in localDB may be missing (before upload) as the
+  // photo manager library doesn't always fetch the title by default.
+  Future<Map<String, Set<String>>> getIgnoredFiles() async {
+    final db = await instance.database;
+    final rows = await db.query(tableName);
+    final result = <String, Set<String>>{};
+    for (final row in rows) {
+      final ignoredFile = _getIgnoredFileFromRow(row);
+      result
+          .putIfAbsent(ignoredFile.localID, () => <String>{})
+          .add(ignoredFile.title);
+    }
+    return result;
+  }
+
+  IgnoredFile _getIgnoredFileFromRow(Map<String, dynamic> row) {
+    return IgnoredFile(row[columnLocalID], row[columnTitle], row[columnReason]);
+  }
+
+  Map<String, dynamic> _getRowForIgnoredFile(IgnoredFile ignoredFile) {
+    assert(ignoredFile.title != null);
+    assert(ignoredFile.localID != null);
+    final row = <String, dynamic>{};
+    row[columnLocalID] = ignoredFile.localID;
+    row[columnTitle] = ignoredFile.title;
+    row[columnReason] = ignoredFile.reason;
+    return row;
+  }
+}

+ 221 - 0
lib/db/trash_db.dart

@@ -0,0 +1,221 @@
+import 'dart:convert';
+import 'dart:io';
+
+import 'package:logging/logging.dart';
+import 'package:path/path.dart';
+import 'package:path_provider/path_provider.dart';
+import 'package:photos/models/file_load_result.dart';
+import 'package:photos/models/trash_file.dart';
+import 'package:sqflite/sqflite.dart';
+
+// The TrashDB doesn't need to flatten and store all attributes of a file.
+// Before adding any other column, we should evaluate if we need to query on that
+// column or not while showing trashed items. Even if we miss storing any new attributes,
+// during restore, all file attributes will be fetched & stored as required.
+class TrashDB {
+  static final _databaseName = "ente.trash.db";
+  static final _databaseVersion = 1;
+  static final Logger _logger = Logger("TrashDB");
+  static final tableName = 'trash';
+
+  static final columnUploadedFileID = 'uploaded_file_id';
+  static final columnCollectionID = 'collection_id';
+  static final columnOwnerID = 'owner_id';
+  static final columnTrashUpdatedAt = 't_updated_at';
+  static final columnTrashDeleteBy = 't_delete_by';
+  static final columnEncryptedKey = 'encrypted_key';
+  static final columnKeyDecryptionNonce = 'key_decryption_nonce';
+  static final columnFileDecryptionHeader = 'file_decryption_header';
+  static final columnThumbnailDecryptionHeader = 'thumbnail_decryption_header';
+  static final columnUpdationTime = 'updation_time';
+
+  static final columnCreationTime = 'creation_time';
+  static final columnLocalID = 'local_id';
+
+  // standard file metadata, which isn't editable
+  static final columnFileMetadata = 'file_metadata';
+
+  static final columnMMdEncodedJson = 'mmd_encoded_json';
+  static final columnMMdVersion = 'mmd_ver';
+
+  Future _onCreate(Database db, int version) async {
+    await db.execute('''
+        CREATE TABLE $tableName (
+          $columnUploadedFileID INTEGER PRIMARY KEY NOT NULL,
+          $columnCollectionID INTEGER NOT NULL,
+          $columnOwnerID INTEGER,
+          $columnTrashUpdatedAt INTEGER NOT NULL,
+          $columnTrashDeleteBy INTEGER NOT NULL,
+          $columnEncryptedKey TEXT,
+          $columnKeyDecryptionNonce TEXT,
+          $columnFileDecryptionHeader TEXT,
+          $columnThumbnailDecryptionHeader TEXT,
+          $columnUpdationTime INTEGER,
+          $columnLocalID TEXT,
+          $columnCreationTime INTEGER NOT NULL,
+          $columnFileMetadata TEXT DEFAULT '{}',
+          $columnMMdEncodedJson TEXT DEFAULT '{}',
+          $columnMMdVersion INTEGER DEFAULT 0
+        );
+      CREATE INDEX IF NOT EXISTS creation_time_index ON $tableName($columnCreationTime); 
+      CREATE INDEX IF NOT EXISTS delete_by_time_index ON $tableName($columnTrashDeleteBy);
+      CREATE INDEX IF NOT EXISTS updated_at_time_index ON $tableName($columnTrashUpdatedAt);
+      ''');
+  }
+
+  TrashDB._privateConstructor();
+
+  static final TrashDB instance = TrashDB._privateConstructor();
+
+  // only have a single app-wide reference to the database
+  static Future<Database> _dbFuture;
+
+  Future<Database> get database async {
+    // lazily instantiate the db the first time it is accessed
+    _dbFuture ??= _initDatabase();
+    return _dbFuture;
+  }
+
+  // this opens the database (and creates it if it doesn't exist)
+  Future<Database> _initDatabase() async {
+    Directory documentsDirectory = await getApplicationDocumentsDirectory();
+    String path = join(documentsDirectory.path, _databaseName);
+    _logger.info("DB path " + path);
+    return await openDatabase(
+      path,
+      version: _databaseVersion,
+      onCreate: _onCreate,
+    );
+  }
+
+  Future<void> clearTable() async {
+    final db = await instance.database;
+    await db.delete(tableName);
+  }
+
+  Future<bool> isEmpty() async {
+    final db = await instance.database;
+    var rows = await db.query(tableName, limit: 1);
+    return rows == null || rows.isEmpty;
+  }
+
+  Future<void> insertMultiple(List<TrashFile> trashFiles) async {
+    final startTime = DateTime.now();
+    final db = await instance.database;
+    var batch = db.batch();
+    int batchCounter = 0;
+    for (TrashFile trash in trashFiles) {
+      if (batchCounter == 400) {
+        await batch.commit(noResult: true);
+        batch = db.batch();
+        batchCounter = 0;
+      }
+      batch.insert(
+        tableName,
+        _getRowForTrash(trash),
+        conflictAlgorithm: ConflictAlgorithm.replace,
+      );
+      batchCounter++;
+    }
+    await batch.commit(noResult: true);
+    final endTime = DateTime.now();
+    final duration = Duration(
+        microseconds:
+            endTime.microsecondsSinceEpoch - startTime.microsecondsSinceEpoch);
+    _logger.info("Batch insert of " +
+        trashFiles.length.toString() +
+        " took " +
+        duration.inMilliseconds.toString() +
+        "ms.");
+  }
+
+  Future<int> insert(TrashFile trash) async {
+    final db = await instance.database;
+    return db.insert(
+      tableName,
+      _getRowForTrash(trash),
+      conflictAlgorithm: ConflictAlgorithm.replace,
+    );
+  }
+
+  Future<int> delete(List<int> uploadedFileIDs) async {
+    final db = await instance.database;
+    return db.delete(
+      tableName,
+      where: '$columnUploadedFileID IN (${uploadedFileIDs.join(', ')})',
+    );
+  }
+
+  Future<FileLoadResult> getTrashedFiles(int startTime, int endTime,
+      {int limit, bool asc}) async {
+    final db = await instance.database;
+    final order = (asc ?? false ? 'ASC' : 'DESC');
+    final results = await db.query(
+      tableName,
+      where: '$columnCreationTime >= ? AND $columnCreationTime <= ?',
+      whereArgs: [startTime, endTime],
+      orderBy:
+          '$columnCreationTime ' + order ,
+      limit: limit,
+    );
+    final files = _convertToFiles(results);
+    return FileLoadResult(files, files.length == limit);
+  }
+
+  List<TrashFile> _convertToFiles(List<Map<String, dynamic>> results) {
+    final List<TrashFile> trashedFiles = [];
+    for (final result in results) {
+      trashedFiles.add(_getTrashFromRow(result));
+    }
+    return trashedFiles;
+  }
+
+  TrashFile _getTrashFromRow(Map<String, dynamic> row) {
+    final trashFile = TrashFile();
+    trashFile.updateAt = row[columnTrashUpdatedAt];
+    trashFile.deleteBy = row[columnTrashDeleteBy];
+    trashFile.uploadedFileID = row[columnUploadedFileID];
+    // dirty hack to ensure that the file_downloads & cache mechanism works
+    trashFile.generatedID = -1 * trashFile.uploadedFileID;
+    trashFile.ownerID = row[columnOwnerID];
+    trashFile.collectionID =
+        row[columnCollectionID] == -1 ? null : row[columnCollectionID];
+    trashFile.encryptedKey = row[columnEncryptedKey];
+    trashFile.keyDecryptionNonce = row[columnKeyDecryptionNonce];
+    trashFile.fileDecryptionHeader = row[columnFileDecryptionHeader];
+    trashFile.thumbnailDecryptionHeader = row[columnThumbnailDecryptionHeader];
+    trashFile.updationTime = row[columnUpdationTime] ?? 0;
+
+    trashFile.localID = row[columnLocalID];
+    trashFile.creationTime = row[columnCreationTime];
+    final fileMetadata = row[columnFileMetadata] ?? '{}';
+    trashFile.applyMetadata(jsonDecode(fileMetadata));
+
+    trashFile.mMdVersion = row[columnMMdVersion] ?? 0;
+    trashFile.mMdEncodedJson = row[columnMMdEncodedJson] ?? '{}';
+
+    return trashFile;
+  }
+
+  Map<String, dynamic> _getRowForTrash(TrashFile trash) {
+    final row = <String, dynamic>{};
+    row[columnTrashUpdatedAt] = trash.updateAt;
+    row[columnTrashDeleteBy] = trash.deleteBy;
+    row[columnUploadedFileID] = trash.uploadedFileID;
+    row[columnCollectionID] = trash.collectionID;
+    row[columnOwnerID] = trash.ownerID;
+    row[columnEncryptedKey] = trash.encryptedKey;
+    row[columnKeyDecryptionNonce] = trash.keyDecryptionNonce;
+    row[columnFileDecryptionHeader] = trash.fileDecryptionHeader;
+    row[columnThumbnailDecryptionHeader] = trash.thumbnailDecryptionHeader;
+    row[columnUpdationTime] = trash.updationTime;
+
+    row[columnLocalID] = trash.localID;
+    row[columnCreationTime] = trash.creationTime;
+    row[columnFileMetadata] = jsonEncode(trash.getMetadata());
+
+    row[columnMMdVersion] = trash.mMdVersion ?? 0;
+    row[columnMMdEncodedJson] = trash.mMdEncodedJson ?? '{}';
+    return row;
+  }
+}

+ 3 - 1
lib/main.dart

@@ -5,6 +5,7 @@ import 'package:background_fetch/background_fetch.dart';
 import 'package:flutter/foundation.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter_easyloading/flutter_easyloading.dart';
+import 'package:flutter_gen/gen_l10n/app_localizations.dart';
 import 'package:flutter_localizations/flutter_localizations.dart';
 import 'package:in_app_purchase/in_app_purchase.dart';
 import 'package:logging/logging.dart';
@@ -21,6 +22,7 @@ import 'package:photos/services/memories_service.dart';
 import 'package:photos/services/notification_service.dart';
 import 'package:photos/services/remote_sync_service.dart';
 import 'package:photos/services/sync_service.dart';
+import 'package:photos/services/trash_sync_service.dart';
 import 'package:photos/services/update_service.dart';
 import 'package:photos/ui/app_lock.dart';
 import 'package:photos/ui/home_widget.dart';
@@ -30,7 +32,6 @@ import 'package:photos/utils/file_uploader.dart';
 import 'package:photos/utils/local_settings.dart';
 import 'package:shared_preferences/shared_preferences.dart';
 import 'package:super_logging/super_logging.dart';
-import 'package:flutter_gen/gen_l10n/app_localizations.dart';
 
 import 'l10n/l10n.dart';
 
@@ -146,6 +147,7 @@ Future<void> _init(bool isBackground) async {
   await CollectionsService.instance.init();
   await FileUploader.instance.init(isBackground);
   await LocalSyncService.instance.init(isBackground);
+  await TrashSyncService.instance.init();
   await RemoteSyncService.instance.init();
   await SyncService.instance.init();
   await MemoriesService.instance.init();

+ 24 - 13
lib/models/file.dart

@@ -124,7 +124,20 @@ class File {
     metadataVersion = metadata["version"] ?? 0;
   }
 
-  Future<Map<String, dynamic>> getMetadata(io.File sourceFile) async {
+  Future<Map<String, dynamic>> getMetadataForUpload(io.File sourceFile) async {
+    final asset = await getAsset();
+    // asset can be null for files shared to app
+    if (asset != null) {
+      fileSubType = asset.subTypes;
+      if (fileType == FileType.video) {
+        duration = asset.duration;
+      }
+    }
+    hash = Sodium.bin2base64(await CryptoUtil.getHash(sourceFile));
+    return getMetadata();
+  }
+
+  Map<String, dynamic> getMetadata() {
     final metadata = <String, dynamic>{};
     metadata["localID"] = isSharedMediaToAppSandbox() ? null : localID;
     metadata["title"] = title;
@@ -137,20 +150,18 @@ class File {
       metadata["latitude"] = location.latitude;
       metadata["longitude"] = location.longitude;
     }
-    metadata["fileType"] = fileType.index;
-    final asset = await getAsset();
-    // asset can be null for files shared to app
-    if (asset != null) {
-      fileSubType = asset.subTypes;
+    if (fileSubType != null) {
       metadata["subType"] = fileSubType;
-      if (fileType == FileType.video) {
-        duration = asset.duration;
-        metadata["duration"] = duration;
-      }
     }
-    hash = Sodium.bin2base64(await CryptoUtil.getHash(sourceFile));
-    metadata["hash"] = hash;
-    metadata["version"] = metadataVersion;
+    if (duration != null) {
+      metadata["duration"] = duration;
+    }
+    if (hash != null) {
+      metadata["hash"] = hash;
+    }
+    if (metadataVersion != null) {
+      metadata["version"] = metadataVersion;
+    }
     return metadata;
   }
 

+ 24 - 0
lib/models/ignored_file.dart

@@ -0,0 +1,24 @@
+import 'package:photos/models/trash_file.dart';
+
+const kIgnoreReasonTrash = "trash";
+const kIgnoreReasonInvalidFile = "invalidFile";
+
+class IgnoredFile {
+  final String localID;
+  final String title;
+  String reason;
+
+  IgnoredFile(this.localID, this.title, this.reason);
+
+  factory IgnoredFile.fromTrashItem(TrashFile trashFile) {
+    if (trashFile == null) return null;
+    if (trashFile.localID == null ||
+        trashFile.title == null ||
+        trashFile.localID.isEmpty ||
+        trashFile.title.isEmpty) {
+      return null;
+    }
+
+    return IgnoredFile(trashFile.localID, trashFile.title, kIgnoreReasonTrash);
+  }
+}

+ 15 - 0
lib/models/trash_file.dart

@@ -0,0 +1,15 @@
+import 'package:photos/models/file.dart';
+
+class TrashFile extends File {
+
+  // time when file was put in the trash for first time
+  int createdAt;
+
+  // for non-deleted trash items, updateAt is usually equal to the latest time
+  // when the file was moved to trash
+  int updateAt;
+
+  // time after which will will be deleted from trash & user's storage usage
+  // will go down
+  int deleteBy;
+}

+ 24 - 0
lib/models/trash_item_request.dart

@@ -0,0 +1,24 @@
+class TrashRequest {
+  final int fileID;
+  final int collectionID;
+
+  TrashRequest(this.fileID, this.collectionID)
+      : assert(fileID != null),
+        assert(collectionID != null);
+
+  factory TrashRequest.fromJson(Map<String, dynamic> json) {
+    return TrashRequest(json['fileID'], json['collectionID']);
+  }
+
+  Map<String, dynamic> toJson() {
+    final Map<String, dynamic> data = <String, dynamic>{};
+    data['fileID'] = fileID;
+    data['collectionID'] = collectionID;
+    return data;
+  }
+
+  @override
+  String toString() {
+    return 'TrashItemRequest{fileID: $fileID, collectionID: $collectionID}';
+  }
+}

+ 96 - 7
lib/services/collections_service.dart

@@ -11,8 +11,10 @@ import 'package:photos/core/event_bus.dart';
 import 'package:photos/core/network.dart';
 import 'package:photos/db/collections_db.dart';
 import 'package:photos/db/files_db.dart';
+import 'package:photos/db/trash_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/models/collection.dart';
 import 'package:photos/models/collection_file_item.dart';
@@ -66,7 +68,7 @@ class CollectionsService {
   }
 
   Future<List<Collection>> sync() async {
-    _logger.info("Syncing");
+    _logger.info("Syncing collections");
     final lastCollectionUpdationTime =
         _prefs.getInt(_collectionsSyncTimeKey) ?? 0;
 
@@ -75,13 +77,19 @@ class CollectionsService {
         await _fetchCollections(lastCollectionUpdationTime ?? 0);
     final updatedCollections = <Collection>[];
     int maxUpdationTime = lastCollectionUpdationTime;
+    final ownerID = _config.getUserID();
     for (final collection in fetchedCollections) {
       if (collection.isDeleted) {
         await _filesDB.deleteCollection(collection.id);
-        await _db.deleteCollection(collection.id);
         await setCollectionSyncTime(collection.id, null);
         Bus.instance.fire(LocalPhotosUpdatedEvent(List<File>.empty()));
+      }
+      // remove reference for incoming collections when unshared/deleted
+      if (collection.isDeleted && ownerID != collection?.owner?.id) {
+        await _db.deleteCollection(collection.id);
       } else {
+        // keep entry for deletedCollection as collectionKey may be used during
+        // trash file decryption
         updatedCollections.add(collection);
       }
       maxUpdationTime = collection.updationTime > maxUpdationTime
@@ -111,7 +119,7 @@ class CollectionsService {
     final collections = await _db.getAllCollections();
     final updatedCollections = <Collection>[];
     for (final c in collections) {
-      if (c.updationTime > getCollectionSyncTime(c.id)) {
+      if (c.updationTime > getCollectionSyncTime(c.id) && !c.isDeleted) {
         updatedCollections.add(c);
       }
     }
@@ -141,8 +149,11 @@ class CollectionsService {
     return _localCollections[path];
   }
 
-  List<Collection> getCollections() {
-    return _collectionIDToCollections.values.toList();
+  // getActiveCollections returns list of collections which are not deleted yet
+  List<Collection> getActiveCollections() {
+    return _collectionIDToCollections.values
+        .toList()
+        .where((element) => !element.isDeleted);
   }
 
   Future<List<User>> getSharees(int collectionID) {
@@ -213,6 +224,13 @@ class CollectionsService {
   Uint8List getCollectionKey(int collectionID) {
     if (!_cachedKeys.containsKey(collectionID)) {
       final collection = _collectionIDToCollections[collectionID];
+      if (collection == null) {
+        // Async fetch for collection. A collection might be
+        // missing from older clients when we used to delete the collection
+        // from db. For trashed files, we need collection data for decryption.
+        fetchCollectionByID(collectionID);
+        throw AssertionError('collectionID $collectionID is not cached');
+      }
       _cachedKeys[collectionID] = _getDecryptedKey(collection);
     }
     return _cachedKeys[collectionID];
@@ -303,6 +321,28 @@ class CollectionsService {
     return collection;
   }
 
+  Future<Collection> fetchCollectionByID(int collectionID) async {
+    try {
+      _logger.fine('fetching collectionByID $collectionID');
+      final response = await _dio.get(
+        Configuration.instance.getHttpEndpoint() + "/collections/$collectionID",
+        options: Options(
+            headers: {"X-Auth-Token": Configuration.instance.getToken()}),
+      );
+      assert(response != null && response.data != null);
+      final collection = Collection.fromMap(response.data["collection"]);
+      await _db.insert(List.from([collection]));
+      _cacheCollectionAttributes(collection);
+      return collection;
+    } catch (e) {
+      if (e is DioError && e.response?.statusCode == 401) {
+        throw UnauthorizedError();
+      }
+      _logger.severe('failed to fetch collection: $collectionID', e);
+      rethrow;
+    }
+  }
+
   Future<Collection> getOrCreateForPath(String path) async {
     if (_localCollections.containsKey(path) &&
         _localCollections[path].owner.id == _config.getUserID()) {
@@ -380,6 +420,53 @@ class CollectionsService {
     }
   }
 
+  Future<void> restore(int toCollectionID, List<File> files) async {
+    final params = <String, dynamic>{};
+    params["collectionID"] = toCollectionID;
+    params["files"] = [];
+    final toCollectionKey = getCollectionKey(toCollectionID);
+    for (final file in files) {
+      final key = decryptFileKey(file);
+      file.generatedID = null; // So that a new entry is created in the FilesDB
+      file.collectionID = toCollectionID;
+      final encryptedKeyData = CryptoUtil.encryptSync(key, toCollectionKey);
+      file.encryptedKey = Sodium.bin2base64(encryptedKeyData.encryptedData);
+      file.keyDecryptionNonce = Sodium.bin2base64(encryptedKeyData.nonce);
+      params["files"].add(CollectionFileItem(
+              file.uploadedFileID, file.encryptedKey, file.keyDecryptionNonce)
+          .toMap());
+    }
+    try {
+      await _dio.post(
+        Configuration.instance.getHttpEndpoint() + "/collections/restore-files",
+        data: params,
+        options: Options(
+            headers: {"X-Auth-Token": Configuration.instance.getToken()}),
+      );
+      await _filesDB.insertMultiple(files);
+      await TrashDB.instance
+          .delete(files.map((e) => e.uploadedFileID).toList());
+      Bus.instance.fire(CollectionUpdatedEvent(toCollectionID, files));
+      Bus.instance.fire(FilesUpdatedEvent(files));
+      // Remove imported local files which are imported but not uploaded.
+      // This handles the case where local file was trashed -> imported again
+      // but not uploaded automatically as it was trashed.
+      final localIDs = files
+          .where((e) => e.localID != null)
+          .map((e) => e.localID)
+          .toSet()
+          .toList();
+      if (localIDs.isNotEmpty) {
+        await _filesDB.deleteUnSyncedLocalFiles(localIDs);
+      }
+      // Force reload home gallery to pull in the restored files
+      Bus.instance.fire(ForceReloadHomeGalleryEvent());
+    } catch (e, s) {
+      _logger.severe("failed to restore files", e, s);
+      rethrow;
+    }
+  }
+
   Future<void> move(
       int toCollectionID, int fromCollectionID, List<File> files) async {
     _validateMoveRequest(toCollectionID, fromCollectionID, files);
@@ -455,13 +542,14 @@ class CollectionsService {
       params["fileIDs"].add(file.uploadedFileID);
     }
     await _dio.post(
-      Configuration.instance.getHttpEndpoint() + "/collections/remove-files",
+      Configuration.instance.getHttpEndpoint() + "/collections/v2/remove-files",
       data: params,
       options:
           Options(headers: {"X-Auth-Token": Configuration.instance.getToken()}),
     );
     await _filesDB.removeFromCollection(collectionID, params["fileIDs"]);
     Bus.instance.fire(CollectionUpdatedEvent(collectionID, files));
+    Bus.instance.fire(LocalPhotosUpdatedEvent(files));
     RemoteSyncService.instance.sync(silently: true);
   }
 
@@ -482,7 +570,8 @@ class CollectionsService {
   Collection _cacheCollectionAttributes(Collection collection) {
     final collectionWithDecryptedName =
         _getCollectionWithDecryptedName(collection);
-    if (collection.attributes.encryptedPath != null) {
+    if (collection.attributes.encryptedPath != null &&
+        !(collection.isDeleted)) {
       _localCollections[decryptCollectionPath(collection)] =
           collectionWithDecryptedName;
     }

+ 1 - 1
lib/services/favorites_service.dart

@@ -63,7 +63,7 @@ class FavoritesService {
 
   Future<Collection> _getFavoritesCollection() async {
     if (_cachedFavoritesCollectionID == null) {
-      final collections = _collectionsService.getCollections();
+      final collections = _collectionsService.getActiveCollections();
       for (final collection in collections) {
         if (collection.owner.id == _config.getUserID() &&
             collection.type == CollectionType.favorites) {

+ 36 - 2
lib/services/remote_sync_service.dart

@@ -7,6 +7,7 @@ import 'package:photos/core/configuration.dart';
 import 'package:photos/core/errors.dart';
 import 'package:photos/core/event_bus.dart';
 import 'package:photos/db/files_db.dart';
+import 'package:photos/db/ignored_files_db.dart';
 import 'package:photos/events/collection_updated_event.dart';
 import 'package:photos/events/files_updated_event.dart';
 import 'package:photos/events/local_photos_updated_event.dart';
@@ -15,6 +16,7 @@ import 'package:photos/models/file.dart';
 import 'package:photos/models/file_type.dart';
 import 'package:photos/services/collections_service.dart';
 import 'package:photos/services/local_sync_service.dart';
+import 'package:photos/services/trash_sync_service.dart';
 import 'package:photos/utils/diff_fetcher.dart';
 import 'package:photos/utils/file_uploader.dart';
 import 'package:photos/utils/file_util.dart';
@@ -60,7 +62,12 @@ class RemoteSyncService {
     if (!_hasSyncedArchive()) {
       await _markArchiveAsSynced();
     }
-
+    // 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
+    // recently trashed.
+    await TrashSyncService.instance.syncTrash()
+    .onError((e, s) => _logger.severe('trash sync failed', e, s));
     bool hasUploadedFiles = await _uploadDiff();
     if (hasUploadedFiles) {
       sync(silently: true);
@@ -82,7 +89,7 @@ class RemoteSyncService {
   }
 
   Future<void> _resyncAllCollectionsSinceTime(int sinceTime) async {
-    final collections = _collectionsService.getCollections();
+    final collections = _collectionsService.getActiveCollections();
     for (final c in collections) {
       await _syncCollectionDiff(c.id,
           min(_collectionsService.getCollectionSyncTime(c.id), sinceTime));
@@ -119,6 +126,23 @@ class RemoteSyncService {
     }
   }
 
+  bool _shouldIgnoreFileUpload(
+      Map<String, Set<String>> ignoredFilesMap, File file) {
+    if (file.localID == null || file.localID.isEmpty) {
+      return false;
+    }
+    if (!ignoredFilesMap.containsKey(file.localID)) {
+      return false;
+    }
+    // only compare title in Android because title may be missing in IOS
+    // and iOS anyways use uuid for localIDs of file, so collision should be
+    // rare.
+    if (Platform.isAndroid) {
+      return ignoredFilesMap[file.localID].contains(file.title ?? '');
+    }
+    return true;
+  }
+
   Future<bool> _uploadDiff() async {
     final foldersToBackUp = Configuration.instance.getPathsToBackUp();
     List<File> filesToBeUploaded;
@@ -133,6 +157,16 @@ class RemoteSyncService {
       filesToBeUploaded
           .removeWhere((element) => element.fileType == FileType.video);
     }
+    if (filesToBeUploaded.isNotEmpty) {
+      final ignoredFilesMap = await IgnoredFilesDB.instance.getIgnoredFiles();
+      final int prevCount = filesToBeUploaded.length;
+      filesToBeUploaded.removeWhere(
+          (file) => _shouldIgnoreFileUpload(ignoredFilesMap, file));
+      if (prevCount != filesToBeUploaded.length) {
+        _logger.info((prevCount - filesToBeUploaded.length).toString() +
+            " files were ignored for upload");
+      }
+    }
     _logger.info(
         filesToBeUploaded.length.toString() + " new files to be uploaded.");
 

+ 133 - 0
lib/services/trash_sync_service.dart

@@ -0,0 +1,133 @@
+import 'package:dio/dio.dart';
+import 'package:logging/logging.dart';
+import 'package:photos/core/configuration.dart';
+import 'package:photos/core/network.dart';
+import 'package:photos/db/ignored_files_db.dart';
+import 'package:photos/db/trash_db.dart';
+import 'package:photos/models/file.dart';
+import 'package:photos/models/ignored_file.dart';
+import 'package:photos/models/trash_file.dart';
+import 'package:photos/models/trash_item_request.dart';
+import 'package:photos/utils/trash_diff_fetcher.dart';
+import 'package:shared_preferences/shared_preferences.dart';
+
+class TrashSyncService {
+  final _logger = Logger("TrashSyncService");
+  final _diffFetcher = TrashDiffFetcher();
+  final _trashDB = TrashDB.instance;
+  static const kDiffLimit = 2500;
+  static const kLastTrashSyncTime = "last_trash_sync_time";
+  SharedPreferences _prefs;
+
+  TrashSyncService._privateConstructor();
+
+  static final TrashSyncService instance =
+      TrashSyncService._privateConstructor();
+  final _dio = Network.instance.getDio();
+
+  Future<void> init() async {
+    _prefs = await SharedPreferences.getInstance();
+  }
+
+  Future<void> syncTrash() async {
+    final lastSyncTime = _getSyncTime();
+    _logger.fine('sync trash sinceTime : $lastSyncTime');
+    final diff = await _diffFetcher.getTrashFilesDiff(lastSyncTime, kDiffLimit);
+    if (diff.trashedFiles.isNotEmpty) {
+      _logger.fine("inserting ${diff.trashedFiles.length} items in trash");
+      await _trashDB.insertMultiple(diff.trashedFiles);
+    }
+    if (diff.deletedFiles.isNotEmpty) {
+      _logger.fine("discard ${diff.deletedFiles.length} deleted items");
+      await _trashDB
+          .delete(diff.deletedFiles.map((e) => e.uploadedFileID).toList());
+    }
+    if (diff.restoredFiles.isNotEmpty) {
+      _logger.fine("discard ${diff.restoredFiles.length} restored items");
+      await _trashDB
+          .delete(diff.restoredFiles.map((e) => e.uploadedFileID).toList());
+    }
+
+    await _updateIgnoredFiles(diff);
+
+    if (diff.lastSyncedTimeStamp != 0) {
+      await _setSyncTime(diff.lastSyncedTimeStamp);
+    }
+    if (diff.fetchCount == kDiffLimit) {
+      return await syncTrash();
+    }
+  }
+
+  Future<void> _updateIgnoredFiles(Diff diff) async {
+    final ignoredFiles = <IgnoredFile>[];
+    for (TrashFile t in diff.trashedFiles) {
+      final file = IgnoredFile.fromTrashItem(t);
+      if (file != null) {
+        ignoredFiles.add(file);
+      }
+    }
+    for (TrashFile t in diff.deletedFiles) {
+      final file = IgnoredFile.fromTrashItem(t);
+      if (file != null) {
+        ignoredFiles.add(file);
+      }
+    }
+    if (ignoredFiles.isNotEmpty) {
+      _logger.fine('updating ${ignoredFiles.length} ignored files ');
+      await IgnoredFilesDB.instance.insertMultiple(ignoredFiles);
+    }
+  }
+
+  Future<void> _setSyncTime(int time) async {
+    return _prefs.setInt(kLastTrashSyncTime, time);
+  }
+
+  int _getSyncTime() {
+    return _prefs.getInt(kLastTrashSyncTime) ?? 0;
+  }
+
+  Future<void> trashFilesOnServer(List<TrashRequest> trashRequestItems) async {
+    final params = <String, dynamic>{};
+    final includedFileIDs = <int>{};
+    params["items"] = [];
+    for (final item in trashRequestItems) {
+      if (!includedFileIDs.contains(item.fileID)) {
+        params["items"].add(item.toJson());
+        includedFileIDs.add(item.fileID);
+      }
+    }
+    return await _dio.post(
+      Configuration.instance.getHttpEndpoint() + "/files/trash",
+      options: Options(
+        headers: {
+          "X-Auth-Token": Configuration.instance.getToken(),
+        },
+      ),
+      data: params,
+    );
+  }
+
+  Future<void> deleteFromTrash(List<File> files) async {
+    final params = <String, dynamic>{};
+    final uniqueFileIds = files.map((e) => e.uploadedFileID).toSet().toList();
+    params["fileIDs"] = [];
+    for (final fileID in uniqueFileIds) {
+      params["fileIDs"].add(fileID);
+    }
+    try {
+      await _dio.post(
+        Configuration.instance.getHttpEndpoint() + "/trash/delete",
+        options: Options(
+          headers: {
+            "X-Auth-Token": Configuration.instance.getToken(),
+          },
+        ),
+        data: params,
+      );
+      _trashDB.delete(uniqueFileIds);
+    } catch (e, s) {
+      _logger.severe("failed to delete from trash", e, s);
+      rethrow;
+    }
+  }
+}

+ 78 - 34
lib/ui/collections_gallery_widget.dart

@@ -15,15 +15,16 @@ 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/services/collections_service.dart';
+import 'package:photos/ui/archive_page.dart';
 import 'package:photos/ui/collection_page.dart';
 import 'package:photos/ui/common_elements.dart';
 import 'package:photos/ui/device_folder_page.dart';
 import 'package:photos/ui/loading_widget.dart';
 import 'package:photos/ui/thumbnail_widget.dart';
+import 'package:photos/ui/trash_page.dart';
 import 'package:photos/utils/local_settings.dart';
 import 'package:photos/utils/navigation_util.dart';
 import 'package:photos/utils/toast_util.dart';
-import 'package:photos/ui/archive_page.dart';
 
 class CollectionsGalleryWidget extends StatefulWidget {
   const CollectionsGalleryWidget({Key key}) : super(key: key);
@@ -181,42 +182,85 @@ class _CollectionsGalleryWidgetState extends State<CollectionsGalleryWidget>
                 : nothingToSeeHere,
             Divider(),
             Padding(padding: EdgeInsets.all(8)),
-            OutlinedButton(
-                style: OutlinedButton.styleFrom(
-                  shape: RoundedRectangleBorder(
-                    borderRadius: BorderRadius.circular(5),
-                  ),
-                  padding: EdgeInsets.fromLTRB(20, 10, 20, 10),
-                  side: BorderSide(
-                    width: 2,
-                    color: Colors.white12,
-                  ),
-                ),
-                child: Row(
-                  mainAxisAlignment: MainAxisAlignment.center,
-                  crossAxisAlignment: CrossAxisAlignment.center,
-                  mainAxisSize: MainAxisSize.min,
-                  children: const [
-                    Icon(
-                      Icons.archive_outlined,
-                      color: Colors.white,
+            Row(
+              mainAxisAlignment: MainAxisAlignment.center,
+              crossAxisAlignment: CrossAxisAlignment.center,
+              mainAxisSize: MainAxisSize.min,
+              children: [
+                OutlinedButton(
+                    style: OutlinedButton.styleFrom(
+                      shape: RoundedRectangleBorder(
+                        borderRadius: BorderRadius.circular(5),
+                      ),
+                      padding: EdgeInsets.fromLTRB(20, 10, 20, 10),
+                      side: BorderSide(
+                        width: 2,
+                        color: Colors.white12,
+                      ),
+                    ),
+                    child: Row(
+                      mainAxisAlignment: MainAxisAlignment.center,
+                      crossAxisAlignment: CrossAxisAlignment.center,
+                      mainAxisSize: MainAxisSize.min,
+                      children: const [
+                        Icon(
+                          Icons.archive_outlined,
+                          color: Colors.white,
+                        ),
+                        Padding(padding: EdgeInsets.all(6)),
+                        Text(
+                          "archive",
+                          style: TextStyle(
+                            color: Colors.white,
+                          ),
+                        ),
+                      ],
                     ),
-                    Padding(padding: EdgeInsets.all(6)),
-                    Text(
-                      "archive",
-                      style: TextStyle(
-                        color: Colors.white,
+                    onPressed: () async {
+                      routeToPage(
+                        context,
+                        ArchivePage(),
+                      );
+                    }),
+                Padding(padding: EdgeInsets.fromLTRB(18,0,18,0)),
+                OutlinedButton(
+                    style: OutlinedButton.styleFrom(
+                      shape: RoundedRectangleBorder(
+                        borderRadius: BorderRadius.circular(5),
+                      ),
+                      padding: EdgeInsets.fromLTRB(20, 10, 20, 10),
+                      side: BorderSide(
+                        width: 2,
+                        color: Colors.white12,
                       ),
                     ),
-                  ],
-                ),
-                onPressed: () async {
-                  routeToPage(
-                    context,
-                    ArchivePage(),
-                  );
-                }),
-            Padding(padding: EdgeInsets.fromLTRB(12, 12, 12, 36)),
+                    child: Row(
+                      mainAxisAlignment: MainAxisAlignment.center,
+                      crossAxisAlignment: CrossAxisAlignment.center,
+                      mainAxisSize: MainAxisSize.min,
+                      children: const [
+                        Icon(
+                          Icons.delete_outline_sharp,
+                          color: Colors.white,
+                        ),
+                        Padding(padding: EdgeInsets.all(6)),
+                        Text(
+                          "trash",
+                          style: TextStyle(
+                            color: Colors.white,
+                          ),
+                        ),
+                      ],
+                    ),
+                    onPressed: () async {
+                      routeToPage(
+                        context,
+                        TrashPage(),
+                      );
+                    }),
+              ],
+            ),
+            Padding(padding: EdgeInsets.fromLTRB(12, 12, 12, 72)),
           ],
         ),
       ),

+ 58 - 14
lib/ui/create_collection_page.dart

@@ -19,7 +19,24 @@ import 'package:photos/utils/share_util.dart';
 import 'package:photos/utils/toast_util.dart';
 import 'package:receive_sharing_intent/receive_sharing_intent.dart';
 
-enum CollectionActionType { addFiles, moveFiles }
+enum CollectionActionType { addFiles, moveFiles, restoreFiles }
+
+String _actionName(CollectionActionType type, bool plural) {
+  final titleSuffix = (plural ? "s" : "");
+  String text = "";
+  switch (type) {
+    case CollectionActionType.addFiles:
+      text = "add file";
+      break;
+    case CollectionActionType.moveFiles:
+      text = "move file";
+      break;
+    case CollectionActionType.restoreFiles:
+      text = "restore file";
+      break;
+  }
+  return text + titleSuffix;
+}
 
 class CreateCollectionPage extends StatefulWidget {
   final SelectedFiles selectedFiles;
@@ -43,14 +60,9 @@ class _CreateCollectionPageState extends State<CreateCollectionPage> {
     final filesCount = widget.sharedFiles != null
         ? widget.sharedFiles.length
         : widget.selectedFiles.files.length;
-    final titleSuffix = (filesCount == 1 ? "" : "s");
     return Scaffold(
       appBar: AppBar(
-        title: Text(
-          widget.actionType == CollectionActionType.addFiles
-              ? "add file" + titleSuffix
-              : "move file" + titleSuffix,
-        ),
+        title: Text(_actionName(widget.actionType, filesCount > 1)),
       ),
       body: _getBody(context),
     );
@@ -153,7 +165,7 @@ class _CreateCollectionPageState extends State<CreateCollectionPage> {
           ],
         ),
         onTap: () async {
-          if (await _addOrMoveToCollection(item.collection.id)) {
+          if (await _runCollectionAction(item.collection.id)) {
             showToast(widget.actionType == CollectionActionType.addFiles
                 ? "added successfully to " + item.collection.name
                 : "moved successfully to " + item.collection.name);
@@ -206,8 +218,12 @@ class _CreateCollectionPageState extends State<CreateCollectionPage> {
             Navigator.of(context, rootNavigator: true).pop('dialog');
             final collection = await _createAlbum(_albumName);
             if (collection != null) {
-              if (await _addOrMoveToCollection(collection.id)) {
-                showToast("album '" + _albumName + "' created.");
+              if (await _runCollectionAction(collection.id)) {
+                if (widget.actionType == CollectionActionType.restoreFiles) {
+                  showToast('restored files to album ' + _albumName);
+                } else {
+                  showToast("album '" + _albumName + "' created.");
+                }
                 _navigateToCollection(collection);
               }
             }
@@ -235,10 +251,16 @@ class _CreateCollectionPageState extends State<CreateCollectionPage> {
             )));
   }
 
-  Future<bool> _addOrMoveToCollection(int collectionID) async {
-    return widget.actionType == CollectionActionType.addFiles
-        ? _addToCollection(collectionID)
-        : _moveFilesToCollection(collectionID);
+  Future<bool> _runCollectionAction(int collectionID) async {
+    switch (widget.actionType) {
+      case CollectionActionType.addFiles:
+        return _addToCollection(collectionID);
+      case CollectionActionType.moveFiles:
+        return _moveFilesToCollection(collectionID);
+      case CollectionActionType.restoreFiles:
+        return _restoreFilesToCollection(collectionID);
+    }
+    throw AssertionError("unexpected actionType ${widget.actionType}");
   }
 
   Future<bool> _moveFilesToCollection(int toCollectionID) async {
@@ -264,6 +286,28 @@ class _CreateCollectionPageState extends State<CreateCollectionPage> {
     }
   }
 
+  Future<bool> _restoreFilesToCollection(int toCollectionID) async {
+    final dialog = createProgressDialog(context, "restoring files...");
+    await dialog.show();
+    try {
+      await CollectionsService.instance
+          .restore(toCollectionID, widget.selectedFiles.files?.toList());
+      RemoteSyncService.instance.sync(silently: true);
+      widget.selectedFiles?.clearAll();
+      await dialog.hide();
+      return true;
+    } on AssertionError catch (e, s) {
+      await dialog.hide();
+      showErrorDialog(context, "oops", e.message);
+      return false;
+    } catch (e, s) {
+      _logger.severe("Could not move to album", e, s);
+      await dialog.hide();
+      showGenericErrorDialog(context);
+      return false;
+    }
+  }
+
   Future<bool> _addToCollection(int collectionID) async {
     final dialog = createProgressDialog(context, "uploading files to album...");
     await dialog.show();

+ 4 - 1
lib/ui/fading_app_bar.dart

@@ -11,6 +11,7 @@ import 'package:photos/db/files_db.dart';
 import 'package:photos/events/local_photos_updated_event.dart';
 import 'package:photos/models/file.dart';
 import 'package:photos/models/file_type.dart';
+import 'package:photos/models/trash_file.dart';
 import 'package:photos/services/favorites_service.dart';
 import 'package:photos/services/local_sync_service.dart';
 import 'package:photos/ui/custom_app_bar.dart';
@@ -90,6 +91,8 @@ class FadingAppBarState extends State<FadingAppBar> {
 
   AppBar _buildAppBar() {
     final List<Widget> actions = [];
+    final isTrashedFile =  widget.file is TrashFile;
+    final shouldShowActions = widget.shouldShowActions && !isTrashedFile;
     // only show fav option for files owned by the user
     if (widget.file.ownerID == null || widget.file.ownerID == widget.userID) {
       actions.add(_getFavoriteButton());
@@ -152,7 +155,7 @@ class FadingAppBarState extends State<FadingAppBar> {
           fontSize: 14,
         ),
       ),
-      actions: widget.shouldShowActions ? actions : [],
+      actions: shouldShowActions ? actions : [],
       backgroundColor: Color(0x00000000),
       elevation: 0,
     );

+ 59 - 6
lib/ui/fading_bottom_bar.dart

@@ -2,12 +2,17 @@ import 'dart:io';
 
 import 'package:flutter/cupertino.dart';
 import 'package:flutter/material.dart';
+import 'package:page_transition/page_transition.dart';
 import 'package:photos/core/configuration.dart';
 import 'package:photos/models/file.dart';
 import 'package:photos/models/file_type.dart';
 import 'package:photos/models/magic_metadata.dart';
+import 'package:photos/models/selected_files.dart';
+import 'package:photos/models/trash_file.dart';
+import 'package:photos/ui/create_collection_page.dart';
 import 'package:photos/ui/file_info_dialog.dart';
 import 'package:photos/utils/archive_util.dart';
+import 'package:photos/utils/delete_file_util.dart';
 import 'package:photos/utils/share_util.dart';
 
 class FadingBottomBar extends StatefulWidget {
@@ -69,7 +74,10 @@ class FadingBottomBarState extends State<FadingBottomBar> {
         ),
       ),
     );
-    if (!widget.showOnlyInfoButton) {
+    if (widget.file is TrashFile) {
+      _addTrashOptions(children);
+    }
+    if (!widget.showOnlyInfoButton && widget.file is! TrashFile) {
       if (widget.file.fileType == FileType.image ||
           widget.file.fileType == FileType.livePhoto) {
         children.add(
@@ -97,11 +105,10 @@ class FadingBottomBarState extends State<FadingBottomBar> {
             child: Padding(
               padding: const EdgeInsets.only(top: 12, bottom: 12),
               child: IconButton(
-                icon: Icon(
-                  Platform.isAndroid
-                      ? (isArchived
-                          ? Icons.unarchive_outlined
-                          : Icons.archive_outlined)
+                icon: Icon(Platform.isAndroid
+                    ? (isArchived
+                        ? Icons.unarchive_outlined
+                        : Icons.archive_outlined)
                     : (isArchived
                         ? CupertinoIcons.archivebox_fill
                         : CupertinoIcons.archivebox)),
@@ -162,6 +169,52 @@ class FadingBottomBarState extends State<FadingBottomBar> {
     );
   }
 
+  void _addTrashOptions(List<Widget> children) {
+    children.add(
+      Tooltip(
+        message: "restore",
+        child: Padding(
+          padding: const EdgeInsets.only(top: 12, bottom: 12),
+          child: IconButton(
+            icon: Icon(Icons.restore_outlined),
+            onPressed: () {
+              final selectedFiles = SelectedFiles();
+              selectedFiles.toggleSelection(widget.file);
+              Navigator.push(
+                  context,
+                  PageTransition(
+                      type: PageTransitionType.bottomToTop,
+                      child: CreateCollectionPage(
+                        selectedFiles,
+                        null,
+                        actionType: CollectionActionType.restoreFiles,
+                      )));
+            },
+          ),
+        ),
+      ),
+    );
+
+    children.add(
+      Tooltip(
+        message: "delete",
+        child: Padding(
+          padding: const EdgeInsets.only(top: 12, bottom: 12),
+          child: IconButton(
+            icon: Icon(Icons.delete_forever_outlined),
+            onPressed: () async {
+              final trashedFile = <TrashFile>[];
+              trashedFile.add(widget.file);
+              if (await deleteFromTrash(context, trashedFile) == true) {
+                Navigator.pop(context);
+              }
+            },
+          ),
+        ),
+      ),
+    );
+  }
+
   Future<void> _displayInfo(File file) async {
     return showDialog<void>(
       context: context,

+ 1 - 1
lib/ui/file_info_dialog.dart

@@ -137,7 +137,7 @@ class _FileInfoWidgetState extends State<FileInfoWidget> {
     if (_isImage && _exif != null) {
       items.add(_getExifWidgets(_exif));
     }
-    if (widget.file.uploadedFileID != null) {
+    if (widget.file.uploadedFileID != null && widget.file.updationTime != null) {
       items.addAll(
         [
           Row(

+ 37 - 2
lib/ui/gallery_app_bar_widget.dart

@@ -3,7 +3,6 @@ import 'dart:io';
 
 import 'package:flutter/cupertino.dart';
 import 'package:flutter/material.dart';
-import 'package:fluttertoast/fluttertoast.dart';
 import 'package:logging/logging.dart';
 import 'package:page_transition/page_transition.dart';
 import 'package:photos/core/configuration.dart';
@@ -19,13 +18,13 @@ import 'package:photos/ui/share_collection_widget.dart';
 import 'package:photos/utils/archive_util.dart';
 import 'package:photos/utils/delete_file_util.dart';
 import 'package:photos/utils/dialog_util.dart';
-import 'package:photos/services/file_magic_service.dart';
 import 'package:photos/utils/share_util.dart';
 import 'package:photos/utils/toast_util.dart';
 
 enum GalleryAppBarType {
   homepage,
   archive,
+  trash,
   local_folder,
   // indicator for gallery view of collections shared with the user
   shared_collection,
@@ -257,6 +256,10 @@ class _GalleryAppBarWidgetState extends State<GalleryAppBarWidget> {
 
   List<Widget> _getActions(BuildContext context) {
     List<Widget> actions = <Widget>[];
+    if (widget.type == GalleryAppBarType.trash) {
+      _addTrashAction(actions);
+      return actions;
+    }
     // skip add button for incoming collection till this feature is implemented
     if (Configuration.instance.hasConfiguredAccount() &&
         widget.type != GalleryAppBarType.shared_collection) {
@@ -382,6 +385,38 @@ class _GalleryAppBarWidgetState extends State<GalleryAppBarWidget> {
     return actions;
   }
 
+  void _addTrashAction(List<Widget> actions) {
+    actions.add(Tooltip(
+      message: "restore",
+      child: IconButton(
+        icon: Icon(Icons.restore_outlined),
+        onPressed: () {
+          Navigator.push(
+              context,
+              PageTransition(
+                  type: PageTransitionType.bottomToTop,
+                  child: CreateCollectionPage(
+                    widget.selectedFiles,
+                    null,
+                    actionType: CollectionActionType.restoreFiles,
+                  )));
+        },
+      ),
+    ));
+    actions.add(Tooltip(
+      message: "delete permanently",
+      child: IconButton(
+        icon: Icon(Icons.delete_forever_outlined),
+        onPressed: () async {
+          if (await deleteFromTrash(
+              context, widget.selectedFiles.files.toList())) {
+            _clearSelectedFiles();
+          }
+        },
+      ),
+    ));
+  }
+
   Future<void> _handleVisibilityChangeRequest(
       BuildContext context, int newVisibility) async {
     try {

+ 22 - 4
lib/ui/thumbnail_widget.dart

@@ -1,5 +1,7 @@
 import 'package:flutter/material.dart';
+import 'dart:math';
 import 'package:logging/logging.dart';
+import 'package:flutter/widgets.dart';
 import 'package:photos/core/cache/thumbnail_cache.dart';
 import 'package:photos/core/constants.dart';
 import 'package:photos/core/errors.dart';
@@ -8,7 +10,9 @@ import 'package:photos/db/files_db.dart';
 import 'package:photos/events/local_photos_updated_event.dart';
 import 'package:photos/models/file.dart';
 import 'package:photos/models/file_type.dart';
+import 'package:photos/models/trash_file.dart';
 import 'package:photos/ui/common_elements.dart';
+import 'package:photos/utils/date_time_util.dart';
 import 'package:photos/utils/file_util.dart';
 import 'package:photos/utils/thumbnail_util.dart';
 
@@ -29,10 +33,22 @@ class ThumbnailWidget extends StatefulWidget {
     this.diskLoadDeferDuration,
     this.serverLoadDeferDuration,
   }) : super(key: key ?? Key(file.tag()));
+
   @override
   _ThumbnailWidgetState createState() => _ThumbnailWidgetState();
 }
 
+Widget getFileInfoContainer(File file) {
+  if (file is TrashFile) {
+    return Container(
+      child: Text(daysLeft(file.deleteBy)),
+      alignment: Alignment.bottomCenter,
+      padding: EdgeInsets.fromLTRB(0, 0, 0, 5),
+    );
+  }
+  return emptyContainer;
+}
+
 class _ThumbnailWidgetState extends State<ThumbnailWidget> {
   static final _logger = Logger("ThumbnailWidget");
 
@@ -164,7 +180,7 @@ class _ThumbnailWidgetState extends State<ThumbnailWidget> {
         ),
         widget.shouldShowSyncStatus && widget.file.uploadedFileID == null
             ? kUnsyncedIconOverlay
-            : emptyContainer,
+            : getFileInfoContainer(widget.file),
       ],
       fit: StackFit.expand,
     );
@@ -198,9 +214,11 @@ class _ThumbnailWidgetState extends State<ThumbnailWidget> {
     getThumbnailFromLocal(widget.file).then((thumbData) async {
       if (thumbData == null) {
         if (widget.file.uploadedFileID != null) {
-          _logger.fine("Removing localID reference for " + widget.file.tag());
-          widget.file.localID = null;
-          FilesDB.instance.update(widget.file);
+          if (widget.file is! TrashFile) {
+            _logger.fine("Removing localID reference for " + widget.file.tag());
+            widget.file.localID = null;
+            FilesDB.instance.update(widget.file);
+          }
           _loadNetworkImage();
         } else {
           if (await doesLocalFileExist(widget.file) == false) {

+ 87 - 0
lib/ui/trash_page.dart

@@ -0,0 +1,87 @@
+import 'dart:io';
+
+import 'package:flutter/cupertino.dart';
+import 'package:flutter/material.dart';
+import 'package:photos/core/event_bus.dart';
+import 'package:photos/db/trash_db.dart';
+import 'package:photos/events/files_updated_event.dart';
+import 'package:photos/models/file_load_result.dart';
+import 'package:photos/models/selected_files.dart';
+
+import 'gallery.dart';
+import 'gallery_app_bar_widget.dart';
+
+class TrashPage extends StatelessWidget {
+  final String tagPrefix;
+  final GalleryAppBarType appBarType;
+  final _selectedFiles = SelectedFiles();
+
+  TrashPage(
+      {this.tagPrefix = "trash_page",
+      this.appBarType = GalleryAppBarType.trash,
+      Key key})
+      : super(key: key);
+
+  @override
+  Widget build(Object context) {
+    final gallery = Gallery(
+        asyncLoader: (creationStartTime, creationEndTime, {limit, asc}) {
+          return TrashDB.instance.getTrashedFiles(
+              creationStartTime, creationEndTime,
+              limit: limit, asc: asc);
+        },
+        reloadEvent: Bus.instance.on<FilesUpdatedEvent>().where(
+              (event) =>
+                  event.updatedFiles.firstWhere(
+                      (element) => element.uploadedFileID != null,
+                      orElse: () => null) !=
+                  null,
+            ),
+        forceReloadEvents: [
+          Bus.instance.on<FilesUpdatedEvent>().where(
+                (event) =>
+                    event.updatedFiles.firstWhere(
+                        (element) => element.uploadedFileID != null,
+                        orElse: () => null) !=
+                    null,
+              ),
+        ],
+        tagPrefix: tagPrefix,
+        selectedFiles: _selectedFiles,
+        initialFiles: null,
+        footer: _footerWidget());
+
+    return Scaffold(
+      appBar: PreferredSize(
+        preferredSize: Size.fromHeight(50.0),
+        child: GalleryAppBarWidget(
+          appBarType,
+          "trash",
+          _selectedFiles,
+        ),
+      ),
+      body: gallery,
+    );
+  }
+
+  Widget _footerWidget() {
+    return FutureBuilder<FileLoadResult>(
+        future: TrashDB.instance
+            .getTrashedFiles(0, DateTime.now().microsecondsSinceEpoch),
+        builder: (context, snapshot) {
+          if (snapshot.hasData && snapshot.data.files.isNotEmpty) {
+            return Padding(
+              padding: EdgeInsets.all(15),
+              child: Text(
+                'memories shows the number the days after which they will be permanently deleted.',
+                style: TextStyle(
+                  fontSize: 16,
+                ),
+              ),
+            );
+          } else {
+            return Container();
+          }
+        });
+  }
+}

+ 7 - 1
lib/utils/date_time_util.dart

@@ -1,5 +1,4 @@
 import 'package:flutter/material.dart';
-
 Map<int, String> _months = {
   1: "Jan",
   2: "Feb",
@@ -104,6 +103,13 @@ String getFormattedDate(DateTime dateTime) {
       dateTime.year.toString();
 }
 
+String daysLeft(int futureTime) {
+  int daysLeft = ((futureTime - DateTime.now().microsecondsSinceEpoch) /
+          Duration.microsecondsPerDay)
+      .ceil();
+  return '$daysLeft day' + (daysLeft <= 1 ? "" : "s");
+}
+
 String formatDuration(Duration position) {
   final ms = position.inMilliseconds;
 

+ 38 - 9
lib/utils/delete_file_util.dart

@@ -16,8 +16,12 @@ import 'package:photos/events/collection_updated_event.dart';
 import 'package:photos/events/files_updated_event.dart';
 import 'package:photos/events/local_photos_updated_event.dart';
 import 'package:photos/models/file.dart';
+import 'package:photos/models/trash_file.dart';
+import 'package:photos/models/trash_item_request.dart';
 import 'package:photos/services/remote_sync_service.dart';
 import 'package:photos/services/sync_service.dart';
+import 'package:photos/services/trash_sync_service.dart';
+import 'package:photos/ui/common/dialogs.dart';
 import 'package:photos/ui/linear_progress_dialog.dart';
 import 'package:photos/utils/dialog_util.dart';
 import 'package:photos/utils/toast_util.dart';
@@ -55,7 +59,7 @@ Future<void> deleteFilesFromEverywhere(
   }
   deletedIDs.addAll(await _tryDeleteSharedMediaFiles(localSharedMediaIDs));
   final updatedCollectionIDs = <int>{};
-  final List<int> uploadedFileIDsToBeDeleted = [];
+  final List<TrashRequest> uploadedFilesToBeTrashed = [];
   final List<File> deletedFiles = [];
   for (final file in files) {
     if (file.localID != null) {
@@ -64,7 +68,7 @@ Future<void> deleteFilesFromEverywhere(
           alreadyDeletedIDs.contains(file.localID)) {
         deletedFiles.add(file);
         if (file.uploadedFileID != null) {
-          uploadedFileIDsToBeDeleted.add(file.uploadedFileID);
+          uploadedFilesToBeTrashed.add(TrashRequest(file.uploadedFileID, file.collectionID));
           updatedCollectionIDs.add(file.collectionID);
         } else {
           await FilesDB.instance.deleteLocalFile(file);
@@ -73,15 +77,16 @@ Future<void> deleteFilesFromEverywhere(
     } else {
       updatedCollectionIDs.add(file.collectionID);
       deletedFiles.add(file);
-      uploadedFileIDsToBeDeleted.add(file.uploadedFileID);
+      uploadedFilesToBeTrashed.add(TrashRequest(file.uploadedFileID, file.collectionID));
     }
   }
-  if (uploadedFileIDsToBeDeleted.isNotEmpty) {
+  if (uploadedFilesToBeTrashed.isNotEmpty) {
     try {
-      await SyncService.instance
-          .deleteFilesOnServer(uploadedFileIDsToBeDeleted);
-      await FilesDB.instance
-          .deleteMultipleUploadedFiles(uploadedFileIDsToBeDeleted);
+      final fileIDs = uploadedFilesToBeTrashed.map((item) => item.fileID).toList();
+      await TrashSyncService.instance.trashFilesOnServer(uploadedFilesToBeTrashed);
+      // await SyncService.instance
+      //     .deleteFilesOnServer(fileIDs);
+       await FilesDB.instance.deleteMultipleUploadedFiles(fileIDs);
     } catch (e) {
       _logger.severe(e);
       await dialog.hide();
@@ -104,7 +109,7 @@ Future<void> deleteFilesFromEverywhere(
   }
   await dialog.hide();
   showToast("deleted from everywhere");
-  if (uploadedFileIDsToBeDeleted.isNotEmpty) {
+  if (uploadedFilesToBeTrashed.isNotEmpty) {
     RemoteSyncService.instance.sync(silently: true);
   }
 }
@@ -186,6 +191,30 @@ Future<void> deleteFilesOnDeviceOnly(
   await dialog.hide();
 }
 
+Future<bool> deleteFromTrash(
+    BuildContext context, List<File> files) async {
+  final result = await showChoiceDialog(context, "delete permanently?",
+      "the files will be permanently removed from your ente account",
+      firstAction: "delete", actionType: ActionType.critical);
+  if (result != DialogUserChoice.firstChoice) {
+    return false;
+  }
+  final dialog = createProgressDialog(context, "permanently deleting...");
+  await dialog.show();
+  try {
+    await TrashSyncService.instance.deleteFromTrash(files);
+    showToast("successfully deleted");
+    await dialog.hide();
+    Bus.instance.fire(FilesUpdatedEvent(files, type: EventType.deleted));
+    return true;
+  } catch (e, s) {
+    _logger.info("failed to delete from trash", e, s);
+    await dialog.hide();
+    await showGenericErrorDialog(context);
+    return false;
+  }
+}
+
 Future<bool> deleteLocalFiles(
     BuildContext context, List<String> localIDs) async {
   final List<String> deletedIDs = [];

+ 1 - 1
lib/utils/file_uploader.dart

@@ -328,7 +328,7 @@ class FileUploader {
       final fileUploadURL = await _getUploadURL();
       String fileObjectKey = await _putFile(fileUploadURL, encryptedFile);
 
-      final metadata = await file.getMetadata(mediaUploadData.sourceFile);
+      final metadata = await file.getMetadataForUpload(mediaUploadData.sourceFile);
       final encryptedMetadataData = await CryptoUtil.encryptChaCha(
           utf8.encode(jsonEncode(metadata)), fileAttributes.key);
       final fileDecryptionHeader = Sodium.bin2base64(fileAttributes.header);

+ 114 - 0
lib/utils/trash_diff_fetcher.dart

@@ -0,0 +1,114 @@
+import 'dart:convert';
+import 'dart:math';
+
+import 'package:dio/dio.dart';
+import 'package:flutter_sodium/flutter_sodium.dart';
+import 'package:logging/logging.dart';
+import 'package:photos/core/configuration.dart';
+import 'package:photos/core/event_bus.dart';
+import 'package:photos/core/network.dart';
+import 'package:photos/events/remote_sync_event.dart';
+import 'package:photos/models/trash_file.dart';
+import 'package:photos/utils/crypto_util.dart';
+import 'package:photos/utils/file_download_util.dart';
+
+class TrashDiffFetcher {
+  final _logger = Logger("TrashDiffFetcher");
+  final _dio = Network.instance.getDio();
+
+  Future<Diff> getTrashFilesDiff(int sinceTime, int limit) async {
+    try {
+      final response = await _dio.get(
+        Configuration.instance.getHttpEndpoint() + "/trash/diff",
+        options: Options(
+            headers: {"X-Auth-Token": Configuration.instance.getToken()}),
+        queryParameters: {
+          "sinceTime": sinceTime,
+          "limit": limit,
+        },
+      );
+      int latestUpdatedAtTime = 0;
+      final trashedFiles = <TrashFile>[];
+      final deletedFiles = <TrashFile>[];
+      final restoredFiles = <TrashFile>[];
+      if (response != null) {
+        Bus.instance.fire(RemoteSyncEvent(true));
+        final diff = response.data["diff"] as List;
+        final startTime = DateTime.now();
+        for (final item in diff) {
+          final trash = TrashFile();
+          trash.createdAt = item['createdAt'];
+          trash.updateAt = item['updatedAt'];
+          latestUpdatedAtTime = max(latestUpdatedAtTime, trash.updateAt);
+          trash.deleteBy = item['deleteBy'];
+          trash.uploadedFileID = item["file"]["id"];
+          trash.collectionID = item["file"]["collectionID"];
+          trash.updationTime = item["file"]["updationTime"];
+          trash.ownerID = item["file"]["ownerID"];
+          trash.encryptedKey = item["file"]["encryptedKey"];
+          trash.keyDecryptionNonce = item["file"]["keyDecryptionNonce"];
+          trash.fileDecryptionHeader = item["file"]["file"]["decryptionHeader"];
+          trash.thumbnailDecryptionHeader =
+              item["file"]["thumbnail"]["decryptionHeader"];
+          trash.metadataDecryptionHeader =
+              item["file"]["metadata"]["decryptionHeader"];
+          final fileDecryptionKey = decryptFileKey(trash);
+          final encodedMetadata = await CryptoUtil.decryptChaCha(
+            Sodium.base642bin(item["file"]["metadata"]["encryptedData"]),
+            fileDecryptionKey,
+            Sodium.base642bin(trash.metadataDecryptionHeader),
+          );
+          Map<String, dynamic> metadata =
+              jsonDecode(utf8.decode(encodedMetadata));
+          trash.applyMetadata(metadata);
+          if (item["file"]['magicMetadata'] != null) {
+            final utfEncodedMmd = await CryptoUtil.decryptChaCha(
+                Sodium.base642bin(item["file"]['magicMetadata']['data']),
+                fileDecryptionKey,
+                Sodium.base642bin(item["file"]['magicMetadata']['header']));
+            trash.mMdEncodedJson = utf8.decode(utfEncodedMmd);
+            trash.mMdVersion = item["file"]['magicMetadata']['version'];
+          }
+          if (item["isDeleted"]) {
+            deletedFiles.add(trash);
+            continue;
+          }
+          if (item['isRestored']) {
+            restoredFiles.add(trash);
+            continue;
+          }
+          trashedFiles.add(trash);
+        }
+
+        final endTime = DateTime.now();
+        _logger.info("time for parsing " +
+            diff.length.toString() +
+            ": " +
+            Duration(
+                    microseconds: (endTime.microsecondsSinceEpoch -
+                        startTime.microsecondsSinceEpoch))
+                .inMilliseconds
+                .toString());
+        return Diff(trashedFiles, restoredFiles, deletedFiles, diff.length,
+            latestUpdatedAtTime);
+      } else {
+        Bus.instance.fire(RemoteSyncEvent(false));
+        return Diff(<TrashFile>[], <TrashFile>[], <TrashFile>[], 0, 0);
+      }
+    } catch (e, s) {
+      _logger.severe(e, s);
+      rethrow;
+    }
+  }
+}
+
+class Diff {
+  final List<TrashFile> trashedFiles;
+  final List<TrashFile> restoredFiles;
+  final List<TrashFile> deletedFiles;
+  final int fetchCount;
+  final int lastSyncedTimeStamp;
+
+  Diff(this.trashedFiles, this.restoredFiles, this.deletedFiles,
+      this.fetchCount, this.lastSyncedTimeStamp);
+}