mirror of
https://github.com/immich-app/immich
synced 2026-08-22 13:13:05 +00:00
* feat(mobile): handle Android ACTION_VIEW intent - add ViewIntent Pigeon API and generated bindings - implement Android ViewIntentPlugin + iOS no-op host - route ExternalMediaViewer by ViewIntentAttachment - buffer pending view intents and flush on user ready/resume * feat(mobile): fallback to computed checksum for timeline match - hash local asset on-demand when checksum missing - search main timeline by localId or checksum before standalone viewer - persist computed hash into local_asset_entity * fix(mobile): proper handling is user authenticated * feat(mobile): open ACTION_VIEW fallback in AssetViewer drop ExternalMediaViewer route * feat(mobile): add logger * test(mobile): add unit tests for view intent pending/flush flow * fix(mobile): fix format * fix(mobile): remove redundant iOS code update code related to LocalAsset model and asset viewer * refactor(mobile): simplify view intent flow and support file-backed ACTION_VIEW assets remove redundant view intent model/repository layer handle transient ACTION_VIEW files in viewer/upload flow clean up managed temp files for fallback assets * refactor(mobile): extract MediaStore utils and resolve view intents via merged assets * refactor(mobile): move deferred view intents into providers, split view-intent providers, and clean up ACTION_VIEW handling * refactor(mobile): resolve merge conflicts use NativeSyncApi for hash files instead method from removed BackgroundServicePlugin.kt * style(mobile): format files * style(mobile): format files #2 * refactor(mobile): lazily materialize view-intent files and clean up temp-file handling * fix(mobile): flush pending view intents after login navigation * refactor(mobile): split view intent handler by platform and trigger it from app events * refactor(mobile): move view intent handling behind platform-specific factories * refactor(mobile): simplify code * fix(mobile): hand off deep-link viewer to main timeline after upload Add MainTimelineHandoffCoordinator to switch the asset viewer to the main timeline once a view-intent asset is uploaded and becomes available, and guard viewer reload/navigation transitions to avoid race conditions and crashes. * refactor(mobile): use remote asset ids for view intent handoff and simplify resolver * refactor(mobile): resolve merge conflicts * style(mobile): reformat code * style(mobile): reformat code #2 * fix(mobile): stabilize Android view intent asset resolution and fallback viewer * refactor(mobile): share AssetViewer pre-navigation state preparation * fix(mobile): wait for main timeline before deferred view intent handoff * refactor(mobile): decouple view intent asset resolver from providers * fix(mobile): avoid double pop when canceling upload dialog * fix(mobile): resolve view intent MIME type with fallbacks * docs(mobile): clarify view intent fallback asset TODO * fix(mobile): resolve merge conflicts * cleanup * lint --------- Co-authored-by: Peter Ombodi <peter.ombodi@gmail.com> Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
123 lines
4.7 KiB
Dart
123 lines
4.7 KiB
Dart
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/domain/models/timeline.model.dart';
|
|
import 'package:immich_mobile/domain/services/timeline.service.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/timeline.provider.dart';
|
|
import 'package:immich_mobile/services/view_intent_asset_resolver.service.dart';
|
|
import 'package:mocktail/mocktail.dart';
|
|
|
|
import '../infrastructure/repository.mock.dart';
|
|
|
|
class MockTimelineFactory extends Mock implements TimelineFactory {}
|
|
|
|
void main() {
|
|
late MockDriftLocalAssetRepository mockLocalAssetRepository;
|
|
late MockTimelineFactory timelineFactory;
|
|
late List<TimelineService> createdTimelineServices;
|
|
late ProviderContainer container;
|
|
|
|
setUp(() {
|
|
mockLocalAssetRepository = MockDriftLocalAssetRepository();
|
|
timelineFactory = MockTimelineFactory();
|
|
createdTimelineServices = [];
|
|
|
|
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);
|
|
createdTimelineServices.add(timelineService);
|
|
return timelineService;
|
|
});
|
|
|
|
container = ProviderContainer(
|
|
overrides: [
|
|
localAssetRepository.overrideWith((ref) => mockLocalAssetRepository),
|
|
timelineFactoryProvider.overrideWith((ref) => timelineFactory),
|
|
],
|
|
);
|
|
|
|
addTearDown(() async {
|
|
for (final timelineService in createdTimelineServices) {
|
|
await timelineService.dispose();
|
|
}
|
|
container.dispose();
|
|
});
|
|
});
|
|
|
|
test('returns DB-backed local asset wrapped in a 1-element deep-link timeline', () async {
|
|
final localAsset = _localAsset(id: 'local-1', checksum: 'checksum-1');
|
|
when(() => mockLocalAssetRepository.getById('local-1')).thenAnswer((_) async => localAsset);
|
|
|
|
final result = await _resolve(container, _payload(localAssetId: 'local-1'));
|
|
|
|
expect(result.asset, equals(localAsset));
|
|
expect(result.timelineService.origin, TimelineOrigin.deepLink);
|
|
expect(result.viewIntentFilePath, isNull, reason: 'DB-backed assets carry their own source — no temp file needed');
|
|
});
|
|
|
|
test('returns transient asset with temp file path when localAssetId has no DB row', () async {
|
|
when(() => mockLocalAssetRepository.getById('local-1')).thenAnswer((_) async => null);
|
|
|
|
final result = await _resolve(container, _payload(localAssetId: 'local-1', path: '/tmp/incoming.jpg'));
|
|
|
|
expect(result.asset, isA<LocalAsset>());
|
|
expect(result.timelineService.origin, TimelineOrigin.deepLink);
|
|
expect(result.viewIntentFilePath, '/tmp/incoming.jpg');
|
|
});
|
|
|
|
test('returns transient asset for path-only attachment', () async {
|
|
final result = await _resolve(
|
|
container,
|
|
_payload(localAssetId: null, path: '/tmp/incoming.webp', mimeType: 'image/webp'),
|
|
);
|
|
|
|
expect(result.asset, isA<LocalAsset>());
|
|
expect(result.timelineService.origin, TimelineOrigin.deepLink);
|
|
expect(result.viewIntentFilePath, '/tmp/incoming.webp');
|
|
|
|
final asset = result.asset as LocalAsset;
|
|
expect(asset.localId, startsWith('-'));
|
|
expect(asset.name, 'incoming.webp');
|
|
expect(asset.playbackStyle, AssetPlaybackStyle.imageAnimated);
|
|
});
|
|
|
|
test('throws when neither localAssetId nor path is provided', () async {
|
|
await expectLater(
|
|
_resolve(container, _payload(localAssetId: null, path: null)),
|
|
throwsA(isA<StateError>()),
|
|
);
|
|
});
|
|
}
|
|
|
|
Future<ViewIntentResolvedAsset> _resolve(ProviderContainer container, ViewIntentPayload payload) {
|
|
return container.read(viewIntentAssetResolverProvider).resolve(payload);
|
|
}
|
|
|
|
ViewIntentPayload _payload({String? localAssetId = 'local-1', String? path, String mimeType = 'image/jpeg'}) {
|
|
return ViewIntentPayload(path: path, mimeType: mimeType, localAssetId: localAssetId);
|
|
}
|
|
|
|
LocalAsset _localAsset({required String id, String? checksum}) {
|
|
return LocalAsset(
|
|
id: id,
|
|
name: '$id.jpg',
|
|
checksum: checksum,
|
|
type: AssetType.image,
|
|
createdAt: DateTime(2026, 4, 20),
|
|
updatedAt: DateTime(2026, 4, 20),
|
|
playbackStyle: AssetPlaybackStyle.image,
|
|
isEdited: false,
|
|
);
|
|
}
|
|
|
|
TimelineService _timelineServiceFromAssets(List<BaseAsset> assets, TimelineOrigin origin) {
|
|
return TimelineService((
|
|
assetSource: (index, count) async => assets.skip(index).take(count).toList(),
|
|
bucketSource: () => Stream.value([Bucket(assetCount: assets.length)]),
|
|
origin: origin,
|
|
));
|
|
}
|