mirror of
https://github.com/immich-app/immich
synced 2026-08-15 13:03:57 +00:00
Merge 76098c36d7 into ffc83eae36
This commit is contained in:
commit
53546f0d49
31 changed files with 2043 additions and 99 deletions
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
161
mobile/lib/providers/asset_upload_coordinator.provider.dart
Normal file
161
mobile/lib/providers/asset_upload_coordinator.provider.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 &&
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
});
|
||||
}
|
||||
|
|
@ -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));
|
||||
});
|
||||
}
|
||||
|
|
@ -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]);
|
||||
});
|
||||
}
|
||||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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', () {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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'];
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue