clipboard: add UxClipboard.readImage native bridge
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".
This commit is contained in:
57
android/src/main/kotlin/io/swipelab/ux/ClipboardPlugin.kt
Normal file
57
android/src/main/kotlin/io/swipelab/ux/ClipboardPlugin.kt
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ class UxPlugin : FlutterPlugin, ActivityAware {
|
|||||||
SensorPlugin(),
|
SensorPlugin(),
|
||||||
FilePlugin(),
|
FilePlugin(),
|
||||||
ScannerPlugin(),
|
ScannerPlugin(),
|
||||||
|
ClipboardPlugin(),
|
||||||
)
|
)
|
||||||
|
|
||||||
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) =
|
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) =
|
||||||
|
|||||||
52
ios/Classes/ClipboardPlugin.swift
Normal file
52
ios/Classes/ClipboardPlugin.swift
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ public class UxPlugin: NSObject, FlutterPlugin {
|
|||||||
SensorPlugin(),
|
SensorPlugin(),
|
||||||
FilePlugin(),
|
FilePlugin(),
|
||||||
ScannerPlugin(),
|
ScannerPlugin(),
|
||||||
|
ClipboardPlugin(),
|
||||||
]
|
]
|
||||||
for plugin in plugins {
|
for plugin in plugins {
|
||||||
plugin.register(with: registrar)
|
plugin.register(with: registrar)
|
||||||
|
|||||||
28
lib/src/clipboard.dart
Normal file
28
lib/src/clipboard.dart
Normal file
@@ -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<Uint8List?> readImage() async {
|
||||||
|
try {
|
||||||
|
final bytes = await _channel.invokeMethod<Uint8List>('readImage');
|
||||||
|
return bytes;
|
||||||
|
} on PlatformException {
|
||||||
|
return null;
|
||||||
|
} on MissingPluginException {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ export 'src/app_info.dart';
|
|||||||
export 'src/bend_box.dart';
|
export 'src/bend_box.dart';
|
||||||
export 'src/json_extension.dart';
|
export 'src/json_extension.dart';
|
||||||
export 'src/bezier.dart';
|
export 'src/bezier.dart';
|
||||||
|
export 'src/clipboard.dart';
|
||||||
export 'src/file.dart';
|
export 'src/file.dart';
|
||||||
export 'src/keyboard.dart';
|
export 'src/keyboard.dart';
|
||||||
export 'src/auto_map.dart';
|
export 'src/auto_map.dart';
|
||||||
|
|||||||
39
macos/Classes/ClipboardPlugin.swift
Normal file
39
macos/Classes/ClipboardPlugin.swift
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ public class UxPlugin: NSObject, FlutterPlugin {
|
|||||||
public static func register(with registrar: FlutterPluginRegistrar) {
|
public static func register(with registrar: FlutterPluginRegistrar) {
|
||||||
plugins = [
|
plugins = [
|
||||||
FilePlugin(),
|
FilePlugin(),
|
||||||
|
ClipboardPlugin(),
|
||||||
]
|
]
|
||||||
for plugin in plugins {
|
for plugin in plugins {
|
||||||
plugin.register(with: registrar)
|
plugin.register(with: registrar)
|
||||||
|
|||||||
Reference in New Issue
Block a user