mirror of
https://github.com/immich-app/immich.git
synced 2025-09-29 15:31:13 -04:00
334 lines
12 KiB
Dart
334 lines
12 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
import 'dart:io';
|
|
|
|
import 'package:collection/collection.dart';
|
|
import 'package:drift/drift.dart';
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
|
|
import 'package:immich_mobile/domain/models/store.model.dart';
|
|
import 'package:immich_mobile/entities/album.entity.dart';
|
|
import 'package:immich_mobile/entities/android_device_asset.entity.dart';
|
|
import 'package:immich_mobile/entities/asset.entity.dart';
|
|
import 'package:immich_mobile/entities/backup_album.entity.dart' as isar_backup_album;
|
|
import 'package:immich_mobile/entities/etag.entity.dart';
|
|
import 'package:immich_mobile/entities/ios_device_asset.entity.dart';
|
|
import 'package:immich_mobile/entities/store.entity.dart';
|
|
import 'package:immich_mobile/infrastructure/entities/device_asset.entity.dart';
|
|
import 'package:immich_mobile/infrastructure/entities/exif.entity.dart';
|
|
import 'package:immich_mobile/infrastructure/entities/local_album.entity.drift.dart';
|
|
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart';
|
|
import 'package:immich_mobile/infrastructure/entities/store.entity.dart';
|
|
import 'package:immich_mobile/infrastructure/entities/store.entity.drift.dart';
|
|
import 'package:immich_mobile/infrastructure/entities/user.entity.dart';
|
|
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
|
import 'package:immich_mobile/utils/diff.dart';
|
|
import 'package:isar/isar.dart';
|
|
// ignore: import_rule_photo_manager
|
|
import 'package:photo_manager/photo_manager.dart';
|
|
|
|
const int targetVersion = 15;
|
|
|
|
Future<void> migrateDatabaseIfNeeded(Isar db, Drift drift) async {
|
|
final hasVersion = Store.tryGet(StoreKey.version) != null;
|
|
final int version = Store.get(StoreKey.version, targetVersion);
|
|
if (version < 9) {
|
|
await Store.put(StoreKey.version, targetVersion);
|
|
final value = await db.storeValues.get(StoreKey.currentUser.id);
|
|
if (value != null) {
|
|
final id = value.intValue;
|
|
if (id != null) {
|
|
await db.writeTxn(() async {
|
|
final user = await db.users.get(id);
|
|
await db.storeValues.put(StoreValue(StoreKey.currentUser.id, strValue: user?.id));
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
if (version < 10) {
|
|
await Store.put(StoreKey.version, targetVersion);
|
|
await _migrateDeviceAsset(db);
|
|
}
|
|
|
|
if (version < 13) {
|
|
await Store.put(StoreKey.photoManagerCustomFilter, true);
|
|
}
|
|
|
|
// This means that the SQLite DB is just created and has no version
|
|
if (version < 14 || !hasVersion) {
|
|
await migrateStoreToSqlite(db, drift);
|
|
await Store.populateCache();
|
|
}
|
|
|
|
// Handle migration only for this version
|
|
// TODO: remove when old timeline is removed
|
|
final needBetaMigration = Store.tryGet(StoreKey.needBetaMigration);
|
|
if (version == 15 && needBetaMigration == null) {
|
|
// Check both databases directly instead of relying on cache
|
|
|
|
final isBeta = Store.tryGet(StoreKey.betaTimeline);
|
|
final isNewInstallation = await _isNewInstallation(db, drift);
|
|
|
|
// For new installations, no migration needed
|
|
// For existing installations, only migrate if beta timeline is not enabled (null or false)
|
|
if (isNewInstallation || isBeta == true) {
|
|
await Store.put(StoreKey.needBetaMigration, false);
|
|
await Store.put(StoreKey.betaTimeline, true);
|
|
} else {
|
|
await resetDriftDatabase(drift);
|
|
await Store.put(StoreKey.needBetaMigration, true);
|
|
}
|
|
}
|
|
|
|
if (targetVersion >= 12) {
|
|
await Store.put(StoreKey.version, targetVersion);
|
|
return;
|
|
}
|
|
|
|
final shouldTruncate = version < 8 || version < targetVersion;
|
|
|
|
if (shouldTruncate) {
|
|
await _migrateTo(db, targetVersion);
|
|
}
|
|
}
|
|
|
|
Future<bool> _isNewInstallation(Isar db, Drift drift) async {
|
|
try {
|
|
final isarUserCount = await db.users.count();
|
|
if (isarUserCount > 0) {
|
|
return false;
|
|
}
|
|
|
|
final isarAssetCount = await db.assets.count();
|
|
if (isarAssetCount > 0) {
|
|
return false;
|
|
}
|
|
|
|
final driftStoreCount = await drift.storeEntity.select().get().then((list) => list.length);
|
|
if (driftStoreCount > 0) {
|
|
return false;
|
|
}
|
|
|
|
final driftAssetCount = await drift.localAssetEntity.select().get().then((list) => list.length);
|
|
if (driftAssetCount > 0) {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
} catch (error) {
|
|
debugPrint("[MIGRATION] Error checking if new installation: $error");
|
|
return false;
|
|
}
|
|
}
|
|
|
|
Future<void> _migrateTo(Isar db, int version) async {
|
|
await Store.delete(StoreKey.assetETag);
|
|
await db.writeTxn(() async {
|
|
await db.assets.clear();
|
|
await db.exifInfos.clear();
|
|
await db.albums.clear();
|
|
await db.eTags.clear();
|
|
await db.users.clear();
|
|
});
|
|
await Store.put(StoreKey.version, version);
|
|
}
|
|
|
|
Future<void> _migrateDeviceAsset(Isar db) async {
|
|
final ids = Platform.isAndroid
|
|
? (await db.androidDeviceAssets.where().findAll())
|
|
.map((a) => _DeviceAsset(assetId: a.id.toString(), hash: a.hash))
|
|
.toList()
|
|
: (await db.iOSDeviceAssets.where().findAll()).map((i) => _DeviceAsset(assetId: i.id, hash: i.hash)).toList();
|
|
|
|
final PermissionState ps = await PhotoManager.requestPermissionExtend();
|
|
if (!ps.hasAccess) {
|
|
if (kDebugMode) {
|
|
debugPrint("[MIGRATION] Photo library permission not granted. Skipping device asset migration.");
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
List<_DeviceAsset> localAssets = [];
|
|
final List<AssetPathEntity> paths = await PhotoManager.getAssetPathList(onlyAll: true);
|
|
|
|
if (paths.isEmpty) {
|
|
localAssets = (await db.assets.where().anyOf(ids, (query, id) => query.localIdEqualTo(id.assetId)).findAll())
|
|
.map((a) => _DeviceAsset(assetId: a.localId!, dateTime: a.fileModifiedAt))
|
|
.toList();
|
|
} else {
|
|
final AssetPathEntity albumWithAll = paths.first;
|
|
final int assetCount = await albumWithAll.assetCountAsync;
|
|
|
|
final List<AssetEntity> allDeviceAssets = await albumWithAll.getAssetListRange(start: 0, end: assetCount);
|
|
|
|
localAssets = allDeviceAssets.map((a) => _DeviceAsset(assetId: a.id, dateTime: a.modifiedDateTime)).toList();
|
|
}
|
|
|
|
debugPrint("[MIGRATION] Device Asset Ids length - ${ids.length}");
|
|
debugPrint("[MIGRATION] Local Asset Ids length - ${localAssets.length}");
|
|
ids.sort((a, b) => a.assetId.compareTo(b.assetId));
|
|
localAssets.sort((a, b) => a.assetId.compareTo(b.assetId));
|
|
final List<DeviceAssetEntity> toAdd = [];
|
|
await diffSortedLists(
|
|
ids,
|
|
localAssets,
|
|
compare: (a, b) => a.assetId.compareTo(b.assetId),
|
|
both: (deviceAsset, asset) {
|
|
toAdd.add(
|
|
DeviceAssetEntity(assetId: deviceAsset.assetId, hash: deviceAsset.hash!, modifiedTime: asset.dateTime!),
|
|
);
|
|
return false;
|
|
},
|
|
onlyFirst: (deviceAsset) {
|
|
if (kDebugMode) {
|
|
debugPrint('[MIGRATION] Local asset not found in DeviceAsset: ${deviceAsset.assetId}');
|
|
}
|
|
},
|
|
onlySecond: (asset) {
|
|
if (kDebugMode) {
|
|
debugPrint('[MIGRATION] Local asset not found in DeviceAsset: ${asset.assetId}');
|
|
}
|
|
},
|
|
);
|
|
|
|
if (kDebugMode) {
|
|
debugPrint("[MIGRATION] Total number of device assets migrated - ${toAdd.length}");
|
|
}
|
|
|
|
await db.writeTxn(() async {
|
|
await db.deviceAssetEntitys.putAll(toAdd);
|
|
});
|
|
}
|
|
|
|
Future<void> migrateDeviceAssetToSqlite(Isar db, Drift drift) async {
|
|
try {
|
|
final isarDeviceAssets = await db.deviceAssetEntitys.where().findAll();
|
|
await drift.batch((batch) {
|
|
for (final deviceAsset in isarDeviceAssets) {
|
|
batch.update(
|
|
drift.localAssetEntity,
|
|
LocalAssetEntityCompanion(checksum: Value(base64.encode(deviceAsset.hash))),
|
|
where: (t) => t.id.equals(deviceAsset.assetId),
|
|
);
|
|
}
|
|
});
|
|
} catch (error) {
|
|
debugPrint("[MIGRATION] Error while migrating device assets to SQLite: $error");
|
|
}
|
|
}
|
|
|
|
Future<void> migrateBackupAlbumsToSqlite(Isar db, Drift drift) async {
|
|
try {
|
|
final isarBackupAlbums = await db.backupAlbums.where().findAll();
|
|
// Recents is a virtual album on Android, and we don't have it with the new sync
|
|
// If recents is selected previously, select all albums during migration except the excluded ones
|
|
if (Platform.isAndroid) {
|
|
final recentAlbum = isarBackupAlbums.firstWhereOrNull((album) => album.id == 'isAll');
|
|
if (recentAlbum != null) {
|
|
await drift.localAlbumEntity.update().write(
|
|
const LocalAlbumEntityCompanion(backupSelection: Value(BackupSelection.selected)),
|
|
);
|
|
final excluded = isarBackupAlbums
|
|
.where((album) => album.selection == isar_backup_album.BackupSelection.exclude)
|
|
.map((album) => album.id)
|
|
.toList();
|
|
await drift.batch((batch) async {
|
|
for (final id in excluded) {
|
|
batch.update(
|
|
drift.localAlbumEntity,
|
|
const LocalAlbumEntityCompanion(backupSelection: Value(BackupSelection.excluded)),
|
|
where: (t) => t.id.equals(id),
|
|
);
|
|
}
|
|
});
|
|
return;
|
|
}
|
|
}
|
|
|
|
await drift.batch((batch) {
|
|
for (final album in isarBackupAlbums) {
|
|
batch.update(
|
|
drift.localAlbumEntity,
|
|
LocalAlbumEntityCompanion(
|
|
backupSelection: Value(switch (album.selection) {
|
|
isar_backup_album.BackupSelection.none => BackupSelection.none,
|
|
isar_backup_album.BackupSelection.select => BackupSelection.selected,
|
|
isar_backup_album.BackupSelection.exclude => BackupSelection.excluded,
|
|
}),
|
|
),
|
|
where: (t) => t.id.equals(album.id),
|
|
);
|
|
}
|
|
});
|
|
} catch (error) {
|
|
debugPrint("[MIGRATION] Error while migrating backup albums to SQLite: $error");
|
|
}
|
|
}
|
|
|
|
Future<void> migrateStoreToSqlite(Isar db, Drift drift) async {
|
|
try {
|
|
final isarStoreValues = await db.storeValues.where().findAll();
|
|
await drift.batch((batch) {
|
|
for (final storeValue in isarStoreValues) {
|
|
final companion = StoreEntityCompanion(
|
|
id: Value(storeValue.id),
|
|
stringValue: Value(storeValue.strValue),
|
|
intValue: Value(storeValue.intValue),
|
|
);
|
|
batch.insert(drift.storeEntity, companion, onConflict: DoUpdate((_) => companion));
|
|
}
|
|
});
|
|
} catch (error) {
|
|
debugPrint("[MIGRATION] Error while migrating store values to SQLite: $error");
|
|
}
|
|
}
|
|
|
|
Future<void> migrateStoreToIsar(Isar db, Drift drift) async {
|
|
try {
|
|
final driftStoreValues = await drift.storeEntity
|
|
.select()
|
|
.map((entity) => StoreValue(entity.id, intValue: entity.intValue, strValue: entity.stringValue))
|
|
.get();
|
|
|
|
await db.writeTxn(() async {
|
|
await db.storeValues.putAll(driftStoreValues);
|
|
});
|
|
} catch (error) {
|
|
debugPrint("[MIGRATION] Error while migrating store values to Isar: $error");
|
|
}
|
|
}
|
|
|
|
class _DeviceAsset {
|
|
final String assetId;
|
|
final List<int>? hash;
|
|
final DateTime? dateTime;
|
|
|
|
const _DeviceAsset({required this.assetId, this.hash, this.dateTime});
|
|
}
|
|
|
|
Future<void> resetDriftDatabase(Drift drift) async {
|
|
// https://github.com/simolus3/drift/commit/bd80a46264b6dd833ef4fd87fffc03f5a832ab41#diff-3f879e03b4a35779344ef16170b9353608dd9c42385f5402ec6035aac4dd8a04R76-R94
|
|
final database = drift.attachedDatabase;
|
|
await database.exclusively(() async {
|
|
// https://stackoverflow.com/a/65743498/25690041
|
|
await database.customStatement('PRAGMA writable_schema = 1;');
|
|
await database.customStatement('DELETE FROM sqlite_master;');
|
|
await database.customStatement('VACUUM;');
|
|
await database.customStatement('PRAGMA writable_schema = 0;');
|
|
await database.customStatement('PRAGMA integrity_check');
|
|
|
|
await database.customStatement('PRAGMA user_version = 0');
|
|
await database.beforeOpen(
|
|
// ignore: invalid_use_of_internal_member
|
|
database.resolvedEngine.executor,
|
|
OpeningDetails(null, database.schemaVersion),
|
|
);
|
|
await database.customStatement('PRAGMA user_version = ${database.schemaVersion}');
|
|
|
|
// Refresh all stream queries
|
|
database.notifyUpdates({for (final table in database.allTables) TableUpdate.onTable(table)});
|
|
});
|
|
}
|