file: add path-only pick + native video thumbnail
Two new methods on `UxFile`, both designed to keep large file content
out of the platform-channel buffer (the failure mode of file_selector
on Android: a ~200 MB video PUT through the Pigeon codec OOM'd the
JVM via `byte[size]` allocation in `FileSelectorApiImpl`).
`UxFile.pick({mimeTypes})` returns `UxPickedFile?` with `path`, `name`,
`mimeType`, `size`. The platform channel reply carries only the
metadata; bytes never cross.
- Android: `ACTION_OPEN_DOCUMENT` + `EXTRA_MIME_TYPES`, registered
as `ActivityResultListener`. On result, stream-copies the SAF
content URI to `cacheDir/ux_pick/<ts>_<safeName>` via an 8 KB
buffer (no full-file allocation in JVM heap), returns the cache
path.
- iOS: `UIDocumentPickerViewController(documentTypes:in: .import)`
— `.import` mode copies the picked file into the app's
Documents/Inbox so the URL is stable. Strong-retained delegate
(the picker's delegate ref is weak).
- macOS: `NSOpenPanel` with `allowedFileTypes`. Sheet-modal when a
Flutter window exists; free-modal otherwise.
`UxFile.videoThumbnail({path, atMs, maxWidth})` returns
`UxVideoThumbnail?` (PNG bytes + dims).
- Android: `MediaMetadataRetriever.getFrameAtTime(..., OPTION_CLOSEST_SYNC)`,
`Bitmap.createScaledBitmap` to maxWidth, PNG-encode via
`ByteArrayOutputStream`, recycle bitmaps in `finally`, release
retriever in `finally`.
- iOS: `AVAssetImageGenerator` with `appliesPreferredTrackTransform = true`,
`maximumSize = (maxWidth, 0)` (preserve aspect), ±500 ms tolerance
for keyframe alignment, decode on `userInitiated` queue.
- macOS: same generator, encoded via `NSBitmapImageRep`.
Compatible with the package's existing iOS 13 / macOS 10.15 deployment
targets — uses legacy `kUTType*` + `UTTypeCreatePreferredIdentifierForTag`
instead of `UTType` (iOS 14 / macOS 11).
This commit is contained in:
@@ -4,18 +4,31 @@ import android.app.Activity
|
|||||||
import android.content.ActivityNotFoundException
|
import android.content.ActivityNotFoundException
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.media.MediaMetadataRetriever
|
||||||
|
import android.net.Uri
|
||||||
|
import android.provider.OpenableColumns
|
||||||
import android.webkit.MimeTypeMap
|
import android.webkit.MimeTypeMap
|
||||||
import androidx.core.content.FileProvider
|
import androidx.core.content.FileProvider
|
||||||
import io.flutter.embedding.engine.plugins.FlutterPlugin
|
import io.flutter.embedding.engine.plugins.FlutterPlugin
|
||||||
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
|
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
|
||||||
import io.flutter.plugin.common.MethodCall
|
import io.flutter.plugin.common.MethodCall
|
||||||
import io.flutter.plugin.common.MethodChannel
|
import io.flutter.plugin.common.MethodChannel
|
||||||
|
import io.flutter.plugin.common.PluginRegistry
|
||||||
|
import java.io.ByteArrayOutputStream
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
import java.io.FileOutputStream
|
||||||
|
|
||||||
class FilePlugin : NativePlugin, MethodChannel.MethodCallHandler {
|
class FilePlugin : NativePlugin, MethodChannel.MethodCallHandler,
|
||||||
|
PluginRegistry.ActivityResultListener {
|
||||||
private var methodChannel: MethodChannel? = null
|
private var methodChannel: MethodChannel? = null
|
||||||
private var context: Context? = null
|
private var context: Context? = null
|
||||||
private var activity: Activity? = null
|
private var activity: Activity? = null
|
||||||
|
private var activityBinding: ActivityPluginBinding? = null
|
||||||
|
|
||||||
|
/// Active pick request — set when [handlePick] launches the picker,
|
||||||
|
/// cleared in [onActivityResult]. Reentrancy is rejected.
|
||||||
|
private var pendingPickResult: MethodChannel.Result? = null
|
||||||
|
|
||||||
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||||
context = binding.applicationContext
|
context = binding.applicationContext
|
||||||
@@ -32,16 +45,26 @@ class FilePlugin : NativePlugin, MethodChannel.MethodCallHandler {
|
|||||||
|
|
||||||
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
|
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
|
||||||
activity = binding.activity
|
activity = binding.activity
|
||||||
|
activityBinding = binding
|
||||||
|
binding.addActivityResultListener(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDetachedFromActivity() {
|
override fun onDetachedFromActivity() {
|
||||||
|
activityBinding?.removeActivityResultListener(this)
|
||||||
|
activityBinding = null
|
||||||
activity = null
|
activity = null
|
||||||
|
// If the activity tears down with a request still in flight, settle
|
||||||
|
// it cleanly rather than leaking the Result.
|
||||||
|
pendingPickResult?.success(null)
|
||||||
|
pendingPickResult = null
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||||
when (call.method) {
|
when (call.method) {
|
||||||
"share" -> handleShare(call, result)
|
"share" -> handleShare(call, result)
|
||||||
"open" -> handleOpen(call, result)
|
"open" -> handleOpen(call, result)
|
||||||
|
"pick" -> handlePick(call, result)
|
||||||
|
"videoThumbnail" -> handleVideoThumbnail(call, result)
|
||||||
else -> result.notImplemented()
|
else -> result.notImplemented()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -101,6 +124,165 @@ class FilePlugin : NativePlugin, MethodChannel.MethodCallHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun handlePick(call: MethodCall, result: MethodChannel.Result) {
|
||||||
|
val act = activity
|
||||||
|
?: return result.error("no_activity", "plugin not attached to an activity", null)
|
||||||
|
if (pendingPickResult != null) {
|
||||||
|
return result.error(
|
||||||
|
"in_progress", "a pick request is already in progress", null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val mimeTypes = (call.argument<List<*>>("mimeTypes"))
|
||||||
|
?.filterIsInstance<String>()
|
||||||
|
?.takeIf { it.isNotEmpty() }
|
||||||
|
|
||||||
|
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
|
||||||
|
addCategory(Intent.CATEGORY_OPENABLE)
|
||||||
|
type = if (mimeTypes != null && mimeTypes.size == 1) mimeTypes[0] else "*/*"
|
||||||
|
if (mimeTypes != null && mimeTypes.size > 1) {
|
||||||
|
putExtra(Intent.EXTRA_MIME_TYPES, mimeTypes.toTypedArray())
|
||||||
|
}
|
||||||
|
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||||
|
}
|
||||||
|
pendingPickResult = result
|
||||||
|
try {
|
||||||
|
act.startActivityForResult(intent, REQUEST_CODE_PICK)
|
||||||
|
} catch (e: ActivityNotFoundException) {
|
||||||
|
pendingPickResult = null
|
||||||
|
result.error("no_picker", "no document picker available: ${e.message}", null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean {
|
||||||
|
if (requestCode != REQUEST_CODE_PICK) return false
|
||||||
|
val r = pendingPickResult ?: return true
|
||||||
|
pendingPickResult = null
|
||||||
|
if (resultCode != Activity.RESULT_OK) {
|
||||||
|
r.success(null)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
val uri = data?.data
|
||||||
|
if (uri == null) {
|
||||||
|
r.success(null)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
val ctx = context
|
||||||
|
if (ctx == null) {
|
||||||
|
r.error("no_context", "lost application context", null)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
val picked = copyUriToCache(ctx, uri)
|
||||||
|
r.success(picked)
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
r.error("copy_failed", "could not stream-copy URI to cache: ${e.message}", null)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stream-copies the [uri]'s content (an SAF document) to a fresh file
|
||||||
|
/// in `cacheDir/ux_pick/`. Returns a map ready for the platform-channel
|
||||||
|
/// reply. Crucially, the bytes never live in JVM heap — they flow
|
||||||
|
/// `InputStream → 8KB buffer → FileOutputStream`.
|
||||||
|
private fun copyUriToCache(ctx: Context, uri: Uri): Map<String, Any?> {
|
||||||
|
val resolver = ctx.contentResolver
|
||||||
|
var displayName: String? = null
|
||||||
|
var size: Long? = null
|
||||||
|
resolver.query(
|
||||||
|
uri,
|
||||||
|
arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE),
|
||||||
|
null, null, null,
|
||||||
|
)?.use { c ->
|
||||||
|
if (c.moveToFirst()) {
|
||||||
|
val nameIdx = c.getColumnIndex(OpenableColumns.DISPLAY_NAME)
|
||||||
|
if (nameIdx >= 0 && !c.isNull(nameIdx)) displayName = c.getString(nameIdx)
|
||||||
|
val sizeIdx = c.getColumnIndex(OpenableColumns.SIZE)
|
||||||
|
if (sizeIdx >= 0 && !c.isNull(sizeIdx)) size = c.getLong(sizeIdx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val safeName = (displayName ?: "picked").replace(Regex("[\\\\/:*?\"<>|]"), "_")
|
||||||
|
val mimeType = resolver.getType(uri)
|
||||||
|
|
||||||
|
val dir = File(ctx.cacheDir, "ux_pick").apply { mkdirs() }
|
||||||
|
val out = File(dir, "${System.currentTimeMillis()}_$safeName")
|
||||||
|
|
||||||
|
var copied = 0L
|
||||||
|
resolver.openInputStream(uri).use { input ->
|
||||||
|
requireNotNull(input) { "openInputStream returned null for $uri" }
|
||||||
|
FileOutputStream(out).use { output ->
|
||||||
|
val buf = ByteArray(8 * 1024)
|
||||||
|
while (true) {
|
||||||
|
val n = input.read(buf)
|
||||||
|
if (n <= 0) break
|
||||||
|
output.write(buf, 0, n)
|
||||||
|
copied += n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return mapOf(
|
||||||
|
"path" to out.absolutePath,
|
||||||
|
"name" to displayName,
|
||||||
|
"mimeType" to mimeType,
|
||||||
|
"size" to (size ?: copied),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleVideoThumbnail(call: MethodCall, result: MethodChannel.Result) {
|
||||||
|
val path = call.argument<String>("path")
|
||||||
|
?: return result.error("bad_args", "path is required", null)
|
||||||
|
val atMs = (call.argument<Number>("atMs") ?: 0).toLong()
|
||||||
|
val maxWidth = (call.argument<Number>("maxWidth") ?: 320).toInt()
|
||||||
|
|
||||||
|
val retriever = MediaMetadataRetriever()
|
||||||
|
try {
|
||||||
|
retriever.setDataSource(path)
|
||||||
|
// OPTION_CLOSEST_SYNC is fastest — picks the nearest keyframe.
|
||||||
|
// For atMs=0 this is the first frame, which is what callers
|
||||||
|
// typically want for a thumbnail.
|
||||||
|
val frame = retriever.getFrameAtTime(
|
||||||
|
atMs * 1000L, // µs
|
||||||
|
MediaMetadataRetriever.OPTION_CLOSEST_SYNC,
|
||||||
|
)
|
||||||
|
if (frame == null) {
|
||||||
|
result.success(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val scaled = scaleBitmap(frame, maxWidth)
|
||||||
|
try {
|
||||||
|
val baos = ByteArrayOutputStream()
|
||||||
|
scaled.compress(Bitmap.CompressFormat.PNG, 100, baos)
|
||||||
|
result.success(
|
||||||
|
mapOf<String, Any?>(
|
||||||
|
"png" to baos.toByteArray(),
|
||||||
|
"width" to scaled.width,
|
||||||
|
"height" to scaled.height,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
if (scaled !== frame) scaled.recycle()
|
||||||
|
frame.recycle()
|
||||||
|
}
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
result.error("decode_failed", "could not extract frame: ${e.message}", null)
|
||||||
|
} finally {
|
||||||
|
try { retriever.release() } catch (_: Throwable) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Scales [bitmap] so its longer edge equals [maxWidth] while
|
||||||
|
/// preserving aspect ratio. Returns the original if already small enough.
|
||||||
|
private fun scaleBitmap(bitmap: Bitmap, maxWidth: Int): Bitmap {
|
||||||
|
val w = bitmap.width
|
||||||
|
val h = bitmap.height
|
||||||
|
val longEdge = maxOf(w, h)
|
||||||
|
if (longEdge <= maxWidth) return bitmap
|
||||||
|
val scale = maxWidth.toFloat() / longEdge
|
||||||
|
val outW = (w * scale).toInt().coerceAtLeast(1)
|
||||||
|
val outH = (h * scale).toInt().coerceAtLeast(1)
|
||||||
|
return Bitmap.createScaledBitmap(bitmap, outW, outH, true)
|
||||||
|
}
|
||||||
|
|
||||||
private fun inferMime(supplied: String?, path: String): String {
|
private fun inferMime(supplied: String?, path: String): String {
|
||||||
if (!supplied.isNullOrBlank() && supplied != "application/octet-stream") {
|
if (!supplied.isNullOrBlank() && supplied != "application/octet-stream") {
|
||||||
return supplied
|
return supplied
|
||||||
@@ -116,6 +298,8 @@ class FilePlugin : NativePlugin, MethodChannel.MethodCallHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
private const val REQUEST_CODE_PICK = 0xC51 // arbitrary, namespaced
|
||||||
|
|
||||||
private val textExtensions = setOf(
|
private val textExtensions = setOf(
|
||||||
"dart", "swift", "kt", "kts", "java", "scala", "groovy",
|
"dart", "swift", "kt", "kts", "java", "scala", "groovy",
|
||||||
"py", "rb", "php", "pl", "sh", "bash", "zsh", "fish",
|
"py", "rb", "php", "pl", "sh", "bash", "zsh", "fish",
|
||||||
|
|||||||
@@ -502,7 +502,7 @@ packages:
|
|||||||
path: ".."
|
path: ".."
|
||||||
relative: true
|
relative: true
|
||||||
source: path
|
source: path
|
||||||
version: "0.7.0"
|
version: "0.8.0"
|
||||||
vector_math:
|
vector_math:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
|
import AVFoundation
|
||||||
import Flutter
|
import Flutter
|
||||||
|
import MobileCoreServices
|
||||||
import QuickLook
|
import QuickLook
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
public class FilePlugin: NSObject, NativePlugin {
|
public class FilePlugin: NSObject, NativePlugin {
|
||||||
private var channel: FlutterMethodChannel?
|
private var channel: FlutterMethodChannel?
|
||||||
private var previewDataSource: FilePreviewDataSource?
|
private var previewDataSource: FilePreviewDataSource?
|
||||||
|
private var pickerDelegate: UxDocumentPickerDelegate?
|
||||||
|
|
||||||
public func register(with registrar: FlutterPluginRegistrar) {
|
public func register(with registrar: FlutterPluginRegistrar) {
|
||||||
let c = FlutterMethodChannel(name: "ux/file", binaryMessenger: registrar.messenger())
|
let c = FlutterMethodChannel(name: "ux/file", binaryMessenger: registrar.messenger())
|
||||||
@@ -16,9 +19,60 @@ public class FilePlugin: NSObject, NativePlugin {
|
|||||||
|
|
||||||
private func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
|
private func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
|
||||||
switch call.method {
|
switch call.method {
|
||||||
case "share": handleShare(call, result: result)
|
case "share": handleShare(call, result: result)
|
||||||
case "open": handleOpen(call, result: result)
|
case "open": handleOpen(call, result: result)
|
||||||
default: result(FlutterMethodNotImplemented)
|
case "pick": handlePick(call, result: result)
|
||||||
|
case "videoThumbnail": handleVideoThumbnail(call, result: result)
|
||||||
|
default: result(FlutterMethodNotImplemented)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleVideoThumbnail(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
|
||||||
|
guard let args = call.arguments as? [String: Any],
|
||||||
|
let path = args["path"] as? String else {
|
||||||
|
return result(FlutterError(code: "bad_args", message: "path is required", details: nil))
|
||||||
|
}
|
||||||
|
let atMs = (args["atMs"] as? NSNumber)?.intValue ?? 0
|
||||||
|
let maxWidth = (args["maxWidth"] as? NSNumber)?.intValue ?? 320
|
||||||
|
|
||||||
|
let url = URL(fileURLWithPath: path)
|
||||||
|
let asset = AVURLAsset(url: url)
|
||||||
|
let generator = AVAssetImageGenerator(asset: asset)
|
||||||
|
generator.appliesPreferredTrackTransform = true
|
||||||
|
// 0 on either axis = preserve aspect ratio at the bound.
|
||||||
|
generator.maximumSize = CGSize(width: CGFloat(maxWidth), height: 0)
|
||||||
|
// Tolerate a few hundred ms of slop so we can land on a keyframe.
|
||||||
|
generator.requestedTimeToleranceBefore = CMTime(value: 500, timescale: 1000)
|
||||||
|
generator.requestedTimeToleranceAfter = CMTime(value: 500, timescale: 1000)
|
||||||
|
|
||||||
|
// Background-thread the decode; method-channel reply marshalls back
|
||||||
|
// to the platform thread automatically.
|
||||||
|
DispatchQueue.global(qos: .userInitiated).async {
|
||||||
|
do {
|
||||||
|
let cgImage = try generator.copyCGImage(
|
||||||
|
at: CMTime(value: Int64(atMs) * 1000, timescale: 1_000_000),
|
||||||
|
actualTime: nil,
|
||||||
|
)
|
||||||
|
let uiImage = UIImage(cgImage: cgImage)
|
||||||
|
guard let png = uiImage.pngData() else {
|
||||||
|
DispatchQueue.main.async { result(nil) }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let reply: [String: Any] = [
|
||||||
|
"png": FlutterStandardTypedData(bytes: png),
|
||||||
|
"width": Int(uiImage.size.width * uiImage.scale),
|
||||||
|
"height": Int(uiImage.size.height * uiImage.scale),
|
||||||
|
]
|
||||||
|
DispatchQueue.main.async { result(reply) }
|
||||||
|
} catch {
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
result(FlutterError(
|
||||||
|
code: "decode_failed",
|
||||||
|
message: "could not extract frame: \(error.localizedDescription)",
|
||||||
|
details: nil,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,6 +112,58 @@ public class FilePlugin: NSObject, NativePlugin {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func handlePick(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
|
||||||
|
guard let topVC = UxWindow.topViewController else {
|
||||||
|
return result(FlutterError(code: "no_view", message: "no top view controller", details: nil))
|
||||||
|
}
|
||||||
|
let args = call.arguments as? [String: Any]
|
||||||
|
let mimeTypes = (args?["mimeTypes"] as? [String]) ?? []
|
||||||
|
let utis = utiStrings(forMimeTypes: mimeTypes)
|
||||||
|
|
||||||
|
// `.import` mode copies the picked file into the app's Documents/Inbox
|
||||||
|
// so the URL is stable after the picker dismisses (matches Android's
|
||||||
|
// cache-copy semantics).
|
||||||
|
let picker = UIDocumentPickerViewController(documentTypes: utis, in: .import)
|
||||||
|
picker.allowsMultipleSelection = false
|
||||||
|
let delegate = UxDocumentPickerDelegate(result: result) { [weak self] in
|
||||||
|
self?.pickerDelegate = nil
|
||||||
|
}
|
||||||
|
// The delegate is weak on UIDocumentPickerViewController; keep a strong ref.
|
||||||
|
pickerDelegate = delegate
|
||||||
|
picker.delegate = delegate
|
||||||
|
|
||||||
|
topVC.present(picker, animated: true, completion: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Maps Dart-side MIME strings (e.g. `image/png`, `image/*`, `*/*`) to
|
||||||
|
/// legacy UTI strings. Compatible with iOS 13+ (UTType requires 14+).
|
||||||
|
/// Wildcards and unknown MIMEs degrade to `public.data` (the universal
|
||||||
|
/// "any data" UTI) so the picker still appears.
|
||||||
|
private func utiStrings(forMimeTypes mimes: [String]) -> [String] {
|
||||||
|
if mimes.isEmpty { return [kUTTypeData as String] }
|
||||||
|
var out: [String] = []
|
||||||
|
for m in mimes {
|
||||||
|
if m == "*/*" { return [kUTTypeData as String] }
|
||||||
|
if m.hasSuffix("/*") {
|
||||||
|
switch String(m.dropLast(2)) {
|
||||||
|
case "image": out.append(kUTTypeImage as String)
|
||||||
|
case "video": out.append(kUTTypeMovie as String)
|
||||||
|
case "audio": out.append(kUTTypeAudio as String)
|
||||||
|
case "text": out.append(kUTTypeText as String)
|
||||||
|
case "application": out.append(kUTTypeData as String)
|
||||||
|
default: out.append(kUTTypeData as String)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if let unmanaged = UTTypeCreatePreferredIdentifierForTag(
|
||||||
|
kUTTagClassMIMEType, m as CFString, nil
|
||||||
|
) {
|
||||||
|
out.append(unmanaged.takeRetainedValue() as String)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out.isEmpty ? [kUTTypeData as String] : out
|
||||||
|
}
|
||||||
|
|
||||||
private func handleOpen(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
|
private func handleOpen(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
|
||||||
guard let args = call.arguments as? [String: Any],
|
guard let args = call.arguments as? [String: Any],
|
||||||
let path = args["path"] as? String else {
|
let path = args["path"] as? String else {
|
||||||
@@ -92,6 +198,60 @@ private final class FilePreviewDataSource: NSObject, QLPreviewControllerDataSour
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// MIME-type lookup from a filename extension via the legacy UTI machinery
|
||||||
|
/// (iOS 13 compatible).
|
||||||
|
fileprivate func mimeFromExtension(_ ext: String) -> String? {
|
||||||
|
guard !ext.isEmpty else { return nil }
|
||||||
|
guard let uti = UTTypeCreatePreferredIdentifierForTag(
|
||||||
|
kUTTagClassFilenameExtension, ext as CFString, nil
|
||||||
|
)?.takeRetainedValue() else { return nil }
|
||||||
|
guard let mime = UTTypeCopyPreferredTagWithClass(
|
||||||
|
uti, kUTTagClassMIMEType
|
||||||
|
)?.takeRetainedValue() else { return nil }
|
||||||
|
return mime as String
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Picker delegate that converts a [UIDocumentPickerViewController] result
|
||||||
|
/// into the Dart-side reply map. `.import` mode copies the picked file
|
||||||
|
/// into the app's Documents/Inbox, so the URL is stable.
|
||||||
|
private final class UxDocumentPickerDelegate: NSObject, UIDocumentPickerDelegate {
|
||||||
|
let result: FlutterResult
|
||||||
|
let onDone: () -> Void
|
||||||
|
private var settled = false
|
||||||
|
|
||||||
|
init(result: @escaping FlutterResult, onDone: @escaping () -> Void) {
|
||||||
|
self.result = result
|
||||||
|
self.onDone = onDone
|
||||||
|
}
|
||||||
|
|
||||||
|
func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
|
||||||
|
guard !settled else { return }
|
||||||
|
settled = true
|
||||||
|
defer { onDone() }
|
||||||
|
guard let url = urls.first else {
|
||||||
|
result(nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let path = url.path
|
||||||
|
let attrs = try? FileManager.default.attributesOfItem(atPath: path)
|
||||||
|
let size = (attrs?[.size] as? NSNumber)?.intValue
|
||||||
|
let mime = mimeFromExtension(url.pathExtension)
|
||||||
|
result([
|
||||||
|
"path": path,
|
||||||
|
"name": url.lastPathComponent,
|
||||||
|
"mimeType": mime as Any,
|
||||||
|
"size": size as Any,
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) {
|
||||||
|
guard !settled else { return }
|
||||||
|
settled = true
|
||||||
|
defer { onDone() }
|
||||||
|
result(nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private final class FileActivityItemSource: NSObject, UIActivityItemSource {
|
private final class FileActivityItemSource: NSObject, UIActivityItemSource {
|
||||||
let url: URL
|
let url: URL
|
||||||
let title: String
|
let title: String
|
||||||
|
|||||||
@@ -1,7 +1,43 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
import 'dart:ui' show Rect;
|
import 'dart:ui' show Rect;
|
||||||
|
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
|
/// A single frame extracted from a video file. [pngBytes] is the encoded
|
||||||
|
/// PNG ready to embed in a thumbnail proto / paint via `Image.memory`;
|
||||||
|
/// [width] / [height] describe the encoded image, which may be smaller
|
||||||
|
/// than the source video due to the `maxWidth` constraint at extraction.
|
||||||
|
class UxVideoThumbnail {
|
||||||
|
const UxVideoThumbnail({
|
||||||
|
required this.pngBytes,
|
||||||
|
required this.width,
|
||||||
|
required this.height,
|
||||||
|
});
|
||||||
|
|
||||||
|
final Uint8List pngBytes;
|
||||||
|
final int width;
|
||||||
|
final int height;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A file the user picked. [path] is on local disk and readable by
|
||||||
|
/// `dart:io File` — for Android content:// URIs the native side
|
||||||
|
/// stream-copies the source to the app's cache; for iOS/macOS picks the
|
||||||
|
/// file is copied to the temp dir so the path is stable after the picker
|
||||||
|
/// dismisses. Bytes are never marshalled across the platform channel.
|
||||||
|
class UxPickedFile {
|
||||||
|
const UxPickedFile({
|
||||||
|
required this.path,
|
||||||
|
this.name,
|
||||||
|
this.mimeType,
|
||||||
|
this.size,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String path;
|
||||||
|
final String? name;
|
||||||
|
final String? mimeType;
|
||||||
|
final int? size;
|
||||||
|
}
|
||||||
|
|
||||||
class UxFile {
|
class UxFile {
|
||||||
UxFile._();
|
UxFile._();
|
||||||
|
|
||||||
@@ -67,4 +103,62 @@ class UxFile {
|
|||||||
});
|
});
|
||||||
return result ?? false;
|
return result ?? false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Present the system file picker. Returns the picked file's local-disk
|
||||||
|
/// path (and optional metadata), or null if the user cancelled.
|
||||||
|
///
|
||||||
|
/// File content is **never** marshalled across the platform channel —
|
||||||
|
/// the native side only ships back the path. Use `dart:io` to read the
|
||||||
|
/// file: `File(picked.path).openRead()` etc.
|
||||||
|
///
|
||||||
|
/// [mimeTypes] filters the picker. Each entry can be a concrete type
|
||||||
|
/// (`image/png`), a wildcard (`image/*`), or `*/*`. Null = `[*/*]`.
|
||||||
|
/// Note: Apple platforms map MIME → UTType internally; common types
|
||||||
|
/// (`image/*`, `video/*`, `application/pdf`) work on all three. For
|
||||||
|
/// Apple-specific types prefer concrete MIME like `image/jpeg` over
|
||||||
|
/// wildcards.
|
||||||
|
static Future<UxPickedFile?> pick({
|
||||||
|
List<String>? mimeTypes,
|
||||||
|
}) async {
|
||||||
|
final result = await _channel.invokeMapMethod<String, Object?>('pick', {
|
||||||
|
if (mimeTypes != null) 'mimeTypes': mimeTypes,
|
||||||
|
});
|
||||||
|
if (result == null) return null;
|
||||||
|
final path = result['path'] as String?;
|
||||||
|
if (path == null) return null;
|
||||||
|
return UxPickedFile(
|
||||||
|
path: path,
|
||||||
|
name: result['name'] as String?,
|
||||||
|
mimeType: result['mimeType'] as String?,
|
||||||
|
size: (result['size'] as num?)?.toInt(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract a single frame from the video at [path]. Returns null if the
|
||||||
|
/// platform's media decoder couldn't open the file (unsupported codec /
|
||||||
|
/// corrupt / not actually a video).
|
||||||
|
///
|
||||||
|
/// [atMs] picks the frame timestamp in milliseconds (default 0 = first
|
||||||
|
/// available keyframe). [maxWidth] caps the output's longer edge while
|
||||||
|
/// preserving aspect ratio.
|
||||||
|
static Future<UxVideoThumbnail?> videoThumbnail({
|
||||||
|
required String path,
|
||||||
|
int atMs = 0,
|
||||||
|
int maxWidth = 320,
|
||||||
|
}) async {
|
||||||
|
final result = await _channel.invokeMapMethod<String, Object?>(
|
||||||
|
'videoThumbnail',
|
||||||
|
<String, Object?>{
|
||||||
|
'path': path,
|
||||||
|
'atMs': atMs,
|
||||||
|
'maxWidth': maxWidth,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (result == null) return null;
|
||||||
|
final bytes = result['png'] as Uint8List?;
|
||||||
|
final width = (result['width'] as num?)?.toInt();
|
||||||
|
final height = (result['height'] as num?)?.toInt();
|
||||||
|
if (bytes == null || width == null || height == null) return null;
|
||||||
|
return UxVideoThumbnail(pngBytes: bytes, width: width, height: height);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import FlutterMacOS
|
|
||||||
import AppKit
|
import AppKit
|
||||||
|
import AVFoundation
|
||||||
|
import CoreServices
|
||||||
|
import FlutterMacOS
|
||||||
import Quartz
|
import Quartz
|
||||||
|
|
||||||
public class FilePlugin: NSObject, NativePlugin {
|
public class FilePlugin: NSObject, NativePlugin {
|
||||||
@@ -15,12 +17,131 @@ public class FilePlugin: NSObject, NativePlugin {
|
|||||||
|
|
||||||
private func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
|
private func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
|
||||||
switch call.method {
|
switch call.method {
|
||||||
case "share": handleShare(call, result: result)
|
case "share": handleShare(call, result: result)
|
||||||
case "open": handleOpen(call, result: result)
|
case "open": handleOpen(call, result: result)
|
||||||
default: result(FlutterMethodNotImplemented)
|
case "pick": handlePick(call, result: result)
|
||||||
|
case "videoThumbnail": handleVideoThumbnail(call, result: result)
|
||||||
|
default: result(FlutterMethodNotImplemented)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func handleVideoThumbnail(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
|
||||||
|
guard let args = call.arguments as? [String: Any],
|
||||||
|
let path = args["path"] as? String else {
|
||||||
|
return result(FlutterError(code: "bad_args", message: "path is required", details: nil))
|
||||||
|
}
|
||||||
|
let atMs = (args["atMs"] as? NSNumber)?.intValue ?? 0
|
||||||
|
let maxWidth = (args["maxWidth"] as? NSNumber)?.intValue ?? 320
|
||||||
|
|
||||||
|
let url = URL(fileURLWithPath: path)
|
||||||
|
let asset = AVURLAsset(url: url)
|
||||||
|
let generator = AVAssetImageGenerator(asset: asset)
|
||||||
|
generator.appliesPreferredTrackTransform = true
|
||||||
|
generator.maximumSize = CGSize(width: CGFloat(maxWidth), height: 0)
|
||||||
|
generator.requestedTimeToleranceBefore = CMTime(value: 500, timescale: 1000)
|
||||||
|
generator.requestedTimeToleranceAfter = CMTime(value: 500, timescale: 1000)
|
||||||
|
|
||||||
|
DispatchQueue.global(qos: .userInitiated).async {
|
||||||
|
do {
|
||||||
|
let cgImage = try generator.copyCGImage(
|
||||||
|
at: CMTime(value: Int64(atMs) * 1000, timescale: 1_000_000),
|
||||||
|
actualTime: nil,
|
||||||
|
)
|
||||||
|
let bitmap = NSBitmapImageRep(cgImage: cgImage)
|
||||||
|
guard let png = bitmap.representation(using: .png, properties: [:]) else {
|
||||||
|
DispatchQueue.main.async { result(nil) }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let reply: [String: Any] = [
|
||||||
|
"png": FlutterStandardTypedData(bytes: png),
|
||||||
|
"width": cgImage.width,
|
||||||
|
"height": cgImage.height,
|
||||||
|
]
|
||||||
|
DispatchQueue.main.async { result(reply) }
|
||||||
|
} catch {
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
result(FlutterError(
|
||||||
|
code: "decode_failed",
|
||||||
|
message: "could not extract frame: \(error.localizedDescription)",
|
||||||
|
details: nil,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handlePick(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
|
||||||
|
let args = call.arguments as? [String: Any]
|
||||||
|
let mimeTypes = (args?["mimeTypes"] as? [String]) ?? []
|
||||||
|
|
||||||
|
let panel = NSOpenPanel()
|
||||||
|
panel.canChooseFiles = true
|
||||||
|
panel.canChooseDirectories = false
|
||||||
|
panel.allowsMultipleSelection = false
|
||||||
|
panel.resolvesAliases = true
|
||||||
|
|
||||||
|
let utis = utiStrings(forMimeTypes: mimeTypes)
|
||||||
|
// Empty / `public.data` only means "any" — leave allowed types unset
|
||||||
|
// so users can pick any file.
|
||||||
|
if !(utis.count == 1 && utis[0] == kUTTypeData as String) {
|
||||||
|
// `allowedFileTypes` accepts both UTI strings and bare file
|
||||||
|
// extensions. Deprecated on macOS 12+ but still functional.
|
||||||
|
panel.allowedFileTypes = utis
|
||||||
|
}
|
||||||
|
|
||||||
|
let host = UxWindow.flutterView?.window
|
||||||
|
let completion: (NSApplication.ModalResponse) -> Void = { response in
|
||||||
|
guard response == .OK, let url = panel.url else {
|
||||||
|
result(nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let path = url.path
|
||||||
|
let attrs = try? FileManager.default.attributesOfItem(atPath: path)
|
||||||
|
let size = (attrs?[.size] as? NSNumber)?.intValue
|
||||||
|
let mime = mimeFromExtension(url.pathExtension)
|
||||||
|
result([
|
||||||
|
"path": path,
|
||||||
|
"name": url.lastPathComponent,
|
||||||
|
"mimeType": mime as Any,
|
||||||
|
"size": size as Any,
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
if let host = host {
|
||||||
|
panel.beginSheetModal(for: host, completionHandler: completion)
|
||||||
|
} else {
|
||||||
|
panel.begin(completionHandler: completion)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Maps Dart-side MIME strings to legacy UTI strings (CoreServices,
|
||||||
|
/// macOS 10.15-compatible). Wildcards and unknown MIMEs degrade to
|
||||||
|
/// `public.data`.
|
||||||
|
private func utiStrings(forMimeTypes mimes: [String]) -> [String] {
|
||||||
|
if mimes.isEmpty { return [kUTTypeData as String] }
|
||||||
|
var out: [String] = []
|
||||||
|
for m in mimes {
|
||||||
|
if m == "*/*" { return [kUTTypeData as String] }
|
||||||
|
if m.hasSuffix("/*") {
|
||||||
|
switch String(m.dropLast(2)) {
|
||||||
|
case "image": out.append(kUTTypeImage as String)
|
||||||
|
case "video": out.append(kUTTypeMovie as String)
|
||||||
|
case "audio": out.append(kUTTypeAudio as String)
|
||||||
|
case "text": out.append(kUTTypeText as String)
|
||||||
|
case "application": out.append(kUTTypeData as String)
|
||||||
|
default: out.append(kUTTypeData as String)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if let unmanaged = UTTypeCreatePreferredIdentifierForTag(
|
||||||
|
kUTTagClassMIMEType, m as CFString, nil
|
||||||
|
) {
|
||||||
|
out.append(unmanaged.takeRetainedValue() as String)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out.isEmpty ? [kUTTypeData as String] : out
|
||||||
|
}
|
||||||
|
|
||||||
private func handleShare(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
|
private func handleShare(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
|
||||||
guard let args = call.arguments as? [String: Any],
|
guard let args = call.arguments as? [String: Any],
|
||||||
let path = args["path"] as? String else {
|
let path = args["path"] as? String else {
|
||||||
@@ -77,6 +198,19 @@ public class FilePlugin: NSObject, NativePlugin {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// MIME-type lookup from a filename extension via the legacy UTI machinery
|
||||||
|
/// (macOS 10.15 compatible).
|
||||||
|
fileprivate func mimeFromExtension(_ ext: String) -> String? {
|
||||||
|
guard !ext.isEmpty else { return nil }
|
||||||
|
guard let uti = UTTypeCreatePreferredIdentifierForTag(
|
||||||
|
kUTTagClassFilenameExtension, ext as CFString, nil
|
||||||
|
)?.takeRetainedValue() else { return nil }
|
||||||
|
guard let mime = UTTypeCopyPreferredTagWithClass(
|
||||||
|
uti, kUTTagClassMIMEType
|
||||||
|
)?.takeRetainedValue() else { return nil }
|
||||||
|
return mime as String
|
||||||
|
}
|
||||||
|
|
||||||
private final class UxQLPreviewResponder: NSView, QLPreviewPanelDataSource {
|
private final class UxQLPreviewResponder: NSView, QLPreviewPanelDataSource {
|
||||||
let url: URL
|
let url: URL
|
||||||
private weak var previousFirstResponder: NSResponder?
|
private weak var previousFirstResponder: NSResponder?
|
||||||
|
|||||||
Reference in New Issue
Block a user