This commit is contained in:
Peter Ombodi 2026-08-15 14:34:23 +02:00 committed by GitHub
commit 53546f0d49
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
31 changed files with 2043 additions and 99 deletions

View file

@ -22,6 +22,7 @@ import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
private const val TAG = "ViewIntentPlugin"
private const val VIEW_INTENT_UNAVAILABLE = "VIEW_INTENT_UNAVAILABLE"
class ViewIntentPlugin : FlutterPlugin, ActivityAware, PluginRegistry.NewIntentListener, ViewIntentHostApi {
private var context: Context? = null
@ -92,7 +93,15 @@ class ViewIntentPlugin : FlutterPlugin, ActivityAware, PluginRegistry.NewIntentL
val localAssetId = extractLocalAssetId(context, uri, mimeType)
val tempFilePath = if (localAssetId == null) {
copyUriToTempFile(context, uri, mimeType)?.absolutePath ?: run {
callback(Result.success(null))
consumeViewIntent(intent)
callback(
Result.failure(
FlutterError(
VIEW_INTENT_UNAVAILABLE,
"Unable to access the file referenced by the incoming view intent",
),
),
)
return@launch
}
} else {
@ -153,7 +162,8 @@ class ViewIntentPlugin : FlutterPlugin, ActivityAware, PluginRegistry.NewIntentL
}
} ?: return null
tempFile
} catch (_: Exception) {
} catch (e: Exception) {
Log.w(TAG, "Failed to materialize view intent URI as a temporary file", e)
null
}
}

View file

@ -13,6 +13,7 @@ import 'package:immich_mobile/repositories/asset_api.repository.dart';
import 'package:immich_mobile/repositories/asset_media.repository.dart';
import 'package:immich_mobile/utils/option.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
import 'package:stream_transform/stream_transform.dart';
class AssetService {
final RemoteAssetRepository _remoteRepository;
@ -37,8 +38,22 @@ class AssetService {
}
Stream<BaseAsset?> watchAsset(BaseAsset asset) {
final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).id;
return asset is LocalAsset ? _localRepository.watch(id) : _remoteRepository.watch(id);
if (asset is RemoteAsset) {
return _remoteRepository.watch(asset.id);
}
return _localRepository.watch((asset as LocalAsset).id).switchMap((localAsset) {
final remoteId = localAsset?.remoteId;
if (localAsset == null || remoteId == null) {
return Stream.value(localAsset);
}
return _remoteRepository.watch(remoteId).map((remoteAsset) => remoteAsset ?? localAsset);
});
}
Stream<RemoteAsset?> watchRemoteAsset(String id) {
return _remoteRepository.watch(id);
}
Future<List<LocalAsset?>> getLocalAssetsByChecksum(String checksum) {

View file

@ -37,6 +37,18 @@ enum TimelineOrigin {
recentlyAdded,
}
extension BaseAssetTimelineX on BaseAsset? {
// Whether this asset should currently be presented as trashed, given the timeline it's
// shown in. The full trash gallery has no single asset to check against; a deep-link
// timeline defers to the asset's own (live) trashed state instead, since — unlike
// origin that can change while the timeline is still open (e.g. after a restore).
bool isEffectivelyTrashed(TimelineOrigin origin) {
final asset = this;
return origin == TimelineOrigin.trash ||
(origin == TimelineOrigin.deepLink && asset is RemoteAsset && asset.isTrashed);
}
}
class TimelineFactory {
final DriftTimelineRepository _timelineRepository;
final SettingsRepository _settingsRepository;

View file

@ -10,6 +10,7 @@ import 'package:immich_mobile/providers/infrastructure/store.provider.dart';
import 'package:immich_mobile/providers/infrastructure/toast.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/providers/view_intent/view_intent_asset_action_coordinator.provider.dart';
import 'package:immich_mobile/services/cleanup.service.dart';
import 'package:immich_mobile/utils/error_handler.dart';
import 'package:immich_mobile/widgets/common/confirm_dialog.dart';
@ -48,6 +49,11 @@ class DeleteAction extends AssetActionBuilder {
@override
ActionItem? create(BuildContext context, WidgetRef ref) {
final viewIntentCoordinator = ref.watch(viewIntentAssetActionCoordinatorProvider);
if (!viewIntentCoordinator.canDelete(source)) {
return null;
}
final trash = ref.watch(_stateProvider(source).select((state) => state?.trash));
if (trash == null) {
return null;
@ -69,6 +75,7 @@ class DeleteAction extends AssetActionBuilder {
final (:localIds, :remoteIds, :trash) = state;
final toastService = ref.read(toastServiceProvider);
final clearSelection = ref.read(clearSelectionProvider(source));
final viewIntentCoordinator = ref.read(viewIntentAssetActionCoordinatorProvider);
try {
final String? message;
@ -86,6 +93,7 @@ class DeleteAction extends AssetActionBuilder {
toastService.success(message);
clearSelection();
await viewIntentCoordinator.afterDelete(source: source, remoteAssetIds: remoteIds, movedToTrash: trash);
} catch (error, stack) {
handleError(error, stack: stack, description: "Failed to delete assets");
}
@ -93,11 +101,11 @@ class DeleteAction extends AssetActionBuilder {
Future<String?> _removeLocalAssets(BuildContext context, WidgetRef ref, List<String> localIds) async {
final count = await _cleanupLocalAssets(context, ref, localIds);
if (count <= 0 || !context.mounted) {
if (count <= 0) {
return null;
}
return context.t.cleanup_deleted_assets(count: count);
return StaticTranslations.instance.cleanup_deleted_assets(count: count);
}
Future<String?> _moveToTrash(
@ -107,14 +115,11 @@ class DeleteAction extends AssetActionBuilder {
List<String> localIds,
) async {
final assetService = ref.read(assetServiceProvider);
final message = context.t.trash_action_prompt(count: remoteIds.length);
if (localIds.isNotEmpty) {
await _cleanupLocalAssets(context, ref, localIds);
if (!context.mounted) {
return null;
}
}
final message = context.t.trash_action_prompt(count: remoteIds.length);
await assetService.trash(remoteIds);
return message;
}

View file

@ -5,6 +5,7 @@ import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/presentation/actions/action.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/toast.provider.dart';
import 'package:immich_mobile/providers/view_intent/view_intent_asset_action_coordinator.provider.dart';
import 'package:immich_mobile/utils/error_handler.dart';
final _stateProvider = Provider.family.autoDispose<List<String>?, ActionSource>((ref, source) {
@ -35,11 +36,13 @@ class RestoreAction extends AssetActionBuilder {
final assetService = ref.read(assetServiceProvider);
final toastService = ref.read(toastServiceProvider);
final clearSelection = ref.read(clearSelectionProvider(source));
final viewIntentCoordinator = ref.read(viewIntentAssetActionCoordinatorProvider);
try {
await assetService.restoreTrash(assetIds);
toastService.success(message);
clearSelection();
await viewIntentCoordinator.afterRestore(source: source, remoteAssetIds: assetIds);
} catch (error, stack) {
handleError(error, stack: stack, description: "Failed to restore assets");
}

View file

@ -6,6 +6,7 @@ import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/presentation/actions/action.dart';
import 'package:immich_mobile/providers/asset_upload_coordinator.provider.dart';
import 'package:immich_mobile/providers/backup/asset_upload_progress.provider.dart';
import 'package:immich_mobile/providers/infrastructure/toast.provider.dart';
import 'package:immich_mobile/services/foreground_upload.service.dart';
@ -36,7 +37,7 @@ class UploadAction extends AssetActionBuilder {
Future<void> _upload(BuildContext context, WidgetRef ref, List<LocalAsset> assets) async {
try {
if (!showProgress) {
await uploadAssets(context, ref, assets);
await uploadAssets(context, ref, assets, source: source);
return;
}
@ -51,7 +52,7 @@ class UploadAction extends AssetActionBuilder {
).whenComplete(() => isDialogOpen = false),
);
await uploadAssets(context, ref, assets);
await uploadAssets(context, ref, assets, source: source);
if (isDialogOpen && context.mounted) {
Navigator.of(context, rootNavigator: true).pop();
@ -63,9 +64,14 @@ class UploadAction extends AssetActionBuilder {
}
@visibleForTesting
Future<void> uploadAssets(BuildContext context, WidgetRef ref, List<LocalAsset> assets) async {
Future<void> uploadAssets(
BuildContext context,
WidgetRef ref,
List<LocalAsset> assets, {
required ActionSource source,
}) async {
final progress = ref.read(assetUploadProgressProvider.notifier);
final uploads = ref.read(foregroundUploadServiceProvider);
final uploads = ref.read(assetUploadCoordinatorProvider);
final toastService = ref.read(toastServiceProvider);
final errorMessage = context.t.scaffold_body_error_occurred;
@ -79,8 +85,9 @@ Future<void> uploadAssets(BuildContext context, WidgetRef ref, List<LocalAsset>
}
try {
await uploads.uploadManual(
assets,
await uploads.upload(
source: source,
assets: assets,
cancelToken: cancelToken,
callbacks: UploadCallbacks(
onProgress: (id, _, bytes, total) => progress.setProgress(id, total > 0 ? bytes / total : 0.0),

View file

@ -412,13 +412,13 @@ class _AssetPageState extends ConsumerState<AssetPage> {
final timelineOrigin = ref.watch(timelineServiceProvider).origin;
final showingOcr = ref.watch(assetViewerProvider.select((s) => s.showingOcr));
final asset = _asset;
final asset = timelineOrigin == TimelineOrigin.deepLink && currentAsset != null ? currentAsset : _asset;
if (asset == null) {
return const Center(child: ImmichLoadingIndicator());
}
BaseAsset displayAsset = asset;
final showAssetStack = ref.watch(timelineServiceProvider.select((s) => s.origin != TimelineOrigin.trash));
final showAssetStack = !asset.isEffectivelyTrashed(timelineOrigin);
final stackChildren = showAssetStack ? ref.watch(stackChildrenNotifier(asset)).valueOrNull : null;
if (stackChildren != null && stackChildren.isNotEmpty) {
final safeStackIndex = stackIndex.clamp(0, stackChildren.length - 1);

View file

@ -17,7 +17,8 @@ class AssetStackRow extends ConsumerWidget {
return const SizedBox.shrink();
}
final hideAssetStack = ref.watch(timelineServiceProvider).origin == TimelineOrigin.trash;
final currentAsset = ref.watch(assetViewerProvider.select((s) => s.currentAsset));
final hideAssetStack = currentAsset.isEffectivelyTrashed(ref.watch(timelineServiceProvider).origin);
if (hideAssetStack) {
return const SizedBox.shrink();
}

View file

@ -24,6 +24,7 @@ import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'
import 'package:immich_mobile/providers/cast.provider.dart';
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/view_intent/active_view_intent_payload_provider.dart';
import 'package:immich_mobile/utils/system_ui.utils.dart';
import 'package:immich_mobile/widgets/photo_view/photo_view.dart';
@ -283,13 +284,17 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
if (index != _currentPage) {
_pageController.jumpToPage(index);
unawaited(_onAssetChanged(index));
} else if (currentAsset is RemoteAsset && currentAsset.stackId != null && assetIndex == null) {
final timelineAsset = timelineService.getAssetSafe(index);
if (timelineAsset is! RemoteAsset || currentAsset.stackId != timelineAsset.stackId) {
} else if (currentAsset != null &&
assetIndex == null &&
!_shouldIgnoreMissingAssetOnTimelineReload(currentAsset, timelineService)) {
if (currentAsset is RemoteAsset && currentAsset.stackId != null) {
final timelineAsset = timelineService.getAssetSafe(index);
if (timelineAsset is! RemoteAsset || currentAsset.stackId != timelineAsset.stackId) {
unawaited(_onAssetChanged(index));
}
} else {
unawaited(_onAssetChanged(index));
}
} else if (currentAsset != null && assetIndex == null) {
unawaited(_onAssetChanged(index));
}
if (_totalAssets != totalAssets) {
@ -299,6 +304,17 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
}
}
// A view intent can update currentAsset before the previous viewer route is
// disposed. Do not let the old viewer's timeline reload restore its previous asset.
bool _shouldIgnoreMissingAssetOnTimelineReload(BaseAsset currentAsset, TimelineService timelineService) {
if (timelineService.origin == TimelineOrigin.deepLink) {
return true;
}
final localAssetId = ref.read(activeViewIntentPayloadProvider)?.localAssetId;
return localAssetId != null && currentAsset.localId == localAssetId;
}
Future<void> _setSystemUIMode(bool controls, bool details) {
final immersive = !controls || (CurrentPlatform.isIOS && details);
return immersive ? SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky) : restoreEdgeToEdge();

View file

@ -40,7 +40,7 @@ class ViewerBottomBar extends ConsumerWidget {
final isReadonlyModeEnabled = ref.watch(readonlyModeProvider);
final showingDetails = ref.watch(assetViewerProvider.select((s) => s.showingDetails));
final isInLockedView = ref.watch(inLockedViewProvider);
final isInTrash = ref.watch(timelineServiceProvider).origin == TimelineOrigin.trash;
final isInTrash = asset.isEffectivelyTrashed(ref.watch(timelineServiceProvider).origin);
final originalTheme = context.themeData;

View file

@ -0,0 +1,161 @@
import 'dart:async';
import 'dart:io';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.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/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/view_intent/active_view_intent_payload_provider.dart';
import 'package:immich_mobile/providers/view_intent/view_intent_file_path.provider.dart';
import 'package:immich_mobile/services/foreground_upload.service.dart';
import 'package:immich_mobile/services/view_intent.service.dart';
import 'package:logging/logging.dart';
final assetUploadCoordinatorProvider = Provider(AssetUploadCoordinator.new);
class AssetUploadCoordinator {
const AssetUploadCoordinator(this._ref);
final Ref _ref;
static final Logger _logger = Logger('AssetUploadCoordinator');
Future<void> upload({
required ActionSource source,
required List<LocalAsset> assets,
required Completer<void> cancelToken,
required UploadCallbacks callbacks,
}) async {
final activeViewIntent = source == ActionSource.viewer ? _ref.read(activeViewIntentPayloadProvider) : null;
final viewIntentFilePath = source == ActionSource.viewer ? _ref.read(viewIntentFilePathProvider) : null;
if (viewIntentFilePath == null) {
final viewerAsset = source == ActionSource.viewer && assets.length == 1 ? assets.single : null;
String? uploadedRemoteAssetId;
final viewerNotifier = _ref.read(assetViewerProvider.notifier);
await _ref
.read(foregroundUploadServiceProvider)
.uploadManual(
assets,
cancelToken: cancelToken,
callbacks: UploadCallbacks(
onProgress: callbacks.onProgress,
onSuccess: (localId, remoteId) {
if (localId == viewerAsset?.localId) {
uploadedRemoteAssetId = remoteId;
}
callbacks.onSuccess?.call(localId, remoteId);
},
onError: callbacks.onError,
onICloudProgress: callbacks.onICloudProgress,
),
);
final remoteAssetId = uploadedRemoteAssetId;
if (viewerAsset == null || remoteAssetId == null || cancelToken.isCompleted) {
return;
}
final remoteAsset = await _waitForRemoteAsset(remoteAssetId);
final latestAsset = _ref.read(assetViewerProvider).currentAsset;
final isCurrentViewIntent =
activeViewIntent == null || identical(_ref.read(activeViewIntentPayloadProvider), activeViewIntent);
if (remoteAsset == null ||
latestAsset == null ||
!latestAsset.refersToSameAsset(viewerAsset) ||
!isCurrentViewIntent) {
return;
}
viewerNotifier.setAsset(remoteAsset.copyWith(localId: viewerAsset.id));
return;
}
if (assets.length != 1) {
throw StateError('A file-backed viewer upload requires exactly one asset.');
}
_logger.fine('Using file-backed upload for view intent');
await _uploadViewIntentFile(
asset: assets.single,
path: viewIntentFilePath,
activeViewIntent: activeViewIntent,
cancelToken: cancelToken,
callbacks: callbacks,
);
}
Future<void> _uploadViewIntentFile({
required LocalAsset asset,
required String path,
required ViewIntentPayload? activeViewIntent,
required Completer<void> cancelToken,
required UploadCallbacks callbacks,
}) async {
final viewIntentService = _ref.read(viewIntentServiceProvider);
String? remoteAssetId;
viewIntentService.markUploadActive(path);
try {
await _ref
.read(foregroundUploadServiceProvider)
.uploadShareIntent(
[File(path)],
cancelToken: cancelToken,
onProgress: (_, bytes, total) => callbacks.onProgress?.call(asset.id, asset.name, bytes, total),
onSuccess: (_, remoteId) {
remoteAssetId = remoteId;
callbacks.onSuccess?.call(asset.id, remoteId);
},
onError: (_, error) => callbacks.onError?.call(asset.id, error),
);
final uploadedRemoteAssetId = remoteAssetId;
if (cancelToken.isCompleted || uploadedRemoteAssetId == null) {
return;
}
final remoteAsset = await _waitForRemoteAsset(uploadedRemoteAssetId);
if (remoteAsset == null || !_isCurrentUpload(asset, path, activeViewIntent)) {
return;
}
_ref.read(assetViewerProvider.notifier).setAsset(remoteAsset);
_ref.read(viewIntentFilePathProvider.notifier).clearIfMatch(path);
await viewIntentService.cleanupManagedTempFileIfCurrent(path);
} finally {
await viewIntentService.markUploadInactive(path);
}
}
Future<RemoteAsset?> _waitForRemoteAsset(String remoteAssetId) async {
try {
return await _ref
.read(assetServiceProvider)
.watchRemoteAsset(remoteAssetId)
.where((asset) => asset != null)
.cast<RemoteAsset>()
.first
.timeout(const Duration(seconds: 15));
} on TimeoutException {
final asset = await _ref.read(assetServiceProvider).getRemoteAsset(remoteAssetId);
_logger.warning(
'Timed out waiting for uploaded asset $remoteAssetId; direct lookup ${asset == null ? 'failed' : 'succeeded'}',
);
return asset;
}
}
bool _isCurrentUpload(LocalAsset asset, String path, ViewIntentPayload? activeViewIntent) {
if (activeViewIntent != null && !identical(_ref.read(activeViewIntentPayloadProvider), activeViewIntent)) {
return false;
}
if (_ref.read(viewIntentFilePathProvider) != path) {
return false;
}
final currentAsset = _ref.read(assetViewerProvider).currentAsset;
return currentAsset != null && currentAsset.refersToSameAsset(asset);
}
}

View file

@ -243,6 +243,7 @@ class ActionNotifier extends Notifier<void> {
count: successCount,
success: isSuccess,
error: isSuccess ? null : 'Failed to upload ${assetsToUpload.length - successCount} assets',
remoteAssetIds: remoteAssetIds,
);
} catch (error, stack) {
_logger.severe('Failed manually upload assets', error, stack);

View file

@ -0,0 +1,26 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/platform/view_intent_api.g.dart';
class ActiveViewIntentNotifier extends Notifier<ViewIntentPayload?> {
@override
ViewIntentPayload? build() => null;
void setPayload(ViewIntentPayload payload) {
state = payload;
}
void clear() {
state = null;
}
void clearIfMatch(ViewIntentPayload payload) {
if (!identical(state, payload)) {
return;
}
state = null;
}
}
final activeViewIntentPayloadProvider = NotifierProvider<ActiveViewIntentNotifier, ViewIntentPayload?>(
ActiveViewIntentNotifier.new,
);

View file

@ -0,0 +1,105 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.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/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/view_intent/active_view_intent_payload_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_trash_scope.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:logging/logging.dart';
final viewIntentAssetActionCoordinatorProvider = Provider(
(ref) {
return ViewIntentAssetActionCoordinator(
ref,
viewIntent: ref.watch(activeViewIntentPayloadProvider),
isFileBacked: ref.watch(viewIntentFilePathProvider) != null,
);
},
dependencies: [
assetViewerProvider,
timelineServiceProvider,
activeViewIntentPayloadProvider,
viewIntentFilePathProvider,
viewIntentTrashScopeProvider,
],
);
class ViewIntentAssetActionCoordinator {
const ViewIntentAssetActionCoordinator(this._ref, {required this._viewIntent, required this._isFileBacked});
final Ref _ref;
final ViewIntentPayload? _viewIntent;
final bool _isFileBacked;
static final Logger _logger = Logger('ViewIntentAssetActionCoordinator');
bool canDelete(ActionSource source) => source != ActionSource.viewer || !_isFileBacked;
Future<void> afterDelete({
required ActionSource source,
required List<String> remoteAssetIds,
required bool movedToTrash,
}) => _runBestEffort('post-delete view intent transition', () async {
if (!_isActiveViewIntent(source, requireTrash: false)) {
return;
}
if (remoteAssetIds.isNotEmpty) {
if (movedToTrash) {
await _reopen(remoteAssetIds.single);
} else {
await _ref.read(appRouterProvider).maybePop();
}
return;
}
final asset = _ref.read(assetViewerProvider).currentAsset;
if (asset != null && !asset.hasRemote) {
await _ref.read(appRouterProvider).maybePop();
}
});
Future<void> afterRestore({required ActionSource source, required List<String> remoteAssetIds}) =>
_runBestEffort('post-restore view intent transition', () async {
if (!_isActiveViewIntent(source, requireTrash: true)) {
return;
}
await _reopen(remoteAssetIds.single);
});
bool _isActiveViewIntent(ActionSource source, {required bool requireTrash}) {
if (source != ActionSource.viewer || _viewIntent == null) {
return false;
}
if (!identical(_ref.read(activeViewIntentPayloadProvider), _viewIntent)) {
return false;
}
final origin = _ref.read(timelineServiceProvider).origin;
if (origin != TimelineOrigin.deepLink) {
return false;
}
return requireTrash ? _ref.read(viewIntentTrashScopeProvider) : true;
}
Future<void> _reopen(String remoteAssetId) async {
final reopened = await _ref.read(viewIntentHandlerProvider).reopenRemoteAsset(remoteAssetId);
if (!reopened) {
_logger.warning('Unable to reopen remote view intent asset $remoteAssetId');
}
}
Future<void> _runBestEffort(String operation, Future<void> Function() transition) async {
try {
await transition();
} catch (error, stackTrace) {
_logger.warning('Failed to complete $operation', error, stackTrace);
}
}
}

View file

@ -11,7 +11,9 @@ abstract class ViewIntentHandler {
Future<void> flushDeferredViewIntent();
Future<void> handle(ViewIntentPayload attachment);
Future<void> handle(ViewIntentPayload payload);
Future<bool> reopenRemoteAsset(String remoteAssetId);
}
final viewIntentHandlerProvider = Provider<ViewIntentHandler>((ref) {

View file

@ -1,15 +1,21 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
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/generated/translations.g.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/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/infrastructure/toast.provider.dart';
import 'package:immich_mobile/providers/view_intent/active_view_intent_payload_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_pending.provider.dart';
import 'package:immich_mobile/providers/view_intent/view_intent_trash_scope.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/services/view_intent.service.dart';
import 'package:immich_mobile/services/view_intent_asset_resolver.service.dart';
@ -41,7 +47,19 @@ class AndroidViewIntentHandler implements ViewIntentHandler {
Future<void> flushDeferredViewIntent() => _flushPending();
Future<void> _checkForViewIntent() async {
final attachment = await _viewIntentService.consumeViewIntent();
final ViewIntentPayload? attachment;
try {
attachment = await _viewIntentService.consumeViewIntent();
} on PlatformException catch (error, stackTrace) {
if (error.code != viewIntentUnavailableErrorCode) {
rethrow;
}
_logger.warning('Incoming view intent is unavailable', error, stackTrace);
_ref.read(toastServiceProvider).error(StaticTranslations.instance.asset_not_found_on_device_android);
await _router.replaceAll([const TabShellRoute()]);
return;
}
if (attachment != null) {
await handle(attachment);
return;
@ -61,26 +79,93 @@ class AndroidViewIntentHandler implements ViewIntentHandler {
}
@override
Future<void> handle(ViewIntentPayload attachment) async {
Future<void> handle(ViewIntentPayload payload) async {
_logger.info(
'handle attachment, mimeType:${attachment.mimeType}, localAssetId=${attachment.localAssetId}, path=${attachment.path}, isAuthenticated:${_ref.read(authProvider).isAuthenticated}',
'handle attachment, mimeType:${payload.mimeType}, localAssetId=${payload.localAssetId}, path=${payload.path}, isAuthenticated:${_ref.read(authProvider).isAuthenticated}',
);
if (!_ref.read(authProvider).isAuthenticated) {
_ref.read(viewIntentPendingProvider.notifier).defer(attachment);
_clearCurrentViewIntent();
_ref.read(viewIntentPendingProvider.notifier).defer(payload);
return;
}
final resolvedAsset = await _viewIntentAssetResolver.resolve(attachment);
_activateViewIntent(payload);
final ViewIntentResolution resolvedAsset;
try {
resolvedAsset = await _viewIntentAssetResolver.resolve(payload);
} catch (_) {
_ref.read(activeViewIntentPayloadProvider.notifier).clearIfMatch(payload);
rethrow;
}
if (!identical(_ref.read(activeViewIntentPayloadProvider), payload)) {
await resolvedAsset.timelineService.dispose();
return;
}
_ref.read(viewIntentTrashScopeProvider.notifier).set(resolvedAsset.isTrashScoped);
_logger.fine('resolved view intent asset: ${resolvedAsset.asset}');
await _openAssetViewer(
resolvedAsset.asset,
resolvedAsset.timelineService,
asset: resolvedAsset.asset,
timelineService: resolvedAsset.timelineService,
attachment: payload,
viewIntentFilePath: resolvedAsset.viewIntentFilePath,
);
}
Future<void> _openAssetViewer(BaseAsset asset, TimelineService timelineService, {String? viewIntentFilePath}) async {
@override
Future<bool> reopenRemoteAsset(String remoteAssetId) async {
final attachment = _ref.read(activeViewIntentPayloadProvider);
if (attachment == null) {
return false;
}
final asset = await _ref.read(assetServiceProvider).getRemoteAsset(remoteAssetId);
if (asset == null || !identical(_ref.read(activeViewIntentPayloadProvider), attachment)) {
return false;
}
final reopenedAttachment = ViewIntentPayload(
path: attachment.path,
mimeType: attachment.mimeType,
localAssetId: attachment.localAssetId,
);
_activateViewIntent(reopenedAttachment);
_ref.read(viewIntentTrashScopeProvider.notifier).set(asset.isTrashed);
final timelineService = _ref.read(timelineFactoryProvider).fromAssets([asset], TimelineOrigin.deepLink);
unawaited(
_openAssetViewer(asset: asset, timelineService: timelineService, attachment: reopenedAttachment).catchError((
Object error,
StackTrace stackTrace,
) {
_logger.severe('Failed to reopen remote view intent asset', error, stackTrace);
}),
);
return true;
}
void _activateViewIntent(ViewIntentPayload attachment) {
_ref.read(activeViewIntentPayloadProvider.notifier).setPayload(attachment);
_ref.read(viewIntentFilePathProvider.notifier).clear();
unawaited(_viewIntentService.cleanupManagedTempFile());
_router.popUntilRoot();
}
void _clearCurrentViewIntent() {
_ref.read(activeViewIntentPayloadProvider.notifier).clear();
_ref.read(viewIntentFilePathProvider.notifier).clear();
_ref.read(viewIntentTrashScopeProvider.notifier).clear();
unawaited(_viewIntentService.cleanupManagedTempFile());
}
Future<void> _openAssetViewer({
required BaseAsset asset,
required TimelineService timelineService,
required ViewIntentPayload attachment,
String? viewIntentFilePath,
}) async {
final notifier = _ref.read(assetViewerProvider.notifier);
notifier.reset();
if (asset.isVideo) {
@ -96,9 +181,14 @@ class AndroidViewIntentHandler implements ViewIntentHandler {
unawaited(_viewIntentService.cleanupManagedTempFile());
}
await _router.replaceAll([
const TabShellRoute(),
AssetViewerRoute(key: UniqueKey(), initialIndex: 0, timelineService: timelineService),
]);
try {
await _router.push(AssetViewerRoute(initialIndex: 0, timelineService: timelineService));
} finally {
_ref.read(activeViewIntentPayloadProvider.notifier).clearIfMatch(attachment);
if (viewIntentFilePath != null) {
_ref.read(viewIntentFilePathProvider.notifier).clearIfMatch(viewIntentFilePath);
await _viewIntentService.cleanupManagedTempFileIfCurrent(viewIntentFilePath);
}
}
}
}

View file

@ -14,5 +14,8 @@ class StubViewIntentHandler implements ViewIntentHandler {
Future<void> flushDeferredViewIntent() async {}
@override
Future<void> handle(ViewIntentPayload attachment) async {}
Future<void> handle(ViewIntentPayload payload) async {}
@override
Future<bool> reopenRemoteAsset(String remoteAssetId) async => false;
}

View file

@ -0,0 +1,14 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
class ViewIntentTrashScopeNotifier extends Notifier<bool> {
@override
bool build() => false;
void set(bool isTrashScoped) => state = isTrashScoped;
void clear() => state = false;
}
final viewIntentTrashScopeProvider = NotifierProvider<ViewIntentTrashScopeNotifier, bool>(
ViewIntentTrashScopeNotifier.new,
);

View file

@ -1,5 +1,6 @@
import 'dart:io';
import 'package:flutter/services.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/platform/view_intent_api.g.dart';
import 'package:path/path.dart' as p;
@ -7,6 +8,8 @@ import 'package:path_provider/path_provider.dart';
final viewIntentServiceProvider = Provider((ref) => ViewIntentService(ViewIntentHostApi()));
const viewIntentUnavailableErrorCode = 'VIEW_INTENT_UNAVAILABLE';
class ViewIntentService {
final ViewIntentHostApi _viewIntentHostApi;
final Future<Directory> Function() _temporaryDirectory;
@ -19,6 +22,12 @@ class ViewIntentService {
Future<ViewIntentPayload?> consumeViewIntent() async {
try {
return await _viewIntentHostApi.consumeViewIntent();
} on PlatformException catch (error) {
if (error.code == viewIntentUnavailableErrorCode) {
rethrow;
}
// Ignore errors - view intent might not be present
return null;
} catch (_) {
// Ignore errors - view intent might not be present
return null;
@ -26,11 +35,12 @@ class ViewIntentService {
}
Future<void> setManagedTempFilePath(String path) async {
final normalizedPath = p.normalize(path);
final previous = _managedTempFilePath;
if (previous == path) {
if (previous == normalizedPath) {
return;
}
_managedTempFilePath = path;
_managedTempFilePath = normalizedPath;
if (previous != null) {
await cleanupTempFile(previous);
}
@ -45,22 +55,24 @@ class ViewIntentService {
}
Future<void> cleanupManagedTempFileIfCurrent(String path) async {
if (_managedTempFilePath == path) {
final normalizedPath = p.normalize(path);
if (_managedTempFilePath == normalizedPath) {
_managedTempFilePath = null;
}
await cleanupTempFile(path);
await cleanupTempFile(normalizedPath);
}
Future<void> cleanupTempFile(String path) async {
if (!_isManagedTempFile(path)) {
final normalizedPath = p.normalize(path);
if (!_isManagedTempFile(normalizedPath)) {
return;
}
if (_activeUploadPaths.contains(path)) {
if (_activeUploadPaths.contains(normalizedPath)) {
return;
}
try {
final file = File(path);
final file = File(normalizedPath);
// ignore: avoid_slow_async_io
if (await file.exists()) {
await file.delete();
@ -78,7 +90,7 @@ class ViewIntentService {
continue;
}
final path = entity.path;
final path = p.normalize(entity.path);
if (!_isManagedTempFile(path) || path == _managedTempFilePath || _activeUploadPaths.contains(path)) {
continue;
}
@ -91,15 +103,16 @@ class ViewIntentService {
}
void markUploadActive(String path) {
_activeUploadPaths.add(path);
_activeUploadPaths.add(p.normalize(path));
}
Future<void> markUploadInactive(String path) async {
if (!_activeUploadPaths.remove(path)) {
final normalizedPath = p.normalize(path);
if (!_activeUploadPaths.remove(normalizedPath)) {
return;
}
if (_managedTempFilePath != path) {
await cleanupTempFile(path);
if (_managedTempFilePath != normalizedPath) {
await cleanupTempFile(normalizedPath);
}
}

View file

@ -1,37 +1,55 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/services/asset.service.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/infrastructure/repositories/local_asset.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:logging/logging.dart';
class ViewIntentResolvedAsset {
class ViewIntentResolution {
final BaseAsset asset;
final TimelineService timelineService;
final bool isTrashScoped;
final String? viewIntentFilePath;
const ViewIntentResolvedAsset({required this.asset, required this.timelineService, this.viewIntentFilePath});
const ViewIntentResolution({
required this.asset,
required this.timelineService,
this.isTrashScoped = false,
this.viewIntentFilePath,
});
}
final viewIntentAssetResolverProvider = Provider<ViewIntentAssetResolver>(
(ref) => ViewIntentAssetResolver(
localAssetRepository: ref.read(localAssetRepository),
assetService: ref.read(assetServiceProvider),
nativeSyncApi: ref.read(nativeSyncApiProvider),
timelineFactory: ref.read(timelineFactoryProvider),
),
);
class ViewIntentAssetResolver {
final DriftLocalAssetRepository _localAssetRepository;
final AssetService _assetService;
final NativeSyncApi _nativeSyncApi;
final TimelineFactory _timelineFactory;
static final Logger _logger = Logger('ViewIntentAssetResolver');
const ViewIntentAssetResolver({required this._localAssetRepository, required this._timelineFactory});
const ViewIntentAssetResolver({
required this._localAssetRepository,
required this._assetService,
required this._nativeSyncApi,
required this._timelineFactory,
});
Future<ViewIntentResolvedAsset> resolve(ViewIntentPayload attachment) async {
Future<ViewIntentResolution> resolve(ViewIntentPayload attachment) async {
final localAssetId = attachment.localAssetId;
final path = attachment.path;
_logger.fine('resolve start, localAssetId=$localAssetId, path=$path, mimeType=${attachment.mimeType}');
@ -40,21 +58,116 @@ class ViewIntentAssetResolver {
throw StateError('ViewIntent resolution requires either a localAssetId or a materialized file path.');
}
final localAsset = localAssetId != null ? await _localAssetRepository.getById(localAssetId) : null;
final asset = localAsset ?? _toTransientAsset(attachment);
({LocalAsset? asset, String? checksum}) resolvedLocal = (asset: null, checksum: null);
if (localAssetId != null) {
resolvedLocal = await _resolveLocalAsset(localAssetId);
final remoteAsset = await _resolveRemoteAsset(
localAssetId,
remoteAssetId: resolvedLocal.asset?.remoteId,
checksum: resolvedLocal.checksum,
);
if (remoteAsset != null) {
return ViewIntentResolution(
asset: remoteAsset,
timelineService: _timelineFor(remoteAsset),
isTrashScoped: remoteAsset.isTrashed,
);
}
}
return ViewIntentResolvedAsset(
final asset = resolvedLocal.asset ?? _toTransientAsset(attachment, resolvedLocal.checksum);
return ViewIntentResolution(
asset: asset,
timelineService: _timelineFactory.fromAssets([asset], TimelineOrigin.deepLink),
viewIntentFilePath: localAsset == null ? path : null,
timelineService: _timelineFor(asset),
viewIntentFilePath: resolvedLocal.asset == null ? path : null,
);
}
LocalAsset _toTransientAsset(ViewIntentPayload attachment) {
TimelineService _timelineFor(BaseAsset asset) => _timelineFactory.fromAssets([asset], TimelineOrigin.deepLink);
Future<({LocalAsset? asset, String? checksum})> _resolveLocalAsset(String localAssetId) async {
final localAsset = await _localAssetRepository.get(localAssetId);
final checksum = localAsset?.checksum ?? await _hashLocalAsset(localAssetId);
if (checksum == null || checksum == localAsset?.checksum) {
return (asset: localAsset, checksum: checksum);
}
if (localAsset != null) {
await _localAssetRepository.updateHashes({localAssetId: checksum});
final resolvedAsset = await _localAssetRepository.get(localAssetId);
return (asset: resolvedAsset ?? localAsset.copyWith(checksum: checksum), checksum: checksum);
}
return (asset: null, checksum: checksum);
}
Future<String?> _hashLocalAsset(String localAssetId) async {
try {
final hashResults = await _nativeSyncApi.hashAssets([localAssetId]);
if (hashResults.isEmpty) {
return null;
}
final result = hashResults.first;
if (result.error != null) {
_logger.warning('Failed to hash view intent local asset $localAssetId: ${result.error}');
return null;
}
return result.hash;
} catch (error, stackTrace) {
_logger.warning('Failed to hash view intent local asset $localAssetId', error, stackTrace);
return null;
}
}
Future<RemoteAsset?> _resolveRemoteAsset(
String localAssetId, {
required String? remoteAssetId,
required String? checksum,
}) async {
RemoteAsset? remoteAsset;
if (remoteAssetId != null) {
remoteAsset = await _assetService.getRemoteAsset(remoteAssetId);
if (remoteAsset != null) {
_logger.fine('resolve matched remote asset by id: $remoteAssetId, asset=$remoteAsset');
}
}
if (remoteAsset == null && checksum != null) {
final candidates = await _assetService.getAllRemoteAssetDebugByChecksum(checksum);
if (candidates.isNotEmpty) {
remoteAsset = ([...candidates]..sort(_compareRemoteAssetCandidates)).first;
_logger.fine('resolve matched remote asset by checksum: $checksum, asset=$remoteAsset');
}
}
if (remoteAsset == null) {
return null;
}
final asset = remoteAsset.copyWith(localId: localAssetId);
return asset;
}
static int _compareRemoteAssetCandidates(RemoteAsset first, RemoteAsset second) {
if (first.isTrashed != second.isTrashed) {
return first.isTrashed ? 1 : -1;
}
final firstDate = first.uploadedAt ?? first.createdAt;
final secondDate = second.uploadedAt ?? second.createdAt;
final date = secondDate.compareTo(firstDate);
return date != 0 ? date : first.id.compareTo(second.id);
}
LocalAsset _toTransientAsset(ViewIntentPayload attachment, String? checksum) {
final now = DateTime.now();
// A FileBackedAsset could model the path more explicitly, but would require broader changes to the asset hierarchy.
return LocalAsset(
id: attachment.localAssetId ?? '-${attachment.path!.hashCode.abs()}',
name: attachment.fileName,
checksum: checksum,
type: attachment.isVideo ? AssetType.video : AssetType.image,
createdAt: now,
updatedAt: now,

View file

@ -109,7 +109,7 @@ enum ActionButtonType {
context.isOwner && //
!context.isInLockedView && //
context.asset.hasRemote && //
context.timelineOrigin == TimelineOrigin.trash,
context.asset.isEffectivelyTrashed(context.timelineOrigin),
ActionButtonType.delete => true,
ActionButtonType.moveToLockFolder =>
context.isOwner && //
@ -135,12 +135,13 @@ enum ActionButtonType {
context.selectedCount == 1,
ActionButtonType.unstack =>
context.isOwner && //
context.timelineOrigin != TimelineOrigin.trash &&
!context.asset.isEffectivelyTrashed(context.timelineOrigin) &&
!context.isInLockedView && //
context.isStacked,
ActionButtonType.openInBrowser => context.asset.hasRemote && !context.isInLockedView,
ActionButtonType.likeActivity =>
!context.isInLockedView &&
context.timelineOrigin != TimelineOrigin.deepLink &&
context.currentAlbum != null &&
context.currentAlbum!.isActivityEnabled &&
context.currentAlbum!.isShared,
@ -154,8 +155,8 @@ enum ActionButtonType {
ActionButtonType.openInfo => true,
ActionButtonType.viewInTimeline =>
context.timelineOrigin != TimelineOrigin.main &&
!context.asset.isEffectivelyTrashed(context.timelineOrigin) &&
context.timelineOrigin != TimelineOrigin.deepLink &&
context.timelineOrigin != TimelineOrigin.trash &&
context.timelineOrigin != TimelineOrigin.lockedFolder &&
context.timelineOrigin != TimelineOrigin.archive &&
context.timelineOrigin != TimelineOrigin.localAlbum &&

View file

@ -0,0 +1,106 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/locales.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/events.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/utils/event_stream.dart';
import 'package:immich_mobile/generated/codegen_loader.g.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.page.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:intl/date_symbol_data_local.dart';
import 'package:mocktail/mocktail.dart';
import '../../../fixtures/asset.stub.dart';
import '../../../unit/presentation/presentation_context.dart';
final _uploadedAsset = RemoteAsset(
id: 'remote-id',
name: 'uploaded.jpg',
ownerId: 'owner-id',
checksum: 'remote-checksum',
type: AssetType.image,
createdAt: DateTime(2026),
updatedAt: DateTime(2026),
isEdited: false,
);
class _UploadedAssetViewerNotifier extends AssetViewerStateNotifier {
@override
AssetViewerState build() {
super.build();
return AssetViewerState(currentAsset: _uploadedAsset);
}
}
TimelineService _viewIntentTimeline() {
return TimelineService((
assetSource: (_, __) async => [LocalAssetStub.image1],
bucketSource: () => Stream.value(const [Bucket(assetCount: 1)]),
origin: TimelineOrigin.deepLink,
));
}
void main() {
late PresentationContext presentationContext;
setUp(() async {
await initializeDateFormatting();
presentationContext = await PresentationContext.create();
when(() => presentationContext.service.asset.service.watchAsset(any())).thenAnswer((_) => const Stream.empty());
});
tearDown(() async {
await presentationContext.dispose();
});
testWidgets('keeps uploaded remote asset when it is missing from a deep-link timeline reload', (tester) async {
final timeline = _viewIntentTimeline();
addTearDown(timeline.dispose);
late ProviderContainer container;
await tester.pumpWidget(
EasyLocalization(
supportedLocales: locales.values.toList(),
path: translationsPath,
startLocale: locales.values.first,
fallbackLocale: locales.values.first,
saveLocale: false,
useFallbackTranslations: true,
assetLoader: const CodegenLoader(),
child: ProviderScope(
overrides: [
...presentationContext.overrides,
timelineServiceProvider.overrideWithValue(timeline),
assetViewerProvider.overrideWith(_UploadedAssetViewerNotifier.new),
],
child: Builder(
builder: (context) {
container = ProviderScope.containerOf(context);
return MaterialApp(
localizationsDelegates: context.localizationDelegates,
supportedLocales: context.supportedLocales,
locale: context.locale,
home: const Material(child: AssetViewer(initialIndex: 0)),
);
},
),
),
),
);
await tester.pump();
await tester.pump(const Duration(milliseconds: 600));
tester.takeException();
EventStream.shared.emit(const TimelineReloadEvent());
await tester.pump();
await tester.pump();
tester.takeException();
expect(container.read(assetViewerProvider).currentAsset, same(_uploadedAsset));
});
}

View file

@ -0,0 +1,365 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/platform/view_intent_api.g.dart';
import 'package:immich_mobile/providers/asset_upload_coordinator.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/view_intent/active_view_intent_payload_provider.dart';
import 'package:immich_mobile/providers/view_intent/view_intent_file_path.provider.dart';
import 'package:immich_mobile/services/foreground_upload.service.dart';
import 'package:immich_mobile/services/view_intent.service.dart';
import 'package:mocktail/mocktail.dart';
import '../service.mocks.dart';
import '../unit/factories/local_asset_factory.dart';
import '../unit/factories/remote_asset_factory.dart';
class MockViewIntentService extends Mock implements ViewIntentService {}
void main() {
late ProviderContainer container;
late MockForegroundUploadService uploadService;
late MockAssetService assetService;
late MockViewIntentService viewIntentService;
setUpAll(() {
registerFallbackValue(LocalAssetFactory.create());
registerFallbackValue(const UploadCallbacks());
});
setUp(() {
uploadService = MockForegroundUploadService();
assetService = MockAssetService();
viewIntentService = MockViewIntentService();
when(() => assetService.watchAsset(any())).thenAnswer((_) => const Stream.empty());
container = ProviderContainer(
overrides: [
foregroundUploadServiceProvider.overrideWithValue(uploadService),
assetServiceProvider.overrideWithValue(assetService),
viewIntentServiceProvider.overrideWithValue(viewIntentService),
],
);
addTearDown(container.dispose);
});
test('replaces a device-backed viewer asset after upload when the local database cannot link it', () async {
final localAsset = LocalAssetFactory.create(id: 'local-outside-backup');
final remoteAsset = RemoteAssetFactory.create(id: 'remote-outside-backup');
container.read(assetViewerProvider.notifier).setAsset(localAsset);
when(() => assetService.watchRemoteAsset(remoteAsset.id)).thenAnswer((_) => Stream.value(remoteAsset));
when(
() => uploadService.uploadManual(
any(),
cancelToken: any(named: 'cancelToken'),
callbacks: any(named: 'callbacks'),
),
).thenAnswer((invocation) async {
final callbacks = invocation.namedArguments[#callbacks] as UploadCallbacks;
callbacks.onSuccess?.call(localAsset.id, remoteAsset.id);
});
await container
.read(assetUploadCoordinatorProvider)
.upload(
source: ActionSource.viewer,
assets: [localAsset],
cancelToken: Completer<void>(),
callbacks: const UploadCallbacks(),
);
final currentAsset = container.read(assetViewerProvider).currentAsset;
expect(currentAsset, isA<RemoteAsset>());
expect(currentAsset?.remoteId, remoteAsset.id);
expect(currentAsset?.localId, localAsset.id);
expect(currentAsset?.isMerged, isTrue);
});
test('does not let a device-backed upload from an older view intent replace the viewer', () async {
final oldPayload = ViewIntentPayload(path: '/tmp/old.jpg', mimeType: 'image/jpeg', localAssetId: 'local-old');
final newPayload = ViewIntentPayload(path: '/tmp/new.jpg', mimeType: 'image/jpeg', localAssetId: 'local-new');
final localAsset = LocalAssetFactory.create(id: 'local-old');
final remoteAsset = RemoteAssetFactory.create(id: 'remote-old');
final remoteController = StreamController<RemoteAsset?>.broadcast();
addTearDown(remoteController.close);
container.read(activeViewIntentPayloadProvider.notifier).setPayload(oldPayload);
container.read(assetViewerProvider.notifier).setAsset(localAsset);
when(() => assetService.watchRemoteAsset(remoteAsset.id)).thenAnswer((_) => remoteController.stream);
when(
() => uploadService.uploadManual(
any(),
cancelToken: any(named: 'cancelToken'),
callbacks: any(named: 'callbacks'),
),
).thenAnswer((invocation) async {
final callbacks = invocation.namedArguments[#callbacks] as UploadCallbacks;
callbacks.onSuccess?.call(localAsset.id, remoteAsset.id);
});
final upload = container
.read(assetUploadCoordinatorProvider)
.upload(
source: ActionSource.viewer,
assets: [localAsset],
cancelToken: Completer<void>(),
callbacks: const UploadCallbacks(),
);
await pumpEventQueue();
container.read(activeViewIntentPayloadProvider.notifier).setPayload(newPayload);
remoteController.add(remoteAsset);
await upload;
expect(container.read(assetViewerProvider).currentAsset, same(localAsset));
});
test('uploads a path-only viewer asset as a file and replaces it with the synchronized remote asset', () async {
const path = 'C:/cache/view_intent_1.jpg';
final localAsset = LocalAssetFactory.create(id: '-1');
final remoteAsset = RemoteAssetFactory.create(id: 'remote-1');
final progress = <(String, int, int)>[];
final succeeded = <(String, String)>[];
container.read(viewIntentFilePathProvider.notifier).setPath(path);
container.read(assetViewerProvider.notifier).setAsset(localAsset);
when(() => viewIntentService.markUploadActive(path)).thenReturn(null);
when(() => viewIntentService.cleanupManagedTempFileIfCurrent(path)).thenAnswer((_) async {});
when(() => viewIntentService.markUploadInactive(path)).thenAnswer((_) async {});
when(() => assetService.watchRemoteAsset('remote-1')).thenAnswer((_) => Stream.value(remoteAsset));
when(
() => uploadService.uploadShareIntent(
any(),
cancelToken: any(named: 'cancelToken'),
onProgress: any(named: 'onProgress'),
onSuccess: any(named: 'onSuccess'),
onError: any(named: 'onError'),
),
).thenAnswer((invocation) async {
final onProgress = invocation.namedArguments[#onProgress] as void Function(String, int, int)?;
final onSuccess = invocation.namedArguments[#onSuccess] as void Function(String, String)?;
onProgress?.call('file-id', 5, 10);
onSuccess?.call('file-id', 'remote-1');
});
await container
.read(assetUploadCoordinatorProvider)
.upload(
source: ActionSource.viewer,
assets: [localAsset],
cancelToken: Completer<void>(),
callbacks: UploadCallbacks(
onProgress: (id, _, bytes, total) => progress.add((id, bytes, total)),
onSuccess: (localId, remoteId) => succeeded.add((localId, remoteId)),
),
);
final files =
verify(
() => uploadService.uploadShareIntent(
captureAny(),
cancelToken: any(named: 'cancelToken'),
onProgress: any(named: 'onProgress'),
onSuccess: any(named: 'onSuccess'),
onError: any(named: 'onError'),
),
).captured.single
as List<File>;
expect(files.single.path, path);
expect(progress, [(localAsset.id, 5, 10)]);
expect(succeeded, [(localAsset.id, remoteAsset.id)]);
expect(container.read(assetViewerProvider).currentAsset, remoteAsset);
expect(container.read(viewIntentFilePathProvider), isNull);
verify(() => viewIntentService.markUploadActive(path)).called(1);
verify(() => viewIntentService.cleanupManagedTempFileIfCurrent(path)).called(1);
verify(() => viewIntentService.markUploadInactive(path)).called(1);
verifyNever(
() => uploadService.uploadManual(
any(),
cancelToken: any(named: 'cancelToken'),
callbacks: any(named: 'callbacks'),
),
);
});
test('keeps the path-only asset current when the upload is cancelled', () async {
const path = 'C:/cache/view_intent_cancelled.jpg';
final localAsset = LocalAssetFactory.create(id: '-2');
final cancelToken = Completer<void>();
container.read(viewIntentFilePathProvider.notifier).setPath(path);
container.read(assetViewerProvider.notifier).setAsset(localAsset);
when(() => viewIntentService.markUploadActive(path)).thenReturn(null);
when(() => viewIntentService.markUploadInactive(path)).thenAnswer((_) async {});
when(
() => uploadService.uploadShareIntent(
any(),
cancelToken: cancelToken,
onProgress: any(named: 'onProgress'),
onSuccess: any(named: 'onSuccess'),
onError: any(named: 'onError'),
),
).thenAnswer((invocation) async {
cancelToken.complete();
final onSuccess = invocation.namedArguments[#onSuccess] as void Function(String, String)?;
onSuccess?.call('file-id', 'remote-cancelled');
});
await container
.read(assetUploadCoordinatorProvider)
.upload(
source: ActionSource.viewer,
assets: [localAsset],
cancelToken: cancelToken,
callbacks: const UploadCallbacks(),
);
expect(container.read(assetViewerProvider).currentAsset, localAsset);
expect(container.read(viewIntentFilePathProvider), path);
verifyNever(() => assetService.watchRemoteAsset('remote-cancelled'));
verifyNever(() => viewIntentService.cleanupManagedTempFileIfCurrent(path));
verify(() => viewIntentService.markUploadInactive(path)).called(1);
});
test('reports a file upload error under the synthetic asset id and keeps the source file', () async {
const path = 'C:/cache/view_intent_failed.jpg';
final localAsset = LocalAssetFactory.create(id: '-5');
final errors = <(String, String)>[];
container.read(viewIntentFilePathProvider.notifier).setPath(path);
container.read(assetViewerProvider.notifier).setAsset(localAsset);
when(() => viewIntentService.markUploadActive(path)).thenReturn(null);
when(() => viewIntentService.markUploadInactive(path)).thenAnswer((_) async {});
when(
() => uploadService.uploadShareIntent(
any(),
cancelToken: any(named: 'cancelToken'),
onProgress: any(named: 'onProgress'),
onSuccess: any(named: 'onSuccess'),
onError: any(named: 'onError'),
),
).thenAnswer((invocation) async {
final onError = invocation.namedArguments[#onError] as void Function(String, String)?;
onError?.call('file-id', 'boom');
});
await container
.read(assetUploadCoordinatorProvider)
.upload(
source: ActionSource.viewer,
assets: [localAsset],
cancelToken: Completer<void>(),
callbacks: UploadCallbacks(onError: (id, error) => errors.add((id, error))),
);
expect(errors, [(localAsset.id, 'boom')]);
expect(container.read(assetViewerProvider).currentAsset, localAsset);
expect(container.read(viewIntentFilePathProvider), path);
verifyNever(() => viewIntentService.cleanupManagedTempFileIfCurrent(path));
verify(() => viewIntentService.markUploadInactive(path)).called(1);
});
test('does not let an older upload replace a newer view intent', () async {
const oldPath = 'C:/cache/view_intent_old.jpg';
const newPath = 'C:/cache/view_intent_new.jpg';
final oldAsset = LocalAssetFactory.create(id: '-3');
final newAsset = LocalAssetFactory.create(id: '-4');
final uploadedRemote = RemoteAssetFactory.create(id: 'remote-old');
final remoteController = StreamController<RemoteAsset?>.broadcast();
addTearDown(remoteController.close);
container.read(viewIntentFilePathProvider.notifier).setPath(oldPath);
container.read(assetViewerProvider.notifier).setAsset(oldAsset);
when(() => viewIntentService.markUploadActive(oldPath)).thenReturn(null);
when(() => viewIntentService.markUploadInactive(oldPath)).thenAnswer((_) async {});
when(() => assetService.watchRemoteAsset(uploadedRemote.id)).thenAnswer((_) => remoteController.stream);
when(
() => uploadService.uploadShareIntent(
any(),
cancelToken: any(named: 'cancelToken'),
onProgress: any(named: 'onProgress'),
onSuccess: any(named: 'onSuccess'),
onError: any(named: 'onError'),
),
).thenAnswer((invocation) async {
final onSuccess = invocation.namedArguments[#onSuccess] as void Function(String, String)?;
onSuccess?.call('file-id', uploadedRemote.id);
});
final upload = container
.read(assetUploadCoordinatorProvider)
.upload(
source: ActionSource.viewer,
assets: [oldAsset],
cancelToken: Completer<void>(),
callbacks: const UploadCallbacks(),
);
await pumpEventQueue();
container.read(viewIntentFilePathProvider.notifier).setPath(newPath);
container.read(assetViewerProvider.notifier).setAsset(newAsset);
remoteController.add(uploadedRemote);
await upload;
expect(container.read(assetViewerProvider).currentAsset, newAsset);
expect(container.read(viewIntentFilePathProvider), newPath);
verifyNever(() => viewIntentService.cleanupManagedTempFileIfCurrent(oldPath));
verify(() => viewIntentService.markUploadInactive(oldPath)).called(1);
});
test('does not let an older file upload replace a newer session for the same asset', () async {
const path = 'C:/cache/view_intent_same.jpg';
final oldPayload = ViewIntentPayload(path: path, mimeType: 'image/jpeg');
final newPayload = ViewIntentPayload(path: path, mimeType: 'image/jpeg');
final localAsset = LocalAssetFactory.create(id: '-6');
final uploadedRemote = RemoteAssetFactory.create(id: 'remote-same');
final remoteController = StreamController<RemoteAsset?>.broadcast();
addTearDown(remoteController.close);
container.read(activeViewIntentPayloadProvider.notifier).setPayload(oldPayload);
container.read(viewIntentFilePathProvider.notifier).setPath(path);
container.read(assetViewerProvider.notifier).setAsset(localAsset);
when(() => viewIntentService.markUploadActive(path)).thenReturn(null);
when(() => viewIntentService.cleanupManagedTempFileIfCurrent(path)).thenAnswer((_) async {});
when(() => viewIntentService.markUploadInactive(path)).thenAnswer((_) async {});
when(() => assetService.watchRemoteAsset(uploadedRemote.id)).thenAnswer((_) => remoteController.stream);
when(
() => uploadService.uploadShareIntent(
any(),
cancelToken: any(named: 'cancelToken'),
onProgress: any(named: 'onProgress'),
onSuccess: any(named: 'onSuccess'),
onError: any(named: 'onError'),
),
).thenAnswer((invocation) async {
final onSuccess = invocation.namedArguments[#onSuccess] as void Function(String, String)?;
onSuccess?.call('file-id', uploadedRemote.id);
});
final upload = container
.read(assetUploadCoordinatorProvider)
.upload(
source: ActionSource.viewer,
assets: [localAsset],
cancelToken: Completer<void>(),
callbacks: const UploadCallbacks(),
);
await pumpEventQueue();
container.read(activeViewIntentPayloadProvider.notifier).setPayload(newPayload);
remoteController.add(uploadedRemote);
await upload;
expect(container.read(assetViewerProvider).currentAsset, same(localAsset));
expect(container.read(viewIntentFilePathProvider), path);
verifyNever(() => viewIntentService.cleanupManagedTempFileIfCurrent(path));
});
}

View file

@ -0,0 +1,284 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.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/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/view_intent/active_view_intent_payload_provider.dart';
import 'package:immich_mobile/providers/view_intent/view_intent_asset_action_coordinator.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_trash_scope.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:mocktail/mocktail.dart';
import '../../unit/factories/local_asset_factory.dart';
import '../../unit/factories/remote_asset_factory.dart';
class _TestViewIntentHandler implements ViewIntentHandler {
_TestViewIntentHandler({this.reopenResult = true, this.reopenError});
final bool reopenResult;
final Object? reopenError;
final List<String> reopenedRemoteAssetIds = [];
@override
Future<void> flushDeferredViewIntent() async {}
@override
Future<void> handle(ViewIntentPayload payload) async {}
@override
void init() {}
@override
Future<void> onAppResumed() async {}
@override
Future<bool> reopenRemoteAsset(String remoteAssetId) async {
reopenedRemoteAssetIds.add(remoteAssetId);
if (reopenError case final error?) {
throw error;
}
return reopenResult;
}
}
class _MockAppRouter extends Mock implements AppRouter {}
class _ViewerNotifier extends AssetViewerStateNotifier {
_ViewerNotifier(this.asset);
final BaseAsset asset;
@override
AssetViewerState build() {
super.build();
return AssetViewerState(currentAsset: asset);
}
}
typedef _Harness = ({ViewIntentAssetActionCoordinator coordinator, _MockAppRouter router, ProviderContainer scope});
void main() {
late ProviderContainer container;
Future<_Harness> pumpHarness(
WidgetTester tester, {
required BaseAsset asset,
required TimelineOrigin origin,
required _TestViewIntentHandler handler,
bool activeViewIntent = true,
bool isTrashScoped = false,
}) async {
final timeline = TimelineService((
assetSource: (_, __) async => [asset],
bucketSource: () => Stream.value(const [Bucket(assetCount: 1)]),
origin: origin,
));
addTearDown(timeline.dispose);
final router = _MockAppRouter();
when(() => router.maybePop()).thenAnswer((_) async => true);
await tester.pumpWidget(
ProviderScope(
overrides: [
timelineServiceProvider.overrideWithValue(timeline),
assetViewerProvider.overrideWith(() => _ViewerNotifier(asset)),
viewIntentHandlerProvider.overrideWithValue(handler),
appRouterProvider.overrideWithValue(router),
],
child: const MaterialApp(
home: Scaffold(body: Text('root', key: Key('root'))),
),
),
);
await tester.pumpAndSettle();
final scope = ProviderScope.containerOf(tester.element(find.byKey(const Key('root'))), listen: false);
scope.read(viewIntentTrashScopeProvider.notifier).set(isTrashScoped);
if (activeViewIntent) {
scope
.read(activeViewIntentPayloadProvider.notifier)
.setPayload(
ViewIntentPayload(path: '/tmp/view-intent.jpg', mimeType: 'image/jpeg', localAssetId: asset.localId),
);
}
return (coordinator: scope.read(viewIntentAssetActionCoordinatorProvider), router: router, scope: scope);
}
setUp(() {
container = ProviderContainer();
});
tearDown(() {
container.dispose();
});
test('allows timeline deletion for a file-backed view intent', () {
container.read(viewIntentFilePathProvider.notifier).setPath('/tmp/materialized.jpg');
final coordinator = container.read(viewIntentAssetActionCoordinatorProvider);
expect(coordinator.canDelete(ActionSource.timeline), isTrue);
});
test('prevents viewer deletion for a file-backed view intent', () {
container.read(viewIntentFilePathProvider.notifier).setPath('/tmp/materialized.jpg');
final coordinator = container.read(viewIntentAssetActionCoordinatorProvider);
expect(coordinator.canDelete(ActionSource.viewer), isFalse);
});
test('allows viewer deletion after the file-backed path is cleared', () {
container.read(viewIntentFilePathProvider.notifier)
..setPath('/tmp/materialized.jpg')
..clear();
final coordinator = container.read(viewIntentAssetActionCoordinatorProvider);
expect(coordinator.canDelete(ActionSource.viewer), isTrue);
});
testWidgets('reopens a remote asset after it is moved to trash', (tester) async {
final asset = RemoteAssetFactory.create();
final handler = _TestViewIntentHandler();
final harness = await pumpHarness(tester, asset: asset, origin: TimelineOrigin.deepLink, handler: handler);
await harness.coordinator.afterDelete(source: ActionSource.viewer, remoteAssetIds: [asset.id], movedToTrash: true);
expect(handler.reopenedRemoteAssetIds, [asset.id]);
});
testWidgets('reopens a restored remote asset from a trash deep link', (tester) async {
final asset = RemoteAssetFactory.create(deletedAt: DateTime(2026, 8, 4));
final handler = _TestViewIntentHandler();
final harness = await pumpHarness(
tester,
asset: asset,
origin: TimelineOrigin.deepLink,
handler: handler,
isTrashScoped: true,
);
await harness.coordinator.afterRestore(source: ActionSource.viewer, remoteAssetIds: [asset.id]);
expect(handler.reopenedRemoteAssetIds, [asset.id]);
});
testWidgets('closes the viewer after permanently deleting a remote asset', (tester) async {
final asset = RemoteAssetFactory.create(deletedAt: DateTime(2026, 8, 4));
final handler = _TestViewIntentHandler();
final harness = await pumpHarness(tester, asset: asset, origin: TimelineOrigin.deepLink, handler: handler);
await harness.coordinator.afterDelete(source: ActionSource.viewer, remoteAssetIds: [asset.id], movedToTrash: false);
verify(() => harness.router.maybePop()).called(1);
});
testWidgets('does not apply a completed delete transition to a newer view intent', (tester) async {
final asset = RemoteAssetFactory.create(deletedAt: DateTime(2026, 8, 4));
final handler = _TestViewIntentHandler();
final harness = await pumpHarness(tester, asset: asset, origin: TimelineOrigin.deepLink, handler: handler);
final newerPayload = ViewIntentPayload(
path: '/tmp/newer-view-intent.jpg',
mimeType: 'image/jpeg',
localAssetId: 'newer-local',
);
harness.scope.read(activeViewIntentPayloadProvider.notifier).setPayload(newerPayload);
await harness.coordinator.afterDelete(source: ActionSource.viewer, remoteAssetIds: [asset.id], movedToTrash: false);
verifyNever(() => harness.router.maybePop());
expect(handler.reopenedRemoteAssetIds, isEmpty);
});
testWidgets('closes the viewer after deleting a local-only asset', (tester) async {
final asset = LocalAssetFactory.create();
final handler = _TestViewIntentHandler();
final harness = await pumpHarness(tester, asset: asset, origin: TimelineOrigin.deepLink, handler: handler);
await harness.coordinator.afterDelete(source: ActionSource.viewer, remoteAssetIds: const [], movedToTrash: false);
verify(() => harness.router.maybePop()).called(1);
});
testWidgets('keeps the viewer open after deleting only a remote asset device copy', (tester) async {
final asset = RemoteAssetFactory.create(localId: 'local');
final handler = _TestViewIntentHandler();
final harness = await pumpHarness(tester, asset: asset, origin: TimelineOrigin.deepLink, handler: handler);
await harness.coordinator.afterDelete(source: ActionSource.viewer, remoteAssetIds: const [], movedToTrash: false);
verifyNever(() => harness.router.maybePop());
});
testWidgets('does nothing for a timeline deletion', (tester) async {
final asset = RemoteAssetFactory.create();
final handler = _TestViewIntentHandler();
final harness = await pumpHarness(tester, asset: asset, origin: TimelineOrigin.deepLink, handler: handler);
await harness.coordinator.afterDelete(
source: ActionSource.timeline,
remoteAssetIds: [asset.id],
movedToTrash: true,
);
expect(handler.reopenedRemoteAssetIds, isEmpty);
});
testWidgets('does nothing when no view intent is active', (tester) async {
final asset = RemoteAssetFactory.create();
final handler = _TestViewIntentHandler();
final harness = await pumpHarness(
tester,
asset: asset,
origin: TimelineOrigin.deepLink,
handler: handler,
activeViewIntent: false,
);
await harness.coordinator.afterDelete(source: ActionSource.viewer, remoteAssetIds: [asset.id], movedToTrash: true);
expect(handler.reopenedRemoteAssetIds, isEmpty);
});
testWidgets('does nothing in a non-deep-link viewer', (tester) async {
final asset = RemoteAssetFactory.create();
final handler = _TestViewIntentHandler();
final harness = await pumpHarness(tester, asset: asset, origin: TimelineOrigin.main, handler: handler);
await harness.coordinator.afterDelete(source: ActionSource.viewer, remoteAssetIds: [asset.id], movedToTrash: true);
expect(handler.reopenedRemoteAssetIds, isEmpty);
});
testWidgets('does not propagate a remote asset that cannot be reopened', (tester) async {
final asset = RemoteAssetFactory.create();
final handler = _TestViewIntentHandler(reopenResult: false);
final harness = await pumpHarness(tester, asset: asset, origin: TimelineOrigin.deepLink, handler: handler);
await harness.coordinator.afterDelete(source: ActionSource.viewer, remoteAssetIds: [asset.id], movedToTrash: true);
expect(handler.reopenedRemoteAssetIds, [asset.id]);
});
testWidgets('does not propagate a reopen exception', (tester) async {
final asset = RemoteAssetFactory.create(deletedAt: DateTime(2026, 8, 4));
final handler = _TestViewIntentHandler(reopenError: StateError('reopen failed'));
final harness = await pumpHarness(
tester,
asset: asset,
origin: TimelineOrigin.deepLink,
handler: handler,
isTrashScoped: true,
);
await harness.coordinator.afterRestore(source: ActionSource.viewer, remoteAssetIds: [asset.id]);
expect(handler.reopenedRemoteAssetIds, [asset.id]);
});
}

View file

@ -1,6 +1,7 @@
import 'dart:async';
import 'package:auto_route/auto_route.dart';
import 'package:flutter/services.dart';
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';
@ -8,16 +9,24 @@ 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/generated/translations.g.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/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/providers/auth.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/infrastructure/toast.provider.dart';
import 'package:immich_mobile/providers/view_intent/active_view_intent_payload_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_android.dart';
import 'package:immich_mobile/providers/view_intent/view_intent_pending.provider.dart';
import 'package:immich_mobile/providers/view_intent/view_intent_trash_scope.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/toast.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/widget.service.dart';
@ -27,6 +36,10 @@ class MockViewIntentHostApi extends Mock implements ViewIntentHostApi {}
class MockViewIntentAssetResolver extends Mock implements ViewIntentAssetResolver {}
class MockAssetService extends Mock implements AssetService {}
class MockTimelineFactory extends Mock implements TimelineFactory {}
class MockAppRouter extends Mock implements AppRouter {}
class MockAuthService extends Mock implements AuthService {}
@ -43,21 +56,23 @@ class FakePageRouteInfo extends Fake implements PageRouteInfo<dynamic> {}
class FakeTimelineService extends Fake implements TimelineService {}
class FakeAssetService extends Fake implements AssetService {
@override
Stream<BaseAsset?> watchAsset(BaseAsset asset) => const Stream.empty();
}
class TestViewIntentService extends ViewIntentService {
ViewIntentPayload? consumedAttachment;
Object? consumeError;
int cleanupStaleTempFilesCalls = 0;
int cleanupManagedTempFileCalls = 0;
final List<String> managedTempPaths = [];
final List<String> cleanedManagedTempPaths = [];
TestViewIntentService() : super(MockViewIntentHostApi());
@override
Future<ViewIntentPayload?> consumeViewIntent() async => consumedAttachment;
Future<ViewIntentPayload?> consumeViewIntent() async {
if (consumeError case final Object error) {
throw error;
}
return consumedAttachment;
}
@override
Future<void> cleanupStaleTempFiles() async {
@ -73,6 +88,20 @@ class TestViewIntentService extends ViewIntentService {
Future<void> setManagedTempFilePath(String path) async {
managedTempPaths.add(path);
}
@override
Future<void> cleanupManagedTempFileIfCurrent(String path) async {
cleanedManagedTempPaths.add(path);
}
}
class TestToastService extends ToastService {
final List<String> errorMessages = [];
@override
void error(String message, {ToastOption? toast}) {
errorMessages.add(message);
}
}
class TestAuthNotifier extends AuthNotifier {
@ -100,8 +129,11 @@ void main() {
late TestViewIntentService viewIntentService;
late MockViewIntentAssetResolver resolver;
late MockAssetService assetService;
late MockTimelineFactory timelineFactory;
late MockAppRouter router;
late TestAuthNotifier authNotifier;
late TestToastService toastService;
late ProviderContainer container;
late AndroidViewIntentHandler handler;
late ViewIntentPayload payload;
@ -112,6 +144,8 @@ void main() {
registerFallbackValue(FakePageRouteInfo());
registerFallbackValue(<PageRouteInfo<dynamic>>[]);
registerFallbackValue(FakeTimelineService());
registerFallbackValue(<BaseAsset>[]);
registerFallbackValue(_remoteAsset(id: 'fallback-remote', localId: 'fallback-local'));
registerFallbackValue(
ViewIntentPayload(path: '/tmp/fallback.jpg', mimeType: 'image/jpeg', localAssetId: 'fallback'),
);
@ -119,24 +153,32 @@ void main() {
setUp(() async {
viewIntentService = TestViewIntentService();
toastService = TestToastService();
resolver = MockViewIntentAssetResolver();
assetService = MockAssetService();
timelineFactory = MockTimelineFactory();
router = MockAppRouter();
payload = ViewIntentPayload(path: '/tmp/incoming.jpg', mimeType: 'image/jpeg', localAssetId: 'local-1');
deepLinkAsset = _localAsset(id: 'local-1');
deepLinkTimelineService = await _createReadyTimelineService([deepLinkAsset], TimelineOrigin.deepLink);
when(() => router.replaceAll(any())).thenAnswer((_) async {});
when(() => router.replace(any())).thenAnswer((_) async => null);
when(() => router.push<Object?>(any())).thenAnswer((_) async => null);
when(() => assetService.watchAsset(any())).thenAnswer((_) => const Stream.empty());
container = ProviderContainer(
overrides: [
viewIntentServiceProvider.overrideWithValue(viewIntentService),
viewIntentAssetResolverProvider.overrideWithValue(resolver),
assetServiceProvider.overrideWithValue(assetService),
timelineFactoryProvider.overrideWithValue(timelineFactory),
toastServiceProvider.overrideWithValue(toastService),
appRouterProvider.overrideWithValue(router),
authProvider.overrideWith((ref) {
authNotifier = TestAuthNotifier(ref, _authState(isAuthenticated: true));
return authNotifier;
}),
assetServiceProvider.overrideWithValue(FakeAssetService()),
],
);
@ -164,7 +206,7 @@ void main() {
authNotifier.setAuthenticated(true);
when(() => resolver.resolve(payload)).thenAnswer((_) async {
return ViewIntentResolvedAsset(asset: deepLinkAsset, timelineService: deepLinkTimelineService);
return ViewIntentResolution(asset: deepLinkAsset, timelineService: deepLinkTimelineService);
});
unawaited(handler.flushDeferredViewIntent());
@ -201,11 +243,21 @@ void main() {
verifyNever(() => resolver.resolve(any()));
});
test('onAppResumed returns to the main screen when the incoming view intent is unavailable', () async {
viewIntentService.consumeError = PlatformException(code: 'VIEW_INTENT_UNAVAILABLE');
await handler.onAppResumed();
expect(toastService.errorMessages, [StaticTranslations.instance.asset_not_found_on_device_android]);
verify(() => router.replaceAll([const TabShellRoute()])).called(1);
verifyNever(() => resolver.resolve(any()));
});
testWidgets('onAppResumed handles attachment immediately when authenticated', (tester) async {
viewIntentService.consumedAttachment = payload;
when(
() => resolver.resolve(payload),
).thenAnswer((_) async => ViewIntentResolvedAsset(asset: deepLinkAsset, timelineService: deepLinkTimelineService));
).thenAnswer((_) async => ViewIntentResolution(asset: deepLinkAsset, timelineService: deepLinkTimelineService));
unawaited(handler.onAppResumed());
await tester.pump();
@ -214,14 +266,159 @@ void main() {
await tester.idle();
verify(() => resolver.resolve(payload)).called(1);
// Routes the user to [TabShell, AssetViewer] so back-press lands on the
// main timeline mirrors the home-screen widget navigation pattern.
final captured = verify(() => router.replaceAll(captureAny())).captured;
verify(() => router.popUntilRoot()).called(1);
final captured = verify(() => router.push<Object?>(captureAny())).captured;
expect(captured, hasLength(1));
final routes = captured.single as List<PageRouteInfo<dynamic>>;
expect(routes, hasLength(2));
expect(routes[0].routeName, TabShellRoute.name);
expect(routes[1].routeName, AssetViewerRoute.name);
final route = captured.single as PageRouteInfo<dynamic>;
expect(route.routeName, AssetViewerRoute.name);
});
test('handle updates current viewer asset when a new view intent arrives', () async {
final secondPayload = ViewIntentPayload(
path: '/tmp/incoming-b.jpg',
mimeType: 'image/jpeg',
localAssetId: 'local-2',
);
final secondAsset = _localAsset(id: 'local-2');
final secondTimelineService = await _createReadyTimelineService([secondAsset], TimelineOrigin.deepLink);
addTearDown(() async => secondTimelineService.dispose());
when(
() => resolver.resolve(payload),
).thenAnswer((_) async => ViewIntentResolution(asset: deepLinkAsset, timelineService: deepLinkTimelineService));
when(
() => resolver.resolve(secondPayload),
).thenAnswer((_) async => ViewIntentResolution(asset: secondAsset, timelineService: secondTimelineService));
await handler.handle(payload);
expect(container.read(assetViewerProvider).currentAsset, deepLinkAsset);
expect(container.read(activeViewIntentPayloadProvider), isNull);
await handler.handle(secondPayload);
expect(container.read(assetViewerProvider).currentAsset, secondAsset);
expect(container.read(activeViewIntentPayloadProvider), isNull);
verify(() => resolver.resolve(payload)).called(1);
verify(() => resolver.resolve(secondPayload)).called(1);
verify(() => router.popUntilRoot()).called(2);
verify(() => router.push<Object?>(any())).called(2);
verifyNever(() => router.replace(any()));
verifyNever(() => router.replaceAll(any()));
});
test('a slower view intent cannot replace a newer one', () async {
final firstResolution = Completer<ViewIntentResolution>();
final secondPayload = ViewIntentPayload(
path: '/tmp/incoming-b.jpg',
mimeType: 'image/jpeg',
localAssetId: 'local-2',
);
final secondAsset = _localAsset(id: 'local-2');
final secondTimelineService = await _createReadyTimelineService([secondAsset], TimelineOrigin.deepLink);
addTearDown(secondTimelineService.dispose);
when(() => resolver.resolve(payload)).thenAnswer((_) => firstResolution.future);
when(
() => resolver.resolve(secondPayload),
).thenAnswer((_) async => ViewIntentResolution(asset: secondAsset, timelineService: secondTimelineService));
final firstHandle = handler.handle(payload);
await pumpEventQueue();
await handler.handle(secondPayload);
firstResolution.complete(ViewIntentResolution(asset: deepLinkAsset, timelineService: deepLinkTimelineService));
await firstHandle;
expect(container.read(assetViewerProvider).currentAsset, secondAsset);
expect(container.read(activeViewIntentPayloadProvider), isNull);
verify(() => router.popUntilRoot()).called(2);
verify(() => router.push<Object?>(any())).called(1);
});
test('closing a file-backed view intent clears only its session state', () async {
const path = '/tmp/view_intent_1.jpg';
final routeClosed = Completer<Object?>();
when(() => router.push<Object?>(any())).thenAnswer((_) => routeClosed.future);
when(() => resolver.resolve(payload)).thenAnswer(
(_) async => ViewIntentResolution(
asset: deepLinkAsset,
timelineService: deepLinkTimelineService,
viewIntentFilePath: path,
),
);
final handling = handler.handle(payload);
await pumpEventQueue();
expect(container.read(activeViewIntentPayloadProvider), same(payload));
expect(container.read(viewIntentFilePathProvider), path);
routeClosed.complete(null);
await handling;
expect(container.read(activeViewIntentPayloadProvider), isNull);
expect(container.read(viewIntentFilePathProvider), isNull);
expect(viewIntentService.cleanedManagedTempPaths, [path]);
});
test('reopenRemoteAsset opens the restored asset in a regular deep-link timeline', () async {
final restoredAsset = _remoteAsset(id: 'remote-1', localId: 'local-1');
final restoredTimeline = await _createReadyTimelineService([restoredAsset], TimelineOrigin.deepLink);
addTearDown(restoredTimeline.dispose);
container.read(activeViewIntentPayloadProvider.notifier).setPayload(payload);
when(() => assetService.getRemoteAsset(restoredAsset.id)).thenAnswer((_) async => restoredAsset);
when(() => timelineFactory.fromAssets(any(), TimelineOrigin.deepLink)).thenReturn(restoredTimeline);
final reopened = await handler.reopenRemoteAsset(restoredAsset.id);
await Future<void>.delayed(Duration.zero);
expect(reopened, isTrue);
expect(container.read(assetViewerProvider).currentAsset, restoredAsset);
expect(container.read(viewIntentTrashScopeProvider), isFalse);
verify(() => timelineFactory.fromAssets(any(), TimelineOrigin.deepLink)).called(1);
verify(() => router.popUntilRoot()).called(1);
final route = verify(() => router.push<Object?>(captureAny())).captured.single as PageRouteInfo<dynamic>;
expect(route.routeName, AssetViewerRoute.name);
});
test('reopenRemoteAsset opens a trashed asset in a trash-scoped deep-link timeline', () async {
final trashedAsset = _remoteAsset(id: 'remote-trashed', localId: 'local-1', deletedAt: DateTime(2026, 8, 4));
final trashTimeline = await _createReadyTimelineService([trashedAsset], TimelineOrigin.deepLink);
addTearDown(trashTimeline.dispose);
container.read(activeViewIntentPayloadProvider.notifier).setPayload(payload);
when(() => assetService.getRemoteAsset(trashedAsset.id)).thenAnswer((_) async => trashedAsset);
when(() => timelineFactory.fromAssets(any(), TimelineOrigin.deepLink)).thenReturn(trashTimeline);
final reopened = await handler.reopenRemoteAsset(trashedAsset.id);
await Future<void>.delayed(Duration.zero);
expect(reopened, isTrue);
expect(container.read(assetViewerProvider).currentAsset, trashedAsset);
expect(trashTimeline.origin, TimelineOrigin.deepLink);
expect(container.read(viewIntentTrashScopeProvider), isTrue);
verify(() => router.popUntilRoot()).called(1);
final route = verify(() => router.push<Object?>(captureAny())).captured.single as PageRouteInfo<dynamic>;
expect(route.routeName, AssetViewerRoute.name);
});
test('reopenRemoteAsset does not replace a newer view intent', () async {
final restoredAsset = _remoteAsset(id: 'remote-delayed', localId: 'local-1');
final lookup = Completer<RemoteAsset?>();
final newerPayload = ViewIntentPayload(path: '/tmp/newer.jpg', mimeType: 'image/jpeg', localAssetId: 'local-2');
container.read(activeViewIntentPayloadProvider.notifier).setPayload(payload);
when(() => assetService.getRemoteAsset(restoredAsset.id)).thenAnswer((_) => lookup.future);
final reopening = handler.reopenRemoteAsset(restoredAsset.id);
await pumpEventQueue();
container.read(activeViewIntentPayloadProvider.notifier).setPayload(newerPayload);
lookup.complete(restoredAsset);
expect(await reopening, isFalse);
verifyNever(() => timelineFactory.fromAssets(any(), TimelineOrigin.deepLink));
verifyNever(() => router.popUntilRoot());
verifyNever(() => router.push<Object?>(any()));
});
}
@ -237,15 +434,31 @@ AuthState _authState({required bool isAuthenticated}) {
);
}
LocalAsset _localAsset({required String id}) {
LocalAsset _localAsset({required String id, String? checksum = 'checksum-1', String? remoteId}) {
return LocalAsset(
id: id,
remoteId: remoteId,
name: '$id.jpg',
checksum: checksum,
type: AssetType.image,
createdAt: DateTime(2026, 4, 20),
updatedAt: DateTime(2026, 4, 20),
playbackStyle: AssetPlaybackStyle.image,
isEdited: false,
);
}
RemoteAsset _remoteAsset({required String id, required String? localId, DateTime? deletedAt}) {
return RemoteAsset(
id: id,
localId: localId,
ownerId: 'user-1',
name: '$id.jpg',
checksum: 'checksum-1',
type: AssetType.image,
createdAt: DateTime(2026, 4, 20),
updatedAt: DateTime(2026, 4, 20),
playbackStyle: AssetPlaybackStyle.image,
deletedAt: deletedAt,
isEdited: false,
);
}

View file

@ -4,9 +4,12 @@ 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/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/services/view_intent_asset_resolver.service.dart';
import 'package:mocktail/mocktail.dart';
@ -15,27 +18,43 @@ import '../infrastructure/repository.mock.dart';
class MockTimelineFactory extends Mock implements TimelineFactory {}
class MockAssetService extends Mock implements AssetService {}
class MockNativeSyncApi extends Mock implements NativeSyncApi {}
void main() {
late MockDriftLocalAssetRepository mockLocalAssetRepository;
late MockAssetService assetService;
late MockNativeSyncApi nativeSyncApi;
late MockTimelineFactory timelineFactory;
late List<TimelineService> createdTimelineServices;
late ProviderContainer container;
setUpAll(() {
registerFallbackValue(<String>[]);
registerFallbackValue(<String, String>{});
});
setUp(() {
mockLocalAssetRepository = MockDriftLocalAssetRepository();
assetService = MockAssetService();
nativeSyncApi = MockNativeSyncApi();
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;
});
when(() => mockLocalAssetRepository.get(any())).thenAnswer((_) async => null);
when(() => assetService.getRemoteAsset(any())).thenAnswer((_) async => null);
when(() => assetService.getAllRemoteAssetDebugByChecksum(any())).thenAnswer((_) async => const []);
when(() => nativeSyncApi.hashAssets(any())).thenAnswer((_) async => const []);
when(() => mockLocalAssetRepository.updateHashes(any())).thenAnswer((_) async {});
_mockTimelineFactoryOrigin(timelineFactory, createdTimelineServices, TimelineOrigin.deepLink);
container = ProviderContainer(
overrides: [
localAssetRepository.overrideWith((ref) => mockLocalAssetRepository),
assetServiceProvider.overrideWithValue(assetService),
nativeSyncApiProvider.overrideWithValue(nativeSyncApi),
timelineFactoryProvider.overrideWith((ref) => timelineFactory),
],
);
@ -50,7 +69,7 @@ void main() {
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);
when(() => mockLocalAssetRepository.get('local-1')).thenAnswer((_) async => localAsset);
final result = await _resolve(container, _payload(localAssetId: 'local-1'));
@ -59,8 +78,63 @@ void main() {
expect(result.viewIntentFilePath, isNull, reason: 'DB-backed assets carry their own source — no temp file needed');
});
test('returns remote merged asset when local checksum matches remote asset', () async {
final localAsset = _localAsset(id: 'local-1', checksum: 'checksum-1', remoteId: 'remote-1');
final remoteAsset = _remoteAsset(id: 'remote-1', checksum: 'checksum-1');
when(() => mockLocalAssetRepository.get('local-1')).thenAnswer((_) async => localAsset);
when(() => assetService.getRemoteAsset('remote-1')).thenAnswer((_) async => remoteAsset);
final result = await _resolve(container, _payload(localAssetId: 'local-1'));
expect(result.asset, isA<RemoteAsset>());
expect((result.asset as RemoteAsset).localId, 'local-1');
expect(result.timelineService.origin, TimelineOrigin.deepLink);
expect(result.viewIntentFilePath, isNull);
verifyNever(() => nativeSyncApi.hashAssets(any()));
});
test('returns remote trashed asset in a trash-scoped deep-link timeline', () async {
final localAsset = _localAsset(id: 'local-1', checksum: 'checksum-1', remoteId: 'remote-1');
final remoteAsset = _remoteAsset(id: 'remote-1', checksum: 'checksum-1', deletedAt: DateTime(2026, 4, 21));
when(() => mockLocalAssetRepository.get('local-1')).thenAnswer((_) async => localAsset);
when(() => assetService.getRemoteAsset('remote-1')).thenAnswer((_) async => remoteAsset);
final result = await _resolve(container, _payload(localAssetId: 'local-1'));
expect(result.asset, isA<RemoteAsset>());
expect((result.asset as RemoteAsset).localId, 'local-1');
expect(result.timelineService.origin, TimelineOrigin.deepLink);
expect(result.isTrashScoped, isTrue);
expect(result.viewIntentFilePath, isNull);
});
test('hashes local asset without checksum and returns remote merged asset', () async {
final localAsset = _localAsset(id: 'local-1');
final mergedLocalAsset = _localAsset(id: 'local-1', checksum: 'checksum-1', remoteId: 'remote-1');
final remoteAsset = _remoteAsset(id: 'remote-1', checksum: 'checksum-1');
var getCallCount = 0;
when(() => mockLocalAssetRepository.get('local-1')).thenAnswer((_) async {
getCallCount++;
return getCallCount == 1 ? localAsset : mergedLocalAsset;
});
when(
() => nativeSyncApi.hashAssets(['local-1']),
).thenAnswer((_) async => [HashResult(assetId: 'local-1', hash: 'checksum-1')]);
when(() => assetService.getRemoteAsset('remote-1')).thenAnswer((_) async => remoteAsset);
final result = await _resolve(container, _payload(localAssetId: 'local-1'));
expect(result.asset, isA<RemoteAsset>());
expect((result.asset as RemoteAsset).localId, 'local-1');
expect(result.timelineService.origin, TimelineOrigin.deepLink);
expect(result.viewIntentFilePath, isNull);
verify(() => nativeSyncApi.hashAssets(['local-1'])).called(1);
verify(() => mockLocalAssetRepository.updateHashes({'local-1': 'checksum-1'})).called(1);
verify(() => mockLocalAssetRepository.get('local-1')).called(2);
});
test('returns transient asset with temp file path when localAssetId has no DB row', () async {
when(() => mockLocalAssetRepository.getById('local-1')).thenAnswer((_) async => null);
when(() => mockLocalAssetRepository.get('local-1')).thenAnswer((_) async => null);
final result = await _resolve(container, _payload(localAssetId: 'local-1', path: '/tmp/incoming.jpg'));
@ -69,6 +143,22 @@ void main() {
expect(result.viewIntentFilePath, '/tmp/incoming.jpg');
});
test('returns cached remote asset when local Drift row is absent but checksum matches', () async {
final remoteAsset = _remoteAsset(id: 'remote-1', checksum: 'checksum-1');
when(
() => nativeSyncApi.hashAssets(['local-1']),
).thenAnswer((_) async => [HashResult(assetId: 'local-1', hash: 'checksum-1')]);
when(() => assetService.getAllRemoteAssetDebugByChecksum('checksum-1')).thenAnswer((_) async => [remoteAsset]);
final result = await _resolve(container, _payload(localAssetId: 'local-1'));
expect(result.asset, isA<RemoteAsset>());
expect((result.asset as RemoteAsset).id, 'remote-1');
expect((result.asset as RemoteAsset).localId, 'local-1');
expect(result.timelineService.origin, TimelineOrigin.deepLink);
verify(() => assetService.getAllRemoteAssetDebugByChecksum('checksum-1')).called(1);
});
test('returns transient asset for path-only attachment', () async {
final result = await _resolve(
container,
@ -86,14 +176,11 @@ void main() {
});
test('throws when neither localAssetId nor path is provided', () async {
await expectLater(
_resolve(container, _payload(localAssetId: null, path: null)),
throwsA(isA<StateError>()),
);
await expectLater(_resolve(container, _payload(localAssetId: null, path: null)), throwsA(isA<StateError>()));
});
}
Future<ViewIntentResolvedAsset> _resolve(ProviderContainer container, ViewIntentPayload payload) {
Future<ViewIntentResolution> _resolve(ProviderContainer container, ViewIntentPayload payload) {
return container.read(viewIntentAssetResolverProvider).resolve(payload);
}
@ -101,9 +188,10 @@ ViewIntentPayload _payload({String? localAssetId = 'local-1', String? path, Stri
return ViewIntentPayload(path: path, mimeType: mimeType, localAssetId: localAssetId);
}
LocalAsset _localAsset({required String id, String? checksum}) {
LocalAsset _localAsset({required String id, String? checksum, String? remoteId}) {
return LocalAsset(
id: id,
remoteId: remoteId,
name: '$id.jpg',
checksum: checksum,
type: AssetType.image,
@ -114,6 +202,34 @@ LocalAsset _localAsset({required String id, String? checksum}) {
);
}
RemoteAsset _remoteAsset({required String id, String? localId, required String checksum, DateTime? deletedAt}) {
return RemoteAsset(
id: id,
localId: localId,
ownerId: 'user-1',
name: '$id.jpg',
checksum: checksum,
type: AssetType.image,
createdAt: DateTime(2026, 4, 20),
updatedAt: DateTime(2026, 4, 20),
isEdited: false,
deletedAt: deletedAt,
);
}
void _mockTimelineFactoryOrigin(
MockTimelineFactory timelineFactory,
List<TimelineService> createdTimelineServices,
TimelineOrigin origin,
) {
when(() => timelineFactory.fromAssets(any(), origin)).thenAnswer((invocation) {
final assets = List<BaseAsset>.from(invocation.positionalArguments[0] as List<BaseAsset>);
final timelineService = _timelineServiceFromAssets(assets, origin);
createdTimelineServices.add(timelineService);
return timelineService;
});
}
TimelineService _timelineServiceFromAssets(List<BaseAsset> assets, TimelineOrigin origin) {
return TimelineService((
assetSource: (index, count) async => assets.skip(index).take(count).toList(),

View file

@ -2,6 +2,7 @@
import 'dart:io';
import 'package:flutter/services.dart';
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';
@ -58,6 +59,15 @@ void main() {
verify(() => hostApi.consumeViewIntent()).called(1);
});
test('consumeViewIntent preserves an unavailable view intent error', () async {
when(() => hostApi.consumeViewIntent()).thenThrow(PlatformException(code: 'VIEW_INTENT_UNAVAILABLE'));
await expectLater(
service.consumeViewIntent(),
throwsA(isA<PlatformException>().having((error) => error.code, 'code', 'VIEW_INTENT_UNAVAILABLE')),
);
});
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');

View file

@ -1,14 +1,23 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart';
import 'package:immich_mobile/domain/services/store.service.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/presentation/actions/action.widget.dart';
import 'package:immich_mobile/presentation/actions/delete.action.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/view_intent/view_intent_asset_action_coordinator.provider.dart';
import 'package:immich_mobile/providers/view_intent/view_intent_file_path.provider.dart';
import 'package:immich_mobile/widgets/common/confirm_dialog.dart';
import 'package:immich_ui/immich_ui.dart';
import 'package:mocktail/mocktail.dart';
@ -18,6 +27,20 @@ import '../../factories/local_asset_factory.dart';
import '../../factories/remote_asset_factory.dart';
import '../presentation_context.dart';
class _MockViewIntentAssetActionCoordinator extends Mock implements ViewIntentAssetActionCoordinator {}
class _ViewerNotifier extends AssetViewerStateNotifier {
_ViewerNotifier(this.asset);
final BaseAsset asset;
@override
AssetViewerState build() {
super.build();
return AssetViewerState(currentAsset: asset);
}
}
void main() {
late PresentationContext context;
late MockAssetService assetService;
@ -43,6 +66,27 @@ void main() {
localId: localId,
);
TimelineService viewIntentTimeline(BaseAsset asset, TimelineOrigin origin) => TimelineService((
assetSource: (_, __) async => [asset],
bucketSource: () => Stream.value(const [Bucket(assetCount: 1)]),
origin: origin,
));
Future<void> pumpViewerDelete(WidgetTester tester, BaseAsset asset, TimelineService timeline, String filePath) async {
await tester.pumpTestWidget(
context,
const ActionIconButton(action: DeleteAction(source: .viewer)),
overrides: [
timelineServiceProvider.overrideWithValue(timeline),
assetViewerProvider.overrideWith(() => _ViewerNotifier(asset)),
],
);
final scope = ProviderScope.containerOf(tester.element(find.byType(ActionIconButton)), listen: false);
scope.read(viewIntentFilePathProvider.notifier).setPath(filePath);
await tester.pumpAndSettle();
}
Future<void> pumpDelete(WidgetTester tester, Set<BaseAsset> selection, {bool trashEnabled = true}) async {
if (!trashEnabled) {
when(
@ -238,6 +282,101 @@ void main() {
expect(find.byType(ImmichIconButton), findsNothing);
});
group('view intent', () {
testWidgets('delegates a successful delete to the view intent coordinator', (tester) async {
final asset = owned();
final timeline = viewIntentTimeline(asset, TimelineOrigin.deepLink);
final coordinator = _MockViewIntentAssetActionCoordinator();
addTearDown(timeline.dispose);
when(() => coordinator.canDelete(ActionSource.viewer)).thenReturn(true);
when(
() => coordinator.afterDelete(source: ActionSource.viewer, remoteAssetIds: [asset.id], movedToTrash: true),
).thenAnswer((_) async {});
await tester.pumpTestWidget(
context,
const ActionIconButton(action: DeleteAction(source: .viewer)),
overrides: [
timelineServiceProvider.overrideWithValue(timeline),
assetViewerProvider.overrideWith(() => _ViewerNotifier(asset)),
viewIntentAssetActionCoordinatorProvider.overrideWithValue(coordinator),
],
);
await tester.tap(find.byType(ImmichIconButton));
await tester.pumpAndSettle();
verify(
() => coordinator.afterDelete(source: ActionSource.viewer, remoteAssetIds: [asset.id], movedToTrash: true),
).called(1);
});
testWidgets('clears timeline selection before the post-delete transition completes', (tester) async {
final asset = owned();
final coordinator = _MockViewIntentAssetActionCoordinator();
final transition = Completer<void>();
when(() => coordinator.canDelete(ActionSource.timeline)).thenReturn(true);
when(
() => coordinator.afterDelete(source: ActionSource.timeline, remoteAssetIds: [asset.id], movedToTrash: true),
).thenAnswer((_) => transition.future);
await tester.pumpTestAction(
context,
const DeleteAction(source: .timeline),
overrides: [
...context.selected({asset}),
viewIntentAssetActionCoordinatorProvider.overrideWithValue(coordinator),
],
);
await tester.pump();
expect(find.byType(ImmichIconButton), findsNothing, reason: 'successful deletion clears the selection');
transition.complete();
await tester.pumpAndSettle();
});
testWidgets('is hidden for a synthetic file-backed asset', (tester) async {
final asset = LocalAssetFactory.create(id: '-42');
final timeline = viewIntentTimeline(asset, TimelineOrigin.deepLink);
addTearDown(timeline.dispose);
await pumpViewerDelete(tester, asset, timeline, '/tmp/materialized.jpg');
expect(find.byType(ImmichIconButton), findsNothing);
});
});
testWidgets('clears selection when local cleanup finishes after the action context is disposed', (tester) async {
final asset = LocalAssetFactory.create();
final cleanupResult = Completer<int>();
when(() => cleanupService.deleteLocalAssets([asset.id])).thenAnswer((_) => cleanupResult.future);
await tester.pumpTestWidget(
context,
const ActionIconButton(action: DeleteAction(source: .timeline)),
overrides: context.selected({asset}),
);
final navigator = tester.state<NavigatorState>(find.byType(Navigator));
unawaited(
navigator.push<void>(
MaterialPageRoute(
builder: (_) => const Scaffold(
body: ActionIconButton(action: DeleteAction(source: .timeline)),
),
),
),
);
await tester.pumpAndSettle();
await tester.tap(find.byType(ImmichIconButton));
await tester.pump();
navigator.pop();
await tester.pumpAndSettle();
cleanupResult.complete(1);
await tester.pumpAndSettle();
expect(find.byType(ImmichIconButton), findsNothing, reason: 'successful cleanup clears the selection');
});
});
group('CleanupLocalAction', () {

View file

@ -1,7 +1,15 @@
import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/constants/enums.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/presentation/actions/action.widget.dart';
import 'package:immich_mobile/presentation/actions/restore.action.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/view_intent/view_intent_asset_action_coordinator.provider.dart';
import 'package:immich_ui/immich_ui.dart';
import 'package:mocktail/mocktail.dart';
@ -9,6 +17,20 @@ import '../../../service.mocks.dart';
import '../../factories/remote_asset_factory.dart';
import '../presentation_context.dart';
class _MockViewIntentAssetActionCoordinator extends Mock implements ViewIntentAssetActionCoordinator {}
class _ViewerNotifier extends AssetViewerStateNotifier {
_ViewerNotifier(this.asset);
final BaseAsset asset;
@override
AssetViewerState build() {
super.build();
return AssetViewerState(currentAsset: asset);
}
}
void main() {
late PresentationContext context;
late MockAssetService assetService;
@ -62,6 +84,57 @@ void main() {
expect(find.byType(ImmichIconButton), findsNothing, reason: 'an empty selection hides the action');
});
testWidgets('clears timeline selection before the post-restore transition completes', (tester) async {
final asset = owned();
final coordinator = _MockViewIntentAssetActionCoordinator();
final transition = Completer<void>();
when(
() => coordinator.afterRestore(source: ActionSource.timeline, remoteAssetIds: [asset.id]),
).thenAnswer((_) => transition.future);
await tester.pumpTestAction(
context,
const RestoreAction(source: .timeline),
overrides: [
...context.selected({asset}),
viewIntentAssetActionCoordinatorProvider.overrideWithValue(coordinator),
],
);
await tester.pump();
expect(find.byType(ImmichIconButton), findsNothing, reason: 'successful restore clears the selection');
transition.complete();
await tester.pumpAndSettle();
});
testWidgets('delegates a successful viewer restore to the view intent coordinator', (tester) async {
final asset = owned();
final coordinator = _MockViewIntentAssetActionCoordinator();
final timeline = TimelineService((
assetSource: (_, __) async => [asset],
bucketSource: () => Stream.value(const [Bucket(assetCount: 1)]),
origin: TimelineOrigin.deepLink,
));
addTearDown(timeline.dispose);
when(
() => coordinator.afterRestore(source: ActionSource.viewer, remoteAssetIds: [asset.id]),
).thenAnswer((_) async {});
await tester.pumpTestAction(
context,
const RestoreAction(source: .viewer),
overrides: [
timelineServiceProvider.overrideWithValue(timeline),
assetViewerProvider.overrideWith(() => _ViewerNotifier(asset)),
viewIntentAssetActionCoordinatorProvider.overrideWithValue(coordinator),
],
);
await tester.pump();
verify(() => assetService.restoreTrash([asset.id])).called(1);
verify(() => coordinator.afterRestore(source: ActionSource.viewer, remoteAssetIds: [asset.id])).called(1);
});
testWidgets('is hidden when no owned asset is trashed', (tester) async {
await tester.pumpTestWidget(
context,

View file

@ -3,10 +3,12 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/presentation/actions/action.widget.dart';
import 'package:immich_mobile/presentation/actions/upload.action.dart';
import 'package:immich_mobile/providers/asset_upload_coordinator.provider.dart';
import 'package:immich_mobile/providers/backup/asset_upload_progress.provider.dart';
import 'package:immich_mobile/providers/infrastructure/toast.provider.dart';
import 'package:immich_mobile/services/foreground_upload.service.dart';
@ -32,6 +34,7 @@ void main() {
});
List<Override> uploadOverrides() => [
assetUploadCoordinatorProvider.overrideWith(AssetUploadCoordinator.new),
foregroundUploadServiceProvider.overrideWithValue(uploadService),
toastServiceProvider.overrideWithValue(context.service.toast),
];
@ -203,7 +206,7 @@ void main() {
overrides: uploadOverrides(),
);
await uploadAssets(tester.element(find.byType(SizedBox)), capturedRef, [asset]);
await uploadAssets(tester.element(find.byType(SizedBox)), capturedRef, [asset], source: ActionSource.timeline);
await settleUpload(tester);
expect(capturedRef.read(assetUploadProgressProvider), isEmpty);

View file

@ -1,9 +1,14 @@
import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/services/asset.service.dart';
import 'package:mocktail/mocktail.dart';
import '../../infrastructure/repository.mock.dart';
import '../../repository.mocks.dart';
import '../factories/local_asset_factory.dart';
import '../factories/remote_asset_factory.dart';
import '../mocks.dart';
void main() {
@ -12,23 +17,55 @@ void main() {
late MockAssetApiRepository apiRepository;
late MockRemoteAssetRepository remoteRepository;
late MockRemoteExifRepository exifRepository;
late MockDriftLocalAssetRepository localRepository;
setUp(() {
mocks = RepositoryMocks();
apiRepository = mocks.assetApi.api;
remoteRepository = mocks.remoteAsset.repo;
exifRepository = mocks.remoteExif.repo;
localRepository = MockDriftLocalAssetRepository();
sut = AssetService(
remoteRepository: remoteRepository,
exifRepository: exifRepository,
localRepository: MockDriftLocalAssetRepository(),
localRepository: localRepository,
apiRepository: apiRepository,
mediaRepository: mocks.assetMedia.api,
trashedLocalRepository: mocks.trashedAsset,
);
});
group('AssetService.watchAsset', () {
test('switches from the local asset to its canonical remote asset after upload', () async {
final localController = StreamController<LocalAsset?>.broadcast();
final remoteController = StreamController<RemoteAsset?>.broadcast();
addTearDown(localController.close);
addTearDown(remoteController.close);
final local = LocalAssetFactory.create(id: 'local-1');
final linkedLocal = local.copyWith(remoteId: 'remote-1');
final remote = RemoteAssetFactory.create(id: 'remote-1', localId: local.id);
when(() => localRepository.watch(local.id)).thenAnswer((_) => localController.stream);
when(() => remoteRepository.watch(remote.id)).thenAnswer((_) => remoteController.stream);
final emitted = <BaseAsset?>[];
final subscription = sut.watchAsset(local).listen(emitted.add);
addTearDown(subscription.cancel);
localController.add(local);
await pumpEventQueue();
expect(emitted, [local]);
localController.add(linkedLocal);
await pumpEventQueue();
remoteController.add(remote);
await pumpEventQueue();
expect(emitted.last, remote);
});
});
group('AssetService.updateDateTime', () {
const ids = ['asset_id_1'];