fix(mobile): handle file-backed view intent uploads

-Route materialized view intents through share-intent upload, replace the
synthetic viewer asset after sync, and manage temporary file cleanup.
This commit is contained in:
Peter Ombodi 2026-08-04 12:00:03 +03:00
parent 849e4472ac
commit 6894b2ea22
8 changed files with 493 additions and 13 deletions

View file

@ -52,6 +52,10 @@ class AssetService {
});
}
Stream<RemoteAsset?> watchRemoteAsset(String id) {
return _remoteRepository.watch(id);
}
Future<List<LocalAsset?>> getLocalAssetsByChecksum(String checksum) {
return _localRepository.getByChecksum(checksum);
}

View file

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

View file

@ -283,16 +283,17 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
if (index != _currentPage) {
_pageController.jumpToPage(index);
unawaited(_onAssetChanged(index));
} else if (currentAsset is RemoteAsset &&
currentAsset.stackId != null &&
} else if (currentAsset != null &&
assetIndex == null &&
!_shouldIgnoreMissingAssetOnTimelineReload(currentAsset, timelineService)) {
final timelineAsset = timelineService.getAssetSafe(index);
if (timelineAsset is! RemoteAsset || currentAsset.stackId != timelineAsset.stackId) {
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) {

View file

@ -0,0 +1,117 @@
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/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset.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 {
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 viewIntentFilePath = source == ActionSource.viewer ? _ref.read(viewIntentFilePathProvider) : null;
if (viewIntentFilePath == null) {
await _ref
.read(foregroundUploadServiceProvider)
.uploadManual(assets, cancelToken: cancelToken, callbacks: callbacks);
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,
cancelToken: cancelToken,
callbacks: callbacks,
);
}
Future<void> _uploadViewIntentFile({
required LocalAsset asset,
required String path,
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)) {
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) {
if (_ref.read(viewIntentFilePathProvider) != path) {
return false;
}
final currentAsset = _ref.read(assetViewerProvider).currentAsset;
return currentAsset != null && currentAsset.refersToSameAsset(asset);
}
}

View file

@ -128,6 +128,7 @@ class ViewIntentAssetResolver {
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,

View file

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

View file

@ -0,0 +1,243 @@
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/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/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('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);
});
}

View file

@ -3,6 +3,7 @@ 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';
@ -203,7 +204,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);