diff --git a/mobile/lib/providers/asset_viewer/asset_viewer.provider.dart b/mobile/lib/providers/asset_viewer/asset_viewer.provider.dart index 5c9f3c92a3..6808860ffc 100644 --- a/mobile/lib/providers/asset_viewer/asset_viewer.provider.dart +++ b/mobile/lib/providers/asset_viewer/asset_viewer.provider.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart'; @@ -77,18 +79,17 @@ class AssetViewerState { } class AssetViewerStateNotifier extends Notifier { + StreamSubscription? _assetSubscription; + @override AssetViewerState build() { - ref.listen(_watchedCurrentAssetProvider, (_, next) { - final updated = next.valueOrNull; - if (updated != null) { - state = state.copyWith(currentAsset: updated); - } - }); + ref.onDispose(() => _assetSubscription?.cancel()); return const AssetViewerState(); } void reset() { + _assetSubscription?.cancel(); + _assetSubscription = null; state = const AssetViewerState(); } @@ -97,6 +98,16 @@ class AssetViewerStateNotifier extends Notifier { return; } state = state.copyWith(currentAsset: asset, stackIndex: 0, showingOcr: false); + _watchCurrentAsset(asset); + } + + void _watchCurrentAsset(BaseAsset asset) { + _assetSubscription?.cancel(); + _assetSubscription = ref.read(assetServiceProvider).watchAsset(asset).listen((updated) { + if (updated != null) { + state = state.copyWith(currentAsset: updated); + } + }); } void setOpacity(double opacity) { @@ -150,12 +161,3 @@ class AssetViewerStateNotifier extends Notifier { } final assetViewerProvider = NotifierProvider(AssetViewerStateNotifier.new); - -final _watchedCurrentAssetProvider = StreamProvider((ref) { - ref.watch(assetViewerProvider.select((s) => s.currentAsset?.heroTag)); - final asset = ref.read(assetViewerProvider).currentAsset; - if (asset == null) { - return const Stream.empty(); - } - return ref.read(assetServiceProvider).watchAsset(asset); -}); diff --git a/mobile/test/domain/service.mock.dart b/mobile/test/domain/service.mock.dart index d5feb56563..70f706f7fe 100644 --- a/mobile/test/domain/service.mock.dart +++ b/mobile/test/domain/service.mock.dart @@ -1,3 +1,4 @@ +import 'package:immich_mobile/domain/services/asset.service.dart'; import 'package:immich_mobile/domain/services/partner.service.dart'; import 'package:immich_mobile/domain/services/store.service.dart'; import 'package:immich_mobile/domain/services/user.service.dart'; @@ -16,4 +17,6 @@ class MockAppSettingsService extends Mock implements AppSettingsService {} class MockPartnerService extends Mock implements PartnerService {} +class MockAssetService extends Mock implements AssetService {} + class MockUserService extends Mock implements UserService {} diff --git a/mobile/test/providers/asset_viewer/asset_viewer_provider_test.dart b/mobile/test/providers/asset_viewer/asset_viewer_provider_test.dart new file mode 100644 index 0000000000..67eb1dd9d1 --- /dev/null +++ b/mobile/test/providers/asset_viewer/asset_viewer_provider_test.dart @@ -0,0 +1,111 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; +import 'package:mocktail/mocktail.dart'; + +import '../../domain/service.mock.dart'; +import '../../unit/factories/remote_asset_factory.dart'; + +void main() { + late ProviderContainer container; + late MockAssetService assetService; + + setUpAll(() => registerFallbackValue(RemoteAssetFactory.create())); + + setUp(() { + assetService = MockAssetService(); + when(() => assetService.watchAsset(any())).thenAnswer((_) => const Stream.empty()); + + container = ProviderContainer(overrides: [assetServiceProvider.overrideWithValue(assetService)]); + addTearDown(container.dispose); + }); + + group('AssetViewerStateNotifier asset watching', () { + test('propagates stream updates for the current asset into state', () async { + final controller = StreamController(); + addTearDown(controller.close); + final asset = RemoteAssetFactory.create(); + when(() => assetService.watchAsset(asset)).thenAnswer((_) => controller.stream); + + final notifier = container.read(assetViewerProvider.notifier); + notifier.setAsset(asset); + + final updated = asset.copyWith(isFavorite: true); + controller.add(updated); + await pumpEventQueue(); + + expect(container.read(assetViewerProvider).currentAsset, updated); + }); + + test('ignores null stream emissions', () async { + final controller = StreamController(); + addTearDown(controller.close); + final asset = RemoteAssetFactory.create(); + when(() => assetService.watchAsset(asset)).thenAnswer((_) => controller.stream); + + container.read(assetViewerProvider.notifier).setAsset(asset); + + controller.add(null); + await pumpEventQueue(); + + expect(container.read(assetViewerProvider).currentAsset, asset); + }); + + test('reset cancels the subscription so later emissions are dropped', () async { + final controller = StreamController(); + addTearDown(controller.close); + final asset = RemoteAssetFactory.create(); + when(() => assetService.watchAsset(asset)).thenAnswer((_) => controller.stream); + + final notifier = container.read(assetViewerProvider.notifier); + notifier.setAsset(asset); + notifier.reset(); + + controller.add(RemoteAssetFactory.create(isFavorite: true)); + await pumpEventQueue(); + + expect(container.read(assetViewerProvider).currentAsset, isNull); + }); + + test('setAsset switches the current asset, cancels the previous watch and listens to the new one', () async { + final first = StreamController(); + final second = StreamController(); + addTearDown(first.close); + addTearDown(second.close); + + final assetOne = RemoteAssetFactory.create(); + final assetTwo = RemoteAssetFactory.create(); + when(() => assetService.watchAsset(assetOne)).thenAnswer((_) => first.stream); + when(() => assetService.watchAsset(assetTwo)).thenAnswer((_) => second.stream); + + final notifier = container.read(assetViewerProvider.notifier); + notifier.setAsset(assetOne); + expect(container.read(assetViewerProvider).currentAsset, assetOne); + + // Updates to the first asset propagate into state. + final updatedOne = assetOne.copyWith(visibility: .archive); + first.add(updatedOne); + await pumpEventQueue(); + expect(container.read(assetViewerProvider).currentAsset, updatedOne); + + // Switch to new asset + notifier.setAsset(assetTwo); + expect(container.read(assetViewerProvider).currentAsset, assetTwo); + + // The previous watch is cancelled: stale emissions from the first stream are dropped. + first.add(assetOne.copyWith(isFavorite: true)); + await pumpEventQueue(); + expect(container.read(assetViewerProvider).currentAsset, assetTwo); + + // The new asset is watched instead: its emissions propagate into state. + final updatedTwo = assetTwo.copyWith(isFavorite: true); + second.add(updatedTwo); + await pumpEventQueue(); + expect(container.read(assetViewerProvider).currentAsset, updatedTwo); + }); + }); +} diff --git a/mobile/test/providers/view_intent/view_intent_handler_android_test.dart b/mobile/test/providers/view_intent/view_intent_handler_android_test.dart index f9c2c9d323..be7549e202 100644 --- a/mobile/test/providers/view_intent/view_intent_handler_android_test.dart +++ b/mobile/test/providers/view_intent/view_intent_handler_android_test.dart @@ -5,19 +5,21 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/timeline.model.dart'; +import 'package:immich_mobile/domain/services/asset.service.dart'; import 'package:immich_mobile/domain/services/timeline.service.dart'; import 'package:immich_mobile/domain/services/user.service.dart'; import 'package:immich_mobile/models/auth/auth_state.model.dart'; import 'package:immich_mobile/platform/view_intent_api.g.dart'; import 'package:immich_mobile/providers/auth.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; import 'package:immich_mobile/providers/view_intent/view_intent_handler_android.dart'; import 'package:immich_mobile/providers/view_intent/view_intent_pending.provider.dart'; import 'package:immich_mobile/routing/router.dart'; +import 'package:immich_mobile/services/api.service.dart'; +import 'package:immich_mobile/services/auth.service.dart'; +import 'package:immich_mobile/services/secure_storage.service.dart'; import 'package:immich_mobile/services/view_intent.service.dart'; import 'package:immich_mobile/services/view_intent_asset_resolver.service.dart'; -import 'package:immich_mobile/services/auth.service.dart'; -import 'package:immich_mobile/services/api.service.dart'; -import 'package:immich_mobile/services/secure_storage.service.dart'; import 'package:immich_mobile/services/widget.service.dart'; import 'package:mocktail/mocktail.dart'; @@ -41,6 +43,11 @@ class FakePageRouteInfo extends Fake implements PageRouteInfo {} class FakeTimelineService extends Fake implements TimelineService {} +class FakeAssetService extends Fake implements AssetService { + @override + Stream watchAsset(BaseAsset asset) => const Stream.empty(); +} + class TestViewIntentService extends ViewIntentService { ViewIntentPayload? consumedAttachment; int cleanupStaleTempFilesCalls = 0; @@ -129,6 +136,7 @@ void main() { authNotifier = TestAuthNotifier(ref, _authState(isAuthenticated: true)); return authNotifier; }), + assetServiceProvider.overrideWithValue(FakeAssetService()), ], ); @@ -195,9 +203,9 @@ void main() { testWidgets('onAppResumed handles attachment immediately when authenticated', (tester) async { viewIntentService.consumedAttachment = payload; - when(() => resolver.resolve(payload)).thenAnswer( - (_) async => ViewIntentResolvedAsset(asset: deepLinkAsset, timelineService: deepLinkTimelineService), - ); + when( + () => resolver.resolve(payload), + ).thenAnswer((_) async => ViewIntentResolvedAsset(asset: deepLinkAsset, timelineService: deepLinkTimelineService)); unawaited(handler.onAppResumed()); await tester.pump(); diff --git a/mobile/test/unit/factories/remote_asset_factory.dart b/mobile/test/unit/factories/remote_asset_factory.dart new file mode 100644 index 0000000000..669eb3998a --- /dev/null +++ b/mobile/test/unit/factories/remote_asset_factory.dart @@ -0,0 +1,23 @@ +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; + +import '../../utils.dart'; + +class RemoteAssetFactory { + const RemoteAssetFactory(); + + static RemoteAsset create({String? id, String? name, String? ownerId, bool isFavorite = false}) { + id = TestUtils.uuid(id); + + return RemoteAsset( + id: id, + name: name ?? 'remote_$id.jpg', + ownerId: TestUtils.uuid(ownerId), + checksum: 'checksum-$id', + type: .image, + createdAt: TestUtils.yesterday(), + updatedAt: TestUtils.now(), + isFavorite: isFavorite, + isEdited: false, + ); + } +}