diff --git a/mobile/ios/Runner/AppDelegate.swift b/mobile/ios/Runner/AppDelegate.swift index 9fe13485c4..c1bc7a7423 100644 --- a/mobile/ios/Runner/AppDelegate.swift +++ b/mobile/ios/Runner/AppDelegate.swift @@ -118,11 +118,37 @@ import UserNotifications return } transcodeVideoToMp4(sourcePath: sourcePath, result: result) + case "clipboardHasImage": + result(UIPasteboard.general.hasImages) + case "readClipboardImage": + guard let imageData = Self.clipboardImageData(from: UIPasteboard.general) else { + result(nil) + return + } + result(FlutterStandardTypedData(bytes: imageData)) default: result(FlutterMethodNotImplemented) } } + static func clipboardImageData(from pasteboard: UIPasteboard) -> Data? { + if let pngData = pasteboard.data(forPasteboardType: "public.png") { + return pngData + } + if let jpegData = pasteboard.data(forPasteboardType: "public.jpeg") { + return jpegData + } + for imageType in ["public.heic", "public.heif", "org.webmproject.webp", "com.compuserve.gif"] { + if let imageData = pasteboard.data(forPasteboardType: imageType) { + return imageData + } + } + guard let image = pasteboard.image else { + return nil + } + return image.pngData() + } + private func transcodeVideoToMp4( sourcePath: String, result: @escaping FlutterResult diff --git a/mobile/ios/RunnerTests/RunnerTests.swift b/mobile/ios/RunnerTests/RunnerTests.swift index 86a7c3b1b6..b898bf861a 100644 --- a/mobile/ios/RunnerTests/RunnerTests.swift +++ b/mobile/ios/RunnerTests/RunnerTests.swift @@ -1,12 +1,54 @@ import Flutter import UIKit import XCTest +@testable import Buzz class RunnerTests: XCTestCase { - func testExample() { - // If you add code to the Runner application, consider adding tests here. - // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + func testClipboardImageDataPrefersOriginalPngBytes() throws { + let pasteboard = try XCTUnwrap( + UIPasteboard(name: UIPasteboard.Name(UUID().uuidString), create: true) + ) + defer { UIPasteboard.remove(withName: pasteboard.name) } + let pngData = Data([0x89, 0x50, 0x4E, 0x47]) + let jpegData = Data([0xFF, 0xD8, 0xFF]) + pasteboard.setItems([ + ["public.png": pngData, "public.jpeg": jpegData] + ]) + + XCTAssertEqual(AppDelegate.clipboardImageData(from: pasteboard), pngData) + } + + func testClipboardImageDataPreservesOriginalWebPBytesForValidation() throws { + let pasteboard = try XCTUnwrap( + UIPasteboard(name: UIPasteboard.Name(UUID().uuidString), create: true) + ) + defer { UIPasteboard.remove(withName: pasteboard.name) } + let webPData = Data("RIFFxxxxWEBP".utf8) + pasteboard.setData(webPData, forPasteboardType: "org.webmproject.webp") + + XCTAssertEqual(AppDelegate.clipboardImageData(from: pasteboard), webPData) + } + + func testClipboardImageDataPreservesOriginalGifBytesForValidation() throws { + let pasteboard = try XCTUnwrap( + UIPasteboard(name: UIPasteboard.Name(UUID().uuidString), create: true) + ) + defer { UIPasteboard.remove(withName: pasteboard.name) } + let gifData = Data("GIF89a".utf8) + pasteboard.setData(gifData, forPasteboardType: "com.compuserve.gif") + + XCTAssertEqual(AppDelegate.clipboardImageData(from: pasteboard), gifData) + } + + func testClipboardImageDataReturnsNilWithoutAnImage() throws { + let pasteboard = try XCTUnwrap( + UIPasteboard(name: UIPasteboard.Name(UUID().uuidString), create: true) + ) + defer { UIPasteboard.remove(withName: pasteboard.name) } + pasteboard.string = "text only" + + XCTAssertNil(AppDelegate.clipboardImageData(from: pasteboard)) } } diff --git a/mobile/lib/features/channels/compose_bar.dart b/mobile/lib/features/channels/compose_bar.dart index 4ba08a05c2..e8bb9a9d93 100644 --- a/mobile/lib/features/channels/compose_bar.dart +++ b/mobile/lib/features/channels/compose_bar.dart @@ -1,8 +1,10 @@ import 'dart:collection'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:image_picker/image_picker.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:nostr/nostr.dart' as nostr; @@ -27,6 +29,13 @@ part 'compose_bar/formatting_toolbar.dart'; part 'compose_bar/attachments.dart'; part 'compose_bar/send_button.dart'; +const _pastedImageMimeTypes = [ + 'image/jpeg', + 'image/jpg', + 'image/png', + 'image/webp', +]; + /// Rich compose bar with @mention autocomplete, emoji picker, and a markdown /// formatting toolbar. Used in both channel and thread views — the caller /// provides an [onSend] callback that handles actual message submission. @@ -66,6 +75,7 @@ class ComposeBar extends HookConsumerWidget { final attachments = useState>([]); final uploadError = useState(null); final uploadingCount = useState(0); + final clipboardHasImage = useState(false); final hasAttachments = attachments.value.isNotEmpty; final hasPendingUploads = uploadingCount.value > 0; final customEmoji = ref.watch(customEmojiListProvider); @@ -74,6 +84,35 @@ class ComposeBar extends HookConsumerWidget { hintText ?? (channelName.isNotEmpty ? 'Message #$channelName' : 'Message\u2026'); + useEffect(() { + if (defaultTargetPlatform != TargetPlatform.iOS) return null; + + var disposed = false; + Future refreshClipboardAvailability() async { + final hasImage = await ref + .read(mediaUploadServiceProvider) + .clipboardHasImage(); + if (!disposed && context.mounted) { + clipboardHasImage.value = hasImage; + } + } + + void refreshWhenFocused() { + if (focusNode.hasFocus) refreshClipboardAvailability(); + } + + final lifecycleListener = AppLifecycleListener( + onResume: refreshClipboardAvailability, + ); + focusNode.addListener(refreshWhenFocused); + refreshClipboardAvailability(); + return () { + disposed = true; + focusNode.removeListener(refreshWhenFocused); + lifecycleListener.dispose(); + }; + }, [focusNode]); + // Mention state -------------------------------------------------------- final mentionQuery = useState(null); final mentionStartIdx = useState(-1); @@ -347,6 +386,60 @@ class ComposeBar extends HookConsumerWidget { } } + Widget buildContextMenu( + BuildContext context, + EditableTextState editableTextState, + ) { + void pasteImage() { + ContextMenuController.removeAny(); + pickAndUpload( + ref.read(mediaUploadServiceProvider).readAndUploadClipboardImage, + ); + } + + if (defaultTargetPlatform == TargetPlatform.iOS && + SystemContextMenu.isSupportedByField(editableTextState)) { + return SystemContextMenu.editableText( + editableTextState: editableTextState, + items: [ + if (clipboardHasImage.value) + IOSSystemContextMenuItemCustom( + title: 'Paste Image', + onPressed: pasteImage, + ), + ...SystemContextMenu.getDefaultItems(editableTextState), + ], + ); + } + + final buttonItems = [...editableTextState.contextMenuButtonItems]; + if (defaultTargetPlatform == TargetPlatform.iOS && + clipboardHasImage.value) { + buttonItems.insert( + 0, + ContextMenuButtonItem(label: 'Paste Image', onPressed: pasteImage), + ); + } + return AdaptiveTextSelectionToolbar.buttonItems( + anchors: editableTextState.contextMenuAnchors, + buttonItems: buttonItems, + ); + } + + void uploadPastedImage(KeyboardInsertedContent content) { + final bytes = content.data; + if (bytes == null || bytes.isEmpty) { + uploadError.value = 'Unable to read pasted image'; + return; + } + + pickAndUpload( + () => ref + .read(mediaUploadServiceProvider) + .uploadImage(XFile.fromData(bytes)), + ); + } + // Insert an emoji at the cursor. void insertEmoji(String emoji) { final text = controller.text; @@ -478,6 +571,11 @@ class ComposeBar extends HookConsumerWidget { controller: controller, focusNode: focusNode, textInputAction: TextInputAction.send, + contextMenuBuilder: buildContextMenu, + contentInsertionConfiguration: ContentInsertionConfiguration( + allowedMimeTypes: _pastedImageMimeTypes, + onContentInserted: uploadPastedImage, + ), onSubmitted: (_) => send(), minLines: 1, maxLines: 5, diff --git a/mobile/lib/shared/relay/media_upload.dart b/mobile/lib/shared/relay/media_upload.dart index 58926a8aa1..63fdc24583 100644 --- a/mobile/lib/shared/relay/media_upload.dart +++ b/mobile/lib/shared/relay/media_upload.dart @@ -16,6 +16,8 @@ const _mediaUploadPlatformChannelName = 'buzz/media_upload'; const _sanitizeImageForUploadMethod = 'sanitizeImageForUpload'; const _transcodeVideoToMp4Method = 'transcodeVideoToMp4'; const _transcodeImageToJpegMethod = 'transcodeImageToJpeg'; +const _readClipboardImageMethod = 'readClipboardImage'; +const _clipboardHasImageMethod = 'clipboardHasImage'; const _uploadAuthKind = 24242; const _uploadAuthLifetimeSeconds = 300; const _heicBrands = { @@ -49,6 +51,7 @@ typedef SanitizeImageBytes = Future Function(Uint8List bytes, String mimeType); typedef TranscodeImageToJpeg = Future Function(Uint8List bytes); typedef TranscodeVideoToMp4 = Future Function(String filePath); +typedef ReadClipboardImage = Future Function(); @immutable class _PreparedUploadImage { @@ -122,6 +125,7 @@ class MediaUploadService { final SanitizeImageBytes _sanitizeImageBytes; final TranscodeImageToJpeg _transcodeImageToJpeg; final TranscodeVideoToMp4 _transcodeVideoToMp4; + final ReadClipboardImage _readClipboardImage; final DateTime Function() _now; final http.Client _http; final bool _ownsHttpClient; @@ -134,6 +138,7 @@ class MediaUploadService { SanitizeImageBytes? sanitizeImageBytes, TranscodeImageToJpeg? transcodeImageToJpeg, TranscodeVideoToMp4? transcodeVideoToMp4, + ReadClipboardImage? readClipboardImage, DateTime Function()? now, http.Client? httpClient, }) : _baseUrl = baseUrl, @@ -144,6 +149,7 @@ class MediaUploadService { _transcodeImageToJpeg = transcodeImageToJpeg ?? _transcodePickedImageToJpeg, _transcodeVideoToMp4 = transcodeVideoToMp4 ?? _transcodePickedVideoToMp4, + _readClipboardImage = readClipboardImage ?? _readPlatformClipboardImage, _now = now ?? DateTime.now, _http = httpClient ?? http.Client(), _ownsHttpClient = httpClient == null; @@ -157,10 +163,29 @@ class MediaUploadService { Future pickAndUploadImage() async { final pickedImage = await _pickGalleryImage(); if (pickedImage == null) return null; - final preparedImage = await _prepareUploadImage(pickedImage); + return uploadImage(pickedImage); + } + + Future uploadImage(XFile image) async { + final preparedImage = await _prepareUploadImage(image); return uploadBytes(preparedImage.bytes, mimeType: preparedImage.mimeType); } + Future clipboardHasImage() async { + return await _mediaUploadPlatformChannel.invokeMethod( + _clipboardHasImageMethod, + ) ?? + false; + } + + Future readAndUploadClipboardImage() async { + final bytes = await _readClipboardImage(); + if (bytes == null || bytes.isEmpty) { + throw Exception('Unable to read pasted image'); + } + return uploadImage(XFile.fromData(bytes)); + } + Future pickAndUploadVideo() async { final pickedVideo = await _pickGalleryVideo(); if (pickedVideo == null) return null; @@ -578,6 +603,12 @@ Future _readFileHeader(String path, int count) async { } } +Future _readPlatformClipboardImage() async { + return _mediaUploadPlatformChannel.invokeMethod( + _readClipboardImageMethod, + ); +} + Future _transcodePickedVideoToMp4(String filePath) async { final result = await _mediaUploadPlatformChannel.invokeMethod( _transcodeVideoToMp4Method, diff --git a/mobile/test/features/channels/compose_bar_test.dart b/mobile/test/features/channels/compose_bar_test.dart index d2ced10ddd..1b0fd60c2c 100644 --- a/mobile/test/features/channels/compose_bar_test.dart +++ b/mobile/test/features/channels/compose_bar_test.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -117,6 +118,7 @@ Widget _buildComposeBar({ List relayAgents = const [], List channels = const [], String? currentPubkey, + bool? supportsShowingSystemContextMenu, }) { return ProviderScope( overrides: [ @@ -135,6 +137,15 @@ Widget _buildComposeBar({ ], child: MaterialApp( theme: AppTheme.light(), + builder: supportsShowingSystemContextMenu == null + ? null + : (context, child) => MediaQuery( + data: MediaQuery.of(context).copyWith( + supportsShowingSystemContextMenu: + supportsShowingSystemContextMenu, + ), + child: child!, + ), home: Scaffold( body: SafeArea( child: ComposeBar(channelId: 'channel-1', onSend: onSend), @@ -209,6 +220,8 @@ void main() { return arguments['bytes'] as Uint8List; case 'transcodeImageToJpeg': return _pngBytes; + case 'clipboardHasImage': + return true; default: return null; } @@ -285,6 +298,415 @@ void main() { expect(find.byTooltip('Remove attachment'), findsNothing); }); + testWidgets('pasted image follows the attachment preview and send path', ( + tester, + ) async { + final keychain = nostr.Keys.generate(); + var galleryPickerCalled = false; + Uint8List? uploadedBytes; + String? uploadedMimeType; + final uploadService = MediaUploadService( + baseUrl: 'https://relay.example', + nsec: keychain.nsec, + httpClient: http_testing.MockClient((request) async { + uploadedBytes = request.bodyBytes; + uploadedMimeType = request.headers['Content-Type']; + return http.Response( + jsonEncode({ + 'url': 'https://relay.example/media/pasted.png', + 'sha256': + '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef', + 'size': 16, + 'type': 'image/png', + 'uploaded': 1, + 'thumb': 'https://relay.example/media/pasted.thumb.jpg', + }), + 200, + ); + }), + pickGalleryVideo: () async => null, + pickGalleryImage: () async { + galleryPickerCalled = true; + return null; + }, + ); + + String? sentContent; + List> sentMediaTags = const []; + await tester.pumpWidget( + _buildComposeBar( + uploadService: uploadService, + onSend: + ( + content, + mentionPubkeys, { + mediaTags = const >[], + }) async { + sentContent = content; + sentMediaTags = mediaTags; + }, + ), + ); + + final textField = tester.widget(find.byType(TextField)); + final insertionConfiguration = textField.contentInsertionConfiguration; + expect(insertionConfiguration, isNotNull); + expect( + insertionConfiguration!.allowedMimeTypes, + containsAll(['image/jpeg', 'image/png', 'image/webp']), + ); + + insertionConfiguration.onContentInserted( + KeyboardInsertedContent( + mimeType: 'image/png', + uri: 'content://clipboard/pasted.png', + data: _pngBytes, + ), + ); + await tester.pumpAndSettle(); + + expect(galleryPickerCalled, isFalse); + expect(uploadedBytes, _pngBytes); + expect(uploadedMimeType, 'image/png'); + expect( + find.byKey( + const ValueKey( + 'compose-attachment:https://relay.example/media/pasted.png', + ), + ), + findsOneWidget, + ); + expect(find.byTooltip('Remove attachment'), findsOneWidget); + + await tester.tap(find.byIcon(LucideIcons.sendHorizontal)); + await tester.pumpAndSettle(); + + expect(sentContent, '\n![image](https://relay.example/media/pasted.png)'); + expect(sentMediaTags, hasLength(1)); + expect( + sentMediaTags.single, + contains('url https://relay.example/media/pasted.png'), + ); + }); + + testWidgets('iOS native context menu preserves defaults and pastes image', ( + tester, + ) async { + final previousPlatform = debugDefaultTargetPlatformOverride; + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + try { + final uploadService = MediaUploadService( + baseUrl: 'https://relay.example', + nsec: nostr.Keys.generate().nsec, + httpClient: http_testing.MockClient( + (request) async => http.Response( + jsonEncode({ + 'url': 'https://relay.example/media/ios-native-paste.png', + 'sha256': + '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef', + 'size': 16, + 'type': 'image/png', + 'uploaded': 1, + }), + 200, + ), + ), + pickGalleryVideo: () async => null, + pickGalleryImage: () async => null, + readClipboardImage: () async => _pngBytes, + ); + await tester.pumpWidget( + _buildComposeBar( + uploadService: uploadService, + supportsShowingSystemContextMenu: true, + onSend: + ( + content, + mentionPubkeys, { + mediaTags = const >[], + }) async {}, + ), + ); + + final textField = tester.widget(find.byType(TextField)); + final editableTextState = tester.state( + find.byType(EditableText), + ); + final defaultItems = SystemContextMenu.getDefaultItems( + editableTextState, + ); + final menu = + textField.contextMenuBuilder!( + tester.element(find.byType(TextField)), + editableTextState, + ) + as SystemContextMenu; + final pasteImage = menu.items.first as IOSSystemContextMenuItemCustom; + + expect(pasteImage.title, 'Paste Image'); + expect(menu.items.skip(1), orderedEquals(defaultItems)); + pasteImage.onPressed(); + await tester.pumpAndSettle(); + + expect( + find.byKey( + const ValueKey( + 'compose-attachment:https://relay.example/media/ios-native-paste.png', + ), + ), + findsOneWidget, + ); + } finally { + debugDefaultTargetPlatformOverride = previousPlatform; + } + }); + + testWidgets('iOS hides Paste Image when clipboard has no image', ( + tester, + ) async { + final previousPlatform = debugDefaultTargetPlatformOverride; + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + _setMockMediaUploadPlatformHandler((call) async { + if (call.method == 'clipboardHasImage') return false; + return null; + }); + try { + final uploadService = MediaUploadService( + baseUrl: 'https://relay.example', + nsec: nostr.Keys.generate().nsec, + pickGalleryVideo: () async => null, + pickGalleryImage: () async => null, + ); + await tester.pumpWidget( + _buildComposeBar( + uploadService: uploadService, + supportsShowingSystemContextMenu: true, + onSend: + ( + content, + mentionPubkeys, { + mediaTags = const >[], + }) async {}, + ), + ); + await tester.pump(); + + final textField = tester.widget(find.byType(TextField)); + final editableTextState = tester.state( + find.byType(EditableText), + ); + final menu = + textField.contextMenuBuilder!( + tester.element(find.byType(TextField)), + editableTextState, + ) + as SystemContextMenu; + + expect(menu.items.whereType(), isEmpty); + } finally { + _setMockMediaUploadPlatformHandler((call) async { + switch (call.method) { + case 'sanitizeImageForUpload': + final arguments = call.arguments as Map; + return arguments['bytes'] as Uint8List; + case 'transcodeImageToJpeg': + return _pngBytes; + case 'clipboardHasImage': + return true; + default: + return null; + } + }); + debugDefaultTargetPlatformOverride = previousPlatform; + } + }); + + testWidgets('iOS adaptive Paste Image reads the clipboard into shared path', ( + tester, + ) async { + final previousPlatform = debugDefaultTargetPlatformOverride; + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + try { + final uploadService = MediaUploadService( + baseUrl: 'https://relay.example', + nsec: nostr.Keys.generate().nsec, + httpClient: http_testing.MockClient( + (request) async => http.Response( + jsonEncode({ + 'url': 'https://relay.example/media/ios-paste.png', + 'sha256': + '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef', + 'size': 16, + 'type': 'image/png', + 'uploaded': 1, + }), + 200, + ), + ), + pickGalleryVideo: () async => null, + pickGalleryImage: () async => null, + readClipboardImage: () async => _pngBytes, + ); + await tester.pumpWidget( + _buildComposeBar( + uploadService: uploadService, + onSend: + ( + content, + mentionPubkeys, { + mediaTags = const >[], + }) async {}, + ), + ); + + final textField = tester.widget(find.byType(TextField)); + final editableTextState = tester.state( + find.byType(EditableText), + ); + final menu = + textField.contextMenuBuilder!( + tester.element(find.byType(TextField)), + editableTextState, + ) + as AdaptiveTextSelectionToolbar; + final pasteImage = menu.buttonItems!.singleWhere( + (item) => item.label == 'Paste Image', + ); + pasteImage.onPressed!(); + await tester.pumpAndSettle(); + + expect( + find.byKey( + const ValueKey( + 'compose-attachment:https://relay.example/media/ios-paste.png', + ), + ), + findsOneWidget, + ); + } finally { + debugDefaultTargetPlatformOverride = previousPlatform; + } + }); + + testWidgets('shows an error when pasted image bytes are unavailable', ( + tester, + ) async { + final uploadService = MediaUploadService( + baseUrl: 'https://relay.example', + nsec: nostr.Keys.generate().nsec, + pickGalleryVideo: () async => null, + pickGalleryImage: () async => null, + ); + await tester.pumpWidget( + _buildComposeBar( + uploadService: uploadService, + onSend: + ( + content, + mentionPubkeys, { + mediaTags = const >[], + }) async {}, + ), + ); + + final textField = tester.widget(find.byType(TextField)); + textField.contentInsertionConfiguration!.onContentInserted( + const KeyboardInsertedContent( + mimeType: 'image/png', + uri: 'content://clipboard/unavailable.png', + ), + ); + await tester.pump(); + + expect(find.text('Unable to read pasted image'), findsOneWidget); + expect(find.byTooltip('Remove attachment'), findsNothing); + }); + + testWidgets('iOS Paste Image reports an unavailable clipboard image', ( + tester, + ) async { + final previousPlatform = debugDefaultTargetPlatformOverride; + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + try { + final uploadService = MediaUploadService( + baseUrl: 'https://relay.example', + nsec: nostr.Keys.generate().nsec, + pickGalleryVideo: () async => null, + pickGalleryImage: () async => null, + readClipboardImage: () async => null, + ); + await tester.pumpWidget( + _buildComposeBar( + uploadService: uploadService, + onSend: + ( + content, + mentionPubkeys, { + mediaTags = const >[], + }) async {}, + ), + ); + + final textField = tester.widget(find.byType(TextField)); + final editableTextState = tester.state( + find.byType(EditableText), + ); + final menu = + textField.contextMenuBuilder!( + tester.element(find.byType(TextField)), + editableTextState, + ) + as AdaptiveTextSelectionToolbar; + menu.buttonItems! + .singleWhere((item) => item.label == 'Paste Image') + .onPressed!(); + await tester.pumpAndSettle(); + + expect(find.text('Unable to read pasted image'), findsOneWidget); + expect(find.byTooltip('Remove attachment'), findsNothing); + } finally { + debugDefaultTargetPlatformOverride = previousPlatform; + } + }); + + testWidgets('does not add Paste Image to non-iOS context menus', ( + tester, + ) async { + final uploadService = MediaUploadService( + baseUrl: 'https://relay.example', + nsec: nostr.Keys.generate().nsec, + pickGalleryVideo: () async => null, + pickGalleryImage: () async => null, + ); + await tester.pumpWidget( + _buildComposeBar( + uploadService: uploadService, + onSend: + ( + content, + mentionPubkeys, { + mediaTags = const >[], + }) async {}, + ), + ); + + final textField = tester.widget(find.byType(TextField)); + final editableTextState = tester.state( + find.byType(EditableText), + ); + final menu = + textField.contextMenuBuilder!( + tester.element(find.byType(TextField)), + editableTextState, + ) + as AdaptiveTextSelectionToolbar; + + expect( + menu.buttonItems!.where((item) => item.label == 'Paste Image'), + isEmpty, + ); + }); + testWidgets('keeps the remove button pinned to the attachment corner', ( tester, ) async { diff --git a/mobile/test/shared/relay/media_upload_test.dart b/mobile/test/shared/relay/media_upload_test.dart index 01c7cb03ad..990bc3f773 100644 --- a/mobile/test/shared/relay/media_upload_test.dart +++ b/mobile/test/shared/relay/media_upload_test.dart @@ -307,6 +307,135 @@ void main() { ); }); + test( + 'checks clipboard image availability through the platform channel', + () async { + final invokedMethods = []; + _setMockMediaUploadPlatformHandler((call) async { + invokedMethods.add(call.method); + if (call.method == 'clipboardHasImage') return true; + return null; + }); + addTearDown(() { + _setMockMediaUploadPlatformHandler((call) async { + switch (call.method) { + case 'sanitizeImageForUpload': + final arguments = call.arguments as Map; + return arguments['bytes'] as Uint8List; + case 'transcodeImageToJpeg': + return _jpegBytes; + default: + return null; + } + }); + }); + final service = MediaUploadService( + baseUrl: 'https://relay.example', + nsec: null, + pickGalleryVideo: () async => null, + pickGalleryImage: () async => null, + ); + + expect(await service.clipboardHasImage(), isTrue); + expect(invokedMethods, ['clipboardHasImage']); + }, + ); + + test('reads clipboard image through the platform channel', () async { + final invokedMethods = []; + _setMockMediaUploadPlatformHandler((call) async { + invokedMethods.add(call.method); + if (call.method == 'readClipboardImage') return _pngBytes; + if (call.method == 'sanitizeImageForUpload') { + final arguments = call.arguments as Map; + return arguments['bytes'] as Uint8List; + } + return null; + }); + addTearDown(() { + _setMockMediaUploadPlatformHandler((call) async { + switch (call.method) { + case 'sanitizeImageForUpload': + final arguments = call.arguments as Map; + return arguments['bytes'] as Uint8List; + case 'transcodeImageToJpeg': + return _jpegBytes; + default: + return null; + } + }); + }); + final service = MediaUploadService( + baseUrl: 'https://relay.example', + nsec: nostr.Keys.generate().nsec, + httpClient: http_testing.MockClient( + (request) async => http.Response( + jsonEncode({ + 'url': 'https://relay.example/media/clipboard.png', + 'sha256': + '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef', + 'size': 16, + 'type': 'image/png', + 'uploaded': 1, + }), + 200, + ), + ), + pickGalleryVideo: () async => null, + pickGalleryImage: () async => null, + ); + + final descriptor = await service.readAndUploadClipboardImage(); + + expect(invokedMethods.first, 'readClipboardImage'); + expect(descriptor.type, 'image/png'); + }); + + test( + 'rejects GIF clipboard bytes through the shared validation path', + () async { + final service = MediaUploadService( + baseUrl: 'https://relay.example', + nsec: null, + pickGalleryVideo: () async => null, + pickGalleryImage: () async => null, + readClipboardImage: () async => _gifBytes, + ); + + expect( + service.readAndUploadClipboardImage, + throwsA( + isA().having( + (error) => error.toString(), + 'message', + contains('GIF uploads are not supported on mobile yet'), + ), + ), + ); + }, + ); + + test('rejects empty clipboard image bytes', () async { + final service = MediaUploadService( + baseUrl: 'https://relay.example', + nsec: null, + pickGalleryVideo: () async => null, + pickGalleryImage: () async => null, + readClipboardImage: () async => Uint8List(0), + ); + + expect( + service.readAndUploadClipboardImage, + throwsA( + isA().having( + (error) => error.toString(), + 'message', + contains('Unable to read pasted image'), + ), + ), + ); + }); + test('returns null when the gallery picker is cancelled', () async { final service = MediaUploadService( baseUrl: 'https://relay.example',