parent
7d8e049745
commit
22a53439b1
@ -1,5 +1,313 @@
|
||||
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.engine.FlutterEngine
|
||||
import io.flutter.plugin.common.EventChannel
|
||||
import io.flutter.plugin.common.MethodCall
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import java.util.Locale
|
||||
|
||||
class MainActivity : FlutterActivity()
|
||||
class MainActivity : FlutterActivity() {
|
||||
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 lateinit var usbManager: UsbManager
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
|
||||
private var eventSink: EventChannel.EventSink? = null
|
||||
private var usbConnection: UsbDeviceConnection? = null
|
||||
private var usbPort: UsbSerialPort? = null
|
||||
private var ioManager: SerialInputOutputManager? = 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?) {
|
||||
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) {
|
||||
super.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" -> {
|
||||
closeUsbConnection()
|
||||
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() {
|
||||
closeUsbConnection()
|
||||
unregisterReceiver(permissionReceiver)
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
private fun registerUsbPermissionReceiver() {
|
||||
val filter = IntentFilter(usbPermissionAction)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
registerReceiver(permissionReceiver, filter, 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
|
||||
}
|
||||
|
||||
try {
|
||||
port.write(data, 1000)
|
||||
result.success(null)
|
||||
} catch (error: Exception) {
|
||||
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,
|
||||
) {
|
||||
try {
|
||||
closeUsbConnection()
|
||||
|
||||
val driver = UsbSerialProber.getDefaultProber().probeDevice(device)
|
||||
if (driver == null) {
|
||||
result.error("usb_driver_missing", "No USB serial driver for ${device.deviceName}", null)
|
||||
return
|
||||
}
|
||||
|
||||
val connection = usbManager.openDevice(device)
|
||||
if (connection == null) {
|
||||
result.error(
|
||||
"usb_open_failed",
|
||||
"UsbManager could not open ${device.deviceName}",
|
||||
null,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
val port = firstPort(driver)
|
||||
if (port == null) {
|
||||
connection.close()
|
||||
result.error("usb_port_missing", "No USB serial port exposed by ${device.deviceName}", null)
|
||||
return
|
||||
}
|
||||
|
||||
port.open(connection)
|
||||
port.setParameters(
|
||||
baudRate,
|
||||
8,
|
||||
UsbSerialPort.STOPBITS_1,
|
||||
UsbSerialPort.PARITY_NONE,
|
||||
)
|
||||
port.rts = false
|
||||
port.dtr = true
|
||||
|
||||
usbConnection = connection
|
||||
usbPort = port
|
||||
|
||||
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,
|
||||
)
|
||||
}
|
||||
closeUsbConnection()
|
||||
}
|
||||
},
|
||||
).also { manager ->
|
||||
manager.start()
|
||||
}
|
||||
|
||||
result.success(null)
|
||||
} catch (error: Exception) {
|
||||
closeUsbConnection()
|
||||
result.error("usb_connect_failed", error.message, null)
|
||||
}
|
||||
}
|
||||
|
||||
private fun firstPort(driver: UsbSerialDriver): UsbSerialPort? {
|
||||
return driver.ports.firstOrNull()
|
||||
}
|
||||
|
||||
private fun closeUsbConnection() {
|
||||
try {
|
||||
ioManager?.stop()
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
ioManager = null
|
||||
|
||||
try {
|
||||
usbPort?.close()
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
usbPort = null
|
||||
|
||||
try {
|
||||
usbConnection?.close()
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
usbConnection = 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,201 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../l10n/l10n.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);
|
||||
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: () {
|
||||
debugPrint(
|
||||
'ConnectionChoiceScreen: USB selected, opening UsbScreen',
|
||||
);
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (_) => const UsbScreen()),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
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 baseGap = isCompact ? 8.0 : 12.0;
|
||||
final content = Flex(
|
||||
direction: isCompact ? Axis.horizontal : Axis.vertical,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(icon, size: isCompact ? 24.0 : 60.0, color: iconColor),
|
||||
SizedBox(
|
||||
width: isCompact ? baseGap : 0,
|
||||
height: isCompact ? 0 : baseGap,
|
||||
),
|
||||
Text(
|
||||
label,
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.visible,
|
||||
style:
|
||||
(isCompact
|
||||
? theme.textTheme.titleMedium
|
||||
: theme.textTheme.titleLarge)
|
||||
?.copyWith(fontWeight: FontWeight.w600),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
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,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,456 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../connector/meshcore_connector.dart';
|
||||
import '../l10n/l10n.dart';
|
||||
import 'contacts_screen.dart';
|
||||
|
||||
class UsbScreen extends StatefulWidget {
|
||||
const UsbScreen({super.key});
|
||||
|
||||
@override
|
||||
State<UsbScreen> createState() => _UsbScreenState();
|
||||
}
|
||||
|
||||
class _UsbScreenState extends State<UsbScreen> {
|
||||
final List<String> _ports = <String>[];
|
||||
bool _isLoadingPorts = true;
|
||||
bool _isConnecting = false;
|
||||
bool _navigatedToContacts = false;
|
||||
String? _selectedPort;
|
||||
String? _errorText;
|
||||
late final MeshCoreConnector _connector;
|
||||
late final VoidCallback _connectionListener;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_connector = context.read<MeshCoreConnector>();
|
||||
_connectionListener = () {
|
||||
if (!mounted) return;
|
||||
if (_connector.state == MeshCoreConnectionState.disconnected) {
|
||||
_navigatedToContacts = false;
|
||||
if (_isConnecting) {
|
||||
setState(() {
|
||||
_isConnecting = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
if (_connector.state == MeshCoreConnectionState.connected &&
|
||||
_connector.isUsbTransportConnected &&
|
||||
!_navigatedToContacts) {
|
||||
_navigatedToContacts = true;
|
||||
Navigator.of(context).pushReplacement(
|
||||
MaterialPageRoute(builder: (_) => const ContactsScreen()),
|
||||
);
|
||||
}
|
||||
};
|
||||
_connector.addListener(_connectionListener);
|
||||
unawaited(_loadPorts());
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_connector.removeListener(_connectionListener);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final l10n = context.l10n;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () {
|
||||
debugPrint('UsbScreen: back button pressed');
|
||||
Navigator.of(context).maybePop();
|
||||
},
|
||||
),
|
||||
title: Text(
|
||||
l10n.connectionChoiceUsbLabel,
|
||||
style: theme.textTheme.titleLarge,
|
||||
),
|
||||
centerTitle: true,
|
||||
),
|
||||
body: SafeArea(
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final availableHeight = constraints.maxHeight.isFinite
|
||||
? constraints.maxHeight
|
||||
: 600.0;
|
||||
final availableWidth = constraints.maxWidth.isFinite
|
||||
? constraints.maxWidth
|
||||
: 800.0;
|
||||
final gap = math.max(8.0, math.min(16.0, availableHeight * 0.025));
|
||||
final iconSize = math.max(
|
||||
28.0,
|
||||
math.min(72.0, availableHeight * 0.12),
|
||||
);
|
||||
final isNarrow = availableWidth < 460.0;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Flexible(
|
||||
flex: 3,
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.usb,
|
||||
size: iconSize,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
SizedBox(height: gap),
|
||||
Flexible(
|
||||
child: FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: Text(
|
||||
l10n.usbScreenTitle,
|
||||
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.usbScreenSubtitle,
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(height: gap),
|
||||
FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: Chip(
|
||||
label: Text(
|
||||
_selectedPort == null
|
||||
? l10n.usbScreenStatus
|
||||
: _friendlyPortName(_selectedPort!),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
backgroundColor:
|
||||
theme.colorScheme.surfaceContainerHighest,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(height: gap),
|
||||
Expanded(child: _buildPortList(context)),
|
||||
if (_errorText != null) ...[
|
||||
SizedBox(height: gap),
|
||||
Flexible(
|
||||
child: FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: Text(
|
||||
_errorText!,
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.error,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
SizedBox(height: gap),
|
||||
if (isNarrow)
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
OutlinedButton.icon(
|
||||
onPressed: _isLoadingPorts || _isConnecting
|
||||
? null
|
||||
: () {
|
||||
debugPrint(
|
||||
'UsbScreen: refresh ports pressed',
|
||||
);
|
||||
_loadPorts();
|
||||
},
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: Text(l10n.repeater_refresh),
|
||||
),
|
||||
SizedBox(height: gap),
|
||||
FilledButton.icon(
|
||||
onPressed: _canConnect
|
||||
? () {
|
||||
final rawPortName = _normalizedPortName(
|
||||
_selectedPort!,
|
||||
);
|
||||
debugPrint(
|
||||
'UsbScreen: connect pressed for $_selectedPort (raw: $rawPortName)',
|
||||
);
|
||||
_connectSelectedPort();
|
||||
}
|
||||
: null,
|
||||
icon: _isConnecting
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
),
|
||||
)
|
||||
: const Icon(Icons.usb),
|
||||
label: Text(l10n.common_connect),
|
||||
),
|
||||
],
|
||||
)
|
||||
else
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: _isLoadingPorts || _isConnecting
|
||||
? null
|
||||
: () {
|
||||
debugPrint(
|
||||
'UsbScreen: refresh ports pressed',
|
||||
);
|
||||
_loadPorts();
|
||||
},
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: Text(l10n.repeater_refresh),
|
||||
),
|
||||
),
|
||||
SizedBox(width: gap),
|
||||
Expanded(
|
||||
child: FilledButton.icon(
|
||||
onPressed: _canConnect
|
||||
? () {
|
||||
final rawPortName = _normalizedPortName(
|
||||
_selectedPort!,
|
||||
);
|
||||
debugPrint(
|
||||
'UsbScreen: connect pressed for $_selectedPort (raw: $rawPortName)',
|
||||
);
|
||||
_connectSelectedPort();
|
||||
}
|
||||
: null,
|
||||
icon: _isConnecting
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
),
|
||||
)
|
||||
: const Icon(Icons.usb),
|
||||
label: Text(l10n.common_connect),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
SizedBox(height: math.max(4.0, gap * 0.75)),
|
||||
Flexible(
|
||||
child: FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: Text(
|
||||
l10n.usbScreenNote,
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
bool get _canConnect =>
|
||||
!_isLoadingPorts &&
|
||||
!_isConnecting &&
|
||||
_selectedPort != null &&
|
||||
_selectedPort!.isNotEmpty;
|
||||
|
||||
Widget _buildPortList(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final l10n = context.l10n;
|
||||
|
||||
if (_isLoadingPorts) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const CircularProgressIndicator(),
|
||||
const SizedBox(height: 12),
|
||||
Text(l10n.common_loading),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (_ports.isEmpty) {
|
||||
return Center(
|
||||
child: Text(
|
||||
l10n.usbScreenEmptyState,
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.separated(
|
||||
itemCount: _ports.length,
|
||||
itemBuilder: (context, index) {
|
||||
final port = _ports[index];
|
||||
final isSelected = port == _selectedPort;
|
||||
final displayName = _friendlyPortName(port);
|
||||
final rawName = _normalizedPortName(port);
|
||||
final showRawName = rawName != displayName;
|
||||
return Material(
|
||||
color: isSelected
|
||||
? theme.colorScheme.primaryContainer
|
||||
: theme.colorScheme.surfaceContainerLow,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: ListTile(
|
||||
onTap: _isConnecting
|
||||
? null
|
||||
: () {
|
||||
setState(() {
|
||||
_selectedPort = port;
|
||||
_errorText = null;
|
||||
});
|
||||
debugPrint('UsbScreen: selected port $port');
|
||||
},
|
||||
leading: Icon(
|
||||
Icons.usb,
|
||||
color: isSelected
|
||||
? theme.colorScheme.onPrimaryContainer
|
||||
: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
title: Text(
|
||||
displayName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
color: isSelected ? theme.colorScheme.onPrimaryContainer : null,
|
||||
),
|
||||
),
|
||||
subtitle: showRawName
|
||||
? Text(
|
||||
rawName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: isSelected
|
||||
? theme.colorScheme.onPrimaryContainer
|
||||
: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
trailing: isSelected
|
||||
? Icon(
|
||||
Icons.check_circle,
|
||||
color: theme.colorScheme.onPrimaryContainer,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
);
|
||||
},
|
||||
separatorBuilder: (context, index) => const SizedBox(height: 10),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _loadPorts() async {
|
||||
if (!mounted) return;
|
||||
|
||||
setState(() {
|
||||
_isLoadingPorts = true;
|
||||
_errorText = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final ports = await _connector.listUsbPorts();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_ports
|
||||
..clear()
|
||||
..addAll(ports);
|
||||
if (_ports.isEmpty) {
|
||||
_selectedPort = null;
|
||||
} else if (!_ports.contains(_selectedPort)) {
|
||||
_selectedPort = _ports.first;
|
||||
}
|
||||
_isLoadingPorts = false;
|
||||
});
|
||||
} catch (error) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_ports.clear();
|
||||
_selectedPort = null;
|
||||
_errorText = error.toString();
|
||||
_isLoadingPorts = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _connectSelectedPort() async {
|
||||
final selectedPort = _selectedPort;
|
||||
if (selectedPort == null || selectedPort.isEmpty) {
|
||||
return;
|
||||
}
|
||||
final rawPortName = _normalizedPortName(selectedPort);
|
||||
|
||||
setState(() {
|
||||
_isConnecting = true;
|
||||
_errorText = null;
|
||||
});
|
||||
|
||||
try {
|
||||
await _connector.connectUsb(portName: rawPortName);
|
||||
} catch (error) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_isConnecting = false;
|
||||
_errorText = error.toString();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
String _normalizedPortName(String portLabel) {
|
||||
final separatorIndex = portLabel.indexOf(' - ');
|
||||
final normalized = separatorIndex >= 0
|
||||
? portLabel.substring(0, separatorIndex)
|
||||
: portLabel;
|
||||
return normalized.trim();
|
||||
}
|
||||
|
||||
String _friendlyPortName(String portLabel) {
|
||||
final separatorIndex = portLabel.indexOf(' - ');
|
||||
if (separatorIndex < 0) {
|
||||
return portLabel.trim();
|
||||
}
|
||||
final friendlyName = portLabel.substring(separatorIndex + 3).trim();
|
||||
if (friendlyName.isEmpty) {
|
||||
return _normalizedPortName(portLabel);
|
||||
}
|
||||
return friendlyName;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,284 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flserial/flserial.dart';
|
||||
import 'package:flserial/flserial_exception.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
/// Wraps the native flserial plugin to expose a stream of raw bytes for the
|
||||
/// MeshCore connector to consume.
|
||||
class UsbSerialService {
|
||||
UsbSerialService();
|
||||
|
||||
static const MethodChannel _androidMethodChannel = MethodChannel(
|
||||
'meshcore_open/android_usb_serial',
|
||||
);
|
||||
static const EventChannel _androidEventChannel = EventChannel(
|
||||
'meshcore_open/android_usb_serial_events',
|
||||
);
|
||||
static const int _serialTxFrameStart = 0x3c;
|
||||
static const int _serialRxFrameStart = 0x3e;
|
||||
static const int _serialHeaderLength = 3;
|
||||
|
||||
final StreamController<Uint8List> _frameController =
|
||||
StreamController<Uint8List>.broadcast();
|
||||
final FlSerial _serial = FlSerial();
|
||||
final List<int> _rxBuffer = <int>[];
|
||||
StreamSubscription<dynamic>? _androidDataSubscription;
|
||||
StreamSubscription<FlSerialEventArgs>? _dataSubscription;
|
||||
UsbSerialStatus _status = UsbSerialStatus.disconnected;
|
||||
String? _connectedPortName;
|
||||
|
||||
UsbSerialStatus get status => _status;
|
||||
String? get activePortName => _connectedPortName;
|
||||
Stream<Uint8List> get frameStream => _frameController.stream;
|
||||
bool get _useAndroidUsbHost =>
|
||||
!kIsWeb && defaultTargetPlatform == TargetPlatform.android;
|
||||
|
||||
bool get isConnected {
|
||||
if (_useAndroidUsbHost) {
|
||||
return _status == UsbSerialStatus.connected;
|
||||
}
|
||||
return _status == UsbSerialStatus.connected &&
|
||||
_serial.isOpen() == FlOpenStatus.open;
|
||||
}
|
||||
|
||||
Future<List<String>> listPorts() async {
|
||||
if (_useAndroidUsbHost) {
|
||||
final ports = await _androidMethodChannel.invokeListMethod<String>(
|
||||
'listPorts',
|
||||
);
|
||||
return ports ?? <String>[];
|
||||
}
|
||||
return Future.value(FlSerial.listPorts());
|
||||
}
|
||||
|
||||
Future<void> connect({
|
||||
required String portName,
|
||||
int baudRate = 115200,
|
||||
}) async {
|
||||
if (_status == UsbSerialStatus.connected ||
|
||||
_status == UsbSerialStatus.connecting) {
|
||||
throw StateError('USB serial transport is already active');
|
||||
}
|
||||
|
||||
_status = UsbSerialStatus.connecting;
|
||||
final normalizedPortName = _normalizePortName(portName);
|
||||
|
||||
if (_useAndroidUsbHost) {
|
||||
try {
|
||||
await _androidMethodChannel.invokeMethod<void>('connect', {
|
||||
'portName': normalizedPortName,
|
||||
'baudRate': baudRate,
|
||||
});
|
||||
debugPrint(
|
||||
'USB serial opened port=$normalizedPortName on Android via USB host bridge',
|
||||
);
|
||||
} on PlatformException catch (error) {
|
||||
_status = UsbSerialStatus.disconnected;
|
||||
throw StateError(error.message ?? error.code);
|
||||
}
|
||||
} else {
|
||||
_serial.init();
|
||||
|
||||
try {
|
||||
final status = _serial.openPort(normalizedPortName, baudRate);
|
||||
if (status != FlOpenStatus.open) {
|
||||
throw StateError(
|
||||
'Failed to open USB port $normalizedPortName ($status)',
|
||||
);
|
||||
}
|
||||
_serial.setByteSize8();
|
||||
_serial.setBitParityNone();
|
||||
_serial.setStopBits1();
|
||||
_serial.setFlowControlNone();
|
||||
_serial.setRTS(false);
|
||||
_serial.setDTR(true);
|
||||
debugPrint(
|
||||
'USB serial opened port=$normalizedPortName cts=${_serial.getCTS()} dsr=${_serial.getDSR()} dtr=true rts=false',
|
||||
);
|
||||
} on FlSerialException catch (error) {
|
||||
_serial.free();
|
||||
_status = UsbSerialStatus.disconnected;
|
||||
throw StateError(
|
||||
'Failed to open USB port $normalizedPortName: ${error.msg} (${error.error})',
|
||||
);
|
||||
} catch (error) {
|
||||
_serial.free();
|
||||
_status = UsbSerialStatus.disconnected;
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
_connectedPortName = normalizedPortName;
|
||||
if (_useAndroidUsbHost) {
|
||||
_androidDataSubscription = _androidEventChannel
|
||||
.receiveBroadcastStream()
|
||||
.listen(
|
||||
_handleAndroidData,
|
||||
onError: _handleSerialError,
|
||||
onDone: _handleSerialDone,
|
||||
);
|
||||
} else {
|
||||
_dataSubscription = _serial.onSerialData.stream.listen(
|
||||
_handleSerialData,
|
||||
onError: _handleSerialError,
|
||||
onDone: _handleSerialDone,
|
||||
);
|
||||
}
|
||||
_status = UsbSerialStatus.connected;
|
||||
}
|
||||
|
||||
Future<void> write(Uint8List data) async {
|
||||
if (!isConnected) {
|
||||
throw StateError('USB serial port is not open');
|
||||
}
|
||||
final packet = Uint8List(_serialHeaderLength + data.length);
|
||||
packet[0] = _serialTxFrameStart;
|
||||
packet[1] = data.length & 0xff;
|
||||
packet[2] = (data.length >> 8) & 0xff;
|
||||
packet.setRange(_serialHeaderLength, packet.length, data);
|
||||
_logFrameSummary('USB TX frame', data);
|
||||
if (_useAndroidUsbHost) {
|
||||
try {
|
||||
await _androidMethodChannel.invokeMethod<void>('write', {
|
||||
'data': packet,
|
||||
});
|
||||
} on PlatformException catch (error) {
|
||||
throw StateError(error.message ?? error.code);
|
||||
}
|
||||
} else {
|
||||
_serial.write(packet);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> disconnect() async {
|
||||
if (_status == UsbSerialStatus.disconnected) return;
|
||||
|
||||
_status = UsbSerialStatus.disconnecting;
|
||||
_connectedPortName = null;
|
||||
await _androidDataSubscription?.cancel();
|
||||
_androidDataSubscription = null;
|
||||
await _dataSubscription?.cancel();
|
||||
_dataSubscription = null;
|
||||
|
||||
if (_useAndroidUsbHost) {
|
||||
try {
|
||||
await _androidMethodChannel.invokeMethod<void>('disconnect');
|
||||
} catch (_) {
|
||||
// Ignore errors while closing.
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
if (_serial.isOpen() == FlOpenStatus.open) {
|
||||
_serial.closePort();
|
||||
}
|
||||
} catch (_) {
|
||||
// Ignore errors while closing.
|
||||
}
|
||||
|
||||
_serial.free();
|
||||
}
|
||||
_status = UsbSerialStatus.disconnected;
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
unawaited(disconnect());
|
||||
unawaited(_frameController.close());
|
||||
}
|
||||
|
||||
void _handleSerialData(FlSerialEventArgs event) {
|
||||
try {
|
||||
final bytes = event.serial.readList();
|
||||
if (bytes.isNotEmpty) {
|
||||
_ingestRawBytes(Uint8List.fromList(bytes));
|
||||
}
|
||||
} catch (error, stack) {
|
||||
_frameController.addError(error, stack);
|
||||
}
|
||||
}
|
||||
|
||||
void _handleAndroidData(dynamic data) {
|
||||
if (data is Uint8List) {
|
||||
_ingestRawBytes(data);
|
||||
return;
|
||||
}
|
||||
if (data is ByteData) {
|
||||
_ingestRawBytes(data.buffer.asUint8List());
|
||||
return;
|
||||
}
|
||||
_frameController.addError(
|
||||
StateError('Unexpected Android USB event payload: ${data.runtimeType}'),
|
||||
);
|
||||
}
|
||||
|
||||
void _handleSerialError(Object error, [StackTrace? stackTrace]) {
|
||||
_frameController.addError(error, stackTrace);
|
||||
}
|
||||
|
||||
void _handleSerialDone() {
|
||||
unawaited(disconnect());
|
||||
}
|
||||
|
||||
String _normalizePortName(String portName) {
|
||||
final separatorIndex = portName.indexOf(' - ');
|
||||
final normalized = separatorIndex >= 0
|
||||
? portName.substring(0, separatorIndex)
|
||||
: portName;
|
||||
return normalized.trim();
|
||||
}
|
||||
|
||||
void _ingestRawBytes(Uint8List bytes) {
|
||||
if (bytes.isEmpty) {
|
||||
return;
|
||||
}
|
||||
_rxBuffer.addAll(bytes);
|
||||
_drainRxBuffer();
|
||||
}
|
||||
|
||||
void _drainRxBuffer() {
|
||||
while (true) {
|
||||
if (_rxBuffer.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (_rxBuffer.first != _serialRxFrameStart &&
|
||||
_rxBuffer.first != _serialTxFrameStart) {
|
||||
_rxBuffer.removeAt(0);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (_rxBuffer.length < _serialHeaderLength) {
|
||||
return;
|
||||
}
|
||||
|
||||
final payloadLength = _rxBuffer[1] | (_rxBuffer[2] << 8);
|
||||
final packetLength = _serialHeaderLength + payloadLength;
|
||||
if (_rxBuffer.length < packetLength) {
|
||||
return;
|
||||
}
|
||||
|
||||
final frameStart = _rxBuffer.first;
|
||||
final payload = Uint8List.fromList(
|
||||
_rxBuffer.sublist(_serialHeaderLength, packetLength),
|
||||
);
|
||||
_rxBuffer.removeRange(0, packetLength);
|
||||
if (frameStart != _serialRxFrameStart) {
|
||||
debugPrint(
|
||||
'USB ignored packet start=0x${frameStart.toRadixString(16).padLeft(2, '0')} len=${payload.length}',
|
||||
);
|
||||
}
|
||||
_frameController.add(payload);
|
||||
}
|
||||
}
|
||||
|
||||
void _logFrameSummary(String prefix, Uint8List bytes) {
|
||||
if (bytes.isEmpty) {
|
||||
debugPrint('$prefix len=0');
|
||||
return;
|
||||
}
|
||||
debugPrint('$prefix code=${bytes[0]} len=${bytes.length}');
|
||||
}
|
||||
}
|
||||
|
||||
enum UsbSerialStatus { disconnected, connecting, connected, disconnecting }
|
||||
Loading…
Reference in new issue