feat(mobile): write the previous checksum when the upload finishes (#31577)

* write the previous checksum when the upload finishes

* ask the server for the checksum when the upload has none
This commit is contained in:
Santo Shakil
2026-09-16 23:47:02 +06:00
committed by GitHub
parent bd59a373de
commit c68ecb3892
11 changed files with 142 additions and 24 deletions
@@ -3,6 +3,7 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
import '../repository_context.dart';
@@ -28,41 +29,80 @@ void main() {
return (checksum: data.checksum, previousChecksum: data.previousChecksum);
}
for (final (platform, scenario, edited, expected) in [
for (final (platform, scenario, before, edited, after) in [
(
TargetPlatform.android,
'updatedAt changed clears the checksum and remembers it',
(checksum: 'a', previousChecksum: null),
_localAsset('edited', updatedAt: DateTime(2025)),
(checksum: null, previousChecksum: 'a'),
),
(
TargetPlatform.android,
'adjustmentTime changed keeps the checksum',
(checksum: 'a', previousChecksum: null),
_localAsset('edited', adjustmentTime: DateTime(2025)),
(checksum: 'a', previousChecksum: null),
),
(
TargetPlatform.android,
'updatedAt changed again before the upload keeps the uploaded checksum',
(checksum: 'b', previousChecksum: 'a'),
_localAsset('edited', updatedAt: DateTime(2025)),
(checksum: null, previousChecksum: 'a'),
),
(
TargetPlatform.iOS,
'adjustmentTime changed clears the checksum and remembers it',
(checksum: 'a', previousChecksum: null),
_localAsset('edited', adjustmentTime: DateTime(2025)),
(checksum: null, previousChecksum: 'a'),
),
(
TargetPlatform.iOS,
'updatedAt changed keeps the checksum',
(checksum: 'a', previousChecksum: null),
_localAsset('edited', updatedAt: DateTime(2025)),
(checksum: 'a', previousChecksum: null),
),
(
TargetPlatform.iOS,
'adjustmentTime changed again before the upload keeps the uploaded checksum',
(checksum: 'b', previousChecksum: 'a'),
_localAsset('edited', adjustmentTime: DateTime(2025)),
(checksum: null, previousChecksum: 'a'),
),
]) {
test('${platform.name}: $scenario', () async {
debugDefaultTargetPlatformOverride = platform;
await ctx.newLocalAsset(id: 'edited', checksum: 'a', updatedAt: DateTime(2024), adjustmentTime: DateTime(2024));
await ctx.newLocalAsset(
id: 'edited',
checksum: before.checksum,
previousChecksum: before.previousChecksum,
updatedAt: DateTime(2024),
adjustmentTime: DateTime(2024),
);
await sut.upsert(album, toUpsert: [edited]);
expect(await row('edited'), expected);
expect(await row('edited'), after);
});
}
test('android: previous follows the uploads across two edits', () async {
debugDefaultTargetPlatformOverride = TargetPlatform.android;
final assets = LocalAssetRepository(ctx.db);
await ctx.newLocalAsset(id: 'edited', checksum: 'a', updatedAt: DateTime(2024), adjustmentTime: DateTime(2024));
await assets.updatePreviousChecksum('edited', 'a');
await sut.upsert(album, toUpsert: [_localAsset('edited', updatedAt: DateTime(2025))]);
expect(await row('edited'), (checksum: null, previousChecksum: 'a'));
await assets.updateHashes({'edited': 'b'});
await assets.updatePreviousChecksum('edited', 'b');
await sut.upsert(album, toUpsert: [_localAsset('edited', updatedAt: DateTime(2026))]);
expect(await row('edited'), (checksum: null, previousChecksum: 'b'));
});
});
}
@@ -54,7 +54,7 @@ void main() {
mockBackupRepository = MockBackupRepository();
mockAssetMediaRepository = MockAssetMediaRepository();
mockAssetService = MockAssetService();
when(() => mockAssetService.stackEditedUpload(any(), any())).thenAnswer((_) async {});
when(() => mockAssetService.stackEditedUpload(any(), any(), any())).thenAnswer((_) async {});
sut = BackgroundUploadService(
mockUploadRepository,
@@ -416,7 +416,7 @@ void main() {
group('onUploadStatus', () {
test('stacks a plain photo after its upload', () async {
final asset = LocalAssetStub.image1;
final asset = LocalAssetStub.image1.copyWith(checksum: 'sha');
final mockEntity = MockAssetEntity();
final mockFile = File('/path/to/photo.jpg');
final void Function(TaskStatusUpdate) onStatus = verify(
@@ -431,12 +431,12 @@ void main() {
final task = await sut.getUploadTask(asset);
onStatus(TaskStatusUpdate(task!, TaskStatus.complete, null, '{"id": "remote"}'));
verify(() => mockAssetService.stackEditedUpload(asset.id, 'remote')).called(1);
verify(() => mockAssetService.stackEditedUpload(asset.id, 'remote', 'sha')).called(1);
verifyNoMoreInteractions(mockAssetService);
});
test('stacks the still of a live photo, not its video', () async {
final asset = LocalAssetStub.image1;
final asset = LocalAssetStub.image1.copyWith(checksum: 'sha');
final mockEntity = MockAssetEntity();
final stillFile = File('/path/to/still.heic');
final videoFile = File('/path/to/motion.mov');
@@ -456,7 +456,7 @@ void main() {
onStatus(TaskStatusUpdate(video!, TaskStatus.complete, null, '{"id": "video"}'));
onStatus(TaskStatusUpdate(still!, TaskStatus.complete, null, '{"id": "still"}'));
verify(() => mockAssetService.stackEditedUpload(asset.id, 'still')).called(1);
verify(() => mockAssetService.stackEditedUpload(asset.id, 'still', 'sha')).called(1);
verifyNoMoreInteractions(mockAssetService);
});
});
@@ -55,7 +55,7 @@ void main() {
mockConnectivityApi = MockConnectivityApi();
mockAssetMediaRepository = MockAssetMediaRepository();
mockAssetService = MockAssetService();
when(() => mockAssetService.stackEditedUpload(any(), any())).thenAnswer((_) async {});
when(() => mockAssetService.stackEditedUpload(any(), any(), any())).thenAnswer((_) async {});
sut = ForegroundUploadService(
mockUploadRepository,
@@ -203,7 +203,7 @@ void main() {
});
test('stacks a plain photo after its upload', () async {
final asset = LocalAssetStub.image1;
final asset = LocalAssetStub.image1.copyWith(checksum: 'sha');
final mockEntity = MockAssetEntity();
final stillFile = File('/path/to/photo.jpg');
@@ -216,12 +216,12 @@ void main() {
await sut.uploadSingleAsset(asset, null, callbacks: const UploadCallbacks());
verify(() => mockAssetService.stackEditedUpload(asset.localId!, 'remote-1')).called(1);
verify(() => mockAssetService.stackEditedUpload(asset.localId!, 'remote-1', 'sha')).called(1);
verifyNoMoreInteractions(mockAssetService);
});
test('stacks the still of a live photo, not its video', () async {
final asset = LocalAssetStub.image1;
final asset = LocalAssetStub.image1.copyWith(checksum: 'sha');
final mockEntity = MockAssetEntity();
final stillFile = File('/path/to/still.heic');
final videoFile = File('/path/to/motion.mov');
@@ -236,7 +236,7 @@ void main() {
await sut.uploadSingleAsset(asset, null, callbacks: const UploadCallbacks());
verify(() => mockAssetService.stackEditedUpload(asset.localId!, 'remote-2')).called(1);
verify(() => mockAssetService.stackEditedUpload(asset.localId!, 'remote-2', 'sha')).called(1);
verifyNoMoreInteractions(mockAssetService);
});
});
@@ -3,6 +3,7 @@ import 'package:drift/native.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/data/db/main/database.dart';
import 'package:immich_mobile/domain/models/stack.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/services/asset.service.dart';
import 'package:immich_mobile/domain/services/store.service.dart';
@@ -119,4 +120,54 @@ void main() {
verifyNever(() => mocks.trashedAsset.applyTrashedAssets(any()));
});
});
group('AssetService.stackEditedUpload', () {
const stack = StackResponse(id: 'stack', primaryAssetId: 'remote', assetIds: ['remote', 'previous']);
setUp(() {
when(() => mocks.localAsset.repo.updatePreviousChecksum(any(), any())).thenAnswer((_) async {});
});
test('stacks over the previous version, then records the uploaded checksum', () async {
when(() => mocks.localAsset.repo.getPreviousRemoteId('local')).thenAnswer((_) async => 'previous');
when(() => apiRepository.stack(any())).thenAnswer((_) async => stack);
await sut.stackEditedUpload('local', 'remote', 'sha');
verifyInOrder([
() => apiRepository.stack(['remote', 'previous']),
() => mocks.localAsset.repo.updatePreviousChecksum('local', 'sha'),
]);
verifyNever(() => apiRepository.getChecksum(any()));
});
test('records the uploaded checksum when there is nothing to stack on', () async {
when(() => mocks.localAsset.repo.getPreviousRemoteId('local')).thenAnswer((_) async => null);
await sut.stackEditedUpload('local', 'remote', 'sha');
verifyNever(() => apiRepository.stack(any()));
verify(() => mocks.localAsset.repo.updatePreviousChecksum('local', 'sha')).called(1);
});
test('records the uploaded checksum even when the stack call fails', () async {
when(() => mocks.localAsset.repo.getPreviousRemoteId('local')).thenAnswer((_) async => 'previous');
when(() => apiRepository.stack(any())).thenThrow(Exception('offline'));
await expectLater(sut.stackEditedUpload('local', 'remote', 'sha'), throwsException);
verify(() => mocks.localAsset.repo.updatePreviousChecksum('local', 'sha')).called(1);
});
test('asks the server for the checksum when the upload did not know it', () async {
when(() => mocks.localAsset.repo.getPreviousRemoteId('local')).thenAnswer((_) async => 'previous');
when(() => apiRepository.getChecksum('remote')).thenAnswer((_) async => 'srv');
when(() => apiRepository.stack(any())).thenAnswer((_) async => stack);
await sut.stackEditedUpload('local', 'remote', null);
verify(() => apiRepository.stack(['remote', 'previous'])).called(1);
verify(() => mocks.localAsset.repo.updatePreviousChecksum('local', 'srv')).called(1);
});
});
}