feat(mobile): stack edited photos over the original on upload (#31082)

* feat(mobile): stack edited photos over the original on upload

* stack through the asset service and give the live photo still its task metadata

* add plain photo stacking tests and the 2x2 edit detection matrix
This commit is contained in:
Santo Shakil
2026-09-15 11:22:14 -07:00
committed by GitHub
parent ab5a9ae48c
commit 962ffaacc1
13 changed files with 3913 additions and 5 deletions
@@ -0,0 +1,78 @@
import 'package:flutter/foundation.dart';
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 '../repository_context.dart';
void main() {
late MediumRepositoryContext ctx;
late LocalAlbumRepository sut;
setUp(() {
ctx = MediumRepositoryContext();
sut = LocalAlbumRepository(ctx.db);
});
tearDown(() async {
debugDefaultTargetPlatformOverride = null;
await ctx.dispose();
});
group('upsert', () {
final album = LocalAlbum(id: 'album', name: 'Camera', updatedAt: DateTime(2024));
Future<({String? checksum, String? previousChecksum})> row(String id) async {
final data = await ctx.db.managers.localAssetEntity.filter((row) => row.id.equals(id)).getSingle();
return (checksum: data.checksum, previousChecksum: data.previousChecksum);
}
for (final (platform, scenario, edited, expected) in [
(
TargetPlatform.android,
'updatedAt changed clears the checksum and remembers it',
_localAsset('edited', updatedAt: DateTime(2025)),
(checksum: null, previousChecksum: 'a'),
),
(
TargetPlatform.android,
'adjustmentTime changed keeps the checksum',
_localAsset('edited', adjustmentTime: DateTime(2025)),
(checksum: 'a', previousChecksum: null),
),
(
TargetPlatform.iOS,
'adjustmentTime changed clears the checksum and remembers it',
_localAsset('edited', adjustmentTime: DateTime(2025)),
(checksum: null, previousChecksum: 'a'),
),
(
TargetPlatform.iOS,
'updatedAt changed keeps the checksum',
_localAsset('edited', updatedAt: DateTime(2025)),
(checksum: 'a', previousChecksum: null),
),
]) {
test('${platform.name}: $scenario', () async {
debugDefaultTargetPlatformOverride = platform;
await ctx.newLocalAsset(id: 'edited', checksum: 'a', updatedAt: DateTime(2024), adjustmentTime: DateTime(2024));
await sut.upsert(album, toUpsert: [edited]);
expect(await row('edited'), expected);
});
}
});
}
LocalAsset _localAsset(String id, {DateTime? updatedAt, DateTime? adjustmentTime}) => LocalAsset(
id: id,
name: '$id.jpg',
type: AssetType.image,
createdAt: DateTime(2024),
updatedAt: updatedAt ?? DateTime(2024),
adjustmentTime: adjustmentTime ?? DateTime(2024),
playbackStyle: AssetPlaybackStyle.image,
isEdited: false,
);
@@ -634,4 +634,21 @@ void main() {
expect(updated?.checksum, isNull);
});
});
group('getPreviousRemoteId', () {
test('finds the owned remote asset that has the previous checksum', () async {
final me = await ctx.newUser();
final partner = await ctx.newUser();
await ctx.newAuthUser(id: me.id);
final original = await ctx.newRemoteAsset(ownerId: me.id, checksum: 'a');
await ctx.newRemoteAsset(ownerId: partner.id, checksum: 'z');
final edited = await ctx.newLocalAsset(checksum: 'b', previousChecksum: 'a');
final partnerEdited = await ctx.newLocalAsset(checksum: 'c', previousChecksum: 'z');
final untouched = await ctx.newLocalAsset(checksum: 'd');
expect(await sut.getPreviousRemoteId(edited.id), original.id);
expect(await sut.getPreviousRemoteId(partnerEdited.id), isNull);
expect(await sut.getPreviousRemoteId(untouched.id), isNull);
});
});
}
@@ -268,6 +268,7 @@ class MediumRepositoryContext {
String? name,
String? checksum,
Option<String>? checksumOption,
String? previousChecksum,
DateTime? createdAt,
AssetType? type,
bool? isFavorite,
@@ -296,6 +297,7 @@ class MediumRepositoryContext {
orientation: .new(orientation ?? 0),
updatedAt: .new(TestUtils.date(updatedAt)),
checksum: _resolveUndefined(checksum, checksumOption, const Uuid().v4()),
previousChecksum: .new(previousChecksum),
createdAt: .new(TestUtils.date(createdAt)),
type: .new(type ?? .image),
isFavorite: .new(isFavorite ?? false),
@@ -1,6 +1,7 @@
import 'dart:convert';
import 'dart:io';
import 'package:background_downloader/background_downloader.dart';
import 'package:drift/drift.dart' hide isNotNull, isNull;
import 'package:drift/native.dart';
import 'package:flutter/foundation.dart';
@@ -20,6 +21,7 @@ import '../fixtures/asset.stub.dart';
import '../infrastructure/repository.mock.dart';
import '../mocks/asset_entity.mock.dart';
import '../repository.mocks.dart';
import '../service.mocks.dart';
void main() {
late BackgroundUploadService sut;
@@ -28,6 +30,7 @@ void main() {
late MockLocalAssetRepository mockLocalAssetRepository;
late MockBackupRepository mockBackupRepository;
late MockAssetMediaRepository mockAssetMediaRepository;
late MockAssetService mockAssetService;
late Drift db;
setUpAll(() async {
@@ -50,6 +53,8 @@ void main() {
mockLocalAssetRepository = MockLocalAssetRepository();
mockBackupRepository = MockBackupRepository();
mockAssetMediaRepository = MockAssetMediaRepository();
mockAssetService = MockAssetService();
when(() => mockAssetService.stackEditedUpload(any(), any())).thenAnswer((_) async {});
sut = BackgroundUploadService(
mockUploadRepository,
@@ -57,6 +62,7 @@ void main() {
mockLocalAssetRepository,
mockBackupRepository,
mockAssetMediaRepository,
mockAssetService,
);
mockUploadRepository.onUploadStatus = (_) {};
@@ -234,6 +240,7 @@ void main() {
mockLocalAssetRepository,
mockBackupRepository,
mockAssetMediaRepository,
mockAssetService,
);
addTearDown(() => sutWithV24.dispose());
@@ -284,6 +291,7 @@ void main() {
mockLocalAssetRepository,
mockBackupRepository,
mockAssetMediaRepository,
mockAssetService,
);
addTearDown(() => sutAndroid.dispose());
@@ -324,6 +332,7 @@ void main() {
mockLocalAssetRepository,
mockBackupRepository,
mockAssetMediaRepository,
mockAssetService,
);
addTearDown(() => sutWithV24.dispose());
@@ -364,6 +373,7 @@ void main() {
mockLocalAssetRepository,
mockBackupRepository,
mockAssetMediaRepository,
mockAssetService,
);
addTearDown(() => sutWithV24.dispose());
@@ -403,4 +413,51 @@ void main() {
expect(metadata[0]['value']['iCloudId'], equals('cloud-id-livephoto'));
});
});
group('onUploadStatus', () {
test('stacks a plain photo after its upload', () async {
final asset = LocalAssetStub.image1;
final mockEntity = MockAssetEntity();
final mockFile = File('/path/to/photo.jpg');
final void Function(TaskStatusUpdate) onStatus = verify(
() => mockUploadRepository.onUploadStatus = captureAny(),
).captured.first;
when(() => mockEntity.isLivePhoto).thenReturn(false);
when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity);
when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => mockFile);
when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => 'photo.jpg');
final task = await sut.getUploadTask(asset);
onStatus(TaskStatusUpdate(task!, TaskStatus.complete, null, '{"id": "remote"}'));
verify(() => mockAssetService.stackEditedUpload(asset.id, 'remote')).called(1);
verifyNoMoreInteractions(mockAssetService);
});
test('stacks the still of a live photo, not its video', () async {
final asset = LocalAssetStub.image1;
final mockEntity = MockAssetEntity();
final stillFile = File('/path/to/still.heic');
final videoFile = File('/path/to/motion.mov');
final void Function(TaskStatusUpdate) onStatus = verify(
() => mockUploadRepository.onUploadStatus = captureAny(),
).captured.first;
when(() => mockEntity.isLivePhoto).thenReturn(true);
when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity);
when(() => mockStorageRepository.getMotionFileForAsset(asset)).thenAnswer((_) async => videoFile);
when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => stillFile);
when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => 'live.heic');
when(() => mockLocalAssetRepository.getById(asset.id)).thenAnswer((_) async => null);
final video = await sut.getUploadTask(asset);
final still = await sut.getLivePhotoUploadTask(asset, 'video');
onStatus(TaskStatusUpdate(video!, TaskStatus.complete, null, '{"id": "video"}'));
onStatus(TaskStatusUpdate(still!, TaskStatus.complete, null, '{"id": "still"}'));
verify(() => mockAssetService.stackEditedUpload(asset.id, 'still')).called(1);
verifyNoMoreInteractions(mockAssetService);
});
});
}
@@ -19,6 +19,7 @@ import '../fixtures/asset.stub.dart';
import '../infrastructure/repository.mock.dart';
import '../mocks/asset_entity.mock.dart';
import '../repository.mocks.dart';
import '../service.mocks.dart';
void main() {
late ForegroundUploadService sut;
@@ -27,6 +28,7 @@ void main() {
late MockBackupRepository mockBackupRepository;
late MockConnectivityApi mockConnectivityApi;
late MockAssetMediaRepository mockAssetMediaRepository;
late MockAssetService mockAssetService;
late Drift db;
setUpAll(() async {
@@ -52,6 +54,8 @@ void main() {
mockBackupRepository = MockBackupRepository();
mockConnectivityApi = MockConnectivityApi();
mockAssetMediaRepository = MockAssetMediaRepository();
mockAssetService = MockAssetService();
when(() => mockAssetService.stackEditedUpload(any(), any())).thenAnswer((_) async {});
sut = ForegroundUploadService(
mockUploadRepository,
@@ -59,6 +63,7 @@ void main() {
mockBackupRepository,
mockConnectivityApi,
mockAssetMediaRepository,
mockAssetService,
);
});
@@ -196,5 +201,43 @@ void main() {
expect(names, equals(['DJI_0001.jpg']));
});
test('stacks a plain photo after its upload', () async {
final asset = LocalAssetStub.image1;
final mockEntity = MockAssetEntity();
final stillFile = File('/path/to/photo.jpg');
when(() => mockEntity.isLivePhoto).thenReturn(false);
when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity);
when(() => mockStorageRepository.isAssetAvailableLocally(asset.id)).thenAnswer((_) async => true);
when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => stillFile);
when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => 'photo.jpg');
captureFields();
await sut.uploadSingleAsset(asset, null, callbacks: const UploadCallbacks());
verify(() => mockAssetService.stackEditedUpload(asset.localId!, 'remote-1')).called(1);
verifyNoMoreInteractions(mockAssetService);
});
test('stacks the still of a live photo, not its video', () async {
final asset = LocalAssetStub.image1;
final mockEntity = MockAssetEntity();
final stillFile = File('/path/to/still.heic');
final videoFile = File('/path/to/motion.mov');
when(() => mockEntity.isLivePhoto).thenReturn(true);
when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity);
when(() => mockStorageRepository.isAssetAvailableLocally(asset.id)).thenAnswer((_) async => true);
when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => stillFile);
when(() => mockStorageRepository.getMotionFileForAsset(asset)).thenAnswer((_) async => videoFile);
when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => 'live.heic');
captureFields();
await sut.uploadSingleAsset(asset, null, callbacks: const UploadCallbacks());
verify(() => mockAssetService.stackEditedUpload(asset.localId!, 'remote-2')).called(1);
verifyNoMoreInteractions(mockAssetService);
});
});
}