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:
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user