mirror of
https://github.com/immich-app/immich.git
synced 2025-12-12 15:50:43 -08:00
* feature(mobile, beta, Android): handle remote asset trash/restore events and rescan media - Handle move to trash and restore from trash for remote assets on Android - Trigger MediaScannerConnection to rescan affected media files * feature(mobile, beta, Android): fix rescan * fix imports * fix checking conditions * refactor naming * fix line breaks * refactor code rollback changes in BackgroundServicePlugin * refactor code (use separate TrashService) * refactor code * parallelize restoreFromTrash calls with Future.wait format trash.provider.dart * try to re-format trash.provider.dart * re-format trash.provider.dart * rename TrashService to TrashSyncService to avoid duplicated names revert changes in original trash.provider.dart * refactor code (minor nitpicks) * process restoreFromTrash sequentially instead of Future.wait * group local assets by checksum before moving to trash delete LocalAssetEntity records when moved to trash refactor code * fix format * use checksum for asset restoration refactro code * fix format * sync trash only for backup-selected assets * feat(db): add local_trashed_asset table and integrate with restoration flow - Add new `local_trashed_asset` table to store metadata of trashed assets - Save trashed asset info into `local_trashed_asset` before deletion - Use `local_trashed_asset` as source for asset restoration - Implement file restoration by `mediaId` * resolve merge conflicts * fix index creating on migration * rework trashed assets handling - add new table trashed_local_asset - mirror trashed assets data in trashed_local_asset. - compute checksums for assets trashed out-of-app. - restore assets present in trashed_local_asset and non-trashed in remote_asset. - simplify moving-to-trash logic based on remote_asset events. * resolve merge conflicts use updated approach for calculating checksums * use CurrentPlatform instead _platform fix mocks * revert redundant changes * Include trashed items in getMediaChanges Process trashed items delta during incremental sync * fix merge conflicts * fix format * trashed_local_asset table mirror of local_asset table structure trashed_local_asset<->local_asset transfer data on move to trash or restore refactor code * refactor and format code * refactor TrashedAsset model fix missed data transfering * refactor code remove unused model * fix label * fix merge conflicts * optimize, refactor code remove redundant code and checking getTrashedAssetsForAlbum for iOS tests for hash trashed assets * format code * fix migration fix tests * fix generated file * reuse exist checksums on trash data update handle restoration errors fix import * format code * sync_stream.service depend on repos refactor assets restoration update dependencies in tests * remove trashed asset model remove trash_sync.service refactor DriftTrashedLocalAssetRepository, LocalSyncService * rework fetching trashed assets data on native side optimize handling trashed assets in local sync service refactor code * update NativeSyncApi on iOS side remove unused code * optimize sync trashed assets call in full sync mode refactor code * fix format * remove albumIds from getTrashedAssets params fix upsert in trashed local asset repo refactor code * fix getTrashedAssets params * fix(trash-sync): clean up NativeSyncApiImplBase and correct applyDelta * refactor(trash-sync): optimize performance and fix minor issues * refactor(trash-sync): add missed index * feat(trash-sync): remove sinceLastCheckpoint param from getTrashedAssets * fix(trash-sync): fix target table * fix(trash-sync): remove unused extension * fix(trash-sync): remove unused code * fix(trash-sync): refactor code * fix(trash-sync): reformat file * fix(trash_sync): refactor code * fix(trash_sync): improve moving to trash * refactor(trash_sync): integrate MANAGE_MEDIA permission request into login flow and advanced settings * refactor(trash_sync): add additional checking for experimental trash sync flag and MANAGE_MEDIA permission. * refactor(trash_sync): resolve merge conflicts * refactor(trash_sync): fix format * resolve merge conflicts add await for alert dialog add missed request * refactor(trash_sync): rework MANAGE_MEDIA info widget show rationale text in permission request alert dialog refactor setting getter * fix(trash_sync): restore missing text values * fix(trash_sync): format file * fix(trash_sync): check backup enabled and remove remote asset existence check * fix(trash_sync): remove checking backup enabled test(trash_sync): cover sync-stream trash/restore paths and dedupe mocks * test(trash_sync): cover trash/restore flows for local_sync_service * chore(e2e): restore test-assets submodule pointer --------- Co-authored-by: Peter Ombodi <peter.ombodi@gmail.com> Co-authored-by: Alex <alex.tran1502@gmail.com>
195 lines
8.1 KiB
Dart
195 lines
8.1 KiB
Dart
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
|
|
import 'package:immich_mobile/domain/services/hash.service.dart';
|
|
import 'package:immich_mobile/platform/native_sync_api.g.dart';
|
|
import 'package:mocktail/mocktail.dart';
|
|
|
|
import '../../fixtures/album.stub.dart';
|
|
import '../../fixtures/asset.stub.dart';
|
|
import '../../infrastructure/repository.mock.dart';
|
|
import '../service.mock.dart';
|
|
|
|
void main() {
|
|
late HashService sut;
|
|
late MockLocalAlbumRepository mockAlbumRepo;
|
|
late MockLocalAssetRepository mockAssetRepo;
|
|
late MockNativeSyncApi mockNativeApi;
|
|
late MockTrashedLocalAssetRepository mockTrashedAssetRepo;
|
|
|
|
setUp(() {
|
|
mockAlbumRepo = MockLocalAlbumRepository();
|
|
mockAssetRepo = MockLocalAssetRepository();
|
|
mockNativeApi = MockNativeSyncApi();
|
|
mockTrashedAssetRepo = MockTrashedLocalAssetRepository();
|
|
|
|
sut = HashService(
|
|
localAlbumRepository: mockAlbumRepo,
|
|
localAssetRepository: mockAssetRepo,
|
|
nativeSyncApi: mockNativeApi,
|
|
trashedLocalAssetRepository: mockTrashedAssetRepo,
|
|
);
|
|
|
|
registerFallbackValue(LocalAlbumStub.recent);
|
|
registerFallbackValue(LocalAssetStub.image1);
|
|
registerFallbackValue(<String, String>{});
|
|
|
|
when(() => mockAssetRepo.updateHashes(any())).thenAnswer((_) async => {});
|
|
});
|
|
|
|
group('HashService hashAssets', () {
|
|
test('skips albums with no assets to hash', () async {
|
|
when(
|
|
() => mockAlbumRepo.getBackupAlbums(),
|
|
).thenAnswer((_) async => [LocalAlbumStub.recent.copyWith(assetCount: 0)]);
|
|
when(() => mockAlbumRepo.getAssetsToHash(LocalAlbumStub.recent.id)).thenAnswer((_) async => []);
|
|
|
|
await sut.hashAssets();
|
|
|
|
verifyNever(() => mockNativeApi.hashAssets(any(), allowNetworkAccess: any(named: 'allowNetworkAccess')));
|
|
});
|
|
});
|
|
|
|
group('HashService _hashAssets', () {
|
|
test('skips empty batches', () async {
|
|
final album = LocalAlbumStub.recent;
|
|
when(() => mockAlbumRepo.getBackupAlbums()).thenAnswer((_) async => [album]);
|
|
when(() => mockAlbumRepo.getAssetsToHash(album.id)).thenAnswer((_) async => []);
|
|
|
|
await sut.hashAssets();
|
|
|
|
verifyNever(() => mockNativeApi.hashAssets(any(), allowNetworkAccess: any(named: 'allowNetworkAccess')));
|
|
});
|
|
|
|
test('processes assets when available', () async {
|
|
final album = LocalAlbumStub.recent;
|
|
final asset = LocalAssetStub.image1;
|
|
|
|
when(() => mockAlbumRepo.getBackupAlbums()).thenAnswer((_) async => [album]);
|
|
when(() => mockAlbumRepo.getAssetsToHash(album.id)).thenAnswer((_) async => [asset]);
|
|
when(
|
|
() => mockNativeApi.hashAssets([asset.id], allowNetworkAccess: false),
|
|
).thenAnswer((_) async => [HashResult(assetId: asset.id, hash: 'test-hash')]);
|
|
|
|
await sut.hashAssets();
|
|
|
|
verify(() => mockNativeApi.hashAssets([asset.id], allowNetworkAccess: false)).called(1);
|
|
final captured = verify(() => mockAssetRepo.updateHashes(captureAny())).captured.first as Map<String, String>;
|
|
expect(captured.length, 1);
|
|
expect(captured[asset.id], 'test-hash');
|
|
});
|
|
|
|
test('handles failed hashes', () async {
|
|
final album = LocalAlbumStub.recent;
|
|
final asset = LocalAssetStub.image1;
|
|
|
|
when(() => mockAlbumRepo.getBackupAlbums()).thenAnswer((_) async => [album]);
|
|
when(() => mockAlbumRepo.getAssetsToHash(album.id)).thenAnswer((_) async => [asset]);
|
|
when(
|
|
() => mockNativeApi.hashAssets([asset.id], allowNetworkAccess: false),
|
|
).thenAnswer((_) async => [HashResult(assetId: asset.id, error: 'Failed to hash')]);
|
|
|
|
await sut.hashAssets();
|
|
|
|
final captured = verify(() => mockAssetRepo.updateHashes(captureAny())).captured.first as Map<String, String>;
|
|
expect(captured.length, 0);
|
|
});
|
|
|
|
test('handles null hash results', () async {
|
|
final album = LocalAlbumStub.recent;
|
|
final asset = LocalAssetStub.image1;
|
|
|
|
when(() => mockAlbumRepo.getBackupAlbums()).thenAnswer((_) async => [album]);
|
|
when(() => mockAlbumRepo.getAssetsToHash(album.id)).thenAnswer((_) async => [asset]);
|
|
when(
|
|
() => mockNativeApi.hashAssets([asset.id], allowNetworkAccess: false),
|
|
).thenAnswer((_) async => [HashResult(assetId: asset.id, hash: null)]);
|
|
|
|
await sut.hashAssets();
|
|
|
|
final captured = verify(() => mockAssetRepo.updateHashes(captureAny())).captured.first as Map<String, String>;
|
|
expect(captured.length, 0);
|
|
});
|
|
|
|
test('batches by size limit', () async {
|
|
const batchSize = 2;
|
|
final sut = HashService(
|
|
localAlbumRepository: mockAlbumRepo,
|
|
localAssetRepository: mockAssetRepo,
|
|
nativeSyncApi: mockNativeApi,
|
|
batchSize: batchSize,
|
|
trashedLocalAssetRepository: mockTrashedAssetRepo,
|
|
);
|
|
|
|
final album = LocalAlbumStub.recent;
|
|
final asset1 = LocalAssetStub.image1;
|
|
final asset2 = LocalAssetStub.image2;
|
|
final asset3 = LocalAssetStub.image1.copyWith(id: 'image3', name: 'image3.jpg');
|
|
|
|
final capturedCalls = <List<String>>[];
|
|
|
|
when(() => mockAssetRepo.updateHashes(any())).thenAnswer((_) async => {});
|
|
when(() => mockAlbumRepo.getBackupAlbums()).thenAnswer((_) async => [album]);
|
|
when(() => mockAlbumRepo.getAssetsToHash(album.id)).thenAnswer((_) async => [asset1, asset2, asset3]);
|
|
when(() => mockNativeApi.hashAssets(any(), allowNetworkAccess: any(named: 'allowNetworkAccess'))).thenAnswer((
|
|
invocation,
|
|
) async {
|
|
final assetIds = invocation.positionalArguments[0] as List<String>;
|
|
capturedCalls.add(List<String>.from(assetIds));
|
|
return assetIds.map((id) => HashResult(assetId: id, hash: '$id-hash')).toList();
|
|
});
|
|
|
|
await sut.hashAssets();
|
|
|
|
expect(capturedCalls.length, 2, reason: 'Should make exactly 2 calls to hashAssets');
|
|
expect(capturedCalls[0], [asset1.id, asset2.id], reason: 'First call should batch the first two assets');
|
|
expect(capturedCalls[1], [asset3.id], reason: 'Second call should have the remaining asset');
|
|
|
|
verify(() => mockAssetRepo.updateHashes(any())).called(2);
|
|
});
|
|
|
|
test('handles mixed success and failure in batch', () async {
|
|
final album = LocalAlbumStub.recent;
|
|
final asset1 = LocalAssetStub.image1;
|
|
final asset2 = LocalAssetStub.image2;
|
|
|
|
when(() => mockAlbumRepo.getBackupAlbums()).thenAnswer((_) async => [album]);
|
|
when(() => mockAlbumRepo.getAssetsToHash(album.id)).thenAnswer((_) async => [asset1, asset2]);
|
|
when(() => mockNativeApi.hashAssets([asset1.id, asset2.id], allowNetworkAccess: false)).thenAnswer(
|
|
(_) async => [
|
|
HashResult(assetId: asset1.id, hash: 'asset1-hash'),
|
|
HashResult(assetId: asset2.id, error: 'Failed to hash asset2'),
|
|
],
|
|
);
|
|
|
|
await sut.hashAssets();
|
|
|
|
final captured = verify(() => mockAssetRepo.updateHashes(captureAny())).captured.first as Map<String, String>;
|
|
expect(captured.length, 1);
|
|
expect(captured[asset1.id], 'asset1-hash');
|
|
});
|
|
|
|
test('uses allowNetworkAccess based on album backup selection', () async {
|
|
final selectedAlbum = LocalAlbumStub.recent.copyWith(backupSelection: BackupSelection.selected);
|
|
final nonSelectedAlbum = LocalAlbumStub.recent.copyWith(id: 'album2', backupSelection: BackupSelection.excluded);
|
|
final asset1 = LocalAssetStub.image1;
|
|
final asset2 = LocalAssetStub.image2;
|
|
|
|
when(() => mockAlbumRepo.getBackupAlbums()).thenAnswer((_) async => [selectedAlbum, nonSelectedAlbum]);
|
|
when(() => mockAlbumRepo.getAssetsToHash(selectedAlbum.id)).thenAnswer((_) async => [asset1]);
|
|
when(() => mockAlbumRepo.getAssetsToHash(nonSelectedAlbum.id)).thenAnswer((_) async => [asset2]);
|
|
when(() => mockNativeApi.hashAssets(any(), allowNetworkAccess: any(named: 'allowNetworkAccess'))).thenAnswer((
|
|
invocation,
|
|
) async {
|
|
final assetIds = invocation.positionalArguments[0] as List<String>;
|
|
return assetIds.map((id) => HashResult(assetId: id, hash: '$id-hash')).toList();
|
|
});
|
|
|
|
await sut.hashAssets();
|
|
|
|
verify(() => mockNativeApi.hashAssets([asset1.id], allowNetworkAccess: true)).called(1);
|
|
verify(() => mockNativeApi.hashAssets([asset2.id], allowNetworkAccess: false)).called(1);
|
|
});
|
|
});
|
|
|
|
}
|