diff --git a/android/src/main/kotlin/io/swipelab/ux/FilePlugin.kt b/android/src/main/kotlin/io/swipelab/ux/FilePlugin.kt index 1878af7..5f72806 100644 --- a/android/src/main/kotlin/io/swipelab/ux/FilePlugin.kt +++ b/android/src/main/kotlin/io/swipelab/ux/FilePlugin.kt @@ -4,18 +4,31 @@ import android.app.Activity import android.content.ActivityNotFoundException import android.content.Context 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 androidx.core.content.FileProvider import io.flutter.embedding.engine.plugins.FlutterPlugin import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel +import io.flutter.plugin.common.PluginRegistry +import java.io.ByteArrayOutputStream 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 context: Context? = 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) { context = binding.applicationContext @@ -32,16 +45,26 @@ class FilePlugin : NativePlugin, MethodChannel.MethodCallHandler { override fun onAttachedToActivity(binding: ActivityPluginBinding) { activity = binding.activity + activityBinding = binding + binding.addActivityResultListener(this) } override fun onDetachedFromActivity() { + activityBinding?.removeActivityResultListener(this) + activityBinding = 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) { when (call.method) { "share" -> handleShare(call, result) "open" -> handleOpen(call, result) + "pick" -> handlePick(call, result) + "videoThumbnail" -> handleVideoThumbnail(call, result) 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>("mimeTypes")) + ?.filterIsInstance() + ?.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 { + 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("path") + ?: return result.error("bad_args", "path is required", null) + val atMs = (call.argument("atMs") ?: 0).toLong() + val maxWidth = (call.argument("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( + "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 { if (!supplied.isNullOrBlank() && supplied != "application/octet-stream") { return supplied @@ -116,6 +298,8 @@ class FilePlugin : NativePlugin, MethodChannel.MethodCallHandler { } companion object { + private const val REQUEST_CODE_PICK = 0xC51 // arbitrary, namespaced + private val textExtensions = setOf( "dart", "swift", "kt", "kts", "java", "scala", "groovy", "py", "rb", "php", "pl", "sh", "bash", "zsh", "fish", diff --git a/example/pubspec.lock b/example/pubspec.lock index ba9f97d..e64c39c 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -502,7 +502,7 @@ packages: path: ".." relative: true source: path - version: "0.7.0" + version: "0.8.0" vector_math: dependency: transitive description: diff --git a/ios/Classes/FilePlugin.swift b/ios/Classes/FilePlugin.swift index b1a5445..6bf4a24 100644 --- a/ios/Classes/FilePlugin.swift +++ b/ios/Classes/FilePlugin.swift @@ -1,10 +1,13 @@ +import AVFoundation import Flutter +import MobileCoreServices import QuickLook import UIKit public class FilePlugin: NSObject, NativePlugin { private var channel: FlutterMethodChannel? private var previewDataSource: FilePreviewDataSource? + private var pickerDelegate: UxDocumentPickerDelegate? public func register(with registrar: FlutterPluginRegistrar) { 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) { switch call.method { - case "share": handleShare(call, result: result) - case "open": handleOpen(call, result: result) - default: result(FlutterMethodNotImplemented) + case "share": handleShare(call, result: result) + case "open": handleOpen(call, result: result) + 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) { guard let args = call.arguments as? [String: Any], 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 { let url: URL let title: String diff --git a/lib/src/file.dart b/lib/src/file.dart index 1da23be..81cab1c 100644 --- a/lib/src/file.dart +++ b/lib/src/file.dart @@ -1,7 +1,43 @@ +import 'dart:typed_data'; import 'dart:ui' show Rect; 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 { UxFile._(); @@ -67,4 +103,62 @@ class UxFile { }); 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 pick({ + List? mimeTypes, + }) async { + final result = await _channel.invokeMapMethod('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 videoThumbnail({ + required String path, + int atMs = 0, + int maxWidth = 320, + }) async { + final result = await _channel.invokeMapMethod( + 'videoThumbnail', + { + '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); + } } diff --git a/macos/Classes/FilePlugin.swift b/macos/Classes/FilePlugin.swift index 3b6d756..412aeee 100644 --- a/macos/Classes/FilePlugin.swift +++ b/macos/Classes/FilePlugin.swift @@ -1,5 +1,7 @@ -import FlutterMacOS import AppKit +import AVFoundation +import CoreServices +import FlutterMacOS import Quartz public class FilePlugin: NSObject, NativePlugin { @@ -15,12 +17,131 @@ public class FilePlugin: NSObject, NativePlugin { private func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { switch call.method { - case "share": handleShare(call, result: result) - case "open": handleOpen(call, result: result) - default: result(FlutterMethodNotImplemented) + case "share": handleShare(call, result: result) + case "open": handleOpen(call, result: result) + 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) { guard let args = call.arguments as? [String: Any], 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 { let url: URL private weak var previousFirstResponder: NSResponder?