mirror of
https://github.com/immich-app/immich.git
synced 2026-06-05 14:25:16 -04:00
9d4a6614b1
* 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>
120 lines
4.2 KiB
Dart
120 lines
4.2 KiB
Dart
import 'dart:io';
|
|
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:immich_mobile/platform/view_intent_api.g.dart';
|
|
import 'package:immich_mobile/services/view_intent.service.dart';
|
|
import 'package:mocktail/mocktail.dart';
|
|
|
|
class MockViewIntentHostApi extends Mock implements ViewIntentHostApi {}
|
|
|
|
void main() {
|
|
late MockViewIntentHostApi hostApi;
|
|
late ViewIntentService service;
|
|
late Directory tempRoot;
|
|
late Directory cacheDir;
|
|
|
|
final attachment = ViewIntentPayload(
|
|
path: '/tmp/file.jpg',
|
|
mimeType: 'image/jpeg',
|
|
localAssetId: '42',
|
|
);
|
|
|
|
setUp(() {
|
|
hostApi = MockViewIntentHostApi();
|
|
tempRoot = Directory.systemTemp.createTempSync('view-intent-root');
|
|
cacheDir = Directory('${tempRoot.path}/cache')..createSync();
|
|
service = ViewIntentService(hostApi, temporaryDirectory: () async => cacheDir);
|
|
});
|
|
|
|
tearDown(() async {
|
|
clearInteractions(hostApi);
|
|
if (await tempRoot.exists()) {
|
|
await tempRoot.delete(recursive: true);
|
|
}
|
|
});
|
|
|
|
test('consumeViewIntent returns null when no attachment', () async {
|
|
when(() => hostApi.consumeViewIntent()).thenAnswer((_) async => null);
|
|
|
|
final result = await service.consumeViewIntent();
|
|
|
|
expect(result, isNull);
|
|
verify(() => hostApi.consumeViewIntent()).called(1);
|
|
});
|
|
|
|
test('consumeViewIntent returns attachment when present', () async {
|
|
when(() => hostApi.consumeViewIntent()).thenAnswer((_) async => attachment);
|
|
|
|
final result = await service.consumeViewIntent();
|
|
|
|
expect(result, attachment);
|
|
verify(() => hostApi.consumeViewIntent()).called(1);
|
|
});
|
|
|
|
test('consumeViewIntent swallows host api errors', () async {
|
|
when(() => hostApi.consumeViewIntent()).thenThrow(Exception('boom'));
|
|
|
|
final result = await service.consumeViewIntent();
|
|
|
|
expect(result, isNull);
|
|
verify(() => hostApi.consumeViewIntent()).called(1);
|
|
});
|
|
|
|
test('setManagedTempFilePath cleans previous managed temp file', () async {
|
|
final firstFile = File('${cacheDir.path}/view_intent_first.jpg')..writeAsStringSync('first');
|
|
final secondFile = File('${cacheDir.path}/view_intent_second.jpg')..writeAsStringSync('second');
|
|
|
|
await service.setManagedTempFilePath(firstFile.path);
|
|
await service.setManagedTempFilePath(secondFile.path);
|
|
|
|
expect(await firstFile.exists(), isFalse);
|
|
expect(await secondFile.exists(), isTrue);
|
|
|
|
await service.cleanupManagedTempFile();
|
|
expect(await secondFile.exists(), isFalse);
|
|
});
|
|
|
|
test('cleanupTempFile defers deletion while an upload is active', () async {
|
|
final tempFile = File('${cacheDir.path}/view_intent_in_flight.jpg')..writeAsStringSync('bytes');
|
|
|
|
service.markUploadActive(tempFile.path);
|
|
await service.cleanupTempFile(tempFile.path);
|
|
|
|
expect(await tempFile.exists(), isTrue, reason: 'active uploads block cleanup');
|
|
|
|
await service.markUploadInactive(tempFile.path);
|
|
expect(await tempFile.exists(), isFalse);
|
|
});
|
|
|
|
test('cleanupTempFile ignores non-managed paths', () async {
|
|
final nonManagedFile = File('${tempRoot.path}/plain_file.jpg')..writeAsStringSync('content');
|
|
|
|
await service.cleanupTempFile(nonManagedFile.path);
|
|
|
|
expect(await nonManagedFile.exists(), isTrue);
|
|
});
|
|
|
|
test('cleanupStaleTempFiles removes view-intent temp files and keeps unrelated files', () async {
|
|
final firstFile = File('${cacheDir.path}/view_intent_first.jpg')..writeAsStringSync('first');
|
|
final secondFile = File('${cacheDir.path}/view_intent_second.jpg')..writeAsStringSync('second');
|
|
final unrelatedFile = File('${cacheDir.path}/plain_file.jpg')..writeAsStringSync('plain');
|
|
|
|
await service.cleanupStaleTempFiles();
|
|
|
|
expect(await firstFile.exists(), isFalse);
|
|
expect(await secondFile.exists(), isFalse);
|
|
expect(await unrelatedFile.exists(), isTrue);
|
|
});
|
|
|
|
test('cleanupStaleTempFiles skips paths with active uploads', () async {
|
|
final stale = File('${cacheDir.path}/view_intent_stale.jpg')..writeAsStringSync('stale');
|
|
final active = File('${cacheDir.path}/view_intent_active.jpg')..writeAsStringSync('active');
|
|
service.markUploadActive(active.path);
|
|
|
|
await service.cleanupStaleTempFiles();
|
|
|
|
expect(await stale.exists(), isFalse);
|
|
expect(await active.exists(), isTrue);
|
|
});
|
|
}
|