import 'dart:async'; import 'dart:convert'; import 'dart:math'; import 'package:collection/collection.dart'; import 'package:dio/dio.dart'; import 'package:flutter/foundation.dart'; import 'package:logging/logging.dart'; import 'package:photos/core/configuration.dart'; import 'package:photos/core/constants.dart'; import 'package:photos/core/errors.dart'; import 'package:photos/core/event_bus.dart'; import 'package:photos/core/network/network.dart'; import 'package:photos/db/collections_db.dart'; import 'package:photos/db/device_files_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/extensions/list.dart'; import 'package:photos/extensions/stop_watch.dart'; import 'package:photos/models/api/collection/create_request.dart'; import "package:photos/models/api/collection/public_url.dart"; import "package:photos/models/api/collection/user.dart"; import 'package:photos/models/collection/collection.dart'; import 'package:photos/models/collection/collection_file_item.dart'; import 'package:photos/models/collection/collection_items.dart'; import 'package:photos/models/file/file.dart'; import "package:photos/models/metadata/collection_magic.dart"; import 'package:photos/services/app_lifecycle_service.dart'; import "package:photos/services/favorites_service.dart"; import 'package:photos/services/file_magic_service.dart'; 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:photos/utils/local_settings.dart"; import 'package:shared_preferences/shared_preferences.dart'; class CollectionsService { static const _collectionSyncTimeKeyPrefix = "collection_sync_time_"; static const _collectionsSyncTimeKey = "collections_sync_time_x"; static const int kMaximumWriteAttempts = 5; final _logger = Logger("CollectionsService"); late CollectionsDB _db; late FilesDB _filesDB; late Configuration _config; late SharedPreferences _prefs; final _enteDio = NetworkClient.instance.enteDio; final _localPathToCollectionID = {}; final _collectionIDToCollections = {}; final _cachedKeys = {}; final _cachedUserIdToUser = {}; Collection? cachedDefaultHiddenCollection; Future>? _collectionIDToNewestFileTime; Collection? cachedUncategorizedCollection; final Map _coverCache = {}; final Map _countCache = {}; CollectionsService._privateConstructor() { _db = CollectionsDB.instance; _filesDB = FilesDB.instance; _config = Configuration.instance; } static final CollectionsService instance = CollectionsService._privateConstructor(); Future init(SharedPreferences preferences) async { _prefs = preferences; final collections = await _db.getAllCollections(); for (final collection in collections) { // using deprecated method because the path is stored in encrypted // format in the DB _cacheCollectionAttributes(collection); } Bus.instance.on().listen((event) { _collectionIDToNewestFileTime = null; if (event.collectionID != null) { _coverCache.removeWhere( (key, value) => key.startsWith(event.collectionID!.toString()), ); _countCache.remove(event.collectionID); } }); } Configuration get config => _config; Map get collectionIDToCollections => _collectionIDToCollections; FilesDB get filesDB => _filesDB; // sync method fetches just sync the collections, not the individual files // within the collection. Future sync() async { _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 collections ${fetchedCollections.length}"); if (fetchedCollections.isEmpty) { return; } final updatedCollections = []; int maxUpdationTime = lastCollectionUpdationTime; final ownerID = _config.getUserID(); bool shouldFireDeleteEvent = false; for (final collection in fetchedCollections) { if (collection.isDeleted) { await _filesDB.deleteCollection(collection.id); await setCollectionSyncTime(collection.id, null); if (_collectionIDToCollections.containsKey(collection.id)) { shouldFireDeleteEvent = true; } } // 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 ? collection.updationTime : maxUpdationTime; } if (shouldFireDeleteEvent) { Bus.instance.fire( LocalPhotosUpdatedEvent( List.empty(), source: "syncCollectionDeleted", ), ); } await _updateDB(updatedCollections); _prefs.setInt(_collectionsSyncTimeKey, maxUpdationTime); watch.logAndReset("till DB insertion ${updatedCollections.length}"); for (final collection in fetchedCollections) { _cacheLocalPathAndCollection(collection); } _logger.info("Collections synced"); watch.log("${fetchedCollections.length} collection cached refreshed "); if (fetchedCollections.isNotEmpty) { Bus.instance.fire( CollectionUpdatedEvent( null, List.empty(), "collections_updated", ), ); } } void clearCache() { _localPathToCollectionID.clear(); _collectionIDToCollections.clear(); cachedDefaultHiddenCollection = null; cachedUncategorizedCollection = null; _cachedKeys.clear(); } Future> getCollectionIDsToBeSynced() async { final idsToRemoveUpdateTimeMap = await _db.getActiveIDsAndRemoteUpdateTime(); final result = {}; for (final MapEntry e in idsToRemoveUpdateTimeMap.entries) { final int cid = e.key; final int remoteUpdateTime = e.value; if (remoteUpdateTime > getCollectionSyncTime(cid)) { result[cid] = remoteUpdateTime; } } return result; } Future> getArchivedCollection() async { final allCollections = getCollectionsForUI(); return allCollections .where( (c) => c.isArchived() && !c.isHidden(), ) .toList(); } List getHiddenCollections({bool includeDefaultHidden = true}) { if (includeDefaultHidden) { return _collectionIDToCollections.values .toList() .where((element) => element.isHidden()) .toList(); } else { return _collectionIDToCollections.values .toList() .where( (element) => (element.isHidden() && element.id != cachedDefaultHiddenCollection?.id), ) .toList(); } } Set getHiddenCollectionIds() { return _collectionIDToCollections.values .toList() .where((element) => element.isHidden()) .map((e) => e.id) .toSet(); } Set archivedOrHiddenCollectionIds() { return _collectionIDToCollections.values .toList() .where( (element) => element.hasShareeArchived() || element.isHidden() || element.isArchived(), ) .map((e) => e.id) .toSet(); } int getCollectionSyncTime(int collectionID) { return _prefs .getInt(_collectionSyncTimeKeyPrefix + collectionID.toString()) ?? 0; } Future> getCollectionIDToNewestFileTime() { _collectionIDToNewestFileTime ??= _filesDB.getCollectionIDToMaxCreationTime(); return _collectionIDToNewestFileTime!; } Future getCover(Collection c) async { final int localSyncTime = getCollectionSyncTime(c.id); final String coverKey = '${c.id}_${localSyncTime}_${c.updationTime}'; if (_coverCache.containsKey(coverKey)) { return Future.value(_coverCache[coverKey]!); } if (kDebugMode) { debugPrint("getCover for collection ${c.id} ${c.displayName}"); } if (c.hasCover) { final coverID = c.pubMagicMetadata.coverID ?? 0; final EnteFile? cover = await filesDB.getUploadedFile(coverID, c.id); if (cover != null) { _coverCache[coverKey] = cover; return Future.value(cover); } } final coverFile = await filesDB.getCollectionFileFirstOrLast( c.id, c.pubMagicMetadata.asc ?? false, ); if (coverFile != null) { _coverCache[coverKey] = coverFile; return Future.value(coverFile); } return null; } EnteFile? getCoverCache(Collection c) { final int localSyncTime = getCollectionSyncTime(c.id); final String coverKey = '${c.id}_${localSyncTime}_${c.updationTime}'; return _coverCache[coverKey]; } Future getFileCount(Collection c) async { if (_countCache.containsKey(c.id)) { return _countCache[c.id]!; } else { final count = await _filesDB.collectionFileCount(c.id); _countCache[c.id] = count; return count; } } int? getCachedFileCount(Collection c) { return _countCache[c.id]; } Future 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 getActiveCollections() { return _collectionIDToCollections.values .toList() .where((element) => !element.isDeleted) .toList(); } // returns collections after removing deleted,uncategorized, and hidden // collections List getCollectionsForUI({ bool includedShared = false, bool includeCollab = false, bool includeUncategorized = false, }) { final Set allowedRoles = { CollectionParticipantRole.owner, }; if (includedShared) { allowedRoles.add(CollectionParticipantRole.viewer); } if (includedShared || includeCollab) { allowedRoles.add(CollectionParticipantRole.collaborator); } final int userID = _config.getUserID()!; return _collectionIDToCollections.values .where( (c) => !c.isDeleted && (includeUncategorized || c.type != CollectionType.uncategorized) && !c.isHidden() && allowedRoles.contains(c.getRole(userID)), ) .toList(); } SharedCollections getSharedCollections() { final List outgoing = []; final List incoming = []; final List quickLinks = []; final List collections = getCollectionsForUI(includedShared: true); for (final c in collections) { if (c.owner!.id == Configuration.instance.getUserID()) { if (c.hasSharees || c.hasLink && !c.isQuickLinkCollection()) { outgoing.add(c); } else if (c.isQuickLinkCollection()) { quickLinks.add(c); } } else { incoming.add(c); } } incoming.sort((first, second) { return second.updationTime.compareTo(first.updationTime); }); return SharedCollections(outgoing, incoming, quickLinks); } Future> getCollectionForOnEnteSection() async { final AlbumSortKey sortKey = LocalSettings.instance.albumSortKey(); final List collections = CollectionsService.instance.getCollectionsForUI(); final bool hasFavorites = FavoritesService.instance.hasFavorites(); late Map collectionIDToNewestPhotoTime; if (sortKey == AlbumSortKey.newestPhoto) { collectionIDToNewestPhotoTime = await CollectionsService.instance.getCollectionIDToNewestFileTime(); } collections.sort( (first, second) { if (sortKey == AlbumSortKey.albumName) { return compareAsciiLowerCaseNatural( first.displayName, second.displayName, ); } else if (sortKey == AlbumSortKey.newestPhoto) { return (collectionIDToNewestPhotoTime[second.id] ?? -1 * intMaxValue) .compareTo( collectionIDToNewestPhotoTime[first.id] ?? -1 * intMaxValue, ); } else { return second.updationTime.compareTo(first.updationTime); } }, ); final List favorites = []; final List pinned = []; final List rest = []; for (final collection in collections) { if (collection.type == CollectionType.uncategorized || collection.isQuickLinkCollection() || collection.isHidden()) { continue; } if (collection.type == CollectionType.favorites) { // Hide fav collection if it's empty if (hasFavorites) { favorites.add(collection); } } else if (collection.isPinned) { pinned.add(collection); } else { rest.add(collection); } } return favorites + pinned + rest; } 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] = matchingUser; } } } } return _cachedUserIdToUser[userID] ?? User( id: userID, email: "unknown@unknown.com", ); } Future> getSharees(int collectionID) { return _enteDio.get( "/collections/sharees", queryParameters: { "collectionID": collectionID, }, ).then((response) { _logger.info(response.toString()); final sharees = []; for (final user in response.data["sharees"]) { sharees.add(User.fromMap(user)); } return sharees; }); } Future> share( int collectionID, String email, String publicKey, CollectionParticipantRole role, ) async { final encryptedKey = CryptoUtil.sealSync( getCollectionKey(collectionID), CryptoUtil.base642bin(publicKey), ); try { final response = await _enteDio.post( "/collections/share", data: { "collectionID": collectionID, "email": email, "encryptedKey": CryptoUtil.bin2base64(encryptedKey), "role": role.toStringVal(), }, ); final sharees = []; 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(); } rethrow; } } Future> unshare(int collectionID, String email) async { try { final response = await _enteDio.post( "/collections/unshare", data: { "collectionID": collectionID, "email": email, }, ); final sharees = []; 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; } catch (e) { _logger.severe(e); rethrow; } } Future trashNonEmptyCollection( Collection collection, ) async { try { await _turnOffDeviceFolderSync(collection); await _enteDio.delete( "/collections/v3/${collection.id}?keepFiles=False&collectionID=${collection.id}", ); await _handleCollectionDeletion(collection); } catch (e) { _logger.severe('failed to trash collection', e); rethrow; } } Future _turnOffDeviceFolderSync(Collection collection) async { final deviceCollections = await _filesDB.getDeviceCollections(); final Map devicePathIDsToUnSync = Map.fromEntries( deviceCollections .where((e) => e.shouldBackup && e.collectionID == collection.id) .map((e) => MapEntry(e.id, false)), ); if (devicePathIDsToUnSync.isNotEmpty) { _logger.info( 'turning off backup status for folders $devicePathIDsToUnSync', ); await RemoteSyncService.instance .updateDeviceFolderSyncStatus(devicePathIDsToUnSync); } } Future trashEmptyCollection( Collection collection, { // during bulk deletion, this event is not fired to avoid quick refresh // of the collection gallery bool isBulkDelete = false, }) async { try { if (!isBulkDelete) { await _turnOffDeviceFolderSync(collection); } // While trashing empty albums, we must pass keepFiles flag as True. // The server will verify that the collection is actually empty before // deleting the files. If keepFiles is set as False and the collection // is not empty, then the files in the collections will be moved to trash. await _enteDio.delete( "/collections/v3/${collection.id}?keepFiles=True&collectionID=${collection.id}", ); if (isBulkDelete) { final deletedCollection = collection.copyWith(isDeleted: true); _collectionIDToCollections[collection.id] = deletedCollection; unawaited(_db.insert([deletedCollection])); } else { await _handleCollectionDeletion(collection); } } on DioError catch (e) { if (e.response != null) { debugPrint("Error " + e.response!.toString()); } rethrow; } catch (e) { _logger.severe('failed to trash empty collection', e); rethrow; } } Future _handleCollectionDeletion(Collection collection) async { await _filesDB.deleteCollection(collection.id); final deletedCollection = collection.copyWith(isDeleted: true); unawaited(_db.insert([deletedCollection])); _collectionIDToCollections[collection.id] = deletedCollection; Bus.instance.fire( CollectionUpdatedEvent( collection.id, [], "delete_collection", type: EventType.deletedFromRemote, ), ); sync().ignore(); LocalSyncService.instance.syncAll().ignore(); } 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] = _getAndCacheDecryptedKey(collection, source: "getCollectionKey"); } return _cachedKeys[collectionID]!; } Uint8List _getAndCacheDecryptedKey( Collection collection, { String source = "", }) { if (_cachedKeys.containsKey(collection.id)) { return _cachedKeys[collection.id]!; } debugPrint( "Compute collection decryption key for ${collection.id} source" " $source", ); final encryptedKey = CryptoUtil.base642bin(collection.encryptedKey); Uint8List? collectionKey; if (collection.owner?.id == _config.getUserID()) { // If the collection is owned by the user, decrypt with the master key if (_config.getKey() == null) { // Possible during AppStore account migration, where SecureStorage // would become inaccessible to the new Developer Account throw Exception("key can not be null"); } collectionKey = CryptoUtil.decryptSync( encryptedKey, _config.getKey()!, CryptoUtil.base642bin(collection.keyDecryptionNonce!), ); } else { // If owned by a different user, decrypt with the public key collectionKey = CryptoUtil.openSealSync( encryptedKey, CryptoUtil.base642bin(_config.getKeyAttributes()!.publicKey), _config.getSecretKey()!, ); } _cachedKeys[collection.id] = collectionKey; return collectionKey; } Future rename(Collection collection, String newName) async { try { // Note: when collection created to sharing few files is renamed // convert that collection to a regular collection type. if (collection.isQuickLinkCollection()) { await updateMagicMetadata(collection, {"subType": 0}); } final encryptedName = CryptoUtil.encryptSync( utf8.encode(newName) as Uint8List, getCollectionKey(collection.id), ); await _enteDio.post( "/collections/rename", data: { "collectionID": collection.id, "encryptedName": CryptoUtil.bin2base64(encryptedName.encryptedData!), "nameDecryptionNonce": CryptoUtil.bin2base64(encryptedName.nonce!), }, ); collection.setName(newName); sync().ignore(); } catch (e, s) { _logger.severe("failed to rename collection", e, s); rethrow; } } Future leaveAlbum(Collection collection) async { try { await _enteDio.post( "/collections/leave/${collection.id}", ); await _handleCollectionDeletion(collection); } catch (e, s) { _logger.severe("failed to leave collection", e, s); rethrow; } } Future updateMagicMetadata( Collection collection, Map newMetadataUpdate, ) async { 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. final Map jsonToUpdate = jsonDecode(collection.mMdEncodedJson ?? '{}'); newMetadataUpdate.forEach((key, value) { jsonToUpdate[key] = value; }); final key = getCollectionKey(collection.id); final encryptedMMd = await CryptoUtil.encryptChaCha( utf8.encode(jsonEncode(jsonToUpdate)) as Uint8List, key, ); // 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. final int currentVersion = max(collection.mMdVersion, 1); final params = UpdateMagicMetadataRequest( id: collection.id, magicMetadata: MetadataRequest( version: currentVersion, count: jsonToUpdate.length, data: CryptoUtil.bin2base64(encryptedMMd.encryptedData!), header: CryptoUtil.bin2base64(encryptedMMd.header!), ), ); await _enteDio.put( "/collections/magic-metadata", data: params, ); // update the local information so that it's reflected on UI collection.mMdEncodedJson = jsonEncode(jsonToUpdate); collection.magicMetadata = CollectionMagicMetadata.fromJson(jsonToUpdate); collection.mMdVersion = currentVersion + 1; _collectionIDToCollections[collection.id] = collection; // trigger sync to fetch the latest collection state from server sync().ignore(); } on DioError catch (e) { if (e.response != null && e.response?.statusCode == 409) { _logger.severe('collection magic data out of sync'); sync().ignore(); } rethrow; } catch (e, s) { _logger.severe("failed to sync magic metadata", e, s); rethrow; } } Future updatePublicMagicMetadata( Collection collection, Map newMetadataUpdate, ) async { 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. final Map jsonToUpdate = jsonDecode(collection.mMdPubEncodedJson ?? '{}'); newMetadataUpdate.forEach((key, value) { jsonToUpdate[key] = value; }); final key = getCollectionKey(collection.id); final encryptedMMd = await CryptoUtil.encryptChaCha( utf8.encode(jsonEncode(jsonToUpdate)) as Uint8List, key, ); // 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. final int currentVersion = max(collection.mMbPubVersion, 1); final params = UpdateMagicMetadataRequest( id: collection.id, magicMetadata: MetadataRequest( version: currentVersion, count: jsonToUpdate.length, data: CryptoUtil.bin2base64(encryptedMMd.encryptedData!), header: CryptoUtil.bin2base64(encryptedMMd.header!), ), ); await _enteDio.put( "/collections/public-magic-metadata", data: params, ); // update the local information so that it's reflected on UI collection.mMdPubEncodedJson = jsonEncode(jsonToUpdate); collection.pubMagicMetadata = CollectionPubMagicMetadata.fromJson(jsonToUpdate); collection.mMbPubVersion = currentVersion + 1; _cacheLocalPathAndCollection(collection); // trigger sync to fetch the latest collection state from server sync().ignore(); } on DioError catch (e) { if (e.response != null && e.response?.statusCode == 409) { _logger.severe('collection magic data out of sync'); sync().ignore(); } rethrow; } catch (e, s) { _logger.severe("failed to sync magic metadata", e, s); rethrow; } } Future updateShareeMagicMetadata( Collection collection, Map newMetadataUpdate, ) async { final int ownerID = Configuration.instance.getUserID()!; try { if (collection.owner?.id == ownerID) { throw AssertionError("cannot modify sharee settings for albums 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. final Map jsonToUpdate = jsonDecode(collection.sharedMmdJson ?? '{}'); newMetadataUpdate.forEach((key, value) { jsonToUpdate[key] = value; }); final key = getCollectionKey(collection.id); final encryptedMMd = await CryptoUtil.encryptChaCha( utf8.encode(jsonEncode(jsonToUpdate)) as Uint8List, key, ); // 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. final int currentVersion = max(collection.sharedMmdVersion, 1); final params = UpdateMagicMetadataRequest( id: collection.id, magicMetadata: MetadataRequest( version: currentVersion, count: jsonToUpdate.length, data: CryptoUtil.bin2base64(encryptedMMd.encryptedData!), header: CryptoUtil.bin2base64(encryptedMMd.header!), ), ); await _enteDio.put( "/collections/sharee-magic-metadata", data: params, ); // update the local information so that it's reflected on UI collection.sharedMmdJson = jsonEncode(jsonToUpdate); collection.sharedMagicMetadata = ShareeMagicMetadata.fromJson(jsonToUpdate); collection.sharedMmdVersion = currentVersion + 1; _cacheLocalPathAndCollection(collection); // trigger sync to fetch the latest collection state from server sync().ignore(); } on DioError catch (e) { if (e.response != null && e.response?.statusCode == 409) { _logger.severe('collection magic data out of sync'); sync().ignore(); } rethrow; } catch (e, s) { _logger.severe("failed to sync magic metadata", e, s); rethrow; } } Future createShareUrl( Collection collection, { bool enableCollect = false, }) async { try { final response = await _enteDio.post( "/collections/share-url", data: { "collectionID": collection.id, "enableCollect": enableCollect, }, ); collection.publicURLs?.add(PublicURL.fromMap(response.data["result"])); await _db.insert(List.from([collection])); _collectionIDToCollections[collection.id] = collection; Bus.instance.fire( CollectionUpdatedEvent(collection.id, [], "shareUrL"), ); } 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 updateShareUrl( Collection collection, Map prop, ) async { prop.putIfAbsent('collectionID', () => collection.id); try { 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])); _collectionIDToCollections[collection.id] = collection; Bus.instance.fire( CollectionUpdatedEvent(collection.id, [], "updateUrl"), ); } on DioError catch (e) { if (e.response?.statusCode == 402) { throw SharingNotPermittedForFreeAccountsError(); } rethrow; } catch (e, s) { _logger.severe("failed to update ShareUrl", e, s); rethrow; } } Future disableShareUrl(Collection collection) async { try { await _enteDio.delete( "/collections/share-url/" + collection.id.toString(), ); collection.publicURLs?.clear(); await _db.insert(List.from([collection])); _collectionIDToCollections[collection.id] = collection; Bus.instance.fire( CollectionUpdatedEvent( collection.id, [], "disableShareUrl", ), ); } on DioError catch (e) { _logger.info(e); rethrow; } } Future> _fetchCollections(int sinceTime) async { try { final response = await _enteDio.get( "/collections", queryParameters: { "sinceTime": sinceTime, "source": AppLifecycleService.instance.isForeground ? "fg" : "bg", }, ); final List collections = []; final c = response.data["collections"]; for (final collectionData in c) { final Collection collection = await _fromRemoteCollection(collectionData); collections.add(collection); } return collections; } catch (e, s) { _logger.warning(e, s); if (e is DioError && e.response?.statusCode == 401) { throw UnauthorizedError(); } rethrow; } } Future _fromRemoteCollection( Map? collectionData, ) async { final Collection collection = Collection.fromMap(collectionData); if (collectionData != null && !collection.isDeleted) { final collectionKey = _getAndCacheDecryptedKey(collection, source: "fetchDecryptMeta"); if (collectionData['magicMetadata'] != null) { final utfEncodedMmd = await CryptoUtil.decryptChaCha( CryptoUtil.base642bin(collectionData['magicMetadata']['data']), collectionKey, CryptoUtil.base642bin(collectionData['magicMetadata']['header']), ); collection.mMdEncodedJson = utf8.decode(utfEncodedMmd); collection.mMdVersion = collectionData['magicMetadata']['version']; collection.magicMetadata = CollectionMagicMetadata.fromEncodedJson( collection.mMdEncodedJson ?? '{}', ); } if (collectionData['pubMagicMetadata'] != null) { final utfEncodedMmd = await CryptoUtil.decryptChaCha( CryptoUtil.base642bin(collectionData['pubMagicMetadata']['data']), collectionKey, CryptoUtil.base642bin( collectionData['pubMagicMetadata']['header'], ), ); collection.mMdPubEncodedJson = utf8.decode(utfEncodedMmd); collection.mMbPubVersion = collectionData['pubMagicMetadata']['version']; collection.pubMagicMetadata = CollectionPubMagicMetadata.fromEncodedJson( collection.mMdPubEncodedJson ?? '{}', ); } if (collectionData['sharedMagicMetadata'] != null) { final utfEncodedMmd = await CryptoUtil.decryptChaCha( CryptoUtil.base642bin( collectionData['sharedMagicMetadata']['data'], ), collectionKey, CryptoUtil.base642bin( collectionData['sharedMagicMetadata']['header'], ), ); collection.sharedMmdJson = utf8.decode(utfEncodedMmd); collection.sharedMmdVersion = collectionData['sharedMagicMetadata']['version']; collection.sharedMagicMetadata = ShareeMagicMetadata.fromEncodedJson( collection.sharedMmdJson ?? '{}', ); } } collection.setName(_getDecryptedCollectionName(collection)); if (collection.canLinkToDevicePath(_config.getUserID()!)) { collection.decryptedPath = (_decryptCollectionPath(collection)); } return collection; } Collection? getCollectionByID(int collectionID) { return _collectionIDToCollections[collectionID]; } Future createAlbum(String albumName) async { final collectionKey = CryptoUtil.generateKey(); final encryptedKeyData = CryptoUtil.encryptSync(collectionKey, _config.getKey()!); final encryptedName = CryptoUtil.encryptSync( utf8.encode(albumName) as Uint8List, collectionKey, ); final collection = await createAndCacheCollection( CreateRequest( encryptedKey: CryptoUtil.bin2base64(encryptedKeyData.encryptedData!), keyDecryptionNonce: CryptoUtil.bin2base64(encryptedKeyData.nonce!), encryptedName: CryptoUtil.bin2base64(encryptedName.encryptedData!), nameDecryptionNonce: CryptoUtil.bin2base64(encryptedName.nonce!), type: CollectionType.album, attributes: CollectionAttributes(), ), ); return collection; } Future fetchCollectionByID(int collectionID) async { try { _logger.fine('fetching collectionByID $collectionID'); final response = await _enteDio.get( "/collections/$collectionID", ); assert(response.data != null); final collectionData = response.data["collection"]; final collection = await _fromRemoteCollection(collectionData); await _db.insert(List.from([collection])); _cacheLocalPathAndCollection(collection); return collection; } catch (e) { if (e is DioError && e.response?.statusCode == 401) { throw UnauthorizedError(); } _logger.severe('failed to fetch collection: $collectionID', e); rethrow; } } Future getOrCreateForPath(String path) async { if (_localPathToCollectionID.containsKey(path)) { final Collection? cachedCollection = _collectionIDToCollections[_localPathToCollectionID[path]]; if (cachedCollection != null && cachedCollection.canLinkToDevicePath(_config.getUserID()!)) { return cachedCollection; } } final collectionKey = CryptoUtil.generateKey(); final encryptedKeyData = CryptoUtil.encryptSync(collectionKey, _config.getKey()!); final encryptedPath = CryptoUtil.encryptSync(utf8.encode(path) as Uint8List, collectionKey); final collection = await createAndCacheCollection( CreateRequest( encryptedKey: CryptoUtil.bin2base64(encryptedKeyData.encryptedData!), keyDecryptionNonce: CryptoUtil.bin2base64(encryptedKeyData.nonce!), encryptedName: CryptoUtil.bin2base64(encryptedPath.encryptedData!), nameDecryptionNonce: CryptoUtil.bin2base64(encryptedPath.nonce!), type: CollectionType.folder, attributes: CollectionAttributes( encryptedPath: CryptoUtil.bin2base64(encryptedPath.encryptedData!), pathDecryptionNonce: CryptoUtil.bin2base64(encryptedPath.nonce!), version: 1, ), ), ); return collection; } Future addToCollection(int collectionID, List files) async { final containsUploadedFile = files.firstWhereOrNull( (element) => element.uploadedFileID != null, ) != null; if (containsUploadedFile) { final existingFileIDsInCollection = await FilesDB.instance.getUploadedFileIDs(collectionID); files.removeWhere( (element) => element.uploadedFileID != null && existingFileIDsInCollection.contains(element.uploadedFileID), ); } if (files.isEmpty || !containsUploadedFile) { _logger.info("nothing to add to the collection"); return; } final params = {}; params["collectionID"] = collectionID; final batchedFiles = files.chunks(batchSize); for (final batch in batchedFiles) { params["files"] = []; for (final file in batch) { final fileKey = getFileKey(file); file.generatedID = null; // So that a new entry is created in the FilesDB file.collectionID = collectionID; final encryptedKeyData = CryptoUtil.encryptSync(fileKey, getCollectionKey(collectionID)); file.encryptedKey = CryptoUtil.bin2base64(encryptedKeyData.encryptedData!); file.keyDecryptionNonce = CryptoUtil.bin2base64(encryptedKeyData.nonce!); params["files"].add( CollectionFileItem( file.uploadedFileID!, file.encryptedKey!, file.keyDecryptionNonce!, ).toMap(), ); } try { await _enteDio.post( "/collections/add-files", data: params, ); await _filesDB.insertMultiple(batch); Bus.instance.fire(CollectionUpdatedEvent(collectionID, batch, "addTo")); } catch (e) { rethrow; } } } Future linkLocalFileToExistingUploadedFileInAnotherCollection( int destCollectionID, { required EnteFile localFileToUpload, required EnteFile existingUploadedFile, }) async { final params = {}; params["collectionID"] = destCollectionID; params["files"] = []; final int uploadedFileID = existingUploadedFile.uploadedFileID!; // encrypt the fileKey with destination collection's key final fileKey = getFileKey(existingUploadedFile); final encryptedKeyData = CryptoUtil.encryptSync(fileKey, getCollectionKey(destCollectionID)); localFileToUpload.encryptedKey = CryptoUtil.bin2base64(encryptedKeyData.encryptedData!); localFileToUpload.keyDecryptionNonce = CryptoUtil.bin2base64(encryptedKeyData.nonce!); params["files"].add( CollectionFileItem( uploadedFileID, localFileToUpload.encryptedKey!, localFileToUpload.keyDecryptionNonce!, ).toMap(), ); try { await _enteDio.post( "/collections/add-files", data: params, ); localFileToUpload.collectionID = destCollectionID; localFileToUpload.uploadedFileID = uploadedFileID; await _filesDB.insertMultiple([localFileToUpload]); return localFileToUpload; } catch (e) { rethrow; } } Future restore(int toCollectionID, List files) async { final params = {}; params["collectionID"] = toCollectionID; final toCollectionKey = getCollectionKey(toCollectionID); final int ownerID = Configuration.instance.getUserID()!; final Set existingLocalIDS = await FilesDB.instance.getExistingLocalFileIDs(ownerID); final batchedFiles = files.chunks(batchSize); for (final batch in batchedFiles) { params["files"] = []; for (final file in batch) { final fileKey = getFileKey(file); file.generatedID = null; // So that a new entry is created in the FilesDB file.collectionID = toCollectionID; // During restore, if trash file local ID is not present in currently // imported files, treat the file as deleted from device if (file.localID != null && !existingLocalIDS.contains(file.localID)) { file.localID = null; } final encryptedKeyData = CryptoUtil.encryptSync(fileKey, toCollectionKey); file.encryptedKey = CryptoUtil.bin2base64(encryptedKeyData.encryptedData!); file.keyDecryptionNonce = CryptoUtil.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; } } } Future move( int toCollectionID, int fromCollectionID, List files, ) async { _validateMoveRequest(toCollectionID, fromCollectionID, files); files.removeWhere((element) => element.uploadedFileID == null); if (files.isEmpty) { _logger.info("nothing to move to collection"); return; } final params = {}; params["toCollectionID"] = toCollectionID; params["fromCollectionID"] = fromCollectionID; final batchedFiles = files.chunks(batchSize); for (final batch in batchedFiles) { params["files"] = []; for (final file in batch) { final fileKey = getFileKey(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 = CryptoUtil.bin2base64(encryptedKeyData.encryptedData!); file.keyDecryptionNonce = CryptoUtil.bin2base64(encryptedKeyData.nonce!); params["files"].add( CollectionFileItem( file.uploadedFileID!, file.encryptedKey!, file.keyDecryptionNonce!, ).toMap(), ); } await _enteDio.post( "/collections/move-files", data: params, ); } // remove files from old collection await _filesDB.removeFromCollection( fromCollectionID, files.map((e) => e.uploadedFileID!).toList(), ); Bus.instance.fire( CollectionUpdatedEvent( fromCollectionID, files, "moveFrom", 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( (element) => existingUploadedIDs.contains(element.uploadedFileID), ); await _filesDB.insertMultiple(files); Bus.instance.fire( CollectionUpdatedEvent(toCollectionID, files, "moveTo"), ); } void _validateMoveRequest( int toCollectionID, int fromCollectionID, List files, ) { if (toCollectionID == fromCollectionID) { throw AssertionError("Can't move to same album"); } for (final file in files) { if (file.uploadedFileID == null) { throw AssertionError("Can only move uploaded memories"); } if (file.collectionID != fromCollectionID) { throw AssertionError("All memories should belong to the same album"); } if (file.ownerID != Configuration.instance.getUserID()) { throw AssertionError("Can only move memories uploaded by you"); } } } Future removeFromCollection( int collectionID, List files, ) async { final params = {}; params["collectionID"] = collectionID; final batchedFiles = files.chunks(batchSize); for (final batch in batchedFiles) { params["fileIDs"] = []; for (final file in batch) { params["fileIDs"].add(file.uploadedFileID); } await _enteDio.post( "/collections/v3/remove-files", data: params, ); await _filesDB.removeFromCollection(collectionID, params["fileIDs"]); Bus.instance .fire(CollectionUpdatedEvent(collectionID, batch, "removeFrom")); Bus.instance.fire(LocalPhotosUpdatedEvent(batch, source: "removeFrom")); } RemoteSyncService.instance.sync(silently: true).ignore(); } Future createAndCacheCollection( CreateRequest createRequest, ) async { final dynamic payload = createRequest.toJson(); return _enteDio .post( "/collections", data: payload, ) .then((response) async { final collectionData = response.data["collection"]; final collection = await _fromRemoteCollection(collectionData); return _cacheLocalPathAndCollection(collection); }); } @Deprecated("Use _cacheLocalPathAndCollection instead") Collection _cacheCollectionAttributes(Collection collection) { final String decryptedName = _getDecryptedCollectionName(collection); collection.setName(decryptedName); if (collection.canLinkToDevicePath(_config.getUserID()!)) { _localPathToCollectionID[_decryptCollectionPath(collection)] = collection.id; } _collectionIDToCollections[collection.id] = collection; return collection; } Collection _cacheLocalPathAndCollection(Collection collection) { assert( collection.decryptedName != null, "decryptedName should be already set", ); if (collection.canLinkToDevicePath(_config.getUserID()!) && (collection.decryptedPath ?? '').isNotEmpty) { _localPathToCollectionID[collection.decryptedPath!] = collection.id; } _collectionIDToCollections[collection.id] = collection; return collection; } String _decryptCollectionPath(Collection collection) { if (collection.decryptedPath != null && collection.decryptedPath!.isNotEmpty) { debugPrint("Using cached decrypted path for collection ${collection.id}"); return collection.decryptedPath!; } else { debugPrint( "Decrypting path for collection ${collection.id} from " "encryptedPath", ); } final key = collection.attributes.version! >= 1 ? getCollectionKey(collection.id) : _config.getKey(); return utf8.decode( CryptoUtil.decryptSync( CryptoUtil.base642bin(collection.attributes.encryptedPath!), key!, CryptoUtil.base642bin(collection.attributes.pathDecryptionNonce!), ), ); } bool hasSyncedCollections() { return _prefs.containsKey(_collectionsSyncTimeKey); } String _getDecryptedCollectionName(Collection collection) { if (collection.isDeleted) { return "Deleted Album"; } if (collection.encryptedName != null && collection.encryptedName!.isNotEmpty) { try { final collectionKey = _getAndCacheDecryptedKey( collection, source: "Name", ); final result = CryptoUtil.decryptSync( CryptoUtil.base642bin(collection.encryptedName!), collectionKey, CryptoUtil.base642bin(collection.nameDecryptionNonce!), ); return utf8.decode(result); } catch (e, s) { _logger.severe( "failed to decrypt collection name: ${collection.id}", e, s, ); } } return collection.displayName; } Future _updateDB(List collections, {int attempt = 1}) async { if (collections.isEmpty) { return; } try { await _db.insert(collections); } catch (e) { _logger.severe("Failed to update collections", e); if (attempt < kMaximumWriteAttempts) { return _updateDB(collections, attempt: ++attempt); } else { rethrow; } } } }