Compare commits

...

1 Commits

Author SHA1 Message Date
shenlong-tanwen ce567691a5 fix: automatic delete should not break sync 2026-07-08 03:33:42 +05:30
5 changed files with 65 additions and 3 deletions
@@ -413,7 +413,16 @@ class LocalSyncService {
if (localAssetsToTrash.isNotEmpty) {
final localIds = localAssetsToTrash.values.expand((assets) => assets).map((asset) => asset.id).toList();
_log.info("Moving to trash ${localIds.join(", ")} assets");
final movedIds = await _assetMediaRepository.deleteAll(localIds);
final List<String> movedIds;
try {
movedIds = await _assetMediaRepository.deleteAll(localIds);
} catch (error, stackTrace) {
_log.severe("Failed to move local assets to trash", error, stackTrace);
// Automatic trash sync shouldn't block sync of other assets
// If the delete fails because an asset is in trash, the successive sync process
// will also remove it from the local DB
return;
}
if (movedIds.isNotEmpty) {
final movedAssetsByAlbum = localAssetsToTrash.map(
(albumId, assets) => MapEntry(albumId, assets.where((asset) => movedIds.contains(asset.id)).toList()),
@@ -516,7 +516,14 @@ class SyncStreamService {
Future<void> _trashLocalAssets(Map<String, List<LocalAsset>> localAssetsToTrash) async {
final localIds = localAssetsToTrash.values.expand((assets) => assets).map((asset) => asset.id).toList();
_logger.info("Moving to trash ${localIds.join(", ")} assets");
final movedIds = await _assetMediaRepository.deleteAll(localIds);
final List<String> movedIds;
try {
movedIds = await _assetMediaRepository.deleteAll(localIds);
} catch (error, stackTrace) {
_logger.severe("Failed to move local assets to trash", error, stackTrace);
// The local sync service retries this on the next sync, so it is safe to log and exit early
return;
}
if (movedIds.isNotEmpty) {
final movedAssetsByAlbum = localAssetsToTrash.map(
(albumId, assets) => MapEntry(albumId, assets.where((asset) => movedIds.contains(asset.id)).toList()),
@@ -45,6 +45,7 @@ class AssetMediaRepository {
return false;
}
// TODO: Replace the calls with a native pigeon method that filters ids no longer in the MediaStore before building the request
Future<List<String>> deleteAll(List<String> ids) async {
if (CurrentPlatform.isAndroid) {
if (await _androidSupportsTrash()) {
@@ -1,6 +1,7 @@
import 'package:drift/drift.dart' as drift;
import 'package:drift/native.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.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';
@@ -197,6 +198,24 @@ void main() {
expect(trashArgs['album-a'], [movedAsset]);
});
test('continues the device diff when moving local assets to trash fails', () async {
await Store.put(StoreKey.manageLocalMediaAndroid, true);
when(() => mockPermissionRepository.hasManageMediaPermission()).thenAnswer((_) async => true);
when(() => mockTrashedLocalAssetRepository.getToTrash()).thenAnswer(
(_) async => {
'album-a': [LocalAssetStub.image1],
},
);
when(
() => mockAssetMediaRepository.deleteAll(any()),
).thenThrow(PlatformException(code: 'PLATFORM_ERROR', message: 'deleteWithIds failed'));
await sut.sync();
verifyNever(() => mockTrashedLocalAssetRepository.trashLocalAsset(any()));
verify(() => mockNativeSyncApi.getMediaChanges()).called(1);
});
test('does not attempt restore when repository has no assets to restore', () async {
when(() => mockTrashedLocalAssetRepository.getToRestore()).thenAnswer((_) async => []);
@@ -3,6 +3,7 @@ import 'dart:async';
import 'package:drift/drift.dart' as drift;
import 'package:drift/native.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.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';
@@ -36,7 +37,6 @@ class _AbortCallbackWrapper {
class _MockAbortCallbackWrapper extends Mock implements _AbortCallbackWrapper {}
void main() {
late SyncStreamService sut;
late SyncStreamRepository mockSyncStreamRepo;
@@ -494,6 +494,32 @@ void main() {
expect(trashArgs['album-a'], [movedAsset]);
});
test("acks the batch and skips trash bookkeeping when moving assets to device trash fails", () async {
final localAsset = LocalAssetStub.image1.copyWith(id: 'stale-local', checksum: 'checksum-stale');
when(() => mockLocalAssetRepo.getAssetsFromBackupAlbums(any())).thenAnswer(
(_) async => {
'album-a': [localAsset],
},
);
when(
() => mockAssetMediaRepo.deleteAll(any()),
).thenThrow(PlatformException(code: 'PLATFORM_ERROR', message: 'deleteWithIds failed'));
final events = [
SyncStreamStub.assetTrashed(
id: 'remote-1',
checksum: localAsset.checksum!,
ack: 'asset-remote-1',
trashedAt: DateTime(2025, 5, 1),
),
];
await simulateEvents(events);
verifyNever(() => mockTrashedLocalAssetRepo.trashLocalAsset(any()));
verify(() => mockSyncApiRepo.ack(['asset-remote-1'])).called(1);
});
test("skips device trashing when no local assets match the remote trash payload", () async {
final events = [
SyncStreamStub.assetTrashed(