ente/lib/services/collections_service.dart

1110 lines
37 KiB
Dart
Raw Normal View History

// @dart=2.9
2022-09-13 06:44:10 +00:00
import 'dart:async';
2020-10-10 23:47:51 +00:00
import 'dart:convert';
2022-03-21 09:51:09 +00:00
import 'dart:math';
2022-11-18 08:56:09 +00:00
import 'dart:typed_data';
2020-10-09 23:51:12 +00:00
2022-12-16 04:48:15 +00:00
import 'package:collection/collection.dart';
2020-10-09 23:51:12 +00:00
import 'package:dio/dio.dart';
2020-10-21 22:22:09 +00:00
import 'package:flutter/foundation.dart';
import 'package:flutter_sodium/flutter_sodium.dart';
import 'package:logging/logging.dart';
2020-10-09 23:51:12 +00:00
import 'package:photos/core/configuration.dart';
2022-11-18 06:05:46 +00:00
import 'package:photos/core/constants.dart';
2021-03-17 22:08:13 +00:00
import 'package:photos/core/errors.dart';
2020-10-28 12:03:28 +00:00
import 'package:photos/core/event_bus.dart';
2020-11-19 18:22:30 +00:00
import 'package:photos/core/network.dart';
import 'package:photos/db/collections_db.dart';
2022-09-13 06:44:10 +00:00
import 'package:photos/db/device_files_db.dart';
2020-10-28 15:25:32 +00:00
import 'package:photos/db/files_db.dart';
2021-10-20 13:43:11 +00:00
import 'package:photos/db/trash_db.dart';
2020-10-28 12:03:28 +00:00
import 'package:photos/events/collection_updated_event.dart';
import 'package:photos/events/files_updated_event.dart';
2021-10-20 14:14:05 +00:00
import 'package:photos/events/force_reload_home_gallery_event.dart';
2021-04-21 13:09:18 +00:00
import 'package:photos/events/local_photos_updated_event.dart';
import 'package:photos/extensions/list.dart';
import 'package:photos/extensions/stop_watch.dart';
2022-10-27 06:05:39 +00:00
import 'package:photos/models/api/collection/create_request.dart';
2020-10-09 23:51:12 +00:00
import 'package:photos/models/collection.dart';
import 'package:photos/models/collection_file_item.dart';
2022-10-19 11:39:56 +00:00
import 'package:photos/models/collection_items.dart';
import 'package:photos/models/file.dart';
2022-03-21 09:32:24 +00:00
import 'package:photos/models/magic_metadata.dart';
2021-11-15 15:35:07 +00:00
import 'package:photos/services/app_lifecycle_service.dart';
2022-03-21 09:32:24 +00:00
import 'package:photos/services/file_magic_service.dart';
2022-09-13 06:44:10 +00:00
import 'package:photos/services/local_sync_service.dart';
import 'package:photos/services/remote_sync_service.dart';
import 'package:photos/utils/crypto_util.dart';
import 'package:photos/utils/file_download_util.dart';
import 'package:shared_preferences/shared_preferences.dart';
class CollectionsService {
2022-07-04 06:02:17 +00:00
static const _collectionSyncTimeKeyPrefix = "collection_sync_time_";
static const _collectionsSyncTimeKey = "collections_sync_time_x";
2021-02-26 07:52:21 +00:00
static const int kMaximumWriteAttempts = 5;
final _logger = Logger("CollectionsService");
2020-10-09 23:51:12 +00:00
CollectionsDB _db;
2020-10-31 12:48:41 +00:00
FilesDB _filesDB;
Configuration _config;
SharedPreferences _prefs;
Future<List<File>> _cachedLatestFiles;
2022-10-14 09:54:57 +00:00
final _enteDio = Network.instance.enteDio;
final _localPathToCollectionID = <String, int>{};
2021-07-21 14:36:42 +00:00
final _collectionIDToCollections = <int, Collection>{};
final _cachedKeys = <int, Uint8List>{};
2022-12-16 04:48:15 +00:00
final _cachedUserIdToUser = <int, User>{};
2022-10-27 06:05:39 +00:00
Collection cachedDefaultHiddenCollection;
CollectionsService._privateConstructor() {
_db = CollectionsDB.instance;
2020-10-31 12:48:41 +00:00
_filesDB = FilesDB.instance;
_config = Configuration.instance;
}
2020-10-09 23:51:12 +00:00
static final CollectionsService instance =
CollectionsService._privateConstructor();
Future<void> init(SharedPreferences preferences) async {
_prefs = preferences;
final collections = await _db.getAllCollections();
2022-03-21 09:32:24 +00:00
for (final collection in collections) {
2020-10-28 15:25:32 +00:00
_cacheCollectionAttributes(collection);
}
Bus.instance.on<LocalPhotosUpdatedEvent>().listen((event) {
_cachedLatestFiles = null;
getLatestCollectionFiles();
});
Bus.instance.on<CollectionUpdatedEvent>().listen((event) {
_cachedLatestFiles = null;
getLatestCollectionFiles();
});
}
2022-10-27 06:05:39 +00:00
Configuration get config => _config;
Map<int, Collection> get collectionIDToCollections =>
_collectionIDToCollections;
FilesDB get filesDB => _filesDB;
// sync method fetches just sync the collections, not the individual files
// within the collection.
2021-11-15 15:35:07 +00:00
Future<List<Collection>> sync() async {
2021-10-20 13:43:11 +00:00
_logger.info("Syncing collections");
final EnteWatch watch = EnteWatch("syncCollection")..start();
final lastCollectionUpdationTime =
_prefs.getInt(_collectionsSyncTimeKey) ?? 0;
// Might not have synced the collection fully
final fetchedCollections =
await _fetchCollections(lastCollectionUpdationTime);
watch.log("remote fetch");
2021-07-21 14:36:42 +00:00
final updatedCollections = <Collection>[];
int maxUpdationTime = lastCollectionUpdationTime;
final ownerID = _config.getUserID();
2020-10-31 12:48:41 +00:00
for (final collection in fetchedCollections) {
if (collection.isDeleted) {
await _filesDB.deleteCollection(collection.id);
await setCollectionSyncTime(collection.id, null);
if (_collectionIDToCollections.containsKey(collection.id)) {
Bus.instance.fire(
LocalPhotosUpdatedEvent(
List<File>.empty(),
source: "syncCollectionDeleted",
),
);
}
}
// remove reference for incoming collections when unshared/deleted
if (collection.isDeleted && ownerID != collection?.owner?.id) {
await _db.deleteCollection(collection.id);
2020-10-31 12:48:41 +00:00
} else {
// keep entry for deletedCollection as collectionKey may be used during
// trash file decryption
updatedCollections.add(collection);
2020-10-31 12:48:41 +00:00
}
maxUpdationTime = collection.updationTime > maxUpdationTime
? collection.updationTime
: maxUpdationTime;
2020-10-31 12:48:41 +00:00
}
2021-02-26 07:52:21 +00:00
await _updateDB(updatedCollections);
_prefs.setInt(_collectionsSyncTimeKey, maxUpdationTime);
watch.logAndReset("till DB insertion");
2020-10-30 20:37:21 +00:00
final collections = await _db.getAllCollections();
2020-10-10 23:47:51 +00:00
for (final collection in collections) {
2020-10-28 15:25:32 +00:00
_cacheCollectionAttributes(collection);
}
watch.log("collection cache refresh");
2020-10-30 20:37:21 +00:00
if (fetchedCollections.isNotEmpty) {
Bus.instance.fire(
CollectionUpdatedEvent(
null,
List<File>.empty(),
"collections_updated",
),
);
2020-10-30 20:37:21 +00:00
}
return collections;
}
2021-07-21 14:36:42 +00:00
void clearCache() {
_localPathToCollectionID.clear();
2021-03-17 21:11:31 +00:00
_collectionIDToCollections.clear();
_cachedKeys.clear();
}
Future<List<Collection>> getCollectionsToBeSynced() async {
final collections = await _db.getAllCollections();
2021-07-21 14:36:42 +00:00
final updatedCollections = <Collection>[];
for (final c in collections) {
if (c.updationTime > getCollectionSyncTime(c.id) && !c.isDeleted) {
updatedCollections.add(c);
}
}
return updatedCollections;
}
2022-03-21 09:32:24 +00:00
Set<int> getArchivedCollections() {
return _collectionIDToCollections.values
.toList()
.where((element) => element.isArchived())
.map((e) => e.id)
.toSet();
}
Set<int> getHiddenCollections() {
return _collectionIDToCollections.values
.toList()
.where((element) => element.isHidden())
.map((e) => e.id)
.toSet();
}
Set<int> collectionsHiddenFromTimeline() {
return _collectionIDToCollections.values
.toList()
.where((element) => element.isHidden() || element.isArchived())
.map((e) => e.id)
.toSet();
}
int getCollectionSyncTime(int collectionID) {
2021-07-21 14:36:42 +00:00
return _prefs
.getInt(_collectionSyncTimeKeyPrefix + collectionID.toString()) ??
0;
}
Future<List<File>> getLatestCollectionFiles() {
2021-07-21 14:36:42 +00:00
_cachedLatestFiles ??= _filesDB.getLatestCollectionFiles();
return _cachedLatestFiles;
}
Future<void> setCollectionSyncTime(int collectionID, int time) async {
final key = _collectionSyncTimeKeyPrefix + collectionID.toString();
if (time == null) {
return _prefs.remove(key);
}
return _prefs.setInt(key, time);
}
// getActiveCollections returns list of collections which are not deleted yet
List<Collection> getActiveCollections() {
return _collectionIDToCollections.values
.toList()
2021-10-26 13:26:00 +00:00
.where((element) => !element.isDeleted)
.toList();
}
2022-12-16 04:48:15 +00:00
User getFileOwner(int userID, int collectionID) {
if (_cachedUserIdToUser.containsKey(userID)) {
return _cachedUserIdToUser[userID];
}
if (collectionID != null) {
final Collection collection = getCollectionByID(collectionID);
if (collection != null) {
if (collection.owner.id == userID) {
_cachedUserIdToUser[userID] = collection.owner;
} else {
final matchingUser = collection.getSharees().firstWhereOrNull(
(u) => u.id == userID,
);
if (matchingUser != null) {
_cachedUserIdToUser[userID] = collection.owner;
}
}
}
}
return _cachedUserIdToUser[userID] ??
User(
id: userID,
email: "unknown@unknown.com",
);
}
2022-10-19 11:39:56 +00:00
Future<List<CollectionWithThumbnail>> getCollectionsWithThumbnails({
bool includedOwnedByOthers = false,
2022-11-23 02:03:04 +00:00
// includeCollabCollections will include collections where the current user
// is added as a collaborator
bool includeCollabCollections = false,
2022-10-19 11:39:56 +00:00
}) async {
final List<CollectionWithThumbnail> collectionsWithThumbnail = [];
final usersCollection = getActiveCollections();
// remove any hidden collection to avoid accidental rendering on UI
usersCollection.removeWhere((element) => element.isHidden());
2022-10-19 11:39:56 +00:00
if (!includedOwnedByOthers) {
final userID = Configuration.instance.getUserID();
if (includeCollabCollections) {
usersCollection.removeWhere(
(c) =>
(c.owner.id != userID) &&
(c.getSharees().any((u) => (u.id ?? -1) == userID && u.isViewer)),
);
} else {
usersCollection.removeWhere((c) => c.owner.id != userID);
}
2022-10-19 11:39:56 +00:00
}
final latestCollectionFiles = await getLatestCollectionFiles();
final Map<int, File> collectionToThumbnailMap = Map.fromEntries(
latestCollectionFiles.map((e) => MapEntry(e.collectionID, e)),
);
for (final c in usersCollection) {
final File thumbnail = collectionToThumbnailMap[c.id];
collectionsWithThumbnail.add(CollectionWithThumbnail(c, thumbnail));
}
return collectionsWithThumbnail;
}
2020-11-02 14:38:59 +00:00
Future<List<User>> getSharees(int collectionID) {
2022-10-14 09:54:57 +00:00
return _enteDio.get(
"/collections/sharees",
2020-10-09 23:51:12 +00:00
queryParameters: {
2020-10-13 05:21:44 +00:00
"collectionID": collectionID,
2020-10-09 23:51:12 +00:00
},
2022-10-14 09:54:57 +00:00
).then((response) {
2020-10-13 05:21:44 +00:00
_logger.info(response.toString());
2021-07-21 14:36:42 +00:00
final sharees = <User>[];
2020-11-02 14:38:59 +00:00
for (final user in response.data["sharees"]) {
sharees.add(User.fromMap(user));
2020-10-13 06:23:45 +00:00
}
2020-11-02 14:38:59 +00:00
return sharees;
2020-10-09 23:51:12 +00:00
});
}
Future<List<User>> share(
2022-11-22 17:43:36 +00:00
int collectionID,
String email,
String publicKey,
CollectionParticipantRole role,
) async {
2020-10-13 06:23:45 +00:00
final encryptedKey = CryptoUtil.sealSync(
2022-06-11 08:23:52 +00:00
getCollectionKey(collectionID),
Sodium.base642bin(publicKey),
);
try {
final response = await _enteDio.post(
2022-10-14 09:54:57 +00:00
"/collections/share",
data: {
"collectionID": collectionID,
"email": email,
"encryptedKey": Sodium.bin2base64(encryptedKey),
2022-11-22 17:43:36 +00:00
"role": role.toStringVal()
},
);
final sharees = <User>[];
for (final user in response.data["sharees"]) {
sharees.add(User.fromMap(user));
}
_collectionIDToCollections[collectionID] =
_collectionIDToCollections[collectionID].copyWith(sharees: sharees);
unawaited(_db.insert([_collectionIDToCollections[collectionID]]));
RemoteSyncService.instance.sync(silently: true).ignore();
return sharees;
} on DioError catch (e) {
if (e.response.statusCode == 402) {
throw SharingNotPermittedForFreeAccountsError();
}
2021-07-21 14:36:42 +00:00
rethrow;
}
2020-10-31 13:17:17 +00:00
}
Future<List<User>> unshare(int collectionID, String email) async {
2021-03-21 11:21:45 +00:00
try {
final response = await _enteDio.post(
2022-10-14 09:54:57 +00:00
"/collections/unshare",
2021-03-21 11:21:45 +00:00
data: {
"collectionID": collectionID,
"email": email,
},
);
final sharees = <User>[];
for (final user in response.data["sharees"]) {
sharees.add(User.fromMap(user));
}
_collectionIDToCollections[collectionID] =
_collectionIDToCollections[collectionID].copyWith(sharees: sharees);
2022-11-06 10:36:33 +00:00
unawaited(_db.insert([_collectionIDToCollections[collectionID]]));
RemoteSyncService.instance.sync(silently: true).ignore();
return sharees;
2021-03-21 11:21:45 +00:00
} catch (e) {
_logger.severe(e);
2021-07-21 14:36:42 +00:00
rethrow;
2021-03-21 11:21:45 +00:00
}
2022-11-06 10:36:33 +00:00
RemoteSyncService.instance.sync(silently: true).ignore();
2020-10-13 06:23:45 +00:00
}
Future<void> trashCollection(
Collection collection,
bool isEmptyCollection,
) async {
2022-09-13 06:44:10 +00:00
try {
// Turn off automatic back-up for the on device folder only when the
// collection is non-empty. This is to handle the case when the existing
// files in the on-device folders where automatically uploaded in some
// other collection or from different device
if (!isEmptyCollection) {
final deviceCollections = await _filesDB.getDeviceCollections();
final Map<String, bool> deivcePathIDsToUnsync = Map.fromEntries(
deviceCollections
.where((e) => e.shouldBackup && e.collectionID == collection.id)
.map((e) => MapEntry(e.id, false)),
2022-09-13 06:44:10 +00:00
);
if (deivcePathIDsToUnsync.isNotEmpty) {
_logger.info(
'turning off backup status for folders $deivcePathIDsToUnsync',
);
await RemoteSyncService.instance
.updateDeviceFolderSyncStatus(deivcePathIDsToUnsync);
}
2022-09-13 06:44:10 +00:00
}
2022-10-14 09:54:57 +00:00
await _enteDio.delete(
"/collections/v2/${collection.id}",
2022-09-13 06:44:10 +00:00
);
2022-11-15 10:20:26 +00:00
await _handleCollectionDeletion(collection);
2022-09-13 06:44:10 +00:00
} catch (e) {
_logger.severe('failed to trash collection', e);
rethrow;
}
}
2022-11-15 10:20:26 +00:00
Future<void> _handleCollectionDeletion(Collection collection) async {
await _filesDB.deleteCollection(collection.id);
final deletedCollection = collection.copyWith(isDeleted: true);
_collectionIDToCollections[collection.id] = deletedCollection;
Bus.instance.fire(
CollectionUpdatedEvent(
collection.id,
<File>[],
"delete_collection",
type: EventType.deletedFromRemote,
),
);
sync().ignore();
unawaited(_db.insert([deletedCollection]));
unawaited(LocalSyncService.instance.syncAll());
}
Uint8List getCollectionKey(int collectionID) {
if (!_cachedKeys.containsKey(collectionID)) {
2020-10-28 15:25:32 +00:00
final collection = _collectionIDToCollections[collectionID];
if (collection == null) {
2021-10-21 11:56:02 +00:00
// 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] = _getAndCacheDecryptedKey(collection);
}
return _cachedKeys[collectionID];
}
Uint8List _getAndCacheDecryptedKey(Collection collection) {
if (_cachedKeys.containsKey(collection.id)) {
return _cachedKeys[collection.id];
}
debugPrint("Compute collection decryption key for ${collection.id}");
final encryptedKey = Sodium.base642bin(collection.encryptedKey);
Uint8List collectionKey;
if (collection.owner.id == _config.getUserID()) {
2022-10-11 01:35:09 +00:00
if (_config.getKey() == null) {
throw Exception("key can not be null");
}
collectionKey = CryptoUtil.decryptSync(
2022-06-11 08:23:52 +00:00
encryptedKey,
_config.getKey(),
Sodium.base642bin(collection.keyDecryptionNonce),
);
} else {
collectionKey = CryptoUtil.openSealSync(
2022-06-11 08:23:52 +00:00
encryptedKey,
Sodium.base642bin(_config.getKeyAttributes().publicKey),
_config.getSecretKey(),
);
}
if (collectionKey != null) {
_cachedKeys[collection.id] = collectionKey;
}
return collectionKey;
}
2021-09-23 02:26:19 +00:00
Future<void> rename(Collection collection, String newName) async {
try {
final encryptedName = CryptoUtil.encryptSync(
2022-06-11 08:23:52 +00:00
utf8.encode(newName),
getCollectionKey(collection.id),
);
2022-10-14 09:54:57 +00:00
await _enteDio.post(
"/collections/rename",
data: {
"collectionID": collection.id,
"encryptedName": Sodium.bin2base64(encryptedName.encryptedData),
"nameDecryptionNonce": Sodium.bin2base64(encryptedName.nonce)
},
);
2021-09-23 02:26:19 +00:00
// trigger sync to fetch the latest name from server
2022-11-14 04:41:40 +00:00
sync().ignore();
} catch (e, s) {
_logger.severe("failed to rename collection", e, s);
rethrow;
}
}
2022-10-11 01:35:09 +00:00
Future<void> leaveAlbum(Collection collection) async {
try {
2022-10-14 09:54:57 +00:00
await _enteDio.post(
"/collections/leave/${collection.id}",
2022-10-11 01:35:09 +00:00
);
2022-11-15 10:20:26 +00:00
await _handleCollectionDeletion(collection);
2022-10-11 01:35:09 +00:00
} catch (e, s) {
_logger.severe("failed to leave collection", e, s);
rethrow;
}
}
2022-03-21 09:32:24 +00:00
Future<void> updateMagicMetadata(
2022-06-11 08:23:52 +00:00
Collection collection,
Map<String, dynamic> newMetadataUpdate,
) async {
2022-03-21 09:32:24 +00:00
final int ownerID = Configuration.instance.getUserID();
try {
if (collection.owner.id != ownerID) {
throw AssertionError("cannot modify albums not owned by you");
}
// read the existing magic metadata and apply new updates to existing data
// current update is simple replace. This will be enhanced in the future,
// as required.
2022-08-29 14:43:31 +00:00
final Map<String, dynamic> jsonToUpdate =
2022-03-21 09:32:24 +00:00
jsonDecode(collection.mMdEncodedJson ?? '{}');
newMetadataUpdate.forEach((key, value) {
jsonToUpdate[key] = value;
});
// update the local information so that it's reflected on UI
collection.mMdEncodedJson = jsonEncode(jsonToUpdate);
collection.magicMetadata = CollectionMagicMetadata.fromJson(jsonToUpdate);
final key = getCollectionKey(collection.id);
final encryptedMMd = await CryptoUtil.encryptChaCha(
2022-06-11 08:23:52 +00:00
utf8.encode(jsonEncode(jsonToUpdate)),
key,
);
2022-03-21 09:51:09 +00:00
// for required field, the json validator on golang doesn't treat 0 as valid
// value. Instead of changing version to ptr, decided to start version with 1.
2022-08-29 14:43:31 +00:00
final int currentVersion = max(collection.mMdVersion, 1);
2022-03-21 09:32:24 +00:00
final params = UpdateMagicMetadataRequest(
id: collection.id,
magicMetadata: MetadataRequest(
2022-03-21 09:51:09 +00:00
version: currentVersion,
2022-03-21 09:32:24 +00:00
count: jsonToUpdate.length,
data: Sodium.bin2base64(encryptedMMd.encryptedData),
header: Sodium.bin2base64(encryptedMMd.header),
),
);
2022-10-14 09:54:57 +00:00
await _enteDio.put(
"/collections/magic-metadata",
2022-03-21 09:32:24 +00:00
data: params,
);
2022-03-21 09:51:09 +00:00
collection.mMdVersion = currentVersion + 1;
2022-03-21 09:53:35 +00:00
_cacheCollectionAttributes(collection);
2022-03-21 09:32:24 +00:00
// trigger sync to fetch the latest collection state from server
2022-11-14 04:41:40 +00:00
sync().ignore();
2022-03-21 09:32:24 +00:00
} on DioError catch (e) {
if (e.response != null && e.response.statusCode == 409) {
_logger.severe('collection magic data out of sync');
2022-11-14 04:41:40 +00:00
sync().ignore();
2022-03-21 09:32:24 +00:00
}
rethrow;
} catch (e, s) {
_logger.severe("failed to sync magic metadata", e, s);
rethrow;
}
}
Future<void> createShareUrl(Collection collection) async {
try {
2022-10-14 09:54:57 +00:00
final response = await _enteDio.post(
"/collections/share-url",
data: {
"collectionID": collection.id,
},
);
collection.publicURLs?.add(PublicURL.fromMap(response.data["result"]));
await _db.insert(List.from([collection]));
_cacheCollectionAttributes(collection);
Bus.instance.fire(
CollectionUpdatedEvent(collection.id, <File>[], "shareUrL"),
);
2022-01-24 10:49:47 +00:00
} on DioError catch (e) {
if (e.response.statusCode == 402) {
throw SharingNotPermittedForFreeAccountsError();
}
rethrow;
} catch (e, s) {
_logger.severe("failed to rename collection", e, s);
rethrow;
}
}
Future<void> updateShareUrl(
2022-06-11 08:23:52 +00:00
Collection collection,
Map<String, dynamic> prop,
) async {
prop.putIfAbsent('collectionID', () => collection.id);
try {
2022-10-14 09:54:57 +00:00
final response = await _enteDio.put(
"/collections/share-url",
data: json.encode(prop),
);
// remove existing url information
collection.publicURLs?.clear();
collection.publicURLs?.add(PublicURL.fromMap(response.data["result"]));
await _db.insert(List.from([collection]));
_cacheCollectionAttributes(collection);
Bus.instance
.fire(CollectionUpdatedEvent(collection.id, <File>[], "updateUrl"));
} on DioError catch (e) {
if (e.response.statusCode == 402) {
throw SharingNotPermittedForFreeAccountsError();
}
rethrow;
} catch (e, s) {
_logger.severe("failed to rename collection", e, s);
rethrow;
}
}
Future<void> disableShareUrl(Collection collection) async {
try {
2022-10-14 09:54:57 +00:00
await _enteDio.delete(
"/collections/share-url/" + collection.id.toString(),
);
collection.publicURLs.clear();
await _db.insert(List.from([collection]));
_cacheCollectionAttributes(collection);
Bus.instance.fire(
CollectionUpdatedEvent(
collection.id,
<File>[],
"disableShareUrl",
),
);
} on DioError catch (e) {
_logger.info(e);
rethrow;
}
}
2021-11-15 15:35:07 +00:00
Future<List<Collection>> _fetchCollections(int sinceTime) async {
2021-03-17 22:08:13 +00:00
try {
2022-10-14 09:54:57 +00:00
final response = await _enteDio.get(
"/collections",
2021-03-17 22:08:13 +00:00
queryParameters: {
"sinceTime": sinceTime,
2021-11-15 15:35:07 +00:00
"source": AppLifecycleService.instance.isForeground ? "fg" : "bg",
2021-03-17 22:08:13 +00:00
},
);
final List<Collection> collections = [];
if (response != null) {
final c = response.data["collections"];
2022-03-21 09:32:24 +00:00
for (final collectionData in c) {
final collection = Collection.fromMap(collectionData);
if (collectionData['magicMetadata'] != null) {
final decryptionKey = _getAndCacheDecryptedKey(collection);
2022-03-21 09:32:24 +00:00
final utfEncodedMmd = await CryptoUtil.decryptChaCha(
2022-06-11 08:23:52 +00:00
Sodium.base642bin(collectionData['magicMetadata']['data']),
decryptionKey,
Sodium.base642bin(collectionData['magicMetadata']['header']),
);
2022-03-21 09:32:24 +00:00
collection.mMdEncodedJson = utf8.decode(utfEncodedMmd);
collection.mMdVersion = collectionData['magicMetadata']['version'];
collection.magicMetadata = CollectionMagicMetadata.fromEncodedJson(
2022-06-11 08:23:52 +00:00
collection.mMdEncodedJson,
);
2022-03-21 09:32:24 +00:00
}
collections.add(collection);
}
}
return collections;
2021-03-17 22:08:13 +00:00
} catch (e) {
if (e is DioError && e.response?.statusCode == 401) {
throw UnauthorizedError();
}
2021-07-21 14:36:42 +00:00
rethrow;
2021-03-17 22:08:13 +00:00
}
}
2020-10-10 23:47:51 +00:00
2020-10-28 15:25:32 +00:00
Collection getCollectionByID(int collectionID) {
return _collectionIDToCollections[collectionID];
}
2020-10-28 12:03:28 +00:00
Future<Collection> createAlbum(String albumName) async {
final key = CryptoUtil.generateKey();
final encryptedKeyData = CryptoUtil.encryptSync(key, _config.getKey());
final encryptedName = CryptoUtil.encryptSync(utf8.encode(albumName), key);
2022-06-11 08:23:52 +00:00
final collection = await createAndCacheCollection(
Collection(
null,
null,
Sodium.bin2base64(encryptedKeyData.encryptedData),
Sodium.bin2base64(encryptedKeyData.nonce),
null,
Sodium.bin2base64(encryptedName.encryptedData),
Sodium.bin2base64(encryptedName.nonce),
CollectionType.album,
CollectionAttributes(),
null,
null,
null,
),
);
2020-10-28 12:03:28 +00:00
return collection;
}
Future<Collection> fetchCollectionByID(int collectionID) async {
try {
_logger.fine('fetching collectionByID $collectionID');
2022-10-14 09:54:57 +00:00
final response = await _enteDio.get(
"/collections/$collectionID",
);
assert(response != null && response.data != null);
2022-03-21 09:32:24 +00:00
final collectionData = response.data["collection"];
final collection = Collection.fromMap(collectionData);
if (collectionData['magicMetadata'] != null) {
final decryptionKey = _getAndCacheDecryptedKey(collection);
2022-03-21 09:32:24 +00:00
final utfEncodedMmd = await CryptoUtil.decryptChaCha(
2022-06-11 08:23:52 +00:00
Sodium.base642bin(collectionData['magicMetadata']['data']),
decryptionKey,
Sodium.base642bin(collectionData['magicMetadata']['header']),
);
2022-03-21 09:32:24 +00:00
collection.mMdEncodedJson = utf8.decode(utfEncodedMmd);
collection.mMdVersion = collectionData['magicMetadata']['version'];
collection.magicMetadata =
CollectionMagicMetadata.fromEncodedJson(collection.mMdEncodedJson);
}
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;
}
}
2020-10-10 23:47:51 +00:00
Future<Collection> getOrCreateForPath(String path) async {
if (_localPathToCollectionID.containsKey(path)) {
final Collection cachedCollection =
_collectionIDToCollections[_localPathToCollectionID[path]];
if (cachedCollection != null &&
!cachedCollection.isDeleted &&
cachedCollection.owner.id == _config.getUserID()) {
return cachedCollection;
}
2020-10-10 23:47:51 +00:00
}
final key = CryptoUtil.generateKey();
final encryptedKeyData = CryptoUtil.encryptSync(key, _config.getKey());
final encryptedPath = CryptoUtil.encryptSync(utf8.encode(path), key);
2022-06-11 08:23:52 +00:00
final collection = await createAndCacheCollection(
Collection(
null,
null,
Sodium.bin2base64(encryptedKeyData.encryptedData),
Sodium.bin2base64(encryptedKeyData.nonce),
null,
Sodium.bin2base64(encryptedPath.encryptedData),
Sodium.bin2base64(encryptedPath.nonce),
CollectionType.folder,
CollectionAttributes(
encryptedPath: Sodium.bin2base64(encryptedPath.encryptedData),
pathDecryptionNonce: Sodium.bin2base64(encryptedPath.nonce),
version: 1,
),
null,
null,
null,
),
2022-06-11 08:23:52 +00:00
);
return collection;
2020-10-10 23:47:51 +00:00
}
Future<void> addToCollection(int collectionID, List<File> files) async {
2021-09-24 06:05:13 +00:00
final containsUploadedFile = files.firstWhere(
2022-06-11 08:23:52 +00:00
(element) => element.uploadedFileID != null,
orElse: () => null,
) !=
null;
2021-09-24 06:05:13 +00:00
if (containsUploadedFile) {
final existingFileIDsInCollection =
await FilesDB.instance.getUploadedFileIDs(collectionID);
2022-06-11 08:23:52 +00:00
files.removeWhere(
(element) =>
element.uploadedFileID != null &&
existingFileIDsInCollection.contains(element.uploadedFileID),
);
}
if (files.isEmpty || !containsUploadedFile) {
_logger.info("nothing to add to the collection");
return;
}
2021-07-21 14:36:42 +00:00
final params = <String, dynamic>{};
2020-10-21 22:22:09 +00:00
params["collectionID"] = collectionID;
2022-11-18 06:05:46 +00:00
final batchedFiles = files.chunks(batchSize);
for (final batch in batchedFiles) {
2022-11-18 07:22:34 +00:00
params["files"] = [];
for (final file in batch) {
final key = decryptFileKey(file);
file.generatedID =
null; // So that a new entry is created in the FilesDB
file.collectionID = collectionID;
final encryptedKeyData =
CryptoUtil.encryptSync(key, getCollectionKey(collectionID));
file.encryptedKey = Sodium.bin2base64(encryptedKeyData.encryptedData);
file.keyDecryptionNonce = Sodium.bin2base64(encryptedKeyData.nonce);
params["files"].add(
CollectionFileItem(
file.uploadedFileID,
file.encryptedKey,
file.keyDecryptionNonce,
).toMap(),
);
2020-10-21 22:22:09 +00:00
}
try {
await _enteDio.post(
"/collections/add-files",
data: params,
);
await _filesDB.insertMultiple(batch);
Bus.instance.fire(CollectionUpdatedEvent(collectionID, batch, "addTo"));
} catch (e) {
rethrow;
}
}
}
Future<File> linkLocalFileToExistingUploadedFileInAnotherCollection(
2022-08-29 05:08:18 +00:00
int destCollectionID, {
@required File localFileToUpload,
@required File existingUploadedFile,
}) async {
final params = <String, dynamic>{};
params["collectionID"] = destCollectionID;
params["files"] = [];
2022-08-29 05:08:18 +00:00
final int uploadedFileID = existingUploadedFile.uploadedFileID;
2022-08-29 05:08:18 +00:00
// encrypt the fileKey with destination collection's key
final fileKey = decryptFileKey(existingUploadedFile);
final encryptedKeyData =
2022-08-29 05:08:18 +00:00
CryptoUtil.encryptSync(fileKey, getCollectionKey(destCollectionID));
localFileToUpload.encryptedKey =
Sodium.bin2base64(encryptedKeyData.encryptedData);
localFileToUpload.keyDecryptionNonce =
Sodium.bin2base64(encryptedKeyData.nonce);
params["files"].add(
CollectionFileItem(
2022-08-29 05:08:18 +00:00
uploadedFileID,
localFileToUpload.encryptedKey,
localFileToUpload.keyDecryptionNonce,
).toMap(),
);
try {
2022-10-14 09:54:57 +00:00
await _enteDio.post(
"/collections/add-files",
data: params,
);
2022-08-29 05:08:18 +00:00
localFileToUpload.collectionID = destCollectionID;
localFileToUpload.uploadedFileID = uploadedFileID;
await _filesDB.insertMultiple([localFileToUpload]);
return localFileToUpload;
} catch (e) {
rethrow;
}
}
Future<void> restore(int toCollectionID, List<File> files) async {
final params = <String, dynamic>{};
params["collectionID"] = toCollectionID;
2021-10-21 11:56:02 +00:00
final toCollectionKey = getCollectionKey(toCollectionID);
2022-11-18 06:05:46 +00:00
final batchedFiles = files.chunks(batchSize);
for (final batch in batchedFiles) {
2022-11-18 07:22:34 +00:00
params["files"] = [];
for (final file in batch) {
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 _enteDio.post(
"/collections/restore-files",
data: params,
);
await _filesDB.insertMultiple(batch);
await TrashDB.instance
.delete(batch.map((e) => e.uploadedFileID).toList());
Bus.instance.fire(
CollectionUpdatedEvent(toCollectionID, batch, "restore"),
);
Bus.instance.fire(FilesUpdatedEvent(batch, source: "restore"));
// 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 = batch
.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("restoredFromTrash"));
} catch (e, s) {
_logger.severe("failed to restore files", e, s);
rethrow;
}
}
}
2021-09-13 08:47:57 +00:00
Future<void> move(
2022-06-11 08:23:52 +00:00
int toCollectionID,
int fromCollectionID,
List<File> files,
) async {
_validateMoveRequest(toCollectionID, fromCollectionID, files);
2022-01-09 09:35:06 +00:00
files.removeWhere((element) => element.uploadedFileID == null);
if (files.isEmpty) {
_logger.info("nothing to move to collection");
return;
}
2021-09-12 05:08:30 +00:00
final params = <String, dynamic>{};
params["toCollectionID"] = toCollectionID;
params["fromCollectionID"] = fromCollectionID;
2022-11-18 06:05:46 +00:00
final batchedFiles = files.chunks(batchSize);
for (final batch in batchedFiles) {
2022-11-18 07:22:34 +00:00
params["files"] = [];
for (final file in batch) {
final fileKey = decryptFileKey(file);
file.generatedID =
null; // So that a new entry is created in the FilesDB
file.collectionID = toCollectionID;
final encryptedKeyData =
CryptoUtil.encryptSync(fileKey, getCollectionKey(toCollectionID));
file.encryptedKey = Sodium.bin2base64(encryptedKeyData.encryptedData);
file.keyDecryptionNonce = Sodium.bin2base64(encryptedKeyData.nonce);
params["files"].add(
CollectionFileItem(
file.uploadedFileID,
file.encryptedKey,
file.keyDecryptionNonce,
).toMap(),
);
}
await _enteDio.post(
"/collections/move-files",
data: params,
2022-06-11 08:23:52 +00:00
);
2021-09-12 05:08:30 +00:00
}
// remove files from old collection
await _filesDB.removeFromCollection(
2022-06-11 08:23:52 +00:00
fromCollectionID,
files.map((e) => e.uploadedFileID).toList(),
);
Bus.instance.fire(
CollectionUpdatedEvent(
fromCollectionID,
files,
"moveFrom",
2022-06-11 08:23:52 +00:00
type: EventType.deletedFromRemote,
),
);
// insert new files in the toCollection which are not part of the toCollection
final existingUploadedIDs =
await FilesDB.instance.getUploadedFileIDs(toCollectionID);
files.removeWhere(
2022-06-11 08:23:52 +00:00
(element) => existingUploadedIDs.contains(element.uploadedFileID),
);
await _filesDB.insertMultiple(files);
Bus.instance.fire(
CollectionUpdatedEvent(toCollectionID, files, "moveTo"),
);
2021-09-12 05:08:30 +00:00
}
void _validateMoveRequest(
2022-06-11 08:23:52 +00:00
int toCollectionID,
int fromCollectionID,
List<File> files,
) {
if (toCollectionID == fromCollectionID) {
2022-11-02 10:25:32 +00:00
throw AssertionError("Can't move to same album");
}
for (final file in files) {
if (file.uploadedFileID == null) {
2022-11-02 10:25:32 +00:00
throw AssertionError("Can only move uploaded memories");
}
if (file.collectionID != fromCollectionID) {
2022-11-02 10:25:32 +00:00
throw AssertionError("All memories should belong to the same album");
}
if (file.ownerID != Configuration.instance.getUserID()) {
2022-11-02 10:25:32 +00:00
throw AssertionError("Can only move memories uploaded by you");
}
}
}
2020-10-28 15:25:32 +00:00
Future<void> removeFromCollection(int collectionID, List<File> files) async {
2021-07-21 14:36:42 +00:00
final params = <String, dynamic>{};
params["collectionID"] = collectionID;
2022-11-18 06:05:46 +00:00
final batchedFiles = files.chunks(batchSize);
for (final batch in batchedFiles) {
2022-11-18 07:22:34 +00:00
params["fileIDs"] = <int>[];
for (final file in batch) {
params["fileIDs"].add(file.uploadedFileID);
}
await _enteDio.post(
"/collections/v2/remove-files",
data: params,
);
await _filesDB.removeFromCollection(collectionID, params["fileIDs"]);
Bus.instance
.fire(CollectionUpdatedEvent(collectionID, batch, "removeFrom"));
Bus.instance.fire(LocalPhotosUpdatedEvent(batch, source: "removeFrom"));
}
2022-11-06 10:36:33 +00:00
RemoteSyncService.instance.sync(silently: true).ignore();
}
2022-10-27 06:05:39 +00:00
Future<Collection> createAndCacheCollection(
Collection collection, {
CreateRequest createRequest,
}) async {
final dynamic payload =
createRequest != null ? createRequest.toJson() : collection.toMap();
2022-10-14 09:54:57 +00:00
return _enteDio
2020-10-10 23:47:51 +00:00
.post(
2022-10-14 09:54:57 +00:00
"/collections",
2022-10-27 06:05:39 +00:00
data: payload,
2020-10-10 23:47:51 +00:00
)
.then((response) {
final collection = Collection.fromMap(response.data["collection"]);
return _cacheCollectionAttributes(collection);
2020-10-10 23:47:51 +00:00
});
}
Collection _cacheCollectionAttributes(Collection collection) {
final collectionWithDecryptedName =
_getCollectionWithDecryptedName(collection);
if (collection.attributes.encryptedPath != null &&
!collection.isDeleted &&
collection.owner.id == _config.getUserID()) {
_localPathToCollectionID[decryptCollectionPath(collection)] =
collection.id;
}
_collectionIDToCollections[collection.id] = collectionWithDecryptedName;
return collectionWithDecryptedName;
}
String decryptCollectionPath(Collection collection) {
final key = collection.attributes.version == 1
? getCollectionKey(collection.id)
: _config.getKey();
2022-06-11 08:23:52 +00:00
return utf8.decode(
CryptoUtil.decryptSync(
Sodium.base642bin(collection.attributes.encryptedPath),
key,
2022-06-11 08:23:52 +00:00
Sodium.base642bin(collection.attributes.pathDecryptionNonce),
),
);
}
bool hasSyncedCollections() {
return _prefs.containsKey(_collectionsSyncTimeKey);
}
2021-01-24 18:59:39 +00:00
Collection _getCollectionWithDecryptedName(Collection collection) {
if (collection.encryptedName != null &&
collection.encryptedName.isNotEmpty) {
2021-07-21 14:36:42 +00:00
String name;
2021-02-15 18:34:15 +00:00
try {
final result = CryptoUtil.decryptSync(
2022-06-11 08:23:52 +00:00
Sodium.base642bin(collection.encryptedName),
_getAndCacheDecryptedKey(collection),
2022-06-11 08:23:52 +00:00
Sodium.base642bin(collection.nameDecryptionNonce),
);
name = utf8.decode(result);
} catch (e, s) {
2022-05-17 17:25:04 +00:00
_logger.severe(
2022-06-11 08:23:52 +00:00
"failed to decrypt collection name: ${collection.id}",
e,
s,
);
2021-02-15 18:34:15 +00:00
name = "Unknown Album";
}
return collection.copyWith(name: name);
} else {
return collection;
}
}
2021-02-26 07:52:21 +00:00
Future _updateDB(List<Collection> collections, {int attempt = 1}) async {
if (collections.isEmpty) {
2022-11-14 05:28:19 +00:00
return;
}
2021-02-26 07:52:21 +00:00
try {
await _db.insert(collections);
} catch (e) {
if (attempt < kMaximumWriteAttempts) {
2021-10-20 11:43:46 +00:00
return _updateDB(collections, attempt: ++attempt);
2021-02-26 07:52:21 +00:00
} else {
2021-07-21 14:36:42 +00:00
rethrow;
2021-02-26 07:52:21 +00:00
}
}
}
}
2020-10-21 22:22:09 +00:00
class SharingNotPermittedForFreeAccountsError extends Error {}