mirror of
https://github.com/immich-app/immich
synced 2026-08-29 13:15:45 +00:00
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:
parent
849e4472ac
commit
6894b2ea22
8 changed files with 493 additions and 13 deletions
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
117
mobile/lib/providers/asset_upload_coordinator.provider.dart
Normal file
117
mobile/lib/providers/asset_upload_coordinator.provider.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,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);
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue