mirror of
https://github.com/immich-app/immich.git
synced 2026-05-18 03:10:24 +03:00
feat: add cloud id during native sync (#20418)
* use adjustment time in iOS for hash reset # Conflicts: # mobile/lib/infrastructure/repositories/local_album.repository.dart # mobile/lib/presentation/pages/drift_asset_troubleshoot.page.dart * migration * feat: sync cloudId and eTag on sync * fixes fixes * more fixes * re-sync updated eTags * add server version check & auto sync cloud ids on compatible servers * fix test * remove button from sync status page * chore: modify for testing * more changes * chore: add commas in toString * use cached provider in splash screen * read upload service provider to prevent reset * log errors from fetching cloud id mapping * WIP: migrate cloud id - debug log * ignore locked asset update * bulk update metadata * change log text --------- Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> Co-authored-by: Alex <alex.tran1502@gmail.com>
This commit is contained in:
@@ -33,6 +33,7 @@ void main() {
|
||||
registerFallbackValue(LocalAssetStub.image1);
|
||||
registerFallbackValue(<String, String>{});
|
||||
|
||||
when(() => mockAssetRepo.getHashMappingFromCloudId()).thenAnswer((_) async => {});
|
||||
when(() => mockAssetRepo.updateHashes(any())).thenAnswer((_) async => {});
|
||||
});
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import 'package:immich_mobile/domain/services/store.service.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
|
||||
@@ -25,6 +26,7 @@ import '../../repository.mocks.dart';
|
||||
void main() {
|
||||
late LocalSyncService sut;
|
||||
late DriftLocalAlbumRepository mockLocalAlbumRepository;
|
||||
late DriftLocalAssetRepository mockLocalAssetRepository;
|
||||
late DriftTrashedLocalAssetRepository mockTrashedLocalAssetRepository;
|
||||
late LocalFilesManagerRepository mockLocalFilesManager;
|
||||
late StorageRepository mockStorageRepository;
|
||||
@@ -47,6 +49,7 @@ void main() {
|
||||
|
||||
setUp(() async {
|
||||
mockLocalAlbumRepository = MockLocalAlbumRepository();
|
||||
mockLocalAssetRepository = MockLocalAssetRepository();
|
||||
mockTrashedLocalAssetRepository = MockTrashedLocalAssetRepository();
|
||||
mockLocalFilesManager = MockLocalFilesManagerRepository();
|
||||
mockStorageRepository = MockStorageRepository();
|
||||
@@ -66,6 +69,7 @@ void main() {
|
||||
|
||||
sut = LocalSyncService(
|
||||
localAlbumRepository: mockLocalAlbumRepository,
|
||||
localAssetRepository: mockLocalAssetRepository,
|
||||
trashedLocalAssetRepository: mockTrashedLocalAssetRepository,
|
||||
localFilesManager: mockLocalFilesManager,
|
||||
storageRepository: mockStorageRepository,
|
||||
|
||||
+4
@@ -18,6 +18,7 @@ import 'schema_v12.dart' as v12;
|
||||
import 'schema_v13.dart' as v13;
|
||||
import 'schema_v14.dart' as v14;
|
||||
import 'schema_v15.dart' as v15;
|
||||
import 'schema_v16.dart' as v16;
|
||||
|
||||
class GeneratedHelper implements SchemaInstantiationHelper {
|
||||
@override
|
||||
@@ -53,6 +54,8 @@ class GeneratedHelper implements SchemaInstantiationHelper {
|
||||
return v14.DatabaseAtV14(db);
|
||||
case 15:
|
||||
return v15.DatabaseAtV15(db);
|
||||
case 16:
|
||||
return v16.DatabaseAtV16(db);
|
||||
default:
|
||||
throw MissingSchemaException(version, versions);
|
||||
}
|
||||
@@ -74,5 +77,6 @@ class GeneratedHelper implements SchemaInstantiationHelper {
|
||||
13,
|
||||
14,
|
||||
15,
|
||||
16,
|
||||
];
|
||||
}
|
||||
|
||||
+8300
File diff suppressed because it is too large
Load Diff
@@ -1,14 +1,22 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:drift/drift.dart' hide isNull, isNotNull;
|
||||
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';
|
||||
import 'package:immich_mobile/domain/services/store.service.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
|
||||
import 'package:immich_mobile/models/server_info/server_config.model.dart';
|
||||
import 'package:immich_mobile/models/server_info/server_disk_info.model.dart';
|
||||
import 'package:immich_mobile/models/server_info/server_features.model.dart';
|
||||
import 'package:immich_mobile/models/server_info/server_info.model.dart';
|
||||
import 'package:immich_mobile/models/server_info/server_version.model.dart';
|
||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||
import 'package:immich_mobile/services/upload.service.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
@@ -16,8 +24,29 @@ import 'package:mocktail/mocktail.dart';
|
||||
import '../domain/service.mock.dart';
|
||||
import '../fixtures/asset.stub.dart';
|
||||
import '../infrastructure/repository.mock.dart';
|
||||
import '../repository.mocks.dart';
|
||||
import '../mocks/asset_entity.mock.dart';
|
||||
import '../repository.mocks.dart';
|
||||
|
||||
// Test ServerInfo stub
|
||||
const _serverInfo = ServerInfo(
|
||||
serverVersion: ServerVersion(major: 2, minor: 4, patch: 0),
|
||||
latestVersion: ServerVersion(major: 2, minor: 4, patch: 0),
|
||||
serverFeatures: ServerFeatures(trash: true, map: true, oauthEnabled: false, passwordLogin: true, ocr: false),
|
||||
serverConfig: ServerConfig(
|
||||
trashDays: 30,
|
||||
oauthButtonText: 'Login with OAuth',
|
||||
externalDomain: '',
|
||||
mapDarkStyleUrl: '',
|
||||
mapLightStyleUrl: '',
|
||||
),
|
||||
serverDiskInfo: ServerDiskInfo(
|
||||
diskAvailable: '100GB',
|
||||
diskSize: '500GB',
|
||||
diskUse: '400GB',
|
||||
diskUsagePercentage: 80.0,
|
||||
),
|
||||
versionStatus: VersionStatus.upToDate,
|
||||
);
|
||||
|
||||
void main() {
|
||||
late UploadService sut;
|
||||
@@ -62,6 +91,7 @@ void main() {
|
||||
mockLocalAssetRepository,
|
||||
mockAppSettingsService,
|
||||
mockAssetMediaRepository,
|
||||
_serverInfo,
|
||||
);
|
||||
|
||||
mockUploadRepository.onUploadStatus = (_) {};
|
||||
@@ -165,4 +195,227 @@ void main() {
|
||||
verify(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).called(1);
|
||||
});
|
||||
});
|
||||
|
||||
group('Server Info - cloudId and eTag metadata', () {
|
||||
test('should include cloudId and eTag metadata on iOS when server version is 2.4+', () async {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
||||
addTearDown(() => debugDefaultTargetPlatformOverride = null);
|
||||
|
||||
final sutWithV24 = UploadService(
|
||||
mockUploadRepository,
|
||||
mockBackupRepository,
|
||||
mockStorageRepository,
|
||||
mockLocalAssetRepository,
|
||||
mockAppSettingsService,
|
||||
mockAssetMediaRepository,
|
||||
_serverInfo,
|
||||
);
|
||||
addTearDown(() => sutWithV24.dispose());
|
||||
|
||||
final assetWithCloudId = LocalAsset(
|
||||
id: 'test-asset-id',
|
||||
name: 'test.jpg',
|
||||
type: AssetType.image,
|
||||
createdAt: DateTime(2025, 1, 1),
|
||||
updatedAt: DateTime(2025, 1, 2),
|
||||
cloudId: 'cloud-id-123',
|
||||
latitude: 37.7749,
|
||||
longitude: -122.4194,
|
||||
adjustmentTime: DateTime(2026, 1, 2),
|
||||
);
|
||||
|
||||
final mockEntity = MockAssetEntity();
|
||||
final mockFile = File('/path/to/test.jpg');
|
||||
|
||||
when(() => mockEntity.isLivePhoto).thenReturn(false);
|
||||
when(() => mockStorageRepository.getAssetEntityForAsset(assetWithCloudId)).thenAnswer((_) async => mockEntity);
|
||||
when(() => mockStorageRepository.getFileForAsset(assetWithCloudId.id)).thenAnswer((_) async => mockFile);
|
||||
when(() => mockAssetMediaRepository.getOriginalFilename(assetWithCloudId.id)).thenAnswer((_) async => 'test.jpg');
|
||||
|
||||
final task = await sutWithV24.getUploadTask(assetWithCloudId);
|
||||
|
||||
expect(task, isNotNull);
|
||||
expect(task!.fields.containsKey('metadata'), isTrue);
|
||||
|
||||
final metadata = jsonDecode(task.fields['metadata']!) as List;
|
||||
expect(metadata, hasLength(1));
|
||||
expect(metadata[0]['key'], equals('mobile-app'));
|
||||
expect(metadata[0]['value']['iCloudId'], equals('cloud-id-123'));
|
||||
expect(metadata[0]['value']['createdAt'], isNotNull);
|
||||
expect(metadata[0]['value']['adjustmentTime'], isNotNull);
|
||||
expect(metadata[0]['value']['latitude'], isNotNull);
|
||||
expect(metadata[0]['value']['longitude'], isNotNull);
|
||||
});
|
||||
|
||||
test('should NOT include metadata on iOS when server version is below 2.4', () async {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
||||
addTearDown(() => debugDefaultTargetPlatformOverride = null);
|
||||
|
||||
final sutWithV23 = UploadService(
|
||||
mockUploadRepository,
|
||||
mockBackupRepository,
|
||||
mockStorageRepository,
|
||||
mockLocalAssetRepository,
|
||||
mockAppSettingsService,
|
||||
mockAssetMediaRepository,
|
||||
_serverInfo.copyWith(
|
||||
serverVersion: const ServerVersion(major: 2, minor: 3, patch: 0),
|
||||
latestVersion: const ServerVersion(major: 2, minor: 3, patch: 0),
|
||||
),
|
||||
);
|
||||
addTearDown(() => sutWithV23.dispose());
|
||||
|
||||
final assetWithCloudId = LocalAsset(
|
||||
id: 'test-asset-id',
|
||||
name: 'test.jpg',
|
||||
type: AssetType.image,
|
||||
createdAt: DateTime(2025, 1, 1),
|
||||
updatedAt: DateTime(2025, 1, 2),
|
||||
cloudId: 'cloud-id-123',
|
||||
latitude: 37.7749,
|
||||
longitude: -122.4194,
|
||||
);
|
||||
|
||||
final mockEntity = MockAssetEntity();
|
||||
final mockFile = File('/path/to/test.jpg');
|
||||
|
||||
when(() => mockEntity.isLivePhoto).thenReturn(false);
|
||||
when(() => mockStorageRepository.getAssetEntityForAsset(assetWithCloudId)).thenAnswer((_) async => mockEntity);
|
||||
when(() => mockStorageRepository.getFileForAsset(assetWithCloudId.id)).thenAnswer((_) async => mockFile);
|
||||
when(() => mockAssetMediaRepository.getOriginalFilename(assetWithCloudId.id)).thenAnswer((_) async => 'test.jpg');
|
||||
|
||||
final task = await sutWithV23.getUploadTask(assetWithCloudId);
|
||||
|
||||
expect(task, isNotNull);
|
||||
expect(task!.fields.containsKey('metadata'), isFalse);
|
||||
});
|
||||
|
||||
test('should NOT include metadata on Android regardless of server version', () async {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.android;
|
||||
addTearDown(() => debugDefaultTargetPlatformOverride = null);
|
||||
|
||||
final sutAndroid = UploadService(
|
||||
mockUploadRepository,
|
||||
mockBackupRepository,
|
||||
mockStorageRepository,
|
||||
mockLocalAssetRepository,
|
||||
mockAppSettingsService,
|
||||
mockAssetMediaRepository,
|
||||
_serverInfo,
|
||||
);
|
||||
addTearDown(() => sutAndroid.dispose());
|
||||
|
||||
final assetWithCloudId = LocalAsset(
|
||||
id: 'test-asset-id',
|
||||
name: 'test.jpg',
|
||||
type: AssetType.image,
|
||||
createdAt: DateTime(2025, 1, 1),
|
||||
updatedAt: DateTime(2025, 1, 2),
|
||||
cloudId: 'cloud-id-123',
|
||||
latitude: 37.7749,
|
||||
longitude: -122.4194,
|
||||
);
|
||||
|
||||
final mockEntity = MockAssetEntity();
|
||||
final mockFile = File('/path/to/test.jpg');
|
||||
|
||||
when(() => mockEntity.isLivePhoto).thenReturn(false);
|
||||
when(() => mockStorageRepository.getAssetEntityForAsset(assetWithCloudId)).thenAnswer((_) async => mockEntity);
|
||||
when(() => mockStorageRepository.getFileForAsset(assetWithCloudId.id)).thenAnswer((_) async => mockFile);
|
||||
when(() => mockAssetMediaRepository.getOriginalFilename(assetWithCloudId.id)).thenAnswer((_) async => 'test.jpg');
|
||||
|
||||
final task = await sutAndroid.getUploadTask(assetWithCloudId);
|
||||
|
||||
expect(task, isNotNull);
|
||||
expect(task!.fields.containsKey('metadata'), isFalse);
|
||||
});
|
||||
|
||||
test('should NOT include metadata when cloudId is null even on iOS with server 2.4+', () async {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
||||
addTearDown(() => debugDefaultTargetPlatformOverride = null);
|
||||
|
||||
final sutWithV24 = UploadService(
|
||||
mockUploadRepository,
|
||||
mockBackupRepository,
|
||||
mockStorageRepository,
|
||||
mockLocalAssetRepository,
|
||||
mockAppSettingsService,
|
||||
mockAssetMediaRepository,
|
||||
_serverInfo,
|
||||
);
|
||||
addTearDown(() => sutWithV24.dispose());
|
||||
|
||||
final assetWithoutCloudId = LocalAsset(
|
||||
id: 'test-asset-id',
|
||||
name: 'test.jpg',
|
||||
type: AssetType.image,
|
||||
createdAt: DateTime(2025, 1, 1),
|
||||
updatedAt: DateTime(2025, 1, 2),
|
||||
cloudId: null, // No cloudId
|
||||
);
|
||||
|
||||
final mockEntity = MockAssetEntity();
|
||||
final mockFile = File('/path/to/test.jpg');
|
||||
|
||||
when(() => mockEntity.isLivePhoto).thenReturn(false);
|
||||
when(() => mockStorageRepository.getAssetEntityForAsset(assetWithoutCloudId)).thenAnswer((_) async => mockEntity);
|
||||
when(() => mockStorageRepository.getFileForAsset(assetWithoutCloudId.id)).thenAnswer((_) async => mockFile);
|
||||
when(
|
||||
() => mockAssetMediaRepository.getOriginalFilename(assetWithoutCloudId.id),
|
||||
).thenAnswer((_) async => 'test.jpg');
|
||||
|
||||
final task = await sutWithV24.getUploadTask(assetWithoutCloudId);
|
||||
|
||||
expect(task, isNotNull);
|
||||
expect(task!.fields.containsKey('metadata'), isFalse);
|
||||
});
|
||||
|
||||
test('should include metadata for live photos with cloudId on iOS 2.4+', () async {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
||||
addTearDown(() => debugDefaultTargetPlatformOverride = null);
|
||||
|
||||
final sutWithV24 = UploadService(
|
||||
mockUploadRepository,
|
||||
mockBackupRepository,
|
||||
mockStorageRepository,
|
||||
mockLocalAssetRepository,
|
||||
mockAppSettingsService,
|
||||
mockAssetMediaRepository,
|
||||
_serverInfo,
|
||||
);
|
||||
addTearDown(() => sutWithV24.dispose());
|
||||
|
||||
final assetWithCloudId = LocalAsset(
|
||||
id: 'test-livephoto-id',
|
||||
name: 'livephoto.heic',
|
||||
type: AssetType.image,
|
||||
createdAt: DateTime(2025, 1, 1),
|
||||
updatedAt: DateTime(2025, 1, 2),
|
||||
cloudId: 'cloud-id-livephoto',
|
||||
latitude: 37.7749,
|
||||
longitude: -122.4194,
|
||||
);
|
||||
|
||||
final mockEntity = MockAssetEntity();
|
||||
final mockFile = File('/path/to/livephoto.heic');
|
||||
|
||||
when(() => mockEntity.isLivePhoto).thenReturn(true);
|
||||
when(() => mockStorageRepository.getAssetEntityForAsset(assetWithCloudId)).thenAnswer((_) async => mockEntity);
|
||||
when(() => mockStorageRepository.getFileForAsset(assetWithCloudId.id)).thenAnswer((_) async => mockFile);
|
||||
when(
|
||||
() => mockAssetMediaRepository.getOriginalFilename(assetWithCloudId.id),
|
||||
).thenAnswer((_) async => 'livephoto.heic');
|
||||
|
||||
final task = await sutWithV24.getLivePhotoUploadTask(assetWithCloudId, 'video-123');
|
||||
|
||||
expect(task, isNotNull);
|
||||
expect(task!.fields.containsKey('metadata'), isTrue);
|
||||
expect(task.fields['livePhotoVideoId'], equals('video-123'));
|
||||
|
||||
final metadata = jsonDecode(task.fields['metadata']!) as List;
|
||||
expect(metadata, hasLength(1));
|
||||
expect(metadata[0]['key'], equals('mobile-app'));
|
||||
expect(metadata[0]['value']['iCloudId'], equals('cloud-id-livephoto'));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user