diff --git a/lib/core/configuration.dart b/lib/core/configuration.dart index d5c1c7983..e6b828222 100644 --- a/lib/core/configuration.dart +++ b/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(); diff --git a/lib/db/collections_db.dart b/lib/db/collections_db.dart index 9f00be432..a9e8eef27 100644 --- a/lib/db/collections_db.dart +++ b/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 _dbFuture; @@ -113,6 +118,15 @@ class CollectionsDB { ]; } + static List addIsDeleted() { + return [ + ''' + ALTER TABLE $table + ADD COLUMN $columnIsDeleted INTEGER DEFAULT $_sqlBoolFalse; + ''' + ]; + } + Future> insert(List 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.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, ); } } diff --git a/lib/db/files_db.dart b/lib/db/files_db.dart index ff31788d9..25cebe1b4 100644 --- a/lib/db/files_db.dart +++ b/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 deleteUnSyncedLocalFiles(List 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 deleteFromCollection(int uploadedFileID, int collectionID) async { final db = await instance.database; return db.delete( diff --git a/lib/db/ignored_files_db.dart b/lib/db/ignored_files_db.dart new file mode 100644 index 000000000..9ea25301a --- /dev/null +++ b/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 _dbFuture; + + Future 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 _initDatabase() async { + Directory documentsDirectory = await getApplicationDocumentsDirectory(); + String path = join(documentsDirectory.path, _databaseName); + return await openDatabase( + path, + version: _databaseVersion, + onCreate: _onCreate, + ); + } + + Future clearTable() async { + final db = await instance.database; + await db.delete(tableName); + } + + Future insertMultiple(List 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 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>> getIgnoredFiles() async { + final db = await instance.database; + final rows = await db.query(tableName); + final result = >{}; + for (final row in rows) { + final ignoredFile = _getIgnoredFileFromRow(row); + result + .putIfAbsent(ignoredFile.localID, () => {}) + .add(ignoredFile.title); + } + return result; + } + + IgnoredFile _getIgnoredFileFromRow(Map row) { + return IgnoredFile(row[columnLocalID], row[columnTitle], row[columnReason]); + } + + Map _getRowForIgnoredFile(IgnoredFile ignoredFile) { + assert(ignoredFile.title != null); + assert(ignoredFile.localID != null); + final row = {}; + row[columnLocalID] = ignoredFile.localID; + row[columnTitle] = ignoredFile.title; + row[columnReason] = ignoredFile.reason; + return row; + } +} diff --git a/lib/db/trash_db.dart b/lib/db/trash_db.dart new file mode 100644 index 000000000..23d407d4a --- /dev/null +++ b/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 _dbFuture; + + Future 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 _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 clearTable() async { + final db = await instance.database; + await db.delete(tableName); + } + + Future isEmpty() async { + final db = await instance.database; + var rows = await db.query(tableName, limit: 1); + return rows == null || rows.isEmpty; + } + + Future insertMultiple(List 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 insert(TrashFile trash) async { + final db = await instance.database; + return db.insert( + tableName, + _getRowForTrash(trash), + conflictAlgorithm: ConflictAlgorithm.replace, + ); + } + + Future delete(List uploadedFileIDs) async { + final db = await instance.database; + return db.delete( + tableName, + where: '$columnUploadedFileID IN (${uploadedFileIDs.join(', ')})', + ); + } + + Future 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 _convertToFiles(List> results) { + final List trashedFiles = []; + for (final result in results) { + trashedFiles.add(_getTrashFromRow(result)); + } + return trashedFiles; + } + + TrashFile _getTrashFromRow(Map 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 _getRowForTrash(TrashFile trash) { + final row = {}; + 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; + } +} diff --git a/lib/main.dart b/lib/main.dart index 3ff4989ef..47ca6e910 100644 --- a/lib/main.dart +++ b/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 _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(); diff --git a/lib/models/file.dart b/lib/models/file.dart index 0a2b85294..6baf6a6bb 100644 --- a/lib/models/file.dart +++ b/lib/models/file.dart @@ -124,7 +124,20 @@ class File { metadataVersion = metadata["version"] ?? 0; } - Future> getMetadata(io.File sourceFile) async { + Future> 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 getMetadata() { final metadata = {}; 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; } diff --git a/lib/models/ignored_file.dart b/lib/models/ignored_file.dart new file mode 100644 index 000000000..a9c525e57 --- /dev/null +++ b/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); + } +} diff --git a/lib/models/trash_file.dart b/lib/models/trash_file.dart new file mode 100644 index 000000000..53e059a8f --- /dev/null +++ b/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; +} diff --git a/lib/models/trash_item_request.dart b/lib/models/trash_item_request.dart new file mode 100644 index 000000000..199f54114 --- /dev/null +++ b/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 json) { + return TrashRequest(json['fileID'], json['collectionID']); + } + + Map toJson() { + final Map data = {}; + data['fileID'] = fileID; + data['collectionID'] = collectionID; + return data; + } + + @override + String toString() { + return 'TrashItemRequest{fileID: $fileID, collectionID: $collectionID}'; + } +} diff --git a/lib/services/collections_service.dart b/lib/services/collections_service.dart index 8fa3ec1ea..10092f393 100644 --- a/lib/services/collections_service.dart +++ b/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> 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 = []; 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.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 = []; 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 getCollections() { - return _collectionIDToCollections.values.toList(); + // getActiveCollections returns list of collections which are not deleted yet + List getActiveCollections() { + return _collectionIDToCollections.values + .toList() + .where((element) => !element.isDeleted); } Future> 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 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 getOrCreateForPath(String path) async { if (_localCollections.containsKey(path) && _localCollections[path].owner.id == _config.getUserID()) { @@ -380,6 +420,53 @@ class CollectionsService { } } + Future restore(int toCollectionID, List files) async { + final params = {}; + 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 move( int toCollectionID, int fromCollectionID, List 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; } diff --git a/lib/services/favorites_service.dart b/lib/services/favorites_service.dart index 047c39a36..800a74ba0 100644 --- a/lib/services/favorites_service.dart +++ b/lib/services/favorites_service.dart @@ -63,7 +63,7 @@ class FavoritesService { Future _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) { diff --git a/lib/services/remote_sync_service.dart b/lib/services/remote_sync_service.dart index cdf8aa029..3fa6b8649 100644 --- a/lib/services/remote_sync_service.dart +++ b/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 _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> 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 _uploadDiff() async { final foldersToBackUp = Configuration.instance.getPathsToBackUp(); List 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."); diff --git a/lib/services/trash_sync_service.dart b/lib/services/trash_sync_service.dart new file mode 100644 index 000000000..cece9f8ad --- /dev/null +++ b/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 init() async { + _prefs = await SharedPreferences.getInstance(); + } + + Future 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 _updateIgnoredFiles(Diff diff) async { + final ignoredFiles = []; + 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 _setSyncTime(int time) async { + return _prefs.setInt(kLastTrashSyncTime, time); + } + + int _getSyncTime() { + return _prefs.getInt(kLastTrashSyncTime) ?? 0; + } + + Future trashFilesOnServer(List trashRequestItems) async { + final params = {}; + final includedFileIDs = {}; + 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 deleteFromTrash(List files) async { + final params = {}; + 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; + } + } +} diff --git a/lib/ui/collections_gallery_widget.dart b/lib/ui/collections_gallery_widget.dart index e224ac260..1f44e65a1 100644 --- a/lib/ui/collections_gallery_widget.dart +++ b/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 : 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, - ), - Padding(padding: EdgeInsets.all(6)), - Text( - "archive", - style: TextStyle( - 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, ), ), - ], - ), - 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.archive_outlined, + 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, + ), + ), + 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)), ], ), ), diff --git a/lib/ui/create_collection_page.dart b/lib/ui/create_collection_page.dart index 12f2b4b8d..6656c95ae 100644 --- a/lib/ui/create_collection_page.dart +++ b/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 { 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 { ], ), 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 { 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 { ))); } - Future _addOrMoveToCollection(int collectionID) async { - return widget.actionType == CollectionActionType.addFiles - ? _addToCollection(collectionID) - : _moveFilesToCollection(collectionID); + Future _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 _moveFilesToCollection(int toCollectionID) async { @@ -264,6 +286,28 @@ class _CreateCollectionPageState extends State { } } + Future _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 _addToCollection(int collectionID) async { final dialog = createProgressDialog(context, "uploading files to album..."); await dialog.show(); diff --git a/lib/ui/fading_app_bar.dart b/lib/ui/fading_app_bar.dart index c3d12a4d8..f32d855f7 100644 --- a/lib/ui/fading_app_bar.dart +++ b/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 { AppBar _buildAppBar() { final List 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 { fontSize: 14, ), ), - actions: widget.shouldShowActions ? actions : [], + actions: shouldShowActions ? actions : [], backgroundColor: Color(0x00000000), elevation: 0, ); diff --git a/lib/ui/fading_bottom_bar.dart b/lib/ui/fading_bottom_bar.dart index 9239f4d35..36b6f0914 100644 --- a/lib/ui/fading_bottom_bar.dart +++ b/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 { ), ), ); - 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 { 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 { ); } + void _addTrashOptions(List 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 = []; + trashedFile.add(widget.file); + if (await deleteFromTrash(context, trashedFile) == true) { + Navigator.pop(context); + } + }, + ), + ), + ), + ); + } + Future _displayInfo(File file) async { return showDialog( context: context, diff --git a/lib/ui/file_info_dialog.dart b/lib/ui/file_info_dialog.dart index 68a04406b..a6a8e48bb 100644 --- a/lib/ui/file_info_dialog.dart +++ b/lib/ui/file_info_dialog.dart @@ -137,7 +137,7 @@ class _FileInfoWidgetState extends State { 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( diff --git a/lib/ui/gallery_app_bar_widget.dart b/lib/ui/gallery_app_bar_widget.dart index 3ed5e578e..a447cda52 100644 --- a/lib/ui/gallery_app_bar_widget.dart +++ b/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 { List _getActions(BuildContext context) { List actions = []; + 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 { return actions; } + void _addTrashAction(List 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 _handleVisibilityChangeRequest( BuildContext context, int newVisibility) async { try { diff --git a/lib/ui/thumbnail_widget.dart b/lib/ui/thumbnail_widget.dart index f5f7a5f41..61f507c82 100644 --- a/lib/ui/thumbnail_widget.dart +++ b/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 { static final _logger = Logger("ThumbnailWidget"); @@ -164,7 +180,7 @@ class _ThumbnailWidgetState extends State { ), widget.shouldShowSyncStatus && widget.file.uploadedFileID == null ? kUnsyncedIconOverlay - : emptyContainer, + : getFileInfoContainer(widget.file), ], fit: StackFit.expand, ); @@ -198,9 +214,11 @@ class _ThumbnailWidgetState extends State { 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) { diff --git a/lib/ui/trash_page.dart b/lib/ui/trash_page.dart new file mode 100644 index 000000000..2fddabad3 --- /dev/null +++ b/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().where( + (event) => + event.updatedFiles.firstWhere( + (element) => element.uploadedFileID != null, + orElse: () => null) != + null, + ), + forceReloadEvents: [ + Bus.instance.on().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( + 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(); + } + }); + } +} diff --git a/lib/utils/date_time_util.dart b/lib/utils/date_time_util.dart index 0107fa4ab..d9516dddf 100644 --- a/lib/utils/date_time_util.dart +++ b/lib/utils/date_time_util.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; - Map _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; diff --git a/lib/utils/delete_file_util.dart b/lib/utils/delete_file_util.dart index 597129441..832b9f16a 100644 --- a/lib/utils/delete_file_util.dart +++ b/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 deleteFilesFromEverywhere( } deletedIDs.addAll(await _tryDeleteSharedMediaFiles(localSharedMediaIDs)); final updatedCollectionIDs = {}; - final List uploadedFileIDsToBeDeleted = []; + final List uploadedFilesToBeTrashed = []; final List deletedFiles = []; for (final file in files) { if (file.localID != null) { @@ -64,7 +68,7 @@ Future 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 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 deleteFilesFromEverywhere( } await dialog.hide(); showToast("deleted from everywhere"); - if (uploadedFileIDsToBeDeleted.isNotEmpty) { + if (uploadedFilesToBeTrashed.isNotEmpty) { RemoteSyncService.instance.sync(silently: true); } } @@ -186,6 +191,30 @@ Future deleteFilesOnDeviceOnly( await dialog.hide(); } +Future deleteFromTrash( + BuildContext context, List 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 deleteLocalFiles( BuildContext context, List localIDs) async { final List deletedIDs = []; diff --git a/lib/utils/file_uploader.dart b/lib/utils/file_uploader.dart index d399ed9b9..84374e5bd 100644 --- a/lib/utils/file_uploader.dart +++ b/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); diff --git a/lib/utils/trash_diff_fetcher.dart b/lib/utils/trash_diff_fetcher.dart new file mode 100644 index 000000000..b78a5cad7 --- /dev/null +++ b/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 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 = []; + final deletedFiles = []; + final restoredFiles = []; + 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 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([], [], [], 0, 0); + } + } catch (e, s) { + _logger.severe(e, s); + rethrow; + } + } +} + +class Diff { + final List trashedFiles; + final List restoredFiles; + final List deletedFiles; + final int fetchCount; + final int lastSyncedTimeStamp; + + Diff(this.trashedFiles, this.restoredFiles, this.deletedFiles, + this.fetchCount, this.lastSyncedTimeStamp); +}