From 91a201da0c1d3085cb9c06ab6b83e1d0bdab108f Mon Sep 17 00:00:00 2001 From: Alex Date: Mon, 12 Jan 2026 14:57:57 -0600 Subject: [PATCH] feat: share intent upload --- .../upload/share_intent_attachment.model.dart | 2 +- .../pages/share_intent/share_intent.page.dart | 36 ++--- .../share_intent_upload.provider.dart | 138 +++++------------- mobile/lib/services/upload.service.dart | 101 +++++++++++++ 4 files changed, 153 insertions(+), 124 deletions(-) diff --git a/mobile/lib/models/upload/share_intent_attachment.model.dart b/mobile/lib/models/upload/share_intent_attachment.model.dart index ae05e4c492..e5388fce2c 100644 --- a/mobile/lib/models/upload/share_intent_attachment.model.dart +++ b/mobile/lib/models/upload/share_intent_attachment.model.dart @@ -7,7 +7,7 @@ import 'package:path/path.dart'; enum ShareIntentAttachmentType { image, video } -enum UploadStatus { enqueued, running, complete, notFound, failed, canceled, waitingToRetry, paused } +enum UploadStatus { enqueued, running, complete, failed } class ShareIntentAttachment { final String path; diff --git a/mobile/lib/pages/share_intent/share_intent.page.dart b/mobile/lib/pages/share_intent/share_intent.page.dart index 9d2dbe80c2..2be51fbfc9 100644 --- a/mobile/lib/pages/share_intent/share_intent.page.dart +++ b/mobile/lib/pages/share_intent/share_intent.page.dart @@ -1,7 +1,6 @@ import 'package:auto_route/auto_route.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart' hide Store; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; @@ -12,7 +11,7 @@ import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/utils/url_helper.dart'; @RoutePage() -class ShareIntentPage extends HookConsumerWidget { +class ShareIntentPage extends ConsumerWidget { const ShareIntentPage({super.key, required this.attachments}); final List attachments; @@ -21,12 +20,13 @@ class ShareIntentPage extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final currentEndpoint = getServerUrl() ?? '--'; final candidates = ref.watch(shareIntentUploadProvider); - final isUploaded = useState(false); - useOnAppLifecycleStateChange((previous, current) { - if (current == AppLifecycleState.resumed) { - isUploaded.value = false; - } - }); + + final isUploading = candidates.any((candidate) => candidate.status == UploadStatus.running); + final isUploaded = + candidates.isNotEmpty && + candidates.every( + (candidate) => candidate.status == UploadStatus.complete || candidate.status == UploadStatus.failed, + ); void removeAttachment(ShareIntentAttachment attachment) { ref.read(shareIntentUploadProvider.notifier).removeAttachment(attachment); @@ -37,11 +37,8 @@ class ShareIntentPage extends HookConsumerWidget { } void upload() async { - for (final attachment in candidates) { - await ref.read(shareIntentUploadProvider.notifier).upload(attachment.file); - } - - isUploaded.value = true; + final files = candidates.map((candidate) => candidate.file).toList(); + await ref.read(shareIntentUploadProvider.notifier).uploadAll(files); } bool isSelected(ShareIntentAttachment attachment) { @@ -84,7 +81,7 @@ class ShareIntentPage extends HookConsumerWidget { padding: const EdgeInsets.symmetric(vertical: 4.0, horizontal: 16), child: LargeLeadingTile( onTap: () => toggleSelection(attachment), - disabled: isUploaded.value, + disabled: isUploading || isUploaded, selected: isSelected(attachment), leading: Stack( children: [ @@ -131,8 +128,8 @@ class ShareIntentPage extends HookConsumerWidget { child: SizedBox( height: 48, child: ElevatedButton( - onPressed: isUploaded.value ? null : upload, - child: isUploaded.value ? UploadingText(candidates: candidates) : const Text('upload').tr(), + onPressed: (isUploading || isUploaded) ? null : upload, + child: (isUploading || isUploaded) ? UploadingText(candidates: candidates) : const Text('upload').tr(), ), ), ), @@ -204,14 +201,7 @@ class UploadStatusIcon extends StatelessWidget { ], ), UploadStatus.complete => Icon(Icons.check_circle_rounded, color: Colors.green, semanticLabel: 'completed'.tr()), - UploadStatus.notFound || UploadStatus.failed => Icon(Icons.error_rounded, color: Colors.red, semanticLabel: 'failed'.tr()), - UploadStatus.canceled => Icon(Icons.cancel_rounded, color: Colors.red, semanticLabel: 'canceled'.tr()), - UploadStatus.waitingToRetry || UploadStatus.paused => Icon( - Icons.pause_circle_rounded, - color: context.primaryColor, - semanticLabel: 'paused'.tr(), - ), }; return statusIcon; diff --git a/mobile/lib/providers/asset_viewer/share_intent_upload.provider.dart b/mobile/lib/providers/asset_viewer/share_intent_upload.provider.dart index 881fdc359f..02fddfdfa9 100644 --- a/mobile/lib/providers/asset_viewer/share_intent_upload.provider.dart +++ b/mobile/lib/providers/asset_viewer/share_intent_upload.provider.dart @@ -1,18 +1,12 @@ import 'dart:io'; -import 'package:background_downloader/background_downloader.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/constants/constants.dart'; -import 'package:immich_mobile/domain/models/store.model.dart'; -import 'package:immich_mobile/entities/store.entity.dart'; -import 'package:immich_mobile/extensions/string_extensions.dart'; import 'package:immich_mobile/models/upload/share_intent_attachment.model.dart'; import 'package:immich_mobile/routing/router.dart'; -import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/services/share_intent_service.dart'; import 'package:immich_mobile/services/upload.service.dart'; import 'package:logging/logging.dart'; -import 'package:path/path.dart'; +import 'package:path/path.dart' as p; final shareIntentUploadProvider = StateNotifierProvider>( ((ref) => ShareIntentUploadStateNotifier( @@ -28,10 +22,7 @@ class ShareIntentUploadStateNotifier extends StateNotifier uploadAll(List files) async { + for (final file in files) { + final fileId = p.hash(file.path).toString(); + _updateStatus(fileId, UploadStatus.running); } - final taskId = task.task.taskId; - final uploadStatus = switch (task.status) { - TaskStatus.complete => UploadStatus.complete, - TaskStatus.failed => UploadStatus.failed, - TaskStatus.canceled => UploadStatus.canceled, - TaskStatus.enqueued => UploadStatus.enqueued, - TaskStatus.running => UploadStatus.running, - TaskStatus.paused => UploadStatus.paused, - TaskStatus.notFound => UploadStatus.notFound, - TaskStatus.waitingToRetry => UploadStatus.waitingToRetry, - }; - - state = [ - for (final attachment in state) - if (attachment.id == taskId.toInt()) attachment.copyWith(status: uploadStatus) else attachment, - ]; - - if (task.status == TaskStatus.failed) { - String? error; - final exception = task.exception; - if (exception != null && exception is TaskHttpException) { - final message = tryJsonDecode(exception.description)?['message'] as String?; - if (message != null) { - final responseCode = exception.httpResponseCode; - error = "${exception.exceptionType}, response code $responseCode: $message"; - } - } - error ??= task.exception?.toString(); - - _logger.warning("Upload failed for asset: ${task.task.filename}, error: $error"); - } - } - - void _taskProgressCallback(TaskProgressUpdate update) { - // Ignore if the task is canceled or completed - if (update.progress == downloadFailed || update.progress == downloadCompleted) { - return; - } - - final taskId = update.task.taskId; - state = [ - for (final attachment in state) - if (attachment.id == taskId.toInt()) attachment.copyWith(uploadProgress: update.progress) else attachment, - ]; - } - - Future upload(File file) async { - final task = await _buildUploadTask(hash(file.path).toString(), file); - - await _uploadService.enqueueTasks([task]); - } - - Future _buildUploadTask(String id, File file, {Map? fields}) async { - final serverEndpoint = Store.get(StoreKey.serverEndpoint); - final url = Uri.parse('$serverEndpoint/assets').toString(); - final headers = ApiService.getRequestHeaders(); - final deviceId = Store.get(StoreKey.deviceId); - - final (baseDirectory, directory, filename) = await Task.split(filePath: file.path); - final stats = await file.stat(); - final fileCreatedAt = stats.changed; - final fileModifiedAt = stats.modified; - - final fieldsMap = { - 'filename': filename, - 'deviceAssetId': id, - 'deviceId': deviceId, - 'fileCreatedAt': fileCreatedAt.toUtc().toIso8601String(), - 'fileModifiedAt': fileModifiedAt.toUtc().toIso8601String(), - 'isFavorite': 'false', - 'duration': '0', - if (fields != null) ...fields, - }; - - return UploadTask( - taskId: id, - httpRequestMethod: 'POST', - url: url, - headers: headers, - filename: filename, - fields: fieldsMap, - baseDirectory: baseDirectory, - directory: directory, - fileField: 'assetData', - group: kManualUploadGroup, - updates: Updates.statusAndProgress, + await _uploadService.uploadFilesWithHttp( + files, + onProgress: (fileId, bytes, totalBytes) { + final progress = totalBytes > 0 ? bytes / totalBytes : 0.0; + _updateProgress(fileId, progress); + }, + onSuccess: (fileId) { + _updateStatus(fileId, UploadStatus.complete, progress: 1.0); + }, + onError: (fileId, errorMessage) { + _logger.warning("Upload failed for file: $fileId, error: $errorMessage"); + _updateStatus(fileId, UploadStatus.failed); + }, ); } + + void _updateStatus(String fileId, UploadStatus status, {double? progress}) { + final id = int.parse(fileId); + state = [ + for (final attachment in state) + if (attachment.id == id) + attachment.copyWith(status: status, uploadProgress: progress ?? attachment.uploadProgress) + else + attachment, + ]; + } + + void _updateProgress(String fileId, double progress) { + final id = int.parse(fileId); + state = [ + for (final attachment in state) + if (attachment.id == id) attachment.copyWith(uploadProgress: progress) else attachment, + ]; + } } diff --git a/mobile/lib/services/upload.service.dart b/mobile/lib/services/upload.service.dart index 5f1b8eb77b..d513c7d522 100644 --- a/mobile/lib/services/upload.service.dart +++ b/mobile/lib/services/upload.service.dart @@ -379,6 +379,107 @@ class UploadService { return _uploadRepository.start(); } + /// Upload multiple files using foreground HTTP with concurrent workers + /// This is used for share intent uploads + Future uploadFilesWithHttp( + List files, { + CancellationToken? cancelToken, + void Function(String fileId, int bytes, int totalBytes)? onProgress, + void Function(String fileId)? onSuccess, + void Function(String fileId, String errorMessage)? onError, + }) async { + if (files.isEmpty) { + return; + } + + const concurrentUploads = 3; + final httpClients = List.generate(concurrentUploads, (_) => Client()); + final effectiveCancelToken = cancelToken ?? CancellationToken(); + + try { + int currentIndex = 0; + + Future worker(Client httpClient) async { + while (true) { + if (effectiveCancelToken.isCancelled) break; + + final index = currentIndex; + if (index >= files.length) break; + currentIndex++; + + final file = files[index]; + final fileId = p.hash(file.path).toString(); + + final result = await _uploadSingleFileWithHttp( + file, + deviceAssetId: fileId, + httpClient: httpClient, + cancelToken: effectiveCancelToken, + onProgress: (bytes, totalBytes) => onProgress?.call(fileId, bytes, totalBytes), + ); + + if (result.isSuccess) { + onSuccess?.call(fileId); + } else if (!result.isCancelled && result.errorMessage != null) { + onError?.call(fileId, result.errorMessage!); + } + } + } + + final workerFutures = >[]; + for (int i = 0; i < concurrentUploads; i++) { + workerFutures.add(worker(httpClients[i])); + } + + await Future.wait(workerFutures); + } finally { + for (final client in httpClients) { + client.close(); + } + } + } + + /// Upload a single file using foreground HTTP upload + Future _uploadSingleFileWithHttp( + File file, { + required String deviceAssetId, + required Client httpClient, + required CancellationToken cancelToken, + void Function(int bytes, int totalBytes)? onProgress, + }) async { + try { + final stats = await file.stat(); + final fileCreatedAt = stats.changed; + final fileModifiedAt = stats.modified; + final filename = p.basename(file.path); + + final headers = ApiService.getRequestHeaders(); + final deviceId = Store.get(StoreKey.deviceId); + + final fields = { + 'deviceAssetId': deviceAssetId, + 'deviceId': deviceId, + 'fileCreatedAt': fileCreatedAt.toUtc().toIso8601String(), + 'fileModifiedAt': fileModifiedAt.toUtc().toIso8601String(), + 'isFavorite': 'false', + 'duration': '0', + }; + + return await _uploadRepository.uploadFile( + file: file, + originalFileName: filename, + headers: headers, + fields: fields, + httpClient: httpClient, + cancelToken: cancelToken, + onProgress: onProgress ?? (_, __) {}, + logContext: 'shareIntent[$deviceAssetId]', + ); + } catch (e) { + return UploadResult.error(errorMessage: e.toString()); + } + } + void _handleTaskStatusUpdate(TaskStatusUpdate update) async { switch (update.status) { case TaskStatus.complete: