import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:logging/logging.dart'; import 'package:photo_manager/photo_manager.dart'; import 'package:photos/db/files_db.dart'; import 'package:photos/models/backup_status.dart'; import 'package:photos/models/device_collection.dart'; import 'package:photos/models/file/file.dart'; import 'package:photos/models/file_load_result.dart'; import 'package:photos/models/upload_strategy.dart'; import 'package:photos/services/local/local_sync_util.dart'; import 'package:sqflite/sqlite_api.dart'; import 'package:tuple/tuple.dart'; extension DeviceFiles on FilesDB { static final Logger _logger = Logger("DeviceFilesDB"); static const _sqlBoolTrue = 1; static const _sqlBoolFalse = 0; Future insertPathIDToLocalIDMapping( Map> mappingToAdd, { ConflictAlgorithm conflictAlgorithm = ConflictAlgorithm.ignore, }) async { debugPrint("Inserting missing PathIDToLocalIDMapping"); final db = await database; var batch = db.batch(); int batchCounter = 0; for (MapEntry e in mappingToAdd.entries) { final String pathID = e.key; for (String localID in e.value) { if (batchCounter == 400) { await batch.commit(noResult: true); batch = db.batch(); batchCounter = 0; } batch.insert( "device_files", { "id": localID, "path_id": pathID, }, conflictAlgorithm: conflictAlgorithm, ); batchCounter++; } } await batch.commit(noResult: true); } Future deletePathIDToLocalIDMapping( Map> mappingsToRemove, ) async { debugPrint("removing PathIDToLocalIDMapping"); final db = await database; var batch = db.batch(); int batchCounter = 0; for (MapEntry e in mappingsToRemove.entries) { final String pathID = e.key; for (String localID in e.value) { if (batchCounter == 400) { await batch.commit(noResult: true); batch = db.batch(); batchCounter = 0; } batch.delete( "device_files", where: 'id = ? AND path_id = ?', whereArgs: [localID, pathID], ); batchCounter++; } } await batch.commit(noResult: true); } Future> getDevicePathIDToImportedFileCount() async { try { final db = await database; final rows = await db.rawQuery( ''' SELECT count(*) as count, path_id FROM device_files GROUP BY path_id ''', ); final result = {}; for (final row in rows) { result[row['path_id'] as String] = row["count"] as int; } return result; } catch (e) { _logger.severe("failed to getDevicePathIDToImportedFileCount", e); rethrow; } } Future>> getDevicePathIDToLocalIDMap() async { try { final db = await database; final rows = await db.rawQuery( ''' SELECT id, path_id FROM device_files; ''', ); final result = >{}; for (final row in rows) { final String pathID = row['path_id'] as String; if (!result.containsKey(pathID)) { result[pathID] = {}; } result[pathID]!.add(row['id'] as String); } return result; } catch (e) { _logger.severe("failed to getDevicePathIDToLocalIDMap", e); rethrow; } } Future> getDevicePathIDs() async { final Database db = await database; final rows = await db.rawQuery( ''' SELECT id FROM device_collections ''', ); final Set result = {}; for (final row in rows) { result.add(row['id'] as String); } return result; } Future insertLocalAssets( List localPathAssets, { bool shouldAutoBackup = false, }) async { final Database db = await database; final Map> pathIDToLocalIDsMap = {}; try { final batch = db.batch(); final Set existingPathIds = await getDevicePathIDs(); for (LocalPathAsset localPathAsset in localPathAssets) { if (localPathAsset.localIDs.isNotEmpty) { pathIDToLocalIDsMap[localPathAsset.pathID] = localPathAsset.localIDs; } if (existingPathIds.contains(localPathAsset.pathID)) { batch.rawUpdate( "UPDATE device_collections SET name = ? where id = " "?", [localPathAsset.pathName, localPathAsset.pathID], ); } else if (localPathAsset.localIDs.isNotEmpty) { batch.insert( "device_collections", { "id": localPathAsset.pathID, "name": localPathAsset.pathName, "should_backup": shouldAutoBackup ? _sqlBoolTrue : _sqlBoolFalse, }, conflictAlgorithm: ConflictAlgorithm.ignore, ); } } await batch.commit(noResult: true); // add the mappings for localIDs if (pathIDToLocalIDsMap.isNotEmpty) { await insertPathIDToLocalIDMapping(pathIDToLocalIDsMap); } } catch (e) { _logger.severe("failed to save path names", e); rethrow; } } Future updateDeviceCoverWithCount( List> devicePathInfo, { bool shouldBackup = false, }) async { bool hasUpdated = false; try { final Database db = await database; final Set existingPathIds = await getDevicePathIDs(); for (Tuple2 tup in devicePathInfo) { final AssetPathEntity pathEntity = tup.item1; final assetCount = await pathEntity.assetCountAsync; final String localID = tup.item2; final bool shouldUpdate = existingPathIds.contains(pathEntity.id); if (shouldUpdate) { final rowUpdated = await db.rawUpdate( "UPDATE device_collections SET name = ?, cover_id = ?, count" " = ? where id = ? AND (name != ? OR cover_id != ? OR count != ?)", [ pathEntity.name, localID, assetCount, pathEntity.id, pathEntity.name, localID, assetCount, ], ); if (rowUpdated > 0) { _logger.fine("Updated $rowUpdated rows for ${pathEntity.name}"); hasUpdated = true; } } else { hasUpdated = true; await db.insert( "device_collections", { "id": pathEntity.id, "name": pathEntity.name, "count": assetCount, "cover_id": localID, "should_backup": shouldBackup ? _sqlBoolTrue : _sqlBoolFalse, }, conflictAlgorithm: ConflictAlgorithm.ignore, ); } } // delete existing pathIDs which are missing on device existingPathIds.removeAll(devicePathInfo.map((e) => e.item1.id).toSet()); if (existingPathIds.isNotEmpty) { hasUpdated = true; _logger.info( 'Deleting non-backed up pathIds from local ' '$existingPathIds', ); for (String pathID in existingPathIds) { // do not delete device collection entries for paths which are // marked for backup. This is to handle "Free up space" // feature, where we delete files which are backed up. Deleting such // entries here result in us losing out on the information that // those folders were marked for automatic backup. await db.delete( "device_collections", where: 'id = ? and should_backup = $_sqlBoolFalse ', whereArgs: [pathID], ); await db.delete( "device_files", where: 'path_id = ?', whereArgs: [pathID], ); } } return hasUpdated; } catch (e) { _logger.severe("failed to save path names", e); rethrow; } } // getDeviceSyncCollectionIDs returns the collectionIDs for the // deviceCollections which are marked for auto-backup Future> getDeviceSyncCollectionIDs() async { final Database db = await database; final rows = await db.rawQuery( ''' SELECT collection_id FROM device_collections where should_backup = $_sqlBoolTrue and collection_id != -1; ''', ); final Set result = {}; for (final row in rows) { result.add(row['collection_id'] as int); } return result; } Future updateDevicePathSyncStatus(Map syncStatus) async { final db = await database; var batch = db.batch(); int batchCounter = 0; for (MapEntry e in syncStatus.entries) { final String pathID = e.key; if (batchCounter == 400) { await batch.commit(noResult: true); batch = db.batch(); batchCounter = 0; } batch.update( "device_collections", { "should_backup": e.value ? _sqlBoolTrue : _sqlBoolFalse, }, where: 'id = ?', whereArgs: [pathID], ); batchCounter++; } await batch.commit(noResult: true); } Future updateDeviceCollection( String pathID, int collectionID, ) async { final db = await database; await db.update( "device_collections", {"collection_id": collectionID}, where: 'id = ?', whereArgs: [pathID], ); return; } Future getFilesInDeviceCollection( DeviceCollection deviceCollection, int? ownerID, int startTime, int endTime, { int? limit, bool? asc, }) async { final db = await database; final order = (asc ?? false ? 'ASC' : 'DESC'); final String rawQuery = ''' SELECT * FROM ${FilesDB.filesTable} WHERE ${FilesDB.columnLocalID} IS NOT NULL AND ${FilesDB.columnCreationTime} >= $startTime AND ${FilesDB.columnCreationTime} <= $endTime AND (${FilesDB.columnOwnerID} IS NULL OR ${FilesDB.columnOwnerID} = $ownerID ) AND ${FilesDB.columnLocalID} IN (SELECT id FROM device_files where path_id = '${deviceCollection.id}' ) ORDER BY ${FilesDB.columnCreationTime} $order , ${FilesDB.columnModificationTime} $order ''' + (limit != null ? ' limit $limit;' : ';'); final results = await db.rawQuery(rawQuery); final files = convertToFiles(results); final dedupe = deduplicateByLocalID(files); return FileLoadResult(dedupe, files.length == limit); } Future getBackedUpForDeviceCollection( String pathID, int ownerID, ) async { final db = await database; const String rawQuery = ''' SELECT ${FilesDB.columnLocalID}, ${FilesDB.columnUploadedFileID}, ${FilesDB.columnFileSize} FROM ${FilesDB.filesTable} WHERE ${FilesDB.columnLocalID} IS NOT NULL AND (${FilesDB.columnOwnerID} IS NULL OR ${FilesDB.columnOwnerID} = ?) AND (${FilesDB.columnUploadedFileID} IS NOT NULL AND ${FilesDB.columnUploadedFileID} IS NOT -1) AND ${FilesDB.columnLocalID} IN (SELECT id FROM device_files where path_id = ?) '''; final results = await db.rawQuery(rawQuery, [ownerID, pathID]); final localIDs = {}; final uploadedIDs = {}; int localSize = 0; for (final result in results) { final String localID = result[FilesDB.columnLocalID] as String; final int? fileSize = result[FilesDB.columnFileSize] as int?; if (!localIDs.contains(localID) && fileSize != null) { localSize += fileSize; } localIDs.add(localID); uploadedIDs.add(result[FilesDB.columnUploadedFileID] as int); } return BackedUpFileIDs(localIDs.toList(), uploadedIDs.toList(), localSize); } Future> getDeviceCollections({ bool includeCoverThumbnail = false, }) async { debugPrint( "Fetching DeviceCollections From DB with thumbnail = " "$includeCoverThumbnail", ); try { final db = await database; final coverFiles = []; if (includeCoverThumbnail) { final fileRows = await db.rawQuery( '''SELECT * FROM FILES where local_id in (select cover_id from device_collections) group by local_id; ''', ); final files = convertToFiles(fileRows); coverFiles.addAll(files); } final deviceCollectionRows = await db.rawQuery( '''SELECT * from device_collections''', ); final List deviceCollections = []; for (var row in deviceCollectionRows) { final DeviceCollection deviceCollection = DeviceCollection( row["id"] as String, (row['name'] ?? '') as String, count: row['count'] as int, collectionID: (row["collection_id"] ?? -1) as int, coverId: row["cover_id"] as String?, shouldBackup: (row["should_backup"] ?? _sqlBoolFalse) == _sqlBoolTrue, uploadStrategy: getUploadType((row["upload_strategy"] ?? 0) as int), ); if (includeCoverThumbnail) { deviceCollection.thumbnail = coverFiles.firstWhereOrNull( (element) => element.localID == deviceCollection.coverId, ); if (deviceCollection.thumbnail == null) { final EnteFile? result = await getDeviceCollectionThumbnail(deviceCollection.id); if (result == null) { _logger.finest( 'Failed to find coverThumbnail for deviceFolder', ); continue; } else { deviceCollection.thumbnail = result; } } } deviceCollections.add(deviceCollection); } if (includeCoverThumbnail) { deviceCollections.sort( (a, b) => b.thumbnail!.creationTime!.compareTo(a.thumbnail!.creationTime!), ); } return deviceCollections; } catch (e) { _logger.severe('Failed to getDeviceCollections', e); rethrow; } } Future getDeviceCollectionThumbnail(String pathID) async { debugPrint("Call fallback method to get potential thumbnail"); final db = await database; final fileRows = await db.rawQuery( '''SELECT * FROM FILES f JOIN device_files df on f.local_id = df.id and df.path_id= ? order by f.creation_time DESC limit 1; ''', [pathID], ); final files = convertToFiles(fileRows); if (files.isNotEmpty) { return files.first; } else { return null; } } }