refactor(mobile): decouple view intent asset resolver from providers

This commit is contained in:
Peter Ombodi
2026-04-27 13:28:53 +03:00
parent d96cb8d386
commit e2f5308cba
4 changed files with 151 additions and 77 deletions
@@ -7,6 +7,7 @@ import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/platform/view_intent_api.g.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/providers/auth.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/view_intent/view_intent_file_path.provider.dart';
import 'package:immich_mobile/providers/view_intent/view_intent_handler.provider.dart';
import 'package:immich_mobile/providers/view_intent/view_intent_main_timeline_ready.provider.dart';
@@ -82,7 +83,11 @@ class AndroidViewIntentHandler implements ViewIntentHandler {
return;
}
final resolvedAsset = await _viewIntentAssetResolver.resolve(attachment);
final resolvedAsset = await _viewIntentAssetResolver.resolve(
attachment,
timelineUsers: _resolveMainTimelineUsers(),
mainTimelineService: _ref.read(timelineServiceProvider),
);
_logger.fine('resolved view intent asset: ${resolvedAsset.asset}');
await _openAssetViewer(
resolvedAsset.asset,
@@ -92,6 +97,16 @@ class AndroidViewIntentHandler implements ViewIntentHandler {
);
}
List<String> _resolveMainTimelineUsers() {
final timelineUsers = _ref.read(timelineUsersProvider).valueOrNull;
final currentUserId = _ref.read(authProvider).userId;
final effectiveTimelineUsers = timelineUsers != null && timelineUsers.isNotEmpty ? timelineUsers : [currentUserId];
_logger.fine(
'resolve main timeline users source, timelineUsers=$timelineUsers, currentUserId=$currentUserId, effective=$effectiveTimelineUsers',
);
return effectiveTimelineUsers;
}
Future<void> _openAssetViewer(
BaseAsset asset,
TimelineService timelineService,
@@ -4,13 +4,13 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/timeline.repository.dart';
import 'package:immich_mobile/models/view_intent/view_intent_payload.extension.dart';
import 'package:immich_mobile/platform/native_sync_api.g.dart';
import 'package:immich_mobile/platform/view_intent_api.g.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:logging/logging.dart';
class ViewIntentResolvedAsset {
@@ -29,23 +29,35 @@ class ViewIntentResolvedAsset {
final viewIntentAssetResolverProvider = Provider<ViewIntentAssetResolver>(
(ref) => ViewIntentAssetResolver(
ref,
ref.read(localAssetRepository),
ref.read(nativeSyncApiProvider),
ref.read(timelineFactoryProvider),
localAssetRepository: ref.read(localAssetRepository),
nativeSyncApi: ref.read(nativeSyncApiProvider),
timelineFactory: ref.read(timelineFactoryProvider),
timelineRepository: ref.read(timelineRepositoryProvider),
),
);
class ViewIntentAssetResolver {
final Ref _ref;
final DriftLocalAssetRepository _localAssetRepository;
final NativeSyncApi _nativeSyncApi;
final TimelineFactory _timelineFactory;
final DriftTimelineRepository _timelineRepository;
static final Logger _logger = Logger('ViewIntentAssetResolver');
const ViewIntentAssetResolver(this._ref, this._localAssetRepository, this._nativeSyncApi, this._timelineFactory);
const ViewIntentAssetResolver({
required DriftLocalAssetRepository localAssetRepository,
required NativeSyncApi nativeSyncApi,
required TimelineFactory timelineFactory,
required DriftTimelineRepository timelineRepository,
}) : _localAssetRepository = localAssetRepository,
_nativeSyncApi = nativeSyncApi,
_timelineFactory = timelineFactory,
_timelineRepository = timelineRepository;
Future<ViewIntentResolvedAsset> resolve(ViewIntentPayload attachment) async {
Future<ViewIntentResolvedAsset> resolve(
ViewIntentPayload attachment, {
required List<String> timelineUsers,
required TimelineService mainTimelineService,
}) async {
final localAssetId = attachment.localAssetId;
final path = attachment.path;
_logger.fine('resolve start, localAssetId=$localAssetId, path=$path, mimeType=${attachment.mimeType}');
@@ -56,7 +68,11 @@ class ViewIntentAssetResolver {
if (localAssetId != null) {
// Try the direct local-id match first when the intent resolves to a real
// MediaStore asset.
final mainTimelineAsset = await _resolveMainTimelineAssetByLocalId(localAssetId);
final mainTimelineAsset = await _resolveMainTimelineAssetByLocalId(
localAssetId,
timelineUsers,
mainTimelineService,
);
if (mainTimelineAsset != null) {
_logger.fine('presenting main timeline asset via localAssetId: ${mainTimelineAsset.asset}');
return mainTimelineAsset;
@@ -69,7 +85,7 @@ class ViewIntentAssetResolver {
final checksum = await _resolveChecksumForMatching(attachment, localAsset: localAsset);
_logger.fine('resolve checksum for matching: $checksum');
if (checksum != null) {
final mainTimelineAsset = await _resolveMainTimelineAssetByChecksum(checksum);
final mainTimelineAsset = await _resolveMainTimelineAssetByChecksum(checksum, timelineUsers, mainTimelineService);
if (mainTimelineAsset != null) {
final lookupType = localAssetId != null ? 'checksum fallback' : 'checksum-only match';
_logger.fine('presenting main timeline asset via $lookupType: ${mainTimelineAsset.asset}');
@@ -92,61 +108,59 @@ class ViewIntentAssetResolver {
);
}
Future<ViewIntentResolvedAsset?> _resolveMainTimelineAssetByLocalId(String localAssetId) async {
Future<ViewIntentResolvedAsset?> _resolveMainTimelineAssetByLocalId(
String localAssetId,
List<String> timelineUsers,
TimelineService mainTimelineService,
) async {
_logger.fine('resolve main timeline by localId start: $localAssetId');
return _resolveMainTimelineAsset(
(effectiveTimelineUsers) =>
_ref.read(timelineRepositoryProvider).getMainTimelineIndexByLocalId(effectiveTimelineUsers, localAssetId),
() => _timelineRepository.getMainTimelineIndexByLocalId(timelineUsers, localAssetId),
timelineUsers: timelineUsers,
mainTimelineService: mainTimelineService,
lookupLabel: 'localId=$localAssetId',
);
}
Future<ViewIntentResolvedAsset?> _resolveMainTimelineAssetByChecksum(String checksum) async {
Future<ViewIntentResolvedAsset?> _resolveMainTimelineAssetByChecksum(
String checksum,
List<String> timelineUsers,
TimelineService mainTimelineService,
) async {
// Some ACTION_VIEW sources do not provide a local MediaStore id, so
// checksum is the only way to match the incoming file to an existing
// merged asset.
_logger.fine('resolve main timeline by checksum start: $checksum');
return _resolveMainTimelineAsset(
(effectiveTimelineUsers) =>
_ref.read(timelineRepositoryProvider).getMainTimelineIndexByChecksum(effectiveTimelineUsers, checksum),
() => _timelineRepository.getMainTimelineIndexByChecksum(timelineUsers, checksum),
timelineUsers: timelineUsers,
mainTimelineService: mainTimelineService,
lookupLabel: 'checksum=$checksum',
);
}
Future<ViewIntentResolvedAsset?> _resolveMainTimelineAsset(
Future<int?> Function(List<String> effectiveTimelineUsers) findIndex, {
Future<int?> Function() findIndex, {
required List<String> timelineUsers,
required TimelineService mainTimelineService,
required String lookupLabel,
}) async {
final effectiveTimelineUsers = _resolveMainTimelineUsers();
_logger.fine('resolve main timeline users for $lookupLabel: $effectiveTimelineUsers');
if (effectiveTimelineUsers.isEmpty) {
_logger.fine('resolve main timeline aborted for $lookupLabel: effectiveTimelineUsers is empty');
_logger.fine('resolve main timeline users for $lookupLabel: $timelineUsers');
if (timelineUsers.isEmpty) {
_logger.fine('resolve main timeline aborted for $lookupLabel: timelineUsers is empty');
return null;
}
final index = await findIndex(effectiveTimelineUsers);
final index = await findIndex();
_logger.fine('resolve main timeline index for $lookupLabel: $index');
if (index == null) {
return null;
}
return _resolveMainTimelineAssetAt(index);
return _resolveMainTimelineAssetAt(index, mainTimelineService);
}
List<String> _resolveMainTimelineUsers() {
final timelineUsers = _ref.read(timelineUsersProvider).valueOrNull;
final currentUserId = _ref.read(currentUserProvider)?.id;
final effectiveTimelineUsers = timelineUsers != null && timelineUsers.isNotEmpty
? timelineUsers
: (currentUserId != null ? [currentUserId] : const <String>[]);
_logger.fine(
'resolve main timeline users source, timelineUsers=$timelineUsers, currentUserId=$currentUserId, effective=$effectiveTimelineUsers',
);
return effectiveTimelineUsers;
}
Future<ViewIntentResolvedAsset?> _resolveMainTimelineAssetAt(int index) async {
final timelineService = _ref.read(timelineServiceProvider);
Future<ViewIntentResolvedAsset?> _resolveMainTimelineAssetAt(int index, TimelineService timelineService) async {
_logger.fine(
'resolve main timeline asset at index start: index=$index, origin=${timelineService.origin}, totalAssets=${timelineService.totalAssets}',
);
@@ -41,6 +41,8 @@ class MockWidgetService extends Mock implements WidgetService {}
class FakePageRouteInfo extends Fake implements PageRouteInfo<dynamic> {}
class FakeTimelineService extends Fake implements TimelineService {}
class TestViewIntentService extends ViewIntentService {
ViewIntentPayload? consumedAttachment;
int cleanupStaleTempFilesCalls = 0;
@@ -107,6 +109,8 @@ void main() {
registerFallbackValue(FakePageRouteInfo());
registerFallbackValue(_pageRoutePredicate);
registerFallbackValue(_localAsset(id: 'fallback'));
registerFallbackValue(<String>[]);
registerFallbackValue(FakeTimelineService());
});
setUp(() async {
@@ -135,6 +139,7 @@ void main() {
);
authNotifier = container.read(authProvider.notifier) as TestAuthNotifier;
await container.read(timelineUsersProvider.future);
handler = container.read(_handlerProvider);
addTearDown(() async {
@@ -149,7 +154,13 @@ void main() {
await handler.handle(payload);
expect(container.read(viewIntentPendingProvider), payload);
verifyNever(() => resolver.resolve(payload));
verifyNever(
() => resolver.resolve(
payload,
timelineUsers: any(named: 'timelineUsers'),
mainTimelineService: any(named: 'mainTimelineService'),
),
);
});
testWidgets('flushDeferredViewIntent waits for main timeline readiness before flushing pending attachment', (
@@ -159,7 +170,13 @@ void main() {
container.read(viewIntentPendingProvider.notifier).defer(payload);
authNotifier.setAuthenticated(true);
when(() => resolver.resolve(payload)).thenAnswer((_) async {
when(
() => resolver.resolve(
payload,
timelineUsers: any(named: 'timelineUsers'),
mainTimelineService: any(named: 'mainTimelineService'),
),
).thenAnswer((_) async {
return ViewIntentResolvedAsset(asset: deepLinkAsset, timelineService: deepLinkTimelineService, initialIndex: 0);
});
@@ -167,7 +184,13 @@ void main() {
await tester.pump();
expect(container.read(viewIntentPendingProvider), payload);
verifyNever(() => resolver.resolve(payload));
verifyNever(
() => resolver.resolve(
payload,
timelineUsers: any(named: 'timelineUsers'),
mainTimelineService: any(named: 'mainTimelineService'),
),
);
container.read(viewIntentMainTimelineReadyProvider.notifier).markMountedOnce();
await tester.pump();
@@ -175,13 +198,21 @@ void main() {
await tester.idle();
expect(container.read(viewIntentPendingProvider), isNull);
verify(() => resolver.resolve(payload)).called(1);
verify(
() => resolver.resolve(payload, timelineUsers: ['user-1'], mainTimelineService: deepLinkTimelineService),
).called(1);
});
test('flushDeferredViewIntent does nothing when there is no pending attachment', () async {
await handler.flushDeferredViewIntent();
verifyNever(() => resolver.resolve(payload));
verifyNever(
() => resolver.resolve(
payload,
timelineUsers: any(named: 'timelineUsers'),
mainTimelineService: any(named: 'mainTimelineService'),
),
);
});
test('onAppResumed cleans stale temp files when no attachment is present', () async {
@@ -190,7 +221,13 @@ void main() {
await handler.onAppResumed();
expect(viewIntentService.cleanupStaleTempFilesCalls, 1);
verifyNever(() => resolver.resolve(payload));
verifyNever(
() => resolver.resolve(
payload,
timelineUsers: any(named: 'timelineUsers'),
mainTimelineService: any(named: 'mainTimelineService'),
),
);
});
test('onAppResumed does not clean stale temp files while pending attachment exists', () async {
@@ -200,12 +237,24 @@ void main() {
await handler.onAppResumed();
expect(viewIntentService.cleanupStaleTempFilesCalls, 0);
verifyNever(() => resolver.resolve(payload));
verifyNever(
() => resolver.resolve(
payload,
timelineUsers: any(named: 'timelineUsers'),
mainTimelineService: any(named: 'mainTimelineService'),
),
);
});
testWidgets('onAppResumed handles attachment immediately when authenticated', (tester) async {
viewIntentService.consumedAttachment = payload;
when(() => resolver.resolve(payload)).thenAnswer(
when(
() => resolver.resolve(
payload,
timelineUsers: any(named: 'timelineUsers'),
mainTimelineService: any(named: 'mainTimelineService'),
),
).thenAnswer(
(_) async =>
ViewIntentResolvedAsset(asset: deepLinkAsset, timelineService: deepLinkTimelineService, initialIndex: 0),
);
@@ -216,7 +265,9 @@ void main() {
await tester.pump();
await tester.idle();
verify(() => resolver.resolve(payload)).called(1);
verify(
() => resolver.resolve(payload, timelineUsers: ['user-1'], mainTimelineService: deepLinkTimelineService),
).called(1);
});
}
@@ -5,18 +5,15 @@ 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/timeline.service.dart';
import 'package:immich_mobile/domain/services/user.service.dart';
import 'package:immich_mobile/infrastructure/repositories/timeline.repository.dart';
import 'package:immich_mobile/platform/native_sync_api.g.dart';
import 'package:immich_mobile/platform/view_intent_api.g.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/services/view_intent_asset_resolver.service.dart';
import 'package:mocktail/mocktail.dart';
import '../fixtures/user.stub.dart';
import '../infrastructure/repository.mock.dart';
class MockTimelineRepository extends Mock implements DriftTimelineRepository {}
@@ -25,20 +22,11 @@ class MockTimelineFactory extends Mock implements TimelineFactory {}
class MockNativeSyncApi extends Mock implements NativeSyncApi {}
class MockUserService extends Mock implements UserService {}
class _StaticCurrentUserProvider extends CurrentUserProvider {
_StaticCurrentUserProvider(UserService userService) : super(userService) {
state = userService.tryGetMyUser();
}
}
void main() {
late MockDriftLocalAssetRepository mockLocalAssetRepository;
late MockNativeSyncApi nativeSyncApi;
late MockTimelineRepository timelineRepository;
late MockTimelineFactory timelineFactory;
late MockUserService userService;
late TimelineService mainTimelineService;
late List<TimelineService> createdTimelineServices;
late ProviderContainer container;
@@ -48,13 +36,9 @@ void main() {
nativeSyncApi = MockNativeSyncApi();
timelineRepository = MockTimelineRepository();
timelineFactory = MockTimelineFactory();
userService = MockUserService();
createdTimelineServices = [];
mainTimelineService = await _setMainTimelineService(const [], createdTimelineServices);
when(() => userService.tryGetMyUser()).thenReturn(UserStub.admin);
when(() => userService.watchMyUser()).thenAnswer((_) => Stream.value(UserStub.admin));
when(() => timelineFactory.fromAssets(any(), TimelineOrigin.deepLink)).thenAnswer((invocation) {
final assets = List<BaseAsset>.from(invocation.positionalArguments[0] as List<BaseAsset>);
final timelineService = _timelineServiceFromAssets(assets, TimelineOrigin.deepLink);
@@ -68,12 +52,8 @@ void main() {
nativeSyncApiProvider.overrideWith((ref) => nativeSyncApi),
timelineRepositoryProvider.overrideWith((ref) => timelineRepository),
timelineFactoryProvider.overrideWith((ref) => timelineFactory),
timelineServiceProvider.overrideWith((ref) => mainTimelineService),
timelineUsersProvider.overrideWith((ref) => Stream.value(['user-1'])),
currentUserProvider.overrideWith((ref) => _StaticCurrentUserProvider(userService)),
],
);
await container.read(timelineUsersProvider.future);
addTearDown(() async {
for (final timelineService in createdTimelineServices) {
@@ -91,7 +71,7 @@ void main() {
when(() => mockLocalAssetRepository.getById('local-1')).thenAnswer((_) async => localAsset);
when(() => timelineRepository.getMainTimelineIndexByLocalId(['user-1'], 'local-1')).thenAnswer((_) async => 0);
final result = await container.read(viewIntentAssetResolverProvider).resolve(_payload(localAssetId: 'local-1'));
final result = await _resolve(container, _payload(localAssetId: 'local-1'), mainTimelineService);
expect(result.asset, same(mainAsset));
expect(result.timelineService, same(mainTimelineService));
@@ -110,7 +90,7 @@ void main() {
when(() => timelineRepository.getMainTimelineIndexByLocalId(['user-1'], 'local-1')).thenAnswer((_) async => null);
when(() => timelineRepository.getMainTimelineIndexByChecksum(['user-1'], 'checksum-1')).thenAnswer((_) async => 0);
final result = await container.read(viewIntentAssetResolverProvider).resolve(_payload(localAssetId: 'local-1'));
final result = await _resolve(container, _payload(localAssetId: 'local-1'), mainTimelineService);
expect(result.asset, same(mainAsset));
expect(result.timelineService, same(mainTimelineService));
@@ -129,7 +109,7 @@ void main() {
).thenAnswer((_) async => [HashResult(assetId: 'local-1', hash: 'checksum-1')]);
when(() => timelineRepository.getMainTimelineIndexByChecksum(['user-1'], 'checksum-1')).thenAnswer((_) async => 0);
final result = await container.read(viewIntentAssetResolverProvider).resolve(_payload(localAssetId: 'local-1'));
final result = await _resolve(container, _payload(localAssetId: 'local-1'), mainTimelineService);
expect(result.asset, same(mainAsset));
expect(result.timelineService, same(mainTimelineService));
@@ -143,7 +123,7 @@ void main() {
when(() => timelineRepository.getMainTimelineIndexByLocalId(['user-1'], 'local-1')).thenAnswer((_) async => null);
when(() => nativeSyncApi.hashAssets(['local-1'], allowNetworkAccess: false)).thenThrow(Exception('hash failed'));
final result = await container.read(viewIntentAssetResolverProvider).resolve(_payload(localAssetId: 'local-1'));
final result = await _resolve(container, _payload(localAssetId: 'local-1'), mainTimelineService);
expect(result.asset, equals(localAsset));
expect(result.timelineService.origin, TimelineOrigin.deepLink);
@@ -160,9 +140,11 @@ void main() {
).thenAnswer((_) async => [HashResult(assetId: '/tmp/incoming.jpg', hash: 'checksum-2')]);
when(() => timelineRepository.getMainTimelineIndexByChecksum(['user-1'], 'checksum-2')).thenAnswer((_) async => 0);
final result = await container
.read(viewIntentAssetResolverProvider)
.resolve(_payload(path: '/tmp/incoming.jpg', localAssetId: null));
final result = await _resolve(
container,
_payload(path: '/tmp/incoming.jpg', localAssetId: null),
mainTimelineService,
);
expect(result.asset, same(mainAsset));
expect(result.timelineService, same(mainTimelineService));
@@ -172,9 +154,11 @@ void main() {
test('returns transient deep-link asset for unmatched path-only attachment', () async {
when(() => nativeSyncApi.hashFiles(['/tmp/incoming.webp'])).thenAnswer((_) async => const []);
final result = await container
.read(viewIntentAssetResolverProvider)
.resolve(_payload(path: '/tmp/incoming.webp', localAssetId: null, mimeType: 'image/webp'));
final result = await _resolve(
container,
_payload(path: '/tmp/incoming.webp', localAssetId: null, mimeType: 'image/webp'),
mainTimelineService,
);
expect(result.asset, isA<LocalAsset>());
expect(result.timelineService.origin, TimelineOrigin.deepLink);
@@ -189,6 +173,16 @@ void main() {
});
}
Future<ViewIntentResolvedAsset> _resolve(
ProviderContainer container,
ViewIntentPayload payload,
TimelineService mainTimelineService,
) {
return container
.read(viewIntentAssetResolverProvider)
.resolve(payload, timelineUsers: const ['user-1'], mainTimelineService: mainTimelineService);
}
ViewIntentPayload _payload({String? localAssetId = 'local-1', String? path, String mimeType = 'image/jpeg'}) {
return ViewIntentPayload(path: path, mimeType: mimeType, localAssetId: localAssetId);
}