commit 523bb073fb8bccb63fe1f1615c482aa8d1a246da Author: Tony Date: Mon Apr 27 03:19:57 2026 +0800 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b9d7f25 --- /dev/null +++ b/.gitignore @@ -0,0 +1,33 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock. +/pubspec.lock +**/doc/api/ +.dart_tool/ +.flutter-plugins-dependencies +/build/ +/coverage/ diff --git a/.metadata b/.metadata new file mode 100644 index 0000000..e9afb13 --- /dev/null +++ b/.metadata @@ -0,0 +1,30 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "d693b4b9dbac2acd4477aea4555ca6dcbea44ba2" + channel: "stable" + +project_type: plugin + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2 + base_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2 + - platform: android + create_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2 + base_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..41cc7d8 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,3 @@ +## 0.0.1 + +* TODO: Describe initial release. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..ba75c69 --- /dev/null +++ b/LICENSE @@ -0,0 +1 @@ +TODO: Add your license here. diff --git a/README.md b/README.md new file mode 100644 index 0000000..f9d4a07 --- /dev/null +++ b/README.md @@ -0,0 +1,18 @@ +# flutter_usb_serial + +A new Flutter plugin project. + +## Getting Started + +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. + +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 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. diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..a5744c1 --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1,4 @@ +include: package:flutter_lints/flutter.yaml + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 0000000..161bdcd --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,9 @@ +*.iml +.gradle +/local.properties +/.idea/workspace.xml +/.idea/libraries +.DS_Store +/build +/captures +.cxx diff --git a/android/build.gradle b/android/build.gradle new file mode 100644 index 0000000..8c1ff2c --- /dev/null +++ b/android/build.gradle @@ -0,0 +1,51 @@ +group = "com.dalimaster.flutter_usb_serial" +version = "1.0-SNAPSHOT" + +buildscript { + ext.kotlin_version = "2.1.0" + repositories { + google() + mavenCentral() + } + dependencies { + classpath("com.android.tools.build:gradle:8.9.1") + classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version") + } +} + +allprojects { + repositories { + google() + mavenCentral() + } +} + +apply plugin: "com.android.library" +apply plugin: "kotlin-android" + +android { + namespace = "com.dalimaster.flutter_usb_serial" + compileSdk = 36 + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_11 + } + + sourceSets { + main.java.srcDirs += "src/main/kotlin" + } + + defaultConfig { + minSdk = 21 + } +} + +dependencies { + // usb-serial-for-android as a local module included via settings.gradle + implementation project(':usbSerialForAndroid') +} diff --git a/android/settings.gradle b/android/settings.gradle new file mode 100644 index 0000000..914b74f --- /dev/null +++ b/android/settings.gradle @@ -0,0 +1,7 @@ +// Include the usb-serial-for-android library as a local module. +// The path is relative to this settings.gradle file. +include ':usbSerialForAndroid' +project(':usbSerialForAndroid').projectDir = new File( + rootProject.projectDir, + '../../../temp/usb-serial-for-android/usbSerialForAndroid' +) diff --git a/android/src/main/AndroidManifest.xml b/android/src/main/AndroidManifest.xml new file mode 100644 index 0000000..77ee995 --- /dev/null +++ b/android/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + 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 new file mode 100644 index 0000000..d06b5aa --- /dev/null +++ b/android/src/main/kotlin/com/dalimaster/flutter_usb_serial/FlutterUsbSerialPlugin.kt @@ -0,0 +1,322 @@ +package com.dalimaster.flutter_usb_serial + +import android.app.PendingIntent +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.hardware.usb.UsbDevice +import android.hardware.usb.UsbManager +import android.os.Build +import com.hoho.android.usbserial.driver.UsbSerialPort +import com.hoho.android.usbserial.driver.UsbSerialProber +import com.hoho.android.usbserial.util.SerialInputOutputManager +import io.flutter.embedding.engine.plugins.FlutterPlugin +import io.flutter.embedding.engine.plugins.activity.ActivityAware +import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding +import io.flutter.plugin.common.EventChannel +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel +import io.flutter.plugin.common.MethodChannel.MethodCallHandler +import io.flutter.plugin.common.MethodChannel.Result +import java.io.IOException + +class FlutterUsbSerialPlugin : + FlutterPlugin, + MethodCallHandler, + EventChannel.StreamHandler, + ActivityAware { + + companion object { + private const val ACTION_USB_PERMISSION = + "com.dalimaster.flutter_usb_serial.USB_PERMISSION" + private const val METHOD_CHANNEL = "flutter_usb_serial/methods" + private const val EVENT_CHANNEL = "flutter_usb_serial/data" + } + + private lateinit var methodChannel: MethodChannel + private lateinit var eventChannel: EventChannel + private lateinit var context: Context + private lateinit var usbManager: UsbManager + + @Volatile + private var eventSink: EventChannel.EventSink? = null + + private data class PortHandle( + val id: Int, + val port: UsbSerialPort, + val ioManager: SerialInputOutputManager, + ) + + private var nextPortId = 1 + private val openPorts = mutableMapOf() + private val portsLock = Any() + + private data class PendingPermission(val device: UsbDevice, val result: Result) + + @Volatile + private var pendingPermission: PendingPermission? = null + private val permissionLock = Any() + + private val permissionReceiver = object : BroadcastReceiver() { + override fun onReceive(ctx: Context?, intent: Intent?) { + if (intent?.action != ACTION_USB_PERMISSION) return + val device: UsbDevice? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + intent.getParcelableExtra(UsbManager.EXTRA_DEVICE, UsbDevice::class.java) + } else { + @Suppress("DEPRECATION") + intent.getParcelableExtra(UsbManager.EXTRA_DEVICE) + } + val granted = intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false) + var pending: PendingPermission? = null + synchronized(permissionLock) { + if (device != null && pendingPermission?.device?.deviceId == device.deviceId) { + pending = pendingPermission + pendingPermission = null + } + } + pending?.result?.success(granted) + } + } + + // ── FlutterPlugin ──────────────────────────────────────────────────────── + + override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) { + context = binding.applicationContext + usbManager = context.getSystemService(Context.USB_SERVICE) as UsbManager + + methodChannel = MethodChannel(binding.binaryMessenger, METHOD_CHANNEL) + methodChannel.setMethodCallHandler(this) + + eventChannel = EventChannel(binding.binaryMessenger, EVENT_CHANNEL) + eventChannel.setStreamHandler(this) + + val filter = IntentFilter(ACTION_USB_PERMISSION) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + context.registerReceiver(permissionReceiver, filter, Context.RECEIVER_NOT_EXPORTED) + } else { + context.registerReceiver(permissionReceiver, filter) + } + } + + override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { + methodChannel.setMethodCallHandler(null) + eventChannel.setStreamHandler(null) + context.unregisterReceiver(permissionReceiver) + closeAllPorts() + } + + // ── ActivityAware ──────────────────────────────────────────────────────── + + override fun onAttachedToActivity(binding: ActivityPluginBinding) {} + override fun onDetachedFromActivityForConfigChanges() {} + override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) {} + override fun onDetachedFromActivity() {} + + // ── EventChannel.StreamHandler ─────────────────────────────────────────── + + override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { + eventSink = events + } + + override fun onCancel(arguments: Any?) { + eventSink = null + } + + // ── MethodCallHandler ──────────────────────────────────────────────────── + + override fun onMethodCall(call: MethodCall, result: Result) { + when (call.method) { + "listDevices" -> listDevices(result) + "requestPermission" -> requestPermission(call, result) + "openPort" -> openPort(call, result) + "write" -> write(call, result) + "close" -> closePort(call, result) + else -> result.notImplemented() + } + } + + // ── Method implementations ─────────────────────────────────────────────── + + private fun listDevices(result: Result) { + val devices = usbManager.deviceList.values.mapNotNull { device -> + encodeDevice(device) + } + result.success(devices) + } + + private fun requestPermission(call: MethodCall, result: Result) { + val deviceId = call.argument("deviceId") ?: run { + result.error("INVALID_ARGUMENT", "deviceId required", null) + return + } + val device = usbManager.deviceList.values.firstOrNull { it.deviceId == deviceId } + if (device == null) { + result.error("DEVICE_NOT_FOUND", "USB device $deviceId not found", null) + return + } + if (usbManager.hasPermission(device)) { + result.success(true) + return + } + synchronized(permissionLock) { + pendingPermission = PendingPermission(device, result) + } + val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + else + PendingIntent.FLAG_UPDATE_CURRENT + val intent = Intent(ACTION_USB_PERMISSION).apply { setPackage(context.packageName) } + val pi = PendingIntent.getBroadcast(context, 0, intent, flags) + usbManager.requestPermission(device, pi) + } + + private fun openPort(call: MethodCall, result: Result) { + val deviceId = call.argument("deviceId") ?: run { + result.error("INVALID_ARGUMENT", "deviceId required", null) + return + } + val baudRate = call.argument("baudRate") ?: 9600 + val dataBits = call.argument("dataBits") ?: 8 + val parity = call.argument("parity") ?: UsbSerialPort.PARITY_NONE + val stopBits = call.argument("stopBits") ?: UsbSerialPort.STOPBITS_1 + + val device = usbManager.deviceList.values.firstOrNull { it.deviceId == deviceId } + if (device == null) { + result.error("DEVICE_NOT_FOUND", "USB device $deviceId not found", null) + return + } + if (!usbManager.hasPermission(device)) { + result.error("PERMISSION_DENIED", "USB permission not granted for device $deviceId", null) + return + } + val driver = UsbSerialProber.getDefaultProber().probeDevice(device) + if (driver == null || driver.ports.isEmpty()) { + result.error("NO_DRIVER", "No serial driver found for device $deviceId", null) + return + } + val connection = usbManager.openDevice(device) + if (connection == null) { + result.error("OPEN_FAILED", "Failed to open USB connection for device $deviceId", null) + return + } + val port = driver.ports[0] + try { + port.open(connection) + port.setParameters(baudRate, dataBits, stopBits, parity) + } catch (e: IOException) { + result.error("OPEN_FAILED", "Failed to configure port: ${e.message}", null) + return + } + + val portId: Int + synchronized(portsLock) { + portId = nextPortId++ + } + + val ioManager = SerialInputOutputManager( + port, + object : SerialInputOutputManager.Listener { + override fun onNewData(data: ByteArray) { + val sink = eventSink ?: return + val event = mapOf("portId" to portId, "type" to "data", "data" to data) + // EventSink must be called on the main thread + android.os.Handler(android.os.Looper.getMainLooper()).post { + sink.success(event) + } + } + + override fun onRunError(e: Exception) { + // Clean up immediately so the port is no longer usable + synchronized(portsLock) { openPorts.remove(portId) } + try { port.close() } catch (_: IOException) {} + val sink = eventSink ?: return + val event = mapOf( + "portId" to portId, + "type" to "error", + "message" to (e.message ?: "IO error"), + ) + android.os.Handler(android.os.Looper.getMainLooper()).post { + sink.success(event) + } + } + } + ) + ioManager.start() + + synchronized(portsLock) { + openPorts[portId] = PortHandle(portId, port, ioManager) + } + result.success(portId) + } + + private fun write(call: MethodCall, result: Result) { + val portId = call.argument("portId") ?: run { + result.error("INVALID_ARGUMENT", "portId required", null) + return + } + val data = call.argument("data") ?: run { + result.error("INVALID_ARGUMENT", "data required", null) + return + } + val handle = synchronized(portsLock) { openPorts[portId] } + if (handle == null) { + result.error("PORT_NOT_FOUND", "Port $portId is not open", null) + return + } + try { + handle.port.write(data, 2000) + result.success(data.size) + } catch (e: IOException) { + result.error("WRITE_FAILED", e.message, null) + } + } + + private fun closePort(call: MethodCall, result: Result) { + val portId = call.argument("portId") ?: run { + result.error("INVALID_ARGUMENT", "portId required", null) + return + } + val handle = synchronized(portsLock) { openPorts.remove(portId) } + if (handle != null) { + handle.ioManager.stop() + try { + handle.port.close() + } catch (_: IOException) {} + } + result.success(null) + } + + private fun closeAllPorts() { + val handles = synchronized(portsLock) { + val all = openPorts.values.toList() + openPorts.clear() + all + } + for (handle in handles) { + handle.ioManager.stop() + try { + handle.port.close() + } catch (_: IOException) {} + } + } + + // ── Helpers ────────────────────────────────────────────────────────────── + + private fun encodeDevice(device: UsbDevice): Map = mapOf( + "deviceId" to device.deviceId, + "vendorId" to device.vendorId, + "productId" to device.productId, + "deviceName" to device.deviceName, + "manufacturerName" to ( + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) + try { device.manufacturerName ?: "" } catch (_: SecurityException) { "" } + else "" + ), + "serialNumber" to ( + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) + try { device.serialNumber ?: "" } catch (_: SecurityException) { "" } + else "" + ), + ) +} diff --git a/android/src/test/kotlin/com/dalimaster/flutter_usb_serial/FlutterUsbSerialPluginTest.kt b/android/src/test/kotlin/com/dalimaster/flutter_usb_serial/FlutterUsbSerialPluginTest.kt new file mode 100644 index 0000000..82132c4 --- /dev/null +++ b/android/src/test/kotlin/com/dalimaster/flutter_usb_serial/FlutterUsbSerialPluginTest.kt @@ -0,0 +1,27 @@ +package com.dalimaster.flutter_usb_serial + +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel +import org.mockito.Mockito +import kotlin.test.Test + +/* + * This demonstrates a simple unit test of the Kotlin portion of this plugin's implementation. + * + * Once you have built the plugin's example app, you can run these tests from the command + * line by running `./gradlew testDebugUnitTest` in the `example/android/` directory, or + * you can run them directly from IDEs that support JUnit such as Android Studio. + */ + +internal class FlutterUsbSerialPluginTest { + @Test + fun onMethodCall_getPlatformVersion_returnsExpectedValue() { + val plugin = FlutterUsbSerialPlugin() + + val call = MethodCall("getPlatformVersion", null) + val mockResult: MethodChannel.Result = Mockito.mock(MethodChannel.Result::class.java) + plugin.onMethodCall(call, mockResult) + + Mockito.verify(mockResult).success("Android " + android.os.Build.VERSION.RELEASE) + } +} diff --git a/example/.gitignore b/example/.gitignore new file mode 100644 index 0000000..3820a95 --- /dev/null +++ b/example/.gitignore @@ -0,0 +1,45 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ +/coverage/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release diff --git a/example/README.md b/example/README.md new file mode 100644 index 0000000..dda92a6 --- /dev/null +++ b/example/README.md @@ -0,0 +1,16 @@ +# flutter_usb_serial_example + +Demonstrates how to use the flutter_usb_serial plugin. + +## Getting Started + +This project is a starting point for a Flutter application. + +A few resources to get you started if this is your first Flutter project: + +- [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab) +- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook) + +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. diff --git a/example/analysis_options.yaml b/example/analysis_options.yaml new file mode 100644 index 0000000..0d29021 --- /dev/null +++ b/example/analysis_options.yaml @@ -0,0 +1,28 @@ +# This file configures the analyzer, which statically analyzes Dart code to +# check for errors, warnings, and lints. +# +# The issues identified by the analyzer are surfaced in the UI of Dart-enabled +# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be +# invoked from the command line by running `flutter analyze`. + +# The following line activates a set of recommended lints for Flutter apps, +# packages, and plugins designed to encourage good coding practices. +include: package:flutter_lints/flutter.yaml + +linter: + # The lint rules applied to this project can be customized in the + # section below to disable rules from the `package:flutter_lints/flutter.yaml` + # included above or to enable additional rules. A list of all available lints + # and their documentation is published at https://dart.dev/lints. + # + # Instead of disabling a lint rule for the entire project in the + # section below, it can also be suppressed for a single line of code + # or a specific dart file by using the `// ignore: name_of_lint` and + # `// ignore_for_file: name_of_lint` syntax on the line or in the file + # producing the lint. + rules: + # avoid_print: false # Uncomment to disable the `avoid_print` rule + # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/example/android/.gitignore b/example/android/.gitignore new file mode 100644 index 0000000..be3943c --- /dev/null +++ b/example/android/.gitignore @@ -0,0 +1,14 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java +.cxx/ + +# Remember to never publicly share your keystore. +# See https://flutter.dev/to/reference-keystore +key.properties +**/*.keystore +**/*.jks diff --git a/example/android/app/build.gradle.kts b/example/android/app/build.gradle.kts new file mode 100644 index 0000000..e68a280 --- /dev/null +++ b/example/android/app/build.gradle.kts @@ -0,0 +1,44 @@ +plugins { + id("com.android.application") + id("kotlin-android") + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id("dev.flutter.flutter-gradle-plugin") +} + +android { + namespace = "com.dalimaster.flutter_usb_serial_example" + compileSdk = flutter.compileSdkVersion + ndkVersion = flutter.ndkVersion + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_11.toString() + } + + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + applicationId = "com.dalimaster.flutter_usb_serial_example" + // You can update the following values to match your application needs. + // For more information, see: https://flutter.dev/to/review-gradle-config. + minSdk = flutter.minSdkVersion + targetSdk = flutter.targetSdkVersion + versionCode = flutter.versionCode + versionName = flutter.versionName + } + + buildTypes { + release { + // TODO: Add your own signing config for the release build. + // Signing with the debug keys for now, so `flutter run --release` works. + signingConfig = signingConfigs.getByName("debug") + } + } +} + +flutter { + source = "../.." +} diff --git a/example/android/app/src/debug/AndroidManifest.xml b/example/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/example/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/example/android/app/src/main/AndroidManifest.xml b/example/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..a955f60 --- /dev/null +++ b/example/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/example/android/app/src/main/kotlin/com/dalimaster/flutter_usb_serial_example/MainActivity.kt b/example/android/app/src/main/kotlin/com/dalimaster/flutter_usb_serial_example/MainActivity.kt new file mode 100644 index 0000000..6e4a730 --- /dev/null +++ b/example/android/app/src/main/kotlin/com/dalimaster/flutter_usb_serial_example/MainActivity.kt @@ -0,0 +1,5 @@ +package com.dalimaster.flutter_usb_serial_example + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity : FlutterActivity() diff --git a/example/android/app/src/main/res/drawable-v21/launch_background.xml b/example/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000..f74085f --- /dev/null +++ b/example/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/example/android/app/src/main/res/drawable/launch_background.xml b/example/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..304732f --- /dev/null +++ b/example/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..db77bb4 Binary files /dev/null and b/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..17987b7 Binary files /dev/null and b/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..09d4391 Binary files /dev/null and b/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..d5f1c8d Binary files /dev/null and b/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..4d6372e Binary files /dev/null and b/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/example/android/app/src/main/res/values-night/styles.xml b/example/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..06952be --- /dev/null +++ b/example/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/example/android/app/src/main/res/values/styles.xml b/example/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..cb1ef88 --- /dev/null +++ b/example/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/example/android/app/src/profile/AndroidManifest.xml b/example/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/example/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/example/android/build.gradle.kts b/example/android/build.gradle.kts new file mode 100644 index 0000000..dbee657 --- /dev/null +++ b/example/android/build.gradle.kts @@ -0,0 +1,24 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +val newBuildDir: Directory = + rootProject.layout.buildDirectory + .dir("../../build") + .get() +rootProject.layout.buildDirectory.value(newBuildDir) + +subprojects { + val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) + project.layout.buildDirectory.value(newSubprojectBuildDir) +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/example/android/gradle.properties b/example/android/gradle.properties new file mode 100644 index 0000000..f018a61 --- /dev/null +++ b/example/android/gradle.properties @@ -0,0 +1,3 @@ +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true +android.enableJetifier=true diff --git a/example/android/gradle/wrapper/gradle-wrapper.properties b/example/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..ac3b479 --- /dev/null +++ b/example/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-all.zip diff --git a/example/android/settings.gradle.kts b/example/android/settings.gradle.kts new file mode 100644 index 0000000..fb605bc --- /dev/null +++ b/example/android/settings.gradle.kts @@ -0,0 +1,26 @@ +pluginManagement { + val flutterSdkPath = + run { + val properties = java.util.Properties() + file("local.properties").inputStream().use { properties.load(it) } + val flutterSdkPath = properties.getProperty("flutter.sdk") + require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } + flutterSdkPath + } + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id("dev.flutter.flutter-plugin-loader") version "1.0.0" + id("com.android.application") version "8.9.1" apply false + id("org.jetbrains.kotlin.android") version "2.1.0" apply false +} + +include(":app") diff --git a/example/integration_test/plugin_integration_test.dart b/example/integration_test/plugin_integration_test.dart new file mode 100644 index 0000000..23b28e4 --- /dev/null +++ b/example/integration_test/plugin_integration_test.dart @@ -0,0 +1,25 @@ +// This is a basic Flutter integration test. +// +// Since integration tests run in a full Flutter application, they can interact +// with the host side of a plugin implementation, unlike Dart unit tests. +// +// For more information about Flutter integration tests, please see +// https://flutter.dev/to/integration-testing + + +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import 'package:flutter_usb_serial/flutter_usb_serial.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('getPlatformVersion test', (WidgetTester tester) async { + final FlutterUsbSerial plugin = FlutterUsbSerial(); + final String? version = await plugin.getPlatformVersion(); + // The version string depends on the host platform running the test, so + // just assert that some non-empty string is returned. + expect(version?.isNotEmpty, true); + }); +} diff --git a/example/lib/main.dart b/example/lib/main.dart new file mode 100644 index 0000000..4eeb6b9 --- /dev/null +++ b/example/lib/main.dart @@ -0,0 +1,63 @@ +import 'package:flutter/material.dart'; +import 'dart:async'; + +import 'package:flutter/services.dart'; +import 'package:flutter_usb_serial/flutter_usb_serial.dart'; + +void main() { + runApp(const MyApp()); +} + +class MyApp extends StatefulWidget { + const MyApp({super.key}); + + @override + State createState() => _MyAppState(); +} + +class _MyAppState extends State { + String _platformVersion = 'Unknown'; + final _flutterUsbSerialPlugin = FlutterUsbSerial(); + + @override + void initState() { + super.initState(); + initPlatformState(); + } + + // Platform messages are asynchronous, so we initialize in an async method. + Future initPlatformState() async { + String platformVersion; + // Platform messages may fail, so we use a try/catch PlatformException. + // We also handle the message potentially returning null. + try { + platformVersion = + await _flutterUsbSerialPlugin.getPlatformVersion() ?? 'Unknown platform version'; + } on PlatformException { + platformVersion = 'Failed to get platform version.'; + } + + // If the widget was removed from the tree while the asynchronous platform + // message was in flight, we want to discard the reply rather than calling + // setState to update our non-existent appearance. + if (!mounted) return; + + setState(() { + _platformVersion = platformVersion; + }); + } + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + appBar: AppBar( + title: const Text('Plugin example app'), + ), + body: Center( + child: Text('Running on: $_platformVersion\n'), + ), + ), + ); + } +} diff --git a/example/pubspec.lock b/example/pubspec.lock new file mode 100644 index 0000000..ba6a8e8 --- /dev/null +++ b/example/pubspec.lock @@ -0,0 +1,283 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + async: + dependency: transitive + description: + name: async + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 + url: "https://pub.dev" + source: hosted + version: "2.13.1" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + characters: + dependency: transitive + description: + name: characters + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + cupertino_icons: + dependency: "direct main" + description: + name: cupertino_icons + sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd" + url: "https://pub.dev" + source: hosted + version: "1.0.9" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_driver: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "5398f14efa795ffb7a33e9b6a08798b26a180edac4ad7db3f231e40f82ce11e1" + url: "https://pub.dev" + source: hosted + version: "5.0.0" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_usb_serial: + dependency: "direct main" + description: + path: ".." + relative: true + source: path + version: "0.1.0" + fuchsia_remote_debug_protocol: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + integration_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + lints: + dependency: transitive + description: + name: lints + sha256: c35bb79562d980e9a453fc715854e1ed39e24e7d0297a880ef54e17f9874a9d7 + url: "https://pub.dev" + source: hosted + version: "5.1.1" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + url: "https://pub.dev" + source: hosted + version: "0.12.17" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + url: "https://pub.dev" + source: hosted + version: "0.11.1" + meta: + dependency: transitive + description: + name: meta + sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c + url: "https://pub.dev" + source: hosted + version: "1.16.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + process: + dependency: transitive + description: + name: process + sha256: c6248e4526673988586e8c00bb22a49210c258dc91df5227d5da9748ecf79744 + url: "https://pub.dev" + source: hosted + version: "5.0.5" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + sync_http: + dependency: transitive + description: + name: sync_http + sha256: "7f0cd72eca000d2e026bcd6f990b81d0ca06022ef4e32fb257b30d3d1014a961" + url: "https://pub.dev" + source: hosted + version: "0.3.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" + url: "https://pub.dev" + source: hosted + version: "0.7.6" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + url: "https://pub.dev" + source: hosted + version: "2.2.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "046d3928e16fa4dc46e8350415661755ab759d9fc97fc21b5ab295f71e4f0499" + url: "https://pub.dev" + source: hosted + version: "15.1.0" + webdriver: + dependency: transitive + description: + name: webdriver + sha256: "2f3a14ca026957870cfd9c635b83507e0e51d8091568e90129fbf805aba7cade" + url: "https://pub.dev" + source: hosted + version: "3.1.0" +sdks: + dart: ">=3.9.2 <4.0.0" + flutter: ">=3.18.0-18.0.pre.54" diff --git a/example/pubspec.yaml b/example/pubspec.yaml new file mode 100644 index 0000000..3726eaf --- /dev/null +++ b/example/pubspec.yaml @@ -0,0 +1,85 @@ +name: flutter_usb_serial_example +description: "Demonstrates how to use the flutter_usb_serial plugin." +# The following line prevents the package from being accidentally published to +# pub.dev using `flutter pub publish`. This is preferred for private packages. +publish_to: 'none' # Remove this line if you wish to publish to pub.dev + +environment: + sdk: ^3.9.2 + +# Dependencies specify other packages that your package needs in order to work. +# To automatically upgrade your package dependencies to the latest versions +# consider running `flutter pub upgrade --major-versions`. Alternatively, +# dependencies can be manually updated by changing the version numbers below to +# the latest version available on pub.dev. To see which dependencies have newer +# versions available, run `flutter pub outdated`. +dependencies: + flutter: + sdk: flutter + + flutter_usb_serial: + # When depending on this package from a real application you should use: + # flutter_usb_serial: ^x.y.z + # See https://dart.dev/tools/pub/dependencies#version-constraints + # The example app is bundled with the plugin so we use a path dependency on + # the parent directory to use the current plugin's version. + path: ../ + + # The following adds the Cupertino Icons font to your application. + # Use with the CupertinoIcons class for iOS style icons. + cupertino_icons: ^1.0.8 + +dev_dependencies: + integration_test: + sdk: flutter + flutter_test: + sdk: flutter + + # The "flutter_lints" package below contains a set of recommended lints to + # encourage good coding practices. The lint set provided by the package is + # activated in the `analysis_options.yaml` file located at the root of your + # package. See that file for information about deactivating specific lint + # rules and activating additional ones. + flutter_lints: ^5.0.0 + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter packages. +flutter: + + # The following line ensures that the Material Icons font is + # included with your application, so that you can use the icons in + # the material Icons class. + uses-material-design: true + + # To add assets to your application, add an assets section, like this: + # assets: + # - images/a_dot_burr.jpeg + # - images/a_dot_ham.jpeg + + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/to/resolution-aware-images + + # For details regarding adding assets from package dependencies, see + # https://flutter.dev/to/asset-from-package + + # To add custom fonts to your application, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts from package dependencies, + # see https://flutter.dev/to/font-from-package diff --git a/example/test/widget_test.dart b/example/test/widget_test.dart new file mode 100644 index 0000000..4fa5eb6 --- /dev/null +++ b/example/test/widget_test.dart @@ -0,0 +1,27 @@ +// This is a basic Flutter widget test. +// +// To perform an interaction with a widget in your test, use the WidgetTester +// utility in the flutter_test package. For example, you can send tap and scroll +// gestures. You can also use WidgetTester to find child widgets in the widget +// tree, read text, and verify that the values of widget properties are correct. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:flutter_usb_serial_example/main.dart'; + +void main() { + testWidgets('Verify Platform version', (WidgetTester tester) async { + // Build our app and trigger a frame. + await tester.pumpWidget(const MyApp()); + + // Verify that platform version is retrieved. + expect( + find.byWidgetPredicate( + (Widget widget) => widget is Text && + widget.data!.startsWith('Running on:'), + ), + findsOneWidget, + ); + }); +} diff --git a/lib/flutter_usb_serial.dart b/lib/flutter_usb_serial.dart new file mode 100644 index 0000000..b7ce329 --- /dev/null +++ b/lib/flutter_usb_serial.dart @@ -0,0 +1,13 @@ +/// Flutter plugin for USB serial communication on Android. +/// +/// Uses `usb-serial-for-android` (https://github.com/mik3y/usb-serial-for-android) +/// as the underlying Android library. +library; + +import 'dart:async'; + +import 'package:flutter/services.dart'; + +part 'src/usb_serial_device.dart'; +part 'src/usb_serial_port.dart'; +part 'src/flutter_usb_serial.dart'; diff --git a/lib/flutter_usb_serial_method_channel.dart b/lib/flutter_usb_serial_method_channel.dart new file mode 100644 index 0000000..15289da --- /dev/null +++ b/lib/flutter_usb_serial_method_channel.dart @@ -0,0 +1,17 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; + +import 'flutter_usb_serial_platform_interface.dart'; + +/// An implementation of [FlutterUsbSerialPlatform] that uses method channels. +class MethodChannelFlutterUsbSerial extends FlutterUsbSerialPlatform { + /// The method channel used to interact with the native platform. + @visibleForTesting + final methodChannel = const MethodChannel('flutter_usb_serial'); + + @override + Future getPlatformVersion() async { + final version = await methodChannel.invokeMethod('getPlatformVersion'); + return version; + } +} diff --git a/lib/flutter_usb_serial_platform_interface.dart b/lib/flutter_usb_serial_platform_interface.dart new file mode 100644 index 0000000..82680d1 --- /dev/null +++ b/lib/flutter_usb_serial_platform_interface.dart @@ -0,0 +1,29 @@ +import 'package:plugin_platform_interface/plugin_platform_interface.dart'; + +import 'flutter_usb_serial_method_channel.dart'; + +abstract class FlutterUsbSerialPlatform extends PlatformInterface { + /// Constructs a FlutterUsbSerialPlatform. + FlutterUsbSerialPlatform() : super(token: _token); + + static final Object _token = Object(); + + static FlutterUsbSerialPlatform _instance = MethodChannelFlutterUsbSerial(); + + /// The default instance of [FlutterUsbSerialPlatform] to use. + /// + /// Defaults to [MethodChannelFlutterUsbSerial]. + static FlutterUsbSerialPlatform get instance => _instance; + + /// Platform-specific implementations should set this with their own + /// platform-specific class that extends [FlutterUsbSerialPlatform] when + /// they register themselves. + static set instance(FlutterUsbSerialPlatform instance) { + PlatformInterface.verifyToken(instance, _token); + _instance = instance; + } + + Future getPlatformVersion() { + throw UnimplementedError('platformVersion() has not been implemented.'); + } +} diff --git a/lib/src/flutter_usb_serial.dart b/lib/src/flutter_usb_serial.dart new file mode 100644 index 0000000..30d41c3 --- /dev/null +++ b/lib/src/flutter_usb_serial.dart @@ -0,0 +1,102 @@ +part of '../flutter_usb_serial.dart'; + +/// Main entry point for the flutter_usb_serial plugin. +/// +/// Only supported on Android. +class FlutterUsbSerial { + FlutterUsbSerial._(); + + static const _methods = MethodChannel('flutter_usb_serial/methods'); + static const _events = EventChannel('flutter_usb_serial/data'); + + static Stream>? _rawEventStream; + + static Stream> get _eventStream { + _rawEventStream ??= _events + .receiveBroadcastStream() + .cast>(); + return _rawEventStream!; + } + + /// Returns the list of currently attached USB serial devices. + static Future> listDevices() async { + final result = await _methods.invokeMethod>('listDevices'); + if (result == null) return []; + return result + .whereType>() + .map(UsbSerialDevice.fromMap) + .toList(); + } + + /// Requests Android USB permission for [device] if not already granted. + /// + /// Returns `true` if permission was granted (or was already held). + static Future requestPermission(UsbSerialDevice device) async { + final granted = await _methods.invokeMethod('requestPermission', { + 'deviceId': device.deviceId, + }); + return granted ?? false; + } + + /// Opens the first port of [device] with the given serial parameters. + /// + /// [baudRate] defaults to 9600. + /// [dataBits] must be 5, 6, 7, or 8. Defaults to 8. + /// [parity] defaults to [UsbSerialParity.none]. + /// [stopBits] defaults to [UsbSerialStopBits.one]. + /// + /// The returned [UsbSerialPort] streams incoming data via [UsbSerialPort.inputStream]. + /// Call [UsbSerialPort.close] when done. + static Future openPort( + UsbSerialDevice device, { + int baudRate = 9600, + int dataBits = 8, + UsbSerialParity parity = UsbSerialParity.none, + UsbSerialStopBits stopBits = UsbSerialStopBits.one, + }) async { + final portId = await _methods.invokeMethod('openPort', { + 'deviceId': device.deviceId, + 'baudRate': baudRate, + 'dataBits': dataBits, + 'parity': parity.index, + 'stopBits': _stopBitsIndex(stopBits), + }); + if (portId == null) { + throw PlatformException( + code: 'OPEN_FAILED', + message: 'Failed to open USB serial port for device ${device.deviceId}', + ); + } + + 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())); + } + } else if (e['type'] == 'error') { + sink.addError(Exception( + e['message'] as String? ?? 'USB IO error')); + } + }, + )); + + return UsbSerialPort._(portId, dataStream); + } + + static int _stopBitsIndex(UsbSerialStopBits sb) { + switch (sb) { + case UsbSerialStopBits.one: + return 1; + case UsbSerialStopBits.onePointFive: + return 3; // UsbSerialPort.STOPBITS_1_5 + case UsbSerialStopBits.two: + return 2; + } + } +} diff --git a/lib/src/usb_serial_device.dart b/lib/src/usb_serial_device.dart new file mode 100644 index 0000000..bb75b8a --- /dev/null +++ b/lib/src/usb_serial_device.dart @@ -0,0 +1,56 @@ +part of '../flutter_usb_serial.dart'; + +/// Represents a USB serial device found during enumeration. +class UsbSerialDevice { + /// Unique device ID (Android USB device ID). + final int deviceId; + + /// Vendor ID (VID). + final int vendorId; + + /// Product ID (PID). + final int productId; + + /// Human-readable device name (product string from descriptor, may be empty). + final String deviceName; + + /// Manufacturer string (may be empty). + final String manufacturerName; + + /// Serial number string (may be empty). + final String serialNumber; + + const UsbSerialDevice({ + required this.deviceId, + required this.vendorId, + required this.productId, + required this.deviceName, + required this.manufacturerName, + required this.serialNumber, + }); + + factory UsbSerialDevice.fromMap(Map map) { + return UsbSerialDevice( + deviceId: (map['deviceId'] as int?) ?? 0, + vendorId: (map['vendorId'] as int?) ?? 0, + productId: (map['productId'] as int?) ?? 0, + deviceName: (map['deviceName'] as String?) ?? '', + manufacturerName: (map['manufacturerName'] as String?) ?? '', + serialNumber: (map['serialNumber'] as String?) ?? '', + ); + } + + Map toMap() => { + 'deviceId': deviceId, + 'vendorId': vendorId, + 'productId': productId, + 'deviceName': deviceName, + 'manufacturerName': manufacturerName, + 'serialNumber': serialNumber, + }; + + @override + String toString() => + 'UsbSerialDevice(id=$deviceId, vid=0x${vendorId.toRadixString(16).padLeft(4, '0')}, ' + 'pid=0x${productId.toRadixString(16).padLeft(4, '0')}, name="$deviceName")'; +} diff --git a/lib/src/usb_serial_port.dart b/lib/src/usb_serial_port.dart new file mode 100644 index 0000000..01f8296 --- /dev/null +++ b/lib/src/usb_serial_port.dart @@ -0,0 +1,41 @@ +part of '../flutter_usb_serial.dart'; + +/// Parity modes for serial communication. +enum UsbSerialParity { none, odd, even, mark, space } + +/// Stop bits for serial communication. +enum UsbSerialStopBits { one, onePointFive, two } + +/// An open serial port handle returned by [FlutterUsbSerial.openPort]. +/// +/// Call [close] when done to release native resources. +class UsbSerialPort { + final int _portId; + + UsbSerialPort._(this._portId, this._dataStream); + + final Stream _dataStream; + + /// Stream of raw bytes received on this port. + Stream get inputStream => _dataStream; + + static const _methods = MethodChannel('flutter_usb_serial/methods'); + + /// Write [data] to the port. + /// + /// Returns the number of bytes actually written, or throws on error. + Future write(Uint8List data) async { + final written = await _methods.invokeMethod('write', { + 'portId': _portId, + 'data': data, + }); + return written ?? 0; + } + + /// Close the port and release native resources. + Future close() async { + await _methods.invokeMethod('close', {'portId': _portId}); + } + + int get portId => _portId; +} diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..540753d --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,24 @@ +name: flutter_usb_serial +description: "Flutter plugin for USB serial communication on Android using usb-serial-for-android." +version: 0.1.0 + +environment: + sdk: ^3.9.2 + flutter: '>=3.3.0' + +dependencies: + flutter: + sdk: flutter + plugin_platform_interface: ^2.0.2 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^5.0.0 + +flutter: + plugin: + platforms: + android: + package: com.dalimaster.flutter_usb_serial + pluginClass: FlutterUsbSerialPlugin diff --git a/test/flutter_usb_serial_method_channel_test.dart b/test/flutter_usb_serial_method_channel_test.dart new file mode 100644 index 0000000..989b25a --- /dev/null +++ b/test/flutter_usb_serial_method_channel_test.dart @@ -0,0 +1,27 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_usb_serial/flutter_usb_serial_method_channel.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + MethodChannelFlutterUsbSerial platform = MethodChannelFlutterUsbSerial(); + const MethodChannel channel = MethodChannel('flutter_usb_serial'); + + setUp(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( + channel, + (MethodCall methodCall) async { + return '42'; + }, + ); + }); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(channel, null); + }); + + test('getPlatformVersion', () async { + expect(await platform.getPlatformVersion(), '42'); + }); +} diff --git a/test/flutter_usb_serial_test.dart b/test/flutter_usb_serial_test.dart new file mode 100644 index 0000000..238e4a5 --- /dev/null +++ b/test/flutter_usb_serial_test.dart @@ -0,0 +1,29 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_usb_serial/flutter_usb_serial.dart'; +import 'package:flutter_usb_serial/flutter_usb_serial_platform_interface.dart'; +import 'package:flutter_usb_serial/flutter_usb_serial_method_channel.dart'; +import 'package:plugin_platform_interface/plugin_platform_interface.dart'; + +class MockFlutterUsbSerialPlatform + with MockPlatformInterfaceMixin + implements FlutterUsbSerialPlatform { + + @override + Future getPlatformVersion() => Future.value('42'); +} + +void main() { + final FlutterUsbSerialPlatform initialPlatform = FlutterUsbSerialPlatform.instance; + + test('$MethodChannelFlutterUsbSerial is the default instance', () { + expect(initialPlatform, isInstanceOf()); + }); + + test('getPlatformVersion', () async { + FlutterUsbSerial flutterUsbSerialPlugin = FlutterUsbSerial(); + MockFlutterUsbSerialPlatform fakePlatform = MockFlutterUsbSerialPlatform(); + FlutterUsbSerialPlatform.instance = fakePlatform; + + expect(await flutterUsbSerialPlugin.getPlatformVersion(), '42'); + }); +}