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:
agra
2026-05-09 07:29:14 +03:00
parent cc28782119
commit fb00e98681
8 changed files with 180 additions and 0 deletions

View 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)
}
}
}

View File

@@ -10,6 +10,7 @@ class UxPlugin : FlutterPlugin, ActivityAware {
SensorPlugin(),
FilePlugin(),
ScannerPlugin(),
ClipboardPlugin(),
)
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) =