Merge pull request #100 from ente-io/trash_api

Trash
This commit is contained in:
Neeraj Gupta 2021-10-22 16:59:02 +05:30 committed by GitHub
commit 99102d882d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
26 changed files with 1257 additions and 99 deletions

View file

@ -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();

View file

@ -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,
);
}
}

View file

@ -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(

View file

@ -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
lib/db/trash_db.dart Normal file
View file

@ -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;
}
}

View file

@ -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();

View file

@ -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;
}
if (duration != null) {
metadata["duration"] = duration;
}
}
hash = Sodium.bin2base64(await CryptoUtil.getHash(sourceFile));
if (hash != null) {
metadata["hash"] = hash;
}
if (metadataVersion != null) {
metadata["version"] = metadataVersion;
}
return metadata;
}

View file

@ -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);
}
}

View file

@ -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;
}

View file

@ -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}';
}
}

View file

@ -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;
}

View file

@ -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) {

View file

@ -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.");

View file

@ -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;
}
}
}

View file

@ -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,6 +182,11 @@ class _CollectionsGalleryWidgetState extends State<CollectionsGalleryWidget>
: nothingToSeeHere,
Divider(),
Padding(padding: EdgeInsets.all(8)),
Row(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
OutlinedButton(
style: OutlinedButton.styleFrom(
shape: RoundedRectangleBorder(
@ -216,7 +222,45 @@ class _CollectionsGalleryWidgetState extends State<CollectionsGalleryWidget>
ArchivePage(),
);
}),
Padding(padding: EdgeInsets.fromLTRB(12, 12, 12, 36)),
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)),
],
),
),

View file

@ -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)) {
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();

View file

@ -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,
);

View file

@ -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,8 +105,7 @@ class FadingBottomBarState extends State<FadingBottomBar> {
child: Padding(
padding: const EdgeInsets.only(top: 12, bottom: 12),
child: IconButton(
icon: Icon(
Platform.isAndroid
icon: Icon(Platform.isAndroid
? (isArchived
? Icons.unarchive_outlined
: Icons.archive_outlined)
@ -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,

View file

@ -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(

View file

@ -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 {

View file

@ -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) {
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
lib/ui/trash_page.dart Normal file
View file

@ -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();
}
});
}
}

View file

@ -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;

View file

@ -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 = [];

View file

@ -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);

View file

@ -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);
}