Skip to content

Commit 1f25422

Browse files
committed
Merge branch 'main' of github.com:immich-app/immich into workflow-ui
2 parents 2fe36e7 + c1198b9 commit 1f25422

File tree

20 files changed

+641
-296
lines changed

20 files changed

+641
-296
lines changed

docs/docs/developer/pr-checklist.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,15 +14,15 @@ When contributing code through a pull request, please check the following:
1414
- [ ] `pnpm run check:typescript` (check typescript)
1515
- [ ] `pnpm test` (unit tests)
1616

17+
:::tip AIO
18+
Run all web checks with `pnpm run check:all`
19+
:::
20+
1721
## Documentation
1822

1923
- [ ] `pnpm run format` (formatting via Prettier)
2024
- [ ] Update the `_redirects` file if you have renamed a page or removed it from the documentation.
2125

22-
:::tip AIO
23-
Run all web checks with `pnpm run check:all`
24-
:::
25-
2626
## Server Checks
2727

2828
- [ ] `pnpm run lint` (linting via ESLint)

docs/docs/install/environment-variables.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ Information on the current workers can be found [here](/administration/jobs-work
9393
All `DB_` variables must be provided to all Immich workers, including `api` and `microservices`.
9494

9595
`DB_URL` must be in the format `postgresql://immichdbusername:immichdbpassword@postgreshost:postgresport/immichdatabasename`.
96-
You can require SSL by adding `?sslmode=require` to the end of the `DB_URL` string, or require SSL and skip certificate verification by adding `?sslmode=require&sslmode=no-verify`.
96+
You can require SSL by adding `?sslmode=require` to the end of the `DB_URL` string, or require SSL and skip certificate verification by adding `?sslmode=require&uselibpqcompat=true`. This allows both immich and `pg_dumpall` (the utility used for database backups) to [properly connect](https://github.com/brianc/node-postgres/tree/master/packages/pg-connection-string#tcp-connections) to your database.
9797

9898
When `DB_URL` is defined, the `DB_HOSTNAME`, `DB_PORT`, `DB_USERNAME`, `DB_PASSWORD` and `DB_DATABASE_NAME` database variables are ignored.
9999

mobile/lib/domain/services/asset.service.dart

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,20 @@ class AssetService {
7575
isFlipped = false;
7676
}
7777

78+
if (width == null || height == null) {
79+
if (asset.hasRemote) {
80+
final id = asset is LocalAsset ? asset.remoteId! : (asset as RemoteAsset).id;
81+
final remoteAsset = await _remoteAssetRepository.get(id);
82+
width = remoteAsset?.width?.toDouble();
83+
height = remoteAsset?.height?.toDouble();
84+
} else {
85+
final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).localId!;
86+
final localAsset = await _localAssetRepository.get(id);
87+
width = localAsset?.width?.toDouble();
88+
height = localAsset?.height?.toDouble();
89+
}
90+
}
91+
7892
final orientedWidth = isFlipped ? height : width;
7993
final orientedHeight = isFlipped ? width : height;
8094
if (orientedWidth != null && orientedHeight != null && orientedHeight > 0) {

mobile/lib/domain/services/local_sync.service.dart

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -363,14 +363,14 @@ extension on Iterable<PlatformAsset> {
363363
}
364364
}
365365

366-
extension on PlatformAsset {
366+
extension PlatformToLocalAsset on PlatformAsset {
367367
LocalAsset toLocalAsset() => LocalAsset(
368368
id: id,
369369
name: name,
370370
checksum: null,
371371
type: AssetType.values.elementAtOrNull(type) ?? AssetType.other,
372372
createdAt: tryFromSecondsSinceEpoch(createdAt, isUtc: true) ?? DateTime.timestamp(),
373-
updatedAt: tryFromSecondsSinceEpoch(createdAt, isUtc: true) ?? DateTime.timestamp(),
373+
updatedAt: tryFromSecondsSinceEpoch(updatedAt, isUtc: true) ?? DateTime.timestamp(),
374374
width: width,
375375
height: height,
376376
durationInSeconds: durationInSeconds,

mobile/lib/utils/migration.dart

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,14 +22,16 @@ import 'package:immich_mobile/infrastructure/entities/store.entity.drift.dart';
2222
import 'package:immich_mobile/infrastructure/entities/user.entity.dart';
2323
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
2424
import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.dart';
25+
import 'package:immich_mobile/platform/native_sync_api.g.dart';
2526
import 'package:immich_mobile/services/app_settings.service.dart';
27+
import 'package:immich_mobile/utils/datetime_helpers.dart';
2628
import 'package:immich_mobile/utils/debug_print.dart';
2729
import 'package:immich_mobile/utils/diff.dart';
2830
import 'package:isar/isar.dart';
2931
// ignore: import_rule_photo_manager
3032
import 'package:photo_manager/photo_manager.dart';
3133

32-
const int targetVersion = 18;
34+
const int targetVersion = 19;
3335

3436
Future<void> migrateDatabaseIfNeeded(Isar db, Drift drift) async {
3537
final hasVersion = Store.tryGet(StoreKey.version) != null;
@@ -78,6 +80,12 @@ Future<void> migrateDatabaseIfNeeded(Isar db, Drift drift) async {
7880
await Store.put(StoreKey.shouldResetSync, true);
7981
}
8082

83+
if (version < 19 && Store.isBetaTimelineEnabled) {
84+
if (!await _populateUpdatedAtTime(drift)) {
85+
return;
86+
}
87+
}
88+
8189
if (targetVersion >= 12) {
8290
await Store.put(StoreKey.version, targetVersion);
8391
return;
@@ -221,6 +229,32 @@ Future<void> _migrateDeviceAsset(Isar db) async {
221229
});
222230
}
223231

232+
Future<bool> _populateUpdatedAtTime(Drift db) async {
233+
try {
234+
final nativeApi = NativeSyncApi();
235+
final albums = await nativeApi.getAlbums();
236+
for (final album in albums) {
237+
final assets = await nativeApi.getAssetsForAlbum(album.id);
238+
await db.batch((batch) async {
239+
for (final asset in assets) {
240+
batch.update(
241+
db.localAssetEntity,
242+
LocalAssetEntityCompanion(
243+
updatedAt: Value(tryFromSecondsSinceEpoch(asset.updatedAt, isUtc: true) ?? DateTime.timestamp()),
244+
),
245+
where: (t) => t.id.equals(asset.id),
246+
);
247+
}
248+
});
249+
}
250+
251+
return true;
252+
} catch (error) {
253+
dPrint(() => "[MIGRATION] Error while populating updatedAt time: $error");
254+
return false;
255+
}
256+
}
257+
224258
Future<void> migrateDeviceAssetToSqlite(Isar db, Drift drift) async {
225259
try {
226260
final isarDeviceAssets = await db.deviceAssetEntitys.where().findAll();
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
import 'package:flutter/foundation.dart';
2+
import 'package:flutter_test/flutter_test.dart';
3+
import 'package:immich_mobile/domain/models/exif.model.dart';
4+
import 'package:immich_mobile/domain/services/asset.service.dart';
5+
import 'package:mocktail/mocktail.dart';
6+
7+
import '../../infrastructure/repository.mock.dart';
8+
import '../../test_utils.dart';
9+
10+
void main() {
11+
late AssetService sut;
12+
late MockRemoteAssetRepository mockRemoteAssetRepository;
13+
late MockDriftLocalAssetRepository mockLocalAssetRepository;
14+
15+
setUp(() {
16+
mockRemoteAssetRepository = MockRemoteAssetRepository();
17+
mockLocalAssetRepository = MockDriftLocalAssetRepository();
18+
sut = AssetService(
19+
remoteAssetRepository: mockRemoteAssetRepository,
20+
localAssetRepository: mockLocalAssetRepository,
21+
);
22+
});
23+
24+
group('getAspectRatio', () {
25+
test('flips dimensions on Android for 90° and 270° orientations', () async {
26+
debugDefaultTargetPlatformOverride = TargetPlatform.android;
27+
addTearDown(() => debugDefaultTargetPlatformOverride = null);
28+
29+
for (final orientation in [90, 270]) {
30+
final localAsset = TestUtils.createLocalAsset(
31+
id: 'local-$orientation',
32+
width: 1920,
33+
height: 1080,
34+
orientation: orientation,
35+
);
36+
37+
final result = await sut.getAspectRatio(localAsset);
38+
39+
expect(result, 1080 / 1920, reason: 'Orientation $orientation should flip on Android');
40+
}
41+
});
42+
43+
test('does not flip dimensions on iOS regardless of orientation', () async {
44+
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
45+
addTearDown(() => debugDefaultTargetPlatformOverride = null);
46+
47+
for (final orientation in [0, 90, 270]) {
48+
final localAsset = TestUtils.createLocalAsset(
49+
id: 'local-$orientation',
50+
width: 1920,
51+
height: 1080,
52+
orientation: orientation,
53+
);
54+
55+
final result = await sut.getAspectRatio(localAsset);
56+
57+
expect(result, 1920 / 1080, reason: 'iOS should never flip dimensions');
58+
}
59+
});
60+
61+
test('fetches dimensions from remote repository when missing from asset', () async {
62+
final remoteAsset = TestUtils.createRemoteAsset(id: 'remote-1', width: null, height: null);
63+
64+
final exif = const ExifInfo(orientation: '1');
65+
66+
final fetchedAsset = TestUtils.createRemoteAsset(id: 'remote-1', width: 1920, height: 1080);
67+
68+
when(() => mockRemoteAssetRepository.getExif('remote-1')).thenAnswer((_) async => exif);
69+
when(() => mockRemoteAssetRepository.get('remote-1')).thenAnswer((_) async => fetchedAsset);
70+
71+
final result = await sut.getAspectRatio(remoteAsset);
72+
73+
expect(result, 1920 / 1080);
74+
verify(() => mockRemoteAssetRepository.get('remote-1')).called(1);
75+
});
76+
77+
test('fetches dimensions from local repository when missing from local asset', () async {
78+
final localAsset = TestUtils.createLocalAsset(id: 'local-1', width: null, height: null, orientation: 0);
79+
80+
final fetchedAsset = TestUtils.createLocalAsset(id: 'local-1', width: 1920, height: 1080, orientation: 0);
81+
82+
when(() => mockLocalAssetRepository.get('local-1')).thenAnswer((_) async => fetchedAsset);
83+
84+
final result = await sut.getAspectRatio(localAsset);
85+
86+
expect(result, 1920 / 1080);
87+
verify(() => mockLocalAssetRepository.get('local-1')).called(1);
88+
});
89+
90+
test('returns 1.0 when dimensions are still unavailable after fetching', () async {
91+
final remoteAsset = TestUtils.createRemoteAsset(id: 'remote-1', width: null, height: null);
92+
93+
final exif = const ExifInfo(orientation: '1');
94+
95+
when(() => mockRemoteAssetRepository.getExif('remote-1')).thenAnswer((_) async => exif);
96+
when(() => mockRemoteAssetRepository.get('remote-1')).thenAnswer((_) async => null);
97+
98+
final result = await sut.getAspectRatio(remoteAsset);
99+
100+
expect(result, 1.0);
101+
});
102+
103+
test('returns 1.0 when height is zero', () async {
104+
final remoteAsset = TestUtils.createRemoteAsset(id: 'remote-1', width: 1920, height: 0);
105+
106+
final exif = const ExifInfo(orientation: '1');
107+
108+
when(() => mockRemoteAssetRepository.getExif('remote-1')).thenAnswer((_) async => exif);
109+
110+
final result = await sut.getAspectRatio(remoteAsset);
111+
112+
expect(result, 1.0);
113+
});
114+
115+
test('handles local asset with remoteId and uses exif from remote', () async {
116+
final localAsset = TestUtils.createLocalAsset(
117+
id: 'local-1',
118+
remoteId: 'remote-1',
119+
width: 1920,
120+
height: 1080,
121+
orientation: 0,
122+
);
123+
124+
final exif = const ExifInfo(orientation: '6');
125+
126+
when(() => mockRemoteAssetRepository.getExif('remote-1')).thenAnswer((_) async => exif);
127+
128+
final result = await sut.getAspectRatio(localAsset);
129+
130+
expect(result, 1080 / 1920);
131+
});
132+
133+
test('handles various flipped EXIF orientations correctly', () async {
134+
final flippedOrientations = ['5', '6', '7', '8', '90', '-90'];
135+
136+
for (final orientation in flippedOrientations) {
137+
final remoteAsset = TestUtils.createRemoteAsset(id: 'remote-$orientation', width: 1920, height: 1080);
138+
139+
final exif = ExifInfo(orientation: orientation);
140+
141+
when(() => mockRemoteAssetRepository.getExif('remote-$orientation')).thenAnswer((_) async => exif);
142+
143+
final result = await sut.getAspectRatio(remoteAsset);
144+
145+
expect(result, 1080 / 1920, reason: 'Orientation $orientation should flip dimensions');
146+
}
147+
});
148+
149+
test('handles various non-flipped EXIF orientations correctly', () async {
150+
final nonFlippedOrientations = ['1', '2', '3', '4'];
151+
152+
for (final orientation in nonFlippedOrientations) {
153+
final remoteAsset = TestUtils.createRemoteAsset(id: 'remote-$orientation', width: 1920, height: 1080);
154+
155+
final exif = ExifInfo(orientation: orientation);
156+
157+
when(() => mockRemoteAssetRepository.getExif('remote-$orientation')).thenAnswer((_) async => exif);
158+
159+
final result = await sut.getAspectRatio(remoteAsset);
160+
161+
expect(result, 1920 / 1080, reason: 'Orientation $orientation should NOT flip dimensions');
162+
}
163+
});
164+
});
165+
}

mobile/test/domain/services/local_sync_service_test.dart

Lines changed: 31 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -54,12 +54,7 @@ void main() {
5454

5555
when(() => mockNativeSyncApi.shouldFullSync()).thenAnswer((_) async => false);
5656
when(() => mockNativeSyncApi.getMediaChanges()).thenAnswer(
57-
(_) async => SyncDelta(
58-
hasChanges: false,
59-
updates: const [],
60-
deletes: const [],
61-
assetAlbums: const {},
62-
),
57+
(_) async => SyncDelta(hasChanges: false, updates: const [], deletes: const [], assetAlbums: const {}),
6358
);
6459
when(() => mockNativeSyncApi.getTrashedAssets()).thenAnswer((_) async => {});
6560
when(() => mockTrashedLocalAssetRepository.processTrashSnapshot(any())).thenAnswer((_) async {});
@@ -144,13 +139,19 @@ void main() {
144139
});
145140

146141
final localAssetToTrash = LocalAssetStub.image2.copyWith(id: 'local-trash', checksum: 'checksum-trash');
147-
when(() => mockTrashedLocalAssetRepository.getToTrash()).thenAnswer((_) async => {'album-a': [localAssetToTrash]});
142+
when(() => mockTrashedLocalAssetRepository.getToTrash()).thenAnswer(
143+
(_) async => {
144+
'album-a': [localAssetToTrash],
145+
},
146+
);
148147

149148
final assetEntity = MockAssetEntity();
150149
when(() => assetEntity.getMediaUrl()).thenAnswer((_) async => 'content://local-trash');
151150
when(() => mockStorageRepository.getAssetEntityForAsset(localAssetToTrash)).thenAnswer((_) async => assetEntity);
152151

153-
await sut.processTrashedAssets({'album-a': [platformAsset]});
152+
await sut.processTrashedAssets({
153+
'album-a': [platformAsset],
154+
});
154155

155156
verify(() => mockTrashedLocalAssetRepository.processTrashSnapshot(any())).called(1);
156157
verify(() => mockTrashedLocalAssetRepository.getToTrash()).called(1);
@@ -159,8 +160,7 @@ void main() {
159160
verify(() => mockTrashedLocalAssetRepository.applyRestoredAssets(restoredIds)).called(1);
160161

161162
verify(() => mockStorageRepository.getAssetEntityForAsset(localAssetToTrash)).called(1);
162-
final moveArgs =
163-
verify(() => mockLocalFilesManager.moveToTrash(captureAny())).captured.single as List<String>;
163+
final moveArgs = verify(() => mockLocalFilesManager.moveToTrash(captureAny())).captured.single as List<String>;
164164
expect(moveArgs, ['content://local-trash']);
165165
final trashArgs =
166166
verify(() => mockTrashedLocalAssetRepository.trashLocalAsset(captureAny())).captured.single
@@ -187,4 +187,25 @@ void main() {
187187
verifyNever(() => mockTrashedLocalAssetRepository.trashLocalAsset(any()));
188188
});
189189
});
190+
191+
group('LocalSyncService - PlatformAsset conversion', () {
192+
test('toLocalAsset uses correct updatedAt timestamp', () {
193+
final platformAsset = PlatformAsset(
194+
id: 'test-id',
195+
name: 'test.jpg',
196+
type: AssetType.image.index,
197+
durationInSeconds: 0,
198+
orientation: 0,
199+
isFavorite: false,
200+
createdAt: 1700000000,
201+
updatedAt: 1732000000,
202+
);
203+
204+
final localAsset = platformAsset.toLocalAsset();
205+
206+
expect(localAsset.createdAt.millisecondsSinceEpoch ~/ 1000, 1700000000);
207+
expect(localAsset.updatedAt.millisecondsSinceEpoch ~/ 1000, 1732000000);
208+
expect(localAsset.updatedAt, isNot(localAsset.createdAt));
209+
});
210+
});
190211
}

mobile/test/infrastructure/repository.mock.dart

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import 'package:immich_mobile/infrastructure/repositories/local_album.repository
44
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
55
import 'package:immich_mobile/infrastructure/repositories/log.repository.dart';
66
import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart';
7+
import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart';
78
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
89
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
910
import 'package:immich_mobile/infrastructure/repositories/sync_api.repository.dart';
@@ -35,6 +36,8 @@ class MockLocalAssetRepository extends Mock implements DriftLocalAssetRepository
3536

3637
class MockDriftLocalAssetRepository extends Mock implements DriftLocalAssetRepository {}
3738

39+
class MockRemoteAssetRepository extends Mock implements RemoteAssetRepository {}
40+
3841
class MockTrashedLocalAssetRepository extends Mock implements DriftTrashedLocalAssetRepository {}
3942

4043
class MockStorageRepository extends Mock implements StorageRepository {}

0 commit comments

Comments
 (0)