From 17414656a9d2997610a63a474210166c47982503 Mon Sep 17 00:00:00 2001 From: Tony Date: Sat, 1 Aug 2026 20:40:46 +0800 Subject: [PATCH] Enhance USB serial plugin for Android and iPadOS with DriverKit support - Updated README.md to reflect platform support for iPadOS. - Implemented isDriverAvailable method in FlutterUsbSerialPlugin for DriverKit readiness check. - Added DriverKitSerialClient C source and header files for iPadOS integration. - Created Swift Package for iPadOS support. - Updated pubspec.yaml and flutter_usb_serial.dart to clarify platform compatibility. - Enhanced tests to verify isDriverAvailable functionality. Signed-off-by: Tony --- README.md | 37 +- .../FlutterUsbSerialPlugin.kt | 1 + ios/flutter_usb_serial/Package.swift | 37 ++ .../DriverKitSerialClient.c | 195 +++++++++++ .../include/DriverKitSerialClient.h | 64 ++++ .../FlutterUsbSerialPlugin.swift | 331 ++++++++++++++++++ .../Resources/PrivacyInfo.xcprivacy | 12 + lib/flutter_usb_serial.dart | 5 +- lib/src/flutter_usb_serial.dart | 43 ++- pubspec.yaml | 4 +- test/flutter_usb_serial_test.dart | 26 +- 11 files changed, 724 insertions(+), 31 deletions(-) create mode 100644 ios/flutter_usb_serial/Package.swift create mode 100644 ios/flutter_usb_serial/Sources/DriverKitSerialClient/DriverKitSerialClient.c create mode 100644 ios/flutter_usb_serial/Sources/DriverKitSerialClient/include/DriverKitSerialClient.h create mode 100644 ios/flutter_usb_serial/Sources/flutter_usb_serial/FlutterUsbSerialPlugin.swift create mode 100644 ios/flutter_usb_serial/Sources/flutter_usb_serial/Resources/PrivacyInfo.xcprivacy diff --git a/README.md b/README.md index f9d4a07..d0fa74b 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,31 @@ # flutter_usb_serial -A new Flutter plugin project. +Flutter USB serial transport for Android and DriverKit-enabled iPadOS. -## Getting Started +## Platforms -This project is a starting point for a Flutter -[plug-in package](https://flutter.dev/to/develop-plugins), -a specialized package that includes platform-specific implementation code for -Android and/or iOS. +- Android uses `usb-serial-for-android` and the normal Android USB permission + flow. +- iPadOS 16+ uses Swift Package Manager and a user client exposed by a + USBDriverKit extension embedded in the host app. DriverKit USB support is + available only on iPads with an M-series chip. -For help getting started with Flutter development, view the -[online documentation](https://docs.flutter.dev), which offers tutorials, -samples, guidance on mobile development, and a full API reference. +The package contains the SwiftPM client plugin, not a reusable pre-signed +DriverKit extension. Apple provisions USB transport entitlements to the host +app and driver for a specific USB vendor. The host app must: -The plugin project was generated without specifying the `--platforms` flag, no platforms are currently supported. -To add platforms, run `flutter create -t plugin --platforms .` in this directory. -You can also find a detailed instruction on how to add platforms in the `pubspec.yaml` at https://flutter.dev/to/pubspec-plugin-platforms. +1. Embed a DriverKit extension whose `IOUserClass` is + `DaliMasterEspressifUsbDriver`, or adapt the client service name. +2. Add `com.apple.developer.driverkit.communicates-with-drivers` to the app. +3. Sign the extension with `com.apple.developer.driverkit` and a + `com.apple.developer.driverkit.transport.usb` entitlement approved for its + USB vendor ID. +4. Enable Swift Package Manager integration for Flutter iOS plugins. + +Call `FlutterUsbSerial.isDriverAvailable()` during startup before showing a USB +transport. It returns `true` only when a compatible driver service is loaded. + +The DaliMaster host implementation is deliberately restricted to Espressif +vendor ID `0x303A` and the USB Serial/JTAG interface. Serial parameter and modem +line methods are accepted for API compatibility; the ESP USB Serial/JTAG bulk +transport does not use UART baud or line coding. diff --git a/android/src/main/kotlin/com/dalimaster/flutter_usb_serial/FlutterUsbSerialPlugin.kt b/android/src/main/kotlin/com/dalimaster/flutter_usb_serial/FlutterUsbSerialPlugin.kt index a17140e..a79d36f 100644 --- a/android/src/main/kotlin/com/dalimaster/flutter_usb_serial/FlutterUsbSerialPlugin.kt +++ b/android/src/main/kotlin/com/dalimaster/flutter_usb_serial/FlutterUsbSerialPlugin.kt @@ -127,6 +127,7 @@ class FlutterUsbSerialPlugin : override fun onMethodCall(call: MethodCall, result: Result) { when (call.method) { + "isDriverAvailable" -> result.success(true) "listDevices" -> listDevices(result) "requestPermission" -> requestPermission(call, result) "openPort" -> openPort(call, result) diff --git a/ios/flutter_usb_serial/Package.swift b/ios/flutter_usb_serial/Package.swift new file mode 100644 index 0000000..854d812 --- /dev/null +++ b/ios/flutter_usb_serial/Package.swift @@ -0,0 +1,37 @@ +// swift-tools-version: 5.9 + +import PackageDescription + +let package = Package( + name: "flutter_usb_serial", + platforms: [ + .iOS("16.0"), + ], + products: [ + .library(name: "flutter-usb-serial", targets: ["flutter_usb_serial"]), + ], + dependencies: [ + .package(name: "FlutterFramework", path: "../FlutterFramework"), + ], + targets: [ + .target( + name: "DriverKitSerialClient", + path: "Sources/DriverKitSerialClient", + publicHeadersPath: "include", + linkerSettings: [ + .linkedFramework("IOKit"), + ] + ), + .target( + name: "flutter_usb_serial", + dependencies: [ + "DriverKitSerialClient", + .product(name: "FlutterFramework", package: "FlutterFramework"), + ], + path: "Sources/flutter_usb_serial", + resources: [ + .process("Resources"), + ] + ), + ] +) diff --git a/ios/flutter_usb_serial/Sources/DriverKitSerialClient/DriverKitSerialClient.c b/ios/flutter_usb_serial/Sources/DriverKitSerialClient/DriverKitSerialClient.c new file mode 100644 index 0000000..4629313 --- /dev/null +++ b/ios/flutter_usb_serial/Sources/DriverKitSerialClient/DriverKitSerialClient.c @@ -0,0 +1,195 @@ +#include "DriverKitSerialClient.h" + +#include +#include +#include + +static const char *const kFlutterUsbDriverService = + "DaliMasterEspressifUsbDriver"; + +enum { + kFlutterUsbDriverGetInfo = 0, + kFlutterUsbDriverWrite = 1, + kFlutterUsbDriverRead = 2, + kFlutterUsbDriverControl = 3, + kFlutterUsbDriverConfigure = 4, + kFlutterUsbDriverSetLineState = 5, + kFlutterUsbDriverPurge = 6, +}; + +static bool FlutterUsbSerialDriverGetInfo( + io_connect_t connection, + FlutterUsbDriverDeviceInfo *info) { + size_t outputSize = sizeof(*info); + memset(info, 0, sizeof(*info)); + kern_return_t result = IOConnectCallStructMethod( + connection, kFlutterUsbDriverGetInfo, NULL, 0, info, &outputSize); + return result == KERN_SUCCESS && outputSize == sizeof(*info) && + info->protocolVersion == 1 && + info->kind == FlutterUsbDriverKindSerial; +} + +size_t FlutterUsbSerialDriverCopyDevices( + FlutterUsbDriverDeviceInfo *devices, + size_t capacity) { + CFMutableDictionaryRef matching = + IOServiceNameMatching(kFlutterUsbDriverService); + if (matching == NULL) { + return 0; + } + + io_iterator_t iterator = IO_OBJECT_NULL; + kern_return_t result = IOServiceGetMatchingServices( + kIOMainPortDefault, matching, &iterator); + if (result != KERN_SUCCESS) { + return 0; + } + + size_t count = 0; + io_service_t service = IO_OBJECT_NULL; + while ((service = IOIteratorNext(iterator)) != IO_OBJECT_NULL) { + io_connect_t connection = IO_OBJECT_NULL; + FlutterUsbDriverDeviceInfo info; + if (IOServiceOpen(service, mach_task_self_, 0, &connection) == + KERN_SUCCESS && + FlutterUsbSerialDriverGetInfo(connection, &info)) { + uint64_t registryEntryId = 0; + if (IORegistryEntryGetRegistryEntryID(service, ®istryEntryId) == + KERN_SUCCESS) { + info.registryEntryId = registryEntryId; + if (devices != NULL && count < capacity) { + devices[count] = info; + } + count++; + } + } + if (connection != IO_OBJECT_NULL) { + IOServiceClose(connection); + } + IOObjectRelease(service); + } + IOObjectRelease(iterator); + return count; +} + +bool FlutterUsbSerialDriverIsAvailable(void) { + return FlutterUsbSerialDriverCopyDevices(NULL, 0) > 0; +} + +int32_t FlutterUsbSerialDriverOpen(uint64_t registryEntryId, + uint32_t *connection) { + if (connection == NULL) { + return kIOReturnBadArgument; + } + *connection = IO_OBJECT_NULL; + + CFMutableDictionaryRef matching = + IOServiceNameMatching(kFlutterUsbDriverService); + if (matching == NULL) { + return kIOReturnNoMemory; + } + io_iterator_t iterator = IO_OBJECT_NULL; + kern_return_t result = IOServiceGetMatchingServices( + kIOMainPortDefault, matching, &iterator); + if (result != KERN_SUCCESS) { + return result; + } + + io_service_t service = IO_OBJECT_NULL; + result = kIOReturnNotFound; + while ((service = IOIteratorNext(iterator)) != IO_OBJECT_NULL) { + uint64_t candidateId = 0; + if (IORegistryEntryGetRegistryEntryID(service, &candidateId) == + KERN_SUCCESS && + candidateId == registryEntryId) { + io_connect_t opened = IO_OBJECT_NULL; + result = IOServiceOpen(service, mach_task_self_, 0, &opened); + if (result == KERN_SUCCESS) { + FlutterUsbDriverDeviceInfo info; + if (FlutterUsbSerialDriverGetInfo(opened, &info)) { + *connection = opened; + } else { + IOServiceClose(opened); + result = kIOReturnUnsupported; + } + } + IOObjectRelease(service); + break; + } + IOObjectRelease(service); + } + IOObjectRelease(iterator); + return result; +} + +void FlutterUsbSerialDriverClose(uint32_t connection) { + if (connection != IO_OBJECT_NULL) { + IOServiceClose((io_connect_t)connection); + } +} + +int32_t FlutterUsbSerialDriverWrite(uint32_t connection, + const uint8_t *bytes, + uint32_t length, + uint32_t *bytesWritten) { + if (connection == IO_OBJECT_NULL || bytes == NULL || bytesWritten == NULL) { + return kIOReturnBadArgument; + } + uint64_t output = 0; + uint32_t outputCount = 1; + kern_return_t result = IOConnectCallMethod( + (io_connect_t)connection, kFlutterUsbDriverWrite, NULL, 0, bytes, length, + &output, &outputCount, NULL, NULL); + *bytesWritten = result == KERN_SUCCESS ? (uint32_t)output : 0; + return result; +} + +int32_t FlutterUsbSerialDriverRead(uint32_t connection, + uint8_t *bytes, + uint32_t capacity, + uint32_t timeoutMilliseconds, + uint32_t *bytesRead) { + if (connection == IO_OBJECT_NULL || bytes == NULL || bytesRead == NULL) { + return kIOReturnBadArgument; + } + const uint64_t inputs[] = {capacity, timeoutMilliseconds}; + size_t outputSize = capacity; + kern_return_t result = IOConnectCallMethod( + (io_connect_t)connection, kFlutterUsbDriverRead, inputs, 2, NULL, 0, + NULL, NULL, bytes, &outputSize); + if (result == kIOReturnTimeout) { + *bytesRead = 0; + return KERN_SUCCESS; + } + *bytesRead = result == KERN_SUCCESS ? (uint32_t)outputSize : 0; + return result; +} + +int32_t FlutterUsbSerialDriverConfigure(uint32_t connection, + uint32_t baudRate, + uint8_t dataBits, + uint8_t parity, + uint8_t stopBits) { + const uint64_t inputs[] = {baudRate, dataBits, parity, stopBits}; + return IOConnectCallScalarMethod((io_connect_t)connection, + kFlutterUsbDriverConfigure, inputs, 4, + NULL, NULL); +} + +int32_t FlutterUsbSerialDriverSetLineState(uint32_t connection, + bool dtr, + bool rts) { + const uint64_t inputs[] = {dtr ? 1U : 0U, rts ? 1U : 0U}; + return IOConnectCallScalarMethod((io_connect_t)connection, + kFlutterUsbDriverSetLineState, inputs, 2, + NULL, NULL); +} + +int32_t FlutterUsbSerialDriverPurge(uint32_t connection, + bool purgeWrite, + bool purgeRead) { + const uint64_t inputs[] = {purgeWrite ? 1U : 0U, purgeRead ? 1U : 0U}; + return IOConnectCallScalarMethod((io_connect_t)connection, + kFlutterUsbDriverPurge, inputs, 2, + NULL, NULL); +} diff --git a/ios/flutter_usb_serial/Sources/DriverKitSerialClient/include/DriverKitSerialClient.h b/ios/flutter_usb_serial/Sources/DriverKitSerialClient/include/DriverKitSerialClient.h new file mode 100644 index 0000000..4a11053 --- /dev/null +++ b/ios/flutter_usb_serial/Sources/DriverKitSerialClient/include/DriverKitSerialClient.h @@ -0,0 +1,64 @@ +#ifndef DRIVER_KIT_SERIAL_CLIENT_H +#define DRIVER_KIT_SERIAL_CLIENT_H + +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +enum { + FlutterUsbDriverKindUnknown = 0, + FlutterUsbDriverKindSerial = 1, + FlutterUsbDriverKindHid = 2, +}; + +typedef struct { + uint64_t registryEntryId; + uint32_t protocolVersion; + uint32_t kind; + uint16_t vendorId; + uint16_t productId; + uint16_t maxInputPacketSize; + uint16_t maxOutputPacketSize; + uint8_t interfaceNumber; + uint8_t inputEndpoint; + uint8_t outputEndpoint; + uint8_t reserved; +} FlutterUsbDriverDeviceInfo; + +bool FlutterUsbSerialDriverIsAvailable(void); +size_t FlutterUsbSerialDriverCopyDevices( + FlutterUsbDriverDeviceInfo *devices, + size_t capacity); +int32_t FlutterUsbSerialDriverOpen(uint64_t registryEntryId, + uint32_t *connection); +void FlutterUsbSerialDriverClose(uint32_t connection); +int32_t FlutterUsbSerialDriverWrite(uint32_t connection, + const uint8_t *bytes, + uint32_t length, + uint32_t *bytesWritten); +int32_t FlutterUsbSerialDriverRead(uint32_t connection, + uint8_t *bytes, + uint32_t capacity, + uint32_t timeoutMilliseconds, + uint32_t *bytesRead); +int32_t FlutterUsbSerialDriverConfigure(uint32_t connection, + uint32_t baudRate, + uint8_t dataBits, + uint8_t parity, + uint8_t stopBits); +int32_t FlutterUsbSerialDriverSetLineState(uint32_t connection, + bool dtr, + bool rts); +int32_t FlutterUsbSerialDriverPurge(uint32_t connection, + bool purgeWrite, + bool purgeRead); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/ios/flutter_usb_serial/Sources/flutter_usb_serial/FlutterUsbSerialPlugin.swift b/ios/flutter_usb_serial/Sources/flutter_usb_serial/FlutterUsbSerialPlugin.swift new file mode 100644 index 0000000..53c847f --- /dev/null +++ b/ios/flutter_usb_serial/Sources/flutter_usb_serial/FlutterUsbSerialPlugin.swift @@ -0,0 +1,331 @@ +import DriverKitSerialClient +import Flutter +import Foundation + +private final class SerialPortHandle { + let portId: Int + let connection: UInt32 + let info: FlutterUsbDriverDeviceInfo + let readQueue: DispatchQueue + var running = true + var dtr = false + var rts = false + + init(portId: Int, connection: UInt32, info: FlutterUsbDriverDeviceInfo) { + self.portId = portId + self.connection = connection + self.info = info + readQueue = DispatchQueue( + label: "flutter_usb_serial.driverkit.\(portId)", + qos: .userInitiated + ) + } +} + +public final class FlutterUsbSerialPlugin: NSObject, FlutterPlugin, FlutterStreamHandler { + private static let methodChannelName = "flutter_usb_serial/methods" + private static let eventChannelName = "flutter_usb_serial/data" + + private var eventSink: FlutterEventSink? + private var nextPortId = 1 + private var ports: [Int: SerialPortHandle] = [:] + private let stateQueue = DispatchQueue(label: "flutter_usb_serial.driverkit.state") + + public static func register(with registrar: FlutterPluginRegistrar) { + let instance = FlutterUsbSerialPlugin() + let methods = FlutterMethodChannel( + name: methodChannelName, + binaryMessenger: registrar.messenger() + ) + registrar.addMethodCallDelegate(instance, channel: methods) + let events = FlutterEventChannel( + name: eventChannelName, + binaryMessenger: registrar.messenger() + ) + events.setStreamHandler(instance) + } + + public func onListen( + withArguments arguments: Any?, + eventSink events: @escaping FlutterEventSink + ) -> FlutterError? { + eventSink = events + return nil + } + + public func onCancel(withArguments arguments: Any?) -> FlutterError? { + eventSink = nil + return nil + } + + public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + switch call.method { + case "isDriverAvailable": + result(FlutterUsbSerialDriverIsAvailable()) + case "listDevices": + result(copyDevices().map(deviceMap)) + case "requestPermission": + result(deviceInfo(from: call.arguments) != nil) + case "openPort": + openPort(arguments: call.arguments, result: result) + case "write": + write(arguments: call.arguments, result: result) + case "setParameters": + setParameters(arguments: call.arguments, result: result) + case "setDTR": + setLineState(arguments: call.arguments, dtr: true, result: result) + case "setRTS": + setLineState(arguments: call.arguments, dtr: false, result: result) + case "purgeHwBuffers": + purge(arguments: call.arguments, result: result) + case "close": + close(arguments: call.arguments, result: result) + default: + result(FlutterMethodNotImplemented) + } + } + + private func copyDevices() -> [FlutterUsbDriverDeviceInfo] { + let count = FlutterUsbSerialDriverCopyDevices(nil, 0) + guard count > 0 else { return [] } + var devices = Array(repeating: FlutterUsbDriverDeviceInfo(), count: count) + let copied = devices.withUnsafeMutableBufferPointer { buffer in + FlutterUsbSerialDriverCopyDevices(buffer.baseAddress, buffer.count) + } + return Array(devices.prefix(min(copied, devices.count))) + } + + private func deviceMap(_ info: FlutterUsbDriverDeviceInfo) -> [String: Any] { + let signedId = Int64(bitPattern: info.registryEntryId) + return [ + "deviceId": NSNumber(value: signedId), + "vendorId": Int(info.vendorId), + "productId": Int(info.productId), + "deviceName": "Espressif USB Serial/JTAG", + "manufacturerName": "Espressif", + "serialNumber": "", + ] + } + + private func deviceInfo(from arguments: Any?) -> FlutterUsbDriverDeviceInfo? { + guard + let map = arguments as? [String: Any], + let number = map["deviceId"] as? NSNumber + else { return nil } + let registryId = UInt64(bitPattern: number.int64Value) + return copyDevices().first { $0.registryEntryId == registryId } + } + + private func openPort(arguments: Any?, result: @escaping FlutterResult) { + guard + let map = arguments as? [String: Any], + let deviceNumber = map["deviceId"] as? NSNumber + else { + result(argumentError("deviceId is required")) + return + } + let registryId = UInt64(bitPattern: deviceNumber.int64Value) + guard let info = copyDevices().first(where: { $0.registryEntryId == registryId }) else { + result(pluginError("DEVICE_NOT_FOUND", "Espressif USB device is not available")) + return + } + + var connection: UInt32 = 0 + let openResult = FlutterUsbSerialDriverOpen(registryId, &connection) + guard openResult == 0 else { + result(driverError("OPEN_FAILED", "Could not open DriverKit user client", openResult)) + return + } + + let baudRate = (map["baudRate"] as? NSNumber)?.uint32Value ?? 9_600 + let dataBits = (map["dataBits"] as? NSNumber)?.uint8Value ?? 8 + let parity = (map["parity"] as? NSNumber)?.uint8Value ?? 0 + let stopBits = (map["stopBits"] as? NSNumber)?.uint8Value ?? 1 + let configureResult = FlutterUsbSerialDriverConfigure( + connection, baudRate, dataBits, parity, stopBits + ) + guard configureResult == 0 else { + FlutterUsbSerialDriverClose(connection) + result(driverError("OPEN_FAILED", "Could not configure USB serial port", configureResult)) + return + } + + let handle: SerialPortHandle = stateQueue.sync { + let id = nextPortId + nextPortId += 1 + let created = SerialPortHandle(portId: id, connection: connection, info: info) + ports[id] = created + return created + } + startReading(handle) + result(handle.portId) + } + + private func startReading(_ handle: SerialPortHandle) { + handle.readQueue.async { [weak self, weak handle] in + guard let self, let handle else { return } + let capacity = max(Int(handle.info.maxInputPacketSize), 64) + var buffer = [UInt8](repeating: 0, count: capacity) + while self.stateQueue.sync(execute: { handle.running }) { + var bytesRead: UInt32 = 0 + let readResult = buffer.withUnsafeMutableBytes { rawBuffer in + FlutterUsbSerialDriverRead( + handle.connection, + rawBuffer.bindMemory(to: UInt8.self).baseAddress, + UInt32(rawBuffer.count), + 250, + &bytesRead + ) + } + if readResult != 0 { + if self.stateQueue.sync(execute: { handle.running }) { + self.emit([ + "portId": handle.portId, + "type": "error", + "message": self.driverMessage("USB read failed", readResult), + ]) + } + break + } + if bytesRead > 0 { + self.emit([ + "portId": handle.portId, + "type": "data", + "data": FlutterStandardTypedData( + bytes: Data(buffer.prefix(Int(bytesRead))) + ), + ]) + } + } + } + } + + private func write(arguments: Any?, result: @escaping FlutterResult) { + guard + let map = arguments as? [String: Any], + let handle = port(from: map), + let typedData = map["data"] as? FlutterStandardTypedData + else { + result(argumentError("portId and data are required")) + return + } + var bytesWritten: UInt32 = 0 + let writeResult = typedData.data.withUnsafeBytes { rawBuffer in + FlutterUsbSerialDriverWrite( + handle.connection, + rawBuffer.bindMemory(to: UInt8.self).baseAddress, + UInt32(rawBuffer.count), + &bytesWritten + ) + } + if writeResult == 0 { + result(Int(bytesWritten)) + } else { + result(driverError("WRITE_FAILED", "USB serial write failed", writeResult)) + } + } + + private func setParameters(arguments: Any?, result: @escaping FlutterResult) { + guard let map = arguments as? [String: Any], let handle = port(from: map) else { + result(argumentError("portId is required")) + return + } + let callResult = FlutterUsbSerialDriverConfigure( + handle.connection, + (map["baudRate"] as? NSNumber)?.uint32Value ?? 9_600, + (map["dataBits"] as? NSNumber)?.uint8Value ?? 8, + (map["parity"] as? NSNumber)?.uint8Value ?? 0, + (map["stopBits"] as? NSNumber)?.uint8Value ?? 1 + ) + completeVoid(callResult, code: "CONFIGURE_FAILED", result: result) + } + + private func setLineState( + arguments: Any?, + dtr: Bool, + result: @escaping FlutterResult + ) { + guard + let map = arguments as? [String: Any], + let handle = port(from: map), + let value = map["value"] as? Bool + else { + result(argumentError("portId and value are required")) + return + } + if dtr { handle.dtr = value } else { handle.rts = value } + let callResult = FlutterUsbSerialDriverSetLineState( + handle.connection, handle.dtr, handle.rts + ) + completeVoid(callResult, code: "LINE_STATE_FAILED", result: result) + } + + private func purge(arguments: Any?, result: @escaping FlutterResult) { + guard let map = arguments as? [String: Any], let handle = port(from: map) else { + result(argumentError("portId is required")) + return + } + let callResult = FlutterUsbSerialDriverPurge( + handle.connection, + map["purgeWriteBuffers"] as? Bool ?? true, + map["purgeReadBuffers"] as? Bool ?? true + ) + completeVoid(callResult, code: "PURGE_FAILED", result: result) + } + + private func close(arguments: Any?, result: @escaping FlutterResult) { + guard + let map = arguments as? [String: Any], + let portId = (map["portId"] as? NSNumber)?.intValue + else { + result(argumentError("portId is required")) + return + } + let handle = stateQueue.sync { () -> SerialPortHandle? in + guard let removed = ports.removeValue(forKey: portId) else { return nil } + removed.running = false + return removed + } + if let handle { + FlutterUsbSerialDriverClose(handle.connection) + } + result(nil) + } + + private func port(from map: [String: Any]) -> SerialPortHandle? { + guard let portId = (map["portId"] as? NSNumber)?.intValue else { return nil } + return stateQueue.sync { ports[portId] } + } + + private func completeVoid( + _ callResult: Int32, + code: String, + result: @escaping FlutterResult + ) { + if callResult == 0 { + result(nil) + } else { + result(driverError(code, "DriverKit operation failed", callResult)) + } + } + + private func emit(_ event: [String: Any]) { + DispatchQueue.main.async { [weak self] in self?.eventSink?(event) } + } + + private func argumentError(_ message: String) -> FlutterError { + pluginError("INVALID_ARGUMENT", message) + } + + private func pluginError(_ code: String, _ message: String) -> FlutterError { + FlutterError(code: code, message: message, details: nil) + } + + private func driverError(_ code: String, _ message: String, _ value: Int32) -> FlutterError { + FlutterError(code: code, message: driverMessage(message, value), details: value) + } + + private func driverMessage(_ message: String, _ value: Int32) -> String { + String(format: "%@ (IOReturn 0x%08X)", message, UInt32(bitPattern: value)) + } +} diff --git a/ios/flutter_usb_serial/Sources/flutter_usb_serial/Resources/PrivacyInfo.xcprivacy b/ios/flutter_usb_serial/Sources/flutter_usb_serial/Resources/PrivacyInfo.xcprivacy new file mode 100644 index 0000000..23a9697 --- /dev/null +++ b/ios/flutter_usb_serial/Sources/flutter_usb_serial/Resources/PrivacyInfo.xcprivacy @@ -0,0 +1,12 @@ + + + + + NSPrivacyAccessedAPITypes + + NSPrivacyCollectedDataTypes + + NSPrivacyTracking + + + diff --git a/lib/flutter_usb_serial.dart b/lib/flutter_usb_serial.dart index b7ce329..44200ff 100644 --- a/lib/flutter_usb_serial.dart +++ b/lib/flutter_usb_serial.dart @@ -1,7 +1,8 @@ -/// Flutter plugin for USB serial communication on Android. +/// Flutter plugin for USB serial communication on Android and iPadOS. /// /// Uses `usb-serial-for-android` (https://github.com/mik3y/usb-serial-for-android) -/// as the underlying Android library. +/// as the underlying Android library. iPadOS uses an app-embedded DriverKit +/// extension supplied by the host application. library; import 'dart:async'; diff --git a/lib/src/flutter_usb_serial.dart b/lib/src/flutter_usb_serial.dart index 30d41c3..8f6842c 100644 --- a/lib/src/flutter_usb_serial.dart +++ b/lib/src/flutter_usb_serial.dart @@ -2,7 +2,8 @@ part of '../flutter_usb_serial.dart'; /// Main entry point for the flutter_usb_serial plugin. /// -/// Only supported on Android. +/// Supported on Android and on iPadOS through an app-embedded DriverKit +/// extension. class FlutterUsbSerial { FlutterUsbSerial._(); @@ -18,6 +19,15 @@ class FlutterUsbSerial { return _rawEventStream!; } + /// Whether the native USB serial backend is ready. + /// + /// Android always provides its USB host backend. On iPadOS this probes for + /// the DriverKit user-client service, so it is `false` until the matching + /// app-embedded driver has loaded successfully. + static Future isDriverAvailable() async { + return await _methods.invokeMethod('isDriverAvailable') ?? false; + } + /// Returns the list of currently attached USB serial devices. static Future> listDevices() async { final result = await _methods.invokeMethod>('listDevices'); @@ -70,21 +80,24 @@ class FlutterUsbSerial { final dataStream = _eventStream .where((e) => e['portId'] == portId) - .transform(StreamTransformer, Uint8List>.fromHandlers( - handleData: (e, sink) { - if (e['type'] == 'data') { - final raw = e['data']; - if (raw is Uint8List) { - sink.add(raw); - } else if (raw is List) { - sink.add(Uint8List.fromList(raw.cast())); + .transform( + StreamTransformer, Uint8List>.fromHandlers( + handleData: (e, sink) { + if (e['type'] == 'data') { + final raw = e['data']; + if (raw is Uint8List) { + sink.add(raw); + } else if (raw is List) { + sink.add(Uint8List.fromList(raw.cast())); + } + } else if (e['type'] == 'error') { + sink.addError( + Exception(e['message'] as String? ?? 'USB IO error'), + ); } - } else if (e['type'] == 'error') { - sink.addError(Exception( - e['message'] as String? ?? 'USB IO error')); - } - }, - )); + }, + ), + ); return UsbSerialPort._(portId, dataStream); } diff --git a/pubspec.yaml b/pubspec.yaml index 540753d..f24a4e5 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,5 +1,5 @@ name: flutter_usb_serial -description: "Flutter plugin for USB serial communication on Android using usb-serial-for-android." +description: "Flutter USB serial plugin for Android and iPadOS DriverKit." version: 0.1.0 environment: @@ -22,3 +22,5 @@ flutter: android: package: com.dalimaster.flutter_usb_serial pluginClass: FlutterUsbSerialPlugin + ios: + pluginClass: FlutterUsbSerialPlugin diff --git a/test/flutter_usb_serial_test.dart b/test/flutter_usb_serial_test.dart index e5daeeb..f87508a 100644 --- a/test/flutter_usb_serial_test.dart +++ b/test/flutter_usb_serial_test.dart @@ -1,10 +1,16 @@ +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_usb_serial/flutter_usb_serial.dart'; import 'package:flutter_usb_serial/flutter_usb_serial_method_channel.dart'; void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + test('$MethodChannelFlutterUsbSerial can be constructed', () { - expect(MethodChannelFlutterUsbSerial(), isA()); + expect( + MethodChannelFlutterUsbSerial(), + isA(), + ); }); test('UsbSerialDevice maps fields from method channel payloads', () { @@ -25,4 +31,22 @@ void main() { expect(device.serialNumber, 'SN-42'); expect(device.toMap()['deviceId'], 7); }); + + test( + 'isDriverAvailable forwards the native DriverKit readiness result', + () async { + const channel = MethodChannel('flutter_usb_serial/methods'); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + expect(call.method, 'isDriverAvailable'); + return true; + }); + addTearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + + expect(await FlutterUsbSerial.isDriverAvailable(), isTrue); + }, + ); }