import 'dart:io'; import 'package:logging/logging.dart'; import 'package:photos/models/file_type.dart'; import 'package:photos/models/location.dart'; import 'package:photos/models/file.dart'; import 'package:path/path.dart'; import 'package:sqflite/sqflite.dart'; import 'package:path_provider/path_provider.dart'; class FilesDB { static final _databaseName = "ente.files.db"; static final _databaseVersion = 1; static final Logger _logger = Logger("FilesDB"); static final table = 'files'; static final columnGeneratedID = '_id'; static final columnUploadedFileID = 'uploaded_file_id'; static final columnOwnerID = 'owner_id'; static final columnCollectionID = 'collection_id'; static final columnLocalID = 'local_id'; static final columnTitle = 'title'; static final columnDeviceFolder = 'device_folder'; static final columnLatitude = 'latitude'; static final columnLongitude = 'longitude'; static final columnFileType = 'file_type'; static final columnIsEncrypted = 'is_encrypted'; static final columnIsDeleted = 'is_deleted'; static final columnCreationTime = 'creation_time'; static final columnModificationTime = 'modification_time'; static final columnUpdationTime = 'updation_time'; static final columnEncryptedKey = 'encrypted_key'; static final columnKeyDecryptionNonce = 'key_decryption_nonce'; static final columnFileDecryptionHeader = 'file_decryption_header'; static final columnThumbnailDecryptionHeader = 'thumbnail_decryption_header'; static final columnMetadataDecryptionHeader = 'metadata_decryption_header'; // make this a singleton class FilesDB._privateConstructor(); static final FilesDB instance = FilesDB._privateConstructor(); // only have a single app-wide reference to the database static Database _database; Future get database async { if (_database != null) return _database; // lazily instantiate the db the first time it is accessed _database = await _initDatabase(); return _database; } // this opens the database (and creates it if it doesn't exist) _initDatabase() async { Directory documentsDirectory = await getApplicationDocumentsDirectory(); String path = join(documentsDirectory.path, _databaseName); return await openDatabase(path, version: _databaseVersion, onCreate: _onCreate); } // SQL code to create the database table Future _onCreate(Database db, int version) async { await db.execute(''' CREATE TABLE $table ( $columnGeneratedID INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, $columnLocalID TEXT, $columnUploadedFileID INTEGER, $columnOwnerID INTEGER, $columnCollectionID INTEGER, $columnTitle TEXT NOT NULL, $columnDeviceFolder TEXT NOT NULL, $columnLatitude REAL, $columnLongitude REAL, $columnFileType INTEGER, $columnIsEncrypted INTEGER DEFAULT 1, $columnModificationTime TEXT NOT NULL, $columnEncryptedKey TEXT, $columnKeyDecryptionNonce TEXT, $columnFileDecryptionHeader TEXT, $columnThumbnailDecryptionHeader TEXT, $columnMetadataDecryptionHeader TEXT, $columnIsDeleted INTEGER DEFAULT 0, $columnCreationTime TEXT NOT NULL, $columnUpdationTime TEXT, UNIQUE($columnUploadedFileID, $columnCollectionID) ); CREATE INDEX collection_id_index ON $table($columnCollectionID); CREATE INDEX device_folder_index ON $table($columnDeviceFolder); CREATE INDEX creation_time_index ON $table($columnCreationTime); CREATE INDEX updation_time_index ON $table($columnUpdationTime); '''); } Future insert(File file) async { final db = await instance.database; return await db.insert(table, _getRowForFile(file)); } Future insertMultiple(List files) async { final db = await instance.database; var batch = db.batch(); int batchCounter = 0; for (File file in files) { if (batchCounter == 400) { await batch.commit(); batch = db.batch(); } batch.insert( table, _getRowForFile(file), conflictAlgorithm: ConflictAlgorithm.replace, ); batchCounter++; } await batch.commit(noResult: true); } Future getFile(int generatedID) async { final db = await instance.database; final results = await db.query(table, where: '$columnGeneratedID = ?', whereArgs: [generatedID]); if (results.isEmpty) { return null; } return _convertToFiles(results)[0]; } Future getUploadedFile(int uploadedID, int collectionID) async { final db = await instance.database; final results = await db.query( table, where: '$columnUploadedFileID = ? AND $columnCollectionID = ?', whereArgs: [ uploadedID, collectionID, ], ); if (results.isEmpty) { return null; } return _convertToFiles(results)[0]; } Future> getDeduplicatedFiles() async { _logger.info("Getting files for collection"); final db = await instance.database; final results = await db.query(table, where: '$columnIsDeleted = 0', orderBy: '$columnCreationTime DESC', groupBy: 'IFNULL($columnUploadedFileID, $columnGeneratedID), IFNULL($columnLocalID, $columnGeneratedID)'); return _convertToFiles(results); } Future> getFiles() async { final db = await instance.database; final results = await db.query( table, where: '$columnIsDeleted = 0', orderBy: '$columnCreationTime DESC', ); return _convertToFiles(results); } Future> getAllVideos() async { final db = await instance.database; final results = await db.query( table, where: '$columnLocalID IS NOT NULL AND $columnFileType = 1 AND $columnIsDeleted = 0', orderBy: '$columnCreationTime DESC', ); return _convertToFiles(results); } Future> getAllInCollectionBeforeCreationTime( int collectionID, int beforeCreationTime, int limit) async { final db = await instance.database; final results = await db.query( table, where: '$columnCollectionID = ? AND $columnIsDeleted = 0 AND $columnCreationTime < ?', whereArgs: [collectionID, beforeCreationTime], orderBy: '$columnCreationTime DESC', limit: limit, ); return _convertToFiles(results); } Future> getAllInPath(String path) async { final db = await instance.database; final results = await db.query( table, where: '$columnLocalID IS NOT NULL AND $columnDeviceFolder = ? AND $columnIsDeleted = 0', whereArgs: [path], orderBy: '$columnCreationTime DESC', groupBy: '$columnLocalID', ); return _convertToFiles(results); } Future> getAllInPathBeforeCreationTime( String path, int beforeCreationTime, int limit) async { final db = await instance.database; final results = await db.query( table, where: '$columnLocalID IS NOT NULL AND $columnDeviceFolder = ? AND $columnIsDeleted = 0 AND $columnCreationTime < ?', whereArgs: [path, beforeCreationTime], orderBy: '$columnCreationTime DESC', groupBy: '$columnLocalID', limit: limit, ); return _convertToFiles(results); } Future> getAllInCollection(int collectionID) async { final db = await instance.database; final results = await db.query( table, where: '$columnCollectionID = ?', whereArgs: [collectionID], orderBy: '$columnCreationTime DESC', ); return _convertToFiles(results); } Future> getFilesCreatedWithinDuration( int startCreationTime, int endCreationTime) async { final db = await instance.database; final results = await db.query( table, where: '$columnCreationTime > ? AND $columnCreationTime < ? AND $columnIsDeleted = 0', whereArgs: [startCreationTime, endCreationTime], orderBy: '$columnCreationTime ASC', ); return _convertToFiles(results); } Future> getDeletedFileIDs() async { final db = await instance.database; final rows = await db.query( table, columns: [columnUploadedFileID], distinct: true, where: '$columnIsDeleted = 1', orderBy: '$columnCreationTime DESC', ); final result = List(); for (final row in rows) { result.add(row[columnUploadedFileID]); } return result; } Future> getFilesToBeUploadedWithinFolders( Set folders) async { final db = await instance.database; String inParam = ""; for (final folder in folders) { inParam += "'" + folder + "',"; } inParam = inParam.substring(0, inParam.length - 1); final results = await db.query( table, where: '$columnUploadedFileID IS NULL AND $columnDeviceFolder IN ($inParam)', orderBy: '$columnCreationTime DESC', ); return _convertToFiles(results); } Future> getUploadedFileIDsToBeUpdated() async { final db = await instance.database; final rows = await db.query( table, columns: [columnUploadedFileID], where: '($columnLocalID IS NOT NULL AND $columnUploadedFileID IS NOT NULL AND $columnUpdationTime IS NULL AND $columnIsDeleted = 0)', orderBy: '$columnCreationTime DESC', distinct: true, ); final uploadedFileIDs = List(); for (final row in rows) { uploadedFileIDs.add(row[columnUploadedFileID]); } return uploadedFileIDs; } Future getUploadedFileInAnyCollection(int uploadedFileID) async { final db = await instance.database; final results = await db.query( table, where: '$columnUploadedFileID = ?', whereArgs: [ uploadedFileID, ], limit: 1, ); if (results.isEmpty) { return null; } return _convertToFiles(results)[0]; } Future> getExistingLocalFileIDs() async { final db = await instance.database; final rows = await db.query( table, columns: [columnLocalID], distinct: true, where: '$columnLocalID IS NOT NULL', ); final result = Set(); for (final row in rows) { result.add(row[columnLocalID]); } return result; } Future updateUploadedFile( String localID, String title, Location location, int creationTime, int modificationTime, int updationTime, ) async { final db = await instance.database; return await db.update( table, { columnTitle: title, columnLatitude: location.latitude, columnLongitude: location.longitude, columnCreationTime: creationTime, columnModificationTime: modificationTime, columnUpdationTime: updationTime, }, where: '$columnLocalID = ?', whereArgs: [localID], ); } Future> getLastCreatedFilesInCollections( List collectionIDs) async { final db = await instance.database; final rows = await db.rawQuery(''' SELECT $columnGeneratedID, $columnLocalID, $columnUploadedFileID, $columnOwnerID, $columnCollectionID, $columnTitle, $columnDeviceFolder, $columnLatitude, $columnLongitude, $columnFileType, $columnIsEncrypted, $columnModificationTime, $columnEncryptedKey, $columnKeyDecryptionNonce, $columnFileDecryptionHeader, $columnThumbnailDecryptionHeader, $columnMetadataDecryptionHeader, $columnIsDeleted, $columnUpdationTime, MAX($columnCreationTime) as $columnCreationTime FROM $table WHERE $columnCollectionID IN (${collectionIDs.join(', ')}) AND $columnIsDeleted = 0 GROUP BY $columnCollectionID ORDER BY $columnCreationTime DESC; '''); final result = Map(); final files = _convertToFiles(rows); for (final file in files) { result[file.collectionID] = file; } return result; } Future> getLastUpdatedFilesInCollections( List collectionIDs) async { final db = await instance.database; final rows = await db.rawQuery(''' SELECT $columnGeneratedID, $columnLocalID, $columnUploadedFileID, $columnOwnerID, $columnCollectionID, $columnTitle, $columnDeviceFolder, $columnLatitude, $columnLongitude, $columnFileType, $columnIsEncrypted, $columnModificationTime, $columnEncryptedKey, $columnKeyDecryptionNonce, $columnFileDecryptionHeader, $columnThumbnailDecryptionHeader, $columnMetadataDecryptionHeader, $columnIsDeleted, $columnCreationTime, MAX($columnUpdationTime) AS $columnUpdationTime FROM $table WHERE $columnCollectionID IN (${collectionIDs.join(', ')}) AND $columnIsDeleted = 0 GROUP BY $columnCollectionID ORDER BY $columnUpdationTime DESC; '''); final result = Map(); final files = _convertToFiles(rows); for (final file in files) { result[file.collectionID] = file; } return result; } Future> getMatchingFiles( String title, String deviceFolder, int creationTime, int modificationTime, ) async { final db = await instance.database; final rows = await db.query( table, where: '''$columnTitle=? AND $columnDeviceFolder=? AND $columnCreationTime=? AND $columnModificationTime=?''', whereArgs: [ title, deviceFolder, creationTime, modificationTime, ], ); if (rows.isNotEmpty) { return _convertToFiles(rows); } else { return null; } } Future getMatchingRemoteFile(int uploadedFileID) async { final db = await instance.database; final rows = await db.query( table, where: '$columnUploadedFileID=?', whereArgs: [uploadedFileID], ); if (rows.isNotEmpty) { return _getFileFromRow(rows[0]); } else { throw ("No matching file found"); } } Future update(File file) async { final db = await instance.database; return await db.update( table, _getRowForFile(file), where: '$columnGeneratedID = ?', whereArgs: [file.generatedID], ); } Future updateUploadedFileAcrossCollections(File file) async { final db = await instance.database; return await db.update( table, _getRowForFileWithoutCollection(file), where: '$columnUploadedFileID = ?', whereArgs: [file.uploadedFileID], ); } Future markForDeletion(int uploadedFileID) async { final db = await instance.database; final values = new Map(); values[columnIsDeleted] = 1; return db.update( table, values, where: '$columnUploadedFileID =?', whereArgs: [uploadedFileID], ); } Future delete(int uploadedFileID) async { final db = await instance.database; return db.delete( table, where: '$columnUploadedFileID =?', whereArgs: [uploadedFileID], ); } Future deleteLocalFile(String localID) async { final db = await instance.database; return db.delete( table, where: '$columnLocalID =?', whereArgs: [localID], ); } Future deleteFromCollection(int uploadedFileID, int collectionID) async { final db = await instance.database; return db.delete( table, where: '$columnUploadedFileID = ? AND $columnCollectionID = ?', whereArgs: [uploadedFileID, collectionID], ); } Future deleteCollection(int collectionID) async { final db = await instance.database; return db.delete( table, where: '$columnCollectionID = ?', whereArgs: [collectionID], ); } Future removeFromCollection(int collectionID, List fileIDs) async { final db = await instance.database; return db.delete( table, where: '$columnCollectionID =? AND $columnUploadedFileID IN (${fileIDs.join(', ')})', whereArgs: [collectionID], ); } Future> getLocalPaths() async { final db = await instance.database; final rows = await db.query( table, columns: [columnDeviceFolder], where: '$columnLocalID IS NOT NULL', distinct: true, ); List result = List(); for (final row in rows) { result.add(row[columnDeviceFolder]); } return result; } Future getLatestFileInCollection(int collectionID) async { final db = await instance.database; final rows = await db.query( table, where: '$columnCollectionID = ? AND $columnIsDeleted = 0', whereArgs: [collectionID], orderBy: '$columnCreationTime DESC', limit: 1, ); if (rows.isNotEmpty) { return _getFileFromRow(rows[0]); } else { return null; } } Future getLastCreatedFileInPath(String path) async { final db = await instance.database; final rows = await db.query( table, where: '$columnDeviceFolder = ? AND $columnLocalID IS NOT NULL AND $columnIsDeleted = 0', whereArgs: [path], orderBy: '$columnCreationTime DESC', limit: 1, ); if (rows.isNotEmpty) { return _getFileFromRow(rows[0]); } else { return null; } } Future getLastModifiedFileInCollection(int collectionID) async { final db = await instance.database; final rows = await db.query( table, where: '$columnCollectionID = ? AND $columnIsDeleted = 0', whereArgs: [collectionID], orderBy: '$columnUpdationTime DESC', limit: 1, ); if (rows.isNotEmpty) { return _getFileFromRow(rows[0]); } else { return null; } } Future doesFileExistInCollection( int uploadedFileID, int collectionID) async { final db = await instance.database; final rows = await db.query( table, where: '$columnUploadedFileID = ? AND $columnCollectionID = ?', whereArgs: [uploadedFileID, collectionID], limit: 1, ); return rows.isNotEmpty; } List _convertToFiles(List> results) { final files = List(); for (final result in results) { files.add(_getFileFromRow(result)); } return files; } Map _getRowForFile(File file) { final row = new Map(); row[columnLocalID] = file.localID; row[columnUploadedFileID] = file.uploadedFileID; row[columnOwnerID] = file.ownerID; row[columnCollectionID] = file.collectionID; row[columnTitle] = file.title; row[columnDeviceFolder] = file.deviceFolder; if (file.location != null) { row[columnLatitude] = file.location.latitude; row[columnLongitude] = file.location.longitude; } switch (file.fileType) { case FileType.image: row[columnFileType] = 0; break; case FileType.video: row[columnFileType] = 1; break; default: row[columnFileType] = -1; } row[columnIsEncrypted] = file.isEncrypted ? 1 : 0; row[columnCreationTime] = file.creationTime; row[columnModificationTime] = file.modificationTime; row[columnUpdationTime] = file.updationTime; row[columnEncryptedKey] = file.encryptedKey; row[columnKeyDecryptionNonce] = file.keyDecryptionNonce; row[columnFileDecryptionHeader] = file.fileDecryptionHeader; row[columnThumbnailDecryptionHeader] = file.thumbnailDecryptionHeader; row[columnMetadataDecryptionHeader] = file.metadataDecryptionHeader; return row; } Map _getRowForFileWithoutCollection(File file) { final row = new Map(); row[columnLocalID] = file.localID; row[columnUploadedFileID] = file.uploadedFileID; row[columnOwnerID] = file.ownerID; row[columnTitle] = file.title; row[columnDeviceFolder] = file.deviceFolder; if (file.location != null) { row[columnLatitude] = file.location.latitude; row[columnLongitude] = file.location.longitude; } switch (file.fileType) { case FileType.image: row[columnFileType] = 0; break; case FileType.video: row[columnFileType] = 1; break; default: row[columnFileType] = -1; } row[columnIsEncrypted] = file.isEncrypted ? 1 : 0; row[columnCreationTime] = file.creationTime; row[columnModificationTime] = file.modificationTime; row[columnUpdationTime] = file.updationTime; row[columnFileDecryptionHeader] = file.fileDecryptionHeader; row[columnThumbnailDecryptionHeader] = file.thumbnailDecryptionHeader; row[columnMetadataDecryptionHeader] = file.metadataDecryptionHeader; return row; } File _getFileFromRow(Map row) { final file = File(); file.generatedID = row[columnGeneratedID]; file.localID = row[columnLocalID]; file.uploadedFileID = row[columnUploadedFileID]; file.ownerID = row[columnOwnerID]; file.collectionID = row[columnCollectionID]; file.title = row[columnTitle]; file.deviceFolder = row[columnDeviceFolder]; if (row[columnLatitude] != null && row[columnLongitude] != null) { file.location = Location(row[columnLatitude], row[columnLongitude]); } file.fileType = getFileType(row[columnFileType]); file.isEncrypted = row[columnIsEncrypted] == 1; file.creationTime = int.parse(row[columnCreationTime]); file.modificationTime = int.parse(row[columnModificationTime]); file.updationTime = row[columnUpdationTime] == null ? -1 : int.parse(row[columnUpdationTime]); file.encryptedKey = row[columnEncryptedKey]; file.keyDecryptionNonce = row[columnKeyDecryptionNonce]; file.fileDecryptionHeader = row[columnFileDecryptionHeader]; file.thumbnailDecryptionHeader = row[columnThumbnailDecryptionHeader]; file.metadataDecryptionHeader = row[columnMetadataDecryptionHeader]; return file; } }