mirror of
https://github.com/immich-app/immich
synced 2025-06-08 02:33:33 +00:00
feat(mobile): hash assets in isolate (#18924)
This commit is contained in:
parent
b46e066cc2
commit
ce6631f7e0
@ -56,6 +56,7 @@ custom_lint:
|
|||||||
allowed:
|
allowed:
|
||||||
# required / wanted
|
# required / wanted
|
||||||
- 'lib/infrastructure/repositories/album_media.repository.dart'
|
- 'lib/infrastructure/repositories/album_media.repository.dart'
|
||||||
|
- 'lib/infrastructure/repositories/storage.repository.dart'
|
||||||
- 'lib/repositories/{album,asset,file}_media.repository.dart'
|
- 'lib/repositories/{album,asset,file}_media.repository.dart'
|
||||||
# acceptable exceptions for the time being
|
# acceptable exceptions for the time being
|
||||||
- lib/entities/asset.entity.dart # to provide local AssetEntity for now
|
- lib/entities/asset.entity.dart # to provide local AssetEntity for now
|
||||||
|
@ -247,6 +247,7 @@ interface NativeSyncApi {
|
|||||||
fun getAlbums(): List<PlatformAlbum>
|
fun getAlbums(): List<PlatformAlbum>
|
||||||
fun getAssetsCountSince(albumId: String, timestamp: Long): Long
|
fun getAssetsCountSince(albumId: String, timestamp: Long): Long
|
||||||
fun getAssetsForAlbum(albumId: String, updatedTimeCond: Long?): List<PlatformAsset>
|
fun getAssetsForAlbum(albumId: String, updatedTimeCond: Long?): List<PlatformAsset>
|
||||||
|
fun hashPaths(paths: List<String>): List<ByteArray?>
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
/** The codec used by NativeSyncApi. */
|
/** The codec used by NativeSyncApi. */
|
||||||
@ -388,6 +389,23 @@ interface NativeSyncApi {
|
|||||||
channel.setMessageHandler(null)
|
channel.setMessageHandler(null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
run {
|
||||||
|
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.hashPaths$separatedMessageChannelSuffix", codec, taskQueue)
|
||||||
|
if (api != null) {
|
||||||
|
channel.setMessageHandler { message, reply ->
|
||||||
|
val args = message as List<Any?>
|
||||||
|
val pathsArg = args[0] as List<String>
|
||||||
|
val wrapped: List<Any?> = try {
|
||||||
|
listOf(api.hashPaths(pathsArg))
|
||||||
|
} catch (exception: Throwable) {
|
||||||
|
MessagesPigeonUtils.wrapError(exception)
|
||||||
|
}
|
||||||
|
reply.reply(wrapped)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
channel.setMessageHandler(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -4,7 +4,10 @@ import android.annotation.SuppressLint
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.database.Cursor
|
import android.database.Cursor
|
||||||
import android.provider.MediaStore
|
import android.provider.MediaStore
|
||||||
|
import android.util.Log
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
import java.io.FileInputStream
|
||||||
|
import java.security.MessageDigest
|
||||||
|
|
||||||
sealed class AssetResult {
|
sealed class AssetResult {
|
||||||
data class ValidAsset(val asset: PlatformAsset, val albumId: String) : AssetResult()
|
data class ValidAsset(val asset: PlatformAsset, val albumId: String) : AssetResult()
|
||||||
@ -16,6 +19,8 @@ open class NativeSyncApiImplBase(context: Context) {
|
|||||||
private val ctx: Context = context.applicationContext
|
private val ctx: Context = context.applicationContext
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
private const val TAG = "NativeSyncApiImplBase"
|
||||||
|
|
||||||
const val MEDIA_SELECTION =
|
const val MEDIA_SELECTION =
|
||||||
"(${MediaStore.Files.FileColumns.MEDIA_TYPE} = ? OR ${MediaStore.Files.FileColumns.MEDIA_TYPE} = ?)"
|
"(${MediaStore.Files.FileColumns.MEDIA_TYPE} = ? OR ${MediaStore.Files.FileColumns.MEDIA_TYPE} = ?)"
|
||||||
val MEDIA_SELECTION_ARGS = arrayOf(
|
val MEDIA_SELECTION_ARGS = arrayOf(
|
||||||
@ -34,6 +39,8 @@ open class NativeSyncApiImplBase(context: Context) {
|
|||||||
MediaStore.MediaColumns.BUCKET_ID,
|
MediaStore.MediaColumns.BUCKET_ID,
|
||||||
MediaStore.MediaColumns.DURATION
|
MediaStore.MediaColumns.DURATION
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const val HASH_BUFFER_SIZE = 2 * 1024 * 1024
|
||||||
}
|
}
|
||||||
|
|
||||||
protected fun getCursor(
|
protected fun getCursor(
|
||||||
@ -174,4 +181,24 @@ open class NativeSyncApiImplBase(context: Context) {
|
|||||||
.mapNotNull { result -> (result as? AssetResult.ValidAsset)?.asset }
|
.mapNotNull { result -> (result as? AssetResult.ValidAsset)?.asset }
|
||||||
.toList()
|
.toList()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun hashPaths(paths: List<String>): List<ByteArray?> {
|
||||||
|
val buffer = ByteArray(HASH_BUFFER_SIZE)
|
||||||
|
val digest = MessageDigest.getInstance("SHA-1")
|
||||||
|
|
||||||
|
return paths.map { path ->
|
||||||
|
try {
|
||||||
|
FileInputStream(path).use { file ->
|
||||||
|
var bytesRead: Int
|
||||||
|
while (file.read(buffer).also { bytesRead = it } > 0) {
|
||||||
|
digest.update(buffer, 0, bytesRead)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
digest.digest()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "Failed to hash file $path: $e")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
2
mobile/drift_schemas/main/drift_schema_v1.json
generated
2
mobile/drift_schemas/main/drift_schema_v1.json
generated
File diff suppressed because one or more lines are too long
@ -307,6 +307,7 @@ protocol NativeSyncApi {
|
|||||||
func getAlbums() throws -> [PlatformAlbum]
|
func getAlbums() throws -> [PlatformAlbum]
|
||||||
func getAssetsCountSince(albumId: String, timestamp: Int64) throws -> Int64
|
func getAssetsCountSince(albumId: String, timestamp: Int64) throws -> Int64
|
||||||
func getAssetsForAlbum(albumId: String, updatedTimeCond: Int64?) throws -> [PlatformAsset]
|
func getAssetsForAlbum(albumId: String, updatedTimeCond: Int64?) throws -> [PlatformAsset]
|
||||||
|
func hashPaths(paths: [String]) throws -> [FlutterStandardTypedData?]
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
|
/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
|
||||||
@ -442,5 +443,22 @@ class NativeSyncApiSetup {
|
|||||||
} else {
|
} else {
|
||||||
getAssetsForAlbumChannel.setMessageHandler(nil)
|
getAssetsForAlbumChannel.setMessageHandler(nil)
|
||||||
}
|
}
|
||||||
|
let hashPathsChannel = taskQueue == nil
|
||||||
|
? FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.hashPaths\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
|
||||||
|
: FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.hashPaths\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue)
|
||||||
|
if let api = api {
|
||||||
|
hashPathsChannel.setMessageHandler { message, reply in
|
||||||
|
let args = message as! [Any?]
|
||||||
|
let pathsArg = args[0] as! [String]
|
||||||
|
do {
|
||||||
|
let result = try api.hashPaths(paths: pathsArg)
|
||||||
|
reply(wrapResult(result))
|
||||||
|
} catch {
|
||||||
|
reply(wrapError(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
hashPathsChannel.setMessageHandler(nil)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import Photos
|
import Photos
|
||||||
|
import CryptoKit
|
||||||
|
|
||||||
struct AssetWrapper: Hashable, Equatable {
|
struct AssetWrapper: Hashable, Equatable {
|
||||||
let asset: PlatformAsset
|
let asset: PlatformAsset
|
||||||
@ -34,6 +35,8 @@ class NativeSyncApiImpl: NativeSyncApi {
|
|||||||
private let changeTokenKey = "immich:changeToken"
|
private let changeTokenKey = "immich:changeToken"
|
||||||
private let albumTypes: [PHAssetCollectionType] = [.album, .smartAlbum]
|
private let albumTypes: [PHAssetCollectionType] = [.album, .smartAlbum]
|
||||||
|
|
||||||
|
private let hashBufferSize = 2 * 1024 * 1024
|
||||||
|
|
||||||
init(with defaults: UserDefaults = .standard) {
|
init(with defaults: UserDefaults = .standard) {
|
||||||
self.defaults = defaults
|
self.defaults = defaults
|
||||||
}
|
}
|
||||||
@ -243,4 +246,24 @@ class NativeSyncApiImpl: NativeSyncApi {
|
|||||||
}
|
}
|
||||||
return assets
|
return assets
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func hashPaths(paths: [String]) throws -> [FlutterStandardTypedData?] {
|
||||||
|
return paths.map { path in
|
||||||
|
guard let file = FileHandle(forReadingAtPath: path) else {
|
||||||
|
print("Cannot open file: \(path)")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var hasher = Insecure.SHA1()
|
||||||
|
while autoreleasepool(invoking: {
|
||||||
|
let chunk = file.readData(ofLength: hashBufferSize)
|
||||||
|
guard !chunk.isEmpty else { return false }
|
||||||
|
hasher.update(data: chunk)
|
||||||
|
return true
|
||||||
|
}) { }
|
||||||
|
|
||||||
|
let digest = hasher.finalize()
|
||||||
|
return FlutterStandardTypedData(bytes: Data(digest))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -29,6 +29,8 @@ abstract interface class ILocalAlbumRepository implements IDatabaseRepository {
|
|||||||
String albumId,
|
String albumId,
|
||||||
Iterable<String> assetIdsToKeep,
|
Iterable<String> assetIdsToKeep,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
Future<List<LocalAsset>> getAssetsToHash(String albumId);
|
||||||
}
|
}
|
||||||
|
|
||||||
enum SortLocalAlbumsBy { id }
|
enum SortLocalAlbumsBy { id }
|
||||||
|
6
mobile/lib/domain/interfaces/local_asset.interface.dart
Normal file
6
mobile/lib/domain/interfaces/local_asset.interface.dart
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import 'package:immich_mobile/domain/interfaces/db.interface.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||||
|
|
||||||
|
abstract interface class ILocalAssetRepository implements IDatabaseRepository {
|
||||||
|
Future<void> updateHashes(Iterable<LocalAsset> hashes);
|
||||||
|
}
|
7
mobile/lib/domain/interfaces/storage.interface.dart
Normal file
7
mobile/lib/domain/interfaces/storage.interface.dart
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||||
|
|
||||||
|
abstract interface class IStorageRepository {
|
||||||
|
Future<File?> getFileForAsset(LocalAsset asset);
|
||||||
|
}
|
@ -1,13 +1,19 @@
|
|||||||
enum BackupSelection {
|
enum BackupSelection {
|
||||||
none,
|
none._(1),
|
||||||
selected,
|
selected._(0),
|
||||||
excluded,
|
excluded._(2);
|
||||||
|
|
||||||
|
// Used to sort albums based on the backupSelection
|
||||||
|
// selected -> none -> excluded
|
||||||
|
final int sortOrder;
|
||||||
|
const BackupSelection._(this.sortOrder);
|
||||||
}
|
}
|
||||||
|
|
||||||
class LocalAlbum {
|
class LocalAlbum {
|
||||||
final String id;
|
final String id;
|
||||||
final String name;
|
final String name;
|
||||||
final DateTime updatedAt;
|
final DateTime updatedAt;
|
||||||
|
final bool isIosSharedAlbum;
|
||||||
|
|
||||||
final int assetCount;
|
final int assetCount;
|
||||||
final BackupSelection backupSelection;
|
final BackupSelection backupSelection;
|
||||||
@ -18,6 +24,7 @@ class LocalAlbum {
|
|||||||
required this.updatedAt,
|
required this.updatedAt,
|
||||||
this.assetCount = 0,
|
this.assetCount = 0,
|
||||||
this.backupSelection = BackupSelection.none,
|
this.backupSelection = BackupSelection.none,
|
||||||
|
this.isIosSharedAlbum = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
LocalAlbum copyWith({
|
LocalAlbum copyWith({
|
||||||
@ -26,6 +33,7 @@ class LocalAlbum {
|
|||||||
DateTime? updatedAt,
|
DateTime? updatedAt,
|
||||||
int? assetCount,
|
int? assetCount,
|
||||||
BackupSelection? backupSelection,
|
BackupSelection? backupSelection,
|
||||||
|
bool? isIosSharedAlbum,
|
||||||
}) {
|
}) {
|
||||||
return LocalAlbum(
|
return LocalAlbum(
|
||||||
id: id ?? this.id,
|
id: id ?? this.id,
|
||||||
@ -33,6 +41,7 @@ class LocalAlbum {
|
|||||||
updatedAt: updatedAt ?? this.updatedAt,
|
updatedAt: updatedAt ?? this.updatedAt,
|
||||||
assetCount: assetCount ?? this.assetCount,
|
assetCount: assetCount ?? this.assetCount,
|
||||||
backupSelection: backupSelection ?? this.backupSelection,
|
backupSelection: backupSelection ?? this.backupSelection,
|
||||||
|
isIosSharedAlbum: isIosSharedAlbum ?? this.isIosSharedAlbum,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -45,7 +54,8 @@ class LocalAlbum {
|
|||||||
other.name == name &&
|
other.name == name &&
|
||||||
other.updatedAt == updatedAt &&
|
other.updatedAt == updatedAt &&
|
||||||
other.assetCount == assetCount &&
|
other.assetCount == assetCount &&
|
||||||
other.backupSelection == backupSelection;
|
other.backupSelection == backupSelection &&
|
||||||
|
other.isIosSharedAlbum == isIosSharedAlbum;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -54,7 +64,8 @@ class LocalAlbum {
|
|||||||
name.hashCode ^
|
name.hashCode ^
|
||||||
updatedAt.hashCode ^
|
updatedAt.hashCode ^
|
||||||
assetCount.hashCode ^
|
assetCount.hashCode ^
|
||||||
backupSelection.hashCode;
|
backupSelection.hashCode ^
|
||||||
|
isIosSharedAlbum.hashCode;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -65,6 +76,7 @@ name: $name,
|
|||||||
updatedAt: $updatedAt,
|
updatedAt: $updatedAt,
|
||||||
assetCount: $assetCount,
|
assetCount: $assetCount,
|
||||||
backupSelection: $backupSelection,
|
backupSelection: $backupSelection,
|
||||||
|
isIosSharedAlbum: $isIosSharedAlbum
|
||||||
}''';
|
}''';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
121
mobile/lib/domain/services/hash.service.dart
Normal file
121
mobile/lib/domain/services/hash.service.dart
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:immich_mobile/constants/constants.dart';
|
||||||
|
import 'package:immich_mobile/domain/interfaces/local_album.interface.dart';
|
||||||
|
import 'package:immich_mobile/domain/interfaces/local_asset.interface.dart';
|
||||||
|
import 'package:immich_mobile/domain/interfaces/storage.interface.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||||
|
import 'package:immich_mobile/platform/native_sync_api.g.dart';
|
||||||
|
import 'package:immich_mobile/presentation/pages/dev/dev_logger.dart';
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
|
|
||||||
|
class HashService {
|
||||||
|
final int batchSizeLimit;
|
||||||
|
final int batchFileLimit;
|
||||||
|
final ILocalAlbumRepository _localAlbumRepository;
|
||||||
|
final ILocalAssetRepository _localAssetRepository;
|
||||||
|
final IStorageRepository _storageRepository;
|
||||||
|
final NativeSyncApi _nativeSyncApi;
|
||||||
|
final _log = Logger('HashService');
|
||||||
|
|
||||||
|
HashService({
|
||||||
|
required ILocalAlbumRepository localAlbumRepository,
|
||||||
|
required ILocalAssetRepository localAssetRepository,
|
||||||
|
required IStorageRepository storageRepository,
|
||||||
|
required NativeSyncApi nativeSyncApi,
|
||||||
|
this.batchSizeLimit = kBatchHashSizeLimit,
|
||||||
|
this.batchFileLimit = kBatchHashFileLimit,
|
||||||
|
}) : _localAlbumRepository = localAlbumRepository,
|
||||||
|
_localAssetRepository = localAssetRepository,
|
||||||
|
_storageRepository = storageRepository,
|
||||||
|
_nativeSyncApi = nativeSyncApi;
|
||||||
|
|
||||||
|
Future<void> hashAssets() async {
|
||||||
|
final Stopwatch stopwatch = Stopwatch()..start();
|
||||||
|
// Sorted by backupSelection followed by isCloud
|
||||||
|
final localAlbums = await _localAlbumRepository.getAll();
|
||||||
|
localAlbums.sort((a, b) {
|
||||||
|
final backupComparison =
|
||||||
|
a.backupSelection.sortOrder.compareTo(b.backupSelection.sortOrder);
|
||||||
|
|
||||||
|
if (backupComparison != 0) {
|
||||||
|
return backupComparison;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Local albums come before iCloud albums
|
||||||
|
return (a.isIosSharedAlbum ? 1 : 0).compareTo(b.isIosSharedAlbum ? 1 : 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (final album in localAlbums) {
|
||||||
|
final assetsToHash =
|
||||||
|
await _localAlbumRepository.getAssetsToHash(album.id);
|
||||||
|
if (assetsToHash.isNotEmpty) {
|
||||||
|
await _hashAssets(assetsToHash);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stopwatch.stop();
|
||||||
|
_log.info("Hashing took - ${stopwatch.elapsedMilliseconds}ms");
|
||||||
|
DLog.log("Hashing took - ${stopwatch.elapsedMilliseconds}ms");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Processes a list of [LocalAsset]s, storing their hash and updating the assets in the DB
|
||||||
|
/// with hash for those that were successfully hashed. Hashes are looked up in a table
|
||||||
|
/// [LocalAssetHashEntity] by local id. Only missing entries are newly hashed and added to the DB.
|
||||||
|
Future<void> _hashAssets(List<LocalAsset> assetsToHash) async {
|
||||||
|
int bytesProcessed = 0;
|
||||||
|
final toHash = <_AssetToPath>[];
|
||||||
|
|
||||||
|
for (final asset in assetsToHash) {
|
||||||
|
final file = await _storageRepository.getFileForAsset(asset);
|
||||||
|
if (file == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
bytesProcessed += await file.length();
|
||||||
|
toHash.add(_AssetToPath(asset: asset, path: file.path));
|
||||||
|
|
||||||
|
if (toHash.length >= batchFileLimit || bytesProcessed >= batchSizeLimit) {
|
||||||
|
await _processBatch(toHash);
|
||||||
|
toHash.clear();
|
||||||
|
bytesProcessed = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await _processBatch(toHash);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Processes a batch of assets.
|
||||||
|
Future<void> _processBatch(List<_AssetToPath> toHash) async {
|
||||||
|
if (toHash.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_log.fine("Hashing ${toHash.length} files");
|
||||||
|
|
||||||
|
final hashed = <LocalAsset>[];
|
||||||
|
final hashes =
|
||||||
|
await _nativeSyncApi.hashPaths(toHash.map((e) => e.path).toList());
|
||||||
|
|
||||||
|
for (final (index, hash) in hashes.indexed) {
|
||||||
|
final asset = toHash[index].asset;
|
||||||
|
if (hash?.length == 20) {
|
||||||
|
hashed.add(asset.copyWith(checksum: base64.encode(hash!)));
|
||||||
|
} else {
|
||||||
|
_log.warning("Failed to hash file ${asset.id}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_log.fine("Hashed ${hashed.length}/${toHash.length} assets");
|
||||||
|
DLog.log("Hashed ${hashed.length}/${toHash.length} assets");
|
||||||
|
|
||||||
|
await _localAssetRepository.updateHashes(hashed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AssetToPath {
|
||||||
|
final LocalAsset asset;
|
||||||
|
final String path;
|
||||||
|
|
||||||
|
const _AssetToPath({required this.asset, required this.path});
|
||||||
|
}
|
@ -365,6 +365,7 @@ extension on Iterable<PlatformAsset> {
|
|||||||
(e) => LocalAsset(
|
(e) => LocalAsset(
|
||||||
id: e.id,
|
id: e.id,
|
||||||
name: e.name,
|
name: e.name,
|
||||||
|
checksum: null,
|
||||||
type: AssetType.values.elementAtOrNull(e.type) ?? AssetType.other,
|
type: AssetType.values.elementAtOrNull(e.type) ?? AssetType.other,
|
||||||
createdAt: e.createdAt == null
|
createdAt: e.createdAt == null
|
||||||
? DateTime.now()
|
? DateTime.now()
|
||||||
|
@ -7,6 +7,7 @@ import 'package:worker_manager/worker_manager.dart';
|
|||||||
class BackgroundSyncManager {
|
class BackgroundSyncManager {
|
||||||
Cancelable<void>? _syncTask;
|
Cancelable<void>? _syncTask;
|
||||||
Cancelable<void>? _deviceAlbumSyncTask;
|
Cancelable<void>? _deviceAlbumSyncTask;
|
||||||
|
Cancelable<void>? _hashTask;
|
||||||
|
|
||||||
BackgroundSyncManager();
|
BackgroundSyncManager();
|
||||||
|
|
||||||
@ -45,6 +46,20 @@ class BackgroundSyncManager {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// No need to cancel the task, as it can also be run when the user logs out
|
||||||
|
Future<void> hashAssets() {
|
||||||
|
if (_hashTask != null) {
|
||||||
|
return _hashTask!.future;
|
||||||
|
}
|
||||||
|
|
||||||
|
_hashTask = runInIsolateGentle(
|
||||||
|
computation: (ref) => ref.read(hashServiceProvider).hashAssets(),
|
||||||
|
);
|
||||||
|
return _hashTask!.whenComplete(() {
|
||||||
|
_hashTask = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> syncRemote() {
|
Future<void> syncRemote() {
|
||||||
if (_syncTask != null) {
|
if (_syncTask != null) {
|
||||||
return _syncTask!.future;
|
return _syncTask!.future;
|
||||||
|
@ -9,6 +9,8 @@ class LocalAlbumEntity extends Table with DriftDefaultsMixin {
|
|||||||
TextColumn get name => text()();
|
TextColumn get name => text()();
|
||||||
DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)();
|
DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)();
|
||||||
IntColumn get backupSelection => intEnum<BackupSelection>()();
|
IntColumn get backupSelection => intEnum<BackupSelection>()();
|
||||||
|
BoolColumn get isIosSharedAlbum =>
|
||||||
|
boolean().withDefault(const Constant(false))();
|
||||||
|
|
||||||
// Used for mark & sweep
|
// Used for mark & sweep
|
||||||
BoolColumn get marker_ => boolean().nullable()();
|
BoolColumn get marker_ => boolean().nullable()();
|
||||||
|
@ -14,6 +14,7 @@ typedef $$LocalAlbumEntityTableCreateCompanionBuilder
|
|||||||
required String name,
|
required String name,
|
||||||
i0.Value<DateTime> updatedAt,
|
i0.Value<DateTime> updatedAt,
|
||||||
required i2.BackupSelection backupSelection,
|
required i2.BackupSelection backupSelection,
|
||||||
|
i0.Value<bool> isIosSharedAlbum,
|
||||||
i0.Value<bool?> marker_,
|
i0.Value<bool?> marker_,
|
||||||
});
|
});
|
||||||
typedef $$LocalAlbumEntityTableUpdateCompanionBuilder
|
typedef $$LocalAlbumEntityTableUpdateCompanionBuilder
|
||||||
@ -22,6 +23,7 @@ typedef $$LocalAlbumEntityTableUpdateCompanionBuilder
|
|||||||
i0.Value<String> name,
|
i0.Value<String> name,
|
||||||
i0.Value<DateTime> updatedAt,
|
i0.Value<DateTime> updatedAt,
|
||||||
i0.Value<i2.BackupSelection> backupSelection,
|
i0.Value<i2.BackupSelection> backupSelection,
|
||||||
|
i0.Value<bool> isIosSharedAlbum,
|
||||||
i0.Value<bool?> marker_,
|
i0.Value<bool?> marker_,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -48,6 +50,10 @@ class $$LocalAlbumEntityTableFilterComposer
|
|||||||
column: $table.backupSelection,
|
column: $table.backupSelection,
|
||||||
builder: (column) => i0.ColumnWithTypeConverterFilters(column));
|
builder: (column) => i0.ColumnWithTypeConverterFilters(column));
|
||||||
|
|
||||||
|
i0.ColumnFilters<bool> get isIosSharedAlbum => $composableBuilder(
|
||||||
|
column: $table.isIosSharedAlbum,
|
||||||
|
builder: (column) => i0.ColumnFilters(column));
|
||||||
|
|
||||||
i0.ColumnFilters<bool> get marker_ => $composableBuilder(
|
i0.ColumnFilters<bool> get marker_ => $composableBuilder(
|
||||||
column: $table.marker_, builder: (column) => i0.ColumnFilters(column));
|
column: $table.marker_, builder: (column) => i0.ColumnFilters(column));
|
||||||
}
|
}
|
||||||
@ -75,6 +81,10 @@ class $$LocalAlbumEntityTableOrderingComposer
|
|||||||
column: $table.backupSelection,
|
column: $table.backupSelection,
|
||||||
builder: (column) => i0.ColumnOrderings(column));
|
builder: (column) => i0.ColumnOrderings(column));
|
||||||
|
|
||||||
|
i0.ColumnOrderings<bool> get isIosSharedAlbum => $composableBuilder(
|
||||||
|
column: $table.isIosSharedAlbum,
|
||||||
|
builder: (column) => i0.ColumnOrderings(column));
|
||||||
|
|
||||||
i0.ColumnOrderings<bool> get marker_ => $composableBuilder(
|
i0.ColumnOrderings<bool> get marker_ => $composableBuilder(
|
||||||
column: $table.marker_, builder: (column) => i0.ColumnOrderings(column));
|
column: $table.marker_, builder: (column) => i0.ColumnOrderings(column));
|
||||||
}
|
}
|
||||||
@ -101,6 +111,9 @@ class $$LocalAlbumEntityTableAnnotationComposer
|
|||||||
get backupSelection => $composableBuilder(
|
get backupSelection => $composableBuilder(
|
||||||
column: $table.backupSelection, builder: (column) => column);
|
column: $table.backupSelection, builder: (column) => column);
|
||||||
|
|
||||||
|
i0.GeneratedColumn<bool> get isIosSharedAlbum => $composableBuilder(
|
||||||
|
column: $table.isIosSharedAlbum, builder: (column) => column);
|
||||||
|
|
||||||
i0.GeneratedColumn<bool> get marker_ =>
|
i0.GeneratedColumn<bool> get marker_ =>
|
||||||
$composableBuilder(column: $table.marker_, builder: (column) => column);
|
$composableBuilder(column: $table.marker_, builder: (column) => column);
|
||||||
}
|
}
|
||||||
@ -139,6 +152,7 @@ class $$LocalAlbumEntityTableTableManager extends i0.RootTableManager<
|
|||||||
i0.Value<DateTime> updatedAt = const i0.Value.absent(),
|
i0.Value<DateTime> updatedAt = const i0.Value.absent(),
|
||||||
i0.Value<i2.BackupSelection> backupSelection =
|
i0.Value<i2.BackupSelection> backupSelection =
|
||||||
const i0.Value.absent(),
|
const i0.Value.absent(),
|
||||||
|
i0.Value<bool> isIosSharedAlbum = const i0.Value.absent(),
|
||||||
i0.Value<bool?> marker_ = const i0.Value.absent(),
|
i0.Value<bool?> marker_ = const i0.Value.absent(),
|
||||||
}) =>
|
}) =>
|
||||||
i1.LocalAlbumEntityCompanion(
|
i1.LocalAlbumEntityCompanion(
|
||||||
@ -146,6 +160,7 @@ class $$LocalAlbumEntityTableTableManager extends i0.RootTableManager<
|
|||||||
name: name,
|
name: name,
|
||||||
updatedAt: updatedAt,
|
updatedAt: updatedAt,
|
||||||
backupSelection: backupSelection,
|
backupSelection: backupSelection,
|
||||||
|
isIosSharedAlbum: isIosSharedAlbum,
|
||||||
marker_: marker_,
|
marker_: marker_,
|
||||||
),
|
),
|
||||||
createCompanionCallback: ({
|
createCompanionCallback: ({
|
||||||
@ -153,6 +168,7 @@ class $$LocalAlbumEntityTableTableManager extends i0.RootTableManager<
|
|||||||
required String name,
|
required String name,
|
||||||
i0.Value<DateTime> updatedAt = const i0.Value.absent(),
|
i0.Value<DateTime> updatedAt = const i0.Value.absent(),
|
||||||
required i2.BackupSelection backupSelection,
|
required i2.BackupSelection backupSelection,
|
||||||
|
i0.Value<bool> isIosSharedAlbum = const i0.Value.absent(),
|
||||||
i0.Value<bool?> marker_ = const i0.Value.absent(),
|
i0.Value<bool?> marker_ = const i0.Value.absent(),
|
||||||
}) =>
|
}) =>
|
||||||
i1.LocalAlbumEntityCompanion.insert(
|
i1.LocalAlbumEntityCompanion.insert(
|
||||||
@ -160,6 +176,7 @@ class $$LocalAlbumEntityTableTableManager extends i0.RootTableManager<
|
|||||||
name: name,
|
name: name,
|
||||||
updatedAt: updatedAt,
|
updatedAt: updatedAt,
|
||||||
backupSelection: backupSelection,
|
backupSelection: backupSelection,
|
||||||
|
isIosSharedAlbum: isIosSharedAlbum,
|
||||||
marker_: marker_,
|
marker_: marker_,
|
||||||
),
|
),
|
||||||
withReferenceMapper: (p0) => p0
|
withReferenceMapper: (p0) => p0
|
||||||
@ -218,6 +235,16 @@ class $LocalAlbumEntityTable extends i3.LocalAlbumEntity
|
|||||||
type: i0.DriftSqlType.int, requiredDuringInsert: true)
|
type: i0.DriftSqlType.int, requiredDuringInsert: true)
|
||||||
.withConverter<i2.BackupSelection>(
|
.withConverter<i2.BackupSelection>(
|
||||||
i1.$LocalAlbumEntityTable.$converterbackupSelection);
|
i1.$LocalAlbumEntityTable.$converterbackupSelection);
|
||||||
|
static const i0.VerificationMeta _isIosSharedAlbumMeta =
|
||||||
|
const i0.VerificationMeta('isIosSharedAlbum');
|
||||||
|
@override
|
||||||
|
late final i0.GeneratedColumn<bool> isIosSharedAlbum =
|
||||||
|
i0.GeneratedColumn<bool>('is_ios_shared_album', aliasedName, false,
|
||||||
|
type: i0.DriftSqlType.bool,
|
||||||
|
requiredDuringInsert: false,
|
||||||
|
defaultConstraints: i0.GeneratedColumn.constraintIsAlways(
|
||||||
|
'CHECK ("is_ios_shared_album" IN (0, 1))'),
|
||||||
|
defaultValue: const i4.Constant(false));
|
||||||
static const i0.VerificationMeta _marker_Meta =
|
static const i0.VerificationMeta _marker_Meta =
|
||||||
const i0.VerificationMeta('marker_');
|
const i0.VerificationMeta('marker_');
|
||||||
@override
|
@override
|
||||||
@ -229,7 +256,7 @@ class $LocalAlbumEntityTable extends i3.LocalAlbumEntity
|
|||||||
i0.GeneratedColumn.constraintIsAlways('CHECK ("marker" IN (0, 1))'));
|
i0.GeneratedColumn.constraintIsAlways('CHECK ("marker" IN (0, 1))'));
|
||||||
@override
|
@override
|
||||||
List<i0.GeneratedColumn> get $columns =>
|
List<i0.GeneratedColumn> get $columns =>
|
||||||
[id, name, updatedAt, backupSelection, marker_];
|
[id, name, updatedAt, backupSelection, isIosSharedAlbum, marker_];
|
||||||
@override
|
@override
|
||||||
String get aliasedName => _alias ?? actualTableName;
|
String get aliasedName => _alias ?? actualTableName;
|
||||||
@override
|
@override
|
||||||
@ -256,6 +283,12 @@ class $LocalAlbumEntityTable extends i3.LocalAlbumEntity
|
|||||||
context.handle(_updatedAtMeta,
|
context.handle(_updatedAtMeta,
|
||||||
updatedAt.isAcceptableOrUnknown(data['updated_at']!, _updatedAtMeta));
|
updatedAt.isAcceptableOrUnknown(data['updated_at']!, _updatedAtMeta));
|
||||||
}
|
}
|
||||||
|
if (data.containsKey('is_ios_shared_album')) {
|
||||||
|
context.handle(
|
||||||
|
_isIosSharedAlbumMeta,
|
||||||
|
isIosSharedAlbum.isAcceptableOrUnknown(
|
||||||
|
data['is_ios_shared_album']!, _isIosSharedAlbumMeta));
|
||||||
|
}
|
||||||
if (data.containsKey('marker')) {
|
if (data.containsKey('marker')) {
|
||||||
context.handle(_marker_Meta,
|
context.handle(_marker_Meta,
|
||||||
marker_.isAcceptableOrUnknown(data['marker']!, _marker_Meta));
|
marker_.isAcceptableOrUnknown(data['marker']!, _marker_Meta));
|
||||||
@ -279,6 +312,8 @@ class $LocalAlbumEntityTable extends i3.LocalAlbumEntity
|
|||||||
backupSelection: i1.$LocalAlbumEntityTable.$converterbackupSelection
|
backupSelection: i1.$LocalAlbumEntityTable.$converterbackupSelection
|
||||||
.fromSql(attachedDatabase.typeMapping.read(i0.DriftSqlType.int,
|
.fromSql(attachedDatabase.typeMapping.read(i0.DriftSqlType.int,
|
||||||
data['${effectivePrefix}backup_selection'])!),
|
data['${effectivePrefix}backup_selection'])!),
|
||||||
|
isIosSharedAlbum: attachedDatabase.typeMapping.read(
|
||||||
|
i0.DriftSqlType.bool, data['${effectivePrefix}is_ios_shared_album'])!,
|
||||||
marker_: attachedDatabase.typeMapping
|
marker_: attachedDatabase.typeMapping
|
||||||
.read(i0.DriftSqlType.bool, data['${effectivePrefix}marker']),
|
.read(i0.DriftSqlType.bool, data['${effectivePrefix}marker']),
|
||||||
);
|
);
|
||||||
@ -305,12 +340,14 @@ class LocalAlbumEntityData extends i0.DataClass
|
|||||||
final String name;
|
final String name;
|
||||||
final DateTime updatedAt;
|
final DateTime updatedAt;
|
||||||
final i2.BackupSelection backupSelection;
|
final i2.BackupSelection backupSelection;
|
||||||
|
final bool isIosSharedAlbum;
|
||||||
final bool? marker_;
|
final bool? marker_;
|
||||||
const LocalAlbumEntityData(
|
const LocalAlbumEntityData(
|
||||||
{required this.id,
|
{required this.id,
|
||||||
required this.name,
|
required this.name,
|
||||||
required this.updatedAt,
|
required this.updatedAt,
|
||||||
required this.backupSelection,
|
required this.backupSelection,
|
||||||
|
required this.isIosSharedAlbum,
|
||||||
this.marker_});
|
this.marker_});
|
||||||
@override
|
@override
|
||||||
Map<String, i0.Expression> toColumns(bool nullToAbsent) {
|
Map<String, i0.Expression> toColumns(bool nullToAbsent) {
|
||||||
@ -323,6 +360,7 @@ class LocalAlbumEntityData extends i0.DataClass
|
|||||||
.$LocalAlbumEntityTable.$converterbackupSelection
|
.$LocalAlbumEntityTable.$converterbackupSelection
|
||||||
.toSql(backupSelection));
|
.toSql(backupSelection));
|
||||||
}
|
}
|
||||||
|
map['is_ios_shared_album'] = i0.Variable<bool>(isIosSharedAlbum);
|
||||||
if (!nullToAbsent || marker_ != null) {
|
if (!nullToAbsent || marker_ != null) {
|
||||||
map['marker'] = i0.Variable<bool>(marker_);
|
map['marker'] = i0.Variable<bool>(marker_);
|
||||||
}
|
}
|
||||||
@ -338,6 +376,7 @@ class LocalAlbumEntityData extends i0.DataClass
|
|||||||
updatedAt: serializer.fromJson<DateTime>(json['updatedAt']),
|
updatedAt: serializer.fromJson<DateTime>(json['updatedAt']),
|
||||||
backupSelection: i1.$LocalAlbumEntityTable.$converterbackupSelection
|
backupSelection: i1.$LocalAlbumEntityTable.$converterbackupSelection
|
||||||
.fromJson(serializer.fromJson<int>(json['backupSelection'])),
|
.fromJson(serializer.fromJson<int>(json['backupSelection'])),
|
||||||
|
isIosSharedAlbum: serializer.fromJson<bool>(json['isIosSharedAlbum']),
|
||||||
marker_: serializer.fromJson<bool?>(json['marker_']),
|
marker_: serializer.fromJson<bool?>(json['marker_']),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -351,6 +390,7 @@ class LocalAlbumEntityData extends i0.DataClass
|
|||||||
'backupSelection': serializer.toJson<int>(i1
|
'backupSelection': serializer.toJson<int>(i1
|
||||||
.$LocalAlbumEntityTable.$converterbackupSelection
|
.$LocalAlbumEntityTable.$converterbackupSelection
|
||||||
.toJson(backupSelection)),
|
.toJson(backupSelection)),
|
||||||
|
'isIosSharedAlbum': serializer.toJson<bool>(isIosSharedAlbum),
|
||||||
'marker_': serializer.toJson<bool?>(marker_),
|
'marker_': serializer.toJson<bool?>(marker_),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -360,12 +400,14 @@ class LocalAlbumEntityData extends i0.DataClass
|
|||||||
String? name,
|
String? name,
|
||||||
DateTime? updatedAt,
|
DateTime? updatedAt,
|
||||||
i2.BackupSelection? backupSelection,
|
i2.BackupSelection? backupSelection,
|
||||||
|
bool? isIosSharedAlbum,
|
||||||
i0.Value<bool?> marker_ = const i0.Value.absent()}) =>
|
i0.Value<bool?> marker_ = const i0.Value.absent()}) =>
|
||||||
i1.LocalAlbumEntityData(
|
i1.LocalAlbumEntityData(
|
||||||
id: id ?? this.id,
|
id: id ?? this.id,
|
||||||
name: name ?? this.name,
|
name: name ?? this.name,
|
||||||
updatedAt: updatedAt ?? this.updatedAt,
|
updatedAt: updatedAt ?? this.updatedAt,
|
||||||
backupSelection: backupSelection ?? this.backupSelection,
|
backupSelection: backupSelection ?? this.backupSelection,
|
||||||
|
isIosSharedAlbum: isIosSharedAlbum ?? this.isIosSharedAlbum,
|
||||||
marker_: marker_.present ? marker_.value : this.marker_,
|
marker_: marker_.present ? marker_.value : this.marker_,
|
||||||
);
|
);
|
||||||
LocalAlbumEntityData copyWithCompanion(i1.LocalAlbumEntityCompanion data) {
|
LocalAlbumEntityData copyWithCompanion(i1.LocalAlbumEntityCompanion data) {
|
||||||
@ -376,6 +418,9 @@ class LocalAlbumEntityData extends i0.DataClass
|
|||||||
backupSelection: data.backupSelection.present
|
backupSelection: data.backupSelection.present
|
||||||
? data.backupSelection.value
|
? data.backupSelection.value
|
||||||
: this.backupSelection,
|
: this.backupSelection,
|
||||||
|
isIosSharedAlbum: data.isIosSharedAlbum.present
|
||||||
|
? data.isIosSharedAlbum.value
|
||||||
|
: this.isIosSharedAlbum,
|
||||||
marker_: data.marker_.present ? data.marker_.value : this.marker_,
|
marker_: data.marker_.present ? data.marker_.value : this.marker_,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -387,14 +432,15 @@ class LocalAlbumEntityData extends i0.DataClass
|
|||||||
..write('name: $name, ')
|
..write('name: $name, ')
|
||||||
..write('updatedAt: $updatedAt, ')
|
..write('updatedAt: $updatedAt, ')
|
||||||
..write('backupSelection: $backupSelection, ')
|
..write('backupSelection: $backupSelection, ')
|
||||||
|
..write('isIosSharedAlbum: $isIosSharedAlbum, ')
|
||||||
..write('marker_: $marker_')
|
..write('marker_: $marker_')
|
||||||
..write(')'))
|
..write(')'))
|
||||||
.toString();
|
.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get hashCode =>
|
int get hashCode => Object.hash(
|
||||||
Object.hash(id, name, updatedAt, backupSelection, marker_);
|
id, name, updatedAt, backupSelection, isIosSharedAlbum, marker_);
|
||||||
@override
|
@override
|
||||||
bool operator ==(Object other) =>
|
bool operator ==(Object other) =>
|
||||||
identical(this, other) ||
|
identical(this, other) ||
|
||||||
@ -403,6 +449,7 @@ class LocalAlbumEntityData extends i0.DataClass
|
|||||||
other.name == this.name &&
|
other.name == this.name &&
|
||||||
other.updatedAt == this.updatedAt &&
|
other.updatedAt == this.updatedAt &&
|
||||||
other.backupSelection == this.backupSelection &&
|
other.backupSelection == this.backupSelection &&
|
||||||
|
other.isIosSharedAlbum == this.isIosSharedAlbum &&
|
||||||
other.marker_ == this.marker_);
|
other.marker_ == this.marker_);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -412,12 +459,14 @@ class LocalAlbumEntityCompanion
|
|||||||
final i0.Value<String> name;
|
final i0.Value<String> name;
|
||||||
final i0.Value<DateTime> updatedAt;
|
final i0.Value<DateTime> updatedAt;
|
||||||
final i0.Value<i2.BackupSelection> backupSelection;
|
final i0.Value<i2.BackupSelection> backupSelection;
|
||||||
|
final i0.Value<bool> isIosSharedAlbum;
|
||||||
final i0.Value<bool?> marker_;
|
final i0.Value<bool?> marker_;
|
||||||
const LocalAlbumEntityCompanion({
|
const LocalAlbumEntityCompanion({
|
||||||
this.id = const i0.Value.absent(),
|
this.id = const i0.Value.absent(),
|
||||||
this.name = const i0.Value.absent(),
|
this.name = const i0.Value.absent(),
|
||||||
this.updatedAt = const i0.Value.absent(),
|
this.updatedAt = const i0.Value.absent(),
|
||||||
this.backupSelection = const i0.Value.absent(),
|
this.backupSelection = const i0.Value.absent(),
|
||||||
|
this.isIosSharedAlbum = const i0.Value.absent(),
|
||||||
this.marker_ = const i0.Value.absent(),
|
this.marker_ = const i0.Value.absent(),
|
||||||
});
|
});
|
||||||
LocalAlbumEntityCompanion.insert({
|
LocalAlbumEntityCompanion.insert({
|
||||||
@ -425,6 +474,7 @@ class LocalAlbumEntityCompanion
|
|||||||
required String name,
|
required String name,
|
||||||
this.updatedAt = const i0.Value.absent(),
|
this.updatedAt = const i0.Value.absent(),
|
||||||
required i2.BackupSelection backupSelection,
|
required i2.BackupSelection backupSelection,
|
||||||
|
this.isIosSharedAlbum = const i0.Value.absent(),
|
||||||
this.marker_ = const i0.Value.absent(),
|
this.marker_ = const i0.Value.absent(),
|
||||||
}) : id = i0.Value(id),
|
}) : id = i0.Value(id),
|
||||||
name = i0.Value(name),
|
name = i0.Value(name),
|
||||||
@ -434,6 +484,7 @@ class LocalAlbumEntityCompanion
|
|||||||
i0.Expression<String>? name,
|
i0.Expression<String>? name,
|
||||||
i0.Expression<DateTime>? updatedAt,
|
i0.Expression<DateTime>? updatedAt,
|
||||||
i0.Expression<int>? backupSelection,
|
i0.Expression<int>? backupSelection,
|
||||||
|
i0.Expression<bool>? isIosSharedAlbum,
|
||||||
i0.Expression<bool>? marker_,
|
i0.Expression<bool>? marker_,
|
||||||
}) {
|
}) {
|
||||||
return i0.RawValuesInsertable({
|
return i0.RawValuesInsertable({
|
||||||
@ -441,6 +492,7 @@ class LocalAlbumEntityCompanion
|
|||||||
if (name != null) 'name': name,
|
if (name != null) 'name': name,
|
||||||
if (updatedAt != null) 'updated_at': updatedAt,
|
if (updatedAt != null) 'updated_at': updatedAt,
|
||||||
if (backupSelection != null) 'backup_selection': backupSelection,
|
if (backupSelection != null) 'backup_selection': backupSelection,
|
||||||
|
if (isIosSharedAlbum != null) 'is_ios_shared_album': isIosSharedAlbum,
|
||||||
if (marker_ != null) 'marker': marker_,
|
if (marker_ != null) 'marker': marker_,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -450,12 +502,14 @@ class LocalAlbumEntityCompanion
|
|||||||
i0.Value<String>? name,
|
i0.Value<String>? name,
|
||||||
i0.Value<DateTime>? updatedAt,
|
i0.Value<DateTime>? updatedAt,
|
||||||
i0.Value<i2.BackupSelection>? backupSelection,
|
i0.Value<i2.BackupSelection>? backupSelection,
|
||||||
|
i0.Value<bool>? isIosSharedAlbum,
|
||||||
i0.Value<bool?>? marker_}) {
|
i0.Value<bool?>? marker_}) {
|
||||||
return i1.LocalAlbumEntityCompanion(
|
return i1.LocalAlbumEntityCompanion(
|
||||||
id: id ?? this.id,
|
id: id ?? this.id,
|
||||||
name: name ?? this.name,
|
name: name ?? this.name,
|
||||||
updatedAt: updatedAt ?? this.updatedAt,
|
updatedAt: updatedAt ?? this.updatedAt,
|
||||||
backupSelection: backupSelection ?? this.backupSelection,
|
backupSelection: backupSelection ?? this.backupSelection,
|
||||||
|
isIosSharedAlbum: isIosSharedAlbum ?? this.isIosSharedAlbum,
|
||||||
marker_: marker_ ?? this.marker_,
|
marker_: marker_ ?? this.marker_,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -477,6 +531,9 @@ class LocalAlbumEntityCompanion
|
|||||||
.$LocalAlbumEntityTable.$converterbackupSelection
|
.$LocalAlbumEntityTable.$converterbackupSelection
|
||||||
.toSql(backupSelection.value));
|
.toSql(backupSelection.value));
|
||||||
}
|
}
|
||||||
|
if (isIosSharedAlbum.present) {
|
||||||
|
map['is_ios_shared_album'] = i0.Variable<bool>(isIosSharedAlbum.value);
|
||||||
|
}
|
||||||
if (marker_.present) {
|
if (marker_.present) {
|
||||||
map['marker'] = i0.Variable<bool>(marker_.value);
|
map['marker'] = i0.Variable<bool>(marker_.value);
|
||||||
}
|
}
|
||||||
@ -490,6 +547,7 @@ class LocalAlbumEntityCompanion
|
|||||||
..write('name: $name, ')
|
..write('name: $name, ')
|
||||||
..write('updatedAt: $updatedAt, ')
|
..write('updatedAt: $updatedAt, ')
|
||||||
..write('backupSelection: $backupSelection, ')
|
..write('backupSelection: $backupSelection, ')
|
||||||
|
..write('isIosSharedAlbum: $isIosSharedAlbum, ')
|
||||||
..write('marker_: $marker_')
|
..write('marker_: $marker_')
|
||||||
..write(')'))
|
..write(')'))
|
||||||
.toString();
|
.toString();
|
||||||
|
@ -98,12 +98,24 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository
|
|||||||
name: localAlbum.name,
|
name: localAlbum.name,
|
||||||
updatedAt: Value(localAlbum.updatedAt),
|
updatedAt: Value(localAlbum.updatedAt),
|
||||||
backupSelection: localAlbum.backupSelection,
|
backupSelection: localAlbum.backupSelection,
|
||||||
|
isIosSharedAlbum: Value(localAlbum.isIosSharedAlbum),
|
||||||
);
|
);
|
||||||
|
|
||||||
return _db.transaction(() async {
|
return _db.transaction(() async {
|
||||||
await _db.localAlbumEntity
|
await _db.localAlbumEntity
|
||||||
.insertOne(companion, onConflict: DoUpdate((_) => companion));
|
.insertOne(companion, onConflict: DoUpdate((_) => companion));
|
||||||
await _addAssets(localAlbum.id, toUpsert);
|
if (toUpsert.isNotEmpty) {
|
||||||
|
await _upsertAssets(toUpsert);
|
||||||
|
await _db.localAlbumAssetEntity.insertAll(
|
||||||
|
toUpsert.map(
|
||||||
|
(a) => LocalAlbumAssetEntityCompanion.insert(
|
||||||
|
assetId: a.id,
|
||||||
|
albumId: localAlbum.id,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
mode: InsertMode.insertOrIgnore,
|
||||||
|
);
|
||||||
|
}
|
||||||
await _removeAssets(localAlbum.id, toDelete);
|
await _removeAssets(localAlbum.id, toDelete);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -122,6 +134,7 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository
|
|||||||
name: album.name,
|
name: album.name,
|
||||||
updatedAt: Value(album.updatedAt),
|
updatedAt: Value(album.updatedAt),
|
||||||
backupSelection: album.backupSelection,
|
backupSelection: album.backupSelection,
|
||||||
|
isIosSharedAlbum: Value(album.isIosSharedAlbum),
|
||||||
marker_: const Value(null),
|
marker_: const Value(null),
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -226,21 +239,52 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _addAssets(String albumId, Iterable<LocalAsset> assets) {
|
@override
|
||||||
if (assets.isEmpty) {
|
Future<List<LocalAsset>> getAssetsToHash(String albumId) {
|
||||||
|
final query = _db.localAlbumAssetEntity.select().join(
|
||||||
|
[
|
||||||
|
innerJoin(
|
||||||
|
_db.localAssetEntity,
|
||||||
|
_db.localAlbumAssetEntity.assetId.equalsExp(_db.localAssetEntity.id),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
..where(
|
||||||
|
_db.localAlbumAssetEntity.albumId.equals(albumId) &
|
||||||
|
_db.localAssetEntity.checksum.isNull(),
|
||||||
|
)
|
||||||
|
..orderBy([OrderingTerm.asc(_db.localAssetEntity.id)]);
|
||||||
|
|
||||||
|
return query
|
||||||
|
.map((row) => row.readTable(_db.localAssetEntity).toDto())
|
||||||
|
.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _upsertAssets(Iterable<LocalAsset> localAssets) {
|
||||||
|
if (localAssets.isEmpty) {
|
||||||
return Future.value();
|
return Future.value();
|
||||||
}
|
}
|
||||||
return transaction(() async {
|
|
||||||
await _upsertAssets(assets);
|
return _db.batch((batch) async {
|
||||||
await _db.localAlbumAssetEntity.insertAll(
|
for (final asset in localAssets) {
|
||||||
assets.map(
|
final companion = LocalAssetEntityCompanion.insert(
|
||||||
(a) => LocalAlbumAssetEntityCompanion.insert(
|
name: asset.name,
|
||||||
assetId: a.id,
|
type: asset.type,
|
||||||
albumId: albumId,
|
createdAt: Value(asset.createdAt),
|
||||||
),
|
updatedAt: Value(asset.updatedAt),
|
||||||
),
|
durationInSeconds: Value.absentIfNull(asset.durationInSeconds),
|
||||||
mode: InsertMode.insertOrIgnore,
|
id: asset.id,
|
||||||
|
checksum: const Value(null),
|
||||||
);
|
);
|
||||||
|
batch.insert<$LocalAssetEntityTable, LocalAssetEntityData>(
|
||||||
|
_db.localAssetEntity,
|
||||||
|
companion,
|
||||||
|
onConflict: DoUpdate(
|
||||||
|
(_) => companion,
|
||||||
|
where: (old) => old.updatedAt.isNotValue(asset.updatedAt),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -301,40 +345,14 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository
|
|||||||
return query.map((row) => row.read(assetId)!).get();
|
return query.map((row) => row.read(assetId)!).get();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _upsertAssets(Iterable<LocalAsset> localAssets) {
|
|
||||||
if (localAssets.isEmpty) {
|
|
||||||
return Future.value();
|
|
||||||
}
|
|
||||||
|
|
||||||
return _db.batch((batch) async {
|
|
||||||
batch.insertAllOnConflictUpdate(
|
|
||||||
_db.localAssetEntity,
|
|
||||||
localAssets.map(
|
|
||||||
(a) => LocalAssetEntityCompanion.insert(
|
|
||||||
name: a.name,
|
|
||||||
type: a.type,
|
|
||||||
createdAt: Value(a.createdAt),
|
|
||||||
updatedAt: Value(a.updatedAt),
|
|
||||||
durationInSeconds: Value.absentIfNull(a.durationInSeconds),
|
|
||||||
id: a.id,
|
|
||||||
checksum: Value.absentIfNull(a.checksum),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _deleteAssets(Iterable<String> ids) {
|
Future<void> _deleteAssets(Iterable<String> ids) {
|
||||||
if (ids.isEmpty) {
|
if (ids.isEmpty) {
|
||||||
return Future.value();
|
return Future.value();
|
||||||
}
|
}
|
||||||
|
|
||||||
return _db.batch(
|
return _db.batch((batch) {
|
||||||
(batch) => batch.deleteWhere(
|
batch.deleteWhere(_db.localAssetEntity, (f) => f.id.isIn(ids));
|
||||||
_db.localAssetEntity,
|
});
|
||||||
(f) => f.id.isIn(ids),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -0,0 +1,28 @@
|
|||||||
|
import 'package:drift/drift.dart';
|
||||||
|
import 'package:immich_mobile/domain/interfaces/local_asset.interface.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||||
|
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart';
|
||||||
|
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||||
|
|
||||||
|
class DriftLocalAssetRepository extends DriftDatabaseRepository
|
||||||
|
implements ILocalAssetRepository {
|
||||||
|
final Drift _db;
|
||||||
|
const DriftLocalAssetRepository(this._db) : super(_db);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> updateHashes(Iterable<LocalAsset> hashes) {
|
||||||
|
if (hashes.isEmpty) {
|
||||||
|
return Future.value();
|
||||||
|
}
|
||||||
|
|
||||||
|
return _db.batch((batch) async {
|
||||||
|
for (final asset in hashes) {
|
||||||
|
batch.update(
|
||||||
|
_db.localAssetEntity,
|
||||||
|
LocalAssetEntityCompanion(checksum: Value(asset.checksum)),
|
||||||
|
where: (e) => e.id.equals(asset.id),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,31 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:immich_mobile/domain/interfaces/storage.interface.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
|
import 'package:photo_manager/photo_manager.dart';
|
||||||
|
|
||||||
|
class StorageRepository implements IStorageRepository {
|
||||||
|
final _log = Logger('StorageRepository');
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<File?> getFileForAsset(LocalAsset asset) async {
|
||||||
|
File? file;
|
||||||
|
try {
|
||||||
|
final entity = await AssetEntity.fromId(asset.id);
|
||||||
|
file = await entity?.originFile;
|
||||||
|
if (file == null) {
|
||||||
|
_log.warning(
|
||||||
|
"Cannot get file for asset ${asset.id}, name: ${asset.name}, created on: ${asset.createdAt}",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error, stackTrace) {
|
||||||
|
_log.warning(
|
||||||
|
"Error getting file for asset ${asset.id}, name: ${asset.name}, created on: ${asset.createdAt}",
|
||||||
|
error,
|
||||||
|
stackTrace,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
}
|
@ -18,8 +18,8 @@ import 'package:immich_mobile/providers/db.provider.dart';
|
|||||||
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
|
||||||
import 'package:immich_mobile/providers/locale_provider.dart';
|
import 'package:immich_mobile/providers/locale_provider.dart';
|
||||||
import 'package:immich_mobile/providers/theme.provider.dart';
|
import 'package:immich_mobile/providers/theme.provider.dart';
|
||||||
import 'package:immich_mobile/routing/router.dart';
|
|
||||||
import 'package:immich_mobile/routing/app_navigation_observer.dart';
|
import 'package:immich_mobile/routing/app_navigation_observer.dart';
|
||||||
|
import 'package:immich_mobile/routing/router.dart';
|
||||||
import 'package:immich_mobile/services/background.service.dart';
|
import 'package:immich_mobile/services/background.service.dart';
|
||||||
import 'package:immich_mobile/services/local_notification.service.dart';
|
import 'package:immich_mobile/services/local_notification.service.dart';
|
||||||
import 'package:immich_mobile/theme/dynamic_theme.dart';
|
import 'package:immich_mobile/theme/dynamic_theme.dart';
|
||||||
|
31
mobile/lib/platform/native_sync_api.g.dart
generated
31
mobile/lib/platform/native_sync_api.g.dart
generated
@ -498,4 +498,35 @@ class NativeSyncApi {
|
|||||||
return (pigeonVar_replyList[0] as List<Object?>?)!.cast<PlatformAsset>();
|
return (pigeonVar_replyList[0] as List<Object?>?)!.cast<PlatformAsset>();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<List<Uint8List?>> hashPaths(List<String> paths) async {
|
||||||
|
final String pigeonVar_channelName =
|
||||||
|
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.hashPaths$pigeonVar_messageChannelSuffix';
|
||||||
|
final BasicMessageChannel<Object?> pigeonVar_channel =
|
||||||
|
BasicMessageChannel<Object?>(
|
||||||
|
pigeonVar_channelName,
|
||||||
|
pigeonChannelCodec,
|
||||||
|
binaryMessenger: pigeonVar_binaryMessenger,
|
||||||
|
);
|
||||||
|
final Future<Object?> pigeonVar_sendFuture =
|
||||||
|
pigeonVar_channel.send(<Object?>[paths]);
|
||||||
|
final List<Object?>? pigeonVar_replyList =
|
||||||
|
await pigeonVar_sendFuture as List<Object?>?;
|
||||||
|
if (pigeonVar_replyList == null) {
|
||||||
|
throw _createConnectionError(pigeonVar_channelName);
|
||||||
|
} else if (pigeonVar_replyList.length > 1) {
|
||||||
|
throw PlatformException(
|
||||||
|
code: pigeonVar_replyList[0]! as String,
|
||||||
|
message: pigeonVar_replyList[1] as String?,
|
||||||
|
details: pigeonVar_replyList[2],
|
||||||
|
);
|
||||||
|
} else if (pigeonVar_replyList[0] == null) {
|
||||||
|
throw PlatformException(
|
||||||
|
code: 'null-error',
|
||||||
|
message: 'Host platform returned null value for non-null return value.',
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return (pigeonVar_replyList[0] as List<Object?>?)!.cast<Uint8List?>();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:immich_mobile/domain/models/log.model.dart';
|
import 'package:immich_mobile/domain/models/log.model.dart';
|
||||||
@ -15,7 +16,6 @@ abstract final class DLog {
|
|||||||
static Stream<List<LogMessage>> watchLog() {
|
static Stream<List<LogMessage>> watchLog() {
|
||||||
final db = Isar.getInstance();
|
final db = Isar.getInstance();
|
||||||
if (db == null) {
|
if (db == null) {
|
||||||
debugPrint('Isar is not initialized');
|
|
||||||
return const Stream.empty();
|
return const Stream.empty();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -30,7 +30,6 @@ abstract final class DLog {
|
|||||||
static void clearLog() {
|
static void clearLog() {
|
||||||
final db = Isar.getInstance();
|
final db = Isar.getInstance();
|
||||||
if (db == null) {
|
if (db == null) {
|
||||||
debugPrint('Isar is not initialized');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -40,7 +39,9 @@ abstract final class DLog {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static void log(String message, [Object? error, StackTrace? stackTrace]) {
|
static void log(String message, [Object? error, StackTrace? stackTrace]) {
|
||||||
|
if (!Platform.environment.containsKey('FLUTTER_TEST')) {
|
||||||
debugPrint('[$kDevLoggerTag] [${DateTime.now()}] $message');
|
debugPrint('[$kDevLoggerTag] [${DateTime.now()}] $message');
|
||||||
|
}
|
||||||
if (error != null) {
|
if (error != null) {
|
||||||
debugPrint('Error: $error');
|
debugPrint('Error: $error');
|
||||||
}
|
}
|
||||||
@ -50,7 +51,6 @@ abstract final class DLog {
|
|||||||
|
|
||||||
final isar = Isar.getInstance();
|
final isar = Isar.getInstance();
|
||||||
if (isar == null) {
|
if (isar == null) {
|
||||||
debugPrint('Isar is not initialized');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -26,6 +26,11 @@ final _features = [
|
|||||||
icon: Icons.photo_library_rounded,
|
icon: Icons.photo_library_rounded,
|
||||||
onTap: (_, ref) => ref.read(backgroundSyncProvider).syncLocal(full: true),
|
onTap: (_, ref) => ref.read(backgroundSyncProvider).syncLocal(full: true),
|
||||||
),
|
),
|
||||||
|
_Feature(
|
||||||
|
name: 'Hash Local Assets',
|
||||||
|
icon: Icons.numbers_outlined,
|
||||||
|
onTap: (_, ref) => ref.read(backgroundSyncProvider).hashAssets(),
|
||||||
|
),
|
||||||
_Feature(
|
_Feature(
|
||||||
name: 'Sync Remote',
|
name: 'Sync Remote',
|
||||||
icon: Icons.refresh_rounded,
|
icon: Icons.refresh_rounded,
|
||||||
|
@ -4,7 +4,6 @@ import 'package:auto_route/auto_route.dart';
|
|||||||
import 'package:collection/collection.dart';
|
import 'package:collection/collection.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/domain/models/local_album.model.dart';
|
|
||||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
|
||||||
@ -94,9 +93,8 @@ class LocalMediaSummaryPage extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
FutureBuilder(
|
FutureBuilder(
|
||||||
future: albumsFuture,
|
future: albumsFuture,
|
||||||
initialData: <LocalAlbum>[],
|
|
||||||
builder: (_, snap) {
|
builder: (_, snap) {
|
||||||
final albums = snap.data!;
|
final albums = snap.data ?? [];
|
||||||
if (albums.isEmpty) {
|
if (albums.isEmpty) {
|
||||||
return const SliverToBoxAdapter(child: SizedBox.shrink());
|
return const SliverToBoxAdapter(child: SizedBox.shrink());
|
||||||
}
|
}
|
||||||
|
8
mobile/lib/providers/infrastructure/asset.provider.dart
Normal file
8
mobile/lib/providers/infrastructure/asset.provider.dart
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/domain/interfaces/local_asset.interface.dart';
|
||||||
|
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
|
||||||
|
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
|
||||||
|
|
||||||
|
final localAssetRepository = Provider<ILocalAssetRepository>(
|
||||||
|
(ref) => DriftLocalAssetRepository(ref.watch(driftProvider)),
|
||||||
|
);
|
@ -0,0 +1,7 @@
|
|||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/domain/interfaces/storage.interface.dart';
|
||||||
|
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
|
||||||
|
|
||||||
|
final storageRepositoryProvider = Provider<IStorageRepository>(
|
||||||
|
(ref) => StorageRepository(),
|
||||||
|
);
|
@ -1,13 +1,16 @@
|
|||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/domain/services/hash.service.dart';
|
||||||
import 'package:immich_mobile/domain/services/local_sync.service.dart';
|
import 'package:immich_mobile/domain/services/local_sync.service.dart';
|
||||||
import 'package:immich_mobile/domain/services/sync_stream.service.dart';
|
import 'package:immich_mobile/domain/services/sync_stream.service.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/sync_api.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/sync_api.repository.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.dart';
|
||||||
import 'package:immich_mobile/providers/api.provider.dart';
|
import 'package:immich_mobile/providers/api.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
|
||||||
|
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/cancel.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/cancel.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
|
||||||
|
import 'package:immich_mobile/providers/infrastructure/storage.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/store.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/store.provider.dart';
|
||||||
|
|
||||||
final syncStreamServiceProvider = Provider(
|
final syncStreamServiceProvider = Provider(
|
||||||
@ -33,3 +36,12 @@ final localSyncServiceProvider = Provider(
|
|||||||
storeService: ref.watch(storeServiceProvider),
|
storeService: ref.watch(storeServiceProvider),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
final hashServiceProvider = Provider(
|
||||||
|
(ref) => HashService(
|
||||||
|
localAlbumRepository: ref.watch(localAlbumRepository),
|
||||||
|
localAssetRepository: ref.watch(localAssetRepository),
|
||||||
|
storageRepository: ref.watch(storageRepositoryProvider),
|
||||||
|
nativeSyncApi: ref.watch(nativeSyncApiProvider),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
@ -1,10 +1,13 @@
|
|||||||
// ignore_for_file: avoid-unsafe-collection-methods
|
// ignore_for_file: avoid-unsafe-collection-methods
|
||||||
|
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:drift/drift.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||||
|
import 'package:immich_mobile/domain/utils/background_sync.dart';
|
||||||
import 'package:immich_mobile/entities/album.entity.dart';
|
import 'package:immich_mobile/entities/album.entity.dart';
|
||||||
import 'package:immich_mobile/entities/android_device_asset.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/asset.entity.dart';
|
||||||
@ -13,14 +16,16 @@ import 'package:immich_mobile/entities/ios_device_asset.entity.dart';
|
|||||||
import 'package:immich_mobile/entities/store.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/device_asset.entity.dart';
|
||||||
import 'package:immich_mobile/infrastructure/entities/exif.entity.dart';
|
import 'package:immich_mobile/infrastructure/entities/exif.entity.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.dart';
|
||||||
import 'package:immich_mobile/infrastructure/entities/user.entity.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:immich_mobile/utils/diff.dart';
|
||||||
import 'package:isar/isar.dart';
|
import 'package:isar/isar.dart';
|
||||||
// ignore: import_rule_photo_manager
|
// ignore: import_rule_photo_manager
|
||||||
import 'package:photo_manager/photo_manager.dart';
|
import 'package:photo_manager/photo_manager.dart';
|
||||||
|
|
||||||
const int targetVersion = 11;
|
const int targetVersion = 12;
|
||||||
|
|
||||||
Future<void> migrateDatabaseIfNeeded(Isar db) async {
|
Future<void> migrateDatabaseIfNeeded(Isar db) async {
|
||||||
final int version = Store.get(StoreKey.version, targetVersion);
|
final int version = Store.get(StoreKey.version, targetVersion);
|
||||||
@ -45,7 +50,15 @@ Future<void> migrateDatabaseIfNeeded(Isar db) async {
|
|||||||
await _migrateDeviceAsset(db);
|
await _migrateDeviceAsset(db);
|
||||||
}
|
}
|
||||||
|
|
||||||
final shouldTruncate = version < 8 && version < targetVersion;
|
if (version < 12 && (!kReleaseMode)) {
|
||||||
|
final backgroundSync = BackgroundSyncManager();
|
||||||
|
await backgroundSync.syncLocal();
|
||||||
|
final drift = Drift();
|
||||||
|
await _migrateDeviceAssetToSqlite(db, drift);
|
||||||
|
await drift.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
final shouldTruncate = version < 8 || version < targetVersion;
|
||||||
if (shouldTruncate) {
|
if (shouldTruncate) {
|
||||||
await _migrateTo(db, targetVersion);
|
await _migrateTo(db, targetVersion);
|
||||||
}
|
}
|
||||||
@ -154,6 +167,28 @@ Future<void> _migrateDeviceAsset(Isar db) async {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _migrateDeviceAssetToSqlite(Isar db, Drift drift) async {
|
||||||
|
final isarDeviceAssets =
|
||||||
|
await db.deviceAssetEntitys.where().sortByAssetId().findAll();
|
||||||
|
await drift.batch((batch) {
|
||||||
|
for (final deviceAsset in isarDeviceAssets) {
|
||||||
|
final companion = LocalAssetEntityCompanion(
|
||||||
|
updatedAt: Value(deviceAsset.modifiedTime),
|
||||||
|
id: Value(deviceAsset.assetId),
|
||||||
|
checksum: Value(base64.encode(deviceAsset.hash)),
|
||||||
|
);
|
||||||
|
batch.insert<$LocalAssetEntityTable, LocalAssetEntityData>(
|
||||||
|
drift.localAssetEntity,
|
||||||
|
companion,
|
||||||
|
onConflict: DoUpdate(
|
||||||
|
(_) => companion,
|
||||||
|
where: (old) => old.updatedAt.equals(deviceAsset.modifiedTime),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
class _DeviceAsset {
|
class _DeviceAsset {
|
||||||
final String assetId;
|
final String assetId;
|
||||||
final List<int>? hash;
|
final List<int>? hash;
|
||||||
|
@ -86,4 +86,7 @@ abstract class NativeSyncApi {
|
|||||||
|
|
||||||
@TaskQueue(type: TaskQueueType.serialBackgroundThread)
|
@TaskQueue(type: TaskQueueType.serialBackgroundThread)
|
||||||
List<PlatformAsset> getAssetsForAlbum(String albumId, {int? updatedTimeCond});
|
List<PlatformAsset> getAssetsForAlbum(String albumId, {int? updatedTimeCond});
|
||||||
|
|
||||||
|
@TaskQueue(type: TaskQueueType.serialBackgroundThread)
|
||||||
|
List<Uint8List?> hashPaths(List<String> paths);
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import 'package:immich_mobile/domain/services/store.service.dart';
|
import 'package:immich_mobile/domain/services/store.service.dart';
|
||||||
import 'package:immich_mobile/domain/services/user.service.dart';
|
import 'package:immich_mobile/domain/services/user.service.dart';
|
||||||
import 'package:immich_mobile/domain/utils/background_sync.dart';
|
import 'package:immich_mobile/domain/utils/background_sync.dart';
|
||||||
|
import 'package:immich_mobile/platform/native_sync_api.g.dart';
|
||||||
import 'package:mocktail/mocktail.dart';
|
import 'package:mocktail/mocktail.dart';
|
||||||
|
|
||||||
class MockStoreService extends Mock implements StoreService {}
|
class MockStoreService extends Mock implements StoreService {}
|
||||||
@ -8,3 +9,5 @@ class MockStoreService extends Mock implements StoreService {}
|
|||||||
class MockUserService extends Mock implements UserService {}
|
class MockUserService extends Mock implements UserService {}
|
||||||
|
|
||||||
class MockBackgroundSyncManager extends Mock implements BackgroundSyncManager {}
|
class MockBackgroundSyncManager extends Mock implements BackgroundSyncManager {}
|
||||||
|
|
||||||
|
class MockNativeSyncApi extends Mock implements NativeSyncApi {}
|
||||||
|
@ -1,425 +1,292 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'dart:math';
|
import 'dart:typed_data';
|
||||||
|
|
||||||
import 'package:collection/collection.dart';
|
|
||||||
import 'package:file/memory.dart';
|
|
||||||
import 'package:flutter/foundation.dart';
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:immich_mobile/domain/interfaces/device_asset.interface.dart';
|
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||||
import 'package:immich_mobile/domain/models/device_asset.model.dart';
|
import 'package:immich_mobile/domain/models/local_album.model.dart';
|
||||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
import 'package:immich_mobile/domain/services/hash.service.dart';
|
||||||
import 'package:immich_mobile/services/background.service.dart';
|
|
||||||
import 'package:immich_mobile/services/hash.service.dart';
|
|
||||||
import 'package:mocktail/mocktail.dart';
|
import 'package:mocktail/mocktail.dart';
|
||||||
import 'package:photo_manager/photo_manager.dart';
|
|
||||||
|
|
||||||
|
import '../../fixtures/album.stub.dart';
|
||||||
import '../../fixtures/asset.stub.dart';
|
import '../../fixtures/asset.stub.dart';
|
||||||
import '../../infrastructure/repository.mock.dart';
|
import '../../infrastructure/repository.mock.dart';
|
||||||
import '../../service.mocks.dart';
|
import '../service.mock.dart';
|
||||||
|
|
||||||
class MockAsset extends Mock implements Asset {}
|
class MockFile extends Mock implements File {}
|
||||||
|
|
||||||
class MockAssetEntity extends Mock implements AssetEntity {}
|
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
late HashService sut;
|
late HashService sut;
|
||||||
late BackgroundService mockBackgroundService;
|
late MockLocalAlbumRepository mockAlbumRepo;
|
||||||
late IDeviceAssetRepository mockDeviceAssetRepository;
|
late MockLocalAssetRepository mockAssetRepo;
|
||||||
|
late MockStorageRepository mockStorageRepo;
|
||||||
|
late MockNativeSyncApi mockNativeApi;
|
||||||
|
|
||||||
setUp(() {
|
setUp(() {
|
||||||
mockBackgroundService = MockBackgroundService();
|
mockAlbumRepo = MockLocalAlbumRepository();
|
||||||
mockDeviceAssetRepository = MockDeviceAssetRepository();
|
mockAssetRepo = MockLocalAssetRepository();
|
||||||
|
mockStorageRepo = MockStorageRepository();
|
||||||
|
mockNativeApi = MockNativeSyncApi();
|
||||||
|
|
||||||
sut = HashService(
|
sut = HashService(
|
||||||
deviceAssetRepository: mockDeviceAssetRepository,
|
localAlbumRepository: mockAlbumRepo,
|
||||||
backgroundService: mockBackgroundService,
|
localAssetRepository: mockAssetRepo,
|
||||||
|
storageRepository: mockStorageRepo,
|
||||||
|
nativeSyncApi: mockNativeApi,
|
||||||
);
|
);
|
||||||
|
|
||||||
when(() => mockDeviceAssetRepository.transaction<Null>(any()))
|
registerFallbackValue(LocalAlbumStub.recent);
|
||||||
.thenAnswer((_) async {
|
registerFallbackValue(LocalAssetStub.image1);
|
||||||
final capturedCallback = verify(
|
|
||||||
() => mockDeviceAssetRepository.transaction<Null>(captureAny()),
|
when(() => mockAssetRepo.updateHashes(any())).thenAnswer((_) async => {});
|
||||||
).captured;
|
|
||||||
// Invoke the transaction callback
|
|
||||||
await (capturedCallback.firstOrNull as Future<Null> Function()?)?.call();
|
|
||||||
});
|
|
||||||
when(() => mockDeviceAssetRepository.updateAll(any()))
|
|
||||||
.thenAnswer((_) async => true);
|
|
||||||
when(() => mockDeviceAssetRepository.deleteIds(any()))
|
|
||||||
.thenAnswer((_) async => true);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
group("HashService: No DeviceAsset entry", () {
|
group('HashService hashAssets', () {
|
||||||
test("hash successfully", () async {
|
test('processes albums in correct order', () async {
|
||||||
final (mockAsset, file, deviceAsset, hash) =
|
final album1 = LocalAlbumStub.recent
|
||||||
await _createAssetMock(AssetStub.image1);
|
.copyWith(id: "1", backupSelection: BackupSelection.none);
|
||||||
|
final album2 = LocalAlbumStub.recent
|
||||||
when(() => mockBackgroundService.digestFiles([file.path]))
|
.copyWith(id: "2", backupSelection: BackupSelection.excluded);
|
||||||
.thenAnswer((_) async => [hash]);
|
final album3 = LocalAlbumStub.recent
|
||||||
// No DB entries for this asset
|
.copyWith(id: "3", backupSelection: BackupSelection.selected);
|
||||||
when(
|
final album4 = LocalAlbumStub.recent.copyWith(
|
||||||
() => mockDeviceAssetRepository.getByIds([AssetStub.image1.localId!]),
|
id: "4",
|
||||||
).thenAnswer((_) async => []);
|
backupSelection: BackupSelection.selected,
|
||||||
|
isIosSharedAlbum: true,
|
||||||
final result = await sut.hashAssets([mockAsset]);
|
|
||||||
|
|
||||||
// Verify we stored the new hash in DB
|
|
||||||
when(() => mockDeviceAssetRepository.transaction<Null>(any()))
|
|
||||||
.thenAnswer((_) async {
|
|
||||||
final capturedCallback = verify(
|
|
||||||
() => mockDeviceAssetRepository.transaction<Null>(captureAny()),
|
|
||||||
).captured;
|
|
||||||
// Invoke the transaction callback
|
|
||||||
await (capturedCallback.firstOrNull as Future<Null> Function()?)
|
|
||||||
?.call();
|
|
||||||
verify(
|
|
||||||
() => mockDeviceAssetRepository.updateAll([
|
|
||||||
deviceAsset.copyWith(modifiedTime: AssetStub.image1.fileModifiedAt),
|
|
||||||
]),
|
|
||||||
).called(1);
|
|
||||||
verify(() => mockDeviceAssetRepository.deleteIds([])).called(1);
|
|
||||||
});
|
|
||||||
expect(
|
|
||||||
result,
|
|
||||||
[AssetStub.image1.copyWith(checksum: base64.encode(hash))],
|
|
||||||
);
|
);
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
group("HashService: Has DeviceAsset entry", () {
|
when(() => mockAlbumRepo.getAll())
|
||||||
test("when the asset is not modified", () async {
|
.thenAnswer((_) async => [album1, album2, album4, album3]);
|
||||||
final hash = utf8.encode("image1-hash");
|
when(() => mockAlbumRepo.getAssetsToHash(any()))
|
||||||
|
.thenAnswer((_) async => []);
|
||||||
|
|
||||||
when(
|
await sut.hashAssets();
|
||||||
() => mockDeviceAssetRepository.getByIds([AssetStub.image1.localId!]),
|
|
||||||
).thenAnswer(
|
|
||||||
(_) async => [
|
|
||||||
DeviceAsset(
|
|
||||||
assetId: AssetStub.image1.localId!,
|
|
||||||
hash: hash,
|
|
||||||
modifiedTime: AssetStub.image1.fileModifiedAt,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
final result = await sut.hashAssets([AssetStub.image1]);
|
|
||||||
|
|
||||||
verifyNever(() => mockBackgroundService.digestFiles(any()));
|
verifyInOrder([
|
||||||
verifyNever(() => mockBackgroundService.digestFile(any()));
|
() => mockAlbumRepo.getAll(),
|
||||||
verifyNever(() => mockDeviceAssetRepository.updateAll(any()));
|
() => mockAlbumRepo.getAssetsToHash(album3.id),
|
||||||
verifyNever(() => mockDeviceAssetRepository.deleteIds(any()));
|
() => mockAlbumRepo.getAssetsToHash(album4.id),
|
||||||
|
() => mockAlbumRepo.getAssetsToHash(album1.id),
|
||||||
expect(result, [
|
() => mockAlbumRepo.getAssetsToHash(album2.id),
|
||||||
AssetStub.image1.copyWith(checksum: base64.encode(hash)),
|
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("hashed successful when asset is modified", () async {
|
test('skips albums with no assets to hash', () async {
|
||||||
final (mockAsset, file, deviceAsset, hash) =
|
when(() => mockAlbumRepo.getAll()).thenAnswer(
|
||||||
await _createAssetMock(AssetStub.image1);
|
(_) async => [LocalAlbumStub.recent.copyWith(assetCount: 0)],
|
||||||
|
|
||||||
when(() => mockBackgroundService.digestFiles([file.path]))
|
|
||||||
.thenAnswer((_) async => [hash]);
|
|
||||||
when(
|
|
||||||
() => mockDeviceAssetRepository.getByIds([AssetStub.image1.localId!]),
|
|
||||||
).thenAnswer((_) async => [deviceAsset]);
|
|
||||||
|
|
||||||
final result = await sut.hashAssets([mockAsset]);
|
|
||||||
|
|
||||||
when(() => mockDeviceAssetRepository.transaction<Null>(any()))
|
|
||||||
.thenAnswer((_) async {
|
|
||||||
final capturedCallback = verify(
|
|
||||||
() => mockDeviceAssetRepository.transaction<Null>(captureAny()),
|
|
||||||
).captured;
|
|
||||||
// Invoke the transaction callback
|
|
||||||
await (capturedCallback.firstOrNull as Future<Null> Function()?)
|
|
||||||
?.call();
|
|
||||||
verify(
|
|
||||||
() => mockDeviceAssetRepository.updateAll([
|
|
||||||
deviceAsset.copyWith(modifiedTime: AssetStub.image1.fileModifiedAt),
|
|
||||||
]),
|
|
||||||
).called(1);
|
|
||||||
verify(() => mockDeviceAssetRepository.deleteIds([])).called(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
verify(() => mockBackgroundService.digestFiles([file.path])).called(1);
|
|
||||||
|
|
||||||
expect(result, [
|
|
||||||
AssetStub.image1.copyWith(checksum: base64.encode(hash)),
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
group("HashService: Cleanup", () {
|
|
||||||
late Asset mockAsset;
|
|
||||||
late Uint8List hash;
|
|
||||||
late DeviceAsset deviceAsset;
|
|
||||||
late File file;
|
|
||||||
|
|
||||||
setUp(() async {
|
|
||||||
(mockAsset, file, deviceAsset, hash) =
|
|
||||||
await _createAssetMock(AssetStub.image1);
|
|
||||||
|
|
||||||
when(() => mockBackgroundService.digestFiles([file.path]))
|
|
||||||
.thenAnswer((_) async => [hash]);
|
|
||||||
when(
|
|
||||||
() => mockDeviceAssetRepository.getByIds([AssetStub.image1.localId!]),
|
|
||||||
).thenAnswer((_) async => [deviceAsset]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("cleanups DeviceAsset when local file cannot be obtained", () async {
|
|
||||||
when(() => mockAsset.local).thenThrow(Exception("File not found"));
|
|
||||||
final result = await sut.hashAssets([mockAsset]);
|
|
||||||
|
|
||||||
verifyNever(() => mockBackgroundService.digestFiles(any()));
|
|
||||||
verifyNever(() => mockBackgroundService.digestFile(any()));
|
|
||||||
verifyNever(() => mockDeviceAssetRepository.updateAll(any()));
|
|
||||||
verify(
|
|
||||||
() => mockDeviceAssetRepository.deleteIds([AssetStub.image1.localId!]),
|
|
||||||
).called(1);
|
|
||||||
|
|
||||||
expect(result, isEmpty);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("cleanups DeviceAsset when hashing failed", () async {
|
|
||||||
when(() => mockDeviceAssetRepository.transaction<Null>(any()))
|
|
||||||
.thenAnswer((_) async {
|
|
||||||
final capturedCallback = verify(
|
|
||||||
() => mockDeviceAssetRepository.transaction<Null>(captureAny()),
|
|
||||||
).captured;
|
|
||||||
// Invoke the transaction callback
|
|
||||||
await (capturedCallback.firstOrNull as Future<Null> Function()?)
|
|
||||||
?.call();
|
|
||||||
|
|
||||||
// Verify the callback inside the transaction because, doing it outside results
|
|
||||||
// in a small delay before the callback is invoked, resulting in other LOCs getting executed
|
|
||||||
// resulting in an incorrect state
|
|
||||||
//
|
|
||||||
// i.e, consider the following piece of code
|
|
||||||
// await _deviceAssetRepository.transaction(() async {
|
|
||||||
// await _deviceAssetRepository.updateAll(toBeAdded);
|
|
||||||
// await _deviceAssetRepository.deleteIds(toBeDeleted);
|
|
||||||
// });
|
|
||||||
// toBeDeleted.clear();
|
|
||||||
// since the transaction method is mocked, the callback is not invoked until it is captured
|
|
||||||
// and executed manually in the next event loop. However, the toBeDeleted.clear() is executed
|
|
||||||
// immediately once the transaction stub is executed, resulting in the deleteIds method being
|
|
||||||
// called with an empty list.
|
|
||||||
//
|
|
||||||
// To avoid this, we capture the callback and execute it within the transaction stub itself
|
|
||||||
// and verify the results inside the transaction stub
|
|
||||||
verify(() => mockDeviceAssetRepository.updateAll([])).called(1);
|
|
||||||
verify(
|
|
||||||
() =>
|
|
||||||
mockDeviceAssetRepository.deleteIds([AssetStub.image1.localId!]),
|
|
||||||
).called(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
when(() => mockBackgroundService.digestFiles([file.path])).thenAnswer(
|
|
||||||
// Invalid hash, length != 20
|
|
||||||
(_) async => [Uint8List.fromList(hash.slice(2).toList())],
|
|
||||||
);
|
);
|
||||||
|
when(() => mockAlbumRepo.getAssetsToHash(LocalAlbumStub.recent.id))
|
||||||
final result = await sut.hashAssets([mockAsset]);
|
|
||||||
|
|
||||||
verify(() => mockBackgroundService.digestFiles([file.path])).called(1);
|
|
||||||
expect(result, isEmpty);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
group("HashService: Batch processing", () {
|
|
||||||
test("processes assets in batches when size limit is reached", () async {
|
|
||||||
// Setup multiple assets with large file sizes
|
|
||||||
final (mock1, mock2, mock3) = await (
|
|
||||||
_createAssetMock(AssetStub.image1),
|
|
||||||
_createAssetMock(AssetStub.image2),
|
|
||||||
_createAssetMock(AssetStub.image3),
|
|
||||||
).wait;
|
|
||||||
|
|
||||||
final (asset1, file1, deviceAsset1, hash1) = mock1;
|
|
||||||
final (asset2, file2, deviceAsset2, hash2) = mock2;
|
|
||||||
final (asset3, file3, deviceAsset3, hash3) = mock3;
|
|
||||||
|
|
||||||
when(() => mockDeviceAssetRepository.getByIds(any()))
|
|
||||||
.thenAnswer((_) async => []);
|
.thenAnswer((_) async => []);
|
||||||
|
|
||||||
// Setup for multiple batch processing calls
|
await sut.hashAssets();
|
||||||
when(() => mockBackgroundService.digestFiles([file1.path, file2.path]))
|
|
||||||
.thenAnswer((_) async => [hash1, hash2]);
|
|
||||||
when(() => mockBackgroundService.digestFiles([file3.path]))
|
|
||||||
.thenAnswer((_) async => [hash3]);
|
|
||||||
|
|
||||||
final size = await file1.length() + await file2.length();
|
verifyNever(() => mockStorageRepo.getFileForAsset(any()));
|
||||||
|
verifyNever(() => mockNativeApi.hashPaths(any()));
|
||||||
sut = HashService(
|
});
|
||||||
deviceAssetRepository: mockDeviceAssetRepository,
|
|
||||||
backgroundService: mockBackgroundService,
|
|
||||||
batchSizeLimit: size,
|
|
||||||
);
|
|
||||||
final result = await sut.hashAssets([asset1, asset2, asset3]);
|
|
||||||
|
|
||||||
// Verify multiple batch process calls
|
|
||||||
verify(() => mockBackgroundService.digestFiles([file1.path, file2.path]))
|
|
||||||
.called(1);
|
|
||||||
verify(() => mockBackgroundService.digestFiles([file3.path])).called(1);
|
|
||||||
|
|
||||||
expect(
|
|
||||||
result,
|
|
||||||
[
|
|
||||||
AssetStub.image1.copyWith(checksum: base64.encode(hash1)),
|
|
||||||
AssetStub.image2.copyWith(checksum: base64.encode(hash2)),
|
|
||||||
AssetStub.image3.copyWith(checksum: base64.encode(hash3)),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("processes assets in batches when file limit is reached", () async {
|
group('HashService _hashAssets', () {
|
||||||
// Setup multiple assets with large file sizes
|
test('skips assets without files', () async {
|
||||||
final (mock1, mock2, mock3) = await (
|
final album = LocalAlbumStub.recent;
|
||||||
_createAssetMock(AssetStub.image1),
|
final asset = LocalAssetStub.image1;
|
||||||
_createAssetMock(AssetStub.image2),
|
when(() => mockAlbumRepo.getAll()).thenAnswer((_) async => [album]);
|
||||||
_createAssetMock(AssetStub.image3),
|
when(() => mockAlbumRepo.getAssetsToHash(album.id))
|
||||||
).wait;
|
.thenAnswer((_) async => [asset]);
|
||||||
|
when(() => mockStorageRepo.getFileForAsset(asset))
|
||||||
|
.thenAnswer((_) async => null);
|
||||||
|
|
||||||
final (asset1, file1, deviceAsset1, hash1) = mock1;
|
await sut.hashAssets();
|
||||||
final (asset2, file2, deviceAsset2, hash2) = mock2;
|
|
||||||
final (asset3, file3, deviceAsset3, hash3) = mock3;
|
|
||||||
|
|
||||||
when(() => mockDeviceAssetRepository.getByIds(any()))
|
verifyNever(() => mockNativeApi.hashPaths(any()));
|
||||||
.thenAnswer((_) async => []);
|
});
|
||||||
|
|
||||||
when(() => mockBackgroundService.digestFiles([file1.path]))
|
test('processes assets when available', () async {
|
||||||
.thenAnswer((_) async => [hash1]);
|
final album = LocalAlbumStub.recent;
|
||||||
when(() => mockBackgroundService.digestFiles([file2.path]))
|
final asset = LocalAssetStub.image1;
|
||||||
.thenAnswer((_) async => [hash2]);
|
final mockFile = MockFile();
|
||||||
when(() => mockBackgroundService.digestFiles([file3.path]))
|
final hash = Uint8List.fromList(List.generate(20, (i) => i));
|
||||||
.thenAnswer((_) async => [hash3]);
|
|
||||||
|
|
||||||
sut = HashService(
|
when(() => mockFile.length()).thenAnswer((_) async => 1000);
|
||||||
deviceAssetRepository: mockDeviceAssetRepository,
|
when(() => mockFile.path).thenReturn('image-path');
|
||||||
backgroundService: mockBackgroundService,
|
|
||||||
|
when(() => mockAlbumRepo.getAll()).thenAnswer((_) async => [album]);
|
||||||
|
when(() => mockAlbumRepo.getAssetsToHash(album.id))
|
||||||
|
.thenAnswer((_) async => [asset]);
|
||||||
|
when(() => mockStorageRepo.getFileForAsset(asset))
|
||||||
|
.thenAnswer((_) async => mockFile);
|
||||||
|
when(() => mockNativeApi.hashPaths(['image-path'])).thenAnswer(
|
||||||
|
(_) async => [hash],
|
||||||
|
);
|
||||||
|
|
||||||
|
await sut.hashAssets();
|
||||||
|
|
||||||
|
verify(() => mockNativeApi.hashPaths(['image-path'])).called(1);
|
||||||
|
final captured = verify(() => mockAssetRepo.updateHashes(captureAny()))
|
||||||
|
.captured
|
||||||
|
.first as List<LocalAsset>;
|
||||||
|
expect(captured.length, 1);
|
||||||
|
expect(captured[0].checksum, base64.encode(hash));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles failed hashes', () async {
|
||||||
|
final album = LocalAlbumStub.recent;
|
||||||
|
final asset = LocalAssetStub.image1;
|
||||||
|
final mockFile = MockFile();
|
||||||
|
when(() => mockFile.length()).thenAnswer((_) async => 1000);
|
||||||
|
when(() => mockFile.path).thenReturn('image-path');
|
||||||
|
|
||||||
|
when(() => mockAlbumRepo.getAll()).thenAnswer((_) async => [album]);
|
||||||
|
when(() => mockAlbumRepo.getAssetsToHash(album.id))
|
||||||
|
.thenAnswer((_) async => [asset]);
|
||||||
|
when(() => mockStorageRepo.getFileForAsset(asset))
|
||||||
|
.thenAnswer((_) async => mockFile);
|
||||||
|
when(() => mockNativeApi.hashPaths(['image-path']))
|
||||||
|
.thenAnswer((_) async => [null]);
|
||||||
|
when(() => mockAssetRepo.updateHashes(any())).thenAnswer((_) async => {});
|
||||||
|
|
||||||
|
await sut.hashAssets();
|
||||||
|
|
||||||
|
final captured = verify(() => mockAssetRepo.updateHashes(captureAny()))
|
||||||
|
.captured
|
||||||
|
.first as List<LocalAsset>;
|
||||||
|
expect(captured.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles invalid hash length', () async {
|
||||||
|
final album = LocalAlbumStub.recent;
|
||||||
|
final asset = LocalAssetStub.image1;
|
||||||
|
final mockFile = MockFile();
|
||||||
|
when(() => mockFile.length()).thenAnswer((_) async => 1000);
|
||||||
|
when(() => mockFile.path).thenReturn('image-path');
|
||||||
|
|
||||||
|
when(() => mockAlbumRepo.getAll()).thenAnswer((_) async => [album]);
|
||||||
|
when(() => mockAlbumRepo.getAssetsToHash(album.id))
|
||||||
|
.thenAnswer((_) async => [asset]);
|
||||||
|
when(() => mockStorageRepo.getFileForAsset(asset))
|
||||||
|
.thenAnswer((_) async => mockFile);
|
||||||
|
|
||||||
|
final invalidHash = Uint8List.fromList([1, 2, 3]);
|
||||||
|
when(() => mockNativeApi.hashPaths(['image-path']))
|
||||||
|
.thenAnswer((_) async => [invalidHash]);
|
||||||
|
when(() => mockAssetRepo.updateHashes(any())).thenAnswer((_) async => {});
|
||||||
|
|
||||||
|
await sut.hashAssets();
|
||||||
|
|
||||||
|
final captured = verify(() => mockAssetRepo.updateHashes(captureAny()))
|
||||||
|
.captured
|
||||||
|
.first as List<LocalAsset>;
|
||||||
|
expect(captured.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('batches by file count limit', () async {
|
||||||
|
final sut = HashService(
|
||||||
|
localAlbumRepository: mockAlbumRepo,
|
||||||
|
localAssetRepository: mockAssetRepo,
|
||||||
|
storageRepository: mockStorageRepo,
|
||||||
|
nativeSyncApi: mockNativeApi,
|
||||||
batchFileLimit: 1,
|
batchFileLimit: 1,
|
||||||
);
|
);
|
||||||
final result = await sut.hashAssets([asset1, asset2, asset3]);
|
|
||||||
|
|
||||||
// Verify multiple batch process calls
|
final album = LocalAlbumStub.recent;
|
||||||
verify(() => mockBackgroundService.digestFiles([file1.path])).called(1);
|
final asset1 = LocalAssetStub.image1;
|
||||||
verify(() => mockBackgroundService.digestFiles([file2.path])).called(1);
|
final asset2 = LocalAssetStub.image2;
|
||||||
verify(() => mockBackgroundService.digestFiles([file3.path])).called(1);
|
final mockFile1 = MockFile();
|
||||||
|
final mockFile2 = MockFile();
|
||||||
|
when(() => mockFile1.length()).thenAnswer((_) async => 100);
|
||||||
|
when(() => mockFile1.path).thenReturn('path-1');
|
||||||
|
when(() => mockFile2.length()).thenAnswer((_) async => 100);
|
||||||
|
when(() => mockFile2.path).thenReturn('path-2');
|
||||||
|
|
||||||
expect(
|
when(() => mockAlbumRepo.getAll()).thenAnswer((_) async => [album]);
|
||||||
result,
|
when(() => mockAlbumRepo.getAssetsToHash(album.id))
|
||||||
[
|
.thenAnswer((_) async => [asset1, asset2]);
|
||||||
AssetStub.image1.copyWith(checksum: base64.encode(hash1)),
|
when(() => mockStorageRepo.getFileForAsset(asset1))
|
||||||
AssetStub.image2.copyWith(checksum: base64.encode(hash2)),
|
.thenAnswer((_) async => mockFile1);
|
||||||
AssetStub.image3.copyWith(checksum: base64.encode(hash3)),
|
when(() => mockStorageRepo.getFileForAsset(asset2))
|
||||||
],
|
.thenAnswer((_) async => mockFile2);
|
||||||
);
|
|
||||||
|
final hash = Uint8List.fromList(List.generate(20, (i) => i));
|
||||||
|
when(() => mockNativeApi.hashPaths(any()))
|
||||||
|
.thenAnswer((_) async => [hash]);
|
||||||
|
when(() => mockAssetRepo.updateHashes(any())).thenAnswer((_) async => {});
|
||||||
|
|
||||||
|
await sut.hashAssets();
|
||||||
|
|
||||||
|
verify(() => mockNativeApi.hashPaths(['path-1'])).called(1);
|
||||||
|
verify(() => mockNativeApi.hashPaths(['path-2'])).called(1);
|
||||||
|
verify(() => mockAssetRepo.updateHashes(any())).called(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("HashService: Sort & Process different states", () async {
|
test('batches by size limit', () async {
|
||||||
final (asset1, file1, deviceAsset1, hash1) =
|
final sut = HashService(
|
||||||
await _createAssetMock(AssetStub.image1); // Will need rehashing
|
localAlbumRepository: mockAlbumRepo,
|
||||||
final (asset2, file2, deviceAsset2, hash2) =
|
localAssetRepository: mockAssetRepo,
|
||||||
await _createAssetMock(AssetStub.image2); // Will have matching hash
|
storageRepository: mockStorageRepo,
|
||||||
final (asset3, file3, deviceAsset3, hash3) =
|
nativeSyncApi: mockNativeApi,
|
||||||
await _createAssetMock(AssetStub.image3); // No DB entry
|
batchSizeLimit: 80,
|
||||||
final asset4 =
|
|
||||||
AssetStub.image3.copyWith(localId: "image4"); // Cannot be hashed
|
|
||||||
|
|
||||||
when(() => mockBackgroundService.digestFiles([file1.path, file3.path]))
|
|
||||||
.thenAnswer((_) async => [hash1, hash3]);
|
|
||||||
// DB entries are not sorted and a dummy entry added
|
|
||||||
when(
|
|
||||||
() => mockDeviceAssetRepository.getByIds([
|
|
||||||
AssetStub.image1.localId!,
|
|
||||||
AssetStub.image2.localId!,
|
|
||||||
AssetStub.image3.localId!,
|
|
||||||
asset4.localId!,
|
|
||||||
]),
|
|
||||||
).thenAnswer(
|
|
||||||
(_) async => [
|
|
||||||
// Same timestamp to reuse deviceAsset
|
|
||||||
deviceAsset2.copyWith(modifiedTime: asset2.fileModifiedAt),
|
|
||||||
deviceAsset1,
|
|
||||||
deviceAsset3.copyWith(assetId: asset4.localId!),
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
final result = await sut.hashAssets([asset1, asset2, asset3, asset4]);
|
final album = LocalAlbumStub.recent;
|
||||||
|
final asset1 = LocalAssetStub.image1;
|
||||||
|
final asset2 = LocalAssetStub.image2;
|
||||||
|
final mockFile1 = MockFile();
|
||||||
|
final mockFile2 = MockFile();
|
||||||
|
when(() => mockFile1.length()).thenAnswer((_) async => 100);
|
||||||
|
when(() => mockFile1.path).thenReturn('path-1');
|
||||||
|
when(() => mockFile2.length()).thenAnswer((_) async => 100);
|
||||||
|
when(() => mockFile2.path).thenReturn('path-2');
|
||||||
|
|
||||||
// Verify correct processing of all assets
|
when(() => mockAlbumRepo.getAll()).thenAnswer((_) async => [album]);
|
||||||
verify(() => mockBackgroundService.digestFiles([file1.path, file3.path]))
|
when(() => mockAlbumRepo.getAssetsToHash(album.id))
|
||||||
.called(1);
|
.thenAnswer((_) async => [asset1, asset2]);
|
||||||
expect(result.length, 3);
|
when(() => mockStorageRepo.getFileForAsset(asset1))
|
||||||
expect(result, [
|
.thenAnswer((_) async => mockFile1);
|
||||||
AssetStub.image2.copyWith(checksum: base64.encode(hash2)),
|
when(() => mockStorageRepo.getFileForAsset(asset2))
|
||||||
AssetStub.image1.copyWith(checksum: base64.encode(hash1)),
|
.thenAnswer((_) async => mockFile2);
|
||||||
AssetStub.image3.copyWith(checksum: base64.encode(hash3)),
|
|
||||||
]);
|
final hash = Uint8List.fromList(List.generate(20, (i) => i));
|
||||||
|
when(() => mockNativeApi.hashPaths(any()))
|
||||||
|
.thenAnswer((_) async => [hash]);
|
||||||
|
when(() => mockAssetRepo.updateHashes(any())).thenAnswer((_) async => {});
|
||||||
|
|
||||||
|
await sut.hashAssets();
|
||||||
|
|
||||||
|
verify(() => mockNativeApi.hashPaths(['path-1'])).called(1);
|
||||||
|
verify(() => mockNativeApi.hashPaths(['path-2'])).called(1);
|
||||||
|
verify(() => mockAssetRepo.updateHashes(any())).called(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
group("HashService: Edge cases", () {
|
test('handles mixed success and failure in batch', () async {
|
||||||
test("handles empty list of assets", () async {
|
final album = LocalAlbumStub.recent;
|
||||||
when(() => mockDeviceAssetRepository.getByIds(any()))
|
final asset1 = LocalAssetStub.image1;
|
||||||
.thenAnswer((_) async => []);
|
final asset2 = LocalAssetStub.image2;
|
||||||
|
final mockFile1 = MockFile();
|
||||||
|
final mockFile2 = MockFile();
|
||||||
|
when(() => mockFile1.length()).thenAnswer((_) async => 100);
|
||||||
|
when(() => mockFile1.path).thenReturn('path-1');
|
||||||
|
when(() => mockFile2.length()).thenAnswer((_) async => 100);
|
||||||
|
when(() => mockFile2.path).thenReturn('path-2');
|
||||||
|
|
||||||
final result = await sut.hashAssets([]);
|
when(() => mockAlbumRepo.getAll()).thenAnswer((_) async => [album]);
|
||||||
|
when(() => mockAlbumRepo.getAssetsToHash(album.id))
|
||||||
|
.thenAnswer((_) async => [asset1, asset2]);
|
||||||
|
when(() => mockStorageRepo.getFileForAsset(asset1))
|
||||||
|
.thenAnswer((_) async => mockFile1);
|
||||||
|
when(() => mockStorageRepo.getFileForAsset(asset2))
|
||||||
|
.thenAnswer((_) async => mockFile2);
|
||||||
|
|
||||||
verifyNever(() => mockBackgroundService.digestFiles(any()));
|
final validHash = Uint8List.fromList(List.generate(20, (i) => i));
|
||||||
verifyNever(() => mockDeviceAssetRepository.updateAll(any()));
|
when(() => mockNativeApi.hashPaths(['path-1', 'path-2']))
|
||||||
verifyNever(() => mockDeviceAssetRepository.deleteIds(any()));
|
.thenAnswer((_) async => [validHash, null]);
|
||||||
|
when(() => mockAssetRepo.updateHashes(any())).thenAnswer((_) async => {});
|
||||||
|
|
||||||
expect(result, isEmpty);
|
await sut.hashAssets();
|
||||||
});
|
|
||||||
|
|
||||||
test("handles all file access failures", () async {
|
final captured = verify(() => mockAssetRepo.updateHashes(captureAny()))
|
||||||
// No DB entries
|
.captured
|
||||||
when(
|
.first as List<LocalAsset>;
|
||||||
() => mockDeviceAssetRepository.getByIds(
|
expect(captured.length, 1);
|
||||||
[AssetStub.image1.localId!, AssetStub.image2.localId!],
|
expect(captured.first.id, asset1.id);
|
||||||
),
|
|
||||||
).thenAnswer((_) async => []);
|
|
||||||
|
|
||||||
final result = await sut.hashAssets([
|
|
||||||
AssetStub.image1,
|
|
||||||
AssetStub.image2,
|
|
||||||
]);
|
|
||||||
|
|
||||||
verifyNever(() => mockBackgroundService.digestFiles(any()));
|
|
||||||
verifyNever(() => mockDeviceAssetRepository.updateAll(any()));
|
|
||||||
expect(result, isEmpty);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<(Asset, File, DeviceAsset, Uint8List)> _createAssetMock(
|
|
||||||
Asset asset,
|
|
||||||
) async {
|
|
||||||
final random = Random();
|
|
||||||
final hash =
|
|
||||||
Uint8List.fromList(List.generate(20, (i) => random.nextInt(255)));
|
|
||||||
final mockAsset = MockAsset();
|
|
||||||
final mockAssetEntity = MockAssetEntity();
|
|
||||||
final fs = MemoryFileSystem();
|
|
||||||
final deviceAsset = DeviceAsset(
|
|
||||||
assetId: asset.localId!,
|
|
||||||
hash: Uint8List.fromList(hash),
|
|
||||||
modifiedTime: DateTime.now(),
|
|
||||||
);
|
|
||||||
final tmp = await fs.systemTempDirectory.createTemp();
|
|
||||||
final file = tmp.childFile("${asset.fileName}-path");
|
|
||||||
await file.writeAsString("${asset.fileName}-content");
|
|
||||||
|
|
||||||
when(() => mockAsset.localId).thenReturn(asset.localId);
|
|
||||||
when(() => mockAsset.fileName).thenReturn(asset.fileName);
|
|
||||||
when(() => mockAsset.fileCreatedAt).thenReturn(asset.fileCreatedAt);
|
|
||||||
when(() => mockAsset.fileModifiedAt).thenReturn(asset.fileModifiedAt);
|
|
||||||
when(() => mockAsset.copyWith(checksum: any(named: "checksum")))
|
|
||||||
.thenReturn(asset.copyWith(checksum: base64.encode(hash)));
|
|
||||||
when(() => mockAsset.local).thenAnswer((_) => mockAssetEntity);
|
|
||||||
when(() => mockAssetEntity.originFile).thenAnswer((_) async => file);
|
|
||||||
|
|
||||||
return (mockAsset, file, deviceAsset, hash);
|
|
||||||
}
|
|
||||||
|
14
mobile/test/fixtures/album.stub.dart
vendored
14
mobile/test/fixtures/album.stub.dart
vendored
@ -1,3 +1,4 @@
|
|||||||
|
import 'package:immich_mobile/domain/models/local_album.model.dart';
|
||||||
import 'package:immich_mobile/entities/album.entity.dart';
|
import 'package:immich_mobile/entities/album.entity.dart';
|
||||||
import 'package:immich_mobile/infrastructure/entities/user.entity.dart';
|
import 'package:immich_mobile/infrastructure/entities/user.entity.dart';
|
||||||
|
|
||||||
@ -101,3 +102,16 @@ final class AlbumStub {
|
|||||||
endDate: DateTime(2026),
|
endDate: DateTime(2026),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
abstract final class LocalAlbumStub {
|
||||||
|
const LocalAlbumStub._();
|
||||||
|
|
||||||
|
static final recent = LocalAlbum(
|
||||||
|
id: "recent-local-id",
|
||||||
|
name: "Recent",
|
||||||
|
updatedAt: DateTime(2023),
|
||||||
|
assetCount: 1000,
|
||||||
|
backupSelection: BackupSelection.none,
|
||||||
|
isIosSharedAlbum: false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
35
mobile/test/fixtures/asset.stub.dart
vendored
35
mobile/test/fixtures/asset.stub.dart
vendored
@ -1,10 +1,11 @@
|
|||||||
|
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||||
import 'package:immich_mobile/domain/models/exif.model.dart';
|
import 'package:immich_mobile/domain/models/exif.model.dart';
|
||||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
import 'package:immich_mobile/entities/asset.entity.dart' as old;
|
||||||
|
|
||||||
final class AssetStub {
|
final class AssetStub {
|
||||||
const AssetStub._();
|
const AssetStub._();
|
||||||
|
|
||||||
static final image1 = Asset(
|
static final image1 = old.Asset(
|
||||||
checksum: "image1-checksum",
|
checksum: "image1-checksum",
|
||||||
localId: "image1",
|
localId: "image1",
|
||||||
remoteId: 'image1-remote',
|
remoteId: 'image1-remote',
|
||||||
@ -13,7 +14,7 @@ final class AssetStub {
|
|||||||
fileModifiedAt: DateTime(2020),
|
fileModifiedAt: DateTime(2020),
|
||||||
updatedAt: DateTime.now(),
|
updatedAt: DateTime.now(),
|
||||||
durationInSeconds: 0,
|
durationInSeconds: 0,
|
||||||
type: AssetType.image,
|
type: old.AssetType.image,
|
||||||
fileName: "image1.jpg",
|
fileName: "image1.jpg",
|
||||||
isFavorite: true,
|
isFavorite: true,
|
||||||
isArchived: false,
|
isArchived: false,
|
||||||
@ -21,7 +22,7 @@ final class AssetStub {
|
|||||||
exifInfo: const ExifInfo(isFlipped: false),
|
exifInfo: const ExifInfo(isFlipped: false),
|
||||||
);
|
);
|
||||||
|
|
||||||
static final image2 = Asset(
|
static final image2 = old.Asset(
|
||||||
checksum: "image2-checksum",
|
checksum: "image2-checksum",
|
||||||
localId: "image2",
|
localId: "image2",
|
||||||
remoteId: 'image2-remote',
|
remoteId: 'image2-remote',
|
||||||
@ -30,7 +31,7 @@ final class AssetStub {
|
|||||||
fileModifiedAt: DateTime(2010),
|
fileModifiedAt: DateTime(2010),
|
||||||
updatedAt: DateTime.now(),
|
updatedAt: DateTime.now(),
|
||||||
durationInSeconds: 60,
|
durationInSeconds: 60,
|
||||||
type: AssetType.video,
|
type: old.AssetType.video,
|
||||||
fileName: "image2.jpg",
|
fileName: "image2.jpg",
|
||||||
isFavorite: false,
|
isFavorite: false,
|
||||||
isArchived: false,
|
isArchived: false,
|
||||||
@ -38,7 +39,7 @@ final class AssetStub {
|
|||||||
exifInfo: const ExifInfo(isFlipped: true),
|
exifInfo: const ExifInfo(isFlipped: true),
|
||||||
);
|
);
|
||||||
|
|
||||||
static final image3 = Asset(
|
static final image3 = old.Asset(
|
||||||
checksum: "image3-checksum",
|
checksum: "image3-checksum",
|
||||||
localId: "image3",
|
localId: "image3",
|
||||||
ownerId: 1,
|
ownerId: 1,
|
||||||
@ -46,10 +47,30 @@ final class AssetStub {
|
|||||||
fileModifiedAt: DateTime(2025),
|
fileModifiedAt: DateTime(2025),
|
||||||
updatedAt: DateTime.now(),
|
updatedAt: DateTime.now(),
|
||||||
durationInSeconds: 60,
|
durationInSeconds: 60,
|
||||||
type: AssetType.image,
|
type: old.AssetType.image,
|
||||||
fileName: "image3.jpg",
|
fileName: "image3.jpg",
|
||||||
isFavorite: true,
|
isFavorite: true,
|
||||||
isArchived: false,
|
isArchived: false,
|
||||||
isTrashed: false,
|
isTrashed: false,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
abstract final class LocalAssetStub {
|
||||||
|
const LocalAssetStub._();
|
||||||
|
|
||||||
|
static final image1 = LocalAsset(
|
||||||
|
id: "image1",
|
||||||
|
name: "image1.jpg",
|
||||||
|
type: AssetType.image,
|
||||||
|
createdAt: DateTime(2025),
|
||||||
|
updatedAt: DateTime(2025, 2),
|
||||||
|
);
|
||||||
|
|
||||||
|
static final image2 = LocalAsset(
|
||||||
|
id: "image2",
|
||||||
|
name: "image2.jpg",
|
||||||
|
type: AssetType.image,
|
||||||
|
createdAt: DateTime(2000),
|
||||||
|
updatedAt: DateTime(20021),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
@ -1,5 +1,8 @@
|
|||||||
import 'package:immich_mobile/domain/interfaces/device_asset.interface.dart';
|
import 'package:immich_mobile/domain/interfaces/device_asset.interface.dart';
|
||||||
|
import 'package:immich_mobile/domain/interfaces/local_album.interface.dart';
|
||||||
|
import 'package:immich_mobile/domain/interfaces/local_asset.interface.dart';
|
||||||
import 'package:immich_mobile/domain/interfaces/log.interface.dart';
|
import 'package:immich_mobile/domain/interfaces/log.interface.dart';
|
||||||
|
import 'package:immich_mobile/domain/interfaces/storage.interface.dart';
|
||||||
import 'package:immich_mobile/domain/interfaces/store.interface.dart';
|
import 'package:immich_mobile/domain/interfaces/store.interface.dart';
|
||||||
import 'package:immich_mobile/domain/interfaces/sync_api.interface.dart';
|
import 'package:immich_mobile/domain/interfaces/sync_api.interface.dart';
|
||||||
import 'package:immich_mobile/domain/interfaces/sync_stream.interface.dart';
|
import 'package:immich_mobile/domain/interfaces/sync_stream.interface.dart';
|
||||||
@ -18,6 +21,12 @@ class MockDeviceAssetRepository extends Mock
|
|||||||
|
|
||||||
class MockSyncStreamRepository extends Mock implements ISyncStreamRepository {}
|
class MockSyncStreamRepository extends Mock implements ISyncStreamRepository {}
|
||||||
|
|
||||||
|
class MockLocalAlbumRepository extends Mock implements ILocalAlbumRepository {}
|
||||||
|
|
||||||
|
class MockLocalAssetRepository extends Mock implements ILocalAssetRepository {}
|
||||||
|
|
||||||
|
class MockStorageRepository extends Mock implements IStorageRepository {}
|
||||||
|
|
||||||
// API Repos
|
// API Repos
|
||||||
class MockUserApiRepository extends Mock implements IUserApiRepository {}
|
class MockUserApiRepository extends Mock implements IUserApiRepository {}
|
||||||
|
|
||||||
|
425
mobile/test/services/hash_service_test.dart
Normal file
425
mobile/test/services/hash_service_test.dart
Normal file
@ -0,0 +1,425 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'dart:math';
|
||||||
|
|
||||||
|
import 'package:collection/collection.dart';
|
||||||
|
import 'package:file/memory.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:immich_mobile/domain/interfaces/device_asset.interface.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/device_asset.model.dart';
|
||||||
|
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||||
|
import 'package:immich_mobile/services/background.service.dart';
|
||||||
|
import 'package:immich_mobile/services/hash.service.dart';
|
||||||
|
import 'package:mocktail/mocktail.dart';
|
||||||
|
import 'package:photo_manager/photo_manager.dart';
|
||||||
|
|
||||||
|
import '../fixtures/asset.stub.dart';
|
||||||
|
import '../infrastructure/repository.mock.dart';
|
||||||
|
import '../service.mocks.dart';
|
||||||
|
|
||||||
|
class MockAsset extends Mock implements Asset {}
|
||||||
|
|
||||||
|
class MockAssetEntity extends Mock implements AssetEntity {}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
late HashService sut;
|
||||||
|
late BackgroundService mockBackgroundService;
|
||||||
|
late IDeviceAssetRepository mockDeviceAssetRepository;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
mockBackgroundService = MockBackgroundService();
|
||||||
|
mockDeviceAssetRepository = MockDeviceAssetRepository();
|
||||||
|
|
||||||
|
sut = HashService(
|
||||||
|
deviceAssetRepository: mockDeviceAssetRepository,
|
||||||
|
backgroundService: mockBackgroundService,
|
||||||
|
);
|
||||||
|
|
||||||
|
when(() => mockDeviceAssetRepository.transaction<Null>(any()))
|
||||||
|
.thenAnswer((_) async {
|
||||||
|
final capturedCallback = verify(
|
||||||
|
() => mockDeviceAssetRepository.transaction<Null>(captureAny()),
|
||||||
|
).captured;
|
||||||
|
// Invoke the transaction callback
|
||||||
|
await (capturedCallback.firstOrNull as Future<Null> Function()?)?.call();
|
||||||
|
});
|
||||||
|
when(() => mockDeviceAssetRepository.updateAll(any()))
|
||||||
|
.thenAnswer((_) async => true);
|
||||||
|
when(() => mockDeviceAssetRepository.deleteIds(any()))
|
||||||
|
.thenAnswer((_) async => true);
|
||||||
|
});
|
||||||
|
|
||||||
|
group("HashService: No DeviceAsset entry", () {
|
||||||
|
test("hash successfully", () async {
|
||||||
|
final (mockAsset, file, deviceAsset, hash) =
|
||||||
|
await _createAssetMock(AssetStub.image1);
|
||||||
|
|
||||||
|
when(() => mockBackgroundService.digestFiles([file.path]))
|
||||||
|
.thenAnswer((_) async => [hash]);
|
||||||
|
// No DB entries for this asset
|
||||||
|
when(
|
||||||
|
() => mockDeviceAssetRepository.getByIds([AssetStub.image1.localId!]),
|
||||||
|
).thenAnswer((_) async => []);
|
||||||
|
|
||||||
|
final result = await sut.hashAssets([mockAsset]);
|
||||||
|
|
||||||
|
// Verify we stored the new hash in DB
|
||||||
|
when(() => mockDeviceAssetRepository.transaction<Null>(any()))
|
||||||
|
.thenAnswer((_) async {
|
||||||
|
final capturedCallback = verify(
|
||||||
|
() => mockDeviceAssetRepository.transaction<Null>(captureAny()),
|
||||||
|
).captured;
|
||||||
|
// Invoke the transaction callback
|
||||||
|
await (capturedCallback.firstOrNull as Future<Null> Function()?)
|
||||||
|
?.call();
|
||||||
|
verify(
|
||||||
|
() => mockDeviceAssetRepository.updateAll([
|
||||||
|
deviceAsset.copyWith(modifiedTime: AssetStub.image1.fileModifiedAt),
|
||||||
|
]),
|
||||||
|
).called(1);
|
||||||
|
verify(() => mockDeviceAssetRepository.deleteIds([])).called(1);
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
result,
|
||||||
|
[AssetStub.image1.copyWith(checksum: base64.encode(hash))],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group("HashService: Has DeviceAsset entry", () {
|
||||||
|
test("when the asset is not modified", () async {
|
||||||
|
final hash = utf8.encode("image1-hash");
|
||||||
|
|
||||||
|
when(
|
||||||
|
() => mockDeviceAssetRepository.getByIds([AssetStub.image1.localId!]),
|
||||||
|
).thenAnswer(
|
||||||
|
(_) async => [
|
||||||
|
DeviceAsset(
|
||||||
|
assetId: AssetStub.image1.localId!,
|
||||||
|
hash: hash,
|
||||||
|
modifiedTime: AssetStub.image1.fileModifiedAt,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
final result = await sut.hashAssets([AssetStub.image1]);
|
||||||
|
|
||||||
|
verifyNever(() => mockBackgroundService.digestFiles(any()));
|
||||||
|
verifyNever(() => mockBackgroundService.digestFile(any()));
|
||||||
|
verifyNever(() => mockDeviceAssetRepository.updateAll(any()));
|
||||||
|
verifyNever(() => mockDeviceAssetRepository.deleteIds(any()));
|
||||||
|
|
||||||
|
expect(result, [
|
||||||
|
AssetStub.image1.copyWith(checksum: base64.encode(hash)),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("hashed successful when asset is modified", () async {
|
||||||
|
final (mockAsset, file, deviceAsset, hash) =
|
||||||
|
await _createAssetMock(AssetStub.image1);
|
||||||
|
|
||||||
|
when(() => mockBackgroundService.digestFiles([file.path]))
|
||||||
|
.thenAnswer((_) async => [hash]);
|
||||||
|
when(
|
||||||
|
() => mockDeviceAssetRepository.getByIds([AssetStub.image1.localId!]),
|
||||||
|
).thenAnswer((_) async => [deviceAsset]);
|
||||||
|
|
||||||
|
final result = await sut.hashAssets([mockAsset]);
|
||||||
|
|
||||||
|
when(() => mockDeviceAssetRepository.transaction<Null>(any()))
|
||||||
|
.thenAnswer((_) async {
|
||||||
|
final capturedCallback = verify(
|
||||||
|
() => mockDeviceAssetRepository.transaction<Null>(captureAny()),
|
||||||
|
).captured;
|
||||||
|
// Invoke the transaction callback
|
||||||
|
await (capturedCallback.firstOrNull as Future<Null> Function()?)
|
||||||
|
?.call();
|
||||||
|
verify(
|
||||||
|
() => mockDeviceAssetRepository.updateAll([
|
||||||
|
deviceAsset.copyWith(modifiedTime: AssetStub.image1.fileModifiedAt),
|
||||||
|
]),
|
||||||
|
).called(1);
|
||||||
|
verify(() => mockDeviceAssetRepository.deleteIds([])).called(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
verify(() => mockBackgroundService.digestFiles([file.path])).called(1);
|
||||||
|
|
||||||
|
expect(result, [
|
||||||
|
AssetStub.image1.copyWith(checksum: base64.encode(hash)),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group("HashService: Cleanup", () {
|
||||||
|
late Asset mockAsset;
|
||||||
|
late Uint8List hash;
|
||||||
|
late DeviceAsset deviceAsset;
|
||||||
|
late File file;
|
||||||
|
|
||||||
|
setUp(() async {
|
||||||
|
(mockAsset, file, deviceAsset, hash) =
|
||||||
|
await _createAssetMock(AssetStub.image1);
|
||||||
|
|
||||||
|
when(() => mockBackgroundService.digestFiles([file.path]))
|
||||||
|
.thenAnswer((_) async => [hash]);
|
||||||
|
when(
|
||||||
|
() => mockDeviceAssetRepository.getByIds([AssetStub.image1.localId!]),
|
||||||
|
).thenAnswer((_) async => [deviceAsset]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("cleanups DeviceAsset when local file cannot be obtained", () async {
|
||||||
|
when(() => mockAsset.local).thenThrow(Exception("File not found"));
|
||||||
|
final result = await sut.hashAssets([mockAsset]);
|
||||||
|
|
||||||
|
verifyNever(() => mockBackgroundService.digestFiles(any()));
|
||||||
|
verifyNever(() => mockBackgroundService.digestFile(any()));
|
||||||
|
verifyNever(() => mockDeviceAssetRepository.updateAll(any()));
|
||||||
|
verify(
|
||||||
|
() => mockDeviceAssetRepository.deleteIds([AssetStub.image1.localId!]),
|
||||||
|
).called(1);
|
||||||
|
|
||||||
|
expect(result, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("cleanups DeviceAsset when hashing failed", () async {
|
||||||
|
when(() => mockDeviceAssetRepository.transaction<Null>(any()))
|
||||||
|
.thenAnswer((_) async {
|
||||||
|
final capturedCallback = verify(
|
||||||
|
() => mockDeviceAssetRepository.transaction<Null>(captureAny()),
|
||||||
|
).captured;
|
||||||
|
// Invoke the transaction callback
|
||||||
|
await (capturedCallback.firstOrNull as Future<Null> Function()?)
|
||||||
|
?.call();
|
||||||
|
|
||||||
|
// Verify the callback inside the transaction because, doing it outside results
|
||||||
|
// in a small delay before the callback is invoked, resulting in other LOCs getting executed
|
||||||
|
// resulting in an incorrect state
|
||||||
|
//
|
||||||
|
// i.e, consider the following piece of code
|
||||||
|
// await _deviceAssetRepository.transaction(() async {
|
||||||
|
// await _deviceAssetRepository.updateAll(toBeAdded);
|
||||||
|
// await _deviceAssetRepository.deleteIds(toBeDeleted);
|
||||||
|
// });
|
||||||
|
// toBeDeleted.clear();
|
||||||
|
// since the transaction method is mocked, the callback is not invoked until it is captured
|
||||||
|
// and executed manually in the next event loop. However, the toBeDeleted.clear() is executed
|
||||||
|
// immediately once the transaction stub is executed, resulting in the deleteIds method being
|
||||||
|
// called with an empty list.
|
||||||
|
//
|
||||||
|
// To avoid this, we capture the callback and execute it within the transaction stub itself
|
||||||
|
// and verify the results inside the transaction stub
|
||||||
|
verify(() => mockDeviceAssetRepository.updateAll([])).called(1);
|
||||||
|
verify(
|
||||||
|
() =>
|
||||||
|
mockDeviceAssetRepository.deleteIds([AssetStub.image1.localId!]),
|
||||||
|
).called(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
when(() => mockBackgroundService.digestFiles([file.path])).thenAnswer(
|
||||||
|
// Invalid hash, length != 20
|
||||||
|
(_) async => [Uint8List.fromList(hash.slice(2).toList())],
|
||||||
|
);
|
||||||
|
|
||||||
|
final result = await sut.hashAssets([mockAsset]);
|
||||||
|
|
||||||
|
verify(() => mockBackgroundService.digestFiles([file.path])).called(1);
|
||||||
|
expect(result, isEmpty);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group("HashService: Batch processing", () {
|
||||||
|
test("processes assets in batches when size limit is reached", () async {
|
||||||
|
// Setup multiple assets with large file sizes
|
||||||
|
final (mock1, mock2, mock3) = await (
|
||||||
|
_createAssetMock(AssetStub.image1),
|
||||||
|
_createAssetMock(AssetStub.image2),
|
||||||
|
_createAssetMock(AssetStub.image3),
|
||||||
|
).wait;
|
||||||
|
|
||||||
|
final (asset1, file1, deviceAsset1, hash1) = mock1;
|
||||||
|
final (asset2, file2, deviceAsset2, hash2) = mock2;
|
||||||
|
final (asset3, file3, deviceAsset3, hash3) = mock3;
|
||||||
|
|
||||||
|
when(() => mockDeviceAssetRepository.getByIds(any()))
|
||||||
|
.thenAnswer((_) async => []);
|
||||||
|
|
||||||
|
// Setup for multiple batch processing calls
|
||||||
|
when(() => mockBackgroundService.digestFiles([file1.path, file2.path]))
|
||||||
|
.thenAnswer((_) async => [hash1, hash2]);
|
||||||
|
when(() => mockBackgroundService.digestFiles([file3.path]))
|
||||||
|
.thenAnswer((_) async => [hash3]);
|
||||||
|
|
||||||
|
final size = await file1.length() + await file2.length();
|
||||||
|
|
||||||
|
sut = HashService(
|
||||||
|
deviceAssetRepository: mockDeviceAssetRepository,
|
||||||
|
backgroundService: mockBackgroundService,
|
||||||
|
batchSizeLimit: size,
|
||||||
|
);
|
||||||
|
final result = await sut.hashAssets([asset1, asset2, asset3]);
|
||||||
|
|
||||||
|
// Verify multiple batch process calls
|
||||||
|
verify(() => mockBackgroundService.digestFiles([file1.path, file2.path]))
|
||||||
|
.called(1);
|
||||||
|
verify(() => mockBackgroundService.digestFiles([file3.path])).called(1);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
result,
|
||||||
|
[
|
||||||
|
AssetStub.image1.copyWith(checksum: base64.encode(hash1)),
|
||||||
|
AssetStub.image2.copyWith(checksum: base64.encode(hash2)),
|
||||||
|
AssetStub.image3.copyWith(checksum: base64.encode(hash3)),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("processes assets in batches when file limit is reached", () async {
|
||||||
|
// Setup multiple assets with large file sizes
|
||||||
|
final (mock1, mock2, mock3) = await (
|
||||||
|
_createAssetMock(AssetStub.image1),
|
||||||
|
_createAssetMock(AssetStub.image2),
|
||||||
|
_createAssetMock(AssetStub.image3),
|
||||||
|
).wait;
|
||||||
|
|
||||||
|
final (asset1, file1, deviceAsset1, hash1) = mock1;
|
||||||
|
final (asset2, file2, deviceAsset2, hash2) = mock2;
|
||||||
|
final (asset3, file3, deviceAsset3, hash3) = mock3;
|
||||||
|
|
||||||
|
when(() => mockDeviceAssetRepository.getByIds(any()))
|
||||||
|
.thenAnswer((_) async => []);
|
||||||
|
|
||||||
|
when(() => mockBackgroundService.digestFiles([file1.path]))
|
||||||
|
.thenAnswer((_) async => [hash1]);
|
||||||
|
when(() => mockBackgroundService.digestFiles([file2.path]))
|
||||||
|
.thenAnswer((_) async => [hash2]);
|
||||||
|
when(() => mockBackgroundService.digestFiles([file3.path]))
|
||||||
|
.thenAnswer((_) async => [hash3]);
|
||||||
|
|
||||||
|
sut = HashService(
|
||||||
|
deviceAssetRepository: mockDeviceAssetRepository,
|
||||||
|
backgroundService: mockBackgroundService,
|
||||||
|
batchFileLimit: 1,
|
||||||
|
);
|
||||||
|
final result = await sut.hashAssets([asset1, asset2, asset3]);
|
||||||
|
|
||||||
|
// Verify multiple batch process calls
|
||||||
|
verify(() => mockBackgroundService.digestFiles([file1.path])).called(1);
|
||||||
|
verify(() => mockBackgroundService.digestFiles([file2.path])).called(1);
|
||||||
|
verify(() => mockBackgroundService.digestFiles([file3.path])).called(1);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
result,
|
||||||
|
[
|
||||||
|
AssetStub.image1.copyWith(checksum: base64.encode(hash1)),
|
||||||
|
AssetStub.image2.copyWith(checksum: base64.encode(hash2)),
|
||||||
|
AssetStub.image3.copyWith(checksum: base64.encode(hash3)),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("HashService: Sort & Process different states", () async {
|
||||||
|
final (asset1, file1, deviceAsset1, hash1) =
|
||||||
|
await _createAssetMock(AssetStub.image1); // Will need rehashing
|
||||||
|
final (asset2, file2, deviceAsset2, hash2) =
|
||||||
|
await _createAssetMock(AssetStub.image2); // Will have matching hash
|
||||||
|
final (asset3, file3, deviceAsset3, hash3) =
|
||||||
|
await _createAssetMock(AssetStub.image3); // No DB entry
|
||||||
|
final asset4 =
|
||||||
|
AssetStub.image3.copyWith(localId: "image4"); // Cannot be hashed
|
||||||
|
|
||||||
|
when(() => mockBackgroundService.digestFiles([file1.path, file3.path]))
|
||||||
|
.thenAnswer((_) async => [hash1, hash3]);
|
||||||
|
// DB entries are not sorted and a dummy entry added
|
||||||
|
when(
|
||||||
|
() => mockDeviceAssetRepository.getByIds([
|
||||||
|
AssetStub.image1.localId!,
|
||||||
|
AssetStub.image2.localId!,
|
||||||
|
AssetStub.image3.localId!,
|
||||||
|
asset4.localId!,
|
||||||
|
]),
|
||||||
|
).thenAnswer(
|
||||||
|
(_) async => [
|
||||||
|
// Same timestamp to reuse deviceAsset
|
||||||
|
deviceAsset2.copyWith(modifiedTime: asset2.fileModifiedAt),
|
||||||
|
deviceAsset1,
|
||||||
|
deviceAsset3.copyWith(assetId: asset4.localId!),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
final result = await sut.hashAssets([asset1, asset2, asset3, asset4]);
|
||||||
|
|
||||||
|
// Verify correct processing of all assets
|
||||||
|
verify(() => mockBackgroundService.digestFiles([file1.path, file3.path]))
|
||||||
|
.called(1);
|
||||||
|
expect(result.length, 3);
|
||||||
|
expect(result, [
|
||||||
|
AssetStub.image2.copyWith(checksum: base64.encode(hash2)),
|
||||||
|
AssetStub.image1.copyWith(checksum: base64.encode(hash1)),
|
||||||
|
AssetStub.image3.copyWith(checksum: base64.encode(hash3)),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
group("HashService: Edge cases", () {
|
||||||
|
test("handles empty list of assets", () async {
|
||||||
|
when(() => mockDeviceAssetRepository.getByIds(any()))
|
||||||
|
.thenAnswer((_) async => []);
|
||||||
|
|
||||||
|
final result = await sut.hashAssets([]);
|
||||||
|
|
||||||
|
verifyNever(() => mockBackgroundService.digestFiles(any()));
|
||||||
|
verifyNever(() => mockDeviceAssetRepository.updateAll(any()));
|
||||||
|
verifyNever(() => mockDeviceAssetRepository.deleteIds(any()));
|
||||||
|
|
||||||
|
expect(result, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles all file access failures", () async {
|
||||||
|
// No DB entries
|
||||||
|
when(
|
||||||
|
() => mockDeviceAssetRepository.getByIds(
|
||||||
|
[AssetStub.image1.localId!, AssetStub.image2.localId!],
|
||||||
|
),
|
||||||
|
).thenAnswer((_) async => []);
|
||||||
|
|
||||||
|
final result = await sut.hashAssets([
|
||||||
|
AssetStub.image1,
|
||||||
|
AssetStub.image2,
|
||||||
|
]);
|
||||||
|
|
||||||
|
verifyNever(() => mockBackgroundService.digestFiles(any()));
|
||||||
|
verifyNever(() => mockDeviceAssetRepository.updateAll(any()));
|
||||||
|
expect(result, isEmpty);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<(Asset, File, DeviceAsset, Uint8List)> _createAssetMock(
|
||||||
|
Asset asset,
|
||||||
|
) async {
|
||||||
|
final random = Random();
|
||||||
|
final hash =
|
||||||
|
Uint8List.fromList(List.generate(20, (i) => random.nextInt(255)));
|
||||||
|
final mockAsset = MockAsset();
|
||||||
|
final mockAssetEntity = MockAssetEntity();
|
||||||
|
final fs = MemoryFileSystem();
|
||||||
|
final deviceAsset = DeviceAsset(
|
||||||
|
assetId: asset.localId!,
|
||||||
|
hash: Uint8List.fromList(hash),
|
||||||
|
modifiedTime: DateTime.now(),
|
||||||
|
);
|
||||||
|
final tmp = await fs.systemTempDirectory.createTemp();
|
||||||
|
final file = tmp.childFile("${asset.fileName}-path");
|
||||||
|
await file.writeAsString("${asset.fileName}-content");
|
||||||
|
|
||||||
|
when(() => mockAsset.localId).thenReturn(asset.localId);
|
||||||
|
when(() => mockAsset.fileName).thenReturn(asset.fileName);
|
||||||
|
when(() => mockAsset.fileCreatedAt).thenReturn(asset.fileCreatedAt);
|
||||||
|
when(() => mockAsset.fileModifiedAt).thenReturn(asset.fileModifiedAt);
|
||||||
|
when(() => mockAsset.copyWith(checksum: any(named: "checksum")))
|
||||||
|
.thenReturn(asset.copyWith(checksum: base64.encode(hash)));
|
||||||
|
when(() => mockAsset.local).thenAnswer((_) => mockAssetEntity);
|
||||||
|
when(() => mockAssetEntity.originFile).thenAnswer((_) async => file);
|
||||||
|
|
||||||
|
return (mockAsset, file, deviceAsset, hash);
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user