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>
191 lines
8.5 KiB
Dart
191 lines
8.5 KiB
Dart
import 'package:drift/drift.dart' as drift;
|
|
import 'package:drift/native.dart';
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
|
import 'package:immich_mobile/domain/models/store.model.dart';
|
|
import 'package:immich_mobile/domain/services/local_sync.service.dart';
|
|
import 'package:immich_mobile/domain/services/store.service.dart';
|
|
import 'package:immich_mobile/entities/store.entity.dart';
|
|
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
|
import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart';
|
|
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
|
|
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
|
|
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
|
|
import 'package:immich_mobile/platform/native_sync_api.g.dart';
|
|
import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
|
|
import 'package:mocktail/mocktail.dart';
|
|
|
|
import '../../domain/service.mock.dart';
|
|
import '../../fixtures/asset.stub.dart';
|
|
import '../../infrastructure/repository.mock.dart';
|
|
import '../../mocks/asset_entity.mock.dart';
|
|
import '../../repository.mocks.dart';
|
|
|
|
void main() {
|
|
late LocalSyncService sut;
|
|
late DriftLocalAlbumRepository mockLocalAlbumRepository;
|
|
late DriftTrashedLocalAssetRepository mockTrashedLocalAssetRepository;
|
|
late LocalFilesManagerRepository mockLocalFilesManager;
|
|
late StorageRepository mockStorageRepository;
|
|
late MockNativeSyncApi mockNativeSyncApi;
|
|
late Drift db;
|
|
|
|
setUpAll(() async {
|
|
TestWidgetsFlutterBinding.ensureInitialized();
|
|
debugDefaultTargetPlatformOverride = TargetPlatform.android;
|
|
|
|
db = Drift(drift.DatabaseConnection(NativeDatabase.memory(), closeStreamsSynchronously: true));
|
|
await StoreService.init(storeRepository: DriftStoreRepository(db));
|
|
});
|
|
|
|
tearDownAll(() async {
|
|
debugDefaultTargetPlatformOverride = null;
|
|
await Store.clear();
|
|
await db.close();
|
|
});
|
|
|
|
setUp(() async {
|
|
mockLocalAlbumRepository = MockLocalAlbumRepository();
|
|
mockTrashedLocalAssetRepository = MockTrashedLocalAssetRepository();
|
|
mockLocalFilesManager = MockLocalFilesManagerRepository();
|
|
mockStorageRepository = MockStorageRepository();
|
|
mockNativeSyncApi = MockNativeSyncApi();
|
|
|
|
when(() => mockNativeSyncApi.shouldFullSync()).thenAnswer((_) async => false);
|
|
when(() => mockNativeSyncApi.getMediaChanges()).thenAnswer(
|
|
(_) async => SyncDelta(
|
|
hasChanges: false,
|
|
updates: const [],
|
|
deletes: const [],
|
|
assetAlbums: const {},
|
|
),
|
|
);
|
|
when(() => mockNativeSyncApi.getTrashedAssets()).thenAnswer((_) async => {});
|
|
when(() => mockTrashedLocalAssetRepository.processTrashSnapshot(any())).thenAnswer((_) async {});
|
|
when(() => mockTrashedLocalAssetRepository.getToRestore()).thenAnswer((_) async => []);
|
|
when(() => mockTrashedLocalAssetRepository.getToTrash()).thenAnswer((_) async => {});
|
|
when(() => mockTrashedLocalAssetRepository.applyRestoredAssets(any())).thenAnswer((_) async {});
|
|
when(() => mockTrashedLocalAssetRepository.trashLocalAsset(any())).thenAnswer((_) async {});
|
|
when(() => mockLocalFilesManager.moveToTrash(any<List<String>>())).thenAnswer((_) async => true);
|
|
|
|
sut = LocalSyncService(
|
|
localAlbumRepository: mockLocalAlbumRepository,
|
|
trashedLocalAssetRepository: mockTrashedLocalAssetRepository,
|
|
localFilesManager: mockLocalFilesManager,
|
|
storageRepository: mockStorageRepository,
|
|
nativeSyncApi: mockNativeSyncApi,
|
|
);
|
|
|
|
await Store.put(StoreKey.manageLocalMediaAndroid, false);
|
|
when(() => mockLocalFilesManager.hasManageMediaPermission()).thenAnswer((_) async => false);
|
|
});
|
|
|
|
group('LocalSyncService - syncTrashedAssets gating', () {
|
|
test('invokes syncTrashedAssets when Android flag enabled and permission granted', () async {
|
|
await Store.put(StoreKey.manageLocalMediaAndroid, true);
|
|
when(() => mockLocalFilesManager.hasManageMediaPermission()).thenAnswer((_) async => true);
|
|
|
|
await sut.sync();
|
|
|
|
verify(() => mockNativeSyncApi.getTrashedAssets()).called(1);
|
|
verify(() => mockTrashedLocalAssetRepository.processTrashSnapshot(any())).called(1);
|
|
});
|
|
|
|
test('skips syncTrashedAssets when store flag disabled', () async {
|
|
await Store.put(StoreKey.manageLocalMediaAndroid, false);
|
|
when(() => mockLocalFilesManager.hasManageMediaPermission()).thenAnswer((_) async => true);
|
|
|
|
await sut.sync();
|
|
|
|
verifyNever(() => mockNativeSyncApi.getTrashedAssets());
|
|
});
|
|
|
|
test('skips syncTrashedAssets when MANAGE_MEDIA permission absent', () async {
|
|
await Store.put(StoreKey.manageLocalMediaAndroid, true);
|
|
when(() => mockLocalFilesManager.hasManageMediaPermission()).thenAnswer((_) async => false);
|
|
|
|
await sut.sync();
|
|
|
|
verifyNever(() => mockNativeSyncApi.getTrashedAssets());
|
|
});
|
|
|
|
test('skips syncTrashedAssets on non-Android platforms', () async {
|
|
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
|
addTearDown(() => debugDefaultTargetPlatformOverride = TargetPlatform.android);
|
|
|
|
await Store.put(StoreKey.manageLocalMediaAndroid, true);
|
|
when(() => mockLocalFilesManager.hasManageMediaPermission()).thenAnswer((_) async => true);
|
|
|
|
await sut.sync();
|
|
|
|
verifyNever(() => mockNativeSyncApi.getTrashedAssets());
|
|
});
|
|
});
|
|
|
|
group('LocalSyncService - syncTrashedAssets behavior', () {
|
|
test('processes trashed snapshot, restores assets, and trashes local files', () async {
|
|
final platformAsset = PlatformAsset(
|
|
id: 'remote-id',
|
|
name: 'remote.jpg',
|
|
type: AssetType.image.index,
|
|
durationInSeconds: 0,
|
|
orientation: 0,
|
|
isFavorite: false,
|
|
);
|
|
|
|
final assetsToRestore = [LocalAssetStub.image1];
|
|
when(() => mockTrashedLocalAssetRepository.getToRestore()).thenAnswer((_) async => assetsToRestore);
|
|
final restoredIds = ['image1'];
|
|
when(() => mockLocalFilesManager.restoreAssetsFromTrash(any())).thenAnswer((invocation) async {
|
|
final Iterable<LocalAsset> requested = invocation.positionalArguments.first as Iterable<LocalAsset>;
|
|
expect(requested, orderedEquals(assetsToRestore));
|
|
return restoredIds;
|
|
});
|
|
|
|
final localAssetToTrash = LocalAssetStub.image2.copyWith(id: 'local-trash', checksum: 'checksum-trash');
|
|
when(() => mockTrashedLocalAssetRepository.getToTrash()).thenAnswer((_) async => {'album-a': [localAssetToTrash]});
|
|
|
|
final assetEntity = MockAssetEntity();
|
|
when(() => assetEntity.getMediaUrl()).thenAnswer((_) async => 'content://local-trash');
|
|
when(() => mockStorageRepository.getAssetEntityForAsset(localAssetToTrash)).thenAnswer((_) async => assetEntity);
|
|
|
|
await sut.processTrashedAssets({'album-a': [platformAsset]});
|
|
|
|
verify(() => mockTrashedLocalAssetRepository.processTrashSnapshot(any())).called(1);
|
|
verify(() => mockTrashedLocalAssetRepository.getToTrash()).called(1);
|
|
|
|
verify(() => mockLocalFilesManager.restoreAssetsFromTrash(any())).called(1);
|
|
verify(() => mockTrashedLocalAssetRepository.applyRestoredAssets(restoredIds)).called(1);
|
|
|
|
verify(() => mockStorageRepository.getAssetEntityForAsset(localAssetToTrash)).called(1);
|
|
final moveArgs =
|
|
verify(() => mockLocalFilesManager.moveToTrash(captureAny())).captured.single as List<String>;
|
|
expect(moveArgs, ['content://local-trash']);
|
|
final trashArgs =
|
|
verify(() => mockTrashedLocalAssetRepository.trashLocalAsset(captureAny())).captured.single
|
|
as Map<String, List<LocalAsset>>;
|
|
expect(trashArgs.keys, ['album-a']);
|
|
expect(trashArgs['album-a'], [localAssetToTrash]);
|
|
});
|
|
|
|
test('does not attempt restore when repository has no assets to restore', () async {
|
|
when(() => mockTrashedLocalAssetRepository.getToRestore()).thenAnswer((_) async => []);
|
|
|
|
await sut.processTrashedAssets({});
|
|
|
|
verifyNever(() => mockLocalFilesManager.restoreAssetsFromTrash(any()));
|
|
verifyNever(() => mockTrashedLocalAssetRepository.applyRestoredAssets(any()));
|
|
});
|
|
|
|
test('does not move local assets when repository finds nothing to trash', () async {
|
|
when(() => mockTrashedLocalAssetRepository.getToTrash()).thenAnswer((_) async => {});
|
|
|
|
await sut.processTrashedAssets({});
|
|
|
|
verifyNever(() => mockLocalFilesManager.moveToTrash(any()));
|
|
verifyNever(() => mockTrashedLocalAssetRepository.trashLocalAsset(any()));
|
|
});
|
|
});
|
|
}
|