commit
99102d882d
|
@ -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();
|
||||
|
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,9 +6,9 @@ import 'package:path_provider/path_provider.dart';
|
|||
import 'package:photos/models/backup_status.dart';
|
||||
import 'package:photos/models/file.dart';
|
||||
import 'package:photos/models/file_load_result.dart';
|
||||
import 'package:photos/models/magic_metadata.dart';
|
||||
import 'package:photos/models/file_type.dart';
|
||||
import 'package:photos/models/location.dart';
|
||||
import 'package:photos/models/magic_metadata.dart';
|
||||
import 'package:sqflite/sqflite.dart';
|
||||
import 'package:sqflite_migration/sqflite_migration.dart';
|
||||
|
||||
|
@ -771,6 +771,20 @@ class FilesDB {
|
|||
return _convertToFiles(results);
|
||||
}
|
||||
|
||||
Future<int> deleteUnSyncedLocalFiles(List<String> localIDs) async {
|
||||
String inParam = "";
|
||||
for (final localID in localIDs) {
|
||||
inParam += "'" + localID + "',";
|
||||
}
|
||||
inParam = inParam.substring(0, inParam.length - 1);
|
||||
final db = await instance.database;
|
||||
return db.delete(
|
||||
table,
|
||||
where:
|
||||
'($columnUploadedFileID is NULL OR $columnUploadedFileID = -1 ) AND $columnLocalID IN ($inParam)',
|
||||
);
|
||||
}
|
||||
|
||||
Future<int> deleteFromCollection(int uploadedFileID, int collectionID) async {
|
||||
final db = await instance.database;
|
||||
return db.delete(
|
||||
|
|
133
lib/db/ignored_files_db.dart
Normal file
133
lib/db/ignored_files_db.dart
Normal 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
221
lib/db/trash_db.dart
Normal 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;
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
24
lib/models/ignored_file.dart
Normal file
24
lib/models/ignored_file.dart
Normal 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);
|
||||
}
|
||||
}
|
15
lib/models/trash_file.dart
Normal file
15
lib/models/trash_file.dart
Normal 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;
|
||||
}
|
24
lib/models/trash_item_request.dart
Normal file
24
lib/models/trash_item_request.dart
Normal 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}';
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -7,6 +7,7 @@ import 'package:photos/core/configuration.dart';
|
|||
import 'package:photos/core/errors.dart';
|
||||
import 'package:photos/core/event_bus.dart';
|
||||
import 'package:photos/db/files_db.dart';
|
||||
import 'package:photos/db/ignored_files_db.dart';
|
||||
import 'package:photos/events/collection_updated_event.dart';
|
||||
import 'package:photos/events/files_updated_event.dart';
|
||||
import 'package:photos/events/local_photos_updated_event.dart';
|
||||
|
@ -15,6 +16,7 @@ import 'package:photos/models/file.dart';
|
|||
import 'package:photos/models/file_type.dart';
|
||||
import 'package:photos/services/collections_service.dart';
|
||||
import 'package:photos/services/local_sync_service.dart';
|
||||
import 'package:photos/services/trash_sync_service.dart';
|
||||
import 'package:photos/utils/diff_fetcher.dart';
|
||||
import 'package:photos/utils/file_uploader.dart';
|
||||
import 'package:photos/utils/file_util.dart';
|
||||
|
@ -60,7 +62,12 @@ class RemoteSyncService {
|
|||
if (!_hasSyncedArchive()) {
|
||||
await _markArchiveAsSynced();
|
||||
}
|
||||
|
||||
// sync trash but consume error during initial launch.
|
||||
// this is to ensure that we don't pause upload due to any error during
|
||||
// the trash sync. Impact: We may end up re-uploading a file which was
|
||||
// recently trashed.
|
||||
await TrashSyncService.instance.syncTrash()
|
||||
.onError((e, s) => _logger.severe('trash sync failed', e, s));
|
||||
bool hasUploadedFiles = await _uploadDiff();
|
||||
if (hasUploadedFiles) {
|
||||
sync(silently: true);
|
||||
|
@ -82,7 +89,7 @@ class RemoteSyncService {
|
|||
}
|
||||
|
||||
Future<void> _resyncAllCollectionsSinceTime(int sinceTime) async {
|
||||
final collections = _collectionsService.getCollections();
|
||||
final collections = _collectionsService.getActiveCollections();
|
||||
for (final c in collections) {
|
||||
await _syncCollectionDiff(c.id,
|
||||
min(_collectionsService.getCollectionSyncTime(c.id), sinceTime));
|
||||
|
@ -119,6 +126,23 @@ class RemoteSyncService {
|
|||
}
|
||||
}
|
||||
|
||||
bool _shouldIgnoreFileUpload(
|
||||
Map<String, Set<String>> ignoredFilesMap, File file) {
|
||||
if (file.localID == null || file.localID.isEmpty) {
|
||||
return false;
|
||||
}
|
||||
if (!ignoredFilesMap.containsKey(file.localID)) {
|
||||
return false;
|
||||
}
|
||||
// only compare title in Android because title may be missing in IOS
|
||||
// and iOS anyways use uuid for localIDs of file, so collision should be
|
||||
// rare.
|
||||
if (Platform.isAndroid) {
|
||||
return ignoredFilesMap[file.localID].contains(file.title ?? '');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<bool> _uploadDiff() async {
|
||||
final foldersToBackUp = Configuration.instance.getPathsToBackUp();
|
||||
List<File> filesToBeUploaded;
|
||||
|
@ -133,6 +157,16 @@ class RemoteSyncService {
|
|||
filesToBeUploaded
|
||||
.removeWhere((element) => element.fileType == FileType.video);
|
||||
}
|
||||
if (filesToBeUploaded.isNotEmpty) {
|
||||
final ignoredFilesMap = await IgnoredFilesDB.instance.getIgnoredFiles();
|
||||
final int prevCount = filesToBeUploaded.length;
|
||||
filesToBeUploaded.removeWhere(
|
||||
(file) => _shouldIgnoreFileUpload(ignoredFilesMap, file));
|
||||
if (prevCount != filesToBeUploaded.length) {
|
||||
_logger.info((prevCount - filesToBeUploaded.length).toString() +
|
||||
" files were ignored for upload");
|
||||
}
|
||||
}
|
||||
_logger.info(
|
||||
filesToBeUploaded.length.toString() + " new files to be uploaded.");
|
||||
|
||||
|
|
133
lib/services/trash_sync_service.dart
Normal file
133
lib/services/trash_sync_service.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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,
|
||||
);
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
87
lib/ui/trash_page.dart
Normal 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();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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 = [];
|
||||
|
|
|
@ -328,7 +328,7 @@ class FileUploader {
|
|||
final fileUploadURL = await _getUploadURL();
|
||||
String fileObjectKey = await _putFile(fileUploadURL, encryptedFile);
|
||||
|
||||
final metadata = await file.getMetadata(mediaUploadData.sourceFile);
|
||||
final metadata = await file.getMetadataForUpload(mediaUploadData.sourceFile);
|
||||
final encryptedMetadataData = await CryptoUtil.encryptChaCha(
|
||||
utf8.encode(jsonEncode(metadata)), fileAttributes.key);
|
||||
final fileDecryptionHeader = Sodium.bin2base64(fileAttributes.header);
|
||||
|
|
114
lib/utils/trash_diff_fetcher.dart
Normal file
114
lib/utils/trash_diff_fetcher.dart
Normal 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);
|
||||
}
|
Loading…
Reference in a new issue