Merge remote-tracking branch 'origin/main' into feature/new-activity-timeline

This commit is contained in:
idubnori
2025-10-29 19:12:12 +09:00
373 changed files with 7251 additions and 4270 deletions
@@ -53,7 +53,7 @@ void main() {
});
tearDown(() async {
sut.dispose();
unawaited(sut.dispose());
await controller.close();
});
@@ -129,7 +129,7 @@ void main() {
final stream = sut.watch(StoreKey.accessToken);
final events = <String?>[_kAccessToken, _kAccessToken.toUpperCase(), null, _kAccessToken.toLowerCase()];
expectLater(stream, emitsInOrder(events));
unawaited(expectLater(stream, emitsInOrder(events)));
for (final event in events) {
valueController.add(event);
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
@@ -99,7 +101,7 @@ void main() {
final count = await db.storeValues.count();
expect(count, isNot(isZero));
await sut.deleteAll();
expectLater(await db.storeValues.count(), isZero);
unawaited(expectLater(await db.storeValues.count(), isZero));
});
});
@@ -124,29 +126,31 @@ void main() {
test('watch()', () async {
final stream = sut.watch(StoreKey.version);
expectLater(stream, emitsInOrder([_kTestVersion, _kTestVersion + 10]));
unawaited(expectLater(stream, emitsInOrder([_kTestVersion, _kTestVersion + 10])));
await pumpEventQueue();
await sut.upsert(StoreKey.version, _kTestVersion + 10);
});
test('watchAll()', () async {
final stream = sut.watchAll();
expectLater(
stream,
emitsInOrder([
[
const StoreDto<Object>(StoreKey.version, _kTestVersion),
StoreDto<Object>(StoreKey.backupFailedSince, _kTestBackupFailed),
const StoreDto<Object>(StoreKey.accessToken, _kTestAccessToken),
const StoreDto<Object>(StoreKey.colorfulInterface, _kTestColorfulInterface),
],
[
const StoreDto<Object>(StoreKey.version, _kTestVersion + 10),
StoreDto<Object>(StoreKey.backupFailedSince, _kTestBackupFailed),
const StoreDto<Object>(StoreKey.accessToken, _kTestAccessToken),
const StoreDto<Object>(StoreKey.colorfulInterface, _kTestColorfulInterface),
],
]),
unawaited(
expectLater(
stream,
emitsInOrder([
[
const StoreDto<Object>(StoreKey.version, _kTestVersion),
StoreDto<Object>(StoreKey.backupFailedSince, _kTestBackupFailed),
const StoreDto<Object>(StoreKey.accessToken, _kTestAccessToken),
const StoreDto<Object>(StoreKey.colorfulInterface, _kTestColorfulInterface),
],
[
const StoreDto<Object>(StoreKey.version, _kTestVersion + 10),
StoreDto<Object>(StoreKey.backupFailedSince, _kTestBackupFailed),
const StoreDto<Object>(StoreKey.accessToken, _kTestAccessToken),
const StoreDto<Object>(StoreKey.colorfulInterface, _kTestColorfulInterface),
],
]),
),
);
await sut.upsert(StoreKey.version, _kTestVersion + 10);
});
@@ -80,6 +80,7 @@ void main() {
int onDataCallCount = 0;
bool abortWasCalledInCallback = false;
List<SyncEvent> receivedEventsBatch1 = [];
final Completer<void> firstBatchReceived = Completer<void>();
Future<void> onDataCallback(List<SyncEvent> events, Function() abort, Function() _) async {
onDataCallCount++;
@@ -87,6 +88,7 @@ void main() {
receivedEventsBatch1 = events;
abort();
abortWasCalledInCallback = true;
firstBatchReceived.complete();
} else {
fail("onData called more than once after abort was invoked");
}
@@ -94,7 +96,8 @@ void main() {
final streamChangesFuture = streamChanges(onDataCallback);
await pumpEventQueue();
// Give the stream subscription time to start (longer delay to account for mock delay)
await Future.delayed(const Duration(milliseconds: 50));
for (int i = 0; i < testBatchSize; i++) {
responseStreamController.add(
@@ -104,6 +107,11 @@ void main() {
);
}
await firstBatchReceived.future.timeout(
const Duration(seconds: 5),
onTimeout: () => fail('First batch was not processed within timeout'),
);
for (int i = testBatchSize; i < testBatchSize * 2; i++) {
responseStreamController.add(
utf8.encode(
@@ -124,12 +132,14 @@ void main() {
test('streamChanges does not process remaining lines in finally block if aborted', () async {
int onDataCallCount = 0;
bool abortWasCalledInCallback = false;
final Completer<void> firstBatchReceived = Completer<void>();
Future<void> onDataCallback(List<SyncEvent> events, Function() abort, Function() _) async {
onDataCallCount++;
if (onDataCallCount == 1) {
abort();
abortWasCalledInCallback = true;
firstBatchReceived.complete();
} else {
fail("onData called more than once after abort was invoked");
}
@@ -137,7 +147,7 @@ void main() {
final streamChangesFuture = streamChanges(onDataCallback);
await pumpEventQueue();
await Future.delayed(const Duration(milliseconds: 50));
for (int i = 0; i < testBatchSize; i++) {
responseStreamController.add(
@@ -147,6 +157,11 @@ void main() {
);
}
await firstBatchReceived.future.timeout(
const Duration(seconds: 5),
onTimeout: () => fail('First batch was not processed within timeout'),
);
// emit a single event to skip batching and trigger finally
responseStreamController.add(
utf8.encode(
@@ -166,13 +181,17 @@ void main() {
int onDataCallCount = 0;
List<SyncEvent> receivedEventsBatch1 = [];
List<SyncEvent> receivedEventsBatch2 = [];
final Completer<void> firstBatchReceived = Completer<void>();
final Completer<void> secondBatchReceived = Completer<void>();
Future<void> onDataCallback(List<SyncEvent> events, Function() _, Function() __) async {
onDataCallCount++;
if (onDataCallCount == 1) {
receivedEventsBatch1 = events;
firstBatchReceived.complete();
} else if (onDataCallCount == 2) {
receivedEventsBatch2 = events;
secondBatchReceived.complete();
} else {
fail("onData called more than expected");
}
@@ -180,7 +199,7 @@ void main() {
final streamChangesFuture = streamChanges(onDataCallback);
await pumpEventQueue();
await Future.delayed(const Duration(milliseconds: 50));
// Batch 1
for (int i = 0; i < testBatchSize; i++) {
@@ -191,7 +210,11 @@ void main() {
);
}
// Partial Batch 2
await firstBatchReceived.future.timeout(
const Duration(seconds: 5),
onTimeout: () => fail('First batch was not processed within timeout'),
);
responseStreamController.add(
utf8.encode(
_createJsonLine(SyncEntityType.userDeleteV1.toString(), SyncUserDeleteV1(userId: "user100").toJson(), 'ack100'),
@@ -199,6 +222,12 @@ void main() {
);
await responseStreamController.close();
await secondBatchReceived.future.timeout(
const Duration(seconds: 5),
onTimeout: () => fail('Second batch was not processed within timeout'),
);
await expectLater(streamChangesFuture, completes);
expect(onDataCallCount, 2);
@@ -217,7 +246,7 @@ void main() {
final streamChangesFuture = streamChanges(onDataCallback);
await pumpEventQueue();
await Future.delayed(const Duration(milliseconds: 50));
responseStreamController.add(
utf8.encode(
@@ -1,3 +1,4 @@
import 'package:immich_mobile/infrastructure/repositories/backup.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/device_asset.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
@@ -10,6 +11,7 @@ import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository
import 'package:immich_mobile/infrastructure/repositories/user.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/user_api.repository.dart';
import 'package:immich_mobile/repositories/drift_album_api_repository.dart';
import 'package:immich_mobile/repositories/upload.repository.dart';
import 'package:mocktail/mocktail.dart';
class MockStoreRepository extends Mock implements IsarStoreRepository {}
@@ -30,8 +32,14 @@ class MockRemoteAlbumRepository extends Mock implements DriftRemoteAlbumReposito
class MockLocalAssetRepository extends Mock implements DriftLocalAssetRepository {}
class MockDriftLocalAssetRepository extends Mock implements DriftLocalAssetRepository {}
class MockStorageRepository extends Mock implements StorageRepository {}
class MockDriftBackupRepository extends Mock implements DriftBackupRepository {}
class MockUploadRepository extends Mock implements UploadRepository {}
// API Repos
class MockUserApiRepository extends Mock implements UserApiRepository {}
@@ -64,9 +64,9 @@ void main() {
TestUtils.init();
db = await TestUtils.initIsar();
await StoreService.init(storeRepository: IsarStoreRepository(db));
Store.put(StoreKey.currentUser, UserStub.admin);
Store.put(StoreKey.serverEndpoint, '');
Store.put(StoreKey.accessToken, '');
await Store.put(StoreKey.currentUser, UserStub.admin);
await Store.put(StoreKey.serverEndpoint, '');
await Store.put(StoreKey.accessToken, '');
});
setUp(() async {
@@ -35,8 +35,8 @@ void main() {
TestUtils.init();
db = await TestUtils.initIsar();
await StoreService.init(storeRepository: IsarStoreRepository(db));
Store.put(StoreKey.currentUser, UserStub.admin);
Store.put(StoreKey.serverEndpoint, '');
await Store.put(StoreKey.currentUser, UserStub.admin);
await Store.put(StoreKey.serverEndpoint, '');
});
setUp(() {
@@ -31,9 +31,9 @@ void main() {
db = await TestUtils.initIsar();
// For UserCircleAvatar
await StoreService.init(storeRepository: IsarStoreRepository(db));
Store.put(StoreKey.currentUser, UserStub.admin);
Store.put(StoreKey.serverEndpoint, '');
Store.put(StoreKey.accessToken, '');
await Store.put(StoreKey.currentUser, UserStub.admin);
await Store.put(StoreKey.serverEndpoint, '');
await Store.put(StoreKey.accessToken, '');
});
setUp(() {
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/utils/async_mutex.dart';
@@ -7,11 +9,11 @@ void main() {
AsyncMutex lock = AsyncMutex();
List<int> events = [];
expect(0, lock.enqueued);
lock.run(() => Future.delayed(const Duration(milliseconds: 10), () => events.add(1)));
unawaited(lock.run(() => Future.delayed(const Duration(milliseconds: 10), () => events.add(1))));
expect(1, lock.enqueued);
lock.run(() => Future.delayed(const Duration(milliseconds: 3), () => events.add(2)));
unawaited(lock.run(() => Future.delayed(const Duration(milliseconds: 3), () => events.add(2))));
expect(2, lock.enqueued);
lock.run(() => Future.delayed(const Duration(milliseconds: 1), () => events.add(3)));
unawaited(lock.run(() => Future.delayed(const Duration(milliseconds: 1), () => events.add(3))));
expect(3, lock.enqueued);
await lock.run(() => Future.delayed(const Duration(milliseconds: 10), () => events.add(4)));
expect(0, lock.enqueued);
@@ -0,0 +1,170 @@
import 'dart:io';
import 'package:drift/drift.dart' hide isNull, isNotNull;
import 'package:drift/native.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.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/services/app_settings.service.dart';
import 'package:immich_mobile/services/upload.service.dart';
import 'package:mocktail/mocktail.dart';
import 'package:photo_manager/photo_manager.dart';
import '../domain/service.mock.dart';
import '../fixtures/asset.stub.dart';
import '../infrastructure/repository.mock.dart';
import '../repository.mocks.dart';
class MockAssetEntity extends Mock implements AssetEntity {}
void main() {
late UploadService sut;
late MockUploadRepository mockUploadRepository;
late MockDriftBackupRepository mockBackupRepository;
late MockStorageRepository mockStorageRepository;
late MockDriftLocalAssetRepository mockLocalAssetRepository;
late MockAppSettingsService mockAppSettingsService;
late MockAssetMediaRepository mockAssetMediaRepository;
late Drift db;
setUpAll(() async {
registerFallbackValue(AppSettingsEnum.useCellularForUploadPhotos);
TestWidgetsFlutterBinding.ensureInitialized();
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(
const MethodChannel('plugins.flutter.io/path_provider'),
(MethodCall methodCall) async => 'test',
);
db = Drift(DatabaseConnection(NativeDatabase.memory(), closeStreamsSynchronously: true));
await StoreService.init(storeRepository: DriftStoreRepository(db));
await Store.put(StoreKey.serverEndpoint, 'http://test-server.com');
await Store.put(StoreKey.deviceId, 'test-device-id');
});
setUp(() {
mockUploadRepository = MockUploadRepository();
mockBackupRepository = MockDriftBackupRepository();
mockStorageRepository = MockStorageRepository();
mockLocalAssetRepository = MockDriftLocalAssetRepository();
mockAppSettingsService = MockAppSettingsService();
mockAssetMediaRepository = MockAssetMediaRepository();
when(() => mockAppSettingsService.getSetting(AppSettingsEnum.useCellularForUploadVideos)).thenReturn(false);
when(() => mockAppSettingsService.getSetting(AppSettingsEnum.useCellularForUploadPhotos)).thenReturn(false);
sut = UploadService(
mockUploadRepository,
mockBackupRepository,
mockStorageRepository,
mockLocalAssetRepository,
mockAppSettingsService,
mockAssetMediaRepository,
);
mockUploadRepository.onUploadStatus = (_) {};
mockUploadRepository.onTaskProgress = (_) {};
});
tearDown(() {
sut.dispose();
});
group('getUploadTask', () {
test('should call getOriginalFilename from AssetMediaRepository for regular photo', () async {
final asset = LocalAssetStub.image1;
final mockEntity = MockAssetEntity();
final mockFile = File('/path/to/file.jpg');
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 => 'OriginalPhoto.jpg');
final task = await sut.getUploadTask(asset);
expect(task, isNotNull);
expect(task!.fields['filename'], equals('OriginalPhoto.jpg'));
verify(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).called(1);
});
test('should call getOriginalFilename when original filename is null', () async {
final asset = LocalAssetStub.image2;
final mockEntity = MockAssetEntity();
final mockFile = File('/path/to/file.jpg');
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 => null);
final task = await sut.getUploadTask(asset);
expect(task, isNotNull);
expect(task!.fields['filename'], equals(asset.name));
verify(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).called(1);
});
test('should call getOriginalFilename for live photo', () async {
final asset = LocalAssetStub.image1;
final mockEntity = MockAssetEntity();
final mockFile = File('/path/to/file.mov');
when(() => mockEntity.isLivePhoto).thenReturn(true);
when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity);
when(() => mockStorageRepository.getMotionFileForAsset(asset)).thenAnswer((_) async => mockFile);
when(
() => mockAssetMediaRepository.getOriginalFilename(asset.id),
).thenAnswer((_) async => 'OriginalLivePhoto.HEIC');
final task = await sut.getUploadTask(asset);
expect(task, isNotNull);
// For live photos, extension should be changed to match the video file
expect(task!.fields['filename'], equals('OriginalLivePhoto.mov'));
verify(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).called(1);
});
});
group('getLivePhotoUploadTask', () {
test('should call getOriginalFilename for live photo upload task', () async {
final asset = LocalAssetStub.image1;
final mockEntity = MockAssetEntity();
final mockFile = File('/path/to/livephoto.heic');
when(() => mockEntity.isLivePhoto).thenReturn(true);
when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity);
when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => mockFile);
when(
() => mockAssetMediaRepository.getOriginalFilename(asset.id),
).thenAnswer((_) async => 'OriginalLivePhoto.HEIC');
final task = await sut.getLivePhotoUploadTask(asset, 'video-id-123');
expect(task, isNotNull);
expect(task!.fields['filename'], equals('OriginalLivePhoto.HEIC'));
expect(task.fields['livePhotoVideoId'], equals('video-id-123'));
verify(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).called(1);
});
test('should call getOriginalFilename when original filename is null', () async {
final asset = LocalAssetStub.image2;
final mockEntity = MockAssetEntity();
final mockFile = File('/path/to/fallback.heic');
when(() => mockEntity.isLivePhoto).thenReturn(true);
when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity);
when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => mockFile);
when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => null);
final task = await sut.getLivePhotoUploadTask(asset, 'video-id-456');
expect(task, isNotNull);
// Should fall back to asset.name when original filename is null
expect(task!.fields['filename'], equals(asset.name));
verify(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).called(1);
});
});
}
@@ -383,6 +383,42 @@ void main() {
});
});
group('similar photos button', () {
test('should show when not locked and has remote', () {
final remoteAsset = createRemoteAsset();
final context = ActionButtonContext(
asset: remoteAsset,
isOwner: true,
isArchived: false,
isTrashEnabled: true,
isInLockedView: false,
isStacked: false,
currentAlbum: null,
advancedTroubleshooting: false,
source: ActionSource.timeline,
);
expect(ActionButtonType.similarPhotos.shouldShow(context), isTrue);
});
test('should not show when in locked view', () {
final remoteAsset = createRemoteAsset();
final context = ActionButtonContext(
asset: remoteAsset,
isOwner: true,
isArchived: false,
isTrashEnabled: true,
isInLockedView: true,
currentAlbum: null,
isStacked: false,
advancedTroubleshooting: false,
source: ActionSource.timeline,
);
expect(ActionButtonType.similarPhotos.shouldShow(context), isFalse);
});
});
group('trash button', () {
test('should show when owner, not locked, has remote, and trash enabled', () {
final remoteAsset = createRemoteAsset();
@@ -777,6 +813,8 @@ void main() {
test('should build correct widget for each button type', () {
for (final buttonType in ActionButtonType.values) {
var buttonContext = context;
if (buttonType == ActionButtonType.removeFromAlbum) {
final album = createRemoteAlbum();
final contextWithAlbum = ActionButtonContext(
@@ -792,6 +830,20 @@ void main() {
);
final widget = buttonType.buildButton(contextWithAlbum);
expect(widget, isA<Widget>());
} else if (buttonType == ActionButtonType.similarPhotos) {
final contextWithAlbum = ActionButtonContext(
asset: createRemoteAsset(),
isOwner: true,
isArchived: false,
isTrashEnabled: true,
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
final widget = buttonType.buildButton(contextWithAlbum);
expect(widget, isA<Widget>());
} else if (buttonType == ActionButtonType.unstack) {
final album = createRemoteAlbum();
final contextWithAlbum = ActionButtonContext(
@@ -808,7 +860,7 @@ void main() {
final widget = buttonType.buildButton(contextWithAlbum);
expect(widget, isA<Widget>());
} else {
final widget = buttonType.buildButton(context);
final widget = buttonType.buildButton(buttonContext);
expect(widget, isA<Widget>());
}
}