From 9ea343d373f29880ef87bd12cd9e5323358e902b Mon Sep 17 00:00:00 2001 From: Taksh Date: Fri, 31 Jul 2026 13:27:00 +0300 Subject: [PATCH 1/3] feat(mobile): add cancellable Blossom uploads with bounded timeouts Give each in-flight media PUT its own HTTP client so cancelActiveUploads() can abort stalled requests, and cap image/file/video uploads with explicit timeouts that surface actionable copy instead of hanging forever. Signed-off-by: Taksh --- mobile/lib/shared/relay/media_upload.dart | 124 +++++++++++++++++----- 1 file changed, 95 insertions(+), 29 deletions(-) diff --git a/mobile/lib/shared/relay/media_upload.dart b/mobile/lib/shared/relay/media_upload.dart index 58c93979d7..f5172ba151 100644 --- a/mobile/lib/shared/relay/media_upload.dart +++ b/mobile/lib/shared/relay/media_upload.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:convert'; import 'dart:io'; @@ -27,6 +28,9 @@ const _readClipboardImageMethod = 'readClipboardImage'; const _clipboardHasImageMethod = 'clipboardHasImage'; const _uploadAuthKind = 24242; const _uploadAuthLifetimeSeconds = 300; +const _imageUploadTimeout = Duration(seconds: 120); +const _videoUploadTimeout = Duration(seconds: 300); +const _fileUploadTimeout = Duration(seconds: 180); const _heicBrands = { 'heic', 'heix', @@ -82,6 +86,26 @@ class MediaPolicyUploadException implements Exception { String toString() => _mediaPolicyUploadMessage; } +/// Thrown when the user cancels an in-flight attachment upload. +class MediaUploadCancelledException implements Exception { + const MediaUploadCancelledException(); + + @override + String toString() => 'Upload cancelled'; +} + +/// Thrown when an attachment upload exceeds its bounded timeout. +class MediaUploadTimeoutException implements Exception { + final Duration timeout; + + const MediaUploadTimeoutException(this.timeout); + + @override + String toString() => + 'Upload timed out after ${timeout.inSeconds}s. Check your connection ' + 'and try again.'; +} + @immutable class _PreparedUploadImage { final Uint8List bytes; @@ -185,6 +209,8 @@ class MediaUploadService { final DateTime Function() _now; final http.Client _http; final bool _ownsHttpClient; + final Set _activeUploadClients = {}; + final Set _cancelledUploadClients = {}; MediaUploadService({ required String baseUrl, @@ -220,11 +246,20 @@ class MediaUploadService { _ownsHttpClient = httpClient == null; void dispose() { + cancelActiveUploads(); if (_ownsHttpClient) { _http.close(); } } + /// Abort every in-flight Blossom upload started by this service instance. + void cancelActiveUploads() { + for (final client in _activeUploadClients.toList()) { + _cancelledUploadClients.add(client); + client.close(); + } + } + Future pickAndUploadImage() async { final pickedImage = await _pickGalleryImage(); if (pickedImage == null) return null; @@ -282,7 +317,11 @@ class MediaUploadService { ); } final bytes = await transcodedFile.readAsBytes(); - return uploadBytes(bytes, mimeType: 'video/mp4'); + return uploadBytes( + bytes, + mimeType: 'video/mp4', + timeout: _videoUploadTimeout, + ); } finally { if (transcodedPath != null) { try { @@ -325,6 +364,7 @@ class MediaUploadService { bytes, mimeType: 'application/octet-stream', allowGenericFile: true, + timeout: _fileUploadTimeout, ); return descriptor.withFilename(_safeAttachmentFilename(pickedFile.name)); } @@ -338,6 +378,7 @@ class MediaUploadService { Future uploadBytes( Uint8List bytes, { required String mimeType, + Duration timeout = _imageUploadTimeout, }) async { if (mimeType == 'image/gif' || (mimeType == 'image/png' && _isAnimatedPng(bytes)) || @@ -348,13 +389,14 @@ class MediaUploadService { throw Exception('failed to sanitize image for upload'); } } - return _uploadPreparedBytes(bytes, mimeType: mimeType); + return _uploadPreparedBytes(bytes, mimeType: mimeType, timeout: timeout); } Future _uploadPreparedBytes( Uint8List bytes, { required String mimeType, bool allowGenericFile = false, + Duration timeout = _imageUploadTimeout, }) async { if (!allowGenericFile && !_allowedImageMimeTypes.contains(mimeType) && @@ -363,40 +405,64 @@ class MediaUploadService { } final sha256 = _sha256Hex(bytes); - var request = _buildUploadRequest( - bytes: bytes, - mimeType: mimeType, - sha256: sha256, - path: _mediaUploadPath, - ); - - var streamed = await _http.send(request); - var response = await http.Response.fromStream(streamed); - if (response.statusCode == HttpStatus.notFound || - response.statusCode == HttpStatus.methodNotAllowed) { - request = _buildUploadRequest( + final uploadClient = http.Client(); + _activeUploadClients.add(uploadClient); + try { + var request = _buildUploadRequest( bytes: bytes, mimeType: mimeType, sha256: sha256, - path: _legacyMediaUploadPath, + path: _mediaUploadPath, ); - streamed = await _http.send(request); - response = await http.Response.fromStream(streamed); - } - if (response.statusCode < 200 || response.statusCode >= 300) { - if (_allowedImageMimeTypes.contains(mimeType) && - (response.statusCode == HttpStatus.unsupportedMediaType || - response.statusCode == HttpStatus.unprocessableEntity)) { - throw const MediaPolicyUploadException(); + + var streamed = await uploadClient + .send(request) + .timeout(timeout, onTimeout: () { + throw MediaUploadTimeoutException(timeout); + }); + var response = await http.Response.fromStream(streamed); + if (response.statusCode == HttpStatus.notFound || + response.statusCode == HttpStatus.methodNotAllowed) { + request = _buildUploadRequest( + bytes: bytes, + mimeType: mimeType, + sha256: sha256, + path: _legacyMediaUploadPath, + ); + streamed = await uploadClient + .send(request) + .timeout(timeout, onTimeout: () { + throw MediaUploadTimeoutException(timeout); + }); + response = await http.Response.fromStream(streamed); } - throw Exception( - 'upload failed (${response.statusCode}): ${response.body}', + if (response.statusCode < 200 || response.statusCode >= 300) { + if (_allowedImageMimeTypes.contains(mimeType) && + (response.statusCode == HttpStatus.unsupportedMediaType || + response.statusCode == HttpStatus.unprocessableEntity)) { + throw const MediaPolicyUploadException(); + } + throw Exception( + 'upload failed (${response.statusCode}): ${response.body}', + ); + } + + return BlobDescriptor.fromJson( + jsonDecode(response.body) as Map, ); + } on MediaUploadTimeoutException { + rethrow; + } on MediaUploadCancelledException { + rethrow; + } on http.ClientException { + if (_cancelledUploadClients.remove(uploadClient)) { + throw const MediaUploadCancelledException(); + } + rethrow; + } finally { + _activeUploadClients.remove(uploadClient); + uploadClient.close(); } - - return BlobDescriptor.fromJson( - jsonDecode(response.body) as Map, - ); } http.Request _buildUploadRequest({ From 9c9c16abc8c20b8f1154d6d83fff36fb30e54ea7 Mon Sep 17 00:00:00 2001 From: Taksh Date: Fri, 31 Jul 2026 13:27:22 +0300 Subject: [PATCH 2/3] fix(mobile): add cancel and dismiss controls for composer uploads Expose a cancel action on the upload progress tile, let users dismiss failed upload copy, and keep cancellation silent while surfacing timeout errors. Signed-off-by: Taksh --- mobile/lib/features/channels/compose_bar.dart | 16 +++++-- .../channels/compose_bar/attachments.dart | 29 +++++++++++++ .../features/channels/compose_bar/layout.dart | 42 +++++++++++++++---- .../channels/compose_bar/send_button.dart | 9 ++++ 4 files changed, 85 insertions(+), 11 deletions(-) diff --git a/mobile/lib/features/channels/compose_bar.dart b/mobile/lib/features/channels/compose_bar.dart index 7560f998f3..2004613ae1 100644 --- a/mobile/lib/features/channels/compose_bar.dart +++ b/mobile/lib/features/channels/compose_bar.dart @@ -546,7 +546,7 @@ class ComposeBar extends HookConsumerWidget { attachments.value = [...attachments.value, uploaded]; } } catch (error) { - if (context.mounted) { + if (context.mounted && _shouldSurfaceUploadError(error)) { uploadError.value = _formatUploadError(error); } } finally { @@ -566,12 +566,18 @@ class ComposeBar extends HookConsumerWidget { if (picked == null || !context.mounted) return; await pickAndUpload(() => upload(picked)); } catch (error) { - if (context.mounted) { + if (context.mounted && _shouldSurfaceUploadError(error)) { uploadError.value = _formatUploadError(error); } } } + void cancelPendingUploads() { + ref.read(mediaUploadServiceProvider).cancelActiveUploads(); + uploadingCount.value = 0; + uploadError.value = null; + } + Future uploadImages(List images) async { if (images.isEmpty) return; uploadError.value = null; @@ -617,7 +623,7 @@ class ComposeBar extends HookConsumerWidget { .map((result) => result.error) .whereType() .firstOrNull; - if (firstError != null) { + if (firstError != null && _shouldSurfaceUploadError(firstError)) { uploadError.value = _formatUploadError(firstError); } } finally { @@ -727,7 +733,7 @@ class ComposeBar extends HookConsumerWidget { try { await choose(); } catch (error) { - if (context.mounted) { + if (context.mounted && _shouldSurfaceUploadError(error)) { uploadError.value = errorMessage ?? _formatUploadError(error); } } @@ -951,6 +957,8 @@ class ComposeBar extends HookConsumerWidget { uploadingCount: uploadingCount.value, onRemoveAttachment: removeAttachment, uploadError: uploadError.value, + onDismissUploadError: () => uploadError.value = null, + onCancelUpload: hasPendingUploads ? cancelPendingUploads : null, isExpanded: isComposerExpanded.value, controller: controller, focusNode: focusNode, diff --git a/mobile/lib/features/channels/compose_bar/attachments.dart b/mobile/lib/features/channels/compose_bar/attachments.dart index 7c53ae1098..30751460f1 100644 --- a/mobile/lib/features/channels/compose_bar/attachments.dart +++ b/mobile/lib/features/channels/compose_bar/attachments.dart @@ -471,11 +471,13 @@ class _AttachmentStrip extends StatelessWidget { final List attachments; final int uploadingCount; final void Function(String url) onRemove; + final VoidCallback? onCancelUpload; const _AttachmentStrip({ required this.attachments, required this.uploadingCount, required this.onRemove, + this.onCancelUpload, }); @override @@ -539,6 +541,33 @@ class _AttachmentStrip extends StatelessWidget { ), ), ), + if (onCancelUpload != null) + PositionedDirectional( + top: 0, + end: 0, + child: Semantics( + button: true, + label: 'Cancel upload', + child: Material( + color: context.colors.surface.withValues( + alpha: 0.92, + ), + shape: const CircleBorder(), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onCancelUpload, + child: Padding( + padding: const EdgeInsets.all(4), + child: Icon( + LucideIcons.x, + size: 14, + color: context.colors.onSurfaceVariant, + ), + ), + ), + ), + ), + ), ], ), ), diff --git a/mobile/lib/features/channels/compose_bar/layout.dart b/mobile/lib/features/channels/compose_bar/layout.dart index 31b930e36d..3c55855846 100644 --- a/mobile/lib/features/channels/compose_bar/layout.dart +++ b/mobile/lib/features/channels/compose_bar/layout.dart @@ -5,6 +5,8 @@ class _ComposeBarLayout extends StatelessWidget { final int uploadingCount; final ValueChanged onRemoveAttachment; final String? uploadError; + final VoidCallback? onDismissUploadError; + final VoidCallback? onCancelUpload; final bool isExpanded; final TextEditingController controller; final FocusNode focusNode; @@ -33,6 +35,8 @@ class _ComposeBarLayout extends StatelessWidget { required this.uploadingCount, required this.onRemoveAttachment, required this.uploadError, + this.onDismissUploadError, + this.onCancelUpload, required this.isExpanded, required this.controller, required this.focusNode, @@ -81,18 +85,42 @@ class _ComposeBarLayout extends StatelessWidget { attachments: attachments, uploadingCount: uploadingCount, onRemove: onRemoveAttachment, + onCancelUpload: onCancelUpload, ), const SizedBox(height: Grid.xxs), ], if (uploadError case final error?) ...[ - Align( - alignment: Alignment.centerLeft, - child: Text( - error, - style: context.textTheme.bodySmall?.copyWith( - color: context.colors.error, + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Text( + error, + style: context.textTheme.bodySmall?.copyWith( + color: context.colors.error, + ), + ), ), - ), + if (onDismissUploadError != null) + Semantics( + button: true, + label: 'Dismiss upload error', + child: IconButton( + visualDensity: VisualDensity.compact, + padding: EdgeInsets.zero, + constraints: const BoxConstraints( + minWidth: 32, + minHeight: 32, + ), + onPressed: onDismissUploadError, + icon: Icon( + LucideIcons.x, + size: 16, + color: context.colors.error, + ), + ), + ), + ], ), const SizedBox(height: Grid.xxs), ], diff --git a/mobile/lib/features/channels/compose_bar/send_button.dart b/mobile/lib/features/channels/compose_bar/send_button.dart index 54060ae948..0a22df48e8 100644 --- a/mobile/lib/features/channels/compose_bar/send_button.dart +++ b/mobile/lib/features/channels/compose_bar/send_button.dart @@ -43,5 +43,14 @@ class _SendButton extends StatelessWidget { } String _formatUploadError(Object error) { + if (error is MediaUploadCancelledException) { + return ''; + } + if (error is MediaUploadTimeoutException) { + return error.toString(); + } return error.toString().replaceFirst('Exception: ', ''); } + +bool _shouldSurfaceUploadError(Object error) => + error is! MediaUploadCancelledException; From f353cb0315ff2e357f7706bc5fb9dda1540fdf51 Mon Sep 17 00:00:00 2001 From: Taksh Date: Fri, 31 Jul 2026 13:31:23 +0300 Subject: [PATCH 3/3] test(mobile): cover upload cancellation and timeout recovery Add an injectable upload HTTP client factory for tests and assert cancel and timeout paths surface the dedicated recovery exceptions. Signed-off-by: Taksh --- mobile/lib/shared/relay/media_upload.dart | 26 ++++-- .../test/shared/relay/media_upload_test.dart | 81 +++++++++++++++++++ 2 files changed, 99 insertions(+), 8 deletions(-) diff --git a/mobile/lib/shared/relay/media_upload.dart b/mobile/lib/shared/relay/media_upload.dart index f5172ba151..49ae0647a8 100644 --- a/mobile/lib/shared/relay/media_upload.dart +++ b/mobile/lib/shared/relay/media_upload.dart @@ -78,6 +78,7 @@ typedef SanitizeImageBytes = typedef TranscodeImageToJpeg = Future Function(Uint8List bytes); typedef TranscodeVideoToMp4 = Future Function(String filePath); typedef ReadClipboardImage = Future Function(); +typedef UploadHttpClientFactory = http.Client Function(); class MediaPolicyUploadException implements Exception { const MediaPolicyUploadException(); @@ -209,6 +210,7 @@ class MediaUploadService { final DateTime Function() _now; final http.Client _http; final bool _ownsHttpClient; + final UploadHttpClientFactory? _uploadHttpClientFactory; final Set _activeUploadClients = {}; final Set _cancelledUploadClients = {}; @@ -225,6 +227,7 @@ class MediaUploadService { ReadClipboardImage? readClipboardImage, DateTime Function()? now, http.Client? httpClient, + UploadHttpClientFactory? uploadHttpClientFactory, }) : _baseUrl = baseUrl, _nsec = nsec, _pickGalleryImage = pickGalleryImage, @@ -243,7 +246,8 @@ class MediaUploadService { _readClipboardImage = readClipboardImage ?? _readPlatformClipboardImage, _now = now ?? DateTime.now, _http = httpClient ?? http.Client(), - _ownsHttpClient = httpClient == null; + _ownsHttpClient = httpClient == null, + _uploadHttpClientFactory = uploadHttpClientFactory; void dispose() { cancelActiveUploads(); @@ -405,7 +409,7 @@ class MediaUploadService { } final sha256 = _sha256Hex(bytes); - final uploadClient = http.Client(); + final uploadClient = (_uploadHttpClientFactory ?? http.Client.new)(); _activeUploadClients.add(uploadClient); try { var request = _buildUploadRequest( @@ -417,9 +421,12 @@ class MediaUploadService { var streamed = await uploadClient .send(request) - .timeout(timeout, onTimeout: () { - throw MediaUploadTimeoutException(timeout); - }); + .timeout( + timeout, + onTimeout: () { + throw MediaUploadTimeoutException(timeout); + }, + ); var response = await http.Response.fromStream(streamed); if (response.statusCode == HttpStatus.notFound || response.statusCode == HttpStatus.methodNotAllowed) { @@ -431,9 +438,12 @@ class MediaUploadService { ); streamed = await uploadClient .send(request) - .timeout(timeout, onTimeout: () { - throw MediaUploadTimeoutException(timeout); - }); + .timeout( + timeout, + onTimeout: () { + throw MediaUploadTimeoutException(timeout); + }, + ); response = await http.Response.fromStream(streamed); } if (response.statusCode < 200 || response.statusCode >= 300) { diff --git a/mobile/test/shared/relay/media_upload_test.dart b/mobile/test/shared/relay/media_upload_test.dart index dcd04359b2..bbfc157033 100644 --- a/mobile/test/shared/relay/media_upload_test.dart +++ b/mobile/test/shared/relay/media_upload_test.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:convert'; import 'dart:io'; @@ -238,6 +239,32 @@ final _animatedWebpBytes = Uint8List.fromList([ const _mediaUploadPlatformChannel = MethodChannel('buzz/media_upload'); +class _CancellableTestClient extends http.BaseClient { + http.BaseRequest? _activeRequest; + Completer? _wait; + + @override + Future send(http.BaseRequest request) async { + _activeRequest = request; + _wait = Completer(); + try { + await _wait!.future; + throw StateError('upload should have been cancelled'); + } on http.ClientException { + rethrow; + } + } + + @override + void close() { + final request = _activeRequest; + if (request != null && _wait != null && !_wait!.isCompleted) { + _wait!.completeError(http.ClientException('closed', request.url)); + } + super.close(); + } +} + void _setMockMediaUploadPlatformHandler( Future Function(MethodCall call)? handler, ) { @@ -1383,4 +1410,58 @@ void main() { } }); }); + + group('upload cancellation and timeouts', () { + test('cancelActiveUploads aborts an in-flight PUT', () async { + final keychain = nostr.Keys.generate(); + final client = _CancellableTestClient(); + final service = MediaUploadService( + baseUrl: 'https://relay.example', + nsec: keychain.nsec, + pickGalleryImage: () async => null, + pickGalleryVideo: () async => null, + uploadHttpClientFactory: () => client, + now: () => DateTime.fromMillisecondsSinceEpoch(1_700_000_000_000), + ); + addTearDown(service.dispose); + + final uploadFuture = service.uploadBytes( + _jpegBytes, + mimeType: 'image/jpeg', + ); + await Future.delayed(const Duration(milliseconds: 20)); + service.cancelActiveUploads(); + + await expectLater( + uploadFuture, + throwsA(isA()), + ); + }); + + test('uploadBytes surfaces MediaUploadTimeoutException', () async { + final keychain = nostr.Keys.generate(); + final client = http_testing.MockClient((request) async { + await Future.delayed(const Duration(seconds: 30)); + return http.Response('{}', 200); + }); + final service = MediaUploadService( + baseUrl: 'https://relay.example', + nsec: keychain.nsec, + pickGalleryImage: () async => null, + pickGalleryVideo: () async => null, + uploadHttpClientFactory: () => client, + now: () => DateTime.fromMillisecondsSinceEpoch(1_700_000_000_000), + ); + addTearDown(service.dispose); + + await expectLater( + service.uploadBytes( + _jpegBytes, + mimeType: 'image/jpeg', + timeout: const Duration(milliseconds: 100), + ), + throwsA(isA()), + ); + }); + }); }