import 'dart:convert'; import 'dart:typed_data'; import 'package:dio/dio.dart'; import 'package:flutter/foundation.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/db/collections_db.dart'; import 'package:photos/db/files_db.dart'; import 'package:photos/events/collection_updated_event.dart'; import 'package:photos/models/collection.dart'; import 'package:photos/models/collection_file_item.dart'; import 'package:photos/models/file.dart'; import 'package:photos/repositories/file_repository.dart'; import 'package:photos/services/sync_service.dart'; import 'package:photos/utils/crypto_util.dart'; import 'package:photos/utils/file_util.dart'; import 'package:shared_preferences/shared_preferences.dart'; class CollectionsService { static final _collectionSyncTimeKeyPrefix = "collection_sync_time_"; final _logger = Logger("CollectionsService"); CollectionsDB _db; FilesDB _filesDB; Configuration _config; SharedPreferences _prefs; final _dio = Network.instance.getDio(); final _localCollections = Map(); final _collectionIDToCollections = Map(); final _cachedKeys = Map(); CollectionsService._privateConstructor() { _db = CollectionsDB.instance; _filesDB = FilesDB.instance; _config = Configuration.instance; } static final CollectionsService instance = CollectionsService._privateConstructor(); Future init() async { _prefs = await SharedPreferences.getInstance(); final collections = await _db.getAllCollections(); for (final collection in collections) { _cacheCollectionAttributes(collection); } } Future> sync() async { _logger.info("Syncing"); final lastCollectionUpdationTime = await _db.getLastCollectionUpdationTime(); // Might not have synced the collection fully final fetchedCollections = await _fetchCollections(lastCollectionUpdationTime ?? 0); final updatedCollections = List(); for (final collection in fetchedCollections) { if (collection.isDeleted) { await _filesDB.deleteCollection(collection.id); await _db.deleteCollection(collection.id); await setCollectionSyncTime(collection.id, null); FileRepository.instance.reloadFiles(); } else { updatedCollections.add(collection); } } await _db.insert(updatedCollections); final collections = await _db.getAllCollections(); for (final collection in collections) { _cacheCollectionAttributes(collection); } if (fetchedCollections.isNotEmpty) { _logger.info("Collections updated"); Bus.instance.fire(CollectionUpdatedEvent()); } return collections; } Future> getCollectionsToBeSynced() async { final collections = await _db.getAllCollections(); final updatedCollections = List(); for (final c in collections) { if (c.updationTime > getCollectionSyncTime(c.id)) { updatedCollections.add(c); } } return updatedCollections; } int getCollectionSyncTime(int collectionID) { var syncTime = _prefs.getInt(_collectionSyncTimeKeyPrefix + collectionID.toString()); if (syncTime == null) { syncTime = 0; } return syncTime; } Future setCollectionSyncTime(int collectionID, int time) async { final key = _collectionSyncTimeKeyPrefix + collectionID.toString(); if (time == null) { return _prefs.remove(key); } return _prefs.setInt(key, time); } Collection getCollectionForPath(String path) { return _localCollections[path]; } List getCollections() { return _collectionIDToCollections.values.toList(); } Future> getSharees(int collectionID) { return _dio .get( Configuration.instance.getHttpEndpoint() + "/collections/sharees", queryParameters: { "collectionID": collectionID, }, options: Options(headers: {"X-Auth-Token": Configuration.instance.getToken()}), ) .then((response) { _logger.info(response.toString()); final sharees = List(); for (final user in response.data["sharees"]) { sharees.add(User.fromMap(user)); } return sharees; }); } Future share(int collectionID, String email, String publicKey) { final encryptedKey = CryptoUtil.sealSync( getCollectionKey(collectionID), Sodium.base642bin(publicKey)); return _dio .post( Configuration.instance.getHttpEndpoint() + "/collections/share", data: { "collectionID": collectionID, "email": email, "encryptedKey": Sodium.bin2base64(encryptedKey), }, options: Options( headers: {"X-Auth-Token": Configuration.instance.getToken()}), ) .then((value) => SyncService.instance.syncWithRemote(silently: true)); } Future unshare(int collectionID, String email) { return _dio .post( Configuration.instance.getHttpEndpoint() + "/collections/unshare", data: { "collectionID": collectionID, "email": email, }, options: Options( headers: {"X-Auth-Token": Configuration.instance.getToken()}), ) .then((value) => SyncService.instance.syncWithRemote(silently: true)); } Uint8List getCollectionKey(int collectionID) { if (!_cachedKeys.containsKey(collectionID)) { final collection = _collectionIDToCollections[collectionID]; _cachedKeys[collectionID] = _getDecryptedKey(collection); } return _cachedKeys[collectionID]; } Uint8List _getDecryptedKey(Collection collection) { final encryptedKey = Sodium.base642bin(collection.encryptedKey); if (collection.owner.id == _config.getUserID()) { return CryptoUtil.decryptSync(encryptedKey, _config.getKey(), Sodium.base642bin(collection.keyDecryptionNonce)); } else { return CryptoUtil.openSealSync( encryptedKey, Sodium.base642bin(_config.getKeyAttributes().publicKey), _config.getSecretKey()); } } Future> _fetchCollections(int sinceTime) { return _dio .get( Configuration.instance.getHttpEndpoint() + "/collections", queryParameters: { "sinceTime": sinceTime, }, options: Options(headers: {"X-Auth-Token": Configuration.instance.getToken()}), ) .then((response) { final collections = List(); if (response != null) { final c = response.data["collections"]; for (final collection in c) { collections.add(Collection.fromMap(collection)); } } return collections; }); } Collection getCollectionByID(int collectionID) { return _collectionIDToCollections[collectionID]; } Future createAlbum(String albumName) async { final key = CryptoUtil.generateKey(); final encryptedKeyData = CryptoUtil.encryptSync(key, _config.getKey()); final encryptedName = CryptoUtil.encryptSync(utf8.encode(albumName), key); 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, )); return collection; } Future getOrCreateForPath(String path) async { if (_localCollections.containsKey(path)) { return _localCollections[path]; } final key = CryptoUtil.generateKey(); final encryptedKeyData = CryptoUtil.encryptSync(key, _config.getKey()); final encryptedPath = CryptoUtil.encryptSync(utf8.encode(path), key); 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, )); return collection; } Future addToCollection(int collectionID, List files) { final params = Map(); params["collectionID"] = collectionID; for (final file in files) { final key = decryptFileKey(file); file.collectionID = collectionID; final encryptedKeyData = CryptoUtil.encryptSync(key, getCollectionKey(collectionID)); file.encryptedKey = Sodium.bin2base64(encryptedKeyData.encryptedData); file.keyDecryptionNonce = Sodium.bin2base64(encryptedKeyData.nonce); if (params["files"] == null) { params["files"] = []; } params["files"].add(CollectionFileItem( file.uploadedFileID, file.encryptedKey, file.keyDecryptionNonce) .toMap()); } return _dio .post( Configuration.instance.getHttpEndpoint() + "/collections/add-files", data: params, options: Options(headers: {"X-Auth-Token": Configuration.instance.getToken()}), ) .then((value) async { await _filesDB.insertMultiple(files); Bus.instance.fire(CollectionUpdatedEvent(collectionID: collectionID)); SyncService.instance.syncWithRemote(silently: true); }); } Future removeFromCollection(int collectionID, List files) async { final params = Map(); params["collectionID"] = collectionID; for (final file in files) { if (params["fileIDs"] == null) { params["fileIDs"] = List(); } params["fileIDs"].add(file.uploadedFileID); } await _dio.post( Configuration.instance.getHttpEndpoint() + "/collections/remove-files", data: params, options: Options(headers: {"X-Auth-Token": Configuration.instance.getToken()}), ); await _filesDB.removeFromCollection(collectionID, params["fileIDs"]); Bus.instance.fire(CollectionUpdatedEvent(collectionID: collectionID)); SyncService.instance.syncWithRemote(silently: true); } Future createAndCacheCollection(Collection collection) async { return _dio .post( Configuration.instance.getHttpEndpoint() + "/collections", data: collection.toMap(), options: Options(headers: {"X-Auth-Token": Configuration.instance.getToken()}), ) .then((response) { final collection = Collection.fromMap(response.data["collection"]); _cacheCollectionAttributes(collection); return collection; }); } void _cacheCollectionAttributes(Collection collection) { final collectionWithDecryptedName = _getCollectionWithDecryptedName(collection); if (collection.attributes.encryptedPath != null) { _localCollections[decryptCollectionPath(collection)] = collectionWithDecryptedName; } _collectionIDToCollections[collection.id] = collectionWithDecryptedName; } String decryptCollectionPath(Collection collection) { final key = collection.attributes.version == 1 ? getCollectionKey(collection.id) : _config.getKey(); return utf8.decode(CryptoUtil.decryptSync( Sodium.base642bin(collection.attributes.encryptedPath), key, Sodium.base642bin(collection.attributes.pathDecryptionNonce))); } Collection _getCollectionWithDecryptedName(Collection collection) { var name; if (collection.encryptedName != null && collection.encryptedName.isNotEmpty) { name = utf8.decode(CryptoUtil.decryptSync( Sodium.base642bin(collection.encryptedName), _getDecryptedKey(collection), Sodium.base642bin(collection.nameDecryptionNonce))); return Collection( collection.id, collection.owner, collection.encryptedKey, collection.keyDecryptionNonce, name, collection.encryptedName, collection.nameDecryptionNonce, collection.type, collection.attributes, collection.sharees, collection.updationTime, ); } else return collection; } } class AddFilesRequest { final int collectionID; final List files; AddFilesRequest( this.collectionID, this.files, ); AddFilesRequest copyWith({ int collectionID, List files, }) { return AddFilesRequest( collectionID ?? this.collectionID, files ?? this.files, ); } Map toMap() { return { 'collectionID': collectionID, 'files': files?.map((x) => x?.toMap())?.toList(), }; } factory AddFilesRequest.fromMap(Map map) { if (map == null) return null; return AddFilesRequest( map['collectionID'], List.from( map['files']?.map((x) => CollectionFileItem.fromMap(x))), ); } String toJson() => json.encode(toMap()); factory AddFilesRequest.fromJson(String source) => AddFilesRequest.fromMap(json.decode(source)); @override String toString() => 'AddFilesRequest(collectionID: $collectionID, files: $files)'; @override bool operator ==(Object o) { if (identical(this, o)) return true; return o is AddFilesRequest && o.collectionID == collectionID && listEquals(o.files, files); } @override int get hashCode => collectionID.hashCode ^ files.hashCode; }