From fb00e98681353bd11bd5e80e0e9973b6e69a7afb Mon Sep 17 00:00:00 2001 From: agra Date: Sat, 9 May 2026 07:29:14 +0300 Subject: [PATCH] clipboard: add UxClipboard.readImage native bridge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Flutter's Clipboard API only exposes text shapes. Banlu's chat composer needs image bytes from the system clipboard for desktop paste, so add a UxClipboard facade backed by per-platform native plugins: * iOS: prefer raw PNG/JPEG bytes off the pasteboard, fall back to re-encoding `UIPasteboard.image` as PNG. * macOS: prefer NSPasteboard `.png`, fall back to TIFF transcoded through NSBitmapImageRep so screenshots / Preview hand-offs still work. * Android: read primary ClipData's first item URI and stream the bytes through ContentResolver — don't trust the clip description's MIME, copy whatever the resolver returns. Returns null (never throws) when the clipboard has no image — callers treat null as "fall through to text paste". --- .../kotlin/io/swipelab/ux/ClipboardPlugin.kt | 57 +++++++++++++++++++ .../main/kotlin/io/swipelab/ux/UxPlugin.kt | 1 + ios/Classes/ClipboardPlugin.swift | 52 +++++++++++++++++ ios/Classes/UxPlugin.swift | 1 + lib/src/clipboard.dart | 28 +++++++++ lib/ux.dart | 1 + macos/Classes/ClipboardPlugin.swift | 39 +++++++++++++ macos/Classes/UxPlugin.swift | 1 + 8 files changed, 180 insertions(+) create mode 100644 android/src/main/kotlin/io/swipelab/ux/ClipboardPlugin.kt create mode 100644 ios/Classes/ClipboardPlugin.swift create mode 100644 lib/src/clipboard.dart create mode 100644 macos/Classes/ClipboardPlugin.swift diff --git a/android/src/main/kotlin/io/swipelab/ux/ClipboardPlugin.kt b/android/src/main/kotlin/io/swipelab/ux/ClipboardPlugin.kt new file mode 100644 index 0000000..f140975 --- /dev/null +++ b/android/src/main/kotlin/io/swipelab/ux/ClipboardPlugin.kt @@ -0,0 +1,57 @@ +package io.swipelab.ux + +import android.content.ClipboardManager +import android.content.Context +import io.flutter.embedding.engine.plugins.FlutterPlugin +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel +import java.io.ByteArrayOutputStream + +class ClipboardPlugin : NativePlugin, MethodChannel.MethodCallHandler { + private var methodChannel: MethodChannel? = null + private var context: Context? = null + + override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) { + context = binding.applicationContext + methodChannel = MethodChannel(binding.binaryMessenger, "ux/clipboard").also { + it.setMethodCallHandler(this) + } + } + + override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { + methodChannel?.setMethodCallHandler(null) + methodChannel = null + context = null + } + + override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { + when (call.method) { + "readImage" -> handleReadImage(result) + else -> result.notImplemented() + } + } + + private fun handleReadImage(result: MethodChannel.Result) { + val ctx = context ?: return result.success(null) + val cm = ctx.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager + ?: return result.success(null) + val clip = cm.primaryClip ?: return result.success(null) + if (clip.itemCount == 0) return result.success(null) + + val uri = clip.getItemAt(0).uri ?: return result.success(null) + + // Don't trust the clip description's MIME — some apps lie. Open + // the stream and copy whatever bytes the resolver returns; the + // composer side is happy to send any media-typed file. + try { + ctx.contentResolver.openInputStream(uri).use { input -> + if (input == null) return result.success(null) + val out = ByteArrayOutputStream() + input.copyTo(out) + result.success(out.toByteArray()) + } + } catch (_: Exception) { + result.success(null) + } + } +} diff --git a/android/src/main/kotlin/io/swipelab/ux/UxPlugin.kt b/android/src/main/kotlin/io/swipelab/ux/UxPlugin.kt index c5c5ec6..0cb3ad5 100644 --- a/android/src/main/kotlin/io/swipelab/ux/UxPlugin.kt +++ b/android/src/main/kotlin/io/swipelab/ux/UxPlugin.kt @@ -10,6 +10,7 @@ class UxPlugin : FlutterPlugin, ActivityAware { SensorPlugin(), FilePlugin(), ScannerPlugin(), + ClipboardPlugin(), ) override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) = diff --git a/ios/Classes/ClipboardPlugin.swift b/ios/Classes/ClipboardPlugin.swift new file mode 100644 index 0000000..59bcc97 --- /dev/null +++ b/ios/Classes/ClipboardPlugin.swift @@ -0,0 +1,52 @@ +import Flutter +import MobileCoreServices +import UIKit +import UniformTypeIdentifiers + +public class ClipboardPlugin: NSObject, NativePlugin { + private var channel: FlutterMethodChannel? + + public func register(with registrar: FlutterPluginRegistrar) { + let c = FlutterMethodChannel(name: "ux/clipboard", binaryMessenger: registrar.messenger()) + c.setMethodCallHandler { [weak self] call, result in + self?.handle(call, result: result) + } + channel = c + } + + private func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + switch call.method { + case "readImage": handleReadImage(result: result) + default: result(FlutterMethodNotImplemented) + } + } + + private func handleReadImage(result: @escaping FlutterResult) { + let pb = UIPasteboard.general + + // Prefer raw PNG / JPEG bytes already on the clipboard so we don't + // round-trip through UIImage when we don't have to. + if #available(iOS 14.0, *) { + if let png = pb.data(forPasteboardType: UTType.png.identifier) { + return result(FlutterStandardTypedData(bytes: png)) + } + if let jpg = pb.data(forPasteboardType: UTType.jpeg.identifier) { + return result(FlutterStandardTypedData(bytes: jpg)) + } + } else { + if let png = pb.data(forPasteboardType: kUTTypePNG as String) { + return result(FlutterStandardTypedData(bytes: png)) + } + if let jpg = pb.data(forPasteboardType: kUTTypeJPEG as String) { + return result(FlutterStandardTypedData(bytes: jpg)) + } + } + + // Fallback: re-encode whatever UIImage resolves to as PNG. + if let img = pb.image, let png = img.pngData() { + return result(FlutterStandardTypedData(bytes: png)) + } + + result(nil) + } +} diff --git a/ios/Classes/UxPlugin.swift b/ios/Classes/UxPlugin.swift index 6505c90..480979d 100644 --- a/ios/Classes/UxPlugin.swift +++ b/ios/Classes/UxPlugin.swift @@ -10,6 +10,7 @@ public class UxPlugin: NSObject, FlutterPlugin { SensorPlugin(), FilePlugin(), ScannerPlugin(), + ClipboardPlugin(), ] for plugin in plugins { plugin.register(with: registrar) diff --git a/lib/src/clipboard.dart b/lib/src/clipboard.dart new file mode 100644 index 0000000..5f29ab1 --- /dev/null +++ b/lib/src/clipboard.dart @@ -0,0 +1,28 @@ +import 'package:flutter/services.dart'; + +/// OS clipboard access for shapes Flutter's [Clipboard] doesn't cover. +/// Right now this is image bytes — the system text path is already +/// handled by the SDK's `Clipboard.getData(Clipboard.kTextPlain)`. +class UxClipboard { + UxClipboard._(); + + static const _channel = MethodChannel('ux/clipboard'); + + /// Returns the image currently on the system clipboard as raw bytes + /// (PNG on iOS / macOS / Android), or `null` if no image is available. + /// + /// On macOS the call also handles TIFF clipboards by transcoding to + /// PNG; Android resolves `content://` URIs through ContentResolver and + /// returns the underlying image bytes verbatim. Never throws — channel + /// failures resolve to `null`. + static Future readImage() async { + try { + final bytes = await _channel.invokeMethod('readImage'); + return bytes; + } on PlatformException { + return null; + } on MissingPluginException { + return null; + } + } +} diff --git a/lib/ux.dart b/lib/ux.dart index ebab76d..4baf7df 100644 --- a/lib/ux.dart +++ b/lib/ux.dart @@ -8,6 +8,7 @@ export 'src/app_info.dart'; export 'src/bend_box.dart'; export 'src/json_extension.dart'; export 'src/bezier.dart'; +export 'src/clipboard.dart'; export 'src/file.dart'; export 'src/keyboard.dart'; export 'src/auto_map.dart'; diff --git a/macos/Classes/ClipboardPlugin.swift b/macos/Classes/ClipboardPlugin.swift new file mode 100644 index 0000000..beb22d5 --- /dev/null +++ b/macos/Classes/ClipboardPlugin.swift @@ -0,0 +1,39 @@ +import AppKit +import FlutterMacOS + +public class ClipboardPlugin: NSObject, NativePlugin { + private var channel: FlutterMethodChannel? + + public func register(with registrar: FlutterPluginRegistrar) { + let c = FlutterMethodChannel(name: "ux/clipboard", binaryMessenger: registrar.messenger) + c.setMethodCallHandler { [weak self] call, result in + self?.handle(call, result: result) + } + channel = c + } + + private func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + switch call.method { + case "readImage": handleReadImage(result: result) + default: result(FlutterMethodNotImplemented) + } + } + + private func handleReadImage(result: @escaping FlutterResult) { + let pb = NSPasteboard.general + + if let png = pb.data(forType: .png) { + return result(FlutterStandardTypedData(bytes: png)) + } + + // macOS Screenshot.app and Preview hand off TIFF; transcode so + // the Dart side always gets PNG bytes regardless of producer. + if let tiff = pb.data(forType: .tiff), + let rep = NSBitmapImageRep(data: tiff), + let png = rep.representation(using: .png, properties: [:]) { + return result(FlutterStandardTypedData(bytes: png)) + } + + result(nil) + } +} diff --git a/macos/Classes/UxPlugin.swift b/macos/Classes/UxPlugin.swift index 6394eed..bf9b7b9 100644 --- a/macos/Classes/UxPlugin.swift +++ b/macos/Classes/UxPlugin.swift @@ -7,6 +7,7 @@ public class UxPlugin: NSObject, FlutterPlugin { public static func register(with registrar: FlutterPluginRegistrar) { plugins = [ FilePlugin(), + ClipboardPlugin(), ] for plugin in plugins { plugin.register(with: registrar)