ente/mobile/lib/db/device_files_db.dart
2024-04-15 12:12:37 +05:30

450 lines
15 KiB
Dart

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<void> insertPathIDToLocalIDMapping(
Map<String, Set<String>> 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<void> deletePathIDToLocalIDMapping(
Map<String, Set<String>> 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<Map<String, int>> 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 = <String, int>{};
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<Map<String, Set<String>>> getDevicePathIDToLocalIDMap() async {
try {
final db = await database;
final rows = await db.rawQuery(
''' SELECT id, path_id FROM device_files; ''',
);
final result = <String, Set<String>>{};
for (final row in rows) {
final String pathID = row['path_id'] as String;
if (!result.containsKey(pathID)) {
result[pathID] = <String>{};
}
result[pathID]!.add(row['id'] as String);
}
return result;
} catch (e) {
_logger.severe("failed to getDevicePathIDToLocalIDMap", e);
rethrow;
}
}
Future<Set<String>> getDevicePathIDs() async {
final Database db = await database;
final rows = await db.rawQuery(
'''
SELECT id FROM device_collections
''',
);
final Set<String> result = <String>{};
for (final row in rows) {
result.add(row['id'] as String);
}
return result;
}
Future<void> insertLocalAssets(
List<LocalPathAsset> localPathAssets, {
bool shouldAutoBackup = false,
}) async {
final Database db = await database;
final Map<String, Set<String>> pathIDToLocalIDsMap = {};
try {
final batch = db.batch();
final Set<String> 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<bool> updateDeviceCoverWithCount(
List<Tuple2<AssetPathEntity, String>> devicePathInfo, {
bool shouldBackup = false,
}) async {
bool hasUpdated = false;
try {
final Database db = await database;
final Set<String> existingPathIds = await getDevicePathIDs();
for (Tuple2<AssetPathEntity, String> 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<Set<int>> 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<int> result = <int>{};
for (final row in rows) {
result.add(row['collection_id'] as int);
}
return result;
}
Future<void> updateDevicePathSyncStatus(Map<String, bool> 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<void> updateDeviceCollection(
String pathID,
int collectionID,
) async {
final db = await database;
await db.update(
"device_collections",
{"collection_id": collectionID},
where: 'id = ?',
whereArgs: [pathID],
);
return;
}
Future<FileLoadResult> 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<BackedUpFileIDs> 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 = <String>{};
final uploadedIDs = <int>{};
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<List<DeviceCollection>> getDeviceCollections({
bool includeCoverThumbnail = false,
}) async {
debugPrint(
"Fetching DeviceCollections From DB with thumbnail = "
"$includeCoverThumbnail",
);
try {
final db = await database;
final coverFiles = <EnteFile>[];
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<DeviceCollection> 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<EnteFile?> 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;
}
}
}