- replace Android USB dependency with app-owned USB host implementation\n- restore BLE-first scanner flow with USB secondary action\n- tighten Web Serial key handling and disconnect logging\n\nTODO (follow-up):\n- review non-English localization copy for tone and consistency\n- trim remaining unused/awkward localization strings introduced during USB UI changeschore/offband-rebrand
parent
74da9e82b5
commit
44c0670dae
@ -1,408 +1,18 @@
|
|||||||
package com.meshcore.meshcore_open
|
package com.meshcore.meshcore_open
|
||||||
|
|
||||||
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.UsbDeviceConnection
|
|
||||||
import android.hardware.usb.UsbManager
|
|
||||||
import android.os.Build
|
|
||||||
import android.os.Handler
|
|
||||||
import android.os.Looper
|
|
||||||
import com.hoho.android.usbserial.driver.UsbSerialDriver
|
|
||||||
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.android.FlutterActivity
|
import io.flutter.embedding.android.FlutterActivity
|
||||||
import io.flutter.embedding.engine.FlutterEngine
|
import io.flutter.embedding.engine.FlutterEngine
|
||||||
import io.flutter.plugin.common.EventChannel
|
|
||||||
import io.flutter.plugin.common.MethodCall
|
|
||||||
import io.flutter.plugin.common.MethodChannel
|
|
||||||
import java.util.Locale
|
|
||||||
import java.util.concurrent.ExecutorService
|
|
||||||
import java.util.concurrent.Executors
|
|
||||||
|
|
||||||
class MainActivity : FlutterActivity() {
|
class MainActivity : FlutterActivity() {
|
||||||
private val usbMethodChannelName = "meshcore_open/android_usb_serial"
|
private val usbFunctions by lazy { MeshcoreUsbFunctions(this) }
|
||||||
private val usbEventChannelName = "meshcore_open/android_usb_serial_events"
|
|
||||||
private val usbPermissionAction = "com.meshcore.meshcore_open.USB_PERMISSION"
|
|
||||||
|
|
||||||
private lateinit var usbManager: UsbManager
|
|
||||||
private val mainHandler = Handler(Looper.getMainLooper())
|
|
||||||
private val usbIoExecutor: ExecutorService = Executors.newSingleThreadExecutor()
|
|
||||||
|
|
||||||
private var eventSink: EventChannel.EventSink? = null
|
|
||||||
private var usbConnection: UsbDeviceConnection? = null
|
|
||||||
private var usbPort: UsbSerialPort? = null
|
|
||||||
private var ioManager: SerialInputOutputManager? = null
|
|
||||||
private var connectedDeviceName: String? = null
|
|
||||||
|
|
||||||
private var pendingConnectResult: MethodChannel.Result? = null
|
|
||||||
private var pendingConnectPortName: String? = null
|
|
||||||
private var pendingConnectBaudRate: Int = 115200
|
|
||||||
|
|
||||||
private val permissionReceiver =
|
|
||||||
object : BroadcastReceiver() {
|
|
||||||
override fun onReceive(context: Context?, intent: Intent?) {
|
|
||||||
when (intent?.action) {
|
|
||||||
UsbManager.ACTION_USB_DEVICE_DETACHED -> {
|
|
||||||
handleUsbDetached(intent)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
usbPermissionAction -> {
|
|
||||||
}
|
|
||||||
else -> {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (intent.action != usbPermissionAction) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val result = pendingConnectResult
|
|
||||||
val portName = pendingConnectPortName
|
|
||||||
pendingConnectResult = null
|
|
||||||
pendingConnectPortName = null
|
|
||||||
|
|
||||||
if (result == null || portName == null) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val granted =
|
|
||||||
intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false)
|
|
||||||
if (!granted) {
|
|
||||||
result.error("usb_permission_denied", "USB permission denied", null)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val device = findUsbDevice(portName)
|
|
||||||
if (device == null) {
|
|
||||||
result.error(
|
|
||||||
"usb_device_missing",
|
|
||||||
"USB device no longer available for $portName",
|
|
||||||
null,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
openUsbDevice(device, pendingConnectBaudRate, result)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||||
super.configureFlutterEngine(flutterEngine)
|
super.configureFlutterEngine(flutterEngine)
|
||||||
|
usbFunctions.configureFlutterEngine(flutterEngine)
|
||||||
usbManager = getSystemService(Context.USB_SERVICE) as UsbManager
|
|
||||||
registerUsbPermissionReceiver()
|
|
||||||
|
|
||||||
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, usbMethodChannelName)
|
|
||||||
.setMethodCallHandler { call, result ->
|
|
||||||
when (call.method) {
|
|
||||||
"listPorts" -> result.success(listUsbPorts())
|
|
||||||
"connect" -> handleUsbConnect(call, result)
|
|
||||||
"write" -> handleUsbWrite(call, result)
|
|
||||||
"disconnect" -> {
|
|
||||||
scheduleCloseUsbConnection {
|
|
||||||
result.success(null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else -> result.notImplemented()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
EventChannel(flutterEngine.dartExecutor.binaryMessenger, usbEventChannelName)
|
|
||||||
.setStreamHandler(
|
|
||||||
object : EventChannel.StreamHandler {
|
|
||||||
override fun onListen(arguments: Any?, events: EventChannel.EventSink) {
|
|
||||||
eventSink = events
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCancel(arguments: Any?) {
|
|
||||||
eventSink = null
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
closeUsbConnection()
|
usbFunctions.dispose()
|
||||||
usbIoExecutor.shutdownNow()
|
|
||||||
try {
|
|
||||||
unregisterReceiver(permissionReceiver)
|
|
||||||
} catch (_: IllegalArgumentException) {
|
|
||||||
}
|
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun registerUsbPermissionReceiver() {
|
|
||||||
val filter =
|
|
||||||
IntentFilter().apply {
|
|
||||||
addAction(usbPermissionAction)
|
|
||||||
addAction(UsbManager.ACTION_USB_DEVICE_DETACHED)
|
|
||||||
}
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
|
||||||
registerReceiver(permissionReceiver, filter, Context.RECEIVER_NOT_EXPORTED)
|
|
||||||
} else {
|
|
||||||
@Suppress("DEPRECATION")
|
|
||||||
registerReceiver(permissionReceiver, filter)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun listUsbPorts(): List<String> {
|
|
||||||
val drivers = UsbSerialProber.getDefaultProber().findAllDrivers(usbManager)
|
|
||||||
return drivers.map { driver ->
|
|
||||||
val device = driver.device
|
|
||||||
val productName = device.productName ?: "USB Serial Device"
|
|
||||||
val vendorProduct =
|
|
||||||
String.format(
|
|
||||||
Locale.US,
|
|
||||||
"VID:%04X PID:%04X",
|
|
||||||
device.vendorId,
|
|
||||||
device.productId,
|
|
||||||
)
|
|
||||||
"${device.deviceName} - $productName - $vendorProduct"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun handleUsbConnect(call: MethodCall, result: MethodChannel.Result) {
|
|
||||||
val portName = call.argument<String>("portName")
|
|
||||||
val baudRate = call.argument<Int>("baudRate") ?: 115200
|
|
||||||
if (portName.isNullOrBlank()) {
|
|
||||||
result.error("usb_invalid_port", "Port name is required", null)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val device = findUsbDevice(portName)
|
|
||||||
if (device == null) {
|
|
||||||
result.error("usb_device_missing", "USB device not found for $portName", null)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (usbManager.hasPermission(device)) {
|
|
||||||
openUsbDevice(device, baudRate, result)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pendingConnectResult != null) {
|
|
||||||
result.error("usb_busy", "Another USB permission request is already pending", null)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
pendingConnectResult = result
|
|
||||||
pendingConnectPortName = portName
|
|
||||||
pendingConnectBaudRate = baudRate
|
|
||||||
|
|
||||||
val permissionIntent = PendingIntent.getBroadcast(
|
|
||||||
this,
|
|
||||||
0,
|
|
||||||
Intent(usbPermissionAction).setPackage(packageName),
|
|
||||||
pendingIntentFlags(),
|
|
||||||
)
|
|
||||||
usbManager.requestPermission(device, permissionIntent)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun handleUsbWrite(call: MethodCall, result: MethodChannel.Result) {
|
|
||||||
val data = call.argument<ByteArray>("data")
|
|
||||||
val port = usbPort
|
|
||||||
if (data == null) {
|
|
||||||
result.error("usb_invalid_data", "Data is required", null)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (port == null) {
|
|
||||||
result.error("usb_not_connected", "USB serial port is not connected", null)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
usbIoExecutor.execute {
|
|
||||||
try {
|
|
||||||
port.write(data, 1000)
|
|
||||||
mainHandler.post {
|
|
||||||
result.success(null)
|
|
||||||
}
|
|
||||||
} catch (error: Exception) {
|
|
||||||
mainHandler.post {
|
|
||||||
result.error("usb_write_failed", error.message, null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun findUsbDevice(portName: String): UsbDevice? {
|
|
||||||
return usbManager.deviceList.values.firstOrNull { it.deviceName == portName }
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun openUsbDevice(
|
|
||||||
device: UsbDevice,
|
|
||||||
baudRate: Int,
|
|
||||||
result: MethodChannel.Result,
|
|
||||||
) {
|
|
||||||
usbIoExecutor.execute {
|
|
||||||
try {
|
|
||||||
closeUsbConnection()
|
|
||||||
|
|
||||||
val driver = UsbSerialProber.getDefaultProber().probeDevice(device)
|
|
||||||
if (driver == null) {
|
|
||||||
mainHandler.post {
|
|
||||||
result.error(
|
|
||||||
"usb_driver_missing",
|
|
||||||
"No USB serial driver for ${device.deviceName}",
|
|
||||||
null,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return@execute
|
|
||||||
}
|
|
||||||
|
|
||||||
val connection = usbManager.openDevice(device)
|
|
||||||
if (connection == null) {
|
|
||||||
mainHandler.post {
|
|
||||||
result.error(
|
|
||||||
"usb_open_failed",
|
|
||||||
"UsbManager could not open ${device.deviceName}",
|
|
||||||
null,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return@execute
|
|
||||||
}
|
|
||||||
|
|
||||||
val port = firstPort(driver)
|
|
||||||
if (port == null) {
|
|
||||||
connection.close()
|
|
||||||
mainHandler.post {
|
|
||||||
result.error(
|
|
||||||
"usb_port_missing",
|
|
||||||
"No USB serial port exposed by ${device.deviceName}",
|
|
||||||
null,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return@execute
|
|
||||||
}
|
|
||||||
|
|
||||||
port.open(connection)
|
|
||||||
port.setParameters(
|
|
||||||
baudRate,
|
|
||||||
8,
|
|
||||||
UsbSerialPort.STOPBITS_1,
|
|
||||||
UsbSerialPort.PARITY_NONE,
|
|
||||||
)
|
|
||||||
port.rts = false
|
|
||||||
port.dtr = true
|
|
||||||
|
|
||||||
usbConnection = connection
|
|
||||||
usbPort = port
|
|
||||||
connectedDeviceName = device.deviceName
|
|
||||||
|
|
||||||
ioManager =
|
|
||||||
SerialInputOutputManager(
|
|
||||||
port,
|
|
||||||
object : SerialInputOutputManager.Listener {
|
|
||||||
override fun onNewData(data: ByteArray) {
|
|
||||||
mainHandler.post {
|
|
||||||
eventSink?.success(data)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onRunError(e: Exception) {
|
|
||||||
mainHandler.post {
|
|
||||||
eventSink?.error(
|
|
||||||
"usb_io_error",
|
|
||||||
e.message ?: "USB serial I/O error",
|
|
||||||
null,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
scheduleCloseUsbConnection()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
).also { manager ->
|
|
||||||
manager.start()
|
|
||||||
}
|
|
||||||
|
|
||||||
mainHandler.post {
|
|
||||||
result.success(null)
|
|
||||||
}
|
|
||||||
} catch (error: Exception) {
|
|
||||||
closeUsbConnection()
|
|
||||||
mainHandler.post {
|
|
||||||
result.error("usb_connect_failed", error.message, null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun firstPort(driver: UsbSerialDriver): UsbSerialPort? {
|
|
||||||
return driver.ports.firstOrNull()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun scheduleCloseUsbConnection(onComplete: (() -> Unit)? = null) {
|
|
||||||
usbIoExecutor.execute {
|
|
||||||
closeUsbConnection()
|
|
||||||
if (onComplete != null) {
|
|
||||||
mainHandler.post(onComplete)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Synchronized
|
|
||||||
private fun closeUsbConnection() {
|
|
||||||
try {
|
|
||||||
ioManager?.stop()
|
|
||||||
} catch (_: Exception) {
|
|
||||||
}
|
|
||||||
ioManager = null
|
|
||||||
|
|
||||||
try {
|
|
||||||
usbPort?.close()
|
|
||||||
} catch (_: Exception) {
|
|
||||||
}
|
|
||||||
usbPort = null
|
|
||||||
|
|
||||||
try {
|
|
||||||
usbConnection?.close()
|
|
||||||
} catch (_: Exception) {
|
|
||||||
}
|
|
||||||
usbConnection = null
|
|
||||||
connectedDeviceName = null
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun handleUsbDetached(intent: Intent) {
|
|
||||||
val detachedDevice =
|
|
||||||
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 detachedName = detachedDevice?.deviceName ?: return
|
|
||||||
|
|
||||||
if (pendingConnectPortName == detachedName) {
|
|
||||||
pendingConnectResult?.error(
|
|
||||||
"usb_device_detached",
|
|
||||||
"USB device was removed before the connection completed",
|
|
||||||
null,
|
|
||||||
)
|
|
||||||
pendingConnectResult = null
|
|
||||||
pendingConnectPortName = null
|
|
||||||
}
|
|
||||||
|
|
||||||
if (connectedDeviceName == detachedName) {
|
|
||||||
scheduleCloseUsbConnection {
|
|
||||||
eventSink?.error(
|
|
||||||
"usb_device_detached",
|
|
||||||
"USB device was disconnected",
|
|
||||||
null,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun pendingIntentFlags(): Int {
|
|
||||||
var flags = PendingIntent.FLAG_UPDATE_CURRENT
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
|
||||||
flags = flags or PendingIntent.FLAG_MUTABLE
|
|
||||||
}
|
|
||||||
return flags
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,574 @@
|
|||||||
|
package com.meshcore.meshcore_open
|
||||||
|
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.IntentFilter
|
||||||
|
import android.hardware.usb.UsbConstants
|
||||||
|
import android.hardware.usb.UsbDevice
|
||||||
|
import android.hardware.usb.UsbDeviceConnection
|
||||||
|
import android.hardware.usb.UsbEndpoint
|
||||||
|
import android.hardware.usb.UsbInterface
|
||||||
|
import android.hardware.usb.UsbManager
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import io.flutter.embedding.android.FlutterActivity
|
||||||
|
import io.flutter.embedding.engine.FlutterEngine
|
||||||
|
import io.flutter.plugin.common.EventChannel
|
||||||
|
import io.flutter.plugin.common.MethodCall
|
||||||
|
import io.flutter.plugin.common.MethodChannel
|
||||||
|
import java.util.Locale
|
||||||
|
import java.util.concurrent.ExecutorService
|
||||||
|
import java.util.concurrent.Executors
|
||||||
|
|
||||||
|
class MeshcoreUsbFunctions(
|
||||||
|
private val activity: FlutterActivity,
|
||||||
|
) {
|
||||||
|
private companion object {
|
||||||
|
const val usbRecipientInterface = 0x01
|
||||||
|
}
|
||||||
|
|
||||||
|
private val usbMethodChannelName = "meshcore_open/android_usb_serial"
|
||||||
|
private val usbEventChannelName = "meshcore_open/android_usb_serial_events"
|
||||||
|
private val usbPermissionAction = "com.meshcore.meshcore_open.USB_PERMISSION"
|
||||||
|
|
||||||
|
private val usbManager by lazy {
|
||||||
|
activity.getSystemService(Context.USB_SERVICE) as UsbManager
|
||||||
|
}
|
||||||
|
private val mainHandler = Handler(Looper.getMainLooper())
|
||||||
|
private val usbIoExecutor: ExecutorService = Executors.newSingleThreadExecutor()
|
||||||
|
|
||||||
|
private var eventSink: EventChannel.EventSink? = null
|
||||||
|
private var usbConnection: UsbDeviceConnection? = null
|
||||||
|
private var usbInEndpoint: UsbEndpoint? = null
|
||||||
|
private var usbOutEndpoint: UsbEndpoint? = null
|
||||||
|
private var controlInterface: UsbInterface? = null
|
||||||
|
private var dataInterface: UsbInterface? = null
|
||||||
|
private var readThread: Thread? = null
|
||||||
|
@Volatile private var isReading = false
|
||||||
|
private var connectedDeviceName: String? = null
|
||||||
|
|
||||||
|
private var pendingConnectResult: MethodChannel.Result? = null
|
||||||
|
private var pendingConnectPortName: String? = null
|
||||||
|
private var pendingConnectBaudRate: Int = 115200
|
||||||
|
|
||||||
|
private data class PortConfig(
|
||||||
|
val controlInterface: UsbInterface?,
|
||||||
|
val dataInterface: UsbInterface,
|
||||||
|
val inEndpoint: UsbEndpoint,
|
||||||
|
val outEndpoint: UsbEndpoint,
|
||||||
|
)
|
||||||
|
|
||||||
|
private val permissionReceiver =
|
||||||
|
object : BroadcastReceiver() {
|
||||||
|
override fun onReceive(context: Context?, intent: Intent?) {
|
||||||
|
when (intent?.action) {
|
||||||
|
UsbManager.ACTION_USB_DEVICE_DETACHED -> {
|
||||||
|
handleUsbDetached(intent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
usbPermissionAction -> Unit
|
||||||
|
else -> return
|
||||||
|
}
|
||||||
|
|
||||||
|
val result = pendingConnectResult
|
||||||
|
val portName = pendingConnectPortName
|
||||||
|
pendingConnectResult = null
|
||||||
|
pendingConnectPortName = null
|
||||||
|
|
||||||
|
if (result == null || portName == null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val granted =
|
||||||
|
intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false)
|
||||||
|
if (!granted) {
|
||||||
|
result.error("usb_permission_denied", "USB permission denied", null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val device = findUsbDevice(portName)
|
||||||
|
if (device == null) {
|
||||||
|
result.error(
|
||||||
|
"usb_device_missing",
|
||||||
|
"USB device no longer available for $portName",
|
||||||
|
null,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
openUsbDevice(device, pendingConnectBaudRate, result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||||
|
registerUsbPermissionReceiver()
|
||||||
|
|
||||||
|
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, usbMethodChannelName)
|
||||||
|
.setMethodCallHandler { call, result ->
|
||||||
|
when (call.method) {
|
||||||
|
"listPorts" -> result.success(listUsbPorts())
|
||||||
|
"connect" -> handleUsbConnect(call, result)
|
||||||
|
"write" -> handleUsbWrite(call, result)
|
||||||
|
"disconnect" -> {
|
||||||
|
scheduleCloseUsbConnection {
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> result.notImplemented()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
EventChannel(flutterEngine.dartExecutor.binaryMessenger, usbEventChannelName)
|
||||||
|
.setStreamHandler(
|
||||||
|
object : EventChannel.StreamHandler {
|
||||||
|
override fun onListen(arguments: Any?, events: EventChannel.EventSink) {
|
||||||
|
eventSink = events
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCancel(arguments: Any?) {
|
||||||
|
eventSink = null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun dispose() {
|
||||||
|
closeUsbConnection()
|
||||||
|
usbIoExecutor.shutdownNow()
|
||||||
|
try {
|
||||||
|
activity.unregisterReceiver(permissionReceiver)
|
||||||
|
} catch (_: IllegalArgumentException) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun registerUsbPermissionReceiver() {
|
||||||
|
val filter =
|
||||||
|
IntentFilter().apply {
|
||||||
|
addAction(usbPermissionAction)
|
||||||
|
addAction(UsbManager.ACTION_USB_DEVICE_DETACHED)
|
||||||
|
}
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
activity.registerReceiver(permissionReceiver, filter, Context.RECEIVER_NOT_EXPORTED)
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
activity.registerReceiver(permissionReceiver, filter)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun listUsbPorts(): List<String> {
|
||||||
|
return usbManager.deviceList.values.map { device ->
|
||||||
|
val productName = device.productName ?: "USB Serial Device"
|
||||||
|
val vendorProduct =
|
||||||
|
String.format(
|
||||||
|
Locale.US,
|
||||||
|
"VID:%04X PID:%04X",
|
||||||
|
device.vendorId,
|
||||||
|
device.productId,
|
||||||
|
)
|
||||||
|
"${device.deviceName} - $productName - $vendorProduct"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleUsbConnect(call: MethodCall, result: MethodChannel.Result) {
|
||||||
|
val portName = call.argument<String>("portName")
|
||||||
|
val baudRate = call.argument<Int>("baudRate") ?: 115200
|
||||||
|
if (portName.isNullOrBlank()) {
|
||||||
|
result.error("usb_invalid_port", "Port name is required", null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val device = findUsbDevice(portName)
|
||||||
|
if (device == null) {
|
||||||
|
result.error("usb_device_missing", "USB device not found for $portName", null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (usbManager.hasPermission(device)) {
|
||||||
|
openUsbDevice(device, baudRate, result)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pendingConnectResult != null) {
|
||||||
|
result.error("usb_busy", "Another USB permission request is already pending", null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingConnectResult = result
|
||||||
|
pendingConnectPortName = portName
|
||||||
|
pendingConnectBaudRate = baudRate
|
||||||
|
|
||||||
|
val permissionIntent = PendingIntent.getBroadcast(
|
||||||
|
activity,
|
||||||
|
0,
|
||||||
|
Intent(usbPermissionAction).setPackage(activity.packageName),
|
||||||
|
pendingIntentFlags(),
|
||||||
|
)
|
||||||
|
usbManager.requestPermission(device, permissionIntent)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleUsbWrite(call: MethodCall, result: MethodChannel.Result) {
|
||||||
|
val data = call.argument<ByteArray>("data")
|
||||||
|
val connection = usbConnection
|
||||||
|
val endpoint = usbOutEndpoint
|
||||||
|
if (data == null) {
|
||||||
|
result.error("usb_invalid_data", "Data is required", null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (connection == null || endpoint == null) {
|
||||||
|
result.error("usb_not_connected", "USB serial port is not connected", null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
usbIoExecutor.execute {
|
||||||
|
try {
|
||||||
|
writeToDevice(data)
|
||||||
|
mainHandler.post { result.success(null) }
|
||||||
|
} catch (error: Exception) {
|
||||||
|
mainHandler.post {
|
||||||
|
result.error("usb_write_failed", error.message, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun findUsbDevice(portName: String): UsbDevice? {
|
||||||
|
return usbManager.deviceList.values.firstOrNull { it.deviceName == portName }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun openUsbDevice(
|
||||||
|
device: UsbDevice,
|
||||||
|
baudRate: Int,
|
||||||
|
result: MethodChannel.Result,
|
||||||
|
) {
|
||||||
|
usbIoExecutor.execute {
|
||||||
|
try {
|
||||||
|
closeUsbConnection()
|
||||||
|
|
||||||
|
val config = resolvePortConfig(device)
|
||||||
|
if (config == null) {
|
||||||
|
mainHandler.post {
|
||||||
|
result.error(
|
||||||
|
"usb_driver_missing",
|
||||||
|
"No compatible USB serial interface for ${device.deviceName}",
|
||||||
|
null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return@execute
|
||||||
|
}
|
||||||
|
|
||||||
|
val connection = usbManager.openDevice(device)
|
||||||
|
if (connection == null) {
|
||||||
|
mainHandler.post {
|
||||||
|
result.error(
|
||||||
|
"usb_open_failed",
|
||||||
|
"UsbManager could not open ${device.deviceName}",
|
||||||
|
null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return@execute
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!connection.claimInterface(config.dataInterface, true)) {
|
||||||
|
connection.close()
|
||||||
|
mainHandler.post {
|
||||||
|
result.error(
|
||||||
|
"usb_open_failed",
|
||||||
|
"Could not claim USB data interface for ${device.deviceName}",
|
||||||
|
null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return@execute
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.controlInterface != null &&
|
||||||
|
config.controlInterface.id != config.dataInterface.id &&
|
||||||
|
!connection.claimInterface(config.controlInterface, true)
|
||||||
|
) {
|
||||||
|
connection.releaseInterface(config.dataInterface)
|
||||||
|
connection.close()
|
||||||
|
mainHandler.post {
|
||||||
|
result.error(
|
||||||
|
"usb_open_failed",
|
||||||
|
"Could not claim USB control interface for ${device.deviceName}",
|
||||||
|
null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return@execute
|
||||||
|
}
|
||||||
|
|
||||||
|
configureDevice(connection, config, baudRate)
|
||||||
|
|
||||||
|
usbConnection = connection
|
||||||
|
usbInEndpoint = config.inEndpoint
|
||||||
|
usbOutEndpoint = config.outEndpoint
|
||||||
|
controlInterface = config.controlInterface
|
||||||
|
dataInterface = config.dataInterface
|
||||||
|
connectedDeviceName = device.deviceName
|
||||||
|
startReadLoop()
|
||||||
|
|
||||||
|
mainHandler.post {
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
} catch (error: Exception) {
|
||||||
|
closeUsbConnection()
|
||||||
|
mainHandler.post {
|
||||||
|
result.error("usb_connect_failed", error.message, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resolvePortConfig(device: UsbDevice): PortConfig? {
|
||||||
|
var preferredDataInterface: UsbInterface? = null
|
||||||
|
var preferredInEndpoint: UsbEndpoint? = null
|
||||||
|
var preferredOutEndpoint: UsbEndpoint? = null
|
||||||
|
var fallbackDataInterface: UsbInterface? = null
|
||||||
|
var fallbackInEndpoint: UsbEndpoint? = null
|
||||||
|
var fallbackOutEndpoint: UsbEndpoint? = null
|
||||||
|
var preferredControlInterface: UsbInterface? = null
|
||||||
|
|
||||||
|
for (interfaceIndex in 0 until device.interfaceCount) {
|
||||||
|
val usbInterface = device.getInterface(interfaceIndex)
|
||||||
|
var inEndpoint: UsbEndpoint? = null
|
||||||
|
var outEndpoint: UsbEndpoint? = null
|
||||||
|
|
||||||
|
for (endpointIndex in 0 until usbInterface.endpointCount) {
|
||||||
|
val endpoint = usbInterface.getEndpoint(endpointIndex)
|
||||||
|
if (endpoint.type != UsbConstants.USB_ENDPOINT_XFER_BULK) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
when (endpoint.direction) {
|
||||||
|
UsbConstants.USB_DIR_IN -> if (inEndpoint == null) inEndpoint = endpoint
|
||||||
|
UsbConstants.USB_DIR_OUT -> if (outEndpoint == null) outEndpoint = endpoint
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val hasDataPair = inEndpoint != null && outEndpoint != null
|
||||||
|
when {
|
||||||
|
usbInterface.interfaceClass == UsbConstants.USB_CLASS_COMM &&
|
||||||
|
preferredControlInterface == null -> {
|
||||||
|
preferredControlInterface = usbInterface
|
||||||
|
}
|
||||||
|
hasDataPair &&
|
||||||
|
usbInterface.interfaceClass == UsbConstants.USB_CLASS_CDC_DATA -> {
|
||||||
|
preferredDataInterface = usbInterface
|
||||||
|
preferredInEndpoint = inEndpoint
|
||||||
|
preferredOutEndpoint = outEndpoint
|
||||||
|
}
|
||||||
|
hasDataPair && fallbackDataInterface == null -> {
|
||||||
|
fallbackDataInterface = usbInterface
|
||||||
|
fallbackInEndpoint = inEndpoint
|
||||||
|
fallbackOutEndpoint = outEndpoint
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val dataInterface = preferredDataInterface ?: fallbackDataInterface ?: return null
|
||||||
|
val inEndpoint = preferredInEndpoint ?: fallbackInEndpoint ?: return null
|
||||||
|
val outEndpoint = preferredOutEndpoint ?: fallbackOutEndpoint ?: return null
|
||||||
|
return PortConfig(preferredControlInterface, dataInterface, inEndpoint, outEndpoint)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun configureDevice(
|
||||||
|
connection: UsbDeviceConnection,
|
||||||
|
config: PortConfig,
|
||||||
|
baudRate: Int,
|
||||||
|
) {
|
||||||
|
val control = config.controlInterface ?: return
|
||||||
|
val lineCoding =
|
||||||
|
byteArrayOf(
|
||||||
|
(baudRate and 0xFF).toByte(),
|
||||||
|
((baudRate shr 8) and 0xFF).toByte(),
|
||||||
|
((baudRate shr 16) and 0xFF).toByte(),
|
||||||
|
((baudRate shr 24) and 0xFF).toByte(),
|
||||||
|
0, // stop bits: 1
|
||||||
|
0, // parity: none
|
||||||
|
8, // data bits
|
||||||
|
)
|
||||||
|
|
||||||
|
val lineCodingResult =
|
||||||
|
connection.controlTransfer(
|
||||||
|
UsbConstants.USB_DIR_OUT or
|
||||||
|
UsbConstants.USB_TYPE_CLASS or
|
||||||
|
usbRecipientInterface,
|
||||||
|
0x20,
|
||||||
|
0,
|
||||||
|
control.id,
|
||||||
|
lineCoding,
|
||||||
|
lineCoding.size,
|
||||||
|
1000,
|
||||||
|
)
|
||||||
|
if (lineCodingResult < 0) {
|
||||||
|
throw IllegalStateException("Failed to configure USB line coding")
|
||||||
|
}
|
||||||
|
|
||||||
|
val controlLineResult =
|
||||||
|
connection.controlTransfer(
|
||||||
|
UsbConstants.USB_DIR_OUT or
|
||||||
|
UsbConstants.USB_TYPE_CLASS or
|
||||||
|
usbRecipientInterface,
|
||||||
|
0x22,
|
||||||
|
0x0001, // DTR on, RTS off
|
||||||
|
control.id,
|
||||||
|
null,
|
||||||
|
0,
|
||||||
|
1000,
|
||||||
|
)
|
||||||
|
if (controlLineResult < 0) {
|
||||||
|
throw IllegalStateException("Failed to configure USB control line state")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startReadLoop() {
|
||||||
|
val connection = usbConnection ?: return
|
||||||
|
val endpoint = usbInEndpoint ?: return
|
||||||
|
|
||||||
|
isReading = true
|
||||||
|
readThread =
|
||||||
|
Thread({
|
||||||
|
val packetSize = endpoint.maxPacketSize.coerceAtLeast(64)
|
||||||
|
val buffer = ByteArray(packetSize * 4)
|
||||||
|
try {
|
||||||
|
while (isReading) {
|
||||||
|
val bytesRead = connection.bulkTransfer(endpoint, buffer, buffer.size, 250)
|
||||||
|
if (!isReading) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if (bytesRead <= 0) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
val packet = buffer.copyOf(bytesRead)
|
||||||
|
mainHandler.post {
|
||||||
|
eventSink?.success(packet)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error: Exception) {
|
||||||
|
if (isReading) {
|
||||||
|
mainHandler.post {
|
||||||
|
eventSink?.error(
|
||||||
|
"usb_io_error",
|
||||||
|
error.message ?: "USB serial I/O error",
|
||||||
|
null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
scheduleCloseUsbConnection()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, "MeshCoreUsbRead").also { thread ->
|
||||||
|
thread.isDaemon = true
|
||||||
|
thread.start()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun writeToDevice(data: ByteArray) {
|
||||||
|
val connection = usbConnection ?: throw IllegalStateException("USB connection missing")
|
||||||
|
val endpoint = usbOutEndpoint ?: throw IllegalStateException("USB output endpoint missing")
|
||||||
|
var offset = 0
|
||||||
|
val maxPacketSize = endpoint.maxPacketSize.coerceAtLeast(64)
|
||||||
|
while (offset < data.size) {
|
||||||
|
val chunkSize = minOf(maxPacketSize, data.size - offset)
|
||||||
|
val chunk = data.copyOfRange(offset, offset + chunkSize)
|
||||||
|
val bytesWritten = connection.bulkTransfer(endpoint, chunk, chunkSize, 1000)
|
||||||
|
if (bytesWritten != chunkSize) {
|
||||||
|
throw IllegalStateException("Short USB write: wrote $bytesWritten of $chunkSize bytes")
|
||||||
|
}
|
||||||
|
offset += chunkSize
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun scheduleCloseUsbConnection(onComplete: (() -> Unit)? = null) {
|
||||||
|
usbIoExecutor.execute {
|
||||||
|
closeUsbConnection()
|
||||||
|
if (onComplete != null) {
|
||||||
|
mainHandler.post(onComplete)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
private fun closeUsbConnection() {
|
||||||
|
isReading = false
|
||||||
|
readThread?.interrupt()
|
||||||
|
if (readThread != null && readThread !== Thread.currentThread()) {
|
||||||
|
try {
|
||||||
|
readThread?.join(300)
|
||||||
|
} catch (_: InterruptedException) {
|
||||||
|
Thread.currentThread().interrupt()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
readThread = null
|
||||||
|
|
||||||
|
val connection = usbConnection
|
||||||
|
val claimedControl = controlInterface
|
||||||
|
val claimedData = dataInterface
|
||||||
|
|
||||||
|
usbInEndpoint = null
|
||||||
|
usbOutEndpoint = null
|
||||||
|
controlInterface = null
|
||||||
|
dataInterface = null
|
||||||
|
usbConnection = null
|
||||||
|
|
||||||
|
if (connection != null) {
|
||||||
|
if (claimedControl != null) {
|
||||||
|
try {
|
||||||
|
connection.releaseInterface(claimedControl)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (claimedData != null && claimedData.id != claimedControl?.id) {
|
||||||
|
try {
|
||||||
|
connection.releaseInterface(claimedData)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
connection.close()
|
||||||
|
} catch (_: Exception) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
connectedDeviceName = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleUsbDetached(intent: Intent) {
|
||||||
|
val detachedDevice =
|
||||||
|
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 detachedName = detachedDevice?.deviceName ?: return
|
||||||
|
|
||||||
|
if (pendingConnectPortName == detachedName) {
|
||||||
|
pendingConnectResult?.error(
|
||||||
|
"usb_device_detached",
|
||||||
|
"USB device was removed before the connection completed",
|
||||||
|
null,
|
||||||
|
)
|
||||||
|
pendingConnectResult = null
|
||||||
|
pendingConnectPortName = null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (connectedDeviceName == detachedName) {
|
||||||
|
scheduleCloseUsbConnection {
|
||||||
|
eventSink?.error(
|
||||||
|
"usb_device_detached",
|
||||||
|
"USB device was disconnected",
|
||||||
|
null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun pendingIntentFlags(): Int {
|
||||||
|
var flags = PendingIntent.FLAG_UPDATE_CURRENT
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
|
flags = flags or PendingIntent.FLAG_MUTABLE
|
||||||
|
}
|
||||||
|
return flags
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,32 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
import 'meshcore_connector.dart';
|
||||||
|
|
||||||
|
class MeshCoreConnectorUsb {
|
||||||
|
const MeshCoreConnectorUsb(this.connector);
|
||||||
|
|
||||||
|
final MeshCoreConnector connector;
|
||||||
|
|
||||||
|
MeshCoreConnectionState get state => connector.state;
|
||||||
|
MeshCoreTransportType get activeTransport => connector.activeTransport;
|
||||||
|
String? get activeUsbPortDisplayLabel => connector.activeUsbPortDisplayLabel;
|
||||||
|
bool get isUsbTransportConnected => connector.isUsbTransportConnected;
|
||||||
|
|
||||||
|
void addListener(VoidCallback listener) => connector.addListener(listener);
|
||||||
|
void removeListener(VoidCallback listener) =>
|
||||||
|
connector.removeListener(listener);
|
||||||
|
|
||||||
|
Future<List<String>> listPorts() => connector.listUsbPorts();
|
||||||
|
|
||||||
|
void setRequestPortLabel(String label) {
|
||||||
|
connector.setUsbRequestPortLabel(label);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> connect({required String portName, int baudRate = 115200}) {
|
||||||
|
return connector.connectUsb(portName: portName, baudRate: baudRate);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> disconnect({bool manual = true}) {
|
||||||
|
return connector.disconnect(manual: manual);
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -1,232 +0,0 @@
|
|||||||
import 'dart:math' as math;
|
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
|
|
||||||
import '../l10n/l10n.dart';
|
|
||||||
import '../utils/platform_info.dart';
|
|
||||||
import 'scanner_screen.dart';
|
|
||||||
import 'usb_screen.dart';
|
|
||||||
|
|
||||||
/// Entry point that lets the user choose between USB or Bluetooth.
|
|
||||||
class ConnectionChoiceScreen extends StatelessWidget {
|
|
||||||
const ConnectionChoiceScreen({super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final l10n = context.l10n;
|
|
||||||
final theme = Theme.of(context);
|
|
||||||
final usbSupported = PlatformInfo.supportsUsbSerial;
|
|
||||||
return Scaffold(
|
|
||||||
appBar: AppBar(
|
|
||||||
title: FittedBox(
|
|
||||||
fit: BoxFit.scaleDown,
|
|
||||||
child: Text(l10n.appTitle, textAlign: TextAlign.center),
|
|
||||||
),
|
|
||||||
centerTitle: true,
|
|
||||||
automaticallyImplyLeading: false,
|
|
||||||
),
|
|
||||||
body: SafeArea(
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 32),
|
|
||||||
child: LayoutBuilder(
|
|
||||||
builder: (context, constraints) {
|
|
||||||
final availableHeight = constraints.maxHeight.isFinite
|
|
||||||
? constraints.maxHeight
|
|
||||||
: 600.0;
|
|
||||||
final gap = math.max(
|
|
||||||
8.0,
|
|
||||||
math.min(20.0, availableHeight * 0.035),
|
|
||||||
);
|
|
||||||
|
|
||||||
return Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
||||||
children: [
|
|
||||||
Flexible(
|
|
||||||
flex: 3,
|
|
||||||
child: Center(
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Flexible(
|
|
||||||
child: FittedBox(
|
|
||||||
fit: BoxFit.scaleDown,
|
|
||||||
child: Text(
|
|
||||||
l10n.connectionChoiceTitle,
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
style: theme.textTheme.headlineSmall?.copyWith(
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
SizedBox(height: math.max(4.0, gap * 0.5)),
|
|
||||||
Flexible(
|
|
||||||
child: FittedBox(
|
|
||||||
fit: BoxFit.scaleDown,
|
|
||||||
child: Text(
|
|
||||||
l10n.connectionChoiceSubtitle,
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
style: theme.textTheme.bodyMedium?.copyWith(
|
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
SizedBox(height: gap),
|
|
||||||
Expanded(
|
|
||||||
flex: 4,
|
|
||||||
child: _ConnectionMethodButton(
|
|
||||||
icon: Icons.usb,
|
|
||||||
label: l10n.connectionChoiceUsbLabel,
|
|
||||||
color: theme.colorScheme.primaryContainer,
|
|
||||||
iconColor: theme.colorScheme.onPrimaryContainer,
|
|
||||||
onPressed: usbSupported
|
|
||||||
? () {
|
|
||||||
debugPrint(
|
|
||||||
'ConnectionChoiceScreen: USB selected, opening UsbScreen',
|
|
||||||
);
|
|
||||||
Navigator.of(context).push(
|
|
||||||
MaterialPageRoute(
|
|
||||||
builder: (_) => const UsbScreen(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
SizedBox(height: gap),
|
|
||||||
Expanded(
|
|
||||||
flex: 4,
|
|
||||||
child: _ConnectionMethodButton(
|
|
||||||
icon: Icons.bluetooth,
|
|
||||||
label: l10n.connectionChoiceBluetoothLabel,
|
|
||||||
color: theme.colorScheme.surfaceContainerHighest,
|
|
||||||
iconColor: theme.colorScheme.onSurfaceVariant,
|
|
||||||
onPressed: () {
|
|
||||||
debugPrint(
|
|
||||||
'ConnectionChoiceScreen: Bluetooth selected, opening ScannerScreen',
|
|
||||||
);
|
|
||||||
Navigator.of(context).push(
|
|
||||||
MaterialPageRoute(
|
|
||||||
builder: (_) => const ScannerScreen(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _ConnectionMethodButton extends StatelessWidget {
|
|
||||||
const _ConnectionMethodButton({
|
|
||||||
required this.icon,
|
|
||||||
required this.label,
|
|
||||||
required this.onPressed,
|
|
||||||
required this.color,
|
|
||||||
required this.iconColor,
|
|
||||||
});
|
|
||||||
|
|
||||||
final IconData icon;
|
|
||||||
final String label;
|
|
||||||
final VoidCallback? onPressed;
|
|
||||||
final Color color;
|
|
||||||
final Color iconColor;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final theme = Theme.of(context);
|
|
||||||
return ElevatedButton(
|
|
||||||
style: ElevatedButton.styleFrom(
|
|
||||||
backgroundColor: color,
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(18)),
|
|
||||||
minimumSize: const Size.fromHeight(0),
|
|
||||||
),
|
|
||||||
onPressed: onPressed,
|
|
||||||
child: LayoutBuilder(
|
|
||||||
builder: (context, constraints) {
|
|
||||||
final availableHeight = constraints.maxHeight.isFinite
|
|
||||||
? constraints.maxHeight
|
|
||||||
: 200.0;
|
|
||||||
final availableWidth = constraints.maxWidth.isFinite
|
|
||||||
? constraints.maxWidth
|
|
||||||
: 320.0;
|
|
||||||
final isCompact = availableHeight < 72.0 || availableWidth < 180.0;
|
|
||||||
final useTightVertical = !isCompact && availableHeight < 120.0;
|
|
||||||
final baseGap = isCompact
|
|
||||||
? 8.0
|
|
||||||
: (useTightVertical
|
|
||||||
? math.max(4.0, math.min(8.0, availableHeight * 0.06))
|
|
||||||
: 12.0);
|
|
||||||
final labelStyle =
|
|
||||||
(isCompact
|
|
||||||
? theme.textTheme.titleMedium
|
|
||||||
: (useTightVertical
|
|
||||||
? theme.textTheme.titleMedium
|
|
||||||
: theme.textTheme.titleLarge))
|
|
||||||
?.copyWith(fontWeight: FontWeight.w600);
|
|
||||||
final verticalIconSize = useTightVertical
|
|
||||||
? math.max(32.0, math.min(48.0, availableHeight * 0.42))
|
|
||||||
: 60.0;
|
|
||||||
final content = isCompact
|
|
||||||
? Row(
|
|
||||||
mainAxisSize: MainAxisSize.max,
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Icon(icon, size: 24.0, color: iconColor),
|
|
||||||
SizedBox(width: baseGap),
|
|
||||||
Flexible(
|
|
||||||
child: Text(
|
|
||||||
label,
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: labelStyle,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Icon(icon, size: verticalIconSize, color: iconColor),
|
|
||||||
SizedBox(height: baseGap),
|
|
||||||
Text(
|
|
||||||
label,
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.visible,
|
|
||||||
style: labelStyle,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
return Center(
|
|
||||||
child: FittedBox(
|
|
||||||
fit: BoxFit.scaleDown,
|
|
||||||
child: ConstrainedBox(
|
|
||||||
constraints: BoxConstraints(
|
|
||||||
maxWidth: math.max(0, availableWidth - 12),
|
|
||||||
maxHeight: math.max(0, availableHeight - 12),
|
|
||||||
),
|
|
||||||
child: content,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Loading…
Reference in new issue