diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/BackgroundServicePlugin.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/BackgroundServicePlugin.kt index ae2ec22a71..c190526c50 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/BackgroundServicePlugin.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/BackgroundServicePlugin.kt @@ -5,6 +5,7 @@ import android.content.ContentResolver import android.content.ContentUris import android.content.Context import android.content.Intent +import android.media.MediaScannerConnection import android.net.Uri import android.os.Build import android.os.Bundle @@ -37,6 +38,7 @@ class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler, private val permissionRequestCode = 1001 private val trashRequestCode = 1002 private var activityBinding: ActivityPluginBinding? = null + private var lastToggledUris: List? = null override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) { onAttachedToEngine(binding.applicationContext, binding.binaryMessenger) @@ -231,7 +233,7 @@ class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler, result.error("TrashError", "Activity or ContentResolver not available", null) return } - + lastToggledUris = contentUris try { val pendingIntent = MediaStore.createTrashRequest(contentResolver, contentUris, isTrashed) pendingResult = result // Store for onActivityResult @@ -309,6 +311,35 @@ class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler, if (requestCode == trashRequestCode) { val approved = resultCode == Activity.RESULT_OK + if (approved) { + lastToggledUris?.forEach { uri -> + val projection = arrayOf(MediaStore.MediaColumns.DATA) + try { + val cursor = context?.contentResolver?.query(uri, projection, null, null, null) + if (cursor == null) { + Log.w(TAG, "Cursor is null for URI: $uri") + return@forEach + } + + cursor.use { + if (it.moveToFirst()) { + val path = it.getStringOrNull(it.getColumnIndex(MediaStore.MediaColumns.DATA)) + if (!path.isNullOrBlank()) { + Log.i(TAG, "Scanning updated file: $path") + MediaScannerConnection.scanFile(context, arrayOf(path), null, null) + } else { + Log.w(TAG, "Path is null or blank for URI: $uri") + } + } else { + Log.w(TAG, "Cursor is empty for URI: $uri") + } + } + } catch (e: Exception) { + Log.e(TAG, "Error during rescan for URI: $uri", e) + } + } + } + lastToggledUris = null pendingResult?.success(approved) pendingResult = null return true diff --git a/mobile/lib/domain/services/asset.service.dart b/mobile/lib/domain/services/asset.service.dart index c8cc61314e..ce1e5177cc 100644 --- a/mobile/lib/domain/services/asset.service.dart +++ b/mobile/lib/domain/services/asset.service.dart @@ -3,19 +3,36 @@ import 'package:immich_mobile/domain/models/exif.model.dart'; import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart'; import 'package:immich_mobile/infrastructure/utils/exif.converter.dart'; +import 'package:immich_mobile/services/app_settings.service.dart'; +import 'package:logging/logging.dart'; import 'package:platform/platform.dart'; +import '../../infrastructure/repositories/storage.repository.dart'; +import '../../repositories/local_files_manager.repository.dart'; + class AssetService { + final AppSettingsService _appSettingsService; final RemoteAssetRepository _remoteAssetRepository; final DriftLocalAssetRepository _localAssetRepository; + final LocalFilesManagerRepository _localFilesManager; + final StorageRepository _storageRepository; final Platform _platform; + final Logger _logger; const AssetService({ + required AppSettingsService appSettingsService, required RemoteAssetRepository remoteAssetRepository, required DriftLocalAssetRepository localAssetRepository, - }) : _remoteAssetRepository = remoteAssetRepository, + required LocalFilesManagerRepository localFilesManager, + required StorageRepository storageRepository, + required Logger logger, + }) : _appSettingsService = appSettingsService, + _remoteAssetRepository = remoteAssetRepository, _localAssetRepository = localAssetRepository, - _platform = const LocalPlatform(); + _localFilesManager = localFilesManager, + _storageRepository = storageRepository, + _platform = const LocalPlatform(), + _logger = logger; Stream watchAsset(BaseAsset asset) { final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).id; @@ -84,4 +101,34 @@ class AssetService { Future getLocalHashedCount() { return _localAssetRepository.getHashedCount(); } + + Future handleRemoteTrashChanges(Iterable<({String checksum, DateTime? deletedAt})> syncData) async { + if (_platform.isAndroid && _appSettingsService.getSetting(AppSettingsEnum.manageLocalMediaAndroid)) {} + final trashedItems = syncData.where((item) => item.deletedAt != null); + + if (trashedItems.isNotEmpty) { + final trashedAssetsChecksums = trashedItems.map((syncAsset) => syncAsset.checksum); + final localAssetsToTrash = await _localAssetRepository.getAssetsByChecksums(trashedAssetsChecksums); + if (localAssetsToTrash.isNotEmpty) { + final mediaUrls = await Future.wait( + localAssetsToTrash.map( + (localAsset) => _storageRepository.getAssetEntityForAsset(localAsset).then((e) => e?.getMediaUrl()), + ), + ); + _logger.fine("Moving to trash ${mediaUrls.join(", ")} assets"); + await _localFilesManager.moveToTrash(mediaUrls.nonNulls.toList()); + } + } + final modifiedItems = syncData.where((e) => e.deletedAt == null); + if (modifiedItems.isNotEmpty) { + final modifiedChecksums = modifiedItems.map((syncAsset) => syncAsset.checksum); + final remoteAssetsToRestore = await _remoteAssetRepository.getAssetsByChecksums(modifiedChecksums, isTrashed: true); + if (remoteAssetsToRestore.isNotEmpty) { + _logger.fine("Restoring from trash ${remoteAssetsToRestore.map((e) => e.name).join(", ")} assets"); + for (final remoteAsset in remoteAssetsToRestore) { + await _localFilesManager.restoreFromTrash(remoteAsset.name, remoteAsset.type.index); + } + } + } + } } diff --git a/mobile/lib/domain/services/sync_stream.service.dart b/mobile/lib/domain/services/sync_stream.service.dart index c21a9cade5..aa60975729 100644 --- a/mobile/lib/domain/services/sync_stream.service.dart +++ b/mobile/lib/domain/services/sync_stream.service.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:immich_mobile/domain/models/sync_event.model.dart'; +import 'package:immich_mobile/domain/services/asset.service.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/presentation/pages/dev/dev_logger.dart'; @@ -12,14 +13,17 @@ class SyncStreamService { final SyncApiRepository _syncApiRepository; final SyncStreamRepository _syncStreamRepository; + final AssetService _assetService; final bool Function()? _cancelChecker; SyncStreamService({ required SyncApiRepository syncApiRepository, required SyncStreamRepository syncStreamRepository, + required AssetService assetService, bool Function()? cancelChecker, }) : _syncApiRepository = syncApiRepository, _syncStreamRepository = syncStreamRepository, + _assetService = assetService, _cancelChecker = cancelChecker; bool get isCancelled => _cancelChecker?.call() ?? false; @@ -114,7 +118,11 @@ class SyncStreamService { case SyncEntityType.partnerDeleteV1: return _syncStreamRepository.deletePartnerV1(data.cast()); case SyncEntityType.assetV1: - return _syncStreamRepository.updateAssetsV1(data.cast()); + final remoteSyncAssets = data.cast(); + await _assetService.handleRemoteTrashChanges( + remoteSyncAssets.map((e) => (checksum: e.checksum, deletedAt: e.deletedAt)), + ); + return _syncStreamRepository.updateAssetsV1(remoteSyncAssets); case SyncEntityType.assetDeleteV1: return _syncStreamRepository.deleteAssetsV1(data.cast()); case SyncEntityType.assetExifV1: diff --git a/mobile/lib/infrastructure/repositories/local_asset.repository.dart b/mobile/lib/infrastructure/repositories/local_asset.repository.dart index 58adac30db..931ddc1473 100644 --- a/mobile/lib/infrastructure/repositories/local_asset.repository.dart +++ b/mobile/lib/infrastructure/repositories/local_asset.repository.dart @@ -65,4 +65,10 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository { Future getHashedCount() { return _db.managers.localAssetEntity.filter((e) => e.checksum.isNull().not()).count(); } + + Future> getAssetsByChecksums(Iterable checksums) { + if (checksums.isEmpty) return Future.value([]); + final query = _db.localAssetEntity.select()..where((lae) => lae.checksum.isIn(checksums)); + return query.map((row) => row.toDto()).get(); + } } diff --git a/mobile/lib/infrastructure/repositories/remote_asset.repository.dart b/mobile/lib/infrastructure/repositories/remote_asset.repository.dart index 44d7cfb6bb..ed8fc217f0 100644 --- a/mobile/lib/infrastructure/repositories/remote_asset.repository.dart +++ b/mobile/lib/infrastructure/repositories/remote_asset.repository.dart @@ -12,6 +12,7 @@ import 'package:maplibre_gl/maplibre_gl.dart'; class RemoteAssetRepository extends DriftDatabaseRepository { final Drift _db; + const RemoteAssetRepository(this._db) : super(_db); /// For testing purposes @@ -252,4 +253,23 @@ class RemoteAssetRepository extends DriftDatabaseRepository { Future getCount() { return _db.managers.remoteAssetEntity.count(); } + + Future> getAssetsByChecksums(Iterable checksums, {bool? isTrashed}) { + if (checksums.isEmpty) return Future.value([]); + final conditions = >[ + _db.remoteAssetEntity.checksum.isIn(checksums), + ]; + + if (isTrashed != null) { + if (isTrashed) { + conditions.add(_db.remoteAssetEntity.deletedAt.isNotNull()); + } else { + conditions.add(_db.remoteAssetEntity.deletedAt.isNull()); + } + } + + final query = _db.remoteAssetEntity.select()..where((rae) => conditions.reduce((a, b) => a & b)); + return query.map((row) => row.toDto()).get(); + } + } diff --git a/mobile/lib/providers/infrastructure/asset.provider.dart b/mobile/lib/providers/infrastructure/asset.provider.dart index 102e6aa60c..81d224ecc9 100644 --- a/mobile/lib/providers/infrastructure/asset.provider.dart +++ b/mobile/lib/providers/infrastructure/asset.provider.dart @@ -2,7 +2,12 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/services/asset.service.dart'; import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart'; +import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/storage.provider.dart'; +import 'package:logging/logging.dart'; + +import '../../repositories/local_files_manager.repository.dart'; final localAssetRepository = Provider( (ref) => DriftLocalAssetRepository(ref.watch(driftProvider)), @@ -14,14 +19,22 @@ final remoteAssetRepositoryProvider = Provider( final assetServiceProvider = Provider( (ref) => AssetService( + appSettingsService: ref.watch(appSettingsServiceProvider), remoteAssetRepository: ref.watch(remoteAssetRepositoryProvider), localAssetRepository: ref.watch(localAssetRepository), + localFilesManager: ref.watch(localFilesManagerRepositoryProvider), + storageRepository: ref.watch(storageRepositoryProvider), + logger: Logger('AssetService'), ), ); final placesProvider = FutureProvider>( (ref) => AssetService( + appSettingsService: ref.watch(appSettingsServiceProvider), remoteAssetRepository: ref.watch(remoteAssetRepositoryProvider), localAssetRepository: ref.watch(localAssetRepository), + localFilesManager: ref.watch(localFilesManagerRepositoryProvider), + storageRepository: ref.watch(storageRepositoryProvider), + logger: Logger('AssetService'), ).getPlaces(), ); diff --git a/mobile/lib/providers/infrastructure/sync.provider.dart b/mobile/lib/providers/infrastructure/sync.provider.dart index ddc6eed441..af0a7e17d5 100644 --- a/mobile/lib/providers/infrastructure/sync.provider.dart +++ b/mobile/lib/providers/infrastructure/sync.provider.dart @@ -16,6 +16,7 @@ final syncStreamServiceProvider = Provider( (ref) => SyncStreamService( syncApiRepository: ref.watch(syncApiRepositoryProvider), syncStreamRepository: ref.watch(syncStreamRepositoryProvider), + assetService: ref.watch(assetServiceProvider), cancelChecker: ref.watch(cancellationProvider), ), ); diff --git a/mobile/test/domain/service.mock.dart b/mobile/test/domain/service.mock.dart index 8293faf125..7aaa31c68a 100644 --- a/mobile/test/domain/service.mock.dart +++ b/mobile/test/domain/service.mock.dart @@ -1,3 +1,4 @@ +import 'package:immich_mobile/domain/services/asset.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/utils/background_sync.dart'; @@ -17,3 +18,5 @@ class MockNativeSyncApi extends Mock implements NativeSyncApi {} class MockAppSettingsService extends Mock implements AppSettingsService {} class MockUploadService extends Mock implements UploadService {} + +class MockAssetService extends Mock implements AssetService {} diff --git a/mobile/test/domain/services/sync_stream_service_test.dart b/mobile/test/domain/services/sync_stream_service_test.dart index 46e585faa0..997fbc2510 100644 --- a/mobile/test/domain/services/sync_stream_service_test.dart +++ b/mobile/test/domain/services/sync_stream_service_test.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:flutter_test/flutter_test.dart'; import 'package:immich_mobile/domain/models/sync_event.model.dart'; +import 'package:immich_mobile/domain/services/asset.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_stream.repository.dart'; @@ -9,6 +10,7 @@ import 'package:mocktail/mocktail.dart'; import '../../fixtures/sync_stream.stub.dart'; import '../../infrastructure/repository.mock.dart'; +import '../service.mock.dart'; class _AbortCallbackWrapper { const _AbortCallbackWrapper(); @@ -30,6 +32,7 @@ void main() { late SyncStreamService sut; late SyncStreamRepository mockSyncStreamRepo; late SyncApiRepository mockSyncApiRepo; + late AssetService mockAssetService; late Function(List, Function()) handleEventsCallback; late _MockAbortCallbackWrapper mockAbortCallbackWrapper; @@ -39,7 +42,7 @@ void main() { mockSyncStreamRepo = MockSyncStreamRepository(); mockSyncApiRepo = MockSyncApiRepository(); mockAbortCallbackWrapper = _MockAbortCallbackWrapper(); - + mockAssetService = MockAssetService(); when(() => mockAbortCallbackWrapper()).thenReturn(false); when(() => mockSyncApiRepo.streamChanges(any())).thenAnswer((invocation) async { @@ -81,7 +84,7 @@ void main() { when(() => mockSyncStreamRepo.updateAssetFacesV1(any())).thenAnswer(successHandler); when(() => mockSyncStreamRepo.deleteAssetFacesV1(any())).thenAnswer(successHandler); - sut = SyncStreamService(syncApiRepository: mockSyncApiRepo, syncStreamRepository: mockSyncStreamRepo); + sut = SyncStreamService(syncApiRepository: mockSyncApiRepo, syncStreamRepository: mockSyncStreamRepo, assetService: mockAssetService); }); Future simulateEvents(List events) async { @@ -146,6 +149,7 @@ void main() { sut = SyncStreamService( syncApiRepository: mockSyncApiRepo, syncStreamRepository: mockSyncStreamRepo, + assetService: mockAssetService, cancelChecker: cancellationChecker.call, ); await sut.sync(); @@ -181,6 +185,7 @@ void main() { sut = SyncStreamService( syncApiRepository: mockSyncApiRepo, syncStreamRepository: mockSyncStreamRepo, + assetService: mockAssetService, cancelChecker: cancellationChecker.call, );