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:
agra
2026-05-02 13:14:21 +03:00
parent a7735fdbb1
commit afc7e9c872
5 changed files with 581 additions and 9 deletions

View File

@@ -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",

View File

@@ -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:

View File

@@ -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

View File

@@ -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);
}
} }

View File

@@ -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?