mirror of
https://github.com/zjs81/meshcore-open.git
synced 2026-06-30 22:20:30 +10:00
Compare commits
79 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fa4da979af | |||
| 91608ff09e | |||
| 71f59d23df | |||
| e90742be25 | |||
| db935a7454 | |||
| 1ad5db27ca | |||
| 81758adc61 | |||
| c81791cf1e | |||
| 1fba5312a2 | |||
| 2f770bbd53 | |||
| 9db79e9d40 | |||
| 1913a5aa11 | |||
| 929c1c3d28 | |||
| 7a2bb20bf7 | |||
| a1b77bb29b | |||
| 4eecfc92dc | |||
| 90c8cf5f3e | |||
| 06fa176367 | |||
| e4285774a0 | |||
| b2da695102 | |||
| e1327a93c7 | |||
| 421bc71bb7 | |||
| fef73b7b62 | |||
| 84ec139ce6 | |||
| b748b96237 | |||
| c2671ac2ae | |||
| 8238b6197f | |||
| 435ba89982 | |||
| 0565cee461 | |||
| ab2b509d6a | |||
| eba95af31f | |||
| 04c016cfe1 | |||
| fb58a3262c | |||
| f584c4fba0 | |||
| b5b930646f | |||
| 3452bdae8c | |||
| 25fc9454a8 | |||
| 524558c511 | |||
| 367e47bb1e | |||
| 21ff765e41 | |||
| 38d40ca0a4 | |||
| 5b4535d5dc | |||
| f9b6299620 | |||
| 7cb84dbf6f | |||
| 44c0670dae | |||
| 74da9e82b5 | |||
| 63583dadda | |||
| 32632669c3 | |||
| 3c0c0d1dea | |||
| e6c9a3fea7 | |||
| f5154b0033 | |||
| 4c7ee3b3b0 | |||
| c2f544eeba | |||
| 98cdac4309 | |||
| d6d11eaad2 | |||
| 3cef9e81b6 | |||
| 5216e00807 | |||
| a0feb129e1 | |||
| f39a22668e | |||
| 781090243c | |||
| ca5784f3f8 | |||
| dcad5c586d | |||
| 4b24506310 | |||
| 47c4e0fb82 | |||
| c041e05972 | |||
| 612612795a | |||
| 3cec3dc233 | |||
| 3542adad1d | |||
| 115689ad95 | |||
| 9a0572e8e4 | |||
| 2d1160d992 | |||
| ee3af52c0f | |||
| 98f7c3b088 | |||
| f462815775 | |||
| 5f4333398e | |||
| c23a1da430 | |||
| 22a53439b1 | |||
| 3502559fae | |||
| e125318137 |
@@ -0,0 +1 @@
|
|||||||
|
6.2.4
|
||||||
@@ -19,6 +19,7 @@
|
|||||||
<!-- Camera permission for QR code scanning -->
|
<!-- Camera permission for QR code scanning -->
|
||||||
<uses-permission android:name="android.permission.CAMERA"/>
|
<uses-permission android:name="android.permission.CAMERA"/>
|
||||||
<uses-feature android:name="android.hardware.camera" android:required="false"/>
|
<uses-feature android:name="android.hardware.camera" android:required="false"/>
|
||||||
|
<uses-feature android:name="android.hardware.usb.host" android:required="false"/>
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:label="meshcore_open"
|
android:label="meshcore_open"
|
||||||
|
|||||||
@@ -1,5 +1,18 @@
|
|||||||
package com.meshcore.meshcore_open
|
package com.meshcore.meshcore_open
|
||||||
|
|
||||||
import io.flutter.embedding.android.FlutterActivity
|
import io.flutter.embedding.android.FlutterActivity
|
||||||
|
import io.flutter.embedding.engine.FlutterEngine
|
||||||
|
|
||||||
class MainActivity : FlutterActivity()
|
class MainActivity : FlutterActivity() {
|
||||||
|
private val usbFunctions by lazy { MeshcoreUsbFunctions(this) }
|
||||||
|
|
||||||
|
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||||
|
super.configureFlutterEngine(flutterEngine)
|
||||||
|
usbFunctions.configureFlutterEngine(flutterEngine)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
usbFunctions.dispose()
|
||||||
|
super.onDestroy()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,582 @@
|
|||||||
|
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()
|
||||||
|
|
||||||
|
@Volatile private var eventSink: EventChannel.EventSink? = null
|
||||||
|
@Volatile private var usbConnection: UsbDeviceConnection? = null
|
||||||
|
@Volatile private var usbInEndpoint: UsbEndpoint? = null
|
||||||
|
@Volatile private var usbOutEndpoint: UsbEndpoint? = null
|
||||||
|
@Volatile private var controlInterface: UsbInterface? = null
|
||||||
|
@Volatile private var dataInterface: UsbInterface? = null
|
||||||
|
private var readThread: Thread? = null
|
||||||
|
@Volatile private var isReading = false
|
||||||
|
@Volatile 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 device = findUsbDevice(portName)
|
||||||
|
if (device == null) {
|
||||||
|
result.error(
|
||||||
|
"usb_device_missing",
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val granted =
|
||||||
|
intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false)
|
||||||
|
if (!granted || !usbManager.hasPermission(device)) {
|
||||||
|
result.error("usb_permission_denied", null, 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", null, null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val device = findUsbDevice(portName)
|
||||||
|
if (device == null) {
|
||||||
|
result.error("usb_device_missing", null, null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (usbManager.hasPermission(device)) {
|
||||||
|
openUsbDevice(device, baudRate, result)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pendingConnectResult != null) {
|
||||||
|
result.error("usb_busy", null, 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", null, null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (connection == null || endpoint == null) {
|
||||||
|
result.error("usb_not_connected", null, 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? {
|
||||||
|
val devices = usbManager.deviceList.values
|
||||||
|
val exactMatch = devices.firstOrNull { it.deviceName == portName }
|
||||||
|
if (exactMatch != null) {
|
||||||
|
return exactMatch
|
||||||
|
}
|
||||||
|
|
||||||
|
val normalizedName = portName.substringBefore(" - ").trim()
|
||||||
|
return devices.firstOrNull { it.deviceName == normalizedName }
|
||||||
|
}
|
||||||
|
|
||||||
|
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",
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return@execute
|
||||||
|
}
|
||||||
|
|
||||||
|
val connection = usbManager.openDevice(device)
|
||||||
|
if (connection == null) {
|
||||||
|
mainHandler.post {
|
||||||
|
result.error(
|
||||||
|
"usb_open_failed",
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return@execute
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!connection.claimInterface(config.dataInterface, true)) {
|
||||||
|
connection.close()
|
||||||
|
mainHandler.post {
|
||||||
|
result.error(
|
||||||
|
"usb_open_failed",
|
||||||
|
null,
|
||||||
|
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",
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return@execute
|
||||||
|
}
|
||||||
|
|
||||||
|
usbConnection = connection
|
||||||
|
usbInEndpoint = config.inEndpoint
|
||||||
|
usbOutEndpoint = config.outEndpoint
|
||||||
|
controlInterface = config.controlInterface
|
||||||
|
dataInterface = config.dataInterface
|
||||||
|
|
||||||
|
configureDevice(connection, config, baudRate)
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,70 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import '../services/app_debug_log_service.dart';
|
||||||
|
import '../services/tcp_transport_service.dart';
|
||||||
|
|
||||||
|
/// Manages TCP transport for MeshCore devices.
|
||||||
|
///
|
||||||
|
/// Owns the [TcpTransportService] and TCP-specific connection state.
|
||||||
|
/// The main [MeshCoreConnector] delegates all TCP operations here.
|
||||||
|
class MeshCoreTcpConnector {
|
||||||
|
final TcpTransportService _service = TcpTransportService();
|
||||||
|
AppDebugLogService? _debugLog;
|
||||||
|
StreamSubscription<Uint8List>? _frameSubscription;
|
||||||
|
|
||||||
|
// --- Getters ---
|
||||||
|
String? get activeEndpoint => _service.activeEndpoint;
|
||||||
|
bool get isConnected => _service.isConnected;
|
||||||
|
|
||||||
|
// --- Configuration ---
|
||||||
|
void setDebugLogService(AppDebugLogService? service) {
|
||||||
|
_debugLog = service;
|
||||||
|
_service.setDebugLogService(service);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Connection lifecycle ---
|
||||||
|
Future<void> connect({required String host, required int port}) async {
|
||||||
|
_debugLog?.info('TcpConnector.connect endpoint=$host:$port', tag: 'TCP');
|
||||||
|
await _frameSubscription?.cancel();
|
||||||
|
_frameSubscription = null;
|
||||||
|
await _service.connect(host: host, port: port);
|
||||||
|
_debugLog?.info(
|
||||||
|
'TcpConnector.connect done, endpoint=${_service.activeEndpoint}',
|
||||||
|
tag: 'TCP',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
StreamSubscription<Uint8List> listenFrames({
|
||||||
|
required void Function(Uint8List) onFrame,
|
||||||
|
required void Function(Object, StackTrace?) onError,
|
||||||
|
required void Function() onDone,
|
||||||
|
}) {
|
||||||
|
_frameSubscription = _service.frameStream.listen(
|
||||||
|
onFrame,
|
||||||
|
onError: onError,
|
||||||
|
onDone: onDone,
|
||||||
|
);
|
||||||
|
return _frameSubscription!;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> cancelFrameSubscription() async {
|
||||||
|
await _frameSubscription?.cancel();
|
||||||
|
_frameSubscription = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> disconnect() async {
|
||||||
|
if (!_service.isConnected && _frameSubscription == null) return;
|
||||||
|
_debugLog?.info('TcpConnector.disconnect', tag: 'TCP');
|
||||||
|
await _frameSubscription?.cancel();
|
||||||
|
_frameSubscription = null;
|
||||||
|
await _service.disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> write(Uint8List data) => _service.write(data);
|
||||||
|
|
||||||
|
void dispose() {
|
||||||
|
_frameSubscription?.cancel();
|
||||||
|
_service.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import '../services/app_debug_log_service.dart';
|
||||||
|
import '../services/usb_serial_service.dart';
|
||||||
|
|
||||||
|
/// Manages USB serial transport for MeshCore devices.
|
||||||
|
///
|
||||||
|
/// Owns the [UsbSerialService] and USB-specific connection state.
|
||||||
|
/// The main [MeshCoreConnector] delegates all USB operations here.
|
||||||
|
class MeshCoreUsbManager {
|
||||||
|
MeshCoreUsbManager();
|
||||||
|
|
||||||
|
final UsbSerialService _service = UsbSerialService();
|
||||||
|
AppDebugLogService? _debugLog;
|
||||||
|
String? _activePortKey;
|
||||||
|
String? _activePortLabel;
|
||||||
|
|
||||||
|
// --- Getters ---
|
||||||
|
String? get activePortKey => _activePortKey;
|
||||||
|
String? get activePortDisplayLabel => _activePortLabel ?? _activePortKey;
|
||||||
|
bool get isConnected => _service.isConnected;
|
||||||
|
Stream<Uint8List> get frameStream => _service.frameStream;
|
||||||
|
|
||||||
|
// --- Configuration ---
|
||||||
|
Future<List<String>> listPorts() => _service.listPorts();
|
||||||
|
|
||||||
|
void setRequestPortLabel(String label) => _service.setRequestPortLabel(label);
|
||||||
|
|
||||||
|
void setFallbackDeviceName(String label) =>
|
||||||
|
_service.setFallbackDeviceName(label);
|
||||||
|
|
||||||
|
void setDebugLogService(AppDebugLogService? service) {
|
||||||
|
_debugLog = service;
|
||||||
|
_service.setDebugLogService(service);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Connection lifecycle ---
|
||||||
|
Future<void> connect({
|
||||||
|
required String portName,
|
||||||
|
int baudRate = 115200,
|
||||||
|
}) async {
|
||||||
|
_debugLog?.info(
|
||||||
|
'UsbManager.connect: portName=$portName baud=$baudRate',
|
||||||
|
tag: 'USB',
|
||||||
|
);
|
||||||
|
await _service.connect(portName: portName, baudRate: baudRate);
|
||||||
|
_activePortKey = _service.activePortKey ?? portName;
|
||||||
|
_activePortLabel = _service.activePortDisplayLabel ?? portName;
|
||||||
|
_debugLog?.info(
|
||||||
|
'UsbManager.connect: done, key=$_activePortKey label=$_activePortLabel',
|
||||||
|
tag: 'USB',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> disconnect() async {
|
||||||
|
if (!_service.isConnected && _activePortKey == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_debugLog?.info('UsbManager.disconnect', tag: 'USB');
|
||||||
|
await _service.disconnect();
|
||||||
|
_activePortKey = null;
|
||||||
|
_activePortLabel = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> write(Uint8List data) => _service.write(data);
|
||||||
|
|
||||||
|
// --- Label management ---
|
||||||
|
void updateConnectedLabel(String selfName) {
|
||||||
|
_service.updateConnectedLabel(selfName);
|
||||||
|
_activePortLabel = _service.activePortDisplayLabel ?? _activePortLabel;
|
||||||
|
}
|
||||||
|
|
||||||
|
void dispose() {
|
||||||
|
_service.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import 'dart:typed_data';
|
|||||||
// Buffer Reader - sequential binary data reader with pointer tracking
|
// Buffer Reader - sequential binary data reader with pointer tracking
|
||||||
class BufferReader {
|
class BufferReader {
|
||||||
int _pointer = 0;
|
int _pointer = 0;
|
||||||
|
int _lastPointer = 0;
|
||||||
final Uint8List _buffer;
|
final Uint8List _buffer;
|
||||||
|
|
||||||
BufferReader(Uint8List data) : _buffer = Uint8List.fromList(data);
|
BufferReader(Uint8List data) : _buffer = Uint8List.fromList(data);
|
||||||
@@ -13,6 +14,7 @@ class BufferReader {
|
|||||||
int readByte() => readBytes(1)[0];
|
int readByte() => readBytes(1)[0];
|
||||||
|
|
||||||
Uint8List readBytes(int count) {
|
Uint8List readBytes(int count) {
|
||||||
|
_lastPointer = _pointer;
|
||||||
if (_pointer + count > _buffer.length) {
|
if (_pointer + count > _buffer.length) {
|
||||||
throw RangeError(
|
throw RangeError(
|
||||||
'Attempted to read $count bytes at offset $_pointer, but only $remaining bytes remaining in buffer of length ${_buffer.length}',
|
'Attempted to read $count bytes at offset $_pointer, but only $remaining bytes remaining in buffer of length ${_buffer.length}',
|
||||||
@@ -24,6 +26,7 @@ class BufferReader {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void skipBytes(int count) {
|
void skipBytes(int count) {
|
||||||
|
_lastPointer = _pointer;
|
||||||
if (_pointer + count > _buffer.length) {
|
if (_pointer + count > _buffer.length) {
|
||||||
throw RangeError(
|
throw RangeError(
|
||||||
'Attempted to skip $count bytes at offset $_pointer, but only $remaining bytes remaining in buffer of length ${_buffer.length}',
|
'Attempted to skip $count bytes at offset $_pointer, but only $remaining bytes remaining in buffer of length ${_buffer.length}',
|
||||||
@@ -35,6 +38,7 @@ class BufferReader {
|
|||||||
Uint8List readRemainingBytes() => readBytes(remaining);
|
Uint8List readRemainingBytes() => readBytes(remaining);
|
||||||
|
|
||||||
String readString() {
|
String readString() {
|
||||||
|
_lastPointer = _pointer;
|
||||||
final value = readRemainingBytes();
|
final value = readRemainingBytes();
|
||||||
try {
|
try {
|
||||||
return utf8.decode(Uint8List.fromList(value), allowMalformed: true);
|
return utf8.decode(Uint8List.fromList(value), allowMalformed: true);
|
||||||
@@ -43,7 +47,8 @@ class BufferReader {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
String readCString(int maxLength) {
|
String readCStringGreedy(int maxLength) {
|
||||||
|
_lastPointer = _pointer;
|
||||||
final value = <int>[];
|
final value = <int>[];
|
||||||
final bytes = readBytes(maxLength);
|
final bytes = readBytes(maxLength);
|
||||||
for (final byte in bytes) {
|
for (final byte in bytes) {
|
||||||
@@ -57,6 +62,24 @@ class BufferReader {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String readCString(int maxLength) {
|
||||||
|
final backupPointer = _pointer;
|
||||||
|
final value = <int>[];
|
||||||
|
int counter = 0;
|
||||||
|
while (counter < maxLength) {
|
||||||
|
final byte = readByte();
|
||||||
|
if (byte == 0) break;
|
||||||
|
value.add(byte);
|
||||||
|
counter++;
|
||||||
|
}
|
||||||
|
_lastPointer = backupPointer;
|
||||||
|
try {
|
||||||
|
return utf8.decode(Uint8List.fromList(value), allowMalformed: true);
|
||||||
|
} catch (e) {
|
||||||
|
return String.fromCharCodes(value); // Latin-1 fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
int readUInt8() => readBytes(1).buffer.asByteData().getUint8(0);
|
int readUInt8() => readBytes(1).buffer.asByteData().getUint8(0);
|
||||||
int readInt8() => readBytes(1).buffer.asByteData().getInt8(0);
|
int readInt8() => readBytes(1).buffer.asByteData().getInt8(0);
|
||||||
int readUInt16LE() =>
|
int readUInt16LE() =>
|
||||||
@@ -78,6 +101,9 @@ class BufferReader {
|
|||||||
if ((value & 0x800000) != 0) value -= 0x1000000;
|
if ((value & 0x800000) != 0) value -= 0x1000000;
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void resetPointer() => _pointer = 0;
|
||||||
|
void rewind() => _pointer = _lastPointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Buffer Writer - accumulating binary data builder
|
// Buffer Writer - accumulating binary data builder
|
||||||
@@ -122,6 +148,19 @@ class BufferWriter {
|
|||||||
void writeHex(String hex) {
|
void writeHex(String hex) {
|
||||||
writeBytes(hex2Uint8List(hex));
|
writeBytes(hex2Uint8List(hex));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void writeBytesPadded(Uint8List bytes, int totalLength) {
|
||||||
|
// Path data (64 bytes, zero-padded)
|
||||||
|
final bytesPadded = Uint8List(totalLength);
|
||||||
|
final len = bytes.length < totalLength ? bytes.length : totalLength;
|
||||||
|
if (bytes.isNotEmpty && len > 0) {
|
||||||
|
final copyLen = bytes.length < totalLength ? bytes.length : totalLength;
|
||||||
|
for (int i = 0; i < copyLen; i++) {
|
||||||
|
bytesPadded[i] = bytes[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeBytes(bytesPadded);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Uint8List hex2Uint8List(String hex) {
|
Uint8List hex2Uint8List(String hex) {
|
||||||
@@ -650,14 +689,17 @@ Uint8List buildResetPathFrame(Uint8List pubKey) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Build CMD_ADD_UPDATE_CONTACT frame to set custom path
|
// Build CMD_ADD_UPDATE_CONTACT frame to set custom path
|
||||||
// Format: [cmd][pub_key x32][type][flags][path_len][path x64][name x32][timestamp x4]
|
// Format: [cmd][pub_key x32][type][flags][path_len][path x64][name x32][Lat? x4, Lon? x4][timestamp? x4]
|
||||||
Uint8List buildUpdateContactPathFrame(
|
Uint8List buildUpdateContactPathFrame(
|
||||||
Uint8List pubKey,
|
Uint8List pubKey,
|
||||||
Uint8List customPath,
|
Uint8List path,
|
||||||
int pathLen, {
|
int pathLen, {
|
||||||
int type = 1, // ADV_TYPE_CHAT
|
int type = 1, // ADV_TYPE_CHAT
|
||||||
int flags = 0,
|
int flags = 0,
|
||||||
String name = '',
|
String name = '',
|
||||||
|
double? lat,
|
||||||
|
double? lon,
|
||||||
|
DateTime? lastModified,
|
||||||
}) {
|
}) {
|
||||||
final writer = BufferWriter();
|
final writer = BufferWriter();
|
||||||
writer.writeByte(cmdAddUpdateContact);
|
writer.writeByte(cmdAddUpdateContact);
|
||||||
@@ -666,17 +708,7 @@ Uint8List buildUpdateContactPathFrame(
|
|||||||
writer.writeByte(flags);
|
writer.writeByte(flags);
|
||||||
writer.writeByte(pathLen);
|
writer.writeByte(pathLen);
|
||||||
|
|
||||||
// Path data (64 bytes, zero-padded)
|
writer.writeBytesPadded(path, maxPathSize);
|
||||||
final pathPadded = Uint8List(maxPathSize);
|
|
||||||
if (customPath.isNotEmpty && pathLen > 0) {
|
|
||||||
final copyLen = customPath.length < maxPathSize
|
|
||||||
? customPath.length
|
|
||||||
: maxPathSize;
|
|
||||||
for (int i = 0; i < copyLen; i++) {
|
|
||||||
pathPadded[i] = customPath[i];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
writer.writeBytes(pathPadded);
|
|
||||||
|
|
||||||
// Name (32 bytes, null-padded)
|
// Name (32 bytes, null-padded)
|
||||||
writer.writeCString(name, maxNameSize);
|
writer.writeCString(name, maxNameSize);
|
||||||
@@ -685,6 +717,27 @@ Uint8List buildUpdateContactPathFrame(
|
|||||||
final timestamp = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
final timestamp = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||||||
writer.writeUInt32LE(timestamp);
|
writer.writeUInt32LE(timestamp);
|
||||||
|
|
||||||
|
if ((lat == null || lon == null) && lastModified != null) {
|
||||||
|
// If lat/lon not provided, write zeros
|
||||||
|
writer.writeInt32LE(0);
|
||||||
|
writer.writeInt32LE(0);
|
||||||
|
} else {
|
||||||
|
// Latitude and Longitude are expected in degrees, convert to int by multiplying by 1e6
|
||||||
|
// Latitude
|
||||||
|
final latitude = lat ?? 0.0;
|
||||||
|
writer.writeInt32LE((latitude * 1e6).round());
|
||||||
|
|
||||||
|
// Longitude
|
||||||
|
final longitude = lon ?? 0.0;
|
||||||
|
writer.writeInt32LE((longitude * 1e6).round());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastModified != null) {
|
||||||
|
// Last modified
|
||||||
|
final lastModifiedTimestamp = lastModified.millisecondsSinceEpoch ~/ 1000;
|
||||||
|
writer.writeUInt32LE(lastModifiedTimestamp);
|
||||||
|
}
|
||||||
|
|
||||||
return writer.toBytes();
|
return writer.toBytes();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+61
-1
@@ -1828,5 +1828,65 @@
|
|||||||
"discoveredContacts_deleteContactAllContent": "Сигурни ли сте, че искате да изтриете всички открити контакти?",
|
"discoveredContacts_deleteContactAllContent": "Сигурни ли сте, че искате да изтриете всички открити контакти?",
|
||||||
"common_deleteAll": "Изтрий всичко",
|
"common_deleteAll": "Изтрий всичко",
|
||||||
"map_guessedLocation": "Предполагано местоположение",
|
"map_guessedLocation": "Предполагано местоположение",
|
||||||
"map_showGuessedLocations": "Покажете местоположенията на предположените възли."
|
"map_showGuessedLocations": "Покажете местоположенията на предположените възли.",
|
||||||
|
"connectionChoiceUsbLabel": "USB",
|
||||||
|
"usbScreenTitle": "Свържете се чрез USB",
|
||||||
|
"connectionChoiceBluetoothLabel": "Bluetooth",
|
||||||
|
"usbScreenSubtitle": "Изберете открития сериен уред и свържете директно към вашия MeshCore възел.",
|
||||||
|
"usbScreenStatus": "Изберете USB устройство",
|
||||||
|
"usbScreenNote": "USB серийната връзка е активна на поддържаните Android устройства и настолни платформи.",
|
||||||
|
"usbScreenEmptyState": "Няма открити USB устройства. Включете едно и опитайте отново.",
|
||||||
|
"usbErrorPermissionDenied": "Не беше разрешено достъпът през USB.",
|
||||||
|
"usbErrorDeviceMissing": "Избраното USB устройство вече не е налично.",
|
||||||
|
"usbErrorInvalidPort": "Изберете валитно USB устройство.",
|
||||||
|
"usbErrorBusy": "Друг мол за свързване през USB вече е в процес на изпълнение.",
|
||||||
|
"usbErrorNotConnected": "Няма свързано USB устройство.",
|
||||||
|
"usbErrorOpenFailed": "Не успях да отворя избраното USB устройство.",
|
||||||
|
"usbErrorConnectFailed": "Не успях да се свържа с избраното USB устройство.",
|
||||||
|
"usbErrorUnsupported": "USB серийната комуникация не се поддържа на тази платформа.",
|
||||||
|
"usbErrorAlreadyActive": "USB връзката вече е активирана.",
|
||||||
|
"usbErrorNoDeviceSelected": "Няма избран USB устройство.",
|
||||||
|
"usbErrorPortClosed": "USB връзката не е активна.",
|
||||||
|
"usbFallbackDeviceName": "Устройство за четене на уеб серийни данни",
|
||||||
|
"@usbConnectionFailed": {
|
||||||
|
"placeholders": {
|
||||||
|
"error": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"usbStatus_connecting": "Свързване към USB устройство...",
|
||||||
|
"usbConnectionFailed": "Неуспешно свързване през USB: {error}",
|
||||||
|
"usbStatus_notConnected": "Изберете USB устройство",
|
||||||
|
"usbStatus_searching": "Търсене на USB устройства...",
|
||||||
|
"usbErrorConnectTimedOut": "Връзката прекъсна. Уверете се, че устройството има софтуер за USB връзка.",
|
||||||
|
"@tcpStatus_connectingTo": {
|
||||||
|
"placeholders": {
|
||||||
|
"endpoint": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"@tcpConnectionFailed": {
|
||||||
|
"placeholders": {
|
||||||
|
"error": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tcpHostHint": "192.168.40.10",
|
||||||
|
"tcpScreenTitle": "Свържете се чрез TCP",
|
||||||
|
"connectionChoiceTcpLabel": "TCP",
|
||||||
|
"tcpHostLabel": "IP адрес",
|
||||||
|
"tcpPortLabel": "Пристанище",
|
||||||
|
"tcpPortHint": "5000",
|
||||||
|
"tcpStatus_notConnected": "Въведете крайната точка и свържете се.",
|
||||||
|
"tcpStatus_connectingTo": "Свързване към {endpoint}...",
|
||||||
|
"tcpErrorHostRequired": "Необходим е IP адрес.",
|
||||||
|
"tcpErrorPortInvalid": "Портът трябва да бъде между 1 и 65535.",
|
||||||
|
"tcpErrorUnsupported": "Транспортът чрез TCP не се поддържа на тази платформа.",
|
||||||
|
"tcpErrorTimedOut": "Връзката TCP изтекла.",
|
||||||
|
"tcpConnectionFailed": "Неуспешно е установено TCP връзката: {error}",
|
||||||
|
"map_showDiscoveryContacts": "Покажи контакти за откриване",
|
||||||
|
"map_setAsMyLocation": "Задайте като моя местоположение"
|
||||||
}
|
}
|
||||||
|
|||||||
+67
-7
@@ -296,8 +296,8 @@
|
|||||||
"contacts_filterContacts": "Filtert Kontakte...",
|
"contacts_filterContacts": "Filtert Kontakte...",
|
||||||
"contacts_noContactsMatchFilter": "Keine Kontakte passen zu Ihrem Filter",
|
"contacts_noContactsMatchFilter": "Keine Kontakte passen zu Ihrem Filter",
|
||||||
"contacts_noMembers": "Keine Mitglieder",
|
"contacts_noMembers": "Keine Mitglieder",
|
||||||
"contacts_lastSeenNow": "gerade gesehen",
|
"contacts_lastSeenNow": "kürzlich",
|
||||||
"contacts_lastSeenMinsAgo": "Letzte Sichtung vor {minutes} Minuten.",
|
"contacts_lastSeenMinsAgo": "~ {minutes} Min.",
|
||||||
"@contacts_lastSeenMinsAgo": {
|
"@contacts_lastSeenMinsAgo": {
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
"minutes": {
|
"minutes": {
|
||||||
@@ -305,8 +305,8 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"contacts_lastSeenHourAgo": "Letzte Sichtung vor 1 Stunde.",
|
"contacts_lastSeenHourAgo": "~ 1 Std.",
|
||||||
"contacts_lastSeenHoursAgo": "Letzte Sichtung vor {hours} Stunden.",
|
"contacts_lastSeenHoursAgo": "~ {hours} Std.",
|
||||||
"@contacts_lastSeenHoursAgo": {
|
"@contacts_lastSeenHoursAgo": {
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
"hours": {
|
"hours": {
|
||||||
@@ -314,8 +314,8 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"contacts_lastSeenDayAgo": "Letzte Sichtung vor 1 Tag",
|
"contacts_lastSeenDayAgo": "~ 1 Tag",
|
||||||
"contacts_lastSeenDaysAgo": "Letzte Sichtung {days} Tage zuvor",
|
"contacts_lastSeenDaysAgo": "~ {days} Tage",
|
||||||
"@contacts_lastSeenDaysAgo": {
|
"@contacts_lastSeenDaysAgo": {
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
"days": {
|
"days": {
|
||||||
@@ -1856,5 +1856,65 @@
|
|||||||
"discoveredContacts_deleteContactAllContent": "Sind Sie sicher, dass Sie alle gefundenen Kontakte löschen möchten?",
|
"discoveredContacts_deleteContactAllContent": "Sind Sie sicher, dass Sie alle gefundenen Kontakte löschen möchten?",
|
||||||
"discoveredContacts_deleteContactAll": "Alle entdeckten Kontakte löschen",
|
"discoveredContacts_deleteContactAll": "Alle entdeckten Kontakte löschen",
|
||||||
"map_showGuessedLocations": "Zeige die vermuteten Knotenpositionen",
|
"map_showGuessedLocations": "Zeige die vermuteten Knotenpositionen",
|
||||||
"map_guessedLocation": "Geschätzter Ort"
|
"map_guessedLocation": "Geschätzter Ort",
|
||||||
|
"usbScreenSubtitle": "Wählen Sie ein erkannten serielles Gerät aus und verbinden Sie es direkt mit Ihrem MeshCore-Knoten.",
|
||||||
|
"connectionChoiceUsbLabel": "USB",
|
||||||
|
"usbScreenTitle": "Verbinden über USB",
|
||||||
|
"connectionChoiceBluetoothLabel": "Bluetooth",
|
||||||
|
"usbScreenStatus": "Wählen Sie ein USB-Gerät aus",
|
||||||
|
"usbScreenNote": "Die USB-Serielle Schnittstelle ist auf unterstützten Android-Geräten und Desktop-Plattformen aktiv.",
|
||||||
|
"usbScreenEmptyState": "Keine USB-Geräte gefunden. Schließen Sie eines an und aktualisieren Sie.",
|
||||||
|
"usbErrorPermissionDenied": "Die USB-Berechtigung wurde abgelehnt.",
|
||||||
|
"usbErrorDeviceMissing": "Das ausgewählte USB-Gerät ist nicht mehr verfügbar.",
|
||||||
|
"usbErrorInvalidPort": "Wählen Sie ein gültiges USB-Gerät aus.",
|
||||||
|
"usbErrorBusy": "Eine weitere Anfrage für eine USB-Verbindung ist bereits in Bearbeitung.",
|
||||||
|
"usbErrorNotConnected": "Es ist kein USB-Gerät angeschlossen.",
|
||||||
|
"usbErrorOpenFailed": "Fehlgeschlagen beim Öffnen des ausgewählten USB-Geräts.",
|
||||||
|
"usbErrorConnectFailed": "Keine Verbindung zum ausgewählten USB-Gerät hergestellt.",
|
||||||
|
"usbErrorUnsupported": "Die USB-Serielle Schnittstelle wird auf dieser Plattform nicht unterstützt.",
|
||||||
|
"usbErrorAlreadyActive": "Eine USB-Verbindung ist bereits hergestellt.",
|
||||||
|
"usbErrorNoDeviceSelected": "Kein USB-Gerät wurde ausgewählt.",
|
||||||
|
"usbErrorPortClosed": "Die USB-Verbindung ist nicht aktiv.",
|
||||||
|
"usbFallbackDeviceName": "Web-Serielle Geräte",
|
||||||
|
"@usbConnectionFailed": {
|
||||||
|
"placeholders": {
|
||||||
|
"error": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"usbStatus_searching": "Suche nach USB-Geräten...",
|
||||||
|
"usbStatus_notConnected": "Wählen Sie ein USB-Gerät aus",
|
||||||
|
"usbStatus_connecting": "Verbindung zum USB-Gerät...",
|
||||||
|
"usbConnectionFailed": "Fehler beim USB-Verbindungsaufbau: {error}",
|
||||||
|
"usbErrorConnectTimedOut": "Verbindung konnte nicht hergestellt werden. Stellen Sie sicher, dass das Gerät die entsprechende USB-Firmware enthält.",
|
||||||
|
"@tcpStatus_connectingTo": {
|
||||||
|
"placeholders": {
|
||||||
|
"endpoint": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"@tcpConnectionFailed": {
|
||||||
|
"placeholders": {
|
||||||
|
"error": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tcpHostLabel": "IP-Adresse",
|
||||||
|
"connectionChoiceTcpLabel": "TCP",
|
||||||
|
"tcpHostHint": "192.168.40.10",
|
||||||
|
"tcpScreenTitle": "Verbinden über TCP",
|
||||||
|
"tcpPortLabel": "Port",
|
||||||
|
"tcpPortHint": "5000",
|
||||||
|
"tcpStatus_notConnected": "Geben Sie den Endpunkt ein und verbinden Sie sich.",
|
||||||
|
"tcpStatus_connectingTo": "Verbindung zu {endpoint}...",
|
||||||
|
"tcpErrorHostRequired": "Eine IP-Adresse ist erforderlich.",
|
||||||
|
"tcpErrorPortInvalid": "Die Portnummer muss zwischen 1 und 65535 liegen.",
|
||||||
|
"tcpErrorUnsupported": "Die TCP-Übertragung wird auf dieser Plattform nicht unterstützt.",
|
||||||
|
"tcpErrorTimedOut": "Die TCP-Verbindung ist abgelaufen.",
|
||||||
|
"tcpConnectionFailed": "Fehler beim TCP-Verbindungsaufbau: {error}",
|
||||||
|
"map_showDiscoveryContacts": "Entdeckungs-Kontakte anzeigen",
|
||||||
|
"map_setAsMyLocation": "Als meine aktuelle Position festlegen"
|
||||||
}
|
}
|
||||||
|
|||||||
+66
-6
@@ -47,6 +47,64 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"scanner_title": "MeshCore Open",
|
"scanner_title": "MeshCore Open",
|
||||||
|
"connectionChoiceUsbLabel": "USB",
|
||||||
|
"connectionChoiceBluetoothLabel": "Bluetooth",
|
||||||
|
"connectionChoiceTcpLabel": "TCP",
|
||||||
|
"tcpScreenTitle": "Connect over TCP",
|
||||||
|
"tcpHostLabel": "IP Address",
|
||||||
|
"tcpHostHint": "192.168.40.10",
|
||||||
|
"tcpPortLabel": "Port",
|
||||||
|
"tcpPortHint": "5000",
|
||||||
|
"tcpStatus_notConnected": "Enter endpoint and connect",
|
||||||
|
"tcpStatus_connectingTo": "Connecting to {endpoint}...",
|
||||||
|
"@tcpStatus_connectingTo": {
|
||||||
|
"placeholders": {
|
||||||
|
"endpoint": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tcpErrorHostRequired": "IP address is required.",
|
||||||
|
"tcpErrorPortInvalid": "Port must be between 1 and 65535.",
|
||||||
|
"tcpErrorUnsupported": "TCP transport is not supported on this platform.",
|
||||||
|
"tcpErrorTimedOut": "TCP connection timed out.",
|
||||||
|
"tcpConnectionFailed": "TCP connection failed: {error}",
|
||||||
|
"@tcpConnectionFailed": {
|
||||||
|
"placeholders": {
|
||||||
|
"error": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"usbScreenTitle": "Connect over USB",
|
||||||
|
"usbScreenSubtitle": "Choose a detected serial device and connect directly to your MeshCore node.",
|
||||||
|
"usbScreenStatus": "Select a USB device",
|
||||||
|
"usbScreenNote": "USB serial is active on supported Android devices and desktop platforms.",
|
||||||
|
"usbScreenEmptyState": "No USB devices found. Plug one in and refresh.",
|
||||||
|
"usbErrorPermissionDenied": "USB permission was denied.",
|
||||||
|
"usbErrorDeviceMissing": "The selected USB device is no longer available.",
|
||||||
|
"usbErrorInvalidPort": "Select a valid USB device.",
|
||||||
|
"usbErrorBusy": "Another USB connection request is already in progress.",
|
||||||
|
"usbErrorNotConnected": "No USB device is connected.",
|
||||||
|
"usbErrorOpenFailed": "Failed to open the selected USB device.",
|
||||||
|
"usbErrorConnectFailed": "Failed to connect to the selected USB device.",
|
||||||
|
"usbErrorUnsupported": "USB serial is not supported on this platform.",
|
||||||
|
"usbErrorAlreadyActive": "A USB connection is already active.",
|
||||||
|
"usbErrorNoDeviceSelected": "No USB device was selected.",
|
||||||
|
"usbErrorPortClosed": "The USB connection is not open.",
|
||||||
|
"usbErrorConnectTimedOut": "Connection timed out. Make sure the device has USB Companion firmware.",
|
||||||
|
"usbFallbackDeviceName": "Web Serial Device",
|
||||||
|
"usbStatus_notConnected": "Select a USB device",
|
||||||
|
"usbStatus_connecting": "Connecting to USB device...",
|
||||||
|
"usbStatus_searching": "Searching for USB devices...",
|
||||||
|
"usbConnectionFailed": "USB connection failed: {error}",
|
||||||
|
"@usbConnectionFailed": {
|
||||||
|
"placeholders": {
|
||||||
|
"error": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"scanner_scanning": "Scanning for devices...",
|
"scanner_scanning": "Scanning for devices...",
|
||||||
"scanner_connecting": "Connecting...",
|
"scanner_connecting": "Connecting...",
|
||||||
"scanner_disconnecting": "Disconnecting...",
|
"scanner_disconnecting": "Disconnecting...",
|
||||||
@@ -369,8 +427,8 @@
|
|||||||
"contacts_filterContacts": "Filter contacts...",
|
"contacts_filterContacts": "Filter contacts...",
|
||||||
"contacts_noContactsMatchFilter": "No contacts match your filter",
|
"contacts_noContactsMatchFilter": "No contacts match your filter",
|
||||||
"contacts_noMembers": "No members",
|
"contacts_noMembers": "No members",
|
||||||
"contacts_lastSeenNow": "Last seen now",
|
"contacts_lastSeenNow": "recently",
|
||||||
"contacts_lastSeenMinsAgo": "Last seen {minutes} mins ago",
|
"contacts_lastSeenMinsAgo": "~ {minutes} min.",
|
||||||
"@contacts_lastSeenMinsAgo": {
|
"@contacts_lastSeenMinsAgo": {
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
"minutes": {
|
"minutes": {
|
||||||
@@ -378,8 +436,8 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"contacts_lastSeenHourAgo": "Last seen 1 hour ago",
|
"contacts_lastSeenHourAgo": "~ 1 hour",
|
||||||
"contacts_lastSeenHoursAgo": "Last seen {hours} hours ago",
|
"contacts_lastSeenHoursAgo": "~ {hours} hours",
|
||||||
"@contacts_lastSeenHoursAgo": {
|
"@contacts_lastSeenHoursAgo": {
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
"hours": {
|
"hours": {
|
||||||
@@ -387,8 +445,8 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"contacts_lastSeenDayAgo": "Last seen 1 day ago",
|
"contacts_lastSeenDayAgo": "~ 1 day",
|
||||||
"contacts_lastSeenDaysAgo": "Last seen {days} days ago",
|
"contacts_lastSeenDaysAgo": "~ {days} days",
|
||||||
"@contacts_lastSeenDaysAgo": {
|
"@contacts_lastSeenDaysAgo": {
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
"days": {
|
"days": {
|
||||||
@@ -749,6 +807,7 @@
|
|||||||
"map_source": "Source",
|
"map_source": "Source",
|
||||||
"map_flags": "Flags",
|
"map_flags": "Flags",
|
||||||
"map_shareMarkerHere": "Share marker here",
|
"map_shareMarkerHere": "Share marker here",
|
||||||
|
"map_setAsMyLocation": "Set as my location",
|
||||||
"map_pinLabel": "Pin label",
|
"map_pinLabel": "Pin label",
|
||||||
"map_label": "Label",
|
"map_label": "Label",
|
||||||
"map_pointOfInterest": "Point of interest",
|
"map_pointOfInterest": "Point of interest",
|
||||||
@@ -776,6 +835,7 @@
|
|||||||
"map_markers": "Markers",
|
"map_markers": "Markers",
|
||||||
"map_showSharedMarkers": "Show shared markers",
|
"map_showSharedMarkers": "Show shared markers",
|
||||||
"map_showGuessedLocations": "Show guessed node locations",
|
"map_showGuessedLocations": "Show guessed node locations",
|
||||||
|
"map_showDiscoveryContacts": "Show Discovery Contacts",
|
||||||
"map_guessedLocation": "Guessed location",
|
"map_guessedLocation": "Guessed location",
|
||||||
"map_lastSeenTime": "Last Seen Time",
|
"map_lastSeenTime": "Last Seen Time",
|
||||||
"map_sharedPin": "Shared pin",
|
"map_sharedPin": "Shared pin",
|
||||||
|
|||||||
+66
-6
@@ -297,7 +297,7 @@
|
|||||||
"contacts_noContactsMatchFilter": "No hay contactos que coincidan con tu filtro",
|
"contacts_noContactsMatchFilter": "No hay contactos que coincidan con tu filtro",
|
||||||
"contacts_noMembers": "No miembros",
|
"contacts_noMembers": "No miembros",
|
||||||
"contacts_lastSeenNow": "Última vez que se vio ahora",
|
"contacts_lastSeenNow": "Última vez que se vio ahora",
|
||||||
"contacts_lastSeenMinsAgo": "Última vez visto hace {minutes} minutos.",
|
"contacts_lastSeenMinsAgo": "~ {minutes} min.",
|
||||||
"@contacts_lastSeenMinsAgo": {
|
"@contacts_lastSeenMinsAgo": {
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
"minutes": {
|
"minutes": {
|
||||||
@@ -305,8 +305,8 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"contacts_lastSeenHourAgo": "Última vez que se vio hace 1 hora",
|
"contacts_lastSeenHourAgo": "~ 1 hora",
|
||||||
"contacts_lastSeenHoursAgo": "Última vez visto hace {hours} horas.",
|
"contacts_lastSeenHoursAgo": "~ {hours} horas",
|
||||||
"@contacts_lastSeenHoursAgo": {
|
"@contacts_lastSeenHoursAgo": {
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
"hours": {
|
"hours": {
|
||||||
@@ -314,8 +314,8 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"contacts_lastSeenDayAgo": "Última vez que se vio hace 1 día",
|
"contacts_lastSeenDayAgo": "~ 1 día",
|
||||||
"contacts_lastSeenDaysAgo": "Última vez visto hace {days} días.",
|
"contacts_lastSeenDaysAgo": "~ {days} días",
|
||||||
"@contacts_lastSeenDaysAgo": {
|
"@contacts_lastSeenDaysAgo": {
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
"days": {
|
"days": {
|
||||||
@@ -1856,5 +1856,65 @@
|
|||||||
"discoveredContacts_deleteContactAll": "Eliminar Todos los Contactos Descubiertos",
|
"discoveredContacts_deleteContactAll": "Eliminar Todos los Contactos Descubiertos",
|
||||||
"discoveredContacts_deleteContactAllContent": "¿Está seguro de que desea eliminar todos los contactos descubiertos!",
|
"discoveredContacts_deleteContactAllContent": "¿Está seguro de que desea eliminar todos los contactos descubiertos!",
|
||||||
"map_guessedLocation": "Ubicación estimada",
|
"map_guessedLocation": "Ubicación estimada",
|
||||||
"map_showGuessedLocations": "Mostrar las ubicaciones estimadas de los nodos."
|
"map_showGuessedLocations": "Mostrar las ubicaciones estimadas de los nodos.",
|
||||||
|
"usbScreenTitle": "Conecte mediante USB",
|
||||||
|
"connectionChoiceUsbLabel": "USB",
|
||||||
|
"connectionChoiceBluetoothLabel": "Bluetooth",
|
||||||
|
"usbScreenSubtitle": "Seleccione el dispositivo de serie detectado y conéctelo directamente a su nodo MeshCore.",
|
||||||
|
"usbScreenStatus": "Seleccione un dispositivo USB",
|
||||||
|
"usbScreenNote": "La comunicación serial a través de USB está activa en dispositivos Android compatibles y en plataformas de escritorio.",
|
||||||
|
"usbScreenEmptyState": "No se encontraron dispositivos USB. Conecte uno y vuelva a intentar.",
|
||||||
|
"usbErrorPermissionDenied": "Se denegó el permiso de acceso a través de USB.",
|
||||||
|
"usbErrorDeviceMissing": "El dispositivo USB seleccionado ya no está disponible.",
|
||||||
|
"usbErrorInvalidPort": "Seleccione un dispositivo USB válido.",
|
||||||
|
"usbErrorBusy": "Ya se ha iniciado una solicitud de conexión USB adicional.",
|
||||||
|
"usbErrorNotConnected": "No hay ningún dispositivo USB conectado.",
|
||||||
|
"usbErrorOpenFailed": "No se pudo abrir el dispositivo USB seleccionado.",
|
||||||
|
"usbErrorConnectFailed": "No se pudo conectar con el dispositivo USB seleccionado.",
|
||||||
|
"usbErrorUnsupported": "La comunicación serial a través de USB no está soportada en esta plataforma.",
|
||||||
|
"usbErrorAlreadyActive": "La conexión USB ya está activa.",
|
||||||
|
"usbErrorNoDeviceSelected": "No se ha seleccionado ningún dispositivo USB.",
|
||||||
|
"usbErrorPortClosed": "La conexión USB no está activa.",
|
||||||
|
"usbFallbackDeviceName": "Dispositivo de serie web",
|
||||||
|
"@usbConnectionFailed": {
|
||||||
|
"placeholders": {
|
||||||
|
"error": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"usbStatus_connecting": "Conectándose al dispositivo USB...",
|
||||||
|
"usbStatus_searching": "Buscando dispositivos USB...",
|
||||||
|
"usbStatus_notConnected": "Seleccione un dispositivo USB",
|
||||||
|
"usbConnectionFailed": "Error al conectar mediante USB: {error}",
|
||||||
|
"usbErrorConnectTimedOut": "La conexión ha caducado. Asegúrese de que el dispositivo tenga el firmware USB Companion.",
|
||||||
|
"@tcpStatus_connectingTo": {
|
||||||
|
"placeholders": {
|
||||||
|
"endpoint": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"@tcpConnectionFailed": {
|
||||||
|
"placeholders": {
|
||||||
|
"error": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tcpScreenTitle": "Establecer conexión a través de TCP",
|
||||||
|
"connectionChoiceTcpLabel": "TCP",
|
||||||
|
"tcpHostHint": "192.168.40.10",
|
||||||
|
"tcpHostLabel": "Dirección IP",
|
||||||
|
"tcpPortLabel": "Puerto",
|
||||||
|
"tcpPortHint": "5000",
|
||||||
|
"tcpStatus_notConnected": "Ingrese la dirección final y conecte.",
|
||||||
|
"tcpStatus_connectingTo": "Conectándose a {endpoint}...",
|
||||||
|
"tcpErrorHostRequired": "Se requiere la dirección IP.",
|
||||||
|
"tcpErrorPortInvalid": "El puerto debe estar entre 1 y 65535.",
|
||||||
|
"tcpErrorUnsupported": "El protocolo de transporte TCP no está soportado en esta plataforma.",
|
||||||
|
"tcpErrorTimedOut": "La conexión TCP ha caducado.",
|
||||||
|
"tcpConnectionFailed": "Error en la conexión TCP: {error}",
|
||||||
|
"map_showDiscoveryContacts": "Mostrar Contactos de Descubrimiento",
|
||||||
|
"map_setAsMyLocation": "Establecer mi ubicación"
|
||||||
}
|
}
|
||||||
|
|||||||
+66
-6
@@ -297,7 +297,7 @@
|
|||||||
"contacts_noContactsMatchFilter": "Aucun contact ne correspond à votre filtre.",
|
"contacts_noContactsMatchFilter": "Aucun contact ne correspond à votre filtre.",
|
||||||
"contacts_noMembers": "Aucun membre",
|
"contacts_noMembers": "Aucun membre",
|
||||||
"contacts_lastSeenNow": "Vu maintenant",
|
"contacts_lastSeenNow": "Vu maintenant",
|
||||||
"contacts_lastSeenMinsAgo": "Vu il y a {minutes} minutes",
|
"contacts_lastSeenMinsAgo": "~ {minutes} min.",
|
||||||
"@contacts_lastSeenMinsAgo": {
|
"@contacts_lastSeenMinsAgo": {
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
"minutes": {
|
"minutes": {
|
||||||
@@ -305,8 +305,8 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"contacts_lastSeenHourAgo": "Vu il y a 1 heure",
|
"contacts_lastSeenHourAgo": "~ 1 heure",
|
||||||
"contacts_lastSeenHoursAgo": "Vu il y a {hours} heures",
|
"contacts_lastSeenHoursAgo": "~ {hours} heures",
|
||||||
"@contacts_lastSeenHoursAgo": {
|
"@contacts_lastSeenHoursAgo": {
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
"hours": {
|
"hours": {
|
||||||
@@ -314,8 +314,8 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"contacts_lastSeenDayAgo": "Vu il y a 1 jour",
|
"contacts_lastSeenDayAgo": "~ 1 jour",
|
||||||
"contacts_lastSeenDaysAgo": "Vu il y a {days} jours",
|
"contacts_lastSeenDaysAgo": "~ {days} jours",
|
||||||
"@contacts_lastSeenDaysAgo": {
|
"@contacts_lastSeenDaysAgo": {
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
"days": {
|
"days": {
|
||||||
@@ -1828,5 +1828,65 @@
|
|||||||
"discoveredContacts_deleteContactAll": "Supprimer tous les contacts découverts",
|
"discoveredContacts_deleteContactAll": "Supprimer tous les contacts découverts",
|
||||||
"discoveredContacts_deleteContactAllContent": "Êtes-vous sûr de vouloir supprimer tous les contacts découverts ?",
|
"discoveredContacts_deleteContactAllContent": "Êtes-vous sûr de vouloir supprimer tous les contacts découverts ?",
|
||||||
"map_showGuessedLocations": "Afficher les emplacements des nœuds estimés",
|
"map_showGuessedLocations": "Afficher les emplacements des nœuds estimés",
|
||||||
"map_guessedLocation": "Lieu deviné"
|
"map_guessedLocation": "Lieu deviné",
|
||||||
|
"connectionChoiceUsbLabel": "USB",
|
||||||
|
"usbScreenTitle": "Connectez via USB",
|
||||||
|
"connectionChoiceBluetoothLabel": "Bluetooth",
|
||||||
|
"usbScreenSubtitle": "Sélectionnez un périphérique série détecté et connectez-vous directement à votre nœud MeshCore.",
|
||||||
|
"usbScreenStatus": "Sélectionnez un périphérique USB",
|
||||||
|
"usbScreenNote": "La communication série USB est active sur les appareils Android et les plateformes de bureau compatibles.",
|
||||||
|
"usbScreenEmptyState": "Aucun périphérique USB n'a été trouvé. Veuillez en brancher un et rafraîchir la page.",
|
||||||
|
"usbErrorPermissionDenied": "L'accès via USB a été refusé.",
|
||||||
|
"usbErrorDeviceMissing": "Le périphérique USB sélectionné n'est plus disponible.",
|
||||||
|
"usbErrorInvalidPort": "Sélectionnez un périphérique USB valide.",
|
||||||
|
"usbErrorBusy": "Une autre demande de connexion USB est déjà en cours.",
|
||||||
|
"usbErrorNotConnected": "Aucun appareil USB n'est connecté.",
|
||||||
|
"usbErrorOpenFailed": "Impossible d'ouvrir l'appareil USB sélectionné.",
|
||||||
|
"usbErrorConnectFailed": "Impossible de se connecter à l'appareil USB sélectionné.",
|
||||||
|
"usbErrorUnsupported": "La communication série USB n'est pas prise en charge sur cette plateforme.",
|
||||||
|
"usbErrorAlreadyActive": "Une connexion USB est déjà établie.",
|
||||||
|
"usbErrorNoDeviceSelected": "Aucun appareil USB n'a été sélectionné.",
|
||||||
|
"usbErrorPortClosed": "La connexion USB n'est pas établie.",
|
||||||
|
"usbFallbackDeviceName": "Dispositif de communication série sur le Web",
|
||||||
|
"@usbConnectionFailed": {
|
||||||
|
"placeholders": {
|
||||||
|
"error": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"usbStatus_notConnected": "Sélectionnez un périphérique USB",
|
||||||
|
"usbConnectionFailed": "Échec de la connexion USB : {error}",
|
||||||
|
"usbStatus_connecting": "Connexion au périphérique USB...",
|
||||||
|
"usbStatus_searching": "Recherche de périphériques USB...",
|
||||||
|
"usbErrorConnectTimedOut": "La connexion a expiré. Assurez-vous que l'appareil dispose du firmware USB Companion.",
|
||||||
|
"@tcpStatus_connectingTo": {
|
||||||
|
"placeholders": {
|
||||||
|
"endpoint": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"@tcpConnectionFailed": {
|
||||||
|
"placeholders": {
|
||||||
|
"error": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tcpHostLabel": "Adresse IP",
|
||||||
|
"connectionChoiceTcpLabel": "TCP",
|
||||||
|
"tcpScreenTitle": "Établir une connexion via TCP",
|
||||||
|
"tcpHostHint": "192.168.40.10",
|
||||||
|
"tcpPortLabel": "Port",
|
||||||
|
"tcpPortHint": "5000",
|
||||||
|
"tcpStatus_notConnected": "Entrez l'adresse de destination et connectez-vous.",
|
||||||
|
"tcpStatus_connectingTo": "Connexion à {endpoint}...",
|
||||||
|
"tcpErrorHostRequired": "Une adresse IP est obligatoire.",
|
||||||
|
"tcpErrorPortInvalid": "La taille du port doit être comprise entre 1 et 65535.",
|
||||||
|
"tcpErrorUnsupported": "Le protocole TCP n'est pas pris en charge sur cette plateforme.",
|
||||||
|
"tcpErrorTimedOut": "La connexion TCP a expiré.",
|
||||||
|
"tcpConnectionFailed": "Échec de la connexion TCP : {error}",
|
||||||
|
"map_showDiscoveryContacts": "Afficher les contacts de découverte",
|
||||||
|
"map_setAsMyLocation": "Définir comme ma localisation"
|
||||||
}
|
}
|
||||||
|
|||||||
+61
-1
@@ -1828,5 +1828,65 @@
|
|||||||
"discoveredContacts_deleteContactAllContent": "Sei sicuro di voler eliminare tutti i contatti scoperti?",
|
"discoveredContacts_deleteContactAllContent": "Sei sicuro di voler eliminare tutti i contatti scoperti?",
|
||||||
"discoveredContacts_deleteContactAll": "Eliminare tutti i contatti scoperti",
|
"discoveredContacts_deleteContactAll": "Eliminare tutti i contatti scoperti",
|
||||||
"map_guessedLocation": "Località indovinata",
|
"map_guessedLocation": "Località indovinata",
|
||||||
"map_showGuessedLocations": "Mostra le posizioni stimate dei nodi"
|
"map_showGuessedLocations": "Mostra le posizioni stimate dei nodi",
|
||||||
|
"connectionChoiceBluetoothLabel": "Bluetooth",
|
||||||
|
"usbScreenSubtitle": "Seleziona il dispositivo seriale rilevato e connettilo direttamente al tuo nodo MeshCore.",
|
||||||
|
"connectionChoiceUsbLabel": "USB",
|
||||||
|
"usbScreenTitle": "Connessione tramite USB",
|
||||||
|
"usbScreenStatus": "Seleziona un dispositivo USB",
|
||||||
|
"usbScreenNote": "La comunicazione seriale USB è attiva sui dispositivi Android supportati e sulle piattaforme desktop.",
|
||||||
|
"usbScreenEmptyState": "Nessun dispositivo USB rilevato. Collegare uno e aggiornare.",
|
||||||
|
"usbErrorPermissionDenied": "È stato negato l'accesso tramite USB.",
|
||||||
|
"usbErrorDeviceMissing": "Il dispositivo USB selezionato non è più disponibile.",
|
||||||
|
"usbErrorInvalidPort": "Seleziona un dispositivo USB valido.",
|
||||||
|
"usbErrorBusy": "Un'altra richiesta di connessione tramite USB è già in corso.",
|
||||||
|
"usbErrorNotConnected": "Non è collegato alcun dispositivo USB.",
|
||||||
|
"usbErrorOpenFailed": "Impossibile aprire il dispositivo USB selezionato.",
|
||||||
|
"usbErrorConnectFailed": "Impossibile connettersi al dispositivo USB selezionato.",
|
||||||
|
"usbErrorUnsupported": "La comunicazione seriale tramite USB non è supportata su questa piattaforma.",
|
||||||
|
"usbErrorAlreadyActive": "La connessione USB è già attiva.",
|
||||||
|
"usbErrorNoDeviceSelected": "Non è stato selezionato alcun dispositivo USB.",
|
||||||
|
"usbErrorPortClosed": "La connessione USB non è attiva.",
|
||||||
|
"usbFallbackDeviceName": "Dispositivo per comunicazione seriale su rete",
|
||||||
|
"@usbConnectionFailed": {
|
||||||
|
"placeholders": {
|
||||||
|
"error": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"usbStatus_searching": "Ricerca di dispositivi USB...",
|
||||||
|
"usbConnectionFailed": "Errore nella connessione USB: {error}",
|
||||||
|
"usbStatus_notConnected": "Seleziona un dispositivo USB",
|
||||||
|
"usbStatus_connecting": "Connessione al dispositivo USB...",
|
||||||
|
"usbErrorConnectTimedOut": "La connessione è scaduta. Assicurarsi che il dispositivo abbia il firmware USB Companion.",
|
||||||
|
"@tcpStatus_connectingTo": {
|
||||||
|
"placeholders": {
|
||||||
|
"endpoint": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"@tcpConnectionFailed": {
|
||||||
|
"placeholders": {
|
||||||
|
"error": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tcpHostLabel": "Indirizzo IP",
|
||||||
|
"tcpHostHint": "192.168.40.10",
|
||||||
|
"connectionChoiceTcpLabel": "TCP",
|
||||||
|
"tcpScreenTitle": "Stabilire una connessione tramite TCP",
|
||||||
|
"tcpPortLabel": "Porta",
|
||||||
|
"tcpPortHint": "5000",
|
||||||
|
"tcpStatus_notConnected": "Inserisci l'endpoint e connettiti.",
|
||||||
|
"tcpStatus_connectingTo": "Connessione a {endpoint}...",
|
||||||
|
"tcpErrorHostRequired": "È necessario fornire un indirizzo IP.",
|
||||||
|
"tcpErrorPortInvalid": "La dimensione della porta deve essere compresa tra 1 e 65535.",
|
||||||
|
"tcpErrorUnsupported": "Il protocollo TCP non è supportato su questa piattaforma.",
|
||||||
|
"tcpErrorTimedOut": "La connessione TCP è scaduta.",
|
||||||
|
"tcpConnectionFailed": "Impossibile stabilire la connessione TCP: {error}",
|
||||||
|
"map_showDiscoveryContacts": "Mostra Contatti di Discovery",
|
||||||
|
"map_setAsMyLocation": "Imposta come la mia posizione"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -322,6 +322,228 @@ abstract class AppLocalizations {
|
|||||||
/// **'MeshCore Open'**
|
/// **'MeshCore Open'**
|
||||||
String get scanner_title;
|
String get scanner_title;
|
||||||
|
|
||||||
|
/// No description provided for @connectionChoiceUsbLabel.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'USB'**
|
||||||
|
String get connectionChoiceUsbLabel;
|
||||||
|
|
||||||
|
/// No description provided for @connectionChoiceBluetoothLabel.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Bluetooth'**
|
||||||
|
String get connectionChoiceBluetoothLabel;
|
||||||
|
|
||||||
|
/// No description provided for @connectionChoiceTcpLabel.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'TCP'**
|
||||||
|
String get connectionChoiceTcpLabel;
|
||||||
|
|
||||||
|
/// No description provided for @tcpScreenTitle.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Connect over TCP'**
|
||||||
|
String get tcpScreenTitle;
|
||||||
|
|
||||||
|
/// No description provided for @tcpHostLabel.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'IP Address'**
|
||||||
|
String get tcpHostLabel;
|
||||||
|
|
||||||
|
/// No description provided for @tcpHostHint.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'192.168.40.10'**
|
||||||
|
String get tcpHostHint;
|
||||||
|
|
||||||
|
/// No description provided for @tcpPortLabel.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Port'**
|
||||||
|
String get tcpPortLabel;
|
||||||
|
|
||||||
|
/// No description provided for @tcpPortHint.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'5000'**
|
||||||
|
String get tcpPortHint;
|
||||||
|
|
||||||
|
/// No description provided for @tcpStatus_notConnected.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Enter endpoint and connect'**
|
||||||
|
String get tcpStatus_notConnected;
|
||||||
|
|
||||||
|
/// No description provided for @tcpStatus_connectingTo.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Connecting to {endpoint}...'**
|
||||||
|
String tcpStatus_connectingTo(String endpoint);
|
||||||
|
|
||||||
|
/// No description provided for @tcpErrorHostRequired.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'IP address is required.'**
|
||||||
|
String get tcpErrorHostRequired;
|
||||||
|
|
||||||
|
/// No description provided for @tcpErrorPortInvalid.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Port must be between 1 and 65535.'**
|
||||||
|
String get tcpErrorPortInvalid;
|
||||||
|
|
||||||
|
/// No description provided for @tcpErrorUnsupported.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'TCP transport is not supported on this platform.'**
|
||||||
|
String get tcpErrorUnsupported;
|
||||||
|
|
||||||
|
/// No description provided for @tcpErrorTimedOut.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'TCP connection timed out.'**
|
||||||
|
String get tcpErrorTimedOut;
|
||||||
|
|
||||||
|
/// No description provided for @tcpConnectionFailed.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'TCP connection failed: {error}'**
|
||||||
|
String tcpConnectionFailed(String error);
|
||||||
|
|
||||||
|
/// No description provided for @usbScreenTitle.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Connect over USB'**
|
||||||
|
String get usbScreenTitle;
|
||||||
|
|
||||||
|
/// No description provided for @usbScreenSubtitle.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Choose a detected serial device and connect directly to your MeshCore node.'**
|
||||||
|
String get usbScreenSubtitle;
|
||||||
|
|
||||||
|
/// No description provided for @usbScreenStatus.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Select a USB device'**
|
||||||
|
String get usbScreenStatus;
|
||||||
|
|
||||||
|
/// No description provided for @usbScreenNote.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'USB serial is active on supported Android devices and desktop platforms.'**
|
||||||
|
String get usbScreenNote;
|
||||||
|
|
||||||
|
/// No description provided for @usbScreenEmptyState.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'No USB devices found. Plug one in and refresh.'**
|
||||||
|
String get usbScreenEmptyState;
|
||||||
|
|
||||||
|
/// No description provided for @usbErrorPermissionDenied.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'USB permission was denied.'**
|
||||||
|
String get usbErrorPermissionDenied;
|
||||||
|
|
||||||
|
/// No description provided for @usbErrorDeviceMissing.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'The selected USB device is no longer available.'**
|
||||||
|
String get usbErrorDeviceMissing;
|
||||||
|
|
||||||
|
/// No description provided for @usbErrorInvalidPort.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Select a valid USB device.'**
|
||||||
|
String get usbErrorInvalidPort;
|
||||||
|
|
||||||
|
/// No description provided for @usbErrorBusy.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Another USB connection request is already in progress.'**
|
||||||
|
String get usbErrorBusy;
|
||||||
|
|
||||||
|
/// No description provided for @usbErrorNotConnected.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'No USB device is connected.'**
|
||||||
|
String get usbErrorNotConnected;
|
||||||
|
|
||||||
|
/// No description provided for @usbErrorOpenFailed.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Failed to open the selected USB device.'**
|
||||||
|
String get usbErrorOpenFailed;
|
||||||
|
|
||||||
|
/// No description provided for @usbErrorConnectFailed.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Failed to connect to the selected USB device.'**
|
||||||
|
String get usbErrorConnectFailed;
|
||||||
|
|
||||||
|
/// No description provided for @usbErrorUnsupported.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'USB serial is not supported on this platform.'**
|
||||||
|
String get usbErrorUnsupported;
|
||||||
|
|
||||||
|
/// No description provided for @usbErrorAlreadyActive.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'A USB connection is already active.'**
|
||||||
|
String get usbErrorAlreadyActive;
|
||||||
|
|
||||||
|
/// No description provided for @usbErrorNoDeviceSelected.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'No USB device was selected.'**
|
||||||
|
String get usbErrorNoDeviceSelected;
|
||||||
|
|
||||||
|
/// No description provided for @usbErrorPortClosed.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'The USB connection is not open.'**
|
||||||
|
String get usbErrorPortClosed;
|
||||||
|
|
||||||
|
/// No description provided for @usbErrorConnectTimedOut.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Connection timed out. Make sure the device has USB Companion firmware.'**
|
||||||
|
String get usbErrorConnectTimedOut;
|
||||||
|
|
||||||
|
/// No description provided for @usbFallbackDeviceName.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Web Serial Device'**
|
||||||
|
String get usbFallbackDeviceName;
|
||||||
|
|
||||||
|
/// No description provided for @usbStatus_notConnected.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Select a USB device'**
|
||||||
|
String get usbStatus_notConnected;
|
||||||
|
|
||||||
|
/// No description provided for @usbStatus_connecting.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Connecting to USB device...'**
|
||||||
|
String get usbStatus_connecting;
|
||||||
|
|
||||||
|
/// No description provided for @usbStatus_searching.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Searching for USB devices...'**
|
||||||
|
String get usbStatus_searching;
|
||||||
|
|
||||||
|
/// No description provided for @usbConnectionFailed.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'USB connection failed: {error}'**
|
||||||
|
String usbConnectionFailed(String error);
|
||||||
|
|
||||||
/// No description provided for @scanner_scanning.
|
/// No description provided for @scanner_scanning.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
@@ -1519,37 +1741,37 @@ abstract class AppLocalizations {
|
|||||||
/// No description provided for @contacts_lastSeenNow.
|
/// No description provided for @contacts_lastSeenNow.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Last seen now'**
|
/// **'recently'**
|
||||||
String get contacts_lastSeenNow;
|
String get contacts_lastSeenNow;
|
||||||
|
|
||||||
/// No description provided for @contacts_lastSeenMinsAgo.
|
/// No description provided for @contacts_lastSeenMinsAgo.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Last seen {minutes} mins ago'**
|
/// **'~ {minutes} min.'**
|
||||||
String contacts_lastSeenMinsAgo(int minutes);
|
String contacts_lastSeenMinsAgo(int minutes);
|
||||||
|
|
||||||
/// No description provided for @contacts_lastSeenHourAgo.
|
/// No description provided for @contacts_lastSeenHourAgo.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Last seen 1 hour ago'**
|
/// **'~ 1 hour'**
|
||||||
String get contacts_lastSeenHourAgo;
|
String get contacts_lastSeenHourAgo;
|
||||||
|
|
||||||
/// No description provided for @contacts_lastSeenHoursAgo.
|
/// No description provided for @contacts_lastSeenHoursAgo.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Last seen {hours} hours ago'**
|
/// **'~ {hours} hours'**
|
||||||
String contacts_lastSeenHoursAgo(int hours);
|
String contacts_lastSeenHoursAgo(int hours);
|
||||||
|
|
||||||
/// No description provided for @contacts_lastSeenDayAgo.
|
/// No description provided for @contacts_lastSeenDayAgo.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Last seen 1 day ago'**
|
/// **'~ 1 day'**
|
||||||
String get contacts_lastSeenDayAgo;
|
String get contacts_lastSeenDayAgo;
|
||||||
|
|
||||||
/// No description provided for @contacts_lastSeenDaysAgo.
|
/// No description provided for @contacts_lastSeenDaysAgo.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Last seen {days} days ago'**
|
/// **'~ {days} days'**
|
||||||
String contacts_lastSeenDaysAgo(int days);
|
String contacts_lastSeenDaysAgo(int days);
|
||||||
|
|
||||||
/// No description provided for @channels_title.
|
/// No description provided for @channels_title.
|
||||||
@@ -2524,6 +2746,12 @@ abstract class AppLocalizations {
|
|||||||
/// **'Share marker here'**
|
/// **'Share marker here'**
|
||||||
String get map_shareMarkerHere;
|
String get map_shareMarkerHere;
|
||||||
|
|
||||||
|
/// No description provided for @map_setAsMyLocation.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Set as my location'**
|
||||||
|
String get map_setAsMyLocation;
|
||||||
|
|
||||||
/// No description provided for @map_pinLabel.
|
/// No description provided for @map_pinLabel.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
@@ -2644,6 +2872,12 @@ abstract class AppLocalizations {
|
|||||||
/// **'Show guessed node locations'**
|
/// **'Show guessed node locations'**
|
||||||
String get map_showGuessedLocations;
|
String get map_showGuessedLocations;
|
||||||
|
|
||||||
|
/// No description provided for @map_showDiscoveryContacts.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Show Discovery Contacts'**
|
||||||
|
String get map_showDiscoveryContacts;
|
||||||
|
|
||||||
/// No description provided for @map_guessedLocation.
|
/// No description provided for @map_guessedLocation.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|||||||
@@ -111,6 +111,134 @@ class AppLocalizationsBg extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get scanner_title => 'MeshCore Open';
|
String get scanner_title => 'MeshCore Open';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get connectionChoiceUsbLabel => 'USB';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get connectionChoiceBluetoothLabel => 'Bluetooth';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get connectionChoiceTcpLabel => 'TCP';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpScreenTitle => 'Свържете се чрез TCP';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpHostLabel => 'IP адрес';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpHostHint => '192.168.40.10';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpPortLabel => 'Пристанище';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpPortHint => '5000';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpStatus_notConnected => 'Въведете крайната точка и свържете се.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String tcpStatus_connectingTo(String endpoint) {
|
||||||
|
return 'Свързване към $endpoint...';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpErrorHostRequired => 'Необходим е IP адрес.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpErrorPortInvalid => 'Портът трябва да бъде между 1 и 65535.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpErrorUnsupported =>
|
||||||
|
'Транспортът чрез TCP не се поддържа на тази платформа.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpErrorTimedOut => 'Връзката TCP изтекла.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String tcpConnectionFailed(String error) {
|
||||||
|
return 'Неуспешно е установено TCP връзката: $error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbScreenTitle => 'Свържете се чрез USB';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbScreenSubtitle =>
|
||||||
|
'Изберете открития сериен уред и свържете директно към вашия MeshCore възел.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbScreenStatus => 'Изберете USB устройство';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbScreenNote =>
|
||||||
|
'USB серийната връзка е активна на поддържаните Android устройства и настолни платформи.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbScreenEmptyState =>
|
||||||
|
'Няма открити USB устройства. Включете едно и опитайте отново.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorPermissionDenied => 'Не беше разрешено достъпът през USB.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorDeviceMissing =>
|
||||||
|
'Избраното USB устройство вече не е налично.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorInvalidPort => 'Изберете валитно USB устройство.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorBusy =>
|
||||||
|
'Друг мол за свързване през USB вече е в процес на изпълнение.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorNotConnected => 'Няма свързано USB устройство.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorOpenFailed =>
|
||||||
|
'Не успях да отворя избраното USB устройство.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorConnectFailed =>
|
||||||
|
'Не успях да се свържа с избраното USB устройство.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorUnsupported =>
|
||||||
|
'USB серийната комуникация не се поддържа на тази платформа.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorAlreadyActive => 'USB връзката вече е активирана.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorNoDeviceSelected => 'Няма избран USB устройство.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorPortClosed => 'USB връзката не е активна.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorConnectTimedOut =>
|
||||||
|
'Връзката прекъсна. Уверете се, че устройството има софтуер за USB връзка.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbFallbackDeviceName =>
|
||||||
|
'Устройство за четене на уеб серийни данни';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbStatus_notConnected => 'Изберете USB устройство';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbStatus_connecting => 'Свързване към USB устройство...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbStatus_searching => 'Търсене на USB устройства...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String usbConnectionFailed(String error) {
|
||||||
|
return 'Неуспешно свързване през USB: $error';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get scanner_scanning => 'Сканиране за устройства...';
|
String get scanner_scanning => 'Сканиране за устройства...';
|
||||||
|
|
||||||
@@ -1383,6 +1511,9 @@ class AppLocalizationsBg extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get map_shareMarkerHere => 'Споделете маркер тук';
|
String get map_shareMarkerHere => 'Споделете маркер тук';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_setAsMyLocation => 'Задайте като моя местоположение';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get map_pinLabel => 'Етикетиране на пин';
|
String get map_pinLabel => 'Етикетиране на пин';
|
||||||
|
|
||||||
@@ -1447,6 +1578,9 @@ class AppLocalizationsBg extends AppLocalizations {
|
|||||||
String get map_showGuessedLocations =>
|
String get map_showGuessedLocations =>
|
||||||
'Покажете местоположенията на предположените възли.';
|
'Покажете местоположенията на предположените възли.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_showDiscoveryContacts => 'Покажи контакти за откриване';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get map_guessedLocation => 'Предполагано местоположение';
|
String get map_guessedLocation => 'Предполагано местоположение';
|
||||||
|
|
||||||
|
|||||||
@@ -111,6 +111,137 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get scanner_title => 'MeshCore Open';
|
String get scanner_title => 'MeshCore Open';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get connectionChoiceUsbLabel => 'USB';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get connectionChoiceBluetoothLabel => 'Bluetooth';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get connectionChoiceTcpLabel => 'TCP';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpScreenTitle => 'Verbinden über TCP';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpHostLabel => 'IP-Adresse';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpHostHint => '192.168.40.10';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpPortLabel => 'Port';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpPortHint => '5000';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpStatus_notConnected =>
|
||||||
|
'Geben Sie den Endpunkt ein und verbinden Sie sich.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String tcpStatus_connectingTo(String endpoint) {
|
||||||
|
return 'Verbindung zu $endpoint...';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpErrorHostRequired => 'Eine IP-Adresse ist erforderlich.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpErrorPortInvalid =>
|
||||||
|
'Die Portnummer muss zwischen 1 und 65535 liegen.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpErrorUnsupported =>
|
||||||
|
'Die TCP-Übertragung wird auf dieser Plattform nicht unterstützt.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpErrorTimedOut => 'Die TCP-Verbindung ist abgelaufen.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String tcpConnectionFailed(String error) {
|
||||||
|
return 'Fehler beim TCP-Verbindungsaufbau: $error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbScreenTitle => 'Verbinden über USB';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbScreenSubtitle =>
|
||||||
|
'Wählen Sie ein erkannten serielles Gerät aus und verbinden Sie es direkt mit Ihrem MeshCore-Knoten.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbScreenStatus => 'Wählen Sie ein USB-Gerät aus';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbScreenNote =>
|
||||||
|
'Die USB-Serielle Schnittstelle ist auf unterstützten Android-Geräten und Desktop-Plattformen aktiv.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbScreenEmptyState =>
|
||||||
|
'Keine USB-Geräte gefunden. Schließen Sie eines an und aktualisieren Sie.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorPermissionDenied =>
|
||||||
|
'Die USB-Berechtigung wurde abgelehnt.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorDeviceMissing =>
|
||||||
|
'Das ausgewählte USB-Gerät ist nicht mehr verfügbar.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorInvalidPort => 'Wählen Sie ein gültiges USB-Gerät aus.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorBusy =>
|
||||||
|
'Eine weitere Anfrage für eine USB-Verbindung ist bereits in Bearbeitung.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorNotConnected => 'Es ist kein USB-Gerät angeschlossen.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorOpenFailed =>
|
||||||
|
'Fehlgeschlagen beim Öffnen des ausgewählten USB-Geräts.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorConnectFailed =>
|
||||||
|
'Keine Verbindung zum ausgewählten USB-Gerät hergestellt.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorUnsupported =>
|
||||||
|
'Die USB-Serielle Schnittstelle wird auf dieser Plattform nicht unterstützt.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorAlreadyActive =>
|
||||||
|
'Eine USB-Verbindung ist bereits hergestellt.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorNoDeviceSelected => 'Kein USB-Gerät wurde ausgewählt.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorPortClosed => 'Die USB-Verbindung ist nicht aktiv.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorConnectTimedOut =>
|
||||||
|
'Verbindung konnte nicht hergestellt werden. Stellen Sie sicher, dass das Gerät die entsprechende USB-Firmware enthält.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbFallbackDeviceName => 'Web-Serielle Geräte';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbStatus_notConnected => 'Wählen Sie ein USB-Gerät aus';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbStatus_connecting => 'Verbindung zum USB-Gerät...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbStatus_searching => 'Suche nach USB-Geräten...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String usbConnectionFailed(String error) {
|
||||||
|
return 'Fehler beim USB-Verbindungsaufbau: $error';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get scanner_scanning => 'Scannen nach Geräten...';
|
String get scanner_scanning => 'Scannen nach Geräten...';
|
||||||
|
|
||||||
@@ -787,27 +918,27 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
String get contacts_noMembers => 'Keine Mitglieder';
|
String get contacts_noMembers => 'Keine Mitglieder';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get contacts_lastSeenNow => 'gerade gesehen';
|
String get contacts_lastSeenNow => 'kürzlich';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String contacts_lastSeenMinsAgo(int minutes) {
|
String contacts_lastSeenMinsAgo(int minutes) {
|
||||||
return 'Letzte Sichtung vor $minutes Minuten.';
|
return '~ $minutes Min.';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get contacts_lastSeenHourAgo => 'Letzte Sichtung vor 1 Stunde.';
|
String get contacts_lastSeenHourAgo => '~ 1 Std.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String contacts_lastSeenHoursAgo(int hours) {
|
String contacts_lastSeenHoursAgo(int hours) {
|
||||||
return 'Letzte Sichtung vor $hours Stunden.';
|
return '~ $hours Std.';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get contacts_lastSeenDayAgo => 'Letzte Sichtung vor 1 Tag';
|
String get contacts_lastSeenDayAgo => '~ 1 Tag';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String contacts_lastSeenDaysAgo(int days) {
|
String contacts_lastSeenDaysAgo(int days) {
|
||||||
return 'Letzte Sichtung $days Tage zuvor';
|
return '~ $days Tage';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -1382,6 +1513,9 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get map_shareMarkerHere => 'Teilen Sie den Marker hier.';
|
String get map_shareMarkerHere => 'Teilen Sie den Marker hier.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_setAsMyLocation => 'Als meine aktuelle Position festlegen';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get map_pinLabel => 'Pin Name';
|
String get map_pinLabel => 'Pin Name';
|
||||||
|
|
||||||
@@ -1446,6 +1580,9 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
String get map_showGuessedLocations =>
|
String get map_showGuessedLocations =>
|
||||||
'Zeige die vermuteten Knotenpositionen';
|
'Zeige die vermuteten Knotenpositionen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_showDiscoveryContacts => 'Entdeckungs-Kontakte anzeigen';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get map_guessedLocation => 'Geschätzter Ort';
|
String get map_guessedLocation => 'Geschätzter Ort';
|
||||||
|
|
||||||
|
|||||||
@@ -111,6 +111,132 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get scanner_title => 'MeshCore Open';
|
String get scanner_title => 'MeshCore Open';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get connectionChoiceUsbLabel => 'USB';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get connectionChoiceBluetoothLabel => 'Bluetooth';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get connectionChoiceTcpLabel => 'TCP';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpScreenTitle => 'Connect over TCP';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpHostLabel => 'IP Address';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpHostHint => '192.168.40.10';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpPortLabel => 'Port';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpPortHint => '5000';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpStatus_notConnected => 'Enter endpoint and connect';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String tcpStatus_connectingTo(String endpoint) {
|
||||||
|
return 'Connecting to $endpoint...';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpErrorHostRequired => 'IP address is required.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpErrorPortInvalid => 'Port must be between 1 and 65535.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpErrorUnsupported =>
|
||||||
|
'TCP transport is not supported on this platform.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpErrorTimedOut => 'TCP connection timed out.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String tcpConnectionFailed(String error) {
|
||||||
|
return 'TCP connection failed: $error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbScreenTitle => 'Connect over USB';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbScreenSubtitle =>
|
||||||
|
'Choose a detected serial device and connect directly to your MeshCore node.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbScreenStatus => 'Select a USB device';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbScreenNote =>
|
||||||
|
'USB serial is active on supported Android devices and desktop platforms.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbScreenEmptyState =>
|
||||||
|
'No USB devices found. Plug one in and refresh.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorPermissionDenied => 'USB permission was denied.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorDeviceMissing =>
|
||||||
|
'The selected USB device is no longer available.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorInvalidPort => 'Select a valid USB device.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorBusy =>
|
||||||
|
'Another USB connection request is already in progress.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorNotConnected => 'No USB device is connected.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorOpenFailed => 'Failed to open the selected USB device.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorConnectFailed =>
|
||||||
|
'Failed to connect to the selected USB device.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorUnsupported =>
|
||||||
|
'USB serial is not supported on this platform.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorAlreadyActive => 'A USB connection is already active.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorNoDeviceSelected => 'No USB device was selected.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorPortClosed => 'The USB connection is not open.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorConnectTimedOut =>
|
||||||
|
'Connection timed out. Make sure the device has USB Companion firmware.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbFallbackDeviceName => 'Web Serial Device';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbStatus_notConnected => 'Select a USB device';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbStatus_connecting => 'Connecting to USB device...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbStatus_searching => 'Searching for USB devices...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String usbConnectionFailed(String error) {
|
||||||
|
return 'USB connection failed: $error';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get scanner_scanning => 'Scanning for devices...';
|
String get scanner_scanning => 'Scanning for devices...';
|
||||||
|
|
||||||
@@ -778,27 +904,27 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
String get contacts_noMembers => 'No members';
|
String get contacts_noMembers => 'No members';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get contacts_lastSeenNow => 'Last seen now';
|
String get contacts_lastSeenNow => 'recently';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String contacts_lastSeenMinsAgo(int minutes) {
|
String contacts_lastSeenMinsAgo(int minutes) {
|
||||||
return 'Last seen $minutes mins ago';
|
return '~ $minutes min.';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get contacts_lastSeenHourAgo => 'Last seen 1 hour ago';
|
String get contacts_lastSeenHourAgo => '~ 1 hour';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String contacts_lastSeenHoursAgo(int hours) {
|
String contacts_lastSeenHoursAgo(int hours) {
|
||||||
return 'Last seen $hours hours ago';
|
return '~ $hours hours';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get contacts_lastSeenDayAgo => 'Last seen 1 day ago';
|
String get contacts_lastSeenDayAgo => '~ 1 day';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String contacts_lastSeenDaysAgo(int days) {
|
String contacts_lastSeenDaysAgo(int days) {
|
||||||
return 'Last seen $days days ago';
|
return '~ $days days';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -1361,6 +1487,9 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get map_shareMarkerHere => 'Share marker here';
|
String get map_shareMarkerHere => 'Share marker here';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_setAsMyLocation => 'Set as my location';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get map_pinLabel => 'Pin label';
|
String get map_pinLabel => 'Pin label';
|
||||||
|
|
||||||
@@ -1424,6 +1553,9 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get map_showGuessedLocations => 'Show guessed node locations';
|
String get map_showGuessedLocations => 'Show guessed node locations';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_showDiscoveryContacts => 'Show Discovery Contacts';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get map_guessedLocation => 'Guessed location';
|
String get map_guessedLocation => 'Guessed location';
|
||||||
|
|
||||||
|
|||||||
@@ -111,6 +111,135 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get scanner_title => 'MeshCore Open';
|
String get scanner_title => 'MeshCore Open';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get connectionChoiceUsbLabel => 'USB';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get connectionChoiceBluetoothLabel => 'Bluetooth';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get connectionChoiceTcpLabel => 'TCP';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpScreenTitle => 'Establecer conexión a través de TCP';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpHostLabel => 'Dirección IP';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpHostHint => '192.168.40.10';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpPortLabel => 'Puerto';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpPortHint => '5000';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpStatus_notConnected => 'Ingrese la dirección final y conecte.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String tcpStatus_connectingTo(String endpoint) {
|
||||||
|
return 'Conectándose a $endpoint...';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpErrorHostRequired => 'Se requiere la dirección IP.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpErrorPortInvalid => 'El puerto debe estar entre 1 y 65535.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpErrorUnsupported =>
|
||||||
|
'El protocolo de transporte TCP no está soportado en esta plataforma.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpErrorTimedOut => 'La conexión TCP ha caducado.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String tcpConnectionFailed(String error) {
|
||||||
|
return 'Error en la conexión TCP: $error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbScreenTitle => 'Conecte mediante USB';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbScreenSubtitle =>
|
||||||
|
'Seleccione el dispositivo de serie detectado y conéctelo directamente a su nodo MeshCore.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbScreenStatus => 'Seleccione un dispositivo USB';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbScreenNote =>
|
||||||
|
'La comunicación serial a través de USB está activa en dispositivos Android compatibles y en plataformas de escritorio.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbScreenEmptyState =>
|
||||||
|
'No se encontraron dispositivos USB. Conecte uno y vuelva a intentar.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorPermissionDenied =>
|
||||||
|
'Se denegó el permiso de acceso a través de USB.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorDeviceMissing =>
|
||||||
|
'El dispositivo USB seleccionado ya no está disponible.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorInvalidPort => 'Seleccione un dispositivo USB válido.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorBusy =>
|
||||||
|
'Ya se ha iniciado una solicitud de conexión USB adicional.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorNotConnected => 'No hay ningún dispositivo USB conectado.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorOpenFailed =>
|
||||||
|
'No se pudo abrir el dispositivo USB seleccionado.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorConnectFailed =>
|
||||||
|
'No se pudo conectar con el dispositivo USB seleccionado.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorUnsupported =>
|
||||||
|
'La comunicación serial a través de USB no está soportada en esta plataforma.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorAlreadyActive => 'La conexión USB ya está activa.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorNoDeviceSelected =>
|
||||||
|
'No se ha seleccionado ningún dispositivo USB.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorPortClosed => 'La conexión USB no está activa.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorConnectTimedOut =>
|
||||||
|
'La conexión ha caducado. Asegúrese de que el dispositivo tenga el firmware USB Companion.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbFallbackDeviceName => 'Dispositivo de serie web';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbStatus_notConnected => 'Seleccione un dispositivo USB';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbStatus_connecting => 'Conectándose al dispositivo USB...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbStatus_searching => 'Buscando dispositivos USB...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String usbConnectionFailed(String error) {
|
||||||
|
return 'Error al conectar mediante USB: $error';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get scanner_scanning => 'Escaneando dispositivos...';
|
String get scanner_scanning => 'Escaneando dispositivos...';
|
||||||
|
|
||||||
@@ -792,23 +921,23 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String contacts_lastSeenMinsAgo(int minutes) {
|
String contacts_lastSeenMinsAgo(int minutes) {
|
||||||
return 'Última vez visto hace $minutes minutos.';
|
return '~ $minutes min.';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get contacts_lastSeenHourAgo => 'Última vez que se vio hace 1 hora';
|
String get contacts_lastSeenHourAgo => '~ 1 hora';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String contacts_lastSeenHoursAgo(int hours) {
|
String contacts_lastSeenHoursAgo(int hours) {
|
||||||
return 'Última vez visto hace $hours horas.';
|
return '~ $hours horas';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get contacts_lastSeenDayAgo => 'Última vez que se vio hace 1 día';
|
String get contacts_lastSeenDayAgo => '~ 1 día';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String contacts_lastSeenDaysAgo(int days) {
|
String contacts_lastSeenDaysAgo(int days) {
|
||||||
return 'Última vez visto hace $days días.';
|
return '~ $days días';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -1380,6 +1509,9 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get map_shareMarkerHere => 'Compartir marcador aquí';
|
String get map_shareMarkerHere => 'Compartir marcador aquí';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_setAsMyLocation => 'Establecer mi ubicación';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get map_pinLabel => 'Etiqueta de marcador';
|
String get map_pinLabel => 'Etiqueta de marcador';
|
||||||
|
|
||||||
@@ -1444,6 +1576,9 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
String get map_showGuessedLocations =>
|
String get map_showGuessedLocations =>
|
||||||
'Mostrar las ubicaciones estimadas de los nodos.';
|
'Mostrar las ubicaciones estimadas de los nodos.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_showDiscoveryContacts => 'Mostrar Contactos de Descubrimiento';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get map_guessedLocation => 'Ubicación estimada';
|
String get map_guessedLocation => 'Ubicación estimada';
|
||||||
|
|
||||||
|
|||||||
@@ -111,6 +111,137 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get scanner_title => 'MeshCore Open';
|
String get scanner_title => 'MeshCore Open';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get connectionChoiceUsbLabel => 'USB';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get connectionChoiceBluetoothLabel => 'Bluetooth';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get connectionChoiceTcpLabel => 'TCP';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpScreenTitle => 'Établir une connexion via TCP';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpHostLabel => 'Adresse IP';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpHostHint => '192.168.40.10';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpPortLabel => 'Port';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpPortHint => '5000';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpStatus_notConnected =>
|
||||||
|
'Entrez l\'adresse de destination et connectez-vous.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String tcpStatus_connectingTo(String endpoint) {
|
||||||
|
return 'Connexion à $endpoint...';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpErrorHostRequired => 'Une adresse IP est obligatoire.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpErrorPortInvalid =>
|
||||||
|
'La taille du port doit être comprise entre 1 et 65535.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpErrorUnsupported =>
|
||||||
|
'Le protocole TCP n\'est pas pris en charge sur cette plateforme.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpErrorTimedOut => 'La connexion TCP a expiré.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String tcpConnectionFailed(String error) {
|
||||||
|
return 'Échec de la connexion TCP : $error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbScreenTitle => 'Connectez via USB';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbScreenSubtitle =>
|
||||||
|
'Sélectionnez un périphérique série détecté et connectez-vous directement à votre nœud MeshCore.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbScreenStatus => 'Sélectionnez un périphérique USB';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbScreenNote =>
|
||||||
|
'La communication série USB est active sur les appareils Android et les plateformes de bureau compatibles.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbScreenEmptyState =>
|
||||||
|
'Aucun périphérique USB n\'a été trouvé. Veuillez en brancher un et rafraîchir la page.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorPermissionDenied => 'L\'accès via USB a été refusé.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorDeviceMissing =>
|
||||||
|
'Le périphérique USB sélectionné n\'est plus disponible.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorInvalidPort => 'Sélectionnez un périphérique USB valide.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorBusy =>
|
||||||
|
'Une autre demande de connexion USB est déjà en cours.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorNotConnected => 'Aucun appareil USB n\'est connecté.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorOpenFailed =>
|
||||||
|
'Impossible d\'ouvrir l\'appareil USB sélectionné.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorConnectFailed =>
|
||||||
|
'Impossible de se connecter à l\'appareil USB sélectionné.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorUnsupported =>
|
||||||
|
'La communication série USB n\'est pas prise en charge sur cette plateforme.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorAlreadyActive => 'Une connexion USB est déjà établie.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorNoDeviceSelected =>
|
||||||
|
'Aucun appareil USB n\'a été sélectionné.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorPortClosed => 'La connexion USB n\'est pas établie.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorConnectTimedOut =>
|
||||||
|
'La connexion a expiré. Assurez-vous que l\'appareil dispose du firmware USB Companion.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbFallbackDeviceName =>
|
||||||
|
'Dispositif de communication série sur le Web';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbStatus_notConnected => 'Sélectionnez un périphérique USB';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbStatus_connecting => 'Connexion au périphérique USB...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbStatus_searching => 'Recherche de périphériques USB...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String usbConnectionFailed(String error) {
|
||||||
|
return 'Échec de la connexion USB : $error';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get scanner_scanning => 'Recherche de périphériques...';
|
String get scanner_scanning => 'Recherche de périphériques...';
|
||||||
|
|
||||||
@@ -794,23 +925,23 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String contacts_lastSeenMinsAgo(int minutes) {
|
String contacts_lastSeenMinsAgo(int minutes) {
|
||||||
return 'Vu il y a $minutes minutes';
|
return '~ $minutes min.';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get contacts_lastSeenHourAgo => 'Vu il y a 1 heure';
|
String get contacts_lastSeenHourAgo => '~ 1 heure';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String contacts_lastSeenHoursAgo(int hours) {
|
String contacts_lastSeenHoursAgo(int hours) {
|
||||||
return 'Vu il y a $hours heures';
|
return '~ $hours heures';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get contacts_lastSeenDayAgo => 'Vu il y a 1 jour';
|
String get contacts_lastSeenDayAgo => '~ 1 jour';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String contacts_lastSeenDaysAgo(int days) {
|
String contacts_lastSeenDaysAgo(int days) {
|
||||||
return 'Vu il y a $days jours';
|
return '~ $days jours';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -1387,6 +1518,9 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get map_shareMarkerHere => 'Partager le marqueur ici';
|
String get map_shareMarkerHere => 'Partager le marqueur ici';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_setAsMyLocation => 'Définir comme ma localisation';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get map_pinLabel => 'Étiquete de repin';
|
String get map_pinLabel => 'Étiquete de repin';
|
||||||
|
|
||||||
@@ -1451,6 +1585,9 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
String get map_showGuessedLocations =>
|
String get map_showGuessedLocations =>
|
||||||
'Afficher les emplacements des nœuds estimés';
|
'Afficher les emplacements des nœuds estimés';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_showDiscoveryContacts => 'Afficher les contacts de découverte';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get map_guessedLocation => 'Lieu deviné';
|
String get map_guessedLocation => 'Lieu deviné';
|
||||||
|
|
||||||
|
|||||||
@@ -111,6 +111,137 @@ class AppLocalizationsIt extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get scanner_title => 'MeshCore Open';
|
String get scanner_title => 'MeshCore Open';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get connectionChoiceUsbLabel => 'USB';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get connectionChoiceBluetoothLabel => 'Bluetooth';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get connectionChoiceTcpLabel => 'TCP';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpScreenTitle => 'Stabilire una connessione tramite TCP';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpHostLabel => 'Indirizzo IP';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpHostHint => '192.168.40.10';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpPortLabel => 'Porta';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpPortHint => '5000';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpStatus_notConnected => 'Inserisci l\'endpoint e connettiti.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String tcpStatus_connectingTo(String endpoint) {
|
||||||
|
return 'Connessione a $endpoint...';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpErrorHostRequired => 'È necessario fornire un indirizzo IP.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpErrorPortInvalid =>
|
||||||
|
'La dimensione della porta deve essere compresa tra 1 e 65535.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpErrorUnsupported =>
|
||||||
|
'Il protocollo TCP non è supportato su questa piattaforma.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpErrorTimedOut => 'La connessione TCP è scaduta.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String tcpConnectionFailed(String error) {
|
||||||
|
return 'Impossibile stabilire la connessione TCP: $error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbScreenTitle => 'Connessione tramite USB';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbScreenSubtitle =>
|
||||||
|
'Seleziona il dispositivo seriale rilevato e connettilo direttamente al tuo nodo MeshCore.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbScreenStatus => 'Seleziona un dispositivo USB';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbScreenNote =>
|
||||||
|
'La comunicazione seriale USB è attiva sui dispositivi Android supportati e sulle piattaforme desktop.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbScreenEmptyState =>
|
||||||
|
'Nessun dispositivo USB rilevato. Collegare uno e aggiornare.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorPermissionDenied =>
|
||||||
|
'È stato negato l\'accesso tramite USB.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorDeviceMissing =>
|
||||||
|
'Il dispositivo USB selezionato non è più disponibile.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorInvalidPort => 'Seleziona un dispositivo USB valido.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorBusy =>
|
||||||
|
'Un\'altra richiesta di connessione tramite USB è già in corso.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorNotConnected => 'Non è collegato alcun dispositivo USB.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorOpenFailed =>
|
||||||
|
'Impossibile aprire il dispositivo USB selezionato.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorConnectFailed =>
|
||||||
|
'Impossibile connettersi al dispositivo USB selezionato.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorUnsupported =>
|
||||||
|
'La comunicazione seriale tramite USB non è supportata su questa piattaforma.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorAlreadyActive => 'La connessione USB è già attiva.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorNoDeviceSelected =>
|
||||||
|
'Non è stato selezionato alcun dispositivo USB.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorPortClosed => 'La connessione USB non è attiva.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorConnectTimedOut =>
|
||||||
|
'La connessione è scaduta. Assicurarsi che il dispositivo abbia il firmware USB Companion.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbFallbackDeviceName =>
|
||||||
|
'Dispositivo per comunicazione seriale su rete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbStatus_notConnected => 'Seleziona un dispositivo USB';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbStatus_connecting => 'Connessione al dispositivo USB...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbStatus_searching => 'Ricerca di dispositivi USB...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String usbConnectionFailed(String error) {
|
||||||
|
return 'Errore nella connessione USB: $error';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get scanner_scanning => 'Scansione in corso per i dispositivi...';
|
String get scanner_scanning => 'Scansione in corso per i dispositivi...';
|
||||||
|
|
||||||
@@ -1379,6 +1510,9 @@ class AppLocalizationsIt extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get map_shareMarkerHere => 'Condividi marcatore qui';
|
String get map_shareMarkerHere => 'Condividi marcatore qui';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_setAsMyLocation => 'Imposta come la mia posizione';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get map_pinLabel => 'Etichetta PIN';
|
String get map_pinLabel => 'Etichetta PIN';
|
||||||
|
|
||||||
@@ -1442,6 +1576,9 @@ class AppLocalizationsIt extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get map_showGuessedLocations => 'Mostra le posizioni stimate dei nodi';
|
String get map_showGuessedLocations => 'Mostra le posizioni stimate dei nodi';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_showDiscoveryContacts => 'Mostra Contatti di Discovery';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get map_guessedLocation => 'Località indovinata';
|
String get map_guessedLocation => 'Località indovinata';
|
||||||
|
|
||||||
|
|||||||
@@ -111,6 +111,134 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get scanner_title => 'MeshCore Open';
|
String get scanner_title => 'MeshCore Open';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get connectionChoiceUsbLabel => 'USB';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get connectionChoiceBluetoothLabel => 'Bluetooth';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get connectionChoiceTcpLabel => 'TCP';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpScreenTitle => 'Verbind via TCP';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpHostLabel => 'IP-adres';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpHostHint => '192.168.40.10';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpPortLabel => 'Poort';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpPortHint => '5000';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpStatus_notConnected => 'Voer het eindpunt in en verbind';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String tcpStatus_connectingTo(String endpoint) {
|
||||||
|
return 'Verbinding maken met $endpoint...';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpErrorHostRequired => 'Een IP-adres is vereist.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpErrorPortInvalid =>
|
||||||
|
'De poortwaarde moet tussen 1 en 65535 liggen.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpErrorUnsupported =>
|
||||||
|
'TCP-transport wordt niet ondersteund op deze platform.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpErrorTimedOut => 'De TCP-verbinding is verlopen.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String tcpConnectionFailed(String error) {
|
||||||
|
return 'Verbinding met TCP mislukt: $error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbScreenTitle => 'Verbind via USB';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbScreenSubtitle =>
|
||||||
|
'Selecteer een gedetecteerd seriële apparaat en verbind deze direct met uw MeshCore-node.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbScreenStatus => 'Selecteer een USB-apparaat';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbScreenNote =>
|
||||||
|
'USB-serieel is actief op ondersteunde Android-apparaten en desktop-platforms.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbScreenEmptyState =>
|
||||||
|
'Geen USB-apparaten gevonden. Sluit er een aan en herlaad.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorPermissionDenied => 'Toegang via USB is geweigerd.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorDeviceMissing =>
|
||||||
|
'Het geselecteerde USB-apparaat is niet meer beschikbaar.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorInvalidPort => 'Selecteer een geldig USB-apparaat.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorBusy =>
|
||||||
|
'Een andere verzoek om een USB-verbinding is al in behandeling.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorNotConnected => 'Er is geen USB-apparaat aangesloten.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorOpenFailed =>
|
||||||
|
'Kon het geselecteerde USB-apparaat niet openen.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorConnectFailed =>
|
||||||
|
'Kon niet verbinding maken met het geselecteerde USB-apparaat.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorUnsupported =>
|
||||||
|
'USB-serieel is niet ondersteund op deze platform.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorAlreadyActive => 'Een USB-verbinding is al actief.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorNoDeviceSelected => 'Geen USB-apparaat is geselecteerd.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorPortClosed => 'De USB-verbinding is niet actief.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorConnectTimedOut =>
|
||||||
|
'Verbinding is verbroken. Zorg ervoor dat het apparaat de juiste USB-firmware heeft.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbFallbackDeviceName => 'Web-serieapparaat';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbStatus_notConnected => 'Selecteer een USB-apparaat';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbStatus_connecting => 'Verbinding maken met USB-apparaat...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbStatus_searching => 'Zoeken naar USB-apparaten...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String usbConnectionFailed(String error) {
|
||||||
|
return 'Fout bij de USB-verbinding: $error';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get scanner_scanning => 'Scannen naar apparaten...';
|
String get scanner_scanning => 'Scannen naar apparaten...';
|
||||||
|
|
||||||
@@ -1374,6 +1502,9 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get map_shareMarkerHere => 'Deel marker hier';
|
String get map_shareMarkerHere => 'Deel marker hier';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_setAsMyLocation => 'Stel dit in als mijn locatie';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get map_pinLabel => 'Label vastzetten';
|
String get map_pinLabel => 'Label vastzetten';
|
||||||
|
|
||||||
@@ -1438,6 +1569,9 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||||||
String get map_showGuessedLocations =>
|
String get map_showGuessedLocations =>
|
||||||
'Toon de voorspelde locaties van de knopen';
|
'Toon de voorspelde locaties van de knopen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_showDiscoveryContacts => 'Ontdek contacten weergeven';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get map_guessedLocation => 'Geroerde locatie';
|
String get map_guessedLocation => 'Geroerde locatie';
|
||||||
|
|
||||||
|
|||||||
@@ -111,6 +111,138 @@ class AppLocalizationsPl extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get scanner_title => 'MeshCore Open';
|
String get scanner_title => 'MeshCore Open';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get connectionChoiceUsbLabel => 'USB';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get connectionChoiceBluetoothLabel => 'Bluetooth';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get connectionChoiceTcpLabel => 'TCP';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpScreenTitle => 'Połącz się za pomocą protokołu TCP';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpHostLabel => 'Adres IP';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpHostHint => '192.168.40.10';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpPortLabel => 'Port';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpPortHint => '5000';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpStatus_notConnected => 'Wprowadź adres URL i połącz';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String tcpStatus_connectingTo(String endpoint) {
|
||||||
|
return 'Połączenie z $endpoint...';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpErrorHostRequired => 'Wymagana jest adresa IP.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpErrorPortInvalid =>
|
||||||
|
'Numer portu musi mieścić się w zakresie od 1 do 65535.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpErrorUnsupported =>
|
||||||
|
'Transport protokoł TCP nie jest obsługiwany na tym urządzeniu.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpErrorTimedOut =>
|
||||||
|
'Połączenie TCP zakończyło się bez powodzenia.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String tcpConnectionFailed(String error) {
|
||||||
|
return 'Błąd połączenia TCP: $error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbScreenTitle => 'Połącz przez USB';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbScreenSubtitle =>
|
||||||
|
'Wybierz wykryty urządzenie szeregowe i podłącz je bezpośrednio do swojego węzła MeshCore.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbScreenStatus => 'Wybierz urządzenie USB';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbScreenNote =>
|
||||||
|
'Port szeregowy USB jest aktywny na urządzeniach z systemem Android i platformach stacjonarnych, które go obsługują.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbScreenEmptyState =>
|
||||||
|
'Nie znaleziono żadnych urządzeń USB. Podłącz jedno i zaktualizuj.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorPermissionDenied =>
|
||||||
|
'Zostało odrzucone żądanie dostępu przez USB.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorDeviceMissing =>
|
||||||
|
'Wybór urządzenia USB już nie jest dostępny.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorInvalidPort => 'Wybierz prawidłowe urządzenie USB.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorBusy =>
|
||||||
|
'Kolejne żądanie połączenia przez USB jest już w trakcie realizacji.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorNotConnected => 'Brak podłączonego urządzenia USB.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorOpenFailed =>
|
||||||
|
'Nie udało się otworzyć wybranego urządzenia USB.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorConnectFailed =>
|
||||||
|
'Nie udało się połączyć z wybranym urządzeniem USB.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorUnsupported =>
|
||||||
|
'Port szeregowy USB nie jest obsługiwany na tym urządzeniu.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorAlreadyActive => 'Połączenie USB jest już aktywne.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorNoDeviceSelected =>
|
||||||
|
'Nie został wybrany żaden urządzenie USB.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorPortClosed => 'Połączenie USB nie jest aktywne.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorConnectTimedOut =>
|
||||||
|
'Połączenie nie zostało nawiązane. Upewnij się, że urządzenie posiada oprogramowanie \"USB Companion\".';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbFallbackDeviceName =>
|
||||||
|
'Urządzenie do komunikacji przez sieć (seria)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbStatus_notConnected => 'Wybierz urządzenie USB';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbStatus_connecting => 'Połączenie z urządzeniem USB...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbStatus_searching => 'Wyszukiwanie urządzeń USB...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String usbConnectionFailed(String error) {
|
||||||
|
return 'Błąd połączenia USB: $error';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get scanner_scanning => 'Skanowanie urządzeń...';
|
String get scanner_scanning => 'Skanowanie urządzeń...';
|
||||||
|
|
||||||
@@ -1380,6 +1512,9 @@ class AppLocalizationsPl extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get map_shareMarkerHere => 'Udostępnij znacznik tutaj';
|
String get map_shareMarkerHere => 'Udostępnij znacznik tutaj';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_setAsMyLocation => 'Ustaw jako moje lokalizację';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get map_pinLabel => 'Oznacz etykietę';
|
String get map_pinLabel => 'Oznacz etykietę';
|
||||||
|
|
||||||
@@ -1444,6 +1579,9 @@ class AppLocalizationsPl extends AppLocalizations {
|
|||||||
String get map_showGuessedLocations =>
|
String get map_showGuessedLocations =>
|
||||||
'Wyświetl lokalizacje zgadanych węzłów';
|
'Wyświetl lokalizacje zgadanych węzłów';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_showDiscoveryContacts => 'Pokaż kontakty odkrywania';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get map_guessedLocation => 'Wydana lokalizacja';
|
String get map_guessedLocation => 'Wydana lokalizacja';
|
||||||
|
|
||||||
|
|||||||
@@ -111,6 +111,136 @@ class AppLocalizationsPt extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get scanner_title => 'MeshCore Open';
|
String get scanner_title => 'MeshCore Open';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get connectionChoiceUsbLabel => 'USB';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get connectionChoiceBluetoothLabel => 'Bluetooth';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get connectionChoiceTcpLabel => 'TCP';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpScreenTitle => 'Estabelecer conexão via TCP';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpHostLabel => 'Endereço IP';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpHostHint => '192.168.40.10';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpPortLabel => 'Porta';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpPortHint => '5000';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpStatus_notConnected => 'Insira o endereço final e conecte-se.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String tcpStatus_connectingTo(String endpoint) {
|
||||||
|
return 'Conectando a $endpoint...';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpErrorHostRequired => 'É necessário fornecer um endereço IP.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpErrorPortInvalid =>
|
||||||
|
'O valor do porto deve estar entre 1 e 65535.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpErrorUnsupported =>
|
||||||
|
'O protocolo TCP não é suportado nesta plataforma.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpErrorTimedOut => 'A conexão TCP expirou.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String tcpConnectionFailed(String error) {
|
||||||
|
return 'Falha na conexão TCP: $error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbScreenTitle => 'Conecte via USB';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbScreenSubtitle =>
|
||||||
|
'Selecione um dispositivo serial detectado e conecte-o diretamente ao seu nó MeshCore.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbScreenStatus => 'Selecione um dispositivo USB';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbScreenNote =>
|
||||||
|
'A comunicação serial via USB está ativa em dispositivos Android e plataformas de desktop compatíveis.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbScreenEmptyState =>
|
||||||
|
'Nenhum dispositivo USB encontrado. Conecte um e atualize.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorPermissionDenied =>
|
||||||
|
'A permissão para acesso via USB foi negada.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorDeviceMissing =>
|
||||||
|
'O dispositivo USB selecionado não está mais disponível.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorInvalidPort => 'Selecione um dispositivo USB válido.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorBusy =>
|
||||||
|
'Já existe uma solicitação de conexão USB em andamento.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorNotConnected => 'Não há nenhum dispositivo USB conectado.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorOpenFailed =>
|
||||||
|
'Não foi possível abrir o dispositivo USB selecionado.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorConnectFailed =>
|
||||||
|
'Não foi possível conectar ao dispositivo USB selecionado.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorUnsupported =>
|
||||||
|
'A comunicação serial via USB não é suportada nesta plataforma.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorAlreadyActive => 'A conexão USB já está ativa.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorNoDeviceSelected =>
|
||||||
|
'Nenhum dispositivo USB foi selecionado.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorPortClosed => 'A conexão USB não está ativa.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorConnectTimedOut =>
|
||||||
|
'A conexão expirou. Verifique se o dispositivo possui o firmware USB Companion.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbFallbackDeviceName => 'Dispositivo de Serial para a Web';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbStatus_notConnected => 'Selecione um dispositivo USB';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbStatus_connecting => 'Conectando ao dispositivo USB...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbStatus_searching => 'Procurando por dispositivos USB...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String usbConnectionFailed(String error) {
|
||||||
|
return 'Falha na conexão USB: $error';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get scanner_scanning => 'Procurando por dispositivos...';
|
String get scanner_scanning => 'Procurando por dispositivos...';
|
||||||
|
|
||||||
@@ -1381,6 +1511,9 @@ class AppLocalizationsPt extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get map_shareMarkerHere => 'Compartilhar marcador aqui';
|
String get map_shareMarkerHere => 'Compartilhar marcador aqui';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_setAsMyLocation => 'Defina minha localização';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get map_pinLabel => 'Rótulo de marcador';
|
String get map_pinLabel => 'Rótulo de marcador';
|
||||||
|
|
||||||
@@ -1445,6 +1578,9 @@ class AppLocalizationsPt extends AppLocalizations {
|
|||||||
String get map_showGuessedLocations =>
|
String get map_showGuessedLocations =>
|
||||||
'Mostrar as localizações dos nós estimados';
|
'Mostrar as localizações dos nós estimados';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_showDiscoveryContacts => 'Mostrar Contatos de Descoberta';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get map_guessedLocation => 'Localização estimada';
|
String get map_guessedLocation => 'Localização estimada';
|
||||||
|
|
||||||
|
|||||||
@@ -111,6 +111,137 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get scanner_title => 'MeshCore Open';
|
String get scanner_title => 'MeshCore Open';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get connectionChoiceUsbLabel => 'USB';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get connectionChoiceBluetoothLabel => 'Bluetooth';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get connectionChoiceTcpLabel => 'TCP';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpScreenTitle => 'Установить соединение по протоколу TCP';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpHostLabel => 'IP-адрес';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpHostHint => '192.168.40.10';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpPortLabel => 'Порт';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpPortHint => '5000';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpStatus_notConnected => 'Введите адрес и подключитесь.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String tcpStatus_connectingTo(String endpoint) {
|
||||||
|
return 'Подключение к $endpoint...';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpErrorHostRequired => 'Необходимо указать IP-адрес.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpErrorPortInvalid =>
|
||||||
|
'Порт должен находиться в диапазоне от 1 до 65535.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpErrorUnsupported =>
|
||||||
|
'Протокол TCP не поддерживается на этой платформе.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpErrorTimedOut => 'Соединение TCP не удалось установить.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String tcpConnectionFailed(String error) {
|
||||||
|
return 'Не удалось установить соединение TCP: $error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbScreenTitle => 'Подключение через USB';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbScreenSubtitle =>
|
||||||
|
'Выберите обнаруженное устройство с последовательным интерфейсом и подключите его напрямую к вашему узлу MeshCore.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbScreenStatus => 'Выберите USB-устройство';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbScreenNote =>
|
||||||
|
'USB-серийный порт активен на поддерживаемых устройствах Android и на настольных платформах.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbScreenEmptyState =>
|
||||||
|
'Не обнаружено устройств USB. Подключите одно из них и обновите список.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorPermissionDenied =>
|
||||||
|
'Запрос на доступ через USB был отклонен.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorDeviceMissing =>
|
||||||
|
'Выбранное USB-устройство больше недоступно.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorInvalidPort => 'Выберите действительное USB-устройство.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorBusy =>
|
||||||
|
'Еще одно запрошенное соединение через USB уже находится в процессе.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorNotConnected => 'Ни одно USB-устройство не подключено.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorOpenFailed =>
|
||||||
|
'Не удалось открыть выбранное USB-устройство.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorConnectFailed =>
|
||||||
|
'Не удалось установить соединение с выбранным USB-устройством.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorUnsupported =>
|
||||||
|
'Поддержка последовательного USB отсутствует на данной платформе.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorAlreadyActive => 'USB-соединение уже установлено.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorNoDeviceSelected =>
|
||||||
|
'Не было выбрано ни одно устройство USB.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorPortClosed => 'USB-соединение не установлено.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorConnectTimedOut =>
|
||||||
|
'Соединение не установлено. Убедитесь, что устройство имеет установленное программное обеспечение USB Companion.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbFallbackDeviceName =>
|
||||||
|
'Устройство для последовательного подключения к сети';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbStatus_notConnected => 'Выберите USB-устройство';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbStatus_connecting => 'Подключение к USB-устройству...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbStatus_searching => 'Поиск USB-устройств...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String usbConnectionFailed(String error) {
|
||||||
|
return 'Не удалось установить соединение через USB: $error';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get scanner_scanning => 'Поиск устройств...';
|
String get scanner_scanning => 'Поиск устройств...';
|
||||||
|
|
||||||
@@ -1382,6 +1513,9 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get map_shareMarkerHere => 'Поделиться меткой здесь';
|
String get map_shareMarkerHere => 'Поделиться меткой здесь';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_setAsMyLocation => 'Установить мое местоположение';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get map_pinLabel => 'Метка';
|
String get map_pinLabel => 'Метка';
|
||||||
|
|
||||||
@@ -1446,6 +1580,9 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
String get map_showGuessedLocations =>
|
String get map_showGuessedLocations =>
|
||||||
'Отобразить предполагаемые места расположения узлов';
|
'Отобразить предполагаемые места расположения узлов';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_showDiscoveryContacts => 'Показать контакты Discovery';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get map_guessedLocation => 'Угаданное место';
|
String get map_guessedLocation => 'Угаданное место';
|
||||||
|
|
||||||
|
|||||||
@@ -111,6 +111,135 @@ class AppLocalizationsSk extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get scanner_title => 'MeshCore Open';
|
String get scanner_title => 'MeshCore Open';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get connectionChoiceUsbLabel => 'USB';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get connectionChoiceBluetoothLabel => 'Bluetooth';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get connectionChoiceTcpLabel => 'TCP';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpScreenTitle => 'Spojte sa pomocou protokolu TCP';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpHostLabel => 'IP adresa';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpHostHint => '192.168.40.10';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpPortLabel => 'Port';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpPortHint => '5000';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpStatus_notConnected => 'Zadajte cieľovú adresu a pripojte sa.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String tcpStatus_connectingTo(String endpoint) {
|
||||||
|
return 'Pripojenie k $endpoint...';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpErrorHostRequired => 'Je potrebné zadať IP adresu.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpErrorPortInvalid => 'Číslo portu musí byť medzi 1 a 65535.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpErrorUnsupported =>
|
||||||
|
'Prevoz prostredníctvom protokolu TCP nie je na tejto platforme podporovaný.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpErrorTimedOut => 'Pripojenie TCP vypršalo.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String tcpConnectionFailed(String error) {
|
||||||
|
return 'Neúspešné vytvorenie TCP spojenia: $error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbScreenTitle => 'Pripojte cez USB';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbScreenSubtitle =>
|
||||||
|
'Vyberte detekovaný sériový zariadenie a pripojte ho priamo k vašej MeshCore uzlu.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbScreenStatus => 'Vyberte USB zariadenie';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbScreenNote =>
|
||||||
|
'USB sériová komunikácia je aktívna na podporovaných zariadeniach s Androidom a na desktopových platformách.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbScreenEmptyState =>
|
||||||
|
'Nenašli sa žiadne USB zariadenia. Pripojte jedno a obnovte.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorPermissionDenied =>
|
||||||
|
'Žiadosť o prístup cez USB bola zamietnutá.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorDeviceMissing =>
|
||||||
|
'Vybrané USB zariadenie už nie je dostupné.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorInvalidPort => 'Vyberte platné USB zariadenie.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorBusy =>
|
||||||
|
'Ďalšia požiadavka na pripojenie cez USB je aktuálne v procese.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorNotConnected => 'Nie je pripojené žiadne USB zariadenie.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorOpenFailed =>
|
||||||
|
'Nepodarilo sa otvoriť vybrané USB zariadenie.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorConnectFailed =>
|
||||||
|
'Nepodarilo sa sa sa pripojiť k vybranému USB zariadeniu.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorUnsupported =>
|
||||||
|
'Podpora USB sériového rozhrania nie je na tejto platforme dostupná.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorAlreadyActive => 'Pripojenie cez USB je už aktivované.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorNoDeviceSelected =>
|
||||||
|
'Nebolo vybrané žiadne USB zariadenie.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorPortClosed => 'Pripojenie cez USB nie je aktivované.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorConnectTimedOut =>
|
||||||
|
'Pripojenie nebolo úspešné. Uistite sa, že zariadenie má nainštalovaný firmware USB Companion.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbFallbackDeviceName => 'Webový sériový zariadenie';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbStatus_notConnected => 'Vyberte USB zariadenie';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbStatus_connecting => 'Pripojenie k USB zariadeniu...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbStatus_searching => 'Hľadanie USB zariadení...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String usbConnectionFailed(String error) {
|
||||||
|
return 'Neúspešné pripojenie cez USB: $error';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get scanner_scanning => 'Skrívania zariadení...';
|
String get scanner_scanning => 'Skrívania zariadení...';
|
||||||
|
|
||||||
@@ -1375,6 +1504,9 @@ class AppLocalizationsSk extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get map_shareMarkerHere => 'Zdieľte značku tu';
|
String get map_shareMarkerHere => 'Zdieľte značku tu';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_setAsMyLocation => 'Nastavte ako moju polohu';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get map_pinLabel => 'Označka upozornenia';
|
String get map_pinLabel => 'Označka upozornenia';
|
||||||
|
|
||||||
@@ -1439,6 +1571,9 @@ class AppLocalizationsSk extends AppLocalizations {
|
|||||||
String get map_showGuessedLocations =>
|
String get map_showGuessedLocations =>
|
||||||
'Zobraziť umiestnenia odhadnutých uzlov';
|
'Zobraziť umiestnenia odhadnutých uzlov';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_showDiscoveryContacts => 'Zobraziť kontakty objavov';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get map_guessedLocation => 'Odhadnutá lokalita';
|
String get map_guessedLocation => 'Odhadnutá lokalita';
|
||||||
|
|
||||||
|
|||||||
@@ -111,6 +111,133 @@ class AppLocalizationsSl extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get scanner_title => 'MeshCore Open';
|
String get scanner_title => 'MeshCore Open';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get connectionChoiceUsbLabel => 'USB';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get connectionChoiceBluetoothLabel => 'Bluetooth';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get connectionChoiceTcpLabel => 'TCP';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpScreenTitle => 'Komunicirajte preko protokola TCP';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpHostLabel => 'IP naslov';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpHostHint => '192.168.40.10';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpPortLabel => 'Vrata';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpPortHint => '5000';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpStatus_notConnected => 'Vnesite končni naslov in se povežite';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String tcpStatus_connectingTo(String endpoint) {
|
||||||
|
return 'Povezava z $endpoint...';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpErrorHostRequired => 'Potrebna je IP-naslov.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpErrorPortInvalid => 'Port mora biti med 1 in 65535.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpErrorUnsupported =>
|
||||||
|
'Transport preko protokola TCP ni podprt na tej platformi.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpErrorTimedOut => 'Povezava TCP je presegla časovno obdobje.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String tcpConnectionFailed(String error) {
|
||||||
|
return 'Napaka pri povezavi TCP: $error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbScreenTitle => 'Povežite preko USB';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbScreenSubtitle =>
|
||||||
|
'Izberite zaznano serijsko napravo in se neposredno povežite z vašo MeshCore napravo.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbScreenStatus => 'Izberite USB naprave';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbScreenNote =>
|
||||||
|
'USB serijska povezava je aktivna na podprtih napravah Android in na desktop platformah.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbScreenEmptyState =>
|
||||||
|
'Niti en USB naprave niso najdeni. Povežite eno in posodobite.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorPermissionDenied =>
|
||||||
|
'Dovoljenje za dostop preko USB-ja je bilo zavrnjeno.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorDeviceMissing => 'Izbrani USB napravej je več ne.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorInvalidPort => 'Izberite veljavno USB naprave.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorBusy => 'Že je v teku zahteva za povezavo preko USB.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorNotConnected => 'Ni priklopljenih USB naprave.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorOpenFailed =>
|
||||||
|
'Uspešno ni bilo mogo, da se odpre izbran naprave USB.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorConnectFailed =>
|
||||||
|
'Niso bilo mogoče uskladiti povezave z izbranim USB napom.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorUnsupported =>
|
||||||
|
'USB serijska komunikacija ni podprta na tej platformi.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorAlreadyActive => 'USB povezava je že aktivirana.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorNoDeviceSelected => 'Ni bilo izbranega USB naprave.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorPortClosed => 'USB povezava ni aktivirana.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorConnectTimedOut =>
|
||||||
|
'Vzpostavitve ni bilo mogo. Prosimo, da se prepričate, da ima naprave trenutno nameštan firmware USB Companion.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbFallbackDeviceName =>
|
||||||
|
'Naprave za serijsko komunikacijo preko spleta';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbStatus_notConnected => 'Izberite USB naprave.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbStatus_connecting => 'Povezava z USB napravo...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbStatus_searching => 'Iskanje USB naprav...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String usbConnectionFailed(String error) {
|
||||||
|
return 'Napaka pri povezavi preko USB: $error';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get scanner_scanning => 'Skeniram za naprave...';
|
String get scanner_scanning => 'Skeniram za naprave...';
|
||||||
|
|
||||||
@@ -1371,6 +1498,9 @@ class AppLocalizationsSl extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get map_shareMarkerHere => 'Delite točke tukaj.';
|
String get map_shareMarkerHere => 'Delite točke tukaj.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_setAsMyLocation => 'Nastavite to kot mojo lokacijo';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get map_pinLabel => 'Oznaka za pritrditev';
|
String get map_pinLabel => 'Oznaka za pritrditev';
|
||||||
|
|
||||||
@@ -1434,6 +1564,9 @@ class AppLocalizationsSl extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get map_showGuessedLocations => 'Pokaži lokacije domnevnih not.';
|
String get map_showGuessedLocations => 'Pokaži lokacije domnevnih not.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_showDiscoveryContacts => 'Prikaži odkritja kontaktov';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get map_guessedLocation => 'Predpostavljena lokacija';
|
String get map_guessedLocation => 'Predpostavljena lokacija';
|
||||||
|
|
||||||
|
|||||||
@@ -111,6 +111,133 @@ class AppLocalizationsSv extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get scanner_title => 'MeshCore Open';
|
String get scanner_title => 'MeshCore Open';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get connectionChoiceUsbLabel => 'USB';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get connectionChoiceBluetoothLabel => 'Bluetooth';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get connectionChoiceTcpLabel => 'TCP';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpScreenTitle => 'Anslut via TCP';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpHostLabel => 'IP-adress';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpHostHint => '192.168.40.10';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpPortLabel => 'Port';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpPortHint => '5000';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpStatus_notConnected => 'Ange slutpunkt och anslut';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String tcpStatus_connectingTo(String endpoint) {
|
||||||
|
return 'Anslutning till $endpoint...';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpErrorHostRequired => 'IP-adress krävs.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpErrorPortInvalid => 'Porten måste vara mellan 1 och 65535.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpErrorUnsupported =>
|
||||||
|
'TCP-transport fungerar inte på denna plattform.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpErrorTimedOut => 'TCP-anslutningen har tidsut gått.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String tcpConnectionFailed(String error) {
|
||||||
|
return 'Fel vid TCP-anslutning: $error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbScreenTitle => 'Anslut via USB';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbScreenSubtitle =>
|
||||||
|
'Välj en detekterad seriell enhet och anslut direkt till din MeshCore-nod.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbScreenStatus => 'Välj en USB-enhet';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbScreenNote =>
|
||||||
|
'USB-seriell kommunikation är aktiv på stödda Android-enheter och på skrivbordsplattformar.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbScreenEmptyState =>
|
||||||
|
'Inga USB-enheter hittades. Anslut en och uppdatera.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorPermissionDenied => 'Tillgången via USB nekas.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorDeviceMissing =>
|
||||||
|
'Den valda USB-enheten är inte längre tillgänglig.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorInvalidPort => 'Välj en giltig USB-enhet.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorBusy =>
|
||||||
|
'En annan förfrågan om USB-anslutning är redan pågående.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorNotConnected => 'Ingen USB-enhet är ansluten.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorOpenFailed =>
|
||||||
|
'Misslyckades med att öppna det valda USB-enheten.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorConnectFailed =>
|
||||||
|
'Kunde inte ansluta till det valda USB-enheten.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorUnsupported =>
|
||||||
|
'USB-seriell kommunikation stöds inte på denna plattform.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorAlreadyActive => 'En USB-anslutning är redan aktiv.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorNoDeviceSelected => 'Ingen USB-enhet valdes.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorPortClosed => 'USB-anslutningen är inte aktiv.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorConnectTimedOut =>
|
||||||
|
'Anslutningen har tidsutgått. Se till att enheten har rätt USB-firmware.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbFallbackDeviceName => 'Web-serieenhet';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbStatus_notConnected => 'Välj en USB-enhet';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbStatus_connecting => 'Anslutning till USB-enhet...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbStatus_searching => 'Söker efter USB-enheter...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String usbConnectionFailed(String error) {
|
||||||
|
return 'Fel vid USB-anslutning: $error';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get scanner_scanning => 'Söker efter enheter...';
|
String get scanner_scanning => 'Söker efter enheter...';
|
||||||
|
|
||||||
@@ -1367,6 +1494,9 @@ class AppLocalizationsSv extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get map_shareMarkerHere => 'Dela markeringen här';
|
String get map_shareMarkerHere => 'Dela markeringen här';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_setAsMyLocation => 'Ange som min plats';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get map_pinLabel => 'Fästetikett';
|
String get map_pinLabel => 'Fästetikett';
|
||||||
|
|
||||||
@@ -1431,6 +1561,9 @@ class AppLocalizationsSv extends AppLocalizations {
|
|||||||
String get map_showGuessedLocations =>
|
String get map_showGuessedLocations =>
|
||||||
'Visa upp de antagna nodernas placeringar';
|
'Visa upp de antagna nodernas placeringar';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_showDiscoveryContacts => 'Visa Discovery-kontakter';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get map_guessedLocation => 'Gissad plats';
|
String get map_guessedLocation => 'Gissad plats';
|
||||||
|
|
||||||
|
|||||||
@@ -111,6 +111,135 @@ class AppLocalizationsUk extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get scanner_title => 'MeshCore Open';
|
String get scanner_title => 'MeshCore Open';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get connectionChoiceUsbLabel => 'USB';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get connectionChoiceBluetoothLabel => 'Bluetooth';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get connectionChoiceTcpLabel => 'TCP';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpScreenTitle => 'З\'єднатися через протокол TCP';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpHostLabel => 'IP-адреса';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpHostHint => '192.168.40.10';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpPortLabel => 'Порт';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpPortHint => '5000';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpStatus_notConnected => 'Введіть кінцеву точку та підключіться';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String tcpStatus_connectingTo(String endpoint) {
|
||||||
|
return 'Підключення до $endpoint...';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpErrorHostRequired => 'Необхідно вказати IP-адресу.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpErrorPortInvalid => 'Порт повинен бути в межах від 1 до 65535.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpErrorUnsupported =>
|
||||||
|
'Транспорт TCP не підтримується на цій платформі.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpErrorTimedOut =>
|
||||||
|
'З\'єднання TCP завершилося через закінчення часу очікування.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String tcpConnectionFailed(String error) {
|
||||||
|
return 'Не вдалося встановити з\'єднання TCP: $error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbScreenTitle => 'Підключити через USB';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbScreenSubtitle =>
|
||||||
|
'Виберіть виявлене серійне пристрій і підключіть його безпосередньо до вашого вузла MeshCore.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbScreenStatus => 'Виберіть пристрій USB';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbScreenNote =>
|
||||||
|
'USB-серіальний інтерфейс активний на підтримуваних пристроях на базі Android та на десктопних платформах.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbScreenEmptyState =>
|
||||||
|
'Не знайдено жодних пристроїв USB. Підключіть один і перезавантажте.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorPermissionDenied =>
|
||||||
|
'Було відмовлено у наданні дозволу на використання USB.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorDeviceMissing => 'Вибране USB-пристрій більше недоступне.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorInvalidPort => 'Виберіть дійсний USB-пристрій.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorBusy =>
|
||||||
|
'Ще один запит на підключення через USB вже обробляється.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorNotConnected => 'Немає підключених пристроїв USB.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorOpenFailed => 'Не вдалося відкрити вибране USB-пристрій.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorConnectFailed =>
|
||||||
|
'Не вдалося підключитися до вибраного USB-пристрою.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorUnsupported =>
|
||||||
|
'Підтримка USB-серіального інтерфейсу не реалізована на цій платформі.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorAlreadyActive => 'USB-з\'єднання вже встановлено.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorNoDeviceSelected =>
|
||||||
|
'Не було вибрано жодного пристрою USB.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorPortClosed => 'З\'єднання USB не встановлено.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorConnectTimedOut =>
|
||||||
|
'З\'єднання не вдалося встановити. Переконайтеся, що пристрій має встановлене програмне забезпечення USB Companion.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbFallbackDeviceName =>
|
||||||
|
'Пристрій для передачі даних по веб-серіалах';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbStatus_notConnected => 'Виберіть пристрій USB';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbStatus_connecting => 'Підключення до USB-пристрою...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbStatus_searching => 'Пошук пристроїв USB...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String usbConnectionFailed(String error) {
|
||||||
|
return 'Не вдалося встановити з\'єднання через USB: $error';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get scanner_scanning => 'Пошук пристроїв...';
|
String get scanner_scanning => 'Пошук пристроїв...';
|
||||||
|
|
||||||
@@ -1381,6 +1510,9 @@ class AppLocalizationsUk extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get map_shareMarkerHere => 'Поділитися маркером тут';
|
String get map_shareMarkerHere => 'Поділитися маркером тут';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_setAsMyLocation => 'Встановити моє місцезнаходження';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get map_pinLabel => 'Мітка піна';
|
String get map_pinLabel => 'Мітка піна';
|
||||||
|
|
||||||
@@ -1445,6 +1577,9 @@ class AppLocalizationsUk extends AppLocalizations {
|
|||||||
String get map_showGuessedLocations =>
|
String get map_showGuessedLocations =>
|
||||||
'Показати місцезнаходження передбачених вузлів';
|
'Показати місцезнаходження передбачених вузлів';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_showDiscoveryContacts => 'Показати контакти Відкриття';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get map_guessedLocation => 'Визначено місцезнаходження';
|
String get map_guessedLocation => 'Визначено місцезнаходження';
|
||||||
|
|
||||||
|
|||||||
@@ -111,6 +111,123 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get scanner_title => '连接设备';
|
String get scanner_title => '连接设备';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get connectionChoiceUsbLabel => 'USB';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get connectionChoiceBluetoothLabel => '蓝牙';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get connectionChoiceTcpLabel => 'TCP';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpScreenTitle => '通过 TCP 连接';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpHostLabel => 'IP地址';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpHostHint => '192.168.40.10';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpPortLabel => '端口';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpPortHint => '5000';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpStatus_notConnected => '输入目标地址,然后连接';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String tcpStatus_connectingTo(String endpoint) {
|
||||||
|
return '连接到 $endpoint...';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpErrorHostRequired => '需要提供IP地址。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpErrorPortInvalid => '端口号必须在 1 到 65535 之间。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpErrorUnsupported => '此平台不支持 TCP 传输。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tcpErrorTimedOut => 'TCP 连接超时。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String tcpConnectionFailed(String error) {
|
||||||
|
return 'TCP 连接失败:$error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbScreenTitle => '通过USB连接';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbScreenSubtitle => '选择已检测到的串行设备,并直接连接到您的 MeshCore 节点。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbScreenStatus => '选择一个 USB 设备';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbScreenNote => 'USB 串行接口在支持的 Android 设备和桌面平台上处于活动状态。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbScreenEmptyState => '未找到任何 USB 设备。请插入一个,然后刷新。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorPermissionDenied => '拒绝了USB权限。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorDeviceMissing => '所选的USB设备已不再可用。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorInvalidPort => '选择一个有效的USB设备。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorBusy => '还有一个 USB 连接请求正在进行中。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorNotConnected => '没有连接任何USB设备。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorOpenFailed => '未能打开所选的USB设备。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorConnectFailed => '未能连接到所选的USB设备。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorUnsupported => '此平台不支持USB串行通信。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorAlreadyActive => 'USB 连接已建立。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorNoDeviceSelected => '未选择任何 USB 设备。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorPortClosed => 'USB 连接未建立。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbErrorConnectTimedOut => '连接超时。请确保设备已安装 USB 伴侣固件。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbFallbackDeviceName => 'Web 串流设备';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbStatus_notConnected => '选择一个 USB 设备';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbStatus_connecting => '连接USB设备...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usbStatus_searching => '正在搜索 USB 设备...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String usbConnectionFailed(String error) {
|
||||||
|
return 'USB 连接失败:$error';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get scanner_scanning => '正在搜索设备...';
|
String get scanner_scanning => '正在搜索设备...';
|
||||||
|
|
||||||
@@ -1304,6 +1421,9 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get map_shareMarkerHere => '在此分享标记';
|
String get map_shareMarkerHere => '在此分享标记';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_setAsMyLocation => '设置为我的位置';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get map_pinLabel => '标签';
|
String get map_pinLabel => '标签';
|
||||||
|
|
||||||
@@ -1366,6 +1486,9 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get map_showGuessedLocations => '显示猜测的节点位置';
|
String get map_showGuessedLocations => '显示猜测的节点位置';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_showDiscoveryContacts => '显示发现联系人';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get map_guessedLocation => '猜测的位置';
|
String get map_guessedLocation => '猜测的位置';
|
||||||
|
|
||||||
|
|||||||
+61
-1
@@ -1828,5 +1828,65 @@
|
|||||||
"discoveredContacts_deleteContactAll": "Verwijder alle ontdekte contacten",
|
"discoveredContacts_deleteContactAll": "Verwijder alle ontdekte contacten",
|
||||||
"discoveredContacts_deleteContactAllContent": "Weet u zeker dat u alle ontdekte contacten wilt verwijderen?",
|
"discoveredContacts_deleteContactAllContent": "Weet u zeker dat u alle ontdekte contacten wilt verwijderen?",
|
||||||
"map_guessedLocation": "Geroerde locatie",
|
"map_guessedLocation": "Geroerde locatie",
|
||||||
"map_showGuessedLocations": "Toon de voorspelde locaties van de knopen"
|
"map_showGuessedLocations": "Toon de voorspelde locaties van de knopen",
|
||||||
|
"connectionChoiceUsbLabel": "USB",
|
||||||
|
"usbScreenSubtitle": "Selecteer een gedetecteerd seriële apparaat en verbind deze direct met uw MeshCore-node.",
|
||||||
|
"connectionChoiceBluetoothLabel": "Bluetooth",
|
||||||
|
"usbScreenTitle": "Verbind via USB",
|
||||||
|
"usbScreenStatus": "Selecteer een USB-apparaat",
|
||||||
|
"usbScreenNote": "USB-serieel is actief op ondersteunde Android-apparaten en desktop-platforms.",
|
||||||
|
"usbScreenEmptyState": "Geen USB-apparaten gevonden. Sluit er een aan en herlaad.",
|
||||||
|
"usbErrorPermissionDenied": "Toegang via USB is geweigerd.",
|
||||||
|
"usbErrorDeviceMissing": "Het geselecteerde USB-apparaat is niet meer beschikbaar.",
|
||||||
|
"usbErrorInvalidPort": "Selecteer een geldig USB-apparaat.",
|
||||||
|
"usbErrorBusy": "Een andere verzoek om een USB-verbinding is al in behandeling.",
|
||||||
|
"usbErrorNotConnected": "Er is geen USB-apparaat aangesloten.",
|
||||||
|
"usbErrorOpenFailed": "Kon het geselecteerde USB-apparaat niet openen.",
|
||||||
|
"usbErrorConnectFailed": "Kon niet verbinding maken met het geselecteerde USB-apparaat.",
|
||||||
|
"usbErrorUnsupported": "USB-serieel is niet ondersteund op deze platform.",
|
||||||
|
"usbErrorAlreadyActive": "Een USB-verbinding is al actief.",
|
||||||
|
"usbErrorNoDeviceSelected": "Geen USB-apparaat is geselecteerd.",
|
||||||
|
"usbErrorPortClosed": "De USB-verbinding is niet actief.",
|
||||||
|
"usbFallbackDeviceName": "Web-serieapparaat",
|
||||||
|
"@usbConnectionFailed": {
|
||||||
|
"placeholders": {
|
||||||
|
"error": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"usbConnectionFailed": "Fout bij de USB-verbinding: {error}",
|
||||||
|
"usbStatus_notConnected": "Selecteer een USB-apparaat",
|
||||||
|
"usbStatus_connecting": "Verbinding maken met USB-apparaat...",
|
||||||
|
"usbStatus_searching": "Zoeken naar USB-apparaten...",
|
||||||
|
"usbErrorConnectTimedOut": "Verbinding is verbroken. Zorg ervoor dat het apparaat de juiste USB-firmware heeft.",
|
||||||
|
"@tcpStatus_connectingTo": {
|
||||||
|
"placeholders": {
|
||||||
|
"endpoint": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"@tcpConnectionFailed": {
|
||||||
|
"placeholders": {
|
||||||
|
"error": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tcpScreenTitle": "Verbind via TCP",
|
||||||
|
"tcpHostLabel": "IP-adres",
|
||||||
|
"tcpHostHint": "192.168.40.10",
|
||||||
|
"connectionChoiceTcpLabel": "TCP",
|
||||||
|
"tcpPortLabel": "Poort",
|
||||||
|
"tcpPortHint": "5000",
|
||||||
|
"tcpStatus_notConnected": "Voer het eindpunt in en verbind",
|
||||||
|
"tcpStatus_connectingTo": "Verbinding maken met {endpoint}...",
|
||||||
|
"tcpErrorHostRequired": "Een IP-adres is vereist.",
|
||||||
|
"tcpErrorPortInvalid": "De poortwaarde moet tussen 1 en 65535 liggen.",
|
||||||
|
"tcpErrorUnsupported": "TCP-transport wordt niet ondersteund op deze platform.",
|
||||||
|
"tcpErrorTimedOut": "De TCP-verbinding is verlopen.",
|
||||||
|
"tcpConnectionFailed": "Verbinding met TCP mislukt: {error}",
|
||||||
|
"map_showDiscoveryContacts": "Ontdek contacten weergeven",
|
||||||
|
"map_setAsMyLocation": "Stel dit in als mijn locatie"
|
||||||
}
|
}
|
||||||
|
|||||||
+61
-1
@@ -1828,5 +1828,65 @@
|
|||||||
"discoveredContacts_deleteContactAllContent": "Czy na pewno chcesz usunąć wszystkie znalezione kontakty?",
|
"discoveredContacts_deleteContactAllContent": "Czy na pewno chcesz usunąć wszystkie znalezione kontakty?",
|
||||||
"discoveredContacts_deleteContactAll": "Usuń wszystkie odkryte kontakty",
|
"discoveredContacts_deleteContactAll": "Usuń wszystkie odkryte kontakty",
|
||||||
"map_guessedLocation": "Wydana lokalizacja",
|
"map_guessedLocation": "Wydana lokalizacja",
|
||||||
"map_showGuessedLocations": "Wyświetl lokalizacje zgadanych węzłów"
|
"map_showGuessedLocations": "Wyświetl lokalizacje zgadanych węzłów",
|
||||||
|
"usbScreenSubtitle": "Wybierz wykryty urządzenie szeregowe i podłącz je bezpośrednio do swojego węzła MeshCore.",
|
||||||
|
"usbScreenTitle": "Połącz przez USB",
|
||||||
|
"connectionChoiceUsbLabel": "USB",
|
||||||
|
"connectionChoiceBluetoothLabel": "Bluetooth",
|
||||||
|
"usbScreenStatus": "Wybierz urządzenie USB",
|
||||||
|
"usbScreenNote": "Port szeregowy USB jest aktywny na urządzeniach z systemem Android i platformach stacjonarnych, które go obsługują.",
|
||||||
|
"usbScreenEmptyState": "Nie znaleziono żadnych urządzeń USB. Podłącz jedno i zaktualizuj.",
|
||||||
|
"usbErrorPermissionDenied": "Zostało odrzucone żądanie dostępu przez USB.",
|
||||||
|
"usbErrorDeviceMissing": "Wybór urządzenia USB już nie jest dostępny.",
|
||||||
|
"usbErrorInvalidPort": "Wybierz prawidłowe urządzenie USB.",
|
||||||
|
"usbErrorBusy": "Kolejne żądanie połączenia przez USB jest już w trakcie realizacji.",
|
||||||
|
"usbErrorNotConnected": "Brak podłączonego urządzenia USB.",
|
||||||
|
"usbErrorOpenFailed": "Nie udało się otworzyć wybranego urządzenia USB.",
|
||||||
|
"usbErrorConnectFailed": "Nie udało się połączyć z wybranym urządzeniem USB.",
|
||||||
|
"usbErrorUnsupported": "Port szeregowy USB nie jest obsługiwany na tym urządzeniu.",
|
||||||
|
"usbErrorAlreadyActive": "Połączenie USB jest już aktywne.",
|
||||||
|
"usbErrorNoDeviceSelected": "Nie został wybrany żaden urządzenie USB.",
|
||||||
|
"usbErrorPortClosed": "Połączenie USB nie jest aktywne.",
|
||||||
|
"usbFallbackDeviceName": "Urządzenie do komunikacji przez sieć (seria)",
|
||||||
|
"@usbConnectionFailed": {
|
||||||
|
"placeholders": {
|
||||||
|
"error": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"usbStatus_searching": "Wyszukiwanie urządzeń USB...",
|
||||||
|
"usbStatus_connecting": "Połączenie z urządzeniem USB...",
|
||||||
|
"usbStatus_notConnected": "Wybierz urządzenie USB",
|
||||||
|
"usbConnectionFailed": "Błąd połączenia USB: {error}",
|
||||||
|
"usbErrorConnectTimedOut": "Połączenie nie zostało nawiązane. Upewnij się, że urządzenie posiada oprogramowanie \"USB Companion\".",
|
||||||
|
"@tcpStatus_connectingTo": {
|
||||||
|
"placeholders": {
|
||||||
|
"endpoint": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"@tcpConnectionFailed": {
|
||||||
|
"placeholders": {
|
||||||
|
"error": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"connectionChoiceTcpLabel": "TCP",
|
||||||
|
"tcpHostHint": "192.168.40.10",
|
||||||
|
"tcpScreenTitle": "Połącz się za pomocą protokołu TCP",
|
||||||
|
"tcpHostLabel": "Adres IP",
|
||||||
|
"tcpPortLabel": "Port",
|
||||||
|
"tcpPortHint": "5000",
|
||||||
|
"tcpStatus_notConnected": "Wprowadź adres URL i połącz",
|
||||||
|
"tcpStatus_connectingTo": "Połączenie z {endpoint}...",
|
||||||
|
"tcpErrorHostRequired": "Wymagana jest adresa IP.",
|
||||||
|
"tcpErrorPortInvalid": "Numer portu musi mieścić się w zakresie od 1 do 65535.",
|
||||||
|
"tcpErrorUnsupported": "Transport protokoł TCP nie jest obsługiwany na tym urządzeniu.",
|
||||||
|
"tcpErrorTimedOut": "Połączenie TCP zakończyło się bez powodzenia.",
|
||||||
|
"tcpConnectionFailed": "Błąd połączenia TCP: {error}",
|
||||||
|
"map_showDiscoveryContacts": "Pokaż kontakty odkrywania",
|
||||||
|
"map_setAsMyLocation": "Ustaw jako moje lokalizację"
|
||||||
}
|
}
|
||||||
|
|||||||
+61
-1
@@ -1828,5 +1828,65 @@
|
|||||||
"discoveredContacts_deleteContactAll": "Excluir Todos os Contatos Descobertos",
|
"discoveredContacts_deleteContactAll": "Excluir Todos os Contatos Descobertos",
|
||||||
"discoveredContacts_deleteContactAllContent": "Tem certeza de que deseja excluir todos os contatos descobertos?",
|
"discoveredContacts_deleteContactAllContent": "Tem certeza de que deseja excluir todos os contatos descobertos?",
|
||||||
"map_guessedLocation": "Localização estimada",
|
"map_guessedLocation": "Localização estimada",
|
||||||
"map_showGuessedLocations": "Mostrar as localizações dos nós estimados"
|
"map_showGuessedLocations": "Mostrar as localizações dos nós estimados",
|
||||||
|
"connectionChoiceUsbLabel": "USB",
|
||||||
|
"connectionChoiceBluetoothLabel": "Bluetooth",
|
||||||
|
"usbScreenTitle": "Conecte via USB",
|
||||||
|
"usbScreenSubtitle": "Selecione um dispositivo serial detectado e conecte-o diretamente ao seu nó MeshCore.",
|
||||||
|
"usbScreenStatus": "Selecione um dispositivo USB",
|
||||||
|
"usbScreenNote": "A comunicação serial via USB está ativa em dispositivos Android e plataformas de desktop compatíveis.",
|
||||||
|
"usbScreenEmptyState": "Nenhum dispositivo USB encontrado. Conecte um e atualize.",
|
||||||
|
"usbErrorPermissionDenied": "A permissão para acesso via USB foi negada.",
|
||||||
|
"usbErrorDeviceMissing": "O dispositivo USB selecionado não está mais disponível.",
|
||||||
|
"usbErrorInvalidPort": "Selecione um dispositivo USB válido.",
|
||||||
|
"usbErrorBusy": "Já existe uma solicitação de conexão USB em andamento.",
|
||||||
|
"usbErrorNotConnected": "Não há nenhum dispositivo USB conectado.",
|
||||||
|
"usbErrorOpenFailed": "Não foi possível abrir o dispositivo USB selecionado.",
|
||||||
|
"usbErrorConnectFailed": "Não foi possível conectar ao dispositivo USB selecionado.",
|
||||||
|
"usbErrorUnsupported": "A comunicação serial via USB não é suportada nesta plataforma.",
|
||||||
|
"usbErrorAlreadyActive": "A conexão USB já está ativa.",
|
||||||
|
"usbErrorNoDeviceSelected": "Nenhum dispositivo USB foi selecionado.",
|
||||||
|
"usbErrorPortClosed": "A conexão USB não está ativa.",
|
||||||
|
"usbFallbackDeviceName": "Dispositivo de Serial para a Web",
|
||||||
|
"@usbConnectionFailed": {
|
||||||
|
"placeholders": {
|
||||||
|
"error": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"usbStatus_searching": "Procurando por dispositivos USB...",
|
||||||
|
"usbStatus_notConnected": "Selecione um dispositivo USB",
|
||||||
|
"usbConnectionFailed": "Falha na conexão USB: {error}",
|
||||||
|
"usbStatus_connecting": "Conectando ao dispositivo USB...",
|
||||||
|
"usbErrorConnectTimedOut": "A conexão expirou. Verifique se o dispositivo possui o firmware USB Companion.",
|
||||||
|
"@tcpStatus_connectingTo": {
|
||||||
|
"placeholders": {
|
||||||
|
"endpoint": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"@tcpConnectionFailed": {
|
||||||
|
"placeholders": {
|
||||||
|
"error": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tcpHostLabel": "Endereço IP",
|
||||||
|
"connectionChoiceTcpLabel": "TCP",
|
||||||
|
"tcpScreenTitle": "Estabelecer conexão via TCP",
|
||||||
|
"tcpHostHint": "192.168.40.10",
|
||||||
|
"tcpPortLabel": "Porta",
|
||||||
|
"tcpPortHint": "5000",
|
||||||
|
"tcpStatus_notConnected": "Insira o endereço final e conecte-se.",
|
||||||
|
"tcpStatus_connectingTo": "Conectando a {endpoint}...",
|
||||||
|
"tcpErrorHostRequired": "É necessário fornecer um endereço IP.",
|
||||||
|
"tcpErrorPortInvalid": "O valor do porto deve estar entre 1 e 65535.",
|
||||||
|
"tcpErrorUnsupported": "O protocolo TCP não é suportado nesta plataforma.",
|
||||||
|
"tcpErrorTimedOut": "A conexão TCP expirou.",
|
||||||
|
"tcpConnectionFailed": "Falha na conexão TCP: {error}",
|
||||||
|
"map_showDiscoveryContacts": "Mostrar Contatos de Descoberta",
|
||||||
|
"map_setAsMyLocation": "Defina minha localização"
|
||||||
}
|
}
|
||||||
|
|||||||
+61
-1
@@ -1068,5 +1068,65 @@
|
|||||||
"discoveredContacts_deleteContactAllContent": "Вы уверены, что хотите удалить все обнаруженные контакты?",
|
"discoveredContacts_deleteContactAllContent": "Вы уверены, что хотите удалить все обнаруженные контакты?",
|
||||||
"discoveredContacts_deleteContactAll": "Удалить Все Обнаруженные Контакты",
|
"discoveredContacts_deleteContactAll": "Удалить Все Обнаруженные Контакты",
|
||||||
"map_guessedLocation": "Угаданное место",
|
"map_guessedLocation": "Угаданное место",
|
||||||
"map_showGuessedLocations": "Отобразить предполагаемые места расположения узлов"
|
"map_showGuessedLocations": "Отобразить предполагаемые места расположения узлов",
|
||||||
|
"connectionChoiceUsbLabel": "USB",
|
||||||
|
"usbScreenSubtitle": "Выберите обнаруженное устройство с последовательным интерфейсом и подключите его напрямую к вашему узлу MeshCore.",
|
||||||
|
"usbScreenTitle": "Подключение через USB",
|
||||||
|
"connectionChoiceBluetoothLabel": "Bluetooth",
|
||||||
|
"usbScreenStatus": "Выберите USB-устройство",
|
||||||
|
"usbScreenNote": "USB-серийный порт активен на поддерживаемых устройствах Android и на настольных платформах.",
|
||||||
|
"usbScreenEmptyState": "Не обнаружено устройств USB. Подключите одно из них и обновите список.",
|
||||||
|
"usbErrorPermissionDenied": "Запрос на доступ через USB был отклонен.",
|
||||||
|
"usbErrorDeviceMissing": "Выбранное USB-устройство больше недоступно.",
|
||||||
|
"usbErrorInvalidPort": "Выберите действительное USB-устройство.",
|
||||||
|
"usbErrorBusy": "Еще одно запрошенное соединение через USB уже находится в процессе.",
|
||||||
|
"usbErrorNotConnected": "Ни одно USB-устройство не подключено.",
|
||||||
|
"usbErrorOpenFailed": "Не удалось открыть выбранное USB-устройство.",
|
||||||
|
"usbErrorConnectFailed": "Не удалось установить соединение с выбранным USB-устройством.",
|
||||||
|
"usbErrorUnsupported": "Поддержка последовательного USB отсутствует на данной платформе.",
|
||||||
|
"usbErrorAlreadyActive": "USB-соединение уже установлено.",
|
||||||
|
"usbErrorNoDeviceSelected": "Не было выбрано ни одно устройство USB.",
|
||||||
|
"usbErrorPortClosed": "USB-соединение не установлено.",
|
||||||
|
"usbFallbackDeviceName": "Устройство для последовательного подключения к сети",
|
||||||
|
"@usbConnectionFailed": {
|
||||||
|
"placeholders": {
|
||||||
|
"error": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"usbStatus_searching": "Поиск USB-устройств...",
|
||||||
|
"usbStatus_connecting": "Подключение к USB-устройству...",
|
||||||
|
"usbConnectionFailed": "Не удалось установить соединение через USB: {error}",
|
||||||
|
"usbStatus_notConnected": "Выберите USB-устройство",
|
||||||
|
"usbErrorConnectTimedOut": "Соединение не установлено. Убедитесь, что устройство имеет установленное программное обеспечение USB Companion.",
|
||||||
|
"@tcpStatus_connectingTo": {
|
||||||
|
"placeholders": {
|
||||||
|
"endpoint": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"@tcpConnectionFailed": {
|
||||||
|
"placeholders": {
|
||||||
|
"error": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tcpHostHint": "192.168.40.10",
|
||||||
|
"connectionChoiceTcpLabel": "TCP",
|
||||||
|
"tcpHostLabel": "IP-адрес",
|
||||||
|
"tcpScreenTitle": "Установить соединение по протоколу TCP",
|
||||||
|
"tcpPortLabel": "Порт",
|
||||||
|
"tcpPortHint": "5000",
|
||||||
|
"tcpStatus_notConnected": "Введите адрес и подключитесь.",
|
||||||
|
"tcpStatus_connectingTo": "Подключение к {endpoint}...",
|
||||||
|
"tcpErrorHostRequired": "Необходимо указать IP-адрес.",
|
||||||
|
"tcpErrorPortInvalid": "Порт должен находиться в диапазоне от 1 до 65535.",
|
||||||
|
"tcpErrorUnsupported": "Протокол TCP не поддерживается на этой платформе.",
|
||||||
|
"tcpErrorTimedOut": "Соединение TCP не удалось установить.",
|
||||||
|
"tcpConnectionFailed": "Не удалось установить соединение TCP: {error}",
|
||||||
|
"map_showDiscoveryContacts": "Показать контакты Discovery",
|
||||||
|
"map_setAsMyLocation": "Установить мое местоположение"
|
||||||
}
|
}
|
||||||
|
|||||||
+61
-1
@@ -1828,5 +1828,65 @@
|
|||||||
"common_deleteAll": "Zmazať všetko",
|
"common_deleteAll": "Zmazať všetko",
|
||||||
"discoveredContacts_deleteContactAllContent": "Ste si istí, že chcete zmazať všetky objavené kontakty?",
|
"discoveredContacts_deleteContactAllContent": "Ste si istí, že chcete zmazať všetky objavené kontakty?",
|
||||||
"map_showGuessedLocations": "Zobraziť umiestnenia odhadnutých uzlov",
|
"map_showGuessedLocations": "Zobraziť umiestnenia odhadnutých uzlov",
|
||||||
"map_guessedLocation": "Odhadnutá lokalita"
|
"map_guessedLocation": "Odhadnutá lokalita",
|
||||||
|
"usbScreenTitle": "Pripojte cez USB",
|
||||||
|
"usbScreenSubtitle": "Vyberte detekovaný sériový zariadenie a pripojte ho priamo k vašej MeshCore uzlu.",
|
||||||
|
"connectionChoiceUsbLabel": "USB",
|
||||||
|
"connectionChoiceBluetoothLabel": "Bluetooth",
|
||||||
|
"usbScreenStatus": "Vyberte USB zariadenie",
|
||||||
|
"usbScreenNote": "USB sériová komunikácia je aktívna na podporovaných zariadeniach s Androidom a na desktopových platformách.",
|
||||||
|
"usbScreenEmptyState": "Nenašli sa žiadne USB zariadenia. Pripojte jedno a obnovte.",
|
||||||
|
"usbErrorPermissionDenied": "Žiadosť o prístup cez USB bola zamietnutá.",
|
||||||
|
"usbErrorDeviceMissing": "Vybrané USB zariadenie už nie je dostupné.",
|
||||||
|
"usbErrorInvalidPort": "Vyberte platné USB zariadenie.",
|
||||||
|
"usbErrorBusy": "Ďalšia požiadavka na pripojenie cez USB je aktuálne v procese.",
|
||||||
|
"usbErrorNotConnected": "Nie je pripojené žiadne USB zariadenie.",
|
||||||
|
"usbErrorOpenFailed": "Nepodarilo sa otvoriť vybrané USB zariadenie.",
|
||||||
|
"usbErrorConnectFailed": "Nepodarilo sa sa sa pripojiť k vybranému USB zariadeniu.",
|
||||||
|
"usbErrorUnsupported": "Podpora USB sériového rozhrania nie je na tejto platforme dostupná.",
|
||||||
|
"usbErrorAlreadyActive": "Pripojenie cez USB je už aktivované.",
|
||||||
|
"usbErrorNoDeviceSelected": "Nebolo vybrané žiadne USB zariadenie.",
|
||||||
|
"usbErrorPortClosed": "Pripojenie cez USB nie je aktivované.",
|
||||||
|
"usbFallbackDeviceName": "Webový sériový zariadenie",
|
||||||
|
"@usbConnectionFailed": {
|
||||||
|
"placeholders": {
|
||||||
|
"error": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"usbStatus_searching": "Hľadanie USB zariadení...",
|
||||||
|
"usbConnectionFailed": "Neúspešné pripojenie cez USB: {error}",
|
||||||
|
"usbStatus_notConnected": "Vyberte USB zariadenie",
|
||||||
|
"usbStatus_connecting": "Pripojenie k USB zariadeniu...",
|
||||||
|
"usbErrorConnectTimedOut": "Pripojenie nebolo úspešné. Uistite sa, že zariadenie má nainštalovaný firmware USB Companion.",
|
||||||
|
"@tcpStatus_connectingTo": {
|
||||||
|
"placeholders": {
|
||||||
|
"endpoint": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"@tcpConnectionFailed": {
|
||||||
|
"placeholders": {
|
||||||
|
"error": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tcpHostHint": "192.168.40.10",
|
||||||
|
"tcpHostLabel": "IP adresa",
|
||||||
|
"tcpScreenTitle": "Spojte sa pomocou protokolu TCP",
|
||||||
|
"connectionChoiceTcpLabel": "TCP",
|
||||||
|
"tcpPortLabel": "Port",
|
||||||
|
"tcpPortHint": "5000",
|
||||||
|
"tcpStatus_notConnected": "Zadajte cieľovú adresu a pripojte sa.",
|
||||||
|
"tcpStatus_connectingTo": "Pripojenie k {endpoint}...",
|
||||||
|
"tcpErrorHostRequired": "Je potrebné zadať IP adresu.",
|
||||||
|
"tcpErrorPortInvalid": "Číslo portu musí byť medzi 1 a 65535.",
|
||||||
|
"tcpErrorUnsupported": "Prevoz prostredníctvom protokolu TCP nie je na tejto platforme podporovaný.",
|
||||||
|
"tcpErrorTimedOut": "Pripojenie TCP vypršalo.",
|
||||||
|
"tcpConnectionFailed": "Neúspešné vytvorenie TCP spojenia: {error}",
|
||||||
|
"map_showDiscoveryContacts": "Zobraziť kontakty objavov",
|
||||||
|
"map_setAsMyLocation": "Nastavte ako moju polohu"
|
||||||
}
|
}
|
||||||
|
|||||||
+61
-1
@@ -1828,5 +1828,65 @@
|
|||||||
"discoveredContacts_deleteContactAllContent": "Ste prepričani, da želite izbrisati vse odkrite kontakte?",
|
"discoveredContacts_deleteContactAllContent": "Ste prepričani, da želite izbrisati vse odkrite kontakte?",
|
||||||
"discoveredContacts_deleteContactAll": "Izbriši vse odkrite kontakte",
|
"discoveredContacts_deleteContactAll": "Izbriši vse odkrite kontakte",
|
||||||
"map_guessedLocation": "Predpostavljena lokacija",
|
"map_guessedLocation": "Predpostavljena lokacija",
|
||||||
"map_showGuessedLocations": "Pokaži lokacije domnevnih not."
|
"map_showGuessedLocations": "Pokaži lokacije domnevnih not.",
|
||||||
|
"usbScreenTitle": "Povežite preko USB",
|
||||||
|
"usbScreenSubtitle": "Izberite zaznano serijsko napravo in se neposredno povežite z vašo MeshCore napravo.",
|
||||||
|
"connectionChoiceBluetoothLabel": "Bluetooth",
|
||||||
|
"connectionChoiceUsbLabel": "USB",
|
||||||
|
"usbScreenStatus": "Izberite USB naprave",
|
||||||
|
"usbScreenNote": "USB serijska povezava je aktivna na podprtih napravah Android in na desktop platformah.",
|
||||||
|
"usbScreenEmptyState": "Niti en USB naprave niso najdeni. Povežite eno in posodobite.",
|
||||||
|
"usbErrorPermissionDenied": "Dovoljenje za dostop preko USB-ja je bilo zavrnjeno.",
|
||||||
|
"usbErrorDeviceMissing": "Izbrani USB napravej je več ne.",
|
||||||
|
"usbErrorInvalidPort": "Izberite veljavno USB naprave.",
|
||||||
|
"usbErrorBusy": "Že je v teku zahteva za povezavo preko USB.",
|
||||||
|
"usbErrorNotConnected": "Ni priklopljenih USB naprave.",
|
||||||
|
"usbErrorOpenFailed": "Uspešno ni bilo mogo, da se odpre izbran naprave USB.",
|
||||||
|
"usbErrorConnectFailed": "Niso bilo mogoče uskladiti povezave z izbranim USB napom.",
|
||||||
|
"usbErrorUnsupported": "USB serijska komunikacija ni podprta na tej platformi.",
|
||||||
|
"usbErrorAlreadyActive": "USB povezava je že aktivirana.",
|
||||||
|
"usbErrorNoDeviceSelected": "Ni bilo izbranega USB naprave.",
|
||||||
|
"usbErrorPortClosed": "USB povezava ni aktivirana.",
|
||||||
|
"usbFallbackDeviceName": "Naprave za serijsko komunikacijo preko spleta",
|
||||||
|
"@usbConnectionFailed": {
|
||||||
|
"placeholders": {
|
||||||
|
"error": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"usbStatus_notConnected": "Izberite USB naprave.",
|
||||||
|
"usbStatus_connecting": "Povezava z USB napravo...",
|
||||||
|
"usbStatus_searching": "Iskanje USB naprav...",
|
||||||
|
"usbConnectionFailed": "Napaka pri povezavi preko USB: {error}",
|
||||||
|
"usbErrorConnectTimedOut": "Vzpostavitve ni bilo mogo. Prosimo, da se prepričate, da ima naprave trenutno nameštan firmware USB Companion.",
|
||||||
|
"@tcpStatus_connectingTo": {
|
||||||
|
"placeholders": {
|
||||||
|
"endpoint": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"@tcpConnectionFailed": {
|
||||||
|
"placeholders": {
|
||||||
|
"error": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"connectionChoiceTcpLabel": "TCP",
|
||||||
|
"tcpHostLabel": "IP naslov",
|
||||||
|
"tcpHostHint": "192.168.40.10",
|
||||||
|
"tcpScreenTitle": "Komunicirajte preko protokola TCP",
|
||||||
|
"tcpPortLabel": "Vrata",
|
||||||
|
"tcpPortHint": "5000",
|
||||||
|
"tcpStatus_notConnected": "Vnesite končni naslov in se povežite",
|
||||||
|
"tcpStatus_connectingTo": "Povezava z {endpoint}...",
|
||||||
|
"tcpErrorHostRequired": "Potrebna je IP-naslov.",
|
||||||
|
"tcpErrorPortInvalid": "Port mora biti med 1 in 65535.",
|
||||||
|
"tcpErrorUnsupported": "Transport preko protokola TCP ni podprt na tej platformi.",
|
||||||
|
"tcpErrorTimedOut": "Povezava TCP je presegla časovno obdobje.",
|
||||||
|
"tcpConnectionFailed": "Napaka pri povezavi TCP: {error}",
|
||||||
|
"map_showDiscoveryContacts": "Prikaži odkritja kontaktov",
|
||||||
|
"map_setAsMyLocation": "Nastavite to kot mojo lokacijo"
|
||||||
}
|
}
|
||||||
|
|||||||
+61
-1
@@ -1828,5 +1828,65 @@
|
|||||||
"discoveredContacts_deleteContactAllContent": "Är du säker på att du vill ta bort alla upptäckta kontakter?",
|
"discoveredContacts_deleteContactAllContent": "Är du säker på att du vill ta bort alla upptäckta kontakter?",
|
||||||
"discoveredContacts_deleteContactAll": "Ta bort alla upptäckta kontakter",
|
"discoveredContacts_deleteContactAll": "Ta bort alla upptäckta kontakter",
|
||||||
"map_guessedLocation": "Gissad plats",
|
"map_guessedLocation": "Gissad plats",
|
||||||
"map_showGuessedLocations": "Visa upp de antagna nodernas placeringar"
|
"map_showGuessedLocations": "Visa upp de antagna nodernas placeringar",
|
||||||
|
"connectionChoiceBluetoothLabel": "Bluetooth",
|
||||||
|
"connectionChoiceUsbLabel": "USB",
|
||||||
|
"usbScreenSubtitle": "Välj en detekterad seriell enhet och anslut direkt till din MeshCore-nod.",
|
||||||
|
"usbScreenTitle": "Anslut via USB",
|
||||||
|
"usbScreenStatus": "Välj en USB-enhet",
|
||||||
|
"usbScreenNote": "USB-seriell kommunikation är aktiv på stödda Android-enheter och på skrivbordsplattformar.",
|
||||||
|
"usbScreenEmptyState": "Inga USB-enheter hittades. Anslut en och uppdatera.",
|
||||||
|
"usbErrorPermissionDenied": "Tillgången via USB nekas.",
|
||||||
|
"usbErrorDeviceMissing": "Den valda USB-enheten är inte längre tillgänglig.",
|
||||||
|
"usbErrorInvalidPort": "Välj en giltig USB-enhet.",
|
||||||
|
"usbErrorBusy": "En annan förfrågan om USB-anslutning är redan pågående.",
|
||||||
|
"usbErrorNotConnected": "Ingen USB-enhet är ansluten.",
|
||||||
|
"usbErrorOpenFailed": "Misslyckades med att öppna det valda USB-enheten.",
|
||||||
|
"usbErrorConnectFailed": "Kunde inte ansluta till det valda USB-enheten.",
|
||||||
|
"usbErrorUnsupported": "USB-seriell kommunikation stöds inte på denna plattform.",
|
||||||
|
"usbErrorAlreadyActive": "En USB-anslutning är redan aktiv.",
|
||||||
|
"usbErrorNoDeviceSelected": "Ingen USB-enhet valdes.",
|
||||||
|
"usbErrorPortClosed": "USB-anslutningen är inte aktiv.",
|
||||||
|
"usbFallbackDeviceName": "Web-serieenhet",
|
||||||
|
"@usbConnectionFailed": {
|
||||||
|
"placeholders": {
|
||||||
|
"error": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"usbStatus_connecting": "Anslutning till USB-enhet...",
|
||||||
|
"usbStatus_notConnected": "Välj en USB-enhet",
|
||||||
|
"usbConnectionFailed": "Fel vid USB-anslutning: {error}",
|
||||||
|
"usbStatus_searching": "Söker efter USB-enheter...",
|
||||||
|
"usbErrorConnectTimedOut": "Anslutningen har tidsutgått. Se till att enheten har rätt USB-firmware.",
|
||||||
|
"@tcpStatus_connectingTo": {
|
||||||
|
"placeholders": {
|
||||||
|
"endpoint": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"@tcpConnectionFailed": {
|
||||||
|
"placeholders": {
|
||||||
|
"error": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tcpHostHint": "192.168.40.10",
|
||||||
|
"tcpHostLabel": "IP-adress",
|
||||||
|
"tcpScreenTitle": "Anslut via TCP",
|
||||||
|
"connectionChoiceTcpLabel": "TCP",
|
||||||
|
"tcpPortLabel": "Port",
|
||||||
|
"tcpPortHint": "5000",
|
||||||
|
"tcpStatus_notConnected": "Ange slutpunkt och anslut",
|
||||||
|
"tcpStatus_connectingTo": "Anslutning till {endpoint}...",
|
||||||
|
"tcpErrorHostRequired": "IP-adress krävs.",
|
||||||
|
"tcpErrorPortInvalid": "Porten måste vara mellan 1 och 65535.",
|
||||||
|
"tcpErrorUnsupported": "TCP-transport fungerar inte på denna plattform.",
|
||||||
|
"tcpErrorTimedOut": "TCP-anslutningen har tidsut gått.",
|
||||||
|
"tcpConnectionFailed": "Fel vid TCP-anslutning: {error}",
|
||||||
|
"map_showDiscoveryContacts": "Visa Discovery-kontakter",
|
||||||
|
"map_setAsMyLocation": "Ange som min plats"
|
||||||
}
|
}
|
||||||
|
|||||||
+61
-1
@@ -1828,5 +1828,65 @@
|
|||||||
"discoveredContacts_deleteContactAll": "Видалити всі виявлені контакти",
|
"discoveredContacts_deleteContactAll": "Видалити всі виявлені контакти",
|
||||||
"discoveredContacts_deleteContactAllContent": "Ви впевнені, що хочете видалити всі виявлені контакти?",
|
"discoveredContacts_deleteContactAllContent": "Ви впевнені, що хочете видалити всі виявлені контакти?",
|
||||||
"map_showGuessedLocations": "Показати місцезнаходження передбачених вузлів",
|
"map_showGuessedLocations": "Показати місцезнаходження передбачених вузлів",
|
||||||
"map_guessedLocation": "Визначено місцезнаходження"
|
"map_guessedLocation": "Визначено місцезнаходження",
|
||||||
|
"usbScreenSubtitle": "Виберіть виявлене серійне пристрій і підключіть його безпосередньо до вашого вузла MeshCore.",
|
||||||
|
"usbScreenTitle": "Підключити через USB",
|
||||||
|
"connectionChoiceBluetoothLabel": "Bluetooth",
|
||||||
|
"connectionChoiceUsbLabel": "USB",
|
||||||
|
"usbScreenStatus": "Виберіть пристрій USB",
|
||||||
|
"usbScreenNote": "USB-серіальний інтерфейс активний на підтримуваних пристроях на базі Android та на десктопних платформах.",
|
||||||
|
"usbScreenEmptyState": "Не знайдено жодних пристроїв USB. Підключіть один і перезавантажте.",
|
||||||
|
"usbErrorPermissionDenied": "Було відмовлено у наданні дозволу на використання USB.",
|
||||||
|
"usbErrorDeviceMissing": "Вибране USB-пристрій більше недоступне.",
|
||||||
|
"usbErrorInvalidPort": "Виберіть дійсний USB-пристрій.",
|
||||||
|
"usbErrorBusy": "Ще один запит на підключення через USB вже обробляється.",
|
||||||
|
"usbErrorNotConnected": "Немає підключених пристроїв USB.",
|
||||||
|
"usbErrorOpenFailed": "Не вдалося відкрити вибране USB-пристрій.",
|
||||||
|
"usbErrorConnectFailed": "Не вдалося підключитися до вибраного USB-пристрою.",
|
||||||
|
"usbErrorUnsupported": "Підтримка USB-серіального інтерфейсу не реалізована на цій платформі.",
|
||||||
|
"usbErrorAlreadyActive": "USB-з'єднання вже встановлено.",
|
||||||
|
"usbErrorNoDeviceSelected": "Не було вибрано жодного пристрою USB.",
|
||||||
|
"usbErrorPortClosed": "З'єднання USB не встановлено.",
|
||||||
|
"usbFallbackDeviceName": "Пристрій для передачі даних по веб-серіалах",
|
||||||
|
"@usbConnectionFailed": {
|
||||||
|
"placeholders": {
|
||||||
|
"error": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"usbStatus_searching": "Пошук пристроїв USB...",
|
||||||
|
"usbStatus_notConnected": "Виберіть пристрій USB",
|
||||||
|
"usbConnectionFailed": "Не вдалося встановити з'єднання через USB: {error}",
|
||||||
|
"usbStatus_connecting": "Підключення до USB-пристрою...",
|
||||||
|
"usbErrorConnectTimedOut": "З'єднання не вдалося встановити. Переконайтеся, що пристрій має встановлене програмне забезпечення USB Companion.",
|
||||||
|
"@tcpStatus_connectingTo": {
|
||||||
|
"placeholders": {
|
||||||
|
"endpoint": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"@tcpConnectionFailed": {
|
||||||
|
"placeholders": {
|
||||||
|
"error": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"connectionChoiceTcpLabel": "TCP",
|
||||||
|
"tcpHostHint": "192.168.40.10",
|
||||||
|
"tcpHostLabel": "IP-адреса",
|
||||||
|
"tcpScreenTitle": "З'єднатися через протокол TCP",
|
||||||
|
"tcpPortLabel": "Порт",
|
||||||
|
"tcpPortHint": "5000",
|
||||||
|
"tcpStatus_notConnected": "Введіть кінцеву точку та підключіться",
|
||||||
|
"tcpStatus_connectingTo": "Підключення до {endpoint}...",
|
||||||
|
"tcpErrorHostRequired": "Необхідно вказати IP-адресу.",
|
||||||
|
"tcpErrorPortInvalid": "Порт повинен бути в межах від 1 до 65535.",
|
||||||
|
"tcpErrorUnsupported": "Транспорт TCP не підтримується на цій платформі.",
|
||||||
|
"tcpErrorTimedOut": "З'єднання TCP завершилося через закінчення часу очікування.",
|
||||||
|
"tcpConnectionFailed": "Не вдалося встановити з'єднання TCP: {error}",
|
||||||
|
"map_showDiscoveryContacts": "Показати контакти Відкриття",
|
||||||
|
"map_setAsMyLocation": "Встановити моє місцезнаходження"
|
||||||
}
|
}
|
||||||
|
|||||||
+61
-1
@@ -1833,5 +1833,65 @@
|
|||||||
"discoveredContacts_deleteContactAllContent": "您确定要删除所有发现的联系人吗?",
|
"discoveredContacts_deleteContactAllContent": "您确定要删除所有发现的联系人吗?",
|
||||||
"discoveredContacts_deleteContactAll": "删除所有发现的联系人",
|
"discoveredContacts_deleteContactAll": "删除所有发现的联系人",
|
||||||
"map_showGuessedLocations": "显示猜测的节点位置",
|
"map_showGuessedLocations": "显示猜测的节点位置",
|
||||||
"map_guessedLocation": "猜测的位置"
|
"map_guessedLocation": "猜测的位置",
|
||||||
|
"connectionChoiceUsbLabel": "USB",
|
||||||
|
"usbScreenTitle": "通过USB连接",
|
||||||
|
"usbScreenSubtitle": "选择已检测到的串行设备,并直接连接到您的 MeshCore 节点。",
|
||||||
|
"connectionChoiceBluetoothLabel": "蓝牙",
|
||||||
|
"usbScreenStatus": "选择一个 USB 设备",
|
||||||
|
"usbScreenNote": "USB 串行接口在支持的 Android 设备和桌面平台上处于活动状态。",
|
||||||
|
"usbScreenEmptyState": "未找到任何 USB 设备。请插入一个,然后刷新。",
|
||||||
|
"usbErrorPermissionDenied": "拒绝了USB权限。",
|
||||||
|
"usbErrorDeviceMissing": "所选的USB设备已不再可用。",
|
||||||
|
"usbErrorInvalidPort": "选择一个有效的USB设备。",
|
||||||
|
"usbErrorBusy": "还有一个 USB 连接请求正在进行中。",
|
||||||
|
"usbErrorNotConnected": "没有连接任何USB设备。",
|
||||||
|
"usbErrorOpenFailed": "未能打开所选的USB设备。",
|
||||||
|
"usbErrorConnectFailed": "未能连接到所选的USB设备。",
|
||||||
|
"usbErrorUnsupported": "此平台不支持USB串行通信。",
|
||||||
|
"usbErrorAlreadyActive": "USB 连接已建立。",
|
||||||
|
"usbErrorNoDeviceSelected": "未选择任何 USB 设备。",
|
||||||
|
"usbErrorPortClosed": "USB 连接未建立。",
|
||||||
|
"usbFallbackDeviceName": "Web 串流设备",
|
||||||
|
"@usbConnectionFailed": {
|
||||||
|
"placeholders": {
|
||||||
|
"error": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"usbStatus_searching": "正在搜索 USB 设备...",
|
||||||
|
"usbStatus_connecting": "连接USB设备...",
|
||||||
|
"usbStatus_notConnected": "选择一个 USB 设备",
|
||||||
|
"usbConnectionFailed": "USB 连接失败:{error}",
|
||||||
|
"usbErrorConnectTimedOut": "连接超时。请确保设备已安装 USB 伴侣固件。",
|
||||||
|
"@tcpStatus_connectingTo": {
|
||||||
|
"placeholders": {
|
||||||
|
"endpoint": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"@tcpConnectionFailed": {
|
||||||
|
"placeholders": {
|
||||||
|
"error": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tcpHostLabel": "IP地址",
|
||||||
|
"tcpHostHint": "192.168.40.10",
|
||||||
|
"tcpScreenTitle": "通过 TCP 连接",
|
||||||
|
"connectionChoiceTcpLabel": "TCP",
|
||||||
|
"tcpPortLabel": "端口",
|
||||||
|
"tcpPortHint": "5000",
|
||||||
|
"tcpStatus_notConnected": "输入目标地址,然后连接",
|
||||||
|
"tcpStatus_connectingTo": "连接到 {endpoint}...",
|
||||||
|
"tcpErrorHostRequired": "需要提供IP地址。",
|
||||||
|
"tcpErrorPortInvalid": "端口号必须在 1 到 65535 之间。",
|
||||||
|
"tcpErrorUnsupported": "此平台不支持 TCP 传输。",
|
||||||
|
"tcpErrorTimedOut": "TCP 连接超时。",
|
||||||
|
"tcpConnectionFailed": "TCP 连接失败:{error}",
|
||||||
|
"map_showDiscoveryContacts": "显示发现联系人",
|
||||||
|
"map_setAsMyLocation": "设置为我的位置"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ class AppSettings {
|
|||||||
final Map<String, String> batteryChemistryByRepeaterId;
|
final Map<String, String> batteryChemistryByRepeaterId;
|
||||||
final UnitSystem unitSystem;
|
final UnitSystem unitSystem;
|
||||||
final Set<String> mutedChannels;
|
final Set<String> mutedChannels;
|
||||||
|
final bool mapShowDiscoveryContacts;
|
||||||
|
|
||||||
AppSettings({
|
AppSettings({
|
||||||
this.clearPathOnMaxRetry = false,
|
this.clearPathOnMaxRetry = false,
|
||||||
@@ -66,6 +67,7 @@ class AppSettings {
|
|||||||
Map<String, String>? batteryChemistryByRepeaterId,
|
Map<String, String>? batteryChemistryByRepeaterId,
|
||||||
this.unitSystem = UnitSystem.metric,
|
this.unitSystem = UnitSystem.metric,
|
||||||
Set<String>? mutedChannels,
|
Set<String>? mutedChannels,
|
||||||
|
this.mapShowDiscoveryContacts = true,
|
||||||
}) : batteryChemistryByDeviceId = batteryChemistryByDeviceId ?? {},
|
}) : batteryChemistryByDeviceId = batteryChemistryByDeviceId ?? {},
|
||||||
batteryChemistryByRepeaterId = batteryChemistryByRepeaterId ?? {},
|
batteryChemistryByRepeaterId = batteryChemistryByRepeaterId ?? {},
|
||||||
mutedChannels = mutedChannels ?? {};
|
mutedChannels = mutedChannels ?? {};
|
||||||
@@ -97,6 +99,7 @@ class AppSettings {
|
|||||||
'battery_chemistry_by_repeater_id': batteryChemistryByRepeaterId,
|
'battery_chemistry_by_repeater_id': batteryChemistryByRepeaterId,
|
||||||
'unit_system': unitSystem.value,
|
'unit_system': unitSystem.value,
|
||||||
'muted_channels': mutedChannels.toList(),
|
'muted_channels': mutedChannels.toList(),
|
||||||
|
'map_show_discovery_contacts': mapShowDiscoveryContacts,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,6 +155,8 @@ class AppSettings {
|
|||||||
?.map((e) => e.toString())
|
?.map((e) => e.toString())
|
||||||
.toSet()) ??
|
.toSet()) ??
|
||||||
{},
|
{},
|
||||||
|
mapShowDiscoveryContacts:
|
||||||
|
json['map_show_discovery_contacts'] as bool? ?? true,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,6 +186,7 @@ class AppSettings {
|
|||||||
Map<String, String>? batteryChemistryByRepeaterId,
|
Map<String, String>? batteryChemistryByRepeaterId,
|
||||||
UnitSystem? unitSystem,
|
UnitSystem? unitSystem,
|
||||||
Set<String>? mutedChannels,
|
Set<String>? mutedChannels,
|
||||||
|
bool? mapShowDiscoveryContacts,
|
||||||
}) {
|
}) {
|
||||||
return AppSettings(
|
return AppSettings(
|
||||||
clearPathOnMaxRetry: clearPathOnMaxRetry ?? this.clearPathOnMaxRetry,
|
clearPathOnMaxRetry: clearPathOnMaxRetry ?? this.clearPathOnMaxRetry,
|
||||||
@@ -217,6 +223,8 @@ class AppSettings {
|
|||||||
batteryChemistryByRepeaterId ?? this.batteryChemistryByRepeaterId,
|
batteryChemistryByRepeaterId ?? this.batteryChemistryByRepeaterId,
|
||||||
unitSystem: unitSystem ?? this.unitSystem,
|
unitSystem: unitSystem ?? this.unitSystem,
|
||||||
mutedChannels: mutedChannels ?? this.mutedChannels,
|
mutedChannels: mutedChannels ?? this.mutedChannels,
|
||||||
|
mapShowDiscoveryContacts:
|
||||||
|
mapShowDiscoveryContacts ?? this.mapShowDiscoveryContacts,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+30
-19
@@ -1,4 +1,6 @@
|
|||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
|
import 'package:meshcore_open/utils/app_logger.dart';
|
||||||
|
|
||||||
import '../connector/meshcore_protocol.dart';
|
import '../connector/meshcore_protocol.dart';
|
||||||
|
|
||||||
class Contact {
|
class Contact {
|
||||||
@@ -15,6 +17,8 @@ class Contact {
|
|||||||
final double? longitude;
|
final double? longitude;
|
||||||
final DateTime lastSeen;
|
final DateTime lastSeen;
|
||||||
final DateTime lastMessageAt;
|
final DateTime lastMessageAt;
|
||||||
|
final bool isActive;
|
||||||
|
final Uint8List? rawPacket;
|
||||||
|
|
||||||
Contact({
|
Contact({
|
||||||
required this.publicKey,
|
required this.publicKey,
|
||||||
@@ -29,6 +33,8 @@ class Contact {
|
|||||||
this.longitude,
|
this.longitude,
|
||||||
required this.lastSeen,
|
required this.lastSeen,
|
||||||
DateTime? lastMessageAt,
|
DateTime? lastMessageAt,
|
||||||
|
this.isActive = true,
|
||||||
|
this.rawPacket,
|
||||||
}) : lastMessageAt = lastMessageAt ?? lastSeen;
|
}) : lastMessageAt = lastMessageAt ?? lastSeen;
|
||||||
|
|
||||||
String get publicKeyHex => pubKeyToHex(publicKey);
|
String get publicKeyHex => pubKeyToHex(publicKey);
|
||||||
@@ -76,6 +82,8 @@ class Contact {
|
|||||||
double? longitude,
|
double? longitude,
|
||||||
DateTime? lastSeen,
|
DateTime? lastSeen,
|
||||||
DateTime? lastMessageAt,
|
DateTime? lastMessageAt,
|
||||||
|
bool? isActive,
|
||||||
|
Uint8List? rawPacket,
|
||||||
}) {
|
}) {
|
||||||
return Contact(
|
return Contact(
|
||||||
publicKey: publicKey ?? this.publicKey,
|
publicKey: publicKey ?? this.publicKey,
|
||||||
@@ -94,6 +102,8 @@ class Contact {
|
|||||||
longitude: longitude ?? this.longitude,
|
longitude: longitude ?? this.longitude,
|
||||||
lastSeen: lastSeen ?? this.lastSeen,
|
lastSeen: lastSeen ?? this.lastSeen,
|
||||||
lastMessageAt: lastMessageAt ?? this.lastMessageAt,
|
lastMessageAt: lastMessageAt ?? this.lastMessageAt,
|
||||||
|
isActive: isActive ?? this.isActive,
|
||||||
|
rawPacket: rawPacket ?? this.rawPacket,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,28 +176,27 @@ class Contact {
|
|||||||
|
|
||||||
static Contact? fromFrame(Uint8List data) {
|
static Contact? fromFrame(Uint8List data) {
|
||||||
if (data.isEmpty) return null;
|
if (data.isEmpty) return null;
|
||||||
if (data[0] != respCodeContact) return null;
|
final reader = BufferReader(data);
|
||||||
try {
|
try {
|
||||||
final pubKey = Uint8List.fromList(
|
final respCode = reader.readByte();
|
||||||
data.sublist(contactPubKeyOffset, contactPubKeyOffset + pubKeySize),
|
if (respCode != respCodeContact && respCode != pushCodeNewAdvert) {
|
||||||
);
|
return null;
|
||||||
final type = data[contactTypeOffset];
|
}
|
||||||
final flags = data[contactFlagsOffset];
|
final pubKey = reader.readBytes(pubKeySize);
|
||||||
final pathLen = data[contactPathLenOffset].toSigned(8);
|
final type = reader.readByte();
|
||||||
|
final flags = reader.readByte();
|
||||||
|
final pathLen = reader.readByte();
|
||||||
final safePathLen = pathLen > 0
|
final safePathLen = pathLen > 0
|
||||||
? (pathLen > maxPathSize ? maxPathSize : pathLen)
|
? (pathLen > maxPathSize ? maxPathSize : pathLen)
|
||||||
: 0;
|
: 0;
|
||||||
final pathBytes = safePathLen > 0
|
final pathBytes = reader.readBytes(maxPathSize).sublist(0, safePathLen);
|
||||||
? Uint8List.fromList(
|
final name = reader.readCStringGreedy(maxNameSize);
|
||||||
data.sublist(contactPathOffset, contactPathOffset + safePathLen),
|
|
||||||
)
|
final lastMod = reader.readUInt32LE();
|
||||||
: Uint8List(0);
|
|
||||||
final name = readCString(data, contactNameOffset, maxNameSize);
|
|
||||||
final lastmod = readUint32LE(data, contactLastModOffset);
|
|
||||||
|
|
||||||
double? lat, lon;
|
double? lat, lon;
|
||||||
final latRaw = readInt32LE(data, contactLatOffset);
|
final latRaw = reader.readInt32LE();
|
||||||
final lonRaw = readInt32LE(data, contactLonOffset);
|
final lonRaw = reader.readInt32LE();
|
||||||
if (latRaw != 0 || lonRaw != 0) {
|
if (latRaw != 0 || lonRaw != 0) {
|
||||||
lat = latRaw / 1e6;
|
lat = latRaw / 1e6;
|
||||||
lon = lonRaw / 1e6;
|
lon = lonRaw / 1e6;
|
||||||
@@ -198,14 +207,16 @@ class Contact {
|
|||||||
name: name.isEmpty ? 'Unknown' : name,
|
name: name.isEmpty ? 'Unknown' : name,
|
||||||
type: type,
|
type: type,
|
||||||
flags: flags,
|
flags: flags,
|
||||||
pathLength: pathLen,
|
pathLength: pathLen > 0 ? (pathLen > maxPathSize ? -1 : pathLen) : -1,
|
||||||
path: pathBytes,
|
path: pathBytes,
|
||||||
latitude: lat,
|
latitude: lat,
|
||||||
longitude: lon,
|
longitude: lon,
|
||||||
lastSeen: DateTime.fromMillisecondsSinceEpoch(lastmod * 1000),
|
lastSeen: DateTime.fromMillisecondsSinceEpoch(lastMod * 1000),
|
||||||
|
isActive: true,
|
||||||
|
rawPacket: null,
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// If parsing fails, return null
|
appLogger.error('Failed to parse contact frame: $e');
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,105 +0,0 @@
|
|||||||
import 'dart:typed_data';
|
|
||||||
import '../connector/meshcore_protocol.dart';
|
|
||||||
|
|
||||||
class DiscoveryContact {
|
|
||||||
final Uint8List rawPacket;
|
|
||||||
final Uint8List publicKey;
|
|
||||||
final String name;
|
|
||||||
final int type;
|
|
||||||
final int pathLength; // -1 = flood, 0+ = direct hops (from device)
|
|
||||||
final Uint8List path; // Path bytes from device
|
|
||||||
final double? latitude;
|
|
||||||
final double? longitude;
|
|
||||||
final DateTime lastSeen;
|
|
||||||
|
|
||||||
DiscoveryContact({
|
|
||||||
required this.rawPacket,
|
|
||||||
required this.publicKey,
|
|
||||||
required this.name,
|
|
||||||
required this.type,
|
|
||||||
required this.pathLength,
|
|
||||||
required this.path,
|
|
||||||
this.latitude,
|
|
||||||
this.longitude,
|
|
||||||
required this.lastSeen,
|
|
||||||
});
|
|
||||||
|
|
||||||
String get publicKeyHex => pubKeyToHex(publicKey);
|
|
||||||
|
|
||||||
String get typeLabel {
|
|
||||||
switch (type) {
|
|
||||||
case advTypeChat:
|
|
||||||
return 'Chat';
|
|
||||||
case advTypeRepeater:
|
|
||||||
return 'Repeater';
|
|
||||||
case advTypeRoom:
|
|
||||||
return 'Room';
|
|
||||||
case advTypeSensor:
|
|
||||||
return 'Sensor';
|
|
||||||
default:
|
|
||||||
return 'Unknown';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
String get pathLabel {
|
|
||||||
if (pathLength < 0) return 'Flood';
|
|
||||||
if (pathLength == 0) return 'Direct';
|
|
||||||
return '$pathLength hops';
|
|
||||||
}
|
|
||||||
|
|
||||||
bool get hasLocation => latitude != null && longitude != null;
|
|
||||||
|
|
||||||
DiscoveryContact copyWith({
|
|
||||||
Uint8List? rawPacket,
|
|
||||||
Uint8List? publicKey,
|
|
||||||
String? name,
|
|
||||||
int? type,
|
|
||||||
int? pathLength,
|
|
||||||
Uint8List? path,
|
|
||||||
double? latitude,
|
|
||||||
double? longitude,
|
|
||||||
DateTime? lastSeen,
|
|
||||||
}) {
|
|
||||||
return DiscoveryContact(
|
|
||||||
rawPacket: rawPacket ?? this.rawPacket,
|
|
||||||
publicKey: publicKey ?? this.publicKey,
|
|
||||||
name: name ?? this.name,
|
|
||||||
type: type ?? this.type,
|
|
||||||
pathLength: pathLength ?? this.pathLength,
|
|
||||||
path: path ?? this.path,
|
|
||||||
latitude: latitude ?? this.latitude,
|
|
||||||
longitude: longitude ?? this.longitude,
|
|
||||||
lastSeen: lastSeen ?? this.lastSeen,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
String get pathIdList {
|
|
||||||
final pathBytes = path;
|
|
||||||
if (pathBytes.isEmpty) return '';
|
|
||||||
final parts = <String>[];
|
|
||||||
final groupSize = pathHashSize;
|
|
||||||
for (int i = 0; i < pathBytes.length; i += groupSize) {
|
|
||||||
final end = (i + groupSize) <= pathBytes.length
|
|
||||||
? (i + groupSize)
|
|
||||||
: pathBytes.length;
|
|
||||||
final chunk = pathBytes.sublist(i, end);
|
|
||||||
parts.add(
|
|
||||||
chunk
|
|
||||||
.map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase())
|
|
||||||
.join(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return parts.join(',');
|
|
||||||
}
|
|
||||||
|
|
||||||
String get shortPubKeyHex {
|
|
||||||
return "<${publicKeyHex.substring(0, 8)}...${publicKeyHex.substring(publicKeyHex.length - 8)}>";
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
bool operator ==(Object other) =>
|
|
||||||
other is DiscoveryContact && publicKeyHex == other.publicKeyHex;
|
|
||||||
|
|
||||||
@override
|
|
||||||
int get hashCode => publicKeyHex.hashCode;
|
|
||||||
}
|
|
||||||
@@ -118,6 +118,19 @@ class _BleDebugLogScreenState extends State<BleDebugLogScreen> {
|
|||||||
: Icons.download,
|
: Icons.download,
|
||||||
size: 18,
|
size: 18,
|
||||||
),
|
),
|
||||||
|
onLongPress: () async {
|
||||||
|
await Clipboard.setData(
|
||||||
|
ClipboardData(
|
||||||
|
text: entry.payload
|
||||||
|
.map(
|
||||||
|
(b) => b
|
||||||
|
.toRadixString(16)
|
||||||
|
.padLeft(2, '0'),
|
||||||
|
)
|
||||||
|
.join(''),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -40,8 +40,11 @@ class ChannelMessagePathScreen extends StatelessWidget {
|
|||||||
final primaryPath = !channelMessage && !message.isOutgoing
|
final primaryPath = !channelMessage && !message.isOutgoing
|
||||||
? Uint8List.fromList(primaryPathTmp.reversed.toList())
|
? Uint8List.fromList(primaryPathTmp.reversed.toList())
|
||||||
: primaryPathTmp;
|
: primaryPathTmp;
|
||||||
|
final contacts = <Contact>[
|
||||||
final hops = _buildPathHops(primaryPath, connector.contacts, l10n);
|
...connector.contacts,
|
||||||
|
...connector.discoveredContacts,
|
||||||
|
];
|
||||||
|
final hops = _buildPathHops(primaryPath, contacts, l10n);
|
||||||
final hasHopDetails = primaryPath.isNotEmpty;
|
final hasHopDetails = primaryPath.isNotEmpty;
|
||||||
final observedLabel = _formatObservedHops(
|
final observedLabel = _formatObservedHops(
|
||||||
primaryPath.length,
|
primaryPath.length,
|
||||||
@@ -364,11 +367,11 @@ class _ChannelMessagePathMapScreenState
|
|||||||
: selectedPathTmp;
|
: selectedPathTmp;
|
||||||
|
|
||||||
final selectedIndex = _indexForPath(selectedPath, observedPaths);
|
final selectedIndex = _indexForPath(selectedPath, observedPaths);
|
||||||
final hops = _buildPathHops(
|
final contacts = <Contact>[
|
||||||
selectedPath,
|
...connector.contacts,
|
||||||
connector.contacts,
|
...connector.discoveredContacts,
|
||||||
context.l10n,
|
];
|
||||||
);
|
final hops = _buildPathHops(selectedPath, contacts, context.l10n);
|
||||||
|
|
||||||
final points = <LatLng>[];
|
final points = <LatLng>[];
|
||||||
|
|
||||||
|
|||||||
@@ -51,6 +51,8 @@ class _ChannelsScreenState extends State<ChannelsScreen>
|
|||||||
// Cache of PSK hex -> Community for quick lookup
|
// Cache of PSK hex -> Community for quick lookup
|
||||||
final Map<String, Community> _pskToCommunity = {};
|
final Map<String, Community> _pskToCommunity = {};
|
||||||
|
|
||||||
|
ChannelMessageStore get _channelMessageStore => ChannelMessageStore();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
@@ -61,6 +63,8 @@ class _ChannelsScreenState extends State<ChannelsScreen>
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loadCommunities() async {
|
Future<void> _loadCommunities() async {
|
||||||
|
final connector = context.read<MeshCoreConnector>();
|
||||||
|
_communityStore.setPublicKeyHex = connector.selfPublicKeyHex;
|
||||||
final communities = await _communityStore.loadCommunities();
|
final communities = await _communityStore.loadCommunities();
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -106,7 +110,9 @@ class _ChannelsScreenState extends State<ChannelsScreen>
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final connector = context.watch<MeshCoreConnector>();
|
final connector = context.watch<MeshCoreConnector>();
|
||||||
|
|
||||||
final channelMessageStore = ChannelMessageStore();
|
final channelMessageStore = ChannelMessageStore();
|
||||||
|
channelMessageStore.setPublicKeyHex = connector.selfPublicKeyHex;
|
||||||
|
|
||||||
// Auto-navigate back to scanner if disconnected
|
// Auto-navigate back to scanner if disconnected
|
||||||
if (!checkConnectionAndNavigate(connector)) {
|
if (!checkConnectionAndNavigate(connector)) {
|
||||||
@@ -712,6 +718,8 @@ class _ChannelsScreenState extends State<ChannelsScreen>
|
|||||||
bool isRegularHashtag = true;
|
bool isRegularHashtag = true;
|
||||||
Community? selectedCommunity;
|
Community? selectedCommunity;
|
||||||
|
|
||||||
|
_communityStore.setPublicKeyHex = connector.selfPublicKeyHex;
|
||||||
|
|
||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (dialogContext) => StatefulBuilder(
|
builder: (dialogContext) => StatefulBuilder(
|
||||||
@@ -763,7 +771,9 @@ class _ChannelsScreenState extends State<ChannelsScreen>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget? buildExpandedContent() {
|
Widget? buildExpandedContent(
|
||||||
|
ChannelMessageStore channelMessageStore,
|
||||||
|
) {
|
||||||
switch (selectedOption) {
|
switch (selectedOption) {
|
||||||
case 0: // Create Private Channel
|
case 0: // Create Private Channel
|
||||||
return Column(
|
return Column(
|
||||||
@@ -788,7 +798,7 @@ class _ChannelsScreenState extends State<ChannelsScreen>
|
|||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: FilledButton(
|
child: FilledButton(
|
||||||
onPressed: () {
|
onPressed: () async {
|
||||||
final name = nameController.text.trim();
|
final name = nameController.text.trim();
|
||||||
if (name.isEmpty) {
|
if (name.isEmpty) {
|
||||||
ScaffoldMessenger.of(
|
ScaffoldMessenger.of(
|
||||||
@@ -810,7 +820,14 @@ class _ChannelsScreenState extends State<ChannelsScreen>
|
|||||||
psk[i] = random.nextInt(256);
|
psk[i] = random.nextInt(256);
|
||||||
}
|
}
|
||||||
Navigator.pop(dialogContext);
|
Navigator.pop(dialogContext);
|
||||||
connector.setChannel(nextIndex, name, psk);
|
await connector.setChannel(
|
||||||
|
nextIndex,
|
||||||
|
name,
|
||||||
|
psk,
|
||||||
|
);
|
||||||
|
await channelMessageStore.clearChannelMessages(
|
||||||
|
nextIndex,
|
||||||
|
);
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
@@ -1329,7 +1346,8 @@ class _ChannelsScreenState extends State<ChannelsScreen>
|
|||||||
subtitle:
|
subtitle:
|
||||||
dialogContext.l10n.channels_createPrivateChannelDesc,
|
dialogContext.l10n.channels_createPrivateChannelDesc,
|
||||||
),
|
),
|
||||||
if (selectedOption == 0) buildExpandedContent()!,
|
if (selectedOption == 0)
|
||||||
|
buildExpandedContent(_channelMessageStore)!,
|
||||||
const Divider(height: 1),
|
const Divider(height: 1),
|
||||||
buildOptionTile(
|
buildOptionTile(
|
||||||
optionIndex: 1,
|
optionIndex: 1,
|
||||||
@@ -1338,7 +1356,8 @@ class _ChannelsScreenState extends State<ChannelsScreen>
|
|||||||
subtitle:
|
subtitle:
|
||||||
dialogContext.l10n.channels_joinPrivateChannelDesc,
|
dialogContext.l10n.channels_joinPrivateChannelDesc,
|
||||||
),
|
),
|
||||||
if (selectedOption == 1) buildExpandedContent()!,
|
if (selectedOption == 1)
|
||||||
|
buildExpandedContent(_channelMessageStore)!,
|
||||||
if (!hasPublicChannel) ...[
|
if (!hasPublicChannel) ...[
|
||||||
const Divider(height: 1),
|
const Divider(height: 1),
|
||||||
buildOptionTile(
|
buildOptionTile(
|
||||||
@@ -1348,7 +1367,8 @@ class _ChannelsScreenState extends State<ChannelsScreen>
|
|||||||
subtitle:
|
subtitle:
|
||||||
dialogContext.l10n.channels_joinPublicChannelDesc,
|
dialogContext.l10n.channels_joinPublicChannelDesc,
|
||||||
),
|
),
|
||||||
if (selectedOption == 2) buildExpandedContent()!,
|
if (selectedOption == 2)
|
||||||
|
buildExpandedContent(_channelMessageStore)!,
|
||||||
],
|
],
|
||||||
const Divider(height: 1),
|
const Divider(height: 1),
|
||||||
buildOptionTile(
|
buildOptionTile(
|
||||||
@@ -1358,7 +1378,8 @@ class _ChannelsScreenState extends State<ChannelsScreen>
|
|||||||
subtitle:
|
subtitle:
|
||||||
dialogContext.l10n.channels_joinHashtagChannelDesc,
|
dialogContext.l10n.channels_joinHashtagChannelDesc,
|
||||||
),
|
),
|
||||||
if (selectedOption == 3) buildExpandedContent()!,
|
if (selectedOption == 3)
|
||||||
|
buildExpandedContent(_channelMessageStore)!,
|
||||||
const Divider(height: 1),
|
const Divider(height: 1),
|
||||||
buildOptionTile(
|
buildOptionTile(
|
||||||
optionIndex: 4,
|
optionIndex: 4,
|
||||||
@@ -1366,7 +1387,8 @@ class _ChannelsScreenState extends State<ChannelsScreen>
|
|||||||
title: dialogContext.l10n.community_scanQr,
|
title: dialogContext.l10n.community_scanQr,
|
||||||
subtitle: dialogContext.l10n.community_join,
|
subtitle: dialogContext.l10n.community_join,
|
||||||
),
|
),
|
||||||
if (selectedOption == 4) buildExpandedContent()!,
|
if (selectedOption == 4)
|
||||||
|
buildExpandedContent(_channelMessageStore)!,
|
||||||
const Divider(height: 1),
|
const Divider(height: 1),
|
||||||
buildOptionTile(
|
buildOptionTile(
|
||||||
optionIndex: 5,
|
optionIndex: 5,
|
||||||
@@ -1374,7 +1396,8 @@ class _ChannelsScreenState extends State<ChannelsScreen>
|
|||||||
title: dialogContext.l10n.community_create,
|
title: dialogContext.l10n.community_create,
|
||||||
subtitle: dialogContext.l10n.community_createDesc,
|
subtitle: dialogContext.l10n.community_createDesc,
|
||||||
),
|
),
|
||||||
if (selectedOption == 5) buildExpandedContent()!,
|
if (selectedOption == 5)
|
||||||
|
buildExpandedContent(_channelMessageStore)!,
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -1524,7 +1547,7 @@ class _ChannelsScreenState extends State<ChannelsScreen>
|
|||||||
try {
|
try {
|
||||||
await connector.deleteChannel(channel.index);
|
await connector.deleteChannel(channel.index);
|
||||||
|
|
||||||
channelMessageStore.clearChannelMessages(channel.index);
|
await channelMessageStore.clearChannelMessages(channel.index);
|
||||||
|
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
|
|
||||||
@@ -1749,6 +1772,7 @@ class _ChannelsScreenState extends State<ChannelsScreen>
|
|||||||
}
|
}
|
||||||
|
|
||||||
final channelCount = communityChannels.length;
|
final channelCount = communityChannels.length;
|
||||||
|
_communityStore.setPublicKeyHex = connector.selfPublicKeyHex;
|
||||||
|
|
||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
|
|||||||
@@ -106,10 +106,9 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
final unreadLabel = context.l10n.chat_unread(unreadCount);
|
final unreadLabel = context.l10n.chat_unread(unreadCount);
|
||||||
final pathLabel = _currentPathLabel(contact);
|
final pathLabel = _currentPathLabel(contact);
|
||||||
|
|
||||||
// Show path details if we have path data (from device or override)
|
// Show path details if we have non-empty path data (from device or override)
|
||||||
final hasPathData =
|
|
||||||
contact.path.isNotEmpty || contact.pathOverrideBytes != null;
|
|
||||||
final effectivePath = contact.pathOverrideBytes ?? contact.path;
|
final effectivePath = contact.pathOverrideBytes ?? contact.path;
|
||||||
|
final hasPathData = effectivePath.isNotEmpty;
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
@@ -143,12 +142,25 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
final contact = _resolveContact(connector);
|
final contact = _resolveContact(connector);
|
||||||
final isFloodMode = contact.pathOverride == -1;
|
final isFloodMode = contact.pathOverride == -1;
|
||||||
|
|
||||||
|
final isDirectMode = contact.pathOverride == 0;
|
||||||
|
final activeMode = isFloodMode
|
||||||
|
? 'flood'
|
||||||
|
: isDirectMode
|
||||||
|
? 'direct'
|
||||||
|
: 'auto';
|
||||||
|
|
||||||
return PopupMenuButton<String>(
|
return PopupMenuButton<String>(
|
||||||
icon: Icon(isFloodMode ? Icons.waves : Icons.route),
|
icon: Icon(isFloodMode ? Icons.waves : Icons.route),
|
||||||
tooltip: context.l10n.chat_routingMode,
|
tooltip: context.l10n.chat_routingMode,
|
||||||
onSelected: (mode) async {
|
onSelected: (mode) async {
|
||||||
if (mode == 'flood') {
|
if (mode == 'flood') {
|
||||||
await connector.setPathOverride(contact, pathLen: -1);
|
await connector.setPathOverride(contact, pathLen: -1);
|
||||||
|
} else if (mode == 'direct') {
|
||||||
|
await connector.setPathOverride(
|
||||||
|
contact,
|
||||||
|
pathLen: 0,
|
||||||
|
pathBytes: Uint8List(0),
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
await connector.setPathOverride(contact, pathLen: null);
|
await connector.setPathOverride(contact, pathLen: null);
|
||||||
}
|
}
|
||||||
@@ -161,7 +173,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
Icon(
|
Icon(
|
||||||
Icons.auto_mode,
|
Icons.auto_mode,
|
||||||
size: 20,
|
size: 20,
|
||||||
color: !isFloodMode
|
color: activeMode == 'auto'
|
||||||
? Theme.of(context).primaryColor
|
? Theme.of(context).primaryColor
|
||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
@@ -169,7 +181,30 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
Text(
|
Text(
|
||||||
context.l10n.chat_autoUseSavedPath,
|
context.l10n.chat_autoUseSavedPath,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontWeight: !isFloodMode
|
fontWeight: activeMode == 'auto'
|
||||||
|
? FontWeight.bold
|
||||||
|
: FontWeight.normal,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
PopupMenuItem(
|
||||||
|
value: 'direct',
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.near_me,
|
||||||
|
size: 20,
|
||||||
|
color: activeMode == 'direct'
|
||||||
|
? Theme.of(context).primaryColor
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
context.l10n.chat_direct,
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: activeMode == 'direct'
|
||||||
? FontWeight.bold
|
? FontWeight.bold
|
||||||
: FontWeight.normal,
|
: FontWeight.normal,
|
||||||
),
|
),
|
||||||
@@ -184,7 +219,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
Icon(
|
Icon(
|
||||||
Icons.waves,
|
Icons.waves,
|
||||||
size: 20,
|
size: 20,
|
||||||
color: isFloodMode
|
color: activeMode == 'flood'
|
||||||
? Theme.of(context).primaryColor
|
? Theme.of(context).primaryColor
|
||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
@@ -192,7 +227,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
Text(
|
Text(
|
||||||
context.l10n.chat_forceFloodMode,
|
context.l10n.chat_forceFloodMode,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontWeight: isFloodMode
|
fontWeight: activeMode == 'flood'
|
||||||
? FontWeight.bold
|
? FontWeight.bold
|
||||||
: FontWeight.normal,
|
: FontWeight.normal,
|
||||||
),
|
),
|
||||||
@@ -251,7 +286,9 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
context.l10n.chat_sendMessageTo(widget.contact.name),
|
context.l10n.chat_sendMessageTo(
|
||||||
|
_resolveContact(context.read<MeshCoreConnector>()).name,
|
||||||
|
),
|
||||||
style: TextStyle(fontSize: 14, color: Colors.grey[500]),
|
style: TextStyle(fontSize: 14, color: Colors.grey[500]),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -269,6 +306,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
|
|
||||||
// Auto-scroll to bottom if user is already at bottom
|
// Auto-scroll to bottom if user is already at bottom
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (!mounted) return;
|
||||||
_scrollController.scrollToBottomIfAtBottom();
|
_scrollController.scrollToBottomIfAtBottom();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -293,10 +331,10 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
final messageIndex = index;
|
final messageIndex = index;
|
||||||
Contact contact = widget.contact;
|
Contact contact = _resolveContact(connector);
|
||||||
final message = reversedMessages[messageIndex];
|
final message = reversedMessages[messageIndex];
|
||||||
String fourByteHex = '';
|
String fourByteHex = '';
|
||||||
if (widget.contact.type == advTypeRoom) {
|
if (contact.type == advTypeRoom) {
|
||||||
contact = _resolveContactFrom4Bytes(
|
contact = _resolveContactFrom4Bytes(
|
||||||
connector,
|
connector,
|
||||||
message.fourByteRoomContactKey.isEmpty
|
message.fourByteRoomContactKey.isEmpty
|
||||||
@@ -314,12 +352,13 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
final textScale = context.select<ChatTextScaleService, double>(
|
final textScale = context.select<ChatTextScaleService, double>(
|
||||||
(service) => service.scale,
|
(service) => service.scale,
|
||||||
);
|
);
|
||||||
|
final resolvedContact = _resolveContact(connector);
|
||||||
return _MessageBubble(
|
return _MessageBubble(
|
||||||
message: message,
|
message: message,
|
||||||
senderName: widget.contact.type == advTypeRoom
|
senderName: resolvedContact.type == advTypeRoom
|
||||||
? "${contact.name} [$fourByteHex]"
|
? "${contact.name} [$fourByteHex]"
|
||||||
: contact.name,
|
: contact.name,
|
||||||
isRoomServer: widget.contact.type == advTypeRoom,
|
isRoomServer: resolvedContact.type == advTypeRoom,
|
||||||
textScale: textScale,
|
textScale: textScale,
|
||||||
onTap: () => _openMessagePath(message, contact),
|
onTap: () => _openMessagePath(message, contact),
|
||||||
onLongPress: () => _showMessageActions(message, contact),
|
onLongPress: () => _showMessageActions(message, contact),
|
||||||
@@ -457,7 +496,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
connector.sendMessage(widget.contact, text);
|
connector.sendMessage(_resolveContact(connector), text);
|
||||||
_textController.clear();
|
_textController.clear();
|
||||||
_textFieldFocusNode.requestFocus();
|
_textFieldFocusNode.requestFocus();
|
||||||
}
|
}
|
||||||
@@ -654,7 +693,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
|
|
||||||
// Set the path override to persist user's choice
|
// Set the path override to persist user's choice
|
||||||
await connector.setPathOverride(
|
await connector.setPathOverride(
|
||||||
widget.contact,
|
_resolveContact(connector),
|
||||||
pathLen: pathLength,
|
pathLen: pathLength,
|
||||||
pathBytes: pathBytes,
|
pathBytes: pathBytes,
|
||||||
);
|
);
|
||||||
@@ -663,7 +702,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
await _notifyPathSet(
|
await _notifyPathSet(
|
||||||
connector,
|
connector,
|
||||||
widget.contact,
|
_resolveContact(connector),
|
||||||
pathBytes,
|
pathBytes,
|
||||||
path.hopCount,
|
path.hopCount,
|
||||||
);
|
);
|
||||||
@@ -722,7 +761,9 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
style: const TextStyle(fontSize: 11),
|
style: const TextStyle(fontSize: 11),
|
||||||
),
|
),
|
||||||
onTap: () async {
|
onTap: () async {
|
||||||
await connector.clearContactPath(widget.contact);
|
await connector.clearContactPath(
|
||||||
|
_resolveContact(connector),
|
||||||
|
);
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
@@ -750,7 +791,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
),
|
),
|
||||||
onTap: () async {
|
onTap: () async {
|
||||||
await connector.setPathOverride(
|
await connector.setPathOverride(
|
||||||
widget.contact,
|
_resolveContact(connector),
|
||||||
pathLen: -1,
|
pathLen: -1,
|
||||||
);
|
);
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
@@ -1005,11 +1046,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (result == null) {
|
if (result == null) {
|
||||||
appLogger.info(
|
return; // Cancelled — keep existing path
|
||||||
'PathSelectionDialog was cancelled or returned null',
|
|
||||||
tag: 'ChatScreen',
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!mounted) {
|
if (!mounted) {
|
||||||
@@ -1025,14 +1062,19 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
tag: 'ChatScreen',
|
tag: 'ChatScreen',
|
||||||
);
|
);
|
||||||
await connector.setPathOverride(
|
await connector.setPathOverride(
|
||||||
widget.contact,
|
_resolveContact(connector),
|
||||||
pathLen: result.length,
|
pathLen: result.length,
|
||||||
pathBytes: result,
|
pathBytes: result,
|
||||||
);
|
);
|
||||||
appLogger.info('setPathOverride completed', tag: 'ChatScreen');
|
appLogger.info('setPathOverride completed', tag: 'ChatScreen');
|
||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
await _notifyPathSet(connector, widget.contact, result, result.length);
|
await _notifyPathSet(
|
||||||
|
connector,
|
||||||
|
_resolveContact(connector),
|
||||||
|
result,
|
||||||
|
result.length,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _openMessagePath(Message message, Contact contact) {
|
void _openMessagePath(Message message, Contact contact) {
|
||||||
@@ -1044,10 +1086,10 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
final String senderName;
|
final String senderName;
|
||||||
if (message.isOutgoing) {
|
if (message.isOutgoing) {
|
||||||
senderName = connector.selfName ?? context.l10n.chat_me;
|
senderName = connector.selfName ?? context.l10n.chat_me;
|
||||||
} else if (widget.contact.type == advTypeRoom) {
|
} else if (_resolveContact(connector).type == advTypeRoom) {
|
||||||
senderName = "${contact.name} [$fourByteHex]";
|
senderName = "${contact.name} [$fourByteHex]";
|
||||||
} else {
|
} else {
|
||||||
senderName = widget.contact.name;
|
senderName = _resolveContact(connector).name;
|
||||||
}
|
}
|
||||||
final pathMessage = ChannelMessage(
|
final pathMessage = ChannelMessage(
|
||||||
senderKey: null,
|
senderKey: null,
|
||||||
@@ -1110,7 +1152,8 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
_retryMessage(message);
|
_retryMessage(message);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
if (widget.contact.type == advTypeRoom)
|
if (_resolveContact(context.read<MeshCoreConnector>()).type ==
|
||||||
|
advTypeRoom)
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.chat),
|
leading: const Icon(Icons.chat),
|
||||||
title: Text(context.l10n.contacts_openChat),
|
title: Text(context.l10n.contacts_openChat),
|
||||||
@@ -1148,7 +1191,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
void _retryMessage(Message message) {
|
void _retryMessage(Message message) {
|
||||||
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
||||||
// Retry using the contact's current path override setting
|
// Retry using the contact's current path override setting
|
||||||
connector.sendMessage(widget.contact, message.text);
|
connector.sendMessage(_resolveContact(connector), message.text);
|
||||||
ScaffoldMessenger.of(
|
ScaffoldMessenger.of(
|
||||||
context,
|
context,
|
||||||
).showSnackBar(SnackBar(content: Text(context.l10n.chat_retryingMessage)));
|
).showSnackBar(SnackBar(content: Text(context.l10n.chat_retryingMessage)));
|
||||||
@@ -1174,7 +1217,8 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
|
|
||||||
// For room servers, include sender name (like channels) since multiple users
|
// For room servers, include sender name (like channels) since multiple users
|
||||||
// For 1:1 chats, sender is implicit (null)
|
// For 1:1 chats, sender is implicit (null)
|
||||||
final senderName = widget.contact.type == advTypeRoom
|
final liveContact = _resolveContact(connector);
|
||||||
|
final senderName = liveContact.type == advTypeRoom
|
||||||
? senderContact.name
|
? senderContact.name
|
||||||
: null;
|
: null;
|
||||||
final hash = ReactionHelper.computeReactionHash(
|
final hash = ReactionHelper.computeReactionHash(
|
||||||
@@ -1183,7 +1227,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
message.text,
|
message.text,
|
||||||
);
|
);
|
||||||
final reactionText = 'r:$hash:$emojiIndex';
|
final reactionText = 'r:$hash:$emojiIndex';
|
||||||
connector.sendMessage(widget.contact, reactionText);
|
connector.sendMessage(_resolveContact(connector), reactionText);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -51,6 +51,9 @@ class _CommunityQrScannerScreenState extends State<CommunityQrScannerScreen> {
|
|||||||
_isProcessing = true;
|
_isProcessing = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
final connector = context.read<MeshCoreConnector>();
|
||||||
|
_communityStore.setPublicKeyHex = connector.selfPublicKeyHex;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Parse the community data
|
// Parse the community data
|
||||||
final community = Community.fromQrData(const Uuid().v4(), data);
|
final community = Community.fromQrData(const Uuid().v4(), data);
|
||||||
@@ -209,6 +212,8 @@ class _CommunityQrScannerScreenState extends State<CommunityQrScannerScreen> {
|
|||||||
bool addPublicChannel,
|
bool addPublicChannel,
|
||||||
) async {
|
) async {
|
||||||
// Save community to local storage
|
// Save community to local storage
|
||||||
|
final connector = context.read<MeshCoreConnector>();
|
||||||
|
_communityStore.setPublicKeyHex = connector.selfPublicKeyHex;
|
||||||
await _communityStore.addCommunity(community);
|
await _communityStore.addCommunity(community);
|
||||||
|
|
||||||
// Optionally add the community public channel to the device
|
// Optionally add the community public channel to the device
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'dart:async';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:meshcore_open/screens/path_trace_map.dart';
|
import 'package:meshcore_open/screens/path_trace_map.dart';
|
||||||
|
import 'package:meshcore_open/services/notification_service.dart';
|
||||||
import 'package:meshcore_open/utils/app_logger.dart';
|
import 'package:meshcore_open/utils/app_logger.dart';
|
||||||
import 'package:meshcore_open/widgets/app_bar.dart';
|
import 'package:meshcore_open/widgets/app_bar.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
@@ -64,6 +65,13 @@ class _ContactsScreenState extends State<ContactsScreen>
|
|||||||
super.initState();
|
super.initState();
|
||||||
_loadGroups();
|
_loadGroups();
|
||||||
_setupFrameListener();
|
_setupFrameListener();
|
||||||
|
_clearAdvertNotifications();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _clearAdvertNotifications() {
|
||||||
|
final connector = context.read<MeshCoreConnector>();
|
||||||
|
final contactIds = connector.contacts.map((c) => c.publicKeyHex).toList();
|
||||||
|
NotificationService().clearAdvertNotifications(contactIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -401,8 +409,15 @@ class _ContactsScreenState extends State<ContactsScreen>
|
|||||||
|
|
||||||
Widget _buildContactsBody(BuildContext context, MeshCoreConnector connector) {
|
Widget _buildContactsBody(BuildContext context, MeshCoreConnector connector) {
|
||||||
final contacts = connector.contacts;
|
final contacts = connector.contacts;
|
||||||
|
final shouldShowStartupSpinner =
|
||||||
|
contacts.isEmpty &&
|
||||||
|
_groups.isEmpty &&
|
||||||
|
connector.isConnected &&
|
||||||
|
(connector.isLoadingContacts ||
|
||||||
|
connector.isLoadingChannels ||
|
||||||
|
connector.selfPublicKey == null);
|
||||||
|
|
||||||
if (contacts.isEmpty && connector.isLoadingContacts && _groups.isEmpty) {
|
if (shouldShowStartupSpinner) {
|
||||||
return const Center(child: CircularProgressIndicator());
|
return const Center(child: CircularProgressIndicator());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import 'package:provider/provider.dart';
|
|||||||
import '../connector/meshcore_connector.dart';
|
import '../connector/meshcore_connector.dart';
|
||||||
import '../connector/meshcore_protocol.dart';
|
import '../connector/meshcore_protocol.dart';
|
||||||
import '../l10n/l10n.dart';
|
import '../l10n/l10n.dart';
|
||||||
import '../models/discovery_contact.dart';
|
import '../models/contact.dart';
|
||||||
import '../utils/contact_search.dart';
|
import '../utils/contact_search.dart';
|
||||||
import '../widgets/app_bar.dart';
|
import '../widgets/app_bar.dart';
|
||||||
import '../widgets/list_filter_widget.dart';
|
import '../widgets/list_filter_widget.dart';
|
||||||
@@ -129,7 +129,7 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _showContactContextMenu(
|
Future<void> _showContactContextMenu(
|
||||||
DiscoveryContact contact,
|
Contact contact,
|
||||||
MeshCoreConnector connector,
|
MeshCoreConnector connector,
|
||||||
) async {
|
) async {
|
||||||
final action = await showModalBottomSheet<String>(
|
final action = await showModalBottomSheet<String>(
|
||||||
@@ -169,7 +169,8 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
|
|||||||
connector.importDiscoveredContact(contact);
|
connector.importDiscoveredContact(contact);
|
||||||
break;
|
break;
|
||||||
case 'copy_contact':
|
case 'copy_contact':
|
||||||
final hexString = pubKeyToHex(contact.rawPacket);
|
if (contact.rawPacket == null) return;
|
||||||
|
final hexString = pubKeyToHex(contact.rawPacket!);
|
||||||
Clipboard.setData(ClipboardData(text: "meshcore://$hexString"));
|
Clipboard.setData(ClipboardData(text: "meshcore://$hexString"));
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
@@ -207,7 +208,7 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildFilters(
|
Widget _buildFilters(
|
||||||
List<DiscoveryContact> filteredAndSorted,
|
List<Contact> filteredAndSorted,
|
||||||
MeshCoreConnector connector,
|
MeshCoreConnector connector,
|
||||||
) {
|
) {
|
||||||
String hintText = "";
|
String hintText = "";
|
||||||
@@ -309,8 +310,8 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
List<DiscoveryContact> _filterAndSortContacts(
|
List<Contact> _filterAndSortContacts(
|
||||||
List<DiscoveryContact> contacts,
|
List<Contact> contacts,
|
||||||
MeshCoreConnector connector,
|
MeshCoreConnector connector,
|
||||||
) {
|
) {
|
||||||
var filtered = contacts.where((contact) {
|
var filtered = contacts.where((contact) {
|
||||||
@@ -350,7 +351,7 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
|
|||||||
return filtered;
|
return filtered;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool _matchesTypeFilter(DiscoveryContact contact) {
|
bool _matchesTypeFilter(Contact contact) {
|
||||||
switch (typeFilter) {
|
switch (typeFilter) {
|
||||||
case ContactTypeFilter.all:
|
case ContactTypeFilter.all:
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
+137
-39
@@ -1,6 +1,7 @@
|
|||||||
import 'dart:math';
|
import 'dart:math';
|
||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_map/flutter_map.dart';
|
import 'package:flutter_map/flutter_map.dart';
|
||||||
import 'package:latlong2/latlong.dart';
|
import 'package:latlong2/latlong.dart';
|
||||||
@@ -50,7 +51,8 @@ class MapScreen extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _MapScreenState extends State<MapScreen> {
|
class _MapScreenState extends State<MapScreen> {
|
||||||
static const double _labelZoomThreshold = 8.5;
|
// Zoom level at which node labels start to appear
|
||||||
|
static const double _labelZoomThreshold = 12.0;
|
||||||
|
|
||||||
final MapController _mapController = MapController();
|
final MapController _mapController = MapController();
|
||||||
final MapMarkerService _markerService = MapMarkerService();
|
final MapMarkerService _markerService = MapMarkerService();
|
||||||
@@ -91,6 +93,15 @@ class _MapScreenState extends State<MapScreen> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool _checkLocationPlausibility(double lat, double lon) {
|
||||||
|
const double epsilon = 1e-6;
|
||||||
|
return (lat.abs() > epsilon || lon.abs() > epsilon) &&
|
||||||
|
lat >= -90.0 &&
|
||||||
|
lat <= 90.0 &&
|
||||||
|
lon >= -180.0 &&
|
||||||
|
lon <= 180.0;
|
||||||
|
}
|
||||||
|
|
||||||
double _standardDeviation(List<double> values) {
|
double _standardDeviation(List<double> values) {
|
||||||
if (values.length <= 1) {
|
if (values.length <= 1) {
|
||||||
return 0.0;
|
return 0.0;
|
||||||
@@ -126,7 +137,15 @@ class _MapScreenState extends State<MapScreen> {
|
|||||||
builder: (context, connector, settingsService, pathHistory, child) {
|
builder: (context, connector, settingsService, pathHistory, child) {
|
||||||
final tileCache = context.read<MapTileCacheService>();
|
final tileCache = context.read<MapTileCacheService>();
|
||||||
final settings = settingsService.settings;
|
final settings = settingsService.settings;
|
||||||
final contacts = connector.contacts;
|
final allContacts = <Contact>[
|
||||||
|
...connector.contacts,
|
||||||
|
...connector.discoveredContacts.where((c) => !c.isActive),
|
||||||
|
];
|
||||||
|
|
||||||
|
final contacts = settings.mapShowDiscoveryContacts
|
||||||
|
? allContacts
|
||||||
|
: allContacts.where((c) => c.isActive).toList();
|
||||||
|
|
||||||
final highlightPosition = widget.highlightPosition;
|
final highlightPosition = widget.highlightPosition;
|
||||||
final sharedMarkers = settings.mapShowMarkers
|
final sharedMarkers = settings.mapShowMarkers
|
||||||
? _collectSharedMarkers(connector)
|
? _collectSharedMarkers(connector)
|
||||||
@@ -159,14 +178,21 @@ class _MapScreenState extends State<MapScreen> {
|
|||||||
: filteredByTime;
|
: filteredByTime;
|
||||||
|
|
||||||
// Filter by location
|
// Filter by location
|
||||||
final contactsWithLocation = filteredByKeyPrefix
|
final contactsWithLocation = filteredByKeyPrefix.where((c) {
|
||||||
.where((c) => c.hasLocation)
|
if (!c.hasLocation) {
|
||||||
.toList();
|
return false;
|
||||||
|
}
|
||||||
|
return _checkLocationPlausibility(c.latitude!, c.longitude!);
|
||||||
|
}).toList();
|
||||||
|
|
||||||
// All contacts with a known location — used as anchors regardless of
|
// All contacts with a known location — used as anchors regardless of
|
||||||
// time/key-prefix filters so that repeaters are always available.
|
// time/key-prefix filters so that repeaters are always available.
|
||||||
final allContactsWithLocation = contacts
|
final allContactsWithLocation = allContacts
|
||||||
.where((c) => c.hasLocation)
|
.where(
|
||||||
|
(c) =>
|
||||||
|
c.hasLocation &&
|
||||||
|
_checkLocationPlausibility(c.latitude!, c.longitude!),
|
||||||
|
)
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
// Compute guessed locations with caching
|
// Compute guessed locations with caching
|
||||||
@@ -468,7 +494,10 @@ class _MapScreenState extends State<MapScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (!_isBuildingPathTrace)
|
if (!_isBuildingPathTrace)
|
||||||
...guessedLocations.map(_buildGuessedMarker),
|
..._buildGuessedMarker(
|
||||||
|
guessedLocations,
|
||||||
|
showLabels: _showNodeLabels,
|
||||||
|
),
|
||||||
..._buildMarkers(
|
..._buildMarkers(
|
||||||
contactsWithLocation,
|
contactsWithLocation,
|
||||||
settings,
|
settings,
|
||||||
@@ -630,6 +659,13 @@ class _MapScreenState extends State<MapScreen> {
|
|||||||
anchors[0].latitude + offsetDeg * cos(angle),
|
anchors[0].latitude + offsetDeg * cos(angle),
|
||||||
anchors[0].longitude + offsetDeg * sin(angle),
|
anchors[0].longitude + offsetDeg * sin(angle),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (!_checkLocationPlausibility(
|
||||||
|
position.latitude,
|
||||||
|
position.longitude,
|
||||||
|
)) {
|
||||||
|
continue; // discard implausible guesses near (0, 0)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
double lat = 0, lon = 0;
|
double lat = 0, lon = 0;
|
||||||
for (final a in anchors) {
|
for (final a in anchors) {
|
||||||
@@ -637,6 +673,12 @@ class _MapScreenState extends State<MapScreen> {
|
|||||||
lon += a.longitude;
|
lon += a.longitude;
|
||||||
}
|
}
|
||||||
position = LatLng(lat / anchors.length, lon / anchors.length);
|
position = LatLng(lat / anchors.length, lon / anchors.length);
|
||||||
|
if (!_checkLocationPlausibility(
|
||||||
|
position.latitude,
|
||||||
|
position.longitude,
|
||||||
|
)) {
|
||||||
|
continue; // discard implausible guesses near (0, 0
|
||||||
|
}
|
||||||
}
|
}
|
||||||
result.add(
|
result.add(
|
||||||
_GuessedLocation(
|
_GuessedLocation(
|
||||||
@@ -710,40 +752,61 @@ class _MapScreenState extends State<MapScreen> {
|
|||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
Marker _buildGuessedMarker(_GuessedLocation guess) {
|
List<Marker> _buildGuessedMarker(
|
||||||
final color = _getNodeColor(guess.contact.type);
|
List<_GuessedLocation> guessed, {
|
||||||
return Marker(
|
required bool showLabels,
|
||||||
point: guess.position,
|
}) {
|
||||||
width: 35,
|
final markers = <Marker>[];
|
||||||
height: 35,
|
|
||||||
child: GestureDetector(
|
for (final guess in guessed) {
|
||||||
onTap: () => _showNodeInfo(
|
final color = _getNodeColor(guess.contact.type);
|
||||||
context,
|
final marker = Marker(
|
||||||
guess.contact,
|
point: guess.position,
|
||||||
guessedPosition: guess.position,
|
width: 35,
|
||||||
),
|
height: 35,
|
||||||
child: Container(
|
child: GestureDetector(
|
||||||
padding: const EdgeInsets.all(4),
|
onTap: () => _showNodeInfo(
|
||||||
decoration: BoxDecoration(
|
context,
|
||||||
color: color.withValues(alpha: guess.highConfidence ? 0.55 : 0.30),
|
guess.contact,
|
||||||
shape: BoxShape.circle,
|
guessedPosition: guess.position,
|
||||||
border: Border.all(color: Colors.white, width: 2),
|
),
|
||||||
boxShadow: [
|
child: Container(
|
||||||
BoxShadow(
|
padding: const EdgeInsets.all(4),
|
||||||
color: Colors.black.withValues(alpha: 0.3),
|
decoration: BoxDecoration(
|
||||||
blurRadius: 4,
|
color: color.withValues(
|
||||||
offset: const Offset(0, 2),
|
alpha: guess.highConfidence ? 0.55 : 0.30,
|
||||||
),
|
),
|
||||||
],
|
shape: BoxShape.circle,
|
||||||
),
|
border: Border.all(color: Colors.white, width: 2),
|
||||||
child: const Icon(
|
boxShadow: [
|
||||||
Icons.not_listed_location,
|
BoxShadow(
|
||||||
color: Colors.white,
|
color: Colors.black.withValues(alpha: 0.3),
|
||||||
size: 20,
|
blurRadius: 4,
|
||||||
|
offset: const Offset(0, 2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
Icons.not_listed_location,
|
||||||
|
color: Colors.white,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
);
|
|
||||||
|
markers.add(marker);
|
||||||
|
|
||||||
|
if (showLabels) {
|
||||||
|
markers.add(
|
||||||
|
_buildNodeLabelMarker(
|
||||||
|
point: guess.position,
|
||||||
|
label: guess.contact.name,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return markers;
|
||||||
}
|
}
|
||||||
|
|
||||||
List<Marker> _buildMarkers(
|
List<Marker> _buildMarkers(
|
||||||
@@ -1203,6 +1266,7 @@ class _MapScreenState extends State<MapScreen> {
|
|||||||
Contact contact, {
|
Contact contact, {
|
||||||
LatLng? guessedPosition,
|
LatLng? guessedPosition,
|
||||||
}) {
|
}) {
|
||||||
|
final connector = context.read<MeshCoreConnector>();
|
||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (dialogContext) => AlertDialog(
|
builder: (dialogContext) => AlertDialog(
|
||||||
@@ -1248,6 +1312,9 @@ class _MapScreenState extends State<MapScreen> {
|
|||||||
advTypeChat) // Only show chat button for chat nodes
|
advTypeChat) // Only show chat button for chat nodes
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
|
if (!contact.isActive) {
|
||||||
|
connector.importDiscoveredContact(contact);
|
||||||
|
}
|
||||||
Navigator.pop(dialogContext);
|
Navigator.pop(dialogContext);
|
||||||
Navigator.push(
|
Navigator.push(
|
||||||
context,
|
context,
|
||||||
@@ -1261,6 +1328,9 @@ class _MapScreenState extends State<MapScreen> {
|
|||||||
if (contact.type == advTypeRepeater)
|
if (contact.type == advTypeRepeater)
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
|
if (!contact.isActive) {
|
||||||
|
connector.importDiscoveredContact(contact);
|
||||||
|
}
|
||||||
Navigator.pop(dialogContext);
|
Navigator.pop(dialogContext);
|
||||||
_showRepeaterLogin(context, contact);
|
_showRepeaterLogin(context, contact);
|
||||||
},
|
},
|
||||||
@@ -1269,6 +1339,9 @@ class _MapScreenState extends State<MapScreen> {
|
|||||||
if (contact.type == advTypeRoom)
|
if (contact.type == advTypeRoom)
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
|
if (!contact.isActive) {
|
||||||
|
connector.importDiscoveredContact(contact);
|
||||||
|
}
|
||||||
Navigator.pop(dialogContext);
|
Navigator.pop(dialogContext);
|
||||||
_showRoomLogin(context, contact);
|
_showRoomLogin(context, contact);
|
||||||
},
|
},
|
||||||
@@ -1436,6 +1509,23 @@ class _MapScreenState extends State<MapScreen> {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.my_location),
|
||||||
|
title: Text(context.l10n.map_setAsMyLocation),
|
||||||
|
onTap: () async {
|
||||||
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
|
final successMsg = context.l10n.settings_locationUpdated;
|
||||||
|
Navigator.pop(sheetContext);
|
||||||
|
if (!connector.isConnected) return;
|
||||||
|
await connector.setNodeLocation(
|
||||||
|
lat: position.latitude,
|
||||||
|
lon: position.longitude,
|
||||||
|
);
|
||||||
|
await connector.refreshDeviceInfo();
|
||||||
|
if (!mounted) return;
|
||||||
|
messenger.showSnackBar(SnackBar(content: Text(successMsg)));
|
||||||
|
},
|
||||||
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.close),
|
leading: const Icon(Icons.close),
|
||||||
title: Text(context.l10n.common_cancel),
|
title: Text(context.l10n.common_cancel),
|
||||||
@@ -1745,6 +1835,14 @@ class _MapScreenState extends State<MapScreen> {
|
|||||||
},
|
},
|
||||||
contentPadding: EdgeInsets.zero,
|
contentPadding: EdgeInsets.zero,
|
||||||
),
|
),
|
||||||
|
CheckboxListTile(
|
||||||
|
title: Text(context.l10n.map_showDiscoveryContacts),
|
||||||
|
value: settings.mapShowDiscoveryContacts,
|
||||||
|
onChanged: (value) {
|
||||||
|
service.setMapShowDiscoveryContacts(value ?? true);
|
||||||
|
},
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(
|
Text(
|
||||||
context.l10n.map_keyPrefix,
|
context.l10n.map_keyPrefix,
|
||||||
|
|||||||
@@ -124,12 +124,14 @@ class _NeighborsScreenState extends State<NeighborsScreen> {
|
|||||||
|
|
||||||
void _handleNeighborsResponse(MeshCoreConnector connector, Uint8List frame) {
|
void _handleNeighborsResponse(MeshCoreConnector connector, Uint8List frame) {
|
||||||
final buffer = BufferReader(frame);
|
final buffer = BufferReader(frame);
|
||||||
|
final contacts = <Contact>[
|
||||||
|
...connector.contacts,
|
||||||
|
...connector.discoveredContacts,
|
||||||
|
];
|
||||||
try {
|
try {
|
||||||
final neighborCount = buffer.readUInt16LE();
|
final neighborCount = buffer.readUInt16LE();
|
||||||
final parsedNeighbors = parseNeighborsData(buffer, buffer.readUInt16LE());
|
final parsedNeighbors = parseNeighborsData(buffer, buffer.readUInt16LE());
|
||||||
connector.contacts.where((c) => c.type == advTypeRepeater).forEach((
|
contacts.where((c) => c.type == advTypeRepeater).forEach((repeater) {
|
||||||
repeater,
|
|
||||||
) {
|
|
||||||
for (var neighborData in parsedNeighbors) {
|
for (var neighborData in parsedNeighbors) {
|
||||||
final publicKey = neighborData['publicKey'];
|
final publicKey = neighborData['publicKey'];
|
||||||
if (listEquals(
|
if (listEquals(
|
||||||
|
|||||||
@@ -114,14 +114,37 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
|
|||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
Uint8List addReturnPath(Uint8List pathBytes) {
|
Uint8List buildPath(Uint8List pathBytes) {
|
||||||
Uint8List? traceBytes;
|
Uint8List traceBytes;
|
||||||
final len = (pathBytes.length + pathBytes.length - 1);
|
|
||||||
traceBytes = Uint8List(len);
|
if (pathBytes.isEmpty) {
|
||||||
for (int i = 0; i < pathBytes.length; i++) {
|
traceBytes = Uint8List(1);
|
||||||
traceBytes[i] = pathBytes[i];
|
traceBytes[0] = widget.targetContact?.publicKey[0] ?? 0;
|
||||||
if (i < pathBytes.length - 1) {
|
return traceBytes;
|
||||||
traceBytes[len - 1 - i] = pathBytes[i];
|
}
|
||||||
|
|
||||||
|
if (widget.targetContact?.type == advTypeRepeater ||
|
||||||
|
widget.targetContact?.type == advTypeRoom) {
|
||||||
|
final len = (pathBytes.length + pathBytes.length + 1);
|
||||||
|
traceBytes = Uint8List(len);
|
||||||
|
traceBytes[pathBytes.length] = widget.targetContact?.publicKey[0] ?? 0;
|
||||||
|
for (int i = 0; i < pathBytes.length; i++) {
|
||||||
|
traceBytes[i] = pathBytes[i];
|
||||||
|
if (i < pathBytes.length) {
|
||||||
|
traceBytes[len - 1 - i] = pathBytes[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (pathBytes.length < 2) {
|
||||||
|
return pathBytes[0] == 0 ? Uint8List(0) : pathBytes;
|
||||||
|
}
|
||||||
|
final len = (pathBytes.length + pathBytes.length - 1);
|
||||||
|
traceBytes = Uint8List(len);
|
||||||
|
for (int i = 0; i < pathBytes.length; i++) {
|
||||||
|
traceBytes[i] = pathBytes[i];
|
||||||
|
if (i < pathBytes.length - 1) {
|
||||||
|
traceBytes[len - 1 - i] = pathBytes[i];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return traceBytes;
|
return traceBytes;
|
||||||
@@ -142,11 +165,16 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
|
|||||||
: widget.path;
|
: widget.path;
|
||||||
|
|
||||||
if (widget.flipPathRound) {
|
if (widget.flipPathRound) {
|
||||||
path = addReturnPath(pathTmp);
|
path = buildPath(pathTmp);
|
||||||
} else {
|
} else {
|
||||||
path = pathTmp;
|
path = pathTmp;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
appLogger.info(
|
||||||
|
'Initiating path trace with path: ${_formatPathPrefixes(path)}',
|
||||||
|
tag: 'PathTraceMapScreen',
|
||||||
|
);
|
||||||
|
|
||||||
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
||||||
final frame = buildTraceReq(
|
final frame = buildTraceReq(
|
||||||
DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||||
@@ -235,10 +263,11 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
|
|||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
Map<int, Contact> pathContacts = {};
|
Map<int, Contact> pathContacts = {};
|
||||||
|
final contacts = <Contact>[
|
||||||
connector.contacts.where((c) => c.type != advTypeChat).forEach((
|
...connector.contacts,
|
||||||
repeater,
|
...connector.discoveredContacts,
|
||||||
) {
|
];
|
||||||
|
contacts.where((c) => c.type != advTypeChat).forEach((repeater) {
|
||||||
for (var repeaterData in pathData) {
|
for (var repeaterData in pathData) {
|
||||||
if (listEquals(
|
if (listEquals(
|
||||||
repeater.publicKey.sublist(0, 1),
|
repeater.publicKey.sublist(0, 1),
|
||||||
|
|||||||
+101
-36
@@ -6,9 +6,12 @@ import 'package:provider/provider.dart';
|
|||||||
|
|
||||||
import '../connector/meshcore_connector.dart';
|
import '../connector/meshcore_connector.dart';
|
||||||
import '../l10n/l10n.dart';
|
import '../l10n/l10n.dart';
|
||||||
|
import '../utils/app_logger.dart';
|
||||||
import '../widgets/adaptive_app_bar_title.dart';
|
import '../widgets/adaptive_app_bar_title.dart';
|
||||||
import '../widgets/device_tile.dart';
|
import '../widgets/device_tile.dart';
|
||||||
import 'contacts_screen.dart';
|
import 'contacts_screen.dart';
|
||||||
|
import 'tcp_screen.dart';
|
||||||
|
import 'usb_screen.dart';
|
||||||
|
|
||||||
/// Screen for scanning and connecting to MeshCore devices
|
/// Screen for scanning and connecting to MeshCore devices
|
||||||
class ScannerScreen extends StatefulWidget {
|
class ScannerScreen extends StatefulWidget {
|
||||||
@@ -20,6 +23,7 @@ class ScannerScreen extends StatefulWidget {
|
|||||||
|
|
||||||
class _ScannerScreenState extends State<ScannerScreen> {
|
class _ScannerScreenState extends State<ScannerScreen> {
|
||||||
bool _changedNavigation = false;
|
bool _changedNavigation = false;
|
||||||
|
late final MeshCoreConnector _connector;
|
||||||
late final VoidCallback _connectionListener;
|
late final VoidCallback _connectionListener;
|
||||||
BluetoothAdapterState _bluetoothState = BluetoothAdapterState.unknown;
|
BluetoothAdapterState _bluetoothState = BluetoothAdapterState.unknown;
|
||||||
late StreamSubscription<BluetoothAdapterState> _bluetoothStateSubscription;
|
late StreamSubscription<BluetoothAdapterState> _bluetoothStateSubscription;
|
||||||
@@ -27,12 +31,15 @@ class _ScannerScreenState extends State<ScannerScreen> {
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
_connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
||||||
|
|
||||||
_connectionListener = () {
|
_connectionListener = () {
|
||||||
if (connector.state == MeshCoreConnectionState.disconnected) {
|
final isCurrentRoute = ModalRoute.of(context)?.isCurrent ?? true;
|
||||||
|
if (_connector.state == MeshCoreConnectionState.disconnected) {
|
||||||
_changedNavigation = false;
|
_changedNavigation = false;
|
||||||
} else if (connector.state == MeshCoreConnectionState.connected &&
|
} else if (_connector.state == MeshCoreConnectionState.connected &&
|
||||||
|
_connector.activeTransport == MeshCoreTransportType.bluetooth &&
|
||||||
|
isCurrentRoute &&
|
||||||
!_changedNavigation) {
|
!_changedNavigation) {
|
||||||
_changedNavigation = true;
|
_changedNavigation = true;
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
@@ -43,7 +50,7 @@ class _ScannerScreenState extends State<ScannerScreen> {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
connector.addListener(_connectionListener);
|
_connector.addListener(_connectionListener);
|
||||||
|
|
||||||
_bluetoothStateSubscription = FlutterBluePlus.adapterState.listen(
|
_bluetoothStateSubscription = FlutterBluePlus.adapterState.listen(
|
||||||
(state) {
|
(state) {
|
||||||
@@ -53,28 +60,42 @@ class _ScannerScreenState extends State<ScannerScreen> {
|
|||||||
});
|
});
|
||||||
// Cancel scan if Bluetooth turns off while scanning
|
// Cancel scan if Bluetooth turns off while scanning
|
||||||
if (state != BluetoothAdapterState.on) {
|
if (state != BluetoothAdapterState.on) {
|
||||||
unawaited(connector.stopScan());
|
unawaited(_connector.stopScan());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onError: (Object e) {
|
onError: (Object e) {
|
||||||
debugPrint("Scanner adapterState stream error: $e");
|
appLogger.warn('Adapter state stream error: $e', tag: 'ScannerScreen');
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
_connector.removeListener(_connectionListener);
|
||||||
connector.removeListener(_connectionListener);
|
|
||||||
unawaited(_bluetoothStateSubscription.cancel());
|
unawaited(_bluetoothStateSubscription.cancel());
|
||||||
|
if (!_changedNavigation) {
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
unawaited(_connector.disconnect(manual: true));
|
||||||
|
});
|
||||||
|
}
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final canPop = Navigator.of(context).canPop();
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
|
leading: canPop
|
||||||
|
? IconButton(
|
||||||
|
icon: const Icon(Icons.arrow_back),
|
||||||
|
onPressed: () {
|
||||||
|
appLogger.info('Back button pressed', tag: 'ScannerScreen');
|
||||||
|
Navigator.of(context).maybePop();
|
||||||
|
},
|
||||||
|
)
|
||||||
|
: null,
|
||||||
title: AdaptiveAppBarTitle(context.l10n.scanner_title),
|
title: AdaptiveAppBarTitle(context.l10n.scanner_title),
|
||||||
centerTitle: true,
|
centerTitle: true,
|
||||||
automaticallyImplyLeading: false,
|
automaticallyImplyLeading: false,
|
||||||
@@ -99,40 +120,84 @@ class _ScannerScreenState extends State<ScannerScreen> {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
floatingActionButton: Consumer<MeshCoreConnector>(
|
bottomNavigationBar: Consumer<MeshCoreConnector>(
|
||||||
builder: (context, connector, child) {
|
builder: (context, connector, child) {
|
||||||
final isScanning =
|
final isScanning =
|
||||||
connector.state == MeshCoreConnectionState.scanning;
|
connector.state == MeshCoreConnectionState.scanning;
|
||||||
final isBluetoothOff = _bluetoothState == BluetoothAdapterState.off;
|
final isBluetoothOff = _bluetoothState == BluetoothAdapterState.off;
|
||||||
|
final usbSupported = PlatformInfo.supportsUsbSerial;
|
||||||
|
final tcpSupported = !PlatformInfo.isWeb;
|
||||||
|
|
||||||
return FloatingActionButton.extended(
|
return SafeArea(
|
||||||
onPressed: isBluetoothOff
|
top: false,
|
||||||
? null
|
minimum: const EdgeInsets.fromLTRB(16, 8, 16, 16),
|
||||||
: () {
|
child: FittedBox(
|
||||||
if (isScanning) {
|
fit: BoxFit.scaleDown,
|
||||||
connector.stopScan();
|
alignment: Alignment.centerRight,
|
||||||
} else {
|
child: Row(
|
||||||
unawaited(
|
mainAxisAlignment: MainAxisAlignment.end,
|
||||||
connector.startScan().catchError((e) {
|
children: [
|
||||||
debugPrint("Scanner screen startScan error: $e");
|
if (usbSupported)
|
||||||
}),
|
FloatingActionButton.extended(
|
||||||
);
|
onPressed: () {
|
||||||
}
|
appLogger.info(
|
||||||
},
|
'USB selected, opening UsbScreen',
|
||||||
icon: isScanning
|
tag: 'ScannerScreen',
|
||||||
? const SizedBox(
|
);
|
||||||
width: 20,
|
Navigator.of(context).push(
|
||||||
height: 20,
|
MaterialPageRoute(builder: (_) => const UsbScreen()),
|
||||||
child: CircularProgressIndicator(
|
);
|
||||||
strokeWidth: 2,
|
},
|
||||||
color: Colors.white,
|
heroTag: 'scanner_usb_action',
|
||||||
|
icon: const Icon(Icons.usb),
|
||||||
|
label: Text(context.l10n.connectionChoiceUsbLabel),
|
||||||
),
|
),
|
||||||
)
|
if (usbSupported) const SizedBox(width: 12),
|
||||||
: const Icon(Icons.bluetooth_searching),
|
if (tcpSupported)
|
||||||
label: Text(
|
FloatingActionButton.extended(
|
||||||
isScanning
|
onPressed: () {
|
||||||
? context.l10n.scanner_stop
|
Navigator.of(context).push(
|
||||||
: context.l10n.scanner_scan,
|
MaterialPageRoute(builder: (_) => const TcpScreen()),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
heroTag: 'scanner_tcp_action',
|
||||||
|
icon: const Icon(Icons.lan),
|
||||||
|
label: Text(context.l10n.connectionChoiceTcpLabel),
|
||||||
|
),
|
||||||
|
if (tcpSupported) const SizedBox(width: 12),
|
||||||
|
FloatingActionButton.extended(
|
||||||
|
heroTag: 'scanner_ble_action',
|
||||||
|
onPressed: isBluetoothOff
|
||||||
|
? null
|
||||||
|
: () {
|
||||||
|
if (isScanning) {
|
||||||
|
connector.stopScan();
|
||||||
|
} else {
|
||||||
|
unawaited(
|
||||||
|
connector.startScan().catchError((e) {
|
||||||
|
appLogger.warn(
|
||||||
|
'startScan error: $e',
|
||||||
|
tag: 'ScannerScreen',
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
icon: isScanning
|
||||||
|
? const SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.bluetooth_searching),
|
||||||
|
label: Text(
|
||||||
|
isScanning
|
||||||
|
? context.l10n.scanner_stop
|
||||||
|
: context.l10n.scanner_scan,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,282 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import '../connector/meshcore_connector.dart';
|
||||||
|
import '../l10n/l10n.dart';
|
||||||
|
import '../utils/platform_info.dart';
|
||||||
|
import '../widgets/adaptive_app_bar_title.dart';
|
||||||
|
import 'contacts_screen.dart';
|
||||||
|
import 'usb_screen.dart';
|
||||||
|
|
||||||
|
class TcpScreen extends StatefulWidget {
|
||||||
|
const TcpScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<TcpScreen> createState() => _TcpScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TcpScreenState extends State<TcpScreen> {
|
||||||
|
late final TextEditingController _hostController;
|
||||||
|
late final TextEditingController _portController;
|
||||||
|
late final MeshCoreConnector _connector;
|
||||||
|
late final VoidCallback _connectionListener;
|
||||||
|
bool _navigatedToContacts = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_hostController = TextEditingController();
|
||||||
|
_portController = TextEditingController(text: '5000');
|
||||||
|
_connector = context.read<MeshCoreConnector>();
|
||||||
|
|
||||||
|
_connectionListener = () {
|
||||||
|
if (!mounted) return;
|
||||||
|
if (_connector.state == MeshCoreConnectionState.disconnected) {
|
||||||
|
_navigatedToContacts = false;
|
||||||
|
}
|
||||||
|
if (_connector.state == MeshCoreConnectionState.connected &&
|
||||||
|
_connector.isTcpTransportConnected &&
|
||||||
|
!_navigatedToContacts) {
|
||||||
|
_navigatedToContacts = true;
|
||||||
|
Navigator.of(context).pushReplacement(
|
||||||
|
MaterialPageRoute(builder: (_) => const ContactsScreen()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
_connector.addListener(_connectionListener);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_hostController.dispose();
|
||||||
|
_portController.dispose();
|
||||||
|
_connector.removeListener(_connectionListener);
|
||||||
|
if (!_navigatedToContacts &&
|
||||||
|
_connector.activeTransport == MeshCoreTransportType.tcp &&
|
||||||
|
_connector.state != MeshCoreConnectionState.disconnected) {
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
unawaited(_connector.disconnect(manual: true));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
leading: IconButton(
|
||||||
|
icon: const Icon(Icons.arrow_back),
|
||||||
|
onPressed: () => Navigator.of(context).maybePop(),
|
||||||
|
),
|
||||||
|
title: AdaptiveAppBarTitle(context.l10n.tcpScreenTitle),
|
||||||
|
centerTitle: true,
|
||||||
|
),
|
||||||
|
body: SafeArea(
|
||||||
|
top: false,
|
||||||
|
child: Consumer<MeshCoreConnector>(
|
||||||
|
builder: (context, connector, child) {
|
||||||
|
final isConnecting =
|
||||||
|
connector.state == MeshCoreConnectionState.connecting &&
|
||||||
|
connector.activeTransport == MeshCoreTransportType.tcp;
|
||||||
|
final isButtonDisabled =
|
||||||
|
isConnecting ||
|
||||||
|
connector.state == MeshCoreConnectionState.scanning;
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
_buildStatusBar(context, connector),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
TextField(
|
||||||
|
controller: _hostController,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: context.l10n.tcpHostLabel,
|
||||||
|
hintText: context.l10n.tcpHostHint,
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
enabled: !isConnecting,
|
||||||
|
keyboardType: TextInputType.url,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
TextField(
|
||||||
|
controller: _portController,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: context.l10n.tcpPortLabel,
|
||||||
|
hintText: context.l10n.tcpPortHint,
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
enabled: !isConnecting,
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
FilledButton.icon(
|
||||||
|
key: const Key('tcp_connect_button'),
|
||||||
|
onPressed: isButtonDisabled ? null : _connectTcp,
|
||||||
|
icon: isConnecting
|
||||||
|
? const SizedBox(
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.lan),
|
||||||
|
label: Text(
|
||||||
|
isConnecting
|
||||||
|
? context.l10n.scanner_connecting
|
||||||
|
: context.l10n.common_connect,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
bottomNavigationBar: SafeArea(
|
||||||
|
top: false,
|
||||||
|
minimum: const EdgeInsets.fromLTRB(16, 8, 16, 16),
|
||||||
|
child: FittedBox(
|
||||||
|
fit: BoxFit.scaleDown,
|
||||||
|
alignment: Alignment.centerRight,
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
if (PlatformInfo.supportsUsbSerial)
|
||||||
|
FloatingActionButton.extended(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.of(context).pushReplacement(
|
||||||
|
MaterialPageRoute(builder: (_) => const UsbScreen()),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
heroTag: 'tcp_usb_action',
|
||||||
|
extendedPadding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
|
icon: const Icon(Icons.usb),
|
||||||
|
label: Text(context.l10n.connectionChoiceUsbLabel),
|
||||||
|
),
|
||||||
|
if (PlatformInfo.supportsUsbSerial) const SizedBox(width: 12),
|
||||||
|
FloatingActionButton.extended(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.of(context).maybePop();
|
||||||
|
},
|
||||||
|
heroTag: 'tcp_ble_action',
|
||||||
|
extendedPadding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
|
icon: const Icon(Icons.bluetooth),
|
||||||
|
label: Text(context.l10n.connectionChoiceBluetoothLabel),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildStatusBar(BuildContext context, MeshCoreConnector connector) {
|
||||||
|
final l10n = context.l10n;
|
||||||
|
String statusText;
|
||||||
|
Color statusColor;
|
||||||
|
|
||||||
|
if (connector.isTcpTransportConnected) {
|
||||||
|
statusText = l10n.scanner_connectedTo(
|
||||||
|
connector.activeTcpEndpoint ?? 'TCP',
|
||||||
|
);
|
||||||
|
statusColor = Colors.green;
|
||||||
|
} else if (connector.state == MeshCoreConnectionState.connecting &&
|
||||||
|
connector.activeTransport == MeshCoreTransportType.tcp) {
|
||||||
|
statusText = l10n.tcpStatus_connectingTo(
|
||||||
|
'${_hostController.text}:${_portController.text}',
|
||||||
|
);
|
||||||
|
statusColor = Colors.orange;
|
||||||
|
} else if (connector.state == MeshCoreConnectionState.disconnecting &&
|
||||||
|
connector.activeTransport == MeshCoreTransportType.tcp) {
|
||||||
|
statusText = l10n.scanner_disconnecting;
|
||||||
|
statusColor = Colors.orange;
|
||||||
|
} else {
|
||||||
|
statusText = l10n.tcpStatus_notConnected;
|
||||||
|
statusColor = Colors.grey;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
|
||||||
|
color: statusColor.withValues(alpha: 0.1),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.circle, size: 12, color: statusColor),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: FittedBox(
|
||||||
|
fit: BoxFit.scaleDown,
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: Text(
|
||||||
|
statusText,
|
||||||
|
style: TextStyle(
|
||||||
|
color: statusColor,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _connectTcp() async {
|
||||||
|
if (_connector.state == MeshCoreConnectionState.connecting ||
|
||||||
|
_connector.state == MeshCoreConnectionState.connected ||
|
||||||
|
_connector.state == MeshCoreConnectionState.disconnecting) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final host = _hostController.text.trim();
|
||||||
|
final parsedPort = int.tryParse(_portController.text.trim());
|
||||||
|
if (host.isEmpty) {
|
||||||
|
_showError(context.l10n.tcpErrorHostRequired);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (parsedPort == null || parsedPort < 1 || parsedPort > 65535) {
|
||||||
|
_showError(context.l10n.tcpErrorPortInvalid);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await _connector.connectTcp(host: host, port: parsedPort);
|
||||||
|
} catch (error) {
|
||||||
|
if (!mounted) return;
|
||||||
|
_showError(_friendlyErrorMessage(error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showError(String message) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(message), backgroundColor: Colors.red),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _friendlyErrorMessage(Object error) {
|
||||||
|
if (error is UnsupportedError) {
|
||||||
|
return context.l10n.tcpErrorUnsupported;
|
||||||
|
}
|
||||||
|
if (error is TimeoutException) {
|
||||||
|
return context.l10n.tcpErrorTimedOut;
|
||||||
|
}
|
||||||
|
if (error is StateError) {
|
||||||
|
return context.l10n.tcpConnectionFailed(error.message);
|
||||||
|
}
|
||||||
|
if (error is ArgumentError) {
|
||||||
|
return context.l10n.tcpConnectionFailed(
|
||||||
|
error.message?.toString() ?? error.toString(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return context.l10n.tcpConnectionFailed(error.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,441 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import '../connector/meshcore_connector.dart';
|
||||||
|
import '../l10n/l10n.dart';
|
||||||
|
import '../utils/app_logger.dart';
|
||||||
|
import '../utils/platform_info.dart';
|
||||||
|
import '../utils/usb_port_labels.dart';
|
||||||
|
import '../widgets/adaptive_app_bar_title.dart';
|
||||||
|
import 'contacts_screen.dart';
|
||||||
|
import 'scanner_screen.dart';
|
||||||
|
import 'tcp_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 _navigatedToContacts = false;
|
||||||
|
bool _didScheduleInitialLoad = false;
|
||||||
|
Timer? _hotPlugTimer;
|
||||||
|
late final MeshCoreConnector _connector;
|
||||||
|
late final VoidCallback _connectionListener;
|
||||||
|
|
||||||
|
bool get _supportsHotPlug =>
|
||||||
|
PlatformInfo.isWindows || PlatformInfo.isLinux || PlatformInfo.isMacOS;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_connector = context.read<MeshCoreConnector>();
|
||||||
|
_connectionListener = () {
|
||||||
|
if (!mounted) return;
|
||||||
|
if (_connector.state == MeshCoreConnectionState.disconnected) {
|
||||||
|
_navigatedToContacts = false;
|
||||||
|
}
|
||||||
|
if (_connector.state == MeshCoreConnectionState.connected &&
|
||||||
|
_connector.isUsbTransportConnected &&
|
||||||
|
!_navigatedToContacts) {
|
||||||
|
_navigatedToContacts = true;
|
||||||
|
Navigator.of(context).pushReplacement(
|
||||||
|
MaterialPageRoute(builder: (_) => const ContactsScreen()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
_connector.addListener(_connectionListener);
|
||||||
|
_startHotPlugTimer();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didChangeDependencies() {
|
||||||
|
super.didChangeDependencies();
|
||||||
|
_connector.setUsbRequestPortLabel(context.l10n.usbScreenStatus);
|
||||||
|
_connector.setUsbFallbackDeviceName(context.l10n.usbFallbackDeviceName);
|
||||||
|
if (!_didScheduleInitialLoad) {
|
||||||
|
_didScheduleInitialLoad = true;
|
||||||
|
unawaited(_loadPorts());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_hotPlugTimer?.cancel();
|
||||||
|
_hotPlugTimer = null;
|
||||||
|
_connector.removeListener(_connectionListener);
|
||||||
|
if (!_navigatedToContacts &&
|
||||||
|
_connector.activeTransport == MeshCoreTransportType.usb &&
|
||||||
|
_connector.state != MeshCoreConnectionState.disconnected) {
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
unawaited(_connector.disconnect(manual: true));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
leading: IconButton(
|
||||||
|
icon: const Icon(Icons.arrow_back),
|
||||||
|
onPressed: () => Navigator.of(context).maybePop(),
|
||||||
|
),
|
||||||
|
title: AdaptiveAppBarTitle(context.l10n.usbScreenTitle),
|
||||||
|
centerTitle: true,
|
||||||
|
),
|
||||||
|
body: SafeArea(
|
||||||
|
top: false,
|
||||||
|
child: Consumer<MeshCoreConnector>(
|
||||||
|
builder: (context, connector, child) {
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
_buildStatusBar(context, connector),
|
||||||
|
Expanded(child: _buildPortList(context, connector)),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
bottomNavigationBar: Consumer<MeshCoreConnector>(
|
||||||
|
builder: (context, connector, child) {
|
||||||
|
final isLoading = _isLoadingPorts;
|
||||||
|
final showBle = true;
|
||||||
|
final showTcp = !PlatformInfo.isWeb;
|
||||||
|
|
||||||
|
return SafeArea(
|
||||||
|
top: false,
|
||||||
|
minimum: const EdgeInsets.fromLTRB(16, 8, 16, 16),
|
||||||
|
child: FittedBox(
|
||||||
|
fit: BoxFit.scaleDown,
|
||||||
|
alignment: Alignment.centerRight,
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
if (showTcp)
|
||||||
|
FloatingActionButton.extended(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.of(context).pushReplacement(
|
||||||
|
MaterialPageRoute(builder: (_) => const TcpScreen()),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
heroTag: 'usb_tcp_action',
|
||||||
|
extendedPadding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 12,
|
||||||
|
),
|
||||||
|
icon: const Icon(Icons.lan),
|
||||||
|
label: Text(context.l10n.connectionChoiceTcpLabel),
|
||||||
|
),
|
||||||
|
if (showTcp && showBle) const SizedBox(width: 12),
|
||||||
|
if (showBle)
|
||||||
|
FloatingActionButton.extended(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.of(context).pushReplacement(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (_) => const ScannerScreen(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
heroTag: 'usb_ble_action',
|
||||||
|
extendedPadding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 12,
|
||||||
|
),
|
||||||
|
icon: const Icon(Icons.bluetooth),
|
||||||
|
label: Text(context.l10n.connectionChoiceBluetoothLabel),
|
||||||
|
),
|
||||||
|
if ((showTcp || showBle) && !_supportsHotPlug)
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
if (!_supportsHotPlug)
|
||||||
|
FloatingActionButton.extended(
|
||||||
|
onPressed: isLoading ? null : _loadPorts,
|
||||||
|
heroTag: 'usb_refresh_action',
|
||||||
|
extendedPadding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 12,
|
||||||
|
),
|
||||||
|
icon: isLoading
|
||||||
|
? const SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.usb),
|
||||||
|
label: Text(context.l10n.scanner_scan),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildStatusBar(BuildContext context, MeshCoreConnector connector) {
|
||||||
|
final l10n = context.l10n;
|
||||||
|
String statusText;
|
||||||
|
Color statusColor;
|
||||||
|
|
||||||
|
if (_isLoadingPorts) {
|
||||||
|
statusText = l10n.usbStatus_searching;
|
||||||
|
statusColor = Colors.blue;
|
||||||
|
} else if (connector.isUsbTransportConnected) {
|
||||||
|
switch (connector.state) {
|
||||||
|
case MeshCoreConnectionState.connected:
|
||||||
|
statusText = l10n.scanner_connectedTo(
|
||||||
|
connector.activeUsbPortDisplayLabel ?? 'USB',
|
||||||
|
);
|
||||||
|
statusColor = Colors.green;
|
||||||
|
case MeshCoreConnectionState.disconnecting:
|
||||||
|
statusText = l10n.scanner_disconnecting;
|
||||||
|
statusColor = Colors.orange;
|
||||||
|
default:
|
||||||
|
statusText = l10n.usbStatus_notConnected;
|
||||||
|
statusColor = Colors.grey;
|
||||||
|
}
|
||||||
|
} else if (connector.state == MeshCoreConnectionState.connecting &&
|
||||||
|
connector.activeTransport == MeshCoreTransportType.usb) {
|
||||||
|
statusText = l10n.usbStatus_connecting;
|
||||||
|
statusColor = Colors.orange;
|
||||||
|
} else {
|
||||||
|
statusText = l10n.usbStatus_notConnected;
|
||||||
|
statusColor = Colors.grey;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
|
||||||
|
color: statusColor.withValues(alpha: 0.1),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.circle, size: 12, color: statusColor),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: FittedBox(
|
||||||
|
fit: BoxFit.scaleDown,
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: Text(
|
||||||
|
statusText,
|
||||||
|
style: TextStyle(
|
||||||
|
color: statusColor,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildPortList(BuildContext context, MeshCoreConnector connector) {
|
||||||
|
final l10n = context.l10n;
|
||||||
|
|
||||||
|
if (_isLoadingPorts) {
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.usb, size: 64, color: Colors.grey[400]),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
l10n.usbStatus_searching,
|
||||||
|
style: TextStyle(fontSize: 16, color: Colors.grey[600]),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_ports.isEmpty) {
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.usb, size: 64, color: Colors.grey[400]),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
l10n.usbScreenEmptyState,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(fontSize: 16, color: Colors.grey[600]),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final isConnecting =
|
||||||
|
connector.state == MeshCoreConnectionState.connecting &&
|
||||||
|
connector.activeTransport == MeshCoreTransportType.usb;
|
||||||
|
|
||||||
|
return ListView.separated(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
itemCount: _ports.length,
|
||||||
|
separatorBuilder: (context, index) => const Divider(),
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final port = _ports[index];
|
||||||
|
final displayName = friendlyUsbPortName(port);
|
||||||
|
final rawName = normalizeUsbPortName(port);
|
||||||
|
final showRawName =
|
||||||
|
rawName != displayName && !rawName.startsWith('web:');
|
||||||
|
|
||||||
|
return ListTile(
|
||||||
|
leading: const Icon(Icons.usb),
|
||||||
|
title: Text(
|
||||||
|
displayName,
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.w500),
|
||||||
|
),
|
||||||
|
subtitle: showRawName ? Text(rawName) : null,
|
||||||
|
trailing: ElevatedButton(
|
||||||
|
onPressed: isConnecting ? null : () => _connectPort(port),
|
||||||
|
child: Text(l10n.common_connect),
|
||||||
|
),
|
||||||
|
onTap: isConnecting ? null : () => _connectPort(port),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _startHotPlugTimer() {
|
||||||
|
if (!_supportsHotPlug) return;
|
||||||
|
_hotPlugTimer?.cancel();
|
||||||
|
_hotPlugTimer = Timer.periodic(const Duration(seconds: 2), (_) {
|
||||||
|
_pollHotPlug();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pollHotPlug() async {
|
||||||
|
if (_isLoadingPorts) return;
|
||||||
|
if (!mounted) return;
|
||||||
|
// Don't poll while connecting or connected.
|
||||||
|
if (_connector.state != MeshCoreConnectionState.disconnected) return;
|
||||||
|
try {
|
||||||
|
final ports = await _connector.listUsbPorts();
|
||||||
|
if (!mounted) return;
|
||||||
|
final added = ports.where((p) => !_ports.contains(p)).toList();
|
||||||
|
final removed = _ports.where((p) => !ports.contains(p)).toList();
|
||||||
|
if (added.isEmpty && removed.isEmpty) return;
|
||||||
|
setState(() {
|
||||||
|
_ports
|
||||||
|
..clear()
|
||||||
|
..addAll(ports);
|
||||||
|
});
|
||||||
|
} catch (_) {
|
||||||
|
// Silent — hot-plug failures are non-critical.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadPorts() async {
|
||||||
|
if (!mounted) return;
|
||||||
|
_connector.setUsbRequestPortLabel(context.l10n.usbScreenStatus);
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_isLoadingPorts = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
final ports = await _connector.listUsbPorts();
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_ports
|
||||||
|
..clear()
|
||||||
|
..addAll(ports);
|
||||||
|
_isLoadingPorts = false;
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_ports.clear();
|
||||||
|
_isLoadingPorts = false;
|
||||||
|
});
|
||||||
|
_showError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _connectPort(String port) async {
|
||||||
|
if (_connector.state != MeshCoreConnectionState.disconnected) return;
|
||||||
|
|
||||||
|
final rawPortName = normalizeUsbPortName(port);
|
||||||
|
appLogger.info(
|
||||||
|
'Connect tapped for $port (raw: $rawPortName)',
|
||||||
|
tag: 'UsbScreen',
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await _connector.connectUsb(portName: rawPortName);
|
||||||
|
} catch (error, stackTrace) {
|
||||||
|
appLogger.error(
|
||||||
|
'Connect failed for $rawPortName: $error\n$stackTrace',
|
||||||
|
tag: 'UsbScreen',
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
_showError(error);
|
||||||
|
unawaited(_loadPorts());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showError(Object error) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(_friendlyErrorMessage(error)),
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _friendlyErrorMessage(Object error) {
|
||||||
|
final l10n = context.l10n;
|
||||||
|
|
||||||
|
if (error is PlatformException) {
|
||||||
|
switch (error.code) {
|
||||||
|
case 'usb_permission_denied':
|
||||||
|
return l10n.usbErrorPermissionDenied;
|
||||||
|
case 'usb_device_missing':
|
||||||
|
case 'usb_device_detached':
|
||||||
|
return l10n.usbErrorDeviceMissing;
|
||||||
|
case 'usb_invalid_port':
|
||||||
|
return l10n.usbErrorInvalidPort;
|
||||||
|
case 'usb_busy':
|
||||||
|
return l10n.usbErrorBusy;
|
||||||
|
case 'usb_not_connected':
|
||||||
|
return l10n.usbErrorNotConnected;
|
||||||
|
case 'usb_open_failed':
|
||||||
|
case 'usb_driver_missing':
|
||||||
|
return l10n.usbErrorOpenFailed;
|
||||||
|
case 'usb_connect_failed':
|
||||||
|
return l10n.usbErrorConnectFailed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error is UnsupportedError) {
|
||||||
|
return l10n.usbErrorUnsupported;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error is StateError) {
|
||||||
|
final msg = error.message;
|
||||||
|
if (msg.contains('already active')) return l10n.usbErrorAlreadyActive;
|
||||||
|
if (msg.contains('No USB serial device selected')) {
|
||||||
|
return l10n.usbErrorNoDeviceSelected;
|
||||||
|
}
|
||||||
|
if (msg.contains('not open') || msg.contains('closed')) {
|
||||||
|
return l10n.usbErrorPortClosed;
|
||||||
|
}
|
||||||
|
if (msg.contains('Timed out')) return l10n.usbErrorConnectTimedOut;
|
||||||
|
if (msg.contains('Failed to open')) return l10n.usbErrorOpenFailed;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error is TimeoutException) {
|
||||||
|
return l10n.usbErrorConnectTimedOut;
|
||||||
|
}
|
||||||
|
|
||||||
|
return error.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -52,7 +52,12 @@ class AppDebugLogService extends ChangeNotifier {
|
|||||||
String tag = 'App',
|
String tag = 'App',
|
||||||
AppDebugLogLevel level = AppDebugLogLevel.info,
|
AppDebugLogLevel level = AppDebugLogLevel.info,
|
||||||
}) {
|
}) {
|
||||||
if (!_enabled) return;
|
if (!_enabled && !kDebugMode) return;
|
||||||
|
if (!_enabled) {
|
||||||
|
// In debug mode, still print to console but don't store entries.
|
||||||
|
debugPrint('[$tag] $message');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
_entries.add(
|
_entries.add(
|
||||||
AppDebugLogEntry(
|
AppDebugLogEntry(
|
||||||
|
|||||||
@@ -134,6 +134,10 @@ class AppSettingsService extends ChangeNotifier {
|
|||||||
appLogger.setEnabled(value);
|
appLogger.setEnabled(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> setMapShowDiscoveryContacts(bool value) async {
|
||||||
|
await updateSettings(_settings.copyWith(mapShowDiscoveryContacts: value));
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> setBatteryChemistryForDevice(
|
Future<void> setBatteryChemistryForDevice(
|
||||||
String deviceId,
|
String deviceId,
|
||||||
String chemistry,
|
String chemistry,
|
||||||
|
|||||||
@@ -44,6 +44,12 @@ class MessageRetryService extends ChangeNotifier {
|
|||||||
[]; // Rolling buffer of recent ACK hashes
|
[]; // Rolling buffer of recent ACK hashes
|
||||||
final Map<String, List<String>> _pendingMessageQueuePerContact =
|
final Map<String, List<String>> _pendingMessageQueuePerContact =
|
||||||
{}; // contactPubKeyHex → FIFO queue of messageIds (DEPRECATED - will be removed)
|
{}; // contactPubKeyHex → FIFO queue of messageIds (DEPRECATED - will be removed)
|
||||||
|
final Map<String, List<String>> _sendQueue =
|
||||||
|
{}; // contactPubKeyHex → ordered list of messageIds awaiting send
|
||||||
|
final Set<String> _activeMessages =
|
||||||
|
{}; // messageIds currently in-flight (sent/retrying)
|
||||||
|
final Set<String> _resolvedMessages =
|
||||||
|
{}; // messageIds already resolved (prevents double _onMessageResolved)
|
||||||
final Map<String, String> _expectedHashToMessageId =
|
final Map<String, String> _expectedHashToMessageId =
|
||||||
{}; // expectedAckHashHex → messageId (for matching RESP_CODE_SENT by hash)
|
{}; // expectedAckHashHex → messageId (for matching RESP_CODE_SENT by hash)
|
||||||
|
|
||||||
@@ -156,7 +162,49 @@ class MessageRetryService extends ChangeNotifier {
|
|||||||
_addMessageCallback!(contact.publicKeyHex, message);
|
_addMessageCallback!(contact.publicKeyHex, message);
|
||||||
}
|
}
|
||||||
|
|
||||||
await _attemptSend(messageId);
|
// Queue per contact — only one message in-flight at a time to avoid
|
||||||
|
// overflowing the firmware's 8-entry expected_ack_table.
|
||||||
|
final contactKey = contact.publicKeyHex;
|
||||||
|
_sendQueue[contactKey] ??= [];
|
||||||
|
_sendQueue[contactKey]!.add(messageId);
|
||||||
|
|
||||||
|
if (!_activeMessages.any(
|
||||||
|
(id) => _pendingContacts[id]?.publicKeyHex == contactKey,
|
||||||
|
)) {
|
||||||
|
_sendNextForContact(contactKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _sendNextForContact(String contactKey) {
|
||||||
|
final queue = _sendQueue[contactKey];
|
||||||
|
if (queue == null) return;
|
||||||
|
|
||||||
|
// Drain stale entries iteratively instead of recursing.
|
||||||
|
while (queue.isNotEmpty) {
|
||||||
|
final messageId = queue.removeAt(0);
|
||||||
|
if (_pendingMessages.containsKey(messageId)) {
|
||||||
|
_activeMessages.add(messageId);
|
||||||
|
_attemptSend(messageId).catchError((e) {
|
||||||
|
debugPrint('_attemptSend threw for $messageId: $e');
|
||||||
|
final msg = _pendingMessages[messageId];
|
||||||
|
if (msg != null) {
|
||||||
|
final failed = msg.copyWith(status: MessageStatus.failed);
|
||||||
|
_pendingMessages[messageId] = failed;
|
||||||
|
_updateMessageCallback?.call(failed);
|
||||||
|
}
|
||||||
|
_onMessageResolved(messageId, contactKey);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Message was cancelled/cleaned up while queued — try next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onMessageResolved(String messageId, String contactKey) {
|
||||||
|
if (_resolvedMessages.contains(messageId)) return;
|
||||||
|
_resolvedMessages.add(messageId);
|
||||||
|
_activeMessages.remove(messageId);
|
||||||
|
_sendNextForContact(contactKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _attemptSend(String messageId) async {
|
Future<void> _attemptSend(String messageId) async {
|
||||||
@@ -169,13 +217,11 @@ class MessageRetryService extends ChangeNotifier {
|
|||||||
// Use the path that was captured when the message was first sent
|
// Use the path that was captured when the message was first sent
|
||||||
if (_setContactPathCallback != null && _clearContactPathCallback != null) {
|
if (_setContactPathCallback != null && _clearContactPathCallback != null) {
|
||||||
if (message.pathLength != null && message.pathLength! < 0) {
|
if (message.pathLength != null && message.pathLength! < 0) {
|
||||||
// Flood mode - clear the path
|
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'Setting flood mode for retry attempt ${message.retryCount}',
|
'Setting flood mode for retry attempt ${message.retryCount}',
|
||||||
);
|
);
|
||||||
_clearContactPathCallback!(contact);
|
await _clearContactPathCallback!(contact);
|
||||||
} else if (message.pathLength != null && message.pathLength! >= 0) {
|
} else if (message.pathLength != null && message.pathLength! >= 0) {
|
||||||
// Specific path (including direct neighbor with pathLength=0)
|
|
||||||
final pathStr = message.pathBytes.isEmpty
|
final pathStr = message.pathBytes.isEmpty
|
||||||
? 'direct'
|
? 'direct'
|
||||||
: message.pathBytes
|
: message.pathBytes
|
||||||
@@ -192,6 +238,24 @@ class MessageRetryService extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Re-validate after async gap — a timer or ACK could have resolved/retried
|
||||||
|
// this message while we were awaiting the path callback.
|
||||||
|
final currentMessage = _pendingMessages[messageId];
|
||||||
|
if (currentMessage == null || _resolvedMessages.contains(messageId)) {
|
||||||
|
debugPrint(
|
||||||
|
'_attemptSend: message $messageId resolved during path sync, aborting',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// If the message was retried by a timer during our await, the retryCount
|
||||||
|
// will have advanced. Only proceed if it still matches the attempt we started.
|
||||||
|
if (currentMessage.retryCount != message.retryCount) {
|
||||||
|
debugPrint(
|
||||||
|
'_attemptSend: message $messageId retryCount changed during path sync, aborting',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
final attempt = message.retryCount.clamp(0, 3);
|
final attempt = message.retryCount.clamp(0, 3);
|
||||||
final timestampSeconds = message.timestamp.millisecondsSinceEpoch ~/ 1000;
|
final timestampSeconds = message.timestamp.millisecondsSinceEpoch ~/ 1000;
|
||||||
|
|
||||||
@@ -231,6 +295,15 @@ class MessageRetryService extends ChangeNotifier {
|
|||||||
|
|
||||||
if (_sendMessageCallback != null) {
|
if (_sendMessageCallback != null) {
|
||||||
_sendMessageCallback!(contact, message.text, attempt, timestampSeconds);
|
_sendMessageCallback!(contact, message.text, attempt, timestampSeconds);
|
||||||
|
} else {
|
||||||
|
// No send callback — message would be stuck forever. Fail it immediately.
|
||||||
|
debugPrint(
|
||||||
|
'_attemptSend: no sendMessageCallback, failing message $messageId',
|
||||||
|
);
|
||||||
|
final failedMessage = message.copyWith(status: MessageStatus.failed);
|
||||||
|
_pendingMessages[messageId] = failedMessage;
|
||||||
|
_updateMessageCallback?.call(failedMessage);
|
||||||
|
_onMessageResolved(messageId, contact.publicKeyHex);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -281,6 +354,7 @@ class MessageRetryService extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// FALLBACK: Old queue-based matching (for messages sent before hash computation was added)
|
// FALLBACK: Old queue-based matching (for messages sent before hash computation was added)
|
||||||
|
// Only match within a single contact's queue to avoid cross-contact mismatches.
|
||||||
if (messageId == null && allowQueueFallback) {
|
if (messageId == null && allowQueueFallback) {
|
||||||
_debugLogService?.warn(
|
_debugLogService?.warn(
|
||||||
'RESP_CODE_SENT: ACK hash $ackHashHex not found in hash table, falling back to queue',
|
'RESP_CODE_SENT: ACK hash $ackHashHex not found in hash table, falling back to queue',
|
||||||
@@ -290,13 +364,16 @@ class MessageRetryService extends ChangeNotifier {
|
|||||||
'Hash-based match failed for $ackHashHex, falling back to queue-based matching',
|
'Hash-based match failed for $ackHashHex, falling back to queue-based matching',
|
||||||
);
|
);
|
||||||
|
|
||||||
for (var entry in _pendingMessageQueuePerContact.entries) {
|
// Search all contact queues so concurrent chats don't miss matches.
|
||||||
|
final queuesToSearch = _pendingMessageQueuePerContact;
|
||||||
|
|
||||||
|
for (var entry in queuesToSearch.entries) {
|
||||||
final contactKey = entry.key;
|
final contactKey = entry.key;
|
||||||
final queue = entry.value;
|
final queue = entry.value;
|
||||||
|
|
||||||
if (queue.isNotEmpty) {
|
// Drain stale entries until we find a valid one or exhaust the queue.
|
||||||
|
while (queue.isNotEmpty) {
|
||||||
final candidateMessageId = queue.removeAt(0);
|
final candidateMessageId = queue.removeAt(0);
|
||||||
|
|
||||||
if (_pendingMessages.containsKey(candidateMessageId)) {
|
if (_pendingMessages.containsKey(candidateMessageId)) {
|
||||||
messageId = candidateMessageId;
|
messageId = candidateMessageId;
|
||||||
contact = _pendingContacts[candidateMessageId];
|
contact = _pendingContacts[candidateMessageId];
|
||||||
@@ -304,21 +381,10 @@ class MessageRetryService extends ChangeNotifier {
|
|||||||
'Queue-based match (fallback): $ackHashHex → message $messageId for $contactKey',
|
'Queue-based match (fallback): $ackHashHex → message $messageId for $contactKey',
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
} else {
|
|
||||||
debugPrint('Dequeued stale message $candidateMessageId - skipping');
|
|
||||||
if (queue.isNotEmpty) {
|
|
||||||
final nextMessageId = queue.removeAt(0);
|
|
||||||
if (_pendingMessages.containsKey(nextMessageId)) {
|
|
||||||
messageId = nextMessageId;
|
|
||||||
contact = _pendingContacts[nextMessageId];
|
|
||||||
debugPrint(
|
|
||||||
'Queue-based match (fallback): $ackHashHex → message $messageId',
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
debugPrint('Dequeued stale message $candidateMessageId - skipping');
|
||||||
}
|
}
|
||||||
|
if (messageId != null) break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -463,22 +529,7 @@ class MessageRetryService extends ChangeNotifier {
|
|||||||
} else {
|
} else {
|
||||||
// Max retries reached - mark as failed
|
// Max retries reached - mark as failed
|
||||||
final failedMessage = message.copyWith(status: MessageStatus.failed);
|
final failedMessage = message.copyWith(status: MessageStatus.failed);
|
||||||
|
_pendingMessages[messageId] = failedMessage;
|
||||||
// Move ACK hashes to history before removing
|
|
||||||
_moveAckHashesToHistory(messageId);
|
|
||||||
|
|
||||||
_pendingMessages.remove(messageId);
|
|
||||||
_pendingContacts.remove(messageId);
|
|
||||||
_pendingPathSelections.remove(messageId);
|
|
||||||
_timeoutTimers[messageId]?.cancel();
|
|
||||||
_timeoutTimers.remove(messageId);
|
|
||||||
|
|
||||||
// Clean up the queue entry for this contact
|
|
||||||
_pendingMessageQueuePerContact[contact.publicKeyHex]?.remove(messageId);
|
|
||||||
if (_pendingMessageQueuePerContact[contact.publicKeyHex]?.isEmpty ??
|
|
||||||
false) {
|
|
||||||
_pendingMessageQueuePerContact.remove(contact.publicKeyHex);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if we should clear the path on max retry
|
// Check if we should clear the path on max retry
|
||||||
if (_appSettingsService?.settings.clearPathOnMaxRetry == true &&
|
if (_appSettingsService?.settings.clearPathOnMaxRetry == true &&
|
||||||
@@ -499,6 +550,30 @@ class MessageRetryService extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|
||||||
|
// Message is done retrying — send next queued message for this contact
|
||||||
|
_onMessageResolved(messageId, contact.publicKeyHex);
|
||||||
|
|
||||||
|
// Keep message in pending maps for 30s grace period so late ACKs
|
||||||
|
// can still match and update the message to delivered.
|
||||||
|
_timeoutTimers[messageId] = Timer(const Duration(seconds: 30), () {
|
||||||
|
_moveAckHashesToHistory(messageId);
|
||||||
|
// Clean up ALL hash mappings for this message
|
||||||
|
_ackHashToMessageId.removeWhere(
|
||||||
|
(_, mapping) => mapping.messageId == messageId,
|
||||||
|
);
|
||||||
|
_expectedHashToMessageId.removeWhere((_, msgId) => msgId == messageId);
|
||||||
|
_pendingMessages.remove(messageId);
|
||||||
|
_pendingContacts.remove(messageId);
|
||||||
|
_pendingPathSelections.remove(messageId);
|
||||||
|
_timeoutTimers.remove(messageId);
|
||||||
|
_resolvedMessages.remove(messageId);
|
||||||
|
final contactKey = contact.publicKeyHex;
|
||||||
|
_pendingMessageQueuePerContact[contactKey]?.remove(messageId);
|
||||||
|
if (_pendingMessageQueuePerContact[contactKey]?.isEmpty ?? false) {
|
||||||
|
_pendingMessageQueuePerContact.remove(contactKey);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -594,7 +669,15 @@ class MessageRetryService extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (matchedMessageId != null) {
|
if (matchedMessageId != null) {
|
||||||
final message = _pendingMessages[matchedMessageId]!;
|
final message = _pendingMessages[matchedMessageId];
|
||||||
|
if (message == null) {
|
||||||
|
// Message was already cleaned up (e.g. grace period expired)
|
||||||
|
_ackHashToMessageId.remove(ackHashHex);
|
||||||
|
debugPrint(
|
||||||
|
'ACK matched $matchedMessageId but message already cleaned up',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
final contact = _pendingContacts[matchedMessageId];
|
final contact = _pendingContacts[matchedMessageId];
|
||||||
final selection = _pendingPathSelections[matchedMessageId];
|
final selection = _pendingPathSelections[matchedMessageId];
|
||||||
|
|
||||||
@@ -616,12 +699,21 @@ class MessageRetryService extends ChangeNotifier {
|
|||||||
tripTimeMs: tripTimeMs,
|
tripTimeMs: tripTimeMs,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Clean up ALL hash mappings for this message (from all retry attempts)
|
||||||
|
_ackHashToMessageId.removeWhere(
|
||||||
|
(_, mapping) => mapping.messageId == matchedMessageId,
|
||||||
|
);
|
||||||
|
_expectedHashToMessageId.removeWhere(
|
||||||
|
(_, msgId) => msgId == matchedMessageId,
|
||||||
|
);
|
||||||
|
|
||||||
// Move ACK hashes to history before removing
|
// Move ACK hashes to history before removing
|
||||||
_moveAckHashesToHistory(matchedMessageId);
|
_moveAckHashesToHistory(matchedMessageId);
|
||||||
|
|
||||||
_pendingMessages.remove(matchedMessageId);
|
_pendingMessages.remove(matchedMessageId);
|
||||||
_pendingContacts.remove(matchedMessageId);
|
_pendingContacts.remove(matchedMessageId);
|
||||||
_pendingPathSelections.remove(matchedMessageId);
|
_pendingPathSelections.remove(matchedMessageId);
|
||||||
|
_resolvedMessages.remove(matchedMessageId);
|
||||||
|
|
||||||
// Clean up the queue entry for this contact (remove any remaining references to this message)
|
// Clean up the queue entry for this contact (remove any remaining references to this message)
|
||||||
if (contact != null) {
|
if (contact != null) {
|
||||||
@@ -646,6 +738,7 @@ class MessageRetryService extends ChangeNotifier {
|
|||||||
true,
|
true,
|
||||||
tripTimeMs,
|
tripTimeMs,
|
||||||
);
|
);
|
||||||
|
_onMessageResolved(matchedMessageId, contact.publicKeyHex);
|
||||||
}
|
}
|
||||||
|
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
@@ -783,6 +876,9 @@ class MessageRetryService extends ChangeNotifier {
|
|||||||
_ackHistory.clear();
|
_ackHistory.clear();
|
||||||
_ackHashToMessageId.clear();
|
_ackHashToMessageId.clear();
|
||||||
_pendingMessageQueuePerContact.clear();
|
_pendingMessageQueuePerContact.clear();
|
||||||
|
_sendQueue.clear();
|
||||||
|
_activeMessages.clear();
|
||||||
|
_resolvedMessages.clear();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
|
import 'dart:io' show Platform, File;
|
||||||
import 'dart:ui';
|
import 'dart:ui';
|
||||||
|
|
||||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
|
import '../utils/platform_info.dart';
|
||||||
|
|
||||||
class NotificationService {
|
class NotificationService {
|
||||||
static final NotificationService _instance = NotificationService._internal();
|
static final NotificationService _instance = NotificationService._internal();
|
||||||
@@ -63,14 +65,27 @@ class NotificationService {
|
|||||||
appUserModelId: 'org.meshcore.open.app',
|
appUserModelId: 'org.meshcore.open.app',
|
||||||
guid: 'e7ea8f85-72f5-4f36-91f6-038f740ccf86',
|
guid: 'e7ea8f85-72f5-4f36-91f6-038f740ccf86',
|
||||||
);
|
);
|
||||||
|
const linuxSettings = LinuxInitializationSettings(
|
||||||
|
defaultActionName: 'Open notification',
|
||||||
|
);
|
||||||
|
|
||||||
const initSettings = InitializationSettings(
|
const initSettings = InitializationSettings(
|
||||||
android: androidSettings,
|
android: androidSettings,
|
||||||
iOS: iosSettings,
|
iOS: iosSettings,
|
||||||
macOS: macSettings,
|
macOS: macSettings,
|
||||||
windows: windowsSettings,
|
windows: windowsSettings,
|
||||||
|
linux: linuxSettings,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// On Linux, the notifications plugin opens a D-Bus session bus
|
||||||
|
// connection whose async subscription can throw an unhandled
|
||||||
|
// SocketException when the bus socket is missing (e.g. running as
|
||||||
|
// root or inside a container without a session bus).
|
||||||
|
if (PlatformInfo.isLinux && !_isDbusSessionAvailable()) {
|
||||||
|
debugPrint('Skipping notification init: D-Bus session bus unavailable');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await _notifications.initialize(
|
await _notifications.initialize(
|
||||||
settings: initSettings,
|
settings: initSettings,
|
||||||
@@ -82,6 +97,15 @@ class NotificationService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static bool _isDbusSessionAvailable() {
|
||||||
|
final addr = Platform.environment['DBUS_SESSION_BUS_ADDRESS'];
|
||||||
|
if (addr != null && addr.isNotEmpty) return true;
|
||||||
|
// Fallback: check the default socket for the current user.
|
||||||
|
final uid = Platform.environment['UID'] ?? Platform.environment['EUID'];
|
||||||
|
final path = '/run/user/${uid ?? '1000'}/bus';
|
||||||
|
return File(path).existsSync();
|
||||||
|
}
|
||||||
|
|
||||||
Future<bool> _ensureInitialized() async {
|
Future<bool> _ensureInitialized() async {
|
||||||
if (!_isInitialized) {
|
if (!_isInitialized) {
|
||||||
await initialize();
|
await initialize();
|
||||||
@@ -208,7 +232,9 @@ class NotificationService {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await _notifications.show(
|
await _notifications.show(
|
||||||
id: contactId?.hashCode ?? DateTime.now().millisecondsSinceEpoch,
|
id: contactId != null
|
||||||
|
? 'advert:$contactId'.hashCode
|
||||||
|
: DateTime.now().millisecondsSinceEpoch,
|
||||||
title: _l10n.notification_newTypeDiscovered(contactType),
|
title: _l10n.notification_newTypeDiscovered(contactType),
|
||||||
body: contactName,
|
body: contactName,
|
||||||
notificationDetails: notificationDetails,
|
notificationDetails: notificationDetails,
|
||||||
@@ -307,6 +333,61 @@ class NotificationService {
|
|||||||
await _notifications.cancel(id: id);
|
await _notifications.cancel(id: id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cancel the notification for a specific contact and update the app badge.
|
||||||
|
Future<void> clearContactNotification(
|
||||||
|
String contactId,
|
||||||
|
int totalUnreadCount,
|
||||||
|
) async {
|
||||||
|
if (!await _ensureInitialized()) return;
|
||||||
|
await _notifications.cancel(id: contactId.hashCode);
|
||||||
|
await _updateBadge(totalUnreadCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cancel the notification for a specific channel and update the app badge.
|
||||||
|
Future<void> clearChannelNotification(
|
||||||
|
int channelIndex,
|
||||||
|
int totalUnreadCount,
|
||||||
|
) async {
|
||||||
|
if (!await _ensureInitialized()) return;
|
||||||
|
await _notifications.cancel(id: channelIndex.hashCode);
|
||||||
|
await _updateBadge(totalUnreadCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cancel advert notifications for the given contact public key hexes.
|
||||||
|
Future<void> clearAdvertNotifications(List<String> contactIds) async {
|
||||||
|
if (!await _ensureInitialized()) return;
|
||||||
|
for (final id in contactIds) {
|
||||||
|
await _notifications.cancel(id: 'advert:$id'.hashCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _updateBadge(int count) async {
|
||||||
|
if (PlatformInfo.isIOS || PlatformInfo.isMacOS) {
|
||||||
|
// On Apple platforms, set the badge number directly via a silent update.
|
||||||
|
final darwinDetails = DarwinNotificationDetails(
|
||||||
|
presentAlert: false,
|
||||||
|
presentSound: false,
|
||||||
|
presentBadge: true,
|
||||||
|
badgeNumber: count,
|
||||||
|
);
|
||||||
|
final details = NotificationDetails(
|
||||||
|
iOS: darwinDetails,
|
||||||
|
macOS: darwinDetails,
|
||||||
|
);
|
||||||
|
// Use a fixed ID so each update replaces the previous one.
|
||||||
|
await _notifications.show(
|
||||||
|
id: 'badge_update'.hashCode,
|
||||||
|
title: null,
|
||||||
|
body: null,
|
||||||
|
notificationDetails: details,
|
||||||
|
);
|
||||||
|
// Immediately cancel the silent notification so it doesn't appear in tray.
|
||||||
|
await _notifications.cancel(id: 'badge_update'.hashCode);
|
||||||
|
}
|
||||||
|
// On Android, badge count is derived from active notifications,
|
||||||
|
// so cancelling the specific notification above is sufficient.
|
||||||
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────
|
||||||
// Public notification methods (rate limiting is enforced automatically)
|
// Public notification methods (rate limiting is enforced automatically)
|
||||||
// ─────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export 'tcp_transport_service_native.dart'
|
||||||
|
if (dart.library.js_interop) 'tcp_transport_service_web.dart';
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'app_debug_log_service.dart';
|
||||||
|
import 'usb_serial_frame_codec.dart';
|
||||||
|
|
||||||
|
class TcpTransportService {
|
||||||
|
final StreamController<Uint8List> _frameController =
|
||||||
|
StreamController<Uint8List>.broadcast();
|
||||||
|
final UsbSerialFrameDecoder _frameDecoder = UsbSerialFrameDecoder();
|
||||||
|
|
||||||
|
StreamSubscription<Uint8List>? _socketSubscription;
|
||||||
|
Socket? _socket;
|
||||||
|
AppDebugLogService? _debugLogService;
|
||||||
|
TcpTransportStatus _status = TcpTransportStatus.disconnected;
|
||||||
|
String? _activeHost;
|
||||||
|
int? _activePort;
|
||||||
|
Future<void> _pendingWrite = Future<void>.value();
|
||||||
|
int _connectGeneration = 0;
|
||||||
|
|
||||||
|
TcpTransportStatus get status => _status;
|
||||||
|
Stream<Uint8List> get frameStream => _frameController.stream;
|
||||||
|
bool get isConnected => _status == TcpTransportStatus.connected;
|
||||||
|
String? get activeEndpoint => _activeHost == null || _activePort == null
|
||||||
|
? null
|
||||||
|
: '$_activeHost:$_activePort';
|
||||||
|
|
||||||
|
void setDebugLogService(AppDebugLogService? service) {
|
||||||
|
_debugLogService = service;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> connect({
|
||||||
|
required String host,
|
||||||
|
required int port,
|
||||||
|
Duration timeout = const Duration(seconds: 10),
|
||||||
|
}) async {
|
||||||
|
if (_status == TcpTransportStatus.connected ||
|
||||||
|
_status == TcpTransportStatus.connecting) {
|
||||||
|
throw StateError('TCP transport is already active');
|
||||||
|
}
|
||||||
|
final trimmedHost = host.trim();
|
||||||
|
if (trimmedHost.isEmpty) {
|
||||||
|
throw ArgumentError.value(host, 'host', 'Host cannot be empty');
|
||||||
|
}
|
||||||
|
if (port < 1 || port > 65535) {
|
||||||
|
throw ArgumentError.value(port, 'port', 'Port must be in 1..65535');
|
||||||
|
}
|
||||||
|
|
||||||
|
_status = TcpTransportStatus.connecting;
|
||||||
|
final generation = ++_connectGeneration;
|
||||||
|
_frameDecoder.reset();
|
||||||
|
|
||||||
|
try {
|
||||||
|
final socket = await Socket.connect(trimmedHost, port, timeout: timeout);
|
||||||
|
if (generation != _connectGeneration ||
|
||||||
|
_status != TcpTransportStatus.connecting) {
|
||||||
|
try {
|
||||||
|
await socket.close();
|
||||||
|
} catch (_) {}
|
||||||
|
try {
|
||||||
|
socket.destroy();
|
||||||
|
} catch (_) {}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
socket.setOption(SocketOption.tcpNoDelay, true);
|
||||||
|
_socket = socket;
|
||||||
|
_activeHost = trimmedHost;
|
||||||
|
_activePort = port;
|
||||||
|
_socketSubscription = socket.listen(
|
||||||
|
_handleSocketData,
|
||||||
|
onError: _handleSocketError,
|
||||||
|
onDone: _handleSocketDone,
|
||||||
|
);
|
||||||
|
_status = TcpTransportStatus.connected;
|
||||||
|
_debugLogService?.info(
|
||||||
|
'TCP transport opened endpoint=$activeEndpoint',
|
||||||
|
tag: 'TCP',
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
await _cleanupFailedConnect();
|
||||||
|
_status = TcpTransportStatus.disconnected;
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> write(Uint8List data) async {
|
||||||
|
if (!isConnected || _socket == null) {
|
||||||
|
throw StateError('TCP transport is not connected');
|
||||||
|
}
|
||||||
|
|
||||||
|
final packet = wrapUsbSerialTxFrame(data);
|
||||||
|
_logFrameSummary('TCP TX frame', data);
|
||||||
|
|
||||||
|
final writeTask = _pendingWrite.then((_) async {
|
||||||
|
final socket = _socket;
|
||||||
|
if (!isConnected || socket == null) {
|
||||||
|
throw StateError('TCP transport is not connected');
|
||||||
|
}
|
||||||
|
socket.add(packet);
|
||||||
|
await socket.flush();
|
||||||
|
});
|
||||||
|
|
||||||
|
_pendingWrite = writeTask.catchError((_) {});
|
||||||
|
await writeTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> disconnect() async {
|
||||||
|
_connectGeneration += 1;
|
||||||
|
if (_status == TcpTransportStatus.disconnected) return;
|
||||||
|
|
||||||
|
final endpoint = activeEndpoint;
|
||||||
|
_status = TcpTransportStatus.disconnecting;
|
||||||
|
_frameDecoder.reset();
|
||||||
|
_activeHost = null;
|
||||||
|
_activePort = null;
|
||||||
|
|
||||||
|
final subscription = _socketSubscription;
|
||||||
|
_socketSubscription = null;
|
||||||
|
await subscription?.cancel();
|
||||||
|
|
||||||
|
final socket = _socket;
|
||||||
|
_socket = null;
|
||||||
|
try {
|
||||||
|
await socket?.close();
|
||||||
|
} catch (_) {}
|
||||||
|
try {
|
||||||
|
socket?.destroy();
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
_status = TcpTransportStatus.disconnected;
|
||||||
|
_debugLogService?.info(
|
||||||
|
'TCP transport closed endpoint=${endpoint ?? 'unknown'}',
|
||||||
|
tag: 'TCP',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void dispose() {
|
||||||
|
unawaited(disconnect().whenComplete(_closeFrameController));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _cleanupFailedConnect() async {
|
||||||
|
final subscription = _socketSubscription;
|
||||||
|
_socketSubscription = null;
|
||||||
|
await subscription?.cancel();
|
||||||
|
final socket = _socket;
|
||||||
|
_socket = null;
|
||||||
|
try {
|
||||||
|
await socket?.close();
|
||||||
|
} catch (_) {}
|
||||||
|
try {
|
||||||
|
socket?.destroy();
|
||||||
|
} catch (_) {}
|
||||||
|
_activeHost = null;
|
||||||
|
_activePort = null;
|
||||||
|
_frameDecoder.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleSocketData(Uint8List bytes) {
|
||||||
|
for (final packet in _frameDecoder.ingest(bytes)) {
|
||||||
|
if (!packet.isRxFrame) {
|
||||||
|
_debugLogService?.info(
|
||||||
|
'TCP ignored packet start=0x${packet.frameStart.toRadixString(16).padLeft(2, '0')} len=${packet.payload.length}',
|
||||||
|
tag: 'TCP',
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
_addFrame(packet.payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleSocketError(Object error, [StackTrace? stackTrace]) {
|
||||||
|
_addFrameError(error, stackTrace);
|
||||||
|
unawaited(disconnect());
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleSocketDone() {
|
||||||
|
if (_status == TcpTransportStatus.disconnecting ||
|
||||||
|
_status == TcpTransportStatus.disconnected) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_addFrameError(StateError('TCP socket closed by remote endpoint'));
|
||||||
|
unawaited(disconnect());
|
||||||
|
}
|
||||||
|
|
||||||
|
void _addFrame(Uint8List payload) {
|
||||||
|
if (_frameController.isClosed) return;
|
||||||
|
_frameController.add(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _addFrameError(Object error, [StackTrace? stackTrace]) {
|
||||||
|
if (_frameController.isClosed) return;
|
||||||
|
_frameController.addError(error, stackTrace);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _logFrameSummary(String prefix, Uint8List payload) {
|
||||||
|
final code = payload.isNotEmpty ? payload.first : -1;
|
||||||
|
_debugLogService?.info(
|
||||||
|
'$prefix code=$code len=${payload.length}',
|
||||||
|
tag: 'TCP',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _closeFrameController() async {
|
||||||
|
if (_frameController.isClosed) return;
|
||||||
|
await _frameController.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum TcpTransportStatus { disconnected, connecting, connected, disconnecting }
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'app_debug_log_service.dart';
|
||||||
|
|
||||||
|
class TcpTransportService {
|
||||||
|
AppDebugLogService? _debugLogService;
|
||||||
|
|
||||||
|
Stream<Uint8List> get frameStream => const Stream<Uint8List>.empty();
|
||||||
|
bool get isConnected => false;
|
||||||
|
String? get activeEndpoint => null;
|
||||||
|
|
||||||
|
void setDebugLogService(AppDebugLogService? service) {
|
||||||
|
_debugLogService = service;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> connect({
|
||||||
|
required String host,
|
||||||
|
required int port,
|
||||||
|
Duration timeout = const Duration(seconds: 10),
|
||||||
|
}) async {
|
||||||
|
_debugLogService?.warn(
|
||||||
|
'TCP transport requested on web for $host:$port',
|
||||||
|
tag: 'TCP',
|
||||||
|
);
|
||||||
|
throw UnsupportedError('TCP transport is not supported on web.');
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> write(Uint8List data) async {
|
||||||
|
throw UnsupportedError('TCP transport is not supported on web.');
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> disconnect() async {}
|
||||||
|
|
||||||
|
void dispose() {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
const int usbSerialTxFrameStart = 0x3c;
|
||||||
|
const int usbSerialRxFrameStart = 0x3e;
|
||||||
|
const int usbSerialHeaderLength = 3;
|
||||||
|
const int usbSerialMaxPayloadLength = 172;
|
||||||
|
|
||||||
|
Uint8List wrapUsbSerialTxFrame(Uint8List payload) {
|
||||||
|
if (payload.length > usbSerialMaxPayloadLength) {
|
||||||
|
throw ArgumentError.value(
|
||||||
|
payload.length,
|
||||||
|
'payload.length',
|
||||||
|
'USB serial payload exceeds $usbSerialMaxPayloadLength bytes',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final packet = Uint8List(usbSerialHeaderLength + payload.length);
|
||||||
|
packet[0] = usbSerialTxFrameStart;
|
||||||
|
packet[1] = payload.length & 0xff;
|
||||||
|
packet[2] = (payload.length >> 8) & 0xff;
|
||||||
|
packet.setRange(usbSerialHeaderLength, packet.length, payload);
|
||||||
|
return packet;
|
||||||
|
}
|
||||||
|
|
||||||
|
class UsbSerialDecodedPacket {
|
||||||
|
const UsbSerialDecodedPacket({
|
||||||
|
required this.frameStart,
|
||||||
|
required this.payload,
|
||||||
|
});
|
||||||
|
|
||||||
|
final int frameStart;
|
||||||
|
final Uint8List payload;
|
||||||
|
|
||||||
|
bool get isRxFrame => frameStart == usbSerialRxFrameStart;
|
||||||
|
}
|
||||||
|
|
||||||
|
class UsbSerialFrameDecoder {
|
||||||
|
final List<int> _rxBuffer = <int>[];
|
||||||
|
int _startIndex = 0;
|
||||||
|
|
||||||
|
void reset() {
|
||||||
|
_rxBuffer.clear();
|
||||||
|
_startIndex = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<UsbSerialDecodedPacket> ingest(Uint8List bytes) {
|
||||||
|
if (bytes.isEmpty) {
|
||||||
|
return const <UsbSerialDecodedPacket>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
_rxBuffer.addAll(bytes);
|
||||||
|
final packets = <UsbSerialDecodedPacket>[];
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
if (_startIndex >= _rxBuffer.length) {
|
||||||
|
_rxBuffer.clear();
|
||||||
|
_startIndex = 0;
|
||||||
|
return packets;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_rxBuffer[_startIndex] != usbSerialRxFrameStart &&
|
||||||
|
_rxBuffer[_startIndex] != usbSerialTxFrameStart) {
|
||||||
|
_startIndex++;
|
||||||
|
_compactBufferIfNeeded();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
final availableLength = _rxBuffer.length - _startIndex;
|
||||||
|
if (availableLength < usbSerialHeaderLength) {
|
||||||
|
_compactBufferIfNeeded(force: true);
|
||||||
|
return packets;
|
||||||
|
}
|
||||||
|
|
||||||
|
final payloadLength =
|
||||||
|
_rxBuffer[_startIndex + 1] | (_rxBuffer[_startIndex + 2] << 8);
|
||||||
|
if (payloadLength > usbSerialMaxPayloadLength) {
|
||||||
|
_startIndex++;
|
||||||
|
_compactBufferIfNeeded();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final packetLength = usbSerialHeaderLength + payloadLength;
|
||||||
|
if (availableLength < packetLength) {
|
||||||
|
_compactBufferIfNeeded(force: true);
|
||||||
|
return packets;
|
||||||
|
}
|
||||||
|
|
||||||
|
final frameStart = _rxBuffer[_startIndex];
|
||||||
|
final payload = Uint8List.fromList(
|
||||||
|
_rxBuffer.sublist(
|
||||||
|
_startIndex + usbSerialHeaderLength,
|
||||||
|
_startIndex + packetLength,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
_startIndex += packetLength;
|
||||||
|
_compactBufferIfNeeded();
|
||||||
|
packets.add(
|
||||||
|
UsbSerialDecodedPacket(frameStart: frameStart, payload: payload),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _compactBufferIfNeeded({bool force = false}) {
|
||||||
|
if (_startIndex == 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!force && _startIndex < 1024 && _startIndex < (_rxBuffer.length ~/ 2)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_rxBuffer.removeRange(0, _startIndex);
|
||||||
|
_startIndex = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export 'usb_serial_service_native.dart'
|
||||||
|
if (dart.library.js_interop) 'usb_serial_service_web.dart';
|
||||||
@@ -0,0 +1,463 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flserial/flserial.dart';
|
||||||
|
import 'package:flserial/flserial_exception.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
|
import 'app_debug_log_service.dart';
|
||||||
|
import '../utils/macos_usb_device_names.dart';
|
||||||
|
import '../utils/platform_info.dart';
|
||||||
|
import '../utils/usb_port_labels.dart';
|
||||||
|
import 'usb_serial_frame_codec.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',
|
||||||
|
);
|
||||||
|
final StreamController<Uint8List> _frameController =
|
||||||
|
StreamController<Uint8List>.broadcast();
|
||||||
|
final UsbSerialFrameDecoder _frameDecoder = UsbSerialFrameDecoder();
|
||||||
|
StreamSubscription<dynamic>? _androidDataSubscription;
|
||||||
|
StreamSubscription<FlSerialEventArgs>? _dataSubscription;
|
||||||
|
UsbSerialStatus _status = UsbSerialStatus.disconnected;
|
||||||
|
String? _connectedPortKey;
|
||||||
|
String? _connectedPortLabel;
|
||||||
|
FlSerial? _serial;
|
||||||
|
AppDebugLogService? _debugLogService;
|
||||||
|
|
||||||
|
UsbSerialStatus get status => _status;
|
||||||
|
String? get activePortKey => _connectedPortKey;
|
||||||
|
String? get activePortDisplayLabel =>
|
||||||
|
_connectedPortLabel ?? _connectedPortKey;
|
||||||
|
Stream<Uint8List> get frameStream => _frameController.stream;
|
||||||
|
bool get _useAndroidUsbHost =>
|
||||||
|
!kIsWeb && defaultTargetPlatform == TargetPlatform.android;
|
||||||
|
bool get _useDesktopFlSerial =>
|
||||||
|
PlatformInfo.isWindows || PlatformInfo.isLinux || PlatformInfo.isMacOS;
|
||||||
|
bool get _isSupportedPlatform => _useAndroidUsbHost || _useDesktopFlSerial;
|
||||||
|
// Always-fresh: do NOT use ??= here – a cached FlSerial retains stale
|
||||||
|
// native handle state (flh) from a prior failed open, causing subsequent
|
||||||
|
// open attempts to fail with "port not exist" even when the device is present.
|
||||||
|
FlSerial _freshSerial() => FlSerial();
|
||||||
|
|
||||||
|
bool get isConnected {
|
||||||
|
if (!_isSupportedPlatform) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Trust _status as the authoritative connection state. Polling
|
||||||
|
// _serial?.isOpen() via the native FL_CTRL_IS_PORT_OPEN query is
|
||||||
|
// unreliable during the brief USB re-enumeration window that many
|
||||||
|
// microcontrollers (e.g. NRF52) trigger in response to DTR assertion.
|
||||||
|
// Actual port drops are handled by the onDone / onError callbacks on the
|
||||||
|
// serial data stream subscription, which update _status correctly.
|
||||||
|
return _status == UsbSerialStatus.connected;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<String>> listPorts() async {
|
||||||
|
if (!_isSupportedPlatform) {
|
||||||
|
return const <String>[];
|
||||||
|
}
|
||||||
|
if (_useAndroidUsbHost) {
|
||||||
|
final ports = await _androidMethodChannel.invokeListMethod<String>(
|
||||||
|
'listPorts',
|
||||||
|
);
|
||||||
|
return ports ?? <String>[];
|
||||||
|
}
|
||||||
|
final rawPorts = FlSerial.listPorts();
|
||||||
|
// On macOS, flserial's native device-name lookup is broken on macOS
|
||||||
|
// 10.15+ because the IOKit class name changed from IOUSBDevice to
|
||||||
|
// IOUSBHostDevice. We resolve names ourselves via ioreg and rewrite any
|
||||||
|
// "port - n/a" entries with the real product name.
|
||||||
|
if (Platform.isMacOS && rawPorts.isNotEmpty) {
|
||||||
|
return _annotateMacOsPorts(rawPorts);
|
||||||
|
}
|
||||||
|
return Future.value(rawPorts);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rewrites the flserial port list on macOS by substituting real USB device
|
||||||
|
/// names (obtained via [ioreg]) for the "n/a" placeholders that flserial
|
||||||
|
/// returns when it can't find the deprecated IOUSBDevice parent.
|
||||||
|
Future<List<String>> _annotateMacOsPorts(List<String> rawPorts) async {
|
||||||
|
final deviceNames = await queryMacOsUsbDeviceNames();
|
||||||
|
if (deviceNames.isEmpty) return rawPorts;
|
||||||
|
return rawPorts.map((entry) {
|
||||||
|
// entry format from fl_ports: "port - description - hardware_id"
|
||||||
|
final port = normalizeUsbPortName(entry); // e.g. /dev/cu.usbmodem1101
|
||||||
|
final knownName = deviceNames[port]; // e.g. "Nordic NRF52 DK"
|
||||||
|
if (knownName == null) return entry; // non-USB port, keep as-is
|
||||||
|
// Replace description field only; preserve hardware_id for device
|
||||||
|
// identity (used by normalizeUsbPortName).
|
||||||
|
final segments = entry.split(' - ');
|
||||||
|
final hardwareId = segments.length >= 3 ? segments.last : 'n/a';
|
||||||
|
return '$port - $knownName - $hardwareId';
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
void setDebugLogService(AppDebugLogService? service) {
|
||||||
|
_debugLogService = service;
|
||||||
|
}
|
||||||
|
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
if (!_isSupportedPlatform) {
|
||||||
|
throw UnsupportedError('USB serial is not supported on this platform.');
|
||||||
|
}
|
||||||
|
|
||||||
|
_status = UsbSerialStatus.connecting;
|
||||||
|
var normalizedPortName = normalizeUsbPortName(portName);
|
||||||
|
_frameDecoder.reset();
|
||||||
|
|
||||||
|
if (_useAndroidUsbHost) {
|
||||||
|
try {
|
||||||
|
await _androidMethodChannel.invokeMethod<void>('connect', {
|
||||||
|
'portName': normalizedPortName,
|
||||||
|
'baudRate': baudRate,
|
||||||
|
});
|
||||||
|
_debugLogService?.info(
|
||||||
|
'USB serial opened port=$normalizedPortName on Android via USB host bridge',
|
||||||
|
tag: 'USB Serial',
|
||||||
|
);
|
||||||
|
} on PlatformException catch (error) {
|
||||||
|
_status = UsbSerialStatus.disconnected;
|
||||||
|
final msg = error.message ?? error.code;
|
||||||
|
_debugLogService?.error(
|
||||||
|
'Android connect failed: $msg',
|
||||||
|
tag: 'USB Serial',
|
||||||
|
);
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// ── Hot-restart guard ─────────────────────────────────────────────────
|
||||||
|
// On hot restart Dart tears down the isolate without calling dispose().
|
||||||
|
// The NativeCallable registered by flserial's setCallback() is
|
||||||
|
// isolate-local and gets freed when the isolate dies, but the native
|
||||||
|
// SerialThread is still alive and will call it → crash.
|
||||||
|
//
|
||||||
|
// flserial uses process-global native state. Calling fl_free() kills ALL
|
||||||
|
// SerialThreads for every open port across all Dart isolates (there is
|
||||||
|
// only one in a Flutter app). Then fl_init() re-initialises the slot
|
||||||
|
// table so subsequent fl_open() calls work normally.
|
||||||
|
//
|
||||||
|
// This must happen before we register any new NativeCallable, so it must
|
||||||
|
// be the very first thing we do in the desktop branch.
|
||||||
|
try {
|
||||||
|
bindings.fl_free();
|
||||||
|
bindings.fl_init(16);
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
// On macOS, flserial lists both cu.* and tty.* device nodes.
|
||||||
|
// When a cu.* open fails with FL_ERROR_PORT_NOT_EXIST, try the tty.*
|
||||||
|
// variant as a fallback (and vice-versa) before giving up.
|
||||||
|
final candidates = _buildPortCandidates(normalizedPortName);
|
||||||
|
FlSerialException? lastError;
|
||||||
|
bool opened = false;
|
||||||
|
|
||||||
|
for (final candidate in candidates) {
|
||||||
|
// Always create a fresh FlSerial instance — a cached instance retains
|
||||||
|
// a stale flh handle from prior failed opens, which causes the native
|
||||||
|
// fl_open() to mis-route the request and report port-not-exist even
|
||||||
|
// when the device node is physically present.
|
||||||
|
final serial = _freshSerial();
|
||||||
|
serial.init();
|
||||||
|
try {
|
||||||
|
final openStatus = serial.openPort(candidate, baudRate);
|
||||||
|
if (openStatus != FlOpenStatus.open) {
|
||||||
|
final msg =
|
||||||
|
'Failed to open USB port $candidate (status: $openStatus)';
|
||||||
|
_debugLogService?.error(msg, tag: 'USB Serial');
|
||||||
|
// Not a FlSerialException — treat as terminal failure
|
||||||
|
_status = UsbSerialStatus.disconnected;
|
||||||
|
throw StateError(msg);
|
||||||
|
}
|
||||||
|
serial.setByteSize8();
|
||||||
|
serial.setBitParityNone();
|
||||||
|
serial.setStopBits1();
|
||||||
|
serial.setFlowControlNone();
|
||||||
|
serial.setRTS(false);
|
||||||
|
serial.setDTR(true);
|
||||||
|
_serial = serial;
|
||||||
|
// Update the normalized port name to whichever candidate succeeded.
|
||||||
|
normalizedPortName = candidate;
|
||||||
|
_debugLogService?.info(
|
||||||
|
'USB serial opened port=$candidate cts=${serial.getCTS()} dsr=${serial.getDSR()} dtr=true rts=false',
|
||||||
|
tag: 'USB Serial',
|
||||||
|
);
|
||||||
|
opened = true;
|
||||||
|
break;
|
||||||
|
} on FlSerialException catch (error) {
|
||||||
|
// The native fl_open() already called fl_close() on failure
|
||||||
|
// internally, so no extra cleanup is needed here for this candidate.
|
||||||
|
_debugLogService?.warn(
|
||||||
|
'Failed to open $candidate: ${error.msg} (code ${error.error})',
|
||||||
|
tag: 'USB Serial',
|
||||||
|
);
|
||||||
|
lastError = error;
|
||||||
|
// Try next candidate
|
||||||
|
} catch (error, stackTrace) {
|
||||||
|
_status = UsbSerialStatus.disconnected;
|
||||||
|
_debugLogService?.error(
|
||||||
|
'Unexpected error opening $candidate: $error\n$stackTrace',
|
||||||
|
tag: 'USB Serial',
|
||||||
|
);
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!opened) {
|
||||||
|
_status = UsbSerialStatus.disconnected;
|
||||||
|
final primary = candidates.first;
|
||||||
|
final msg = lastError != null
|
||||||
|
? 'Failed to open USB port $primary: ${lastError.msg} (code ${lastError.error})'
|
||||||
|
: 'Failed to open USB port $primary';
|
||||||
|
_debugLogService?.error(msg, tag: 'USB Serial');
|
||||||
|
throw StateError(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_connectedPortKey = normalizedPortName;
|
||||||
|
_connectedPortLabel = 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 = wrapUsbSerialTxFrame(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;
|
||||||
|
|
||||||
|
final portLabel = _connectedPortLabel ?? _connectedPortKey;
|
||||||
|
_debugLogService?.info(
|
||||||
|
'USB disconnect starting port=${portLabel ?? 'unknown'}',
|
||||||
|
tag: 'USB Serial',
|
||||||
|
);
|
||||||
|
_status = UsbSerialStatus.disconnecting;
|
||||||
|
_connectedPortKey = null;
|
||||||
|
_connectedPortLabel = null;
|
||||||
|
_frameDecoder.reset();
|
||||||
|
|
||||||
|
if (_useAndroidUsbHost) {
|
||||||
|
await _androidDataSubscription?.cancel();
|
||||||
|
_androidDataSubscription = null;
|
||||||
|
try {
|
||||||
|
await _androidMethodChannel.invokeMethod<void>('disconnect');
|
||||||
|
} catch (_) {
|
||||||
|
// Ignore errors while closing.
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// IMPORTANT: Close and free the native port FIRST, before cancelling the
|
||||||
|
// Dart subscription. The native SerialThread is blocked on a read(); once
|
||||||
|
// closePort() is called it unblocks and the thread exits. If we cancel
|
||||||
|
// the Dart subscription first (freeing the FFI callback pointer) and the
|
||||||
|
// thread fires one final callback before noticing the port is gone, Dart
|
||||||
|
// crashes with "Callback invoked after it has been deleted".
|
||||||
|
final serial = _serial;
|
||||||
|
_serial = null;
|
||||||
|
try {
|
||||||
|
if (serial?.isOpen() == FlOpenStatus.open) {
|
||||||
|
serial?.closePort();
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// Ignore errors while closing.
|
||||||
|
}
|
||||||
|
// Note: we do NOT call free() here; that would globally reset native
|
||||||
|
// state for all ports. The global reset is done in connect() instead,
|
||||||
|
// before the next open, which is the safer place to do it.
|
||||||
|
|
||||||
|
// Now it is safe to cancel the Dart subscription — the native thread has
|
||||||
|
// already seen the port close and will not fire any more callbacks.
|
||||||
|
await _dataSubscription?.cancel();
|
||||||
|
_dataSubscription = null;
|
||||||
|
}
|
||||||
|
_status = UsbSerialStatus.disconnected;
|
||||||
|
_debugLogService?.info(
|
||||||
|
'USB disconnect complete port=${portLabel ?? 'unknown'}',
|
||||||
|
tag: 'USB Serial',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void setRequestPortLabel(String label) {
|
||||||
|
// Native implementations do not use a synthetic chooser row.
|
||||||
|
}
|
||||||
|
|
||||||
|
void setFallbackDeviceName(String label) {
|
||||||
|
// Native implementations use OS-provided device names.
|
||||||
|
}
|
||||||
|
|
||||||
|
void updateConnectedLabel(String label) {
|
||||||
|
final trimmed = label.trim();
|
||||||
|
if (trimmed.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_connectedPortLabel = buildUsbDisplayLabel(
|
||||||
|
basePortLabel: _connectedPortKey ?? trimmed,
|
||||||
|
deviceName: trimmed,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void dispose() {
|
||||||
|
// Synchronously close the native port so the SerialThread exits before
|
||||||
|
// the Dart isolate is torn down (e.g. on hot restart). The async
|
||||||
|
// disconnect() path via unawaited() offers no ordering guarantee — the
|
||||||
|
// isolate may die before the Future resolves, leaving the thread alive
|
||||||
|
// with a dangling NativeCallable pointer.
|
||||||
|
if (_useDesktopFlSerial) {
|
||||||
|
final serial = _serial;
|
||||||
|
try {
|
||||||
|
if (serial?.isOpen() == FlOpenStatus.open) {
|
||||||
|
serial?.closePort(); // synchronous C call — kills the SerialThread
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
// Kick off the full async teardown for anything else (subscription cancel,
|
||||||
|
// stream controller close). These are best-effort at dispose time.
|
||||||
|
unawaited(disconnect().whenComplete(_closeFrameController));
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleSerialData(FlSerialEventArgs event) {
|
||||||
|
try {
|
||||||
|
final bytes = event.serial.readList();
|
||||||
|
if (bytes.isNotEmpty) {
|
||||||
|
_ingestRawBytes(Uint8List.fromList(bytes));
|
||||||
|
}
|
||||||
|
} catch (error, stack) {
|
||||||
|
_addFrameError(error, stack);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleAndroidData(dynamic data) {
|
||||||
|
if (data is Uint8List) {
|
||||||
|
_ingestRawBytes(data);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (data is ByteData) {
|
||||||
|
_ingestRawBytes(data.buffer.asUint8List());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_addFrameError(
|
||||||
|
StateError('Unexpected Android USB event payload: ${data.runtimeType}'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleSerialError(Object error, [StackTrace? stackTrace]) {
|
||||||
|
_addFrameError(error, stackTrace);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleSerialDone() {
|
||||||
|
unawaited(disconnect());
|
||||||
|
}
|
||||||
|
|
||||||
|
void _ingestRawBytes(Uint8List bytes) {
|
||||||
|
for (final packet in _frameDecoder.ingest(bytes)) {
|
||||||
|
if (!packet.isRxFrame) {
|
||||||
|
_debugLogService?.info(
|
||||||
|
'USB ignored packet start=0x${packet.frameStart.toRadixString(16).padLeft(2, '0')} len=${packet.payload.length}',
|
||||||
|
tag: 'USB Serial',
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
_addFrame(packet.payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _addFrame(Uint8List payload) {
|
||||||
|
if (_frameController.isClosed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_frameController.add(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _addFrameError(Object error, [StackTrace? stackTrace]) {
|
||||||
|
if (_frameController.isClosed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_frameController.addError(error, stackTrace);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _closeFrameController() async {
|
||||||
|
if (_frameController.isClosed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await _frameController.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _logFrameSummary(String prefix, Uint8List bytes) {
|
||||||
|
if (bytes.isEmpty) {
|
||||||
|
_debugLogService?.info('$prefix len=0', tag: 'USB Serial');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_debugLogService?.info(
|
||||||
|
'$prefix code=${bytes[0]} len=${bytes.length}',
|
||||||
|
tag: 'USB Serial',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns an ordered list of port paths to try for [portName].
|
||||||
|
///
|
||||||
|
/// On macOS, USB serial devices appear as both `/dev/cu.*` (call-out, the
|
||||||
|
/// correct mode for outgoing serial connections) and `/dev/tty.*` (dial-in).
|
||||||
|
/// `flserial` may list one variant while only the other is actually openable
|
||||||
|
/// at a given moment. We prefer `cu.*` but automatically include the `tty.*`
|
||||||
|
/// sibling as a fallback, and vice-versa.
|
||||||
|
List<String> _buildPortCandidates(String normalizedPort) {
|
||||||
|
if (!Platform.isMacOS) return [normalizedPort];
|
||||||
|
const cuPrefix = '/dev/cu.';
|
||||||
|
const ttyPrefix = '/dev/tty.';
|
||||||
|
if (normalizedPort.startsWith(cuPrefix)) {
|
||||||
|
final suffix = normalizedPort.substring(cuPrefix.length);
|
||||||
|
return [normalizedPort, '$ttyPrefix$suffix'];
|
||||||
|
}
|
||||||
|
if (normalizedPort.startsWith(ttyPrefix)) {
|
||||||
|
final suffix = normalizedPort.substring(ttyPrefix.length);
|
||||||
|
return [normalizedPort, '$cuPrefix$suffix'];
|
||||||
|
}
|
||||||
|
return [normalizedPort];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum UsbSerialStatus { disconnected, connecting, connected, disconnecting }
|
||||||
@@ -0,0 +1,580 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:js_interop';
|
||||||
|
import 'dart:js_interop_unsafe';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:web/web.dart' as web;
|
||||||
|
|
||||||
|
import 'app_debug_log_service.dart';
|
||||||
|
import '../utils/usb_port_labels.dart';
|
||||||
|
import 'usb_serial_frame_codec.dart';
|
||||||
|
|
||||||
|
class UsbSerialService {
|
||||||
|
UsbSerialService();
|
||||||
|
|
||||||
|
static const Map<String, String> _knownUsbNames = <String, String>{
|
||||||
|
'2886:1667': 'Seeed Wio Tracker L1',
|
||||||
|
};
|
||||||
|
static final Map<String, String> _deviceNamesByPortKey = <String, String>{};
|
||||||
|
static final Map<String, String> _baseLabelsByPortKey = <String, String>{};
|
||||||
|
static final Map<String, JSObject> _authorizedPortsByKey =
|
||||||
|
<String, JSObject>{};
|
||||||
|
static int _nextAuthorizedPortId = 1;
|
||||||
|
|
||||||
|
final StreamController<Uint8List> _frameController =
|
||||||
|
StreamController<Uint8List>.broadcast();
|
||||||
|
final UsbSerialFrameDecoder _frameDecoder = UsbSerialFrameDecoder();
|
||||||
|
|
||||||
|
UsbSerialStatus _status = UsbSerialStatus.disconnected;
|
||||||
|
JSObject? _port;
|
||||||
|
JSObject? _reader;
|
||||||
|
JSObject? _writer;
|
||||||
|
String? _connectedPortName;
|
||||||
|
String? _connectedPortKey;
|
||||||
|
String _requestPortLabel = 'Choose USB Device';
|
||||||
|
String _fallbackDeviceName = 'Web Serial Device';
|
||||||
|
AppDebugLogService? _debugLogService;
|
||||||
|
|
||||||
|
UsbSerialStatus get status => _status;
|
||||||
|
String? get activePortKey => _connectedPortKey;
|
||||||
|
String? get activePortDisplayLabel => _connectedPortName ?? _connectedPortKey;
|
||||||
|
Stream<Uint8List> get frameStream => _frameController.stream;
|
||||||
|
bool get isConnected => _status == UsbSerialStatus.connected;
|
||||||
|
|
||||||
|
JSObject get _navigator => JSObject.fromInteropObject(web.window.navigator);
|
||||||
|
bool get _isSupported => _navigator.has('serial');
|
||||||
|
JSObject? get _serial {
|
||||||
|
if (!_isSupported) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final serial = _navigator['serial'];
|
||||||
|
return serial == null ? null : serial as JSObject;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<String>> listPorts() async {
|
||||||
|
if (!_isSupported) {
|
||||||
|
return const <String>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
_resetPortCache();
|
||||||
|
final ports = await _getAuthorizedPorts();
|
||||||
|
return <String>[_requestPortListEntry, ...ports.map(_listEntryForPort)];
|
||||||
|
}
|
||||||
|
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
if (!_isSupported) {
|
||||||
|
throw UnsupportedError('Web Serial is not supported by this browser.');
|
||||||
|
}
|
||||||
|
|
||||||
|
_status = UsbSerialStatus.connecting;
|
||||||
|
_frameDecoder.reset();
|
||||||
|
|
||||||
|
try {
|
||||||
|
final requestedPortName = normalizeUsbPortName(portName);
|
||||||
|
_debugLogService?.info(
|
||||||
|
'Web connect: requested=$requestedPortName baud=$baudRate',
|
||||||
|
tag: 'USB Serial',
|
||||||
|
);
|
||||||
|
final selectedPortKey = requestedPortName.startsWith('web:port:')
|
||||||
|
? requestedPortName
|
||||||
|
: null;
|
||||||
|
_port = _authorizedPortsByKey[requestedPortName];
|
||||||
|
final authorizedPorts = await _getAuthorizedPorts();
|
||||||
|
_debugLogService?.info(
|
||||||
|
'Web connect: ${authorizedPorts.length} authorized port(s), cached=${_port != null}',
|
||||||
|
tag: 'USB Serial',
|
||||||
|
);
|
||||||
|
_port ??= _selectPort(authorizedPorts, requestedPortName);
|
||||||
|
|
||||||
|
_port ??= await _requestPort();
|
||||||
|
if (_port == null) {
|
||||||
|
throw StateError('No USB serial device selected');
|
||||||
|
}
|
||||||
|
|
||||||
|
_debugLogService?.info(
|
||||||
|
'Web connect: opening port at $baudRate baud…',
|
||||||
|
tag: 'USB Serial',
|
||||||
|
);
|
||||||
|
await _openPort(_port!, baudRate);
|
||||||
|
_connectedPortKey = _cachePort(_port!, preferredKey: selectedPortKey);
|
||||||
|
_connectedPortName = _displayLabelForPort(
|
||||||
|
_port!,
|
||||||
|
portKey: _connectedPortKey,
|
||||||
|
);
|
||||||
|
_writer = _getWriter(_port!);
|
||||||
|
_reader = _getReader(_port!);
|
||||||
|
_status = UsbSerialStatus.connected;
|
||||||
|
unawaited(_pumpReads());
|
||||||
|
|
||||||
|
_debugLogService?.info(
|
||||||
|
'USB serial opened port=$_connectedPortName via Web Serial',
|
||||||
|
tag: 'USB Serial',
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
_debugLogService?.error('Web connect failed: $error', tag: 'USB Serial');
|
||||||
|
await _cleanupFailedConnect();
|
||||||
|
_status = UsbSerialStatus.disconnected;
|
||||||
|
_connectedPortName = null;
|
||||||
|
_connectedPortKey = null;
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> write(Uint8List data) async {
|
||||||
|
if (!isConnected || _writer == null) {
|
||||||
|
throw StateError('USB serial port is not open');
|
||||||
|
}
|
||||||
|
|
||||||
|
final packet = wrapUsbSerialTxFrame(data);
|
||||||
|
_logFrameSummary('USB TX frame', data);
|
||||||
|
|
||||||
|
final promise = _writer!.callMethod<JSPromise<JSAny?>>(
|
||||||
|
'write'.toJS,
|
||||||
|
packet.toJS,
|
||||||
|
);
|
||||||
|
await promise.toDart;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> disconnect() async {
|
||||||
|
if (_status == UsbSerialStatus.disconnected) return;
|
||||||
|
|
||||||
|
final portLabel = _connectedPortName ?? _connectedPortKey;
|
||||||
|
_debugLogService?.info(
|
||||||
|
'USB disconnect starting port=${portLabel ?? 'unknown'}',
|
||||||
|
tag: 'USB Serial',
|
||||||
|
);
|
||||||
|
_status = UsbSerialStatus.disconnecting;
|
||||||
|
final reader = _reader;
|
||||||
|
final writer = _writer;
|
||||||
|
final port = _port;
|
||||||
|
|
||||||
|
_reader = null;
|
||||||
|
_writer = null;
|
||||||
|
_port = null;
|
||||||
|
_connectedPortName = null;
|
||||||
|
_connectedPortKey = null;
|
||||||
|
_frameDecoder.reset();
|
||||||
|
|
||||||
|
if (reader != null) {
|
||||||
|
try {
|
||||||
|
await reader.callMethod<JSPromise<JSAny?>>('cancel'.toJS).toDart;
|
||||||
|
} catch (_) {
|
||||||
|
// Ignore errors while closing.
|
||||||
|
}
|
||||||
|
_releaseLock(reader);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (writer != null) {
|
||||||
|
_releaseLock(writer);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (port != null) {
|
||||||
|
try {
|
||||||
|
await port.callMethod<JSPromise<JSAny?>>('close'.toJS).toDart;
|
||||||
|
} catch (_) {
|
||||||
|
// Ignore errors while closing.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_status = UsbSerialStatus.disconnected;
|
||||||
|
_debugLogService?.info(
|
||||||
|
'USB disconnect complete port=${portLabel ?? 'unknown'}',
|
||||||
|
tag: 'USB Serial',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void updateConnectedLabel(String label) {
|
||||||
|
final trimmed = label.trim();
|
||||||
|
final portKey = _connectedPortKey;
|
||||||
|
if (trimmed.isEmpty || portKey == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_deviceNamesByPortKey[portKey] = trimmed;
|
||||||
|
_connectedPortName = _buildDisplayLabel(portKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
void setRequestPortLabel(String label) {
|
||||||
|
final trimmed = label.trim();
|
||||||
|
if (trimmed.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_requestPortLabel = trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
void setFallbackDeviceName(String label) {
|
||||||
|
final trimmed = label.trim();
|
||||||
|
if (trimmed.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_fallbackDeviceName = trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
void setDebugLogService(AppDebugLogService? service) {
|
||||||
|
_debugLogService = service;
|
||||||
|
}
|
||||||
|
|
||||||
|
void dispose() {
|
||||||
|
unawaited(disconnect().whenComplete(_closeFrameController));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<JSObject>> _getAuthorizedPorts() async {
|
||||||
|
final serial = _serial;
|
||||||
|
if (serial == null) {
|
||||||
|
return const <JSObject>[];
|
||||||
|
}
|
||||||
|
final result = await serial
|
||||||
|
.callMethod<JSPromise<JSAny?>>('getPorts'.toJS)
|
||||||
|
.toDart;
|
||||||
|
return _toObjectList(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<JSObject?> _requestPort() async {
|
||||||
|
final serial = _serial;
|
||||||
|
if (serial == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final result = await serial
|
||||||
|
.callMethod<JSPromise<JSAny?>>('requestPort'.toJS)
|
||||||
|
.toDart;
|
||||||
|
return result == null ? null : result as JSObject;
|
||||||
|
}
|
||||||
|
|
||||||
|
JSObject? _selectPort(List<JSObject> ports, String requestedPortName) {
|
||||||
|
if (ports.isEmpty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (requestedPortName.isEmpty || requestedPortName == _requestPortKey) {
|
||||||
|
return ports.first;
|
||||||
|
}
|
||||||
|
if (requestedPortName.startsWith('web:port:')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
for (final port in ports) {
|
||||||
|
final description = _describePort(port);
|
||||||
|
if (description == requestedPortName) {
|
||||||
|
return port;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _openPort(JSObject port, int baudRate) async {
|
||||||
|
final options = JSObject()
|
||||||
|
..['baudRate'] = baudRate.toJS
|
||||||
|
..['flowControl'] = 'none'.toJS;
|
||||||
|
await port.callMethod<JSPromise<JSAny?>>('open'.toJS, options).toDart;
|
||||||
|
|
||||||
|
// Prevent ESP32 USB-CDC reset: hold DTR=true, RTS=false after open.
|
||||||
|
try {
|
||||||
|
final signals = JSObject()
|
||||||
|
..['dataTerminalReady'] = true.toJS
|
||||||
|
..['requestToSend'] = false.toJS;
|
||||||
|
await port
|
||||||
|
.callMethod<JSPromise<JSAny?>>('setSignals'.toJS, signals)
|
||||||
|
.toDart;
|
||||||
|
} catch (_) {
|
||||||
|
// setSignals may not be supported on all browsers/devices.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _cleanupFailedConnect() async {
|
||||||
|
final reader = _reader;
|
||||||
|
final writer = _writer;
|
||||||
|
final port = _port;
|
||||||
|
|
||||||
|
_reader = null;
|
||||||
|
_writer = null;
|
||||||
|
_port = null;
|
||||||
|
|
||||||
|
if (reader != null) {
|
||||||
|
try {
|
||||||
|
await reader.callMethod<JSPromise<JSAny?>>('cancel'.toJS).toDart;
|
||||||
|
} catch (_) {
|
||||||
|
// Ignore cleanup errors after a failed connect.
|
||||||
|
}
|
||||||
|
_releaseLock(reader);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (writer != null) {
|
||||||
|
_releaseLock(writer);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (port != null) {
|
||||||
|
try {
|
||||||
|
await port.callMethod<JSPromise<JSAny?>>('close'.toJS).toDart;
|
||||||
|
} catch (_) {
|
||||||
|
// Ignore cleanup errors after a failed connect.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
JSObject? _getReader(JSObject port) {
|
||||||
|
final readable = port.getProperty<JSAny?>('readable'.toJS);
|
||||||
|
if (readable == null) {
|
||||||
|
throw StateError('Web Serial port is not readable');
|
||||||
|
}
|
||||||
|
final readableObject = readable as JSObject;
|
||||||
|
return readableObject.callMethod<JSAny?>('getReader'.toJS) as JSObject;
|
||||||
|
}
|
||||||
|
|
||||||
|
JSObject? _getWriter(JSObject port) {
|
||||||
|
final writable = port.getProperty<JSAny?>('writable'.toJS);
|
||||||
|
if (writable == null) {
|
||||||
|
throw StateError('Web Serial port is not writable');
|
||||||
|
}
|
||||||
|
final writableObject = writable as JSObject;
|
||||||
|
return writableObject.callMethod<JSAny?>('getWriter'.toJS) as JSObject;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pumpReads() async {
|
||||||
|
final reader = _reader;
|
||||||
|
if (reader == null) {
|
||||||
|
_debugLogService?.warn('_pumpReads: reader is null', tag: 'USB Serial');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_debugLogService?.info('_pumpReads: started', tag: 'USB Serial');
|
||||||
|
try {
|
||||||
|
while (_status == UsbSerialStatus.connected &&
|
||||||
|
identical(reader, _reader)) {
|
||||||
|
final result = await reader
|
||||||
|
.callMethod<JSPromise<JSAny?>>('read'.toJS)
|
||||||
|
.toDart;
|
||||||
|
if (result == null) {
|
||||||
|
_debugLogService?.warn('_pumpReads: null result', tag: 'USB Serial');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
final resultObject = result as JSObject;
|
||||||
|
|
||||||
|
final doneValue = resultObject.getProperty<JSAny?>('done'.toJS);
|
||||||
|
final done = doneValue != null && doneValue.dartify() == true;
|
||||||
|
if (done) {
|
||||||
|
_debugLogService?.info('_pumpReads: done=true', tag: 'USB Serial');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
final value = resultObject.getProperty<JSAny?>('value'.toJS);
|
||||||
|
final bytes = _coerceBytes(value);
|
||||||
|
if (bytes != null && bytes.isNotEmpty) {
|
||||||
|
_debugLogService?.info(
|
||||||
|
'USB RX raw: ${bytes.length} byte(s)',
|
||||||
|
tag: 'USB Serial',
|
||||||
|
);
|
||||||
|
_ingestRawBytes(bytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error, stackTrace) {
|
||||||
|
_debugLogService?.error('_pumpReads error: $error', tag: 'USB Serial');
|
||||||
|
if (_status == UsbSerialStatus.connected) {
|
||||||
|
_addFrameError(error, stackTrace);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
_debugLogService?.info('_pumpReads: ended', tag: 'USB Serial');
|
||||||
|
_releaseLock(reader);
|
||||||
|
if (_status == UsbSerialStatus.connected && identical(reader, _reader)) {
|
||||||
|
_addFrameError(StateError('USB serial connection closed'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Uint8List? _coerceBytes(JSAny? value) {
|
||||||
|
if (value == null) return null;
|
||||||
|
try {
|
||||||
|
return (value as JSUint8Array).toDart;
|
||||||
|
} catch (_) {
|
||||||
|
// Fall back to array-like coercion below.
|
||||||
|
}
|
||||||
|
|
||||||
|
final object = value as JSObject;
|
||||||
|
if (object.has('length')) {
|
||||||
|
final lengthValue = object.getProperty<JSAny?>('length'.toJS)?.dartify();
|
||||||
|
if (lengthValue is num) {
|
||||||
|
final length = lengthValue.toInt();
|
||||||
|
final bytes = Uint8List(length);
|
||||||
|
for (var i = 0; i < length; i++) {
|
||||||
|
final item = object.getProperty<JSAny?>(i.toString().toJS)?.dartify();
|
||||||
|
if (item is num) {
|
||||||
|
bytes[i] = item.toInt();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<JSObject> _toObjectList(JSAny? value) {
|
||||||
|
if (value == null) {
|
||||||
|
return const <JSObject>[];
|
||||||
|
}
|
||||||
|
final object = value as JSObject;
|
||||||
|
if (!object.has('length')) {
|
||||||
|
return const <JSObject>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
final lengthValue = object.getProperty<JSAny?>('length'.toJS)?.dartify();
|
||||||
|
if (lengthValue is! num) {
|
||||||
|
return const <JSObject>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
final length = lengthValue.toInt();
|
||||||
|
final items = <JSObject>[];
|
||||||
|
for (var i = 0; i < length; i++) {
|
||||||
|
final item = object.getProperty<JSAny?>(i.toString().toJS);
|
||||||
|
if (item != null) {
|
||||||
|
items.add(item as JSObject);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
String _describePort(JSObject port) {
|
||||||
|
final info = _portInfo(port);
|
||||||
|
if (info == null) {
|
||||||
|
return _requestPortLabel;
|
||||||
|
}
|
||||||
|
|
||||||
|
final vendorId = info.usbVendorId;
|
||||||
|
final productId = info.usbProductId;
|
||||||
|
final hasVendor = vendorId != null;
|
||||||
|
final hasProduct = productId != null;
|
||||||
|
|
||||||
|
return describeWebUsbPort(
|
||||||
|
vendorId: hasVendor ? vendorId : null,
|
||||||
|
productId: hasProduct ? productId : null,
|
||||||
|
requestPortLabel: _requestPortLabel,
|
||||||
|
fallbackDeviceName: _fallbackDeviceName,
|
||||||
|
knownUsbNames: _knownUsbNames,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
_WebPortInfo? _portInfo(JSObject port) {
|
||||||
|
try {
|
||||||
|
final info = port.callMethod<JSAny?>('getInfo'.toJS);
|
||||||
|
if (info == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final infoObject = info as JSObject;
|
||||||
|
|
||||||
|
final vendorId = infoObject
|
||||||
|
.getProperty<JSAny?>('usbVendorId'.toJS)
|
||||||
|
?.dartify();
|
||||||
|
final productId = infoObject
|
||||||
|
.getProperty<JSAny?>('usbProductId'.toJS)
|
||||||
|
?.dartify();
|
||||||
|
return _WebPortInfo(
|
||||||
|
usbVendorId: vendorId is num ? vendorId.toInt() : null,
|
||||||
|
usbProductId: productId is num ? productId.toInt() : null,
|
||||||
|
);
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _portKeyFor(JSObject port) {
|
||||||
|
return _cachePort(port);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _cachePort(JSObject port, {String? preferredKey}) {
|
||||||
|
final portKey = preferredKey ?? 'web:port:${_nextAuthorizedPortId++}';
|
||||||
|
_baseLabelsByPortKey[portKey] = _describePort(port);
|
||||||
|
_authorizedPortsByKey[portKey] = port;
|
||||||
|
return portKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
String _displayLabelForPort(JSObject port, {String? portKey}) =>
|
||||||
|
_buildDisplayLabel(portKey ?? _portKeyFor(port));
|
||||||
|
|
||||||
|
String _buildDisplayLabel(String portKey) {
|
||||||
|
return buildUsbDisplayLabel(
|
||||||
|
basePortLabel: _baseLabelsByPortKey[portKey] ?? portKey,
|
||||||
|
deviceName: _deviceNamesByPortKey[portKey],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _listEntryForPort(JSObject port) {
|
||||||
|
final portKey = _portKeyFor(port);
|
||||||
|
return '$portKey - ${_displayLabelForPort(port, portKey: portKey)}';
|
||||||
|
}
|
||||||
|
|
||||||
|
String get _requestPortKey => 'web:request';
|
||||||
|
|
||||||
|
String get _requestPortListEntry => '$_requestPortKey - $_requestPortLabel';
|
||||||
|
|
||||||
|
void _resetPortCache() {
|
||||||
|
_authorizedPortsByKey.clear();
|
||||||
|
_baseLabelsByPortKey.clear();
|
||||||
|
_deviceNamesByPortKey.clear();
|
||||||
|
_nextAuthorizedPortId = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _releaseLock(JSObject resource) {
|
||||||
|
try {
|
||||||
|
resource.callMethod<JSAny?>('releaseLock'.toJS);
|
||||||
|
} catch (_) {
|
||||||
|
// Ignore lock release failures.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _ingestRawBytes(Uint8List bytes) {
|
||||||
|
for (final packet in _frameDecoder.ingest(bytes)) {
|
||||||
|
if (!packet.isRxFrame) {
|
||||||
|
_debugLogService?.info(
|
||||||
|
'USB ignored packet start=0x${packet.frameStart.toRadixString(16).padLeft(2, '0')} len=${packet.payload.length}',
|
||||||
|
tag: 'USB Serial',
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
_addFrame(packet.payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _addFrame(Uint8List payload) {
|
||||||
|
if (_frameController.isClosed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_frameController.add(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _addFrameError(Object error, [StackTrace? stackTrace]) {
|
||||||
|
if (_frameController.isClosed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_frameController.addError(error, stackTrace);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _closeFrameController() async {
|
||||||
|
if (_frameController.isClosed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await _frameController.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _logFrameSummary(String prefix, Uint8List bytes) {
|
||||||
|
if (bytes.isEmpty) {
|
||||||
|
_debugLogService?.info('$prefix len=0', tag: 'USB Serial');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_debugLogService?.info(
|
||||||
|
'$prefix code=${bytes[0]} len=${bytes.length}',
|
||||||
|
tag: 'USB Serial',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum UsbSerialStatus { disconnected, connecting, connected, disconnecting }
|
||||||
|
|
||||||
|
final class _WebPortInfo {
|
||||||
|
const _WebPortInfo({required this.usbVendorId, required this.usbProductId});
|
||||||
|
|
||||||
|
final int? usbVendorId;
|
||||||
|
final int? usbProductId;
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
|
import 'package:meshcore_open/utils/app_logger.dart';
|
||||||
|
|
||||||
import '../models/channel_message.dart';
|
import '../models/channel_message.dart';
|
||||||
import '../helpers/smaz.dart';
|
import '../helpers/smaz.dart';
|
||||||
import 'prefs_manager.dart';
|
import 'prefs_manager.dart';
|
||||||
@@ -7,13 +9,25 @@ import 'prefs_manager.dart';
|
|||||||
class ChannelMessageStore {
|
class ChannelMessageStore {
|
||||||
static const String _keyPrefix = 'channel_messages_';
|
static const String _keyPrefix = 'channel_messages_';
|
||||||
|
|
||||||
|
String publicKeyHex = '';
|
||||||
|
set setPublicKeyHex(String value) =>
|
||||||
|
publicKeyHex = value.length > 10 ? value.substring(0, 10) : '';
|
||||||
|
|
||||||
|
String get keyFor => '$_keyPrefix$publicKeyHex';
|
||||||
|
|
||||||
/// Save messages for a specific channel
|
/// Save messages for a specific channel
|
||||||
Future<void> saveChannelMessages(
|
Future<void> saveChannelMessages(
|
||||||
int channelIndex,
|
int channelIndex,
|
||||||
List<ChannelMessage> messages,
|
List<ChannelMessage> messages,
|
||||||
) async {
|
) async {
|
||||||
|
if (publicKeyHex.isEmpty) {
|
||||||
|
appLogger.warn(
|
||||||
|
'Public key hex is not set. Cannot save channel messages.',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
final prefs = PrefsManager.instance;
|
final prefs = PrefsManager.instance;
|
||||||
final key = '$_keyPrefix$channelIndex';
|
final key = '$keyFor$channelIndex';
|
||||||
|
|
||||||
// Convert messages to JSON
|
// Convert messages to JSON
|
||||||
final jsonList = messages.map((msg) => _messageToJson(msg)).toList();
|
final jsonList = messages.map((msg) => _messageToJson(msg)).toList();
|
||||||
@@ -24,12 +38,35 @@ class ChannelMessageStore {
|
|||||||
|
|
||||||
/// Load messages for a specific channel
|
/// Load messages for a specific channel
|
||||||
Future<List<ChannelMessage>> loadChannelMessages(int channelIndex) async {
|
Future<List<ChannelMessage>> loadChannelMessages(int channelIndex) async {
|
||||||
|
if (publicKeyHex.isEmpty) {
|
||||||
|
appLogger.warn(
|
||||||
|
'Public key hex is not set. Cannot load channel messages.',
|
||||||
|
);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
final prefs = PrefsManager.instance;
|
final prefs = PrefsManager.instance;
|
||||||
final key = '$_keyPrefix$channelIndex';
|
final key = '$keyFor$channelIndex';
|
||||||
|
final oldKey = '$_keyPrefix$channelIndex';
|
||||||
final jsonString = prefs.getString(key);
|
|
||||||
if (jsonString == null) return [];
|
|
||||||
|
|
||||||
|
String? jsonString = prefs.getString(key);
|
||||||
|
if (jsonString == null || jsonString.isEmpty) {
|
||||||
|
// Attempt migration from legacy unscoped key on first load
|
||||||
|
final legacyJsonString = prefs.getString(oldKey);
|
||||||
|
prefs.remove(oldKey);
|
||||||
|
if (legacyJsonString != null && legacyJsonString.isNotEmpty) {
|
||||||
|
appLogger.info(
|
||||||
|
'Migrating channel messages from legacy key $oldKey to scoped key $key',
|
||||||
|
);
|
||||||
|
await prefs.setString(key, legacyJsonString);
|
||||||
|
jsonString = legacyJsonString;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (jsonString == null || jsonString.isEmpty) {
|
||||||
|
jsonString = prefs.getString(keyFor);
|
||||||
|
}
|
||||||
|
if (jsonString == null || jsonString.isEmpty) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
final jsonList = jsonDecode(jsonString) as List<dynamic>;
|
final jsonList = jsonDecode(jsonString) as List<dynamic>;
|
||||||
return jsonList.map((json) => _messageFromJson(json)).toList();
|
return jsonList.map((json) => _messageFromJson(json)).toList();
|
||||||
@@ -42,14 +79,14 @@ class ChannelMessageStore {
|
|||||||
/// Clear messages for a specific channel
|
/// Clear messages for a specific channel
|
||||||
Future<void> clearChannelMessages(int channelIndex) async {
|
Future<void> clearChannelMessages(int channelIndex) async {
|
||||||
final prefs = PrefsManager.instance;
|
final prefs = PrefsManager.instance;
|
||||||
final key = '$_keyPrefix$channelIndex';
|
final key = '$keyFor$channelIndex';
|
||||||
await prefs.remove(key);
|
await prefs.remove(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Clear all channel messages
|
/// Clear all channel messages
|
||||||
Future<void> clearAllChannelMessages() async {
|
Future<void> clearAllChannelMessages() async {
|
||||||
final prefs = PrefsManager.instance;
|
final prefs = PrefsManager.instance;
|
||||||
final keys = prefs.getKeys().where((k) => k.startsWith(_keyPrefix));
|
final keys = prefs.getKeys().where((k) => k.startsWith(keyFor));
|
||||||
for (var key in keys) {
|
for (var key in keys) {
|
||||||
await prefs.remove(key);
|
await prefs.remove(key);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,49 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
import '../utils/app_logger.dart';
|
||||||
import 'prefs_manager.dart';
|
import 'prefs_manager.dart';
|
||||||
|
|
||||||
class ChannelOrderStore {
|
class ChannelOrderStore {
|
||||||
static const String _key = 'channel_order';
|
static const String _keyPrefix = 'channel_order_';
|
||||||
|
|
||||||
|
String publicKeyHex = '';
|
||||||
|
set setPublicKeyHex(String value) =>
|
||||||
|
publicKeyHex = value.length > 10 ? value.substring(0, 10) : '';
|
||||||
|
|
||||||
|
String get keyFor => '$_keyPrefix$publicKeyHex';
|
||||||
|
|
||||||
Future<void> saveChannelOrder(List<int> order) async {
|
Future<void> saveChannelOrder(List<int> order) async {
|
||||||
|
if (publicKeyHex.isEmpty) {
|
||||||
|
appLogger.warn('Public key hex is not set. Cannot save channel order.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
final prefs = PrefsManager.instance;
|
final prefs = PrefsManager.instance;
|
||||||
await prefs.setString(_key, jsonEncode(order));
|
await prefs.setString(keyFor, jsonEncode(order));
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<List<int>> loadChannelOrder() async {
|
Future<List<int>> loadChannelOrder() async {
|
||||||
|
if (publicKeyHex.isEmpty) {
|
||||||
|
appLogger.warn('Public key hex is not set. Cannot load channel order.');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
final prefs = PrefsManager.instance;
|
final prefs = PrefsManager.instance;
|
||||||
final raw = prefs.getString(_key);
|
String? jsonString = prefs.getString(keyFor);
|
||||||
if (raw == null || raw.isEmpty) return [];
|
if (jsonString == null || jsonString.isEmpty) {
|
||||||
|
// Attempt migration from legacy unscoped key on first load
|
||||||
|
final legacyJsonString = prefs.getString(_keyPrefix);
|
||||||
|
prefs.remove(_keyPrefix);
|
||||||
|
if (legacyJsonString != null && legacyJsonString.isNotEmpty) {
|
||||||
|
appLogger.info(
|
||||||
|
'Migrating channel order from legacy key $_keyPrefix to scoped key $keyFor',
|
||||||
|
);
|
||||||
|
await prefs.setString(keyFor, legacyJsonString);
|
||||||
|
jsonString = legacyJsonString;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (jsonString == null || jsonString.isEmpty) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
final decoded = jsonDecode(raw);
|
final decoded = jsonDecode(jsonString);
|
||||||
if (decoded is List) {
|
if (decoded is List) {
|
||||||
return decoded
|
return decoded
|
||||||
.map((value) => value is int ? value : int.tryParse('$value'))
|
.map((value) => value is int ? value : int.tryParse('$value'))
|
||||||
@@ -24,7 +53,7 @@ class ChannelOrderStore {
|
|||||||
} catch (_) {
|
} catch (_) {
|
||||||
// fall through to legacy parse
|
// fall through to legacy parse
|
||||||
}
|
}
|
||||||
return raw
|
return jsonString
|
||||||
.split(',')
|
.split(',')
|
||||||
.map((value) => int.tryParse(value))
|
.map((value) => int.tryParse(value))
|
||||||
.whereType<int>()
|
.whereType<int>()
|
||||||
|
|||||||
@@ -1,17 +1,49 @@
|
|||||||
|
import '../utils/app_logger.dart';
|
||||||
import 'prefs_manager.dart';
|
import 'prefs_manager.dart';
|
||||||
|
|
||||||
class ChannelSettingsStore {
|
class ChannelSettingsStore {
|
||||||
static const String _smazKeyPrefix = 'channel_smaz_';
|
static const String _keyPrefix = 'channel_smaz_';
|
||||||
|
|
||||||
|
String publicKeyHex = '';
|
||||||
|
set setPublicKeyHex(String value) =>
|
||||||
|
publicKeyHex = value.length > 10 ? value.substring(0, 10) : '';
|
||||||
|
|
||||||
|
String get keyFor => '$_keyPrefix$publicKeyHex';
|
||||||
|
|
||||||
Future<bool> loadSmazEnabled(int channelIndex) async {
|
Future<bool> loadSmazEnabled(int channelIndex) async {
|
||||||
|
if (publicKeyHex.isEmpty) {
|
||||||
|
appLogger.warn(
|
||||||
|
'Public key hex is not set. Cannot load channel settings.',
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
final prefs = PrefsManager.instance;
|
final prefs = PrefsManager.instance;
|
||||||
final key = '$_smazKeyPrefix$channelIndex';
|
final key = '$keyFor$channelIndex';
|
||||||
return prefs.getBool(key) ?? false;
|
final oldKey = '$_keyPrefix$channelIndex';
|
||||||
|
bool? enabled = prefs.getBool(oldKey);
|
||||||
|
if (enabled == null) {
|
||||||
|
// Attempt migration from legacy unscoped key on first load
|
||||||
|
enabled = prefs.getBool(oldKey);
|
||||||
|
prefs.remove(oldKey);
|
||||||
|
if (enabled != null) {
|
||||||
|
appLogger.info(
|
||||||
|
'Migrating channel settings from legacy key $oldKey to scoped key $key',
|
||||||
|
);
|
||||||
|
await prefs.setBool(key, enabled);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return enabled ?? false;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> saveSmazEnabled(int channelIndex, bool enabled) async {
|
Future<void> saveSmazEnabled(int channelIndex, bool enabled) async {
|
||||||
|
if (publicKeyHex.isEmpty) {
|
||||||
|
appLogger.warn(
|
||||||
|
'Public key hex is not set. Cannot save channel settings.',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
final prefs = PrefsManager.instance;
|
final prefs = PrefsManager.instance;
|
||||||
final key = '$_smazKeyPrefix$channelIndex';
|
final key = '$keyFor$channelIndex';
|
||||||
await prefs.setBool(key, enabled);
|
await prefs.setBool(key, enabled);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,18 +2,46 @@ import 'dart:convert';
|
|||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
|
|
||||||
import '../models/channel.dart';
|
import '../models/channel.dart';
|
||||||
|
import '../utils/app_logger.dart';
|
||||||
import 'prefs_manager.dart';
|
import 'prefs_manager.dart';
|
||||||
|
|
||||||
class ChannelStore {
|
class ChannelStore {
|
||||||
static const String _key = 'channels';
|
static const String _keyPrefix = 'channels';
|
||||||
|
String publicKeyHex = '';
|
||||||
|
set setPublicKeyHex(String value) =>
|
||||||
|
publicKeyHex = value.length >= 10 ? value.substring(0, 10) : '';
|
||||||
|
|
||||||
|
String get keyFor => '$_keyPrefix$publicKeyHex';
|
||||||
|
|
||||||
Future<List<Channel>> loadChannels() async {
|
Future<List<Channel>> loadChannels() async {
|
||||||
|
if (publicKeyHex.isEmpty) {
|
||||||
|
appLogger.warn('Public key hex is not set. Cannot load channels.');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
final prefs = PrefsManager.instance;
|
final prefs = PrefsManager.instance;
|
||||||
final jsonStr = prefs.getString(_key);
|
String? jsonString = prefs.getString(keyFor);
|
||||||
if (jsonStr == null) return [];
|
if (jsonString == null || jsonString.isEmpty) {
|
||||||
|
// Attempt migration from legacy unscoped key on first load
|
||||||
|
final legacyJsonString = prefs.getString(_keyPrefix);
|
||||||
|
prefs.remove(_keyPrefix);
|
||||||
|
if (legacyJsonString != null && legacyJsonString.isNotEmpty) {
|
||||||
|
appLogger.info(
|
||||||
|
'Migrating channel messages from legacy key $_keyPrefix to scoped key $keyFor',
|
||||||
|
);
|
||||||
|
await prefs.setString(keyFor, legacyJsonString);
|
||||||
|
jsonString = legacyJsonString;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (jsonString == null || jsonString.isEmpty) {
|
||||||
|
jsonString = prefs.getString(keyFor);
|
||||||
|
}
|
||||||
|
if (jsonString == null || jsonString.isEmpty) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final jsonList = jsonDecode(jsonStr) as List<dynamic>;
|
final jsonList = jsonDecode(jsonString) as List<dynamic>;
|
||||||
return jsonList
|
return jsonList
|
||||||
.map((entry) => _fromJson(entry as Map<String, dynamic>))
|
.map((entry) => _fromJson(entry as Map<String, dynamic>))
|
||||||
.toList();
|
.toList();
|
||||||
@@ -23,9 +51,13 @@ class ChannelStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> saveChannels(List<Channel> channels) async {
|
Future<void> saveChannels(List<Channel> channels) async {
|
||||||
|
if (publicKeyHex.isEmpty) {
|
||||||
|
appLogger.warn('Public key hex is not set. Cannot save channels.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
final prefs = PrefsManager.instance;
|
final prefs = PrefsManager.instance;
|
||||||
final jsonList = channels.map(_toJson).toList();
|
final jsonList = channels.map(_toJson).toList();
|
||||||
await prefs.setString(_key, jsonEncode(jsonList));
|
await prefs.setString(keyFor, jsonEncode(jsonList));
|
||||||
}
|
}
|
||||||
|
|
||||||
Map<String, dynamic> _toJson(Channel channel) {
|
Map<String, dynamic> _toJson(Channel channel) {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
|
||||||
import '../models/community.dart';
|
import '../models/community.dart';
|
||||||
|
import '../utils/app_logger.dart';
|
||||||
import 'prefs_manager.dart';
|
import 'prefs_manager.dart';
|
||||||
|
|
||||||
/// Persists communities to local storage using SharedPreferences.
|
/// Persists communities to local storage using SharedPreferences.
|
||||||
@@ -9,12 +10,37 @@ import 'prefs_manager.dart';
|
|||||||
/// Each community contains its secret K, so this data should
|
/// Each community contains its secret K, so this data should
|
||||||
/// be considered sensitive (though device encryption handles security).
|
/// be considered sensitive (though device encryption handles security).
|
||||||
class CommunityStore {
|
class CommunityStore {
|
||||||
static const String _communitiesKey = 'communities_v1';
|
static const String _keyPrefix = 'communities_v1';
|
||||||
|
|
||||||
|
String publicKeyHex = '';
|
||||||
|
set setPublicKeyHex(String value) =>
|
||||||
|
publicKeyHex = value.length > 10 ? value.substring(0, 10) : '';
|
||||||
|
|
||||||
|
String get keyFor => '$_keyPrefix$publicKeyHex';
|
||||||
|
|
||||||
/// Load all communities from storage
|
/// Load all communities from storage
|
||||||
Future<List<Community>> loadCommunities() async {
|
Future<List<Community>> loadCommunities() async {
|
||||||
|
if (publicKeyHex.isEmpty) {
|
||||||
|
appLogger.warn('Public key hex is not set. Cannot load communities.');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
final prefs = PrefsManager.instance;
|
final prefs = PrefsManager.instance;
|
||||||
final jsonString = prefs.getString(_communitiesKey);
|
String? jsonString = prefs.getString(keyFor);
|
||||||
|
if (jsonString == null || jsonString.isEmpty) {
|
||||||
|
// Attempt migration from legacy unscoped key on first load
|
||||||
|
final legacyJsonString = prefs.getString(_keyPrefix);
|
||||||
|
prefs.remove(_keyPrefix);
|
||||||
|
if (legacyJsonString != null && legacyJsonString.isNotEmpty) {
|
||||||
|
appLogger.info(
|
||||||
|
'Migrating communities from legacy key $_keyPrefix to scoped key $keyFor',
|
||||||
|
);
|
||||||
|
await prefs.setString(keyFor, legacyJsonString);
|
||||||
|
jsonString = legacyJsonString;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (jsonString == null || jsonString.isEmpty) {
|
||||||
|
jsonString = prefs.getString(keyFor);
|
||||||
|
}
|
||||||
if (jsonString == null || jsonString.isEmpty) {
|
if (jsonString == null || jsonString.isEmpty) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@@ -32,9 +58,13 @@ class CommunityStore {
|
|||||||
|
|
||||||
/// Save all communities to storage
|
/// Save all communities to storage
|
||||||
Future<void> saveCommunities(List<Community> communities) async {
|
Future<void> saveCommunities(List<Community> communities) async {
|
||||||
|
if (publicKeyHex.isEmpty) {
|
||||||
|
appLogger.warn('Public key hex is not set. Cannot save communities.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
final prefs = PrefsManager.instance;
|
final prefs = PrefsManager.instance;
|
||||||
final jsonList = communities.map((c) => c.toJson()).toList();
|
final jsonList = communities.map((c) => c.toJson()).toList();
|
||||||
await prefs.setString(_communitiesKey, jsonEncode(jsonList));
|
await prefs.setString(keyFor, jsonEncode(jsonList));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Add a new community
|
/// Add a new community
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
|
|
||||||
import '../models/discovery_contact.dart';
|
import '../models/contact.dart';
|
||||||
import 'prefs_manager.dart';
|
import 'prefs_manager.dart';
|
||||||
|
|
||||||
class ContactDiscoveryStore {
|
class ContactDiscoveryStore {
|
||||||
static const String _key = 'discovered_contacts';
|
static const String _keyPrefix = 'discovered_contacts';
|
||||||
|
|
||||||
Future<List<DiscoveryContact>> loadContacts() async {
|
Future<List<Contact>> loadContacts() async {
|
||||||
final prefs = PrefsManager.instance;
|
final prefs = PrefsManager.instance;
|
||||||
final jsonStr = prefs.getString(_key);
|
final jsonStr = prefs.getString(_keyPrefix);
|
||||||
if (jsonStr == null) return [];
|
if (jsonStr == null) return [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -22,40 +22,62 @@ class ContactDiscoveryStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> saveContacts(List<DiscoveryContact> contacts) async {
|
Future<void> saveContacts(List<Contact> contacts) async {
|
||||||
final prefs = PrefsManager.instance;
|
final prefs = PrefsManager.instance;
|
||||||
final jsonList = contacts.map(_toJson).toList();
|
final jsonList = contacts.map(_toJson).toList();
|
||||||
await prefs.setString(_key, jsonEncode(jsonList));
|
await prefs.setString(_keyPrefix, jsonEncode(jsonList));
|
||||||
}
|
}
|
||||||
|
|
||||||
Map<String, dynamic> _toJson(DiscoveryContact contact) {
|
Map<String, dynamic> _toJson(Contact contact) {
|
||||||
return {
|
return {
|
||||||
'rawPacket': base64Encode(contact.rawPacket),
|
|
||||||
'publicKey': base64Encode(contact.publicKey),
|
'publicKey': base64Encode(contact.publicKey),
|
||||||
'name': contact.name,
|
'name': contact.name,
|
||||||
'type': contact.type,
|
'type': contact.type,
|
||||||
|
'flags': contact.flags,
|
||||||
'pathLength': contact.pathLength,
|
'pathLength': contact.pathLength,
|
||||||
'path': base64Encode(contact.path),
|
'path': base64Encode(contact.path),
|
||||||
|
'pathOverride': contact.pathOverride,
|
||||||
|
'pathOverrideBytes': contact.pathOverrideBytes != null
|
||||||
|
? base64Encode(contact.pathOverrideBytes!)
|
||||||
|
: null,
|
||||||
'latitude': contact.latitude,
|
'latitude': contact.latitude,
|
||||||
'longitude': contact.longitude,
|
'longitude': contact.longitude,
|
||||||
'lastSeen': contact.lastSeen.millisecondsSinceEpoch,
|
'lastSeen': contact.lastSeen.millisecondsSinceEpoch,
|
||||||
|
'lastMessageAt': contact.lastMessageAt.millisecondsSinceEpoch,
|
||||||
|
'rawPacket': contact.rawPacket != null
|
||||||
|
? base64Encode(contact.rawPacket!)
|
||||||
|
: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
DiscoveryContact _fromJson(Map<String, dynamic> json) {
|
Contact _fromJson(Map<String, dynamic> json) {
|
||||||
final lastSeenMs = json['lastSeen'] as int? ?? 0;
|
final lastSeenMs = json['lastSeen'] as int? ?? 0;
|
||||||
return DiscoveryContact(
|
final lastMessageMs = json['lastMessageAt'] as int?;
|
||||||
rawPacket: Uint8List.fromList(base64Decode(json['rawPacket'] as String)),
|
return Contact(
|
||||||
publicKey: Uint8List.fromList(base64Decode(json['publicKey'] as String)),
|
publicKey: Uint8List.fromList(base64Decode(json['publicKey'] as String)),
|
||||||
name: json['name'] as String? ?? 'Unknown',
|
name: json['name'] as String? ?? 'Unknown',
|
||||||
type: json['type'] as int? ?? 0,
|
type: json['type'] as int? ?? 0,
|
||||||
|
flags: json['flags'] as int? ?? 0,
|
||||||
pathLength: json['pathLength'] as int? ?? -1,
|
pathLength: json['pathLength'] as int? ?? -1,
|
||||||
path: json['path'] != null
|
path: json['path'] != null
|
||||||
? Uint8List.fromList(base64Decode(json['path'] as String))
|
? Uint8List.fromList(base64Decode(json['path'] as String))
|
||||||
: Uint8List(0),
|
: Uint8List(0),
|
||||||
|
pathOverride: json['pathOverride'] as int?,
|
||||||
|
pathOverrideBytes: json['pathOverrideBytes'] != null
|
||||||
|
? Uint8List.fromList(
|
||||||
|
base64Decode(json['pathOverrideBytes'] as String),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
latitude: (json['latitude'] as num?)?.toDouble(),
|
latitude: (json['latitude'] as num?)?.toDouble(),
|
||||||
longitude: (json['longitude'] as num?)?.toDouble(),
|
longitude: (json['longitude'] as num?)?.toDouble(),
|
||||||
lastSeen: DateTime.fromMillisecondsSinceEpoch(lastSeenMs),
|
lastSeen: DateTime.fromMillisecondsSinceEpoch(lastSeenMs),
|
||||||
|
lastMessageAt: DateTime.fromMillisecondsSinceEpoch(
|
||||||
|
lastMessageMs ?? lastSeenMs,
|
||||||
|
),
|
||||||
|
isActive: false,
|
||||||
|
rawPacket: json['rawPacket'] != null
|
||||||
|
? Uint8List.fromList(base64Decode(json['rawPacket'] as String))
|
||||||
|
: null,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,45 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import '../models/contact_group.dart';
|
import '../models/contact_group.dart';
|
||||||
|
import '../utils/app_logger.dart';
|
||||||
import 'prefs_manager.dart';
|
import 'prefs_manager.dart';
|
||||||
|
|
||||||
class ContactGroupStore {
|
class ContactGroupStore {
|
||||||
static const String _key = 'contact_groups';
|
static const String _keyPrefix = 'contact_groups';
|
||||||
|
|
||||||
|
String publicKeyHex = '';
|
||||||
|
set setPublicKeyHex(String value) =>
|
||||||
|
publicKeyHex = value.length > 10 ? value.substring(0, 10) : '';
|
||||||
|
|
||||||
|
String get keyFor => '$_keyPrefix$publicKeyHex';
|
||||||
|
|
||||||
Future<List<ContactGroup>> loadGroups() async {
|
Future<List<ContactGroup>> loadGroups() async {
|
||||||
|
if (publicKeyHex.isEmpty) {
|
||||||
|
appLogger.warn('Public key hex is not set. Cannot load contact groups.');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
final prefs = PrefsManager.instance;
|
final prefs = PrefsManager.instance;
|
||||||
final raw = prefs.getString(_key);
|
String? jsonString = prefs.getString(keyFor);
|
||||||
if (raw == null || raw.isEmpty) return [];
|
if (jsonString == null || jsonString.isEmpty) {
|
||||||
|
// Attempt migration from legacy unscoped key on first load
|
||||||
|
final legacyJsonString = prefs.getString(_keyPrefix);
|
||||||
|
prefs.remove(_keyPrefix);
|
||||||
|
if (legacyJsonString != null && legacyJsonString.isNotEmpty) {
|
||||||
|
appLogger.info(
|
||||||
|
'Migrating channel messages from legacy key $_keyPrefix to scoped key $keyFor',
|
||||||
|
);
|
||||||
|
await prefs.setString(keyFor, legacyJsonString);
|
||||||
|
jsonString = legacyJsonString;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (jsonString == null || jsonString.isEmpty) {
|
||||||
|
jsonString = prefs.getString(keyFor);
|
||||||
|
}
|
||||||
|
if (jsonString == null || jsonString.isEmpty) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final decoded = jsonDecode(raw);
|
final decoded = jsonDecode(jsonString);
|
||||||
if (decoded is List) {
|
if (decoded is List) {
|
||||||
return decoded
|
return decoded
|
||||||
.whereType<Map<String, dynamic>>()
|
.whereType<Map<String, dynamic>>()
|
||||||
@@ -25,8 +53,12 @@ class ContactGroupStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> saveGroups(List<ContactGroup> groups) async {
|
Future<void> saveGroups(List<ContactGroup> groups) async {
|
||||||
|
if (publicKeyHex.isEmpty) {
|
||||||
|
appLogger.warn('Public key hex is not set. Cannot save contact groups.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
final prefs = PrefsManager.instance;
|
final prefs = PrefsManager.instance;
|
||||||
final encoded = jsonEncode(groups.map((group) => group.toJson()).toList());
|
final encoded = jsonEncode(groups.map((group) => group.toJson()).toList());
|
||||||
await prefs.setString(_key, encoded);
|
await prefs.setString(keyFor, encoded);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,49 @@
|
|||||||
|
import '../utils/app_logger.dart';
|
||||||
import 'prefs_manager.dart';
|
import 'prefs_manager.dart';
|
||||||
|
|
||||||
class ContactSettingsStore {
|
class ContactSettingsStore {
|
||||||
static const String _smazKeyPrefix = 'contact_smaz_';
|
static const String _keyPrefix = 'contact_smaz_';
|
||||||
|
|
||||||
|
String publicKeyHex = '';
|
||||||
|
set setPublicKeyHex(String value) =>
|
||||||
|
publicKeyHex = value.length > 10 ? value.substring(0, 10) : '';
|
||||||
|
|
||||||
|
String get keyFor => '$_keyPrefix$publicKeyHex';
|
||||||
|
|
||||||
Future<bool> loadSmazEnabled(String contactKeyHex) async {
|
Future<bool> loadSmazEnabled(String contactKeyHex) async {
|
||||||
|
if (publicKeyHex.isEmpty) {
|
||||||
|
appLogger.warn(
|
||||||
|
'Public key hex is not set. Cannot load contact settings.',
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
final prefs = PrefsManager.instance;
|
final prefs = PrefsManager.instance;
|
||||||
final key = '$_smazKeyPrefix$contactKeyHex';
|
final key = '$keyFor$contactKeyHex';
|
||||||
|
final oldKey = '$_keyPrefix$contactKeyHex';
|
||||||
|
bool? enabled = prefs.getBool(key);
|
||||||
|
if (enabled == null) {
|
||||||
|
// Attempt migration from legacy unscoped key on first load
|
||||||
|
enabled = prefs.getBool(oldKey);
|
||||||
|
prefs.remove(oldKey);
|
||||||
|
if (enabled != null) {
|
||||||
|
appLogger.info(
|
||||||
|
'Migrating contact settings from legacy key $oldKey to scoped key $key',
|
||||||
|
);
|
||||||
|
await prefs.setBool(key, enabled);
|
||||||
|
}
|
||||||
|
}
|
||||||
return prefs.getBool(key) ?? false;
|
return prefs.getBool(key) ?? false;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> saveSmazEnabled(String contactKeyHex, bool enabled) async {
|
Future<void> saveSmazEnabled(String contactKeyHex, bool enabled) async {
|
||||||
|
if (publicKeyHex.isEmpty) {
|
||||||
|
appLogger.warn(
|
||||||
|
'Public key hex is not set. Cannot save contact settings.',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
final prefs = PrefsManager.instance;
|
final prefs = PrefsManager.instance;
|
||||||
final key = '$_smazKeyPrefix$contactKeyHex';
|
final key = '$keyFor$contactKeyHex';
|
||||||
await prefs.setBool(key, enabled);
|
await prefs.setBool(key, enabled);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,18 +2,46 @@ import 'dart:convert';
|
|||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
|
|
||||||
import '../models/contact.dart';
|
import '../models/contact.dart';
|
||||||
|
import '../utils/app_logger.dart';
|
||||||
import 'prefs_manager.dart';
|
import 'prefs_manager.dart';
|
||||||
|
|
||||||
class ContactStore {
|
class ContactStore {
|
||||||
static const String _key = 'contacts';
|
static const String _keyPrefix = 'contacts';
|
||||||
|
|
||||||
|
String publicKeyHex = '';
|
||||||
|
set setPublicKeyHex(String value) =>
|
||||||
|
publicKeyHex = value.length > 10 ? value.substring(0, 10) : '';
|
||||||
|
|
||||||
|
String get keyFor => '$_keyPrefix$publicKeyHex';
|
||||||
|
|
||||||
Future<List<Contact>> loadContacts() async {
|
Future<List<Contact>> loadContacts() async {
|
||||||
|
if (publicKeyHex.isEmpty) {
|
||||||
|
appLogger.warn('Public key hex is not set. Cannot load contacts.');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
final prefs = PrefsManager.instance;
|
final prefs = PrefsManager.instance;
|
||||||
final jsonStr = prefs.getString(_key);
|
String? jsonString = prefs.getString(keyFor);
|
||||||
if (jsonStr == null) return [];
|
if (jsonString == null || jsonString.isEmpty) {
|
||||||
|
// Attempt migration from legacy unscoped key on first load
|
||||||
|
final legacyJsonString = prefs.getString(_keyPrefix);
|
||||||
|
prefs.remove(_keyPrefix);
|
||||||
|
if (legacyJsonString != null && legacyJsonString.isNotEmpty) {
|
||||||
|
appLogger.info(
|
||||||
|
'Migrating contacts from legacy key $_keyPrefix to scoped key $keyFor',
|
||||||
|
);
|
||||||
|
await prefs.setString(keyFor, legacyJsonString);
|
||||||
|
jsonString = legacyJsonString;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (jsonString == null || jsonString.isEmpty) {
|
||||||
|
jsonString = prefs.getString(keyFor);
|
||||||
|
}
|
||||||
|
if (jsonString == null || jsonString.isEmpty) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final jsonList = jsonDecode(jsonStr) as List<dynamic>;
|
final jsonList = jsonDecode(jsonString) as List<dynamic>;
|
||||||
return jsonList
|
return jsonList
|
||||||
.map((entry) => _fromJson(entry as Map<String, dynamic>))
|
.map((entry) => _fromJson(entry as Map<String, dynamic>))
|
||||||
.toList();
|
.toList();
|
||||||
@@ -23,9 +51,13 @@ class ContactStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> saveContacts(List<Contact> contacts) async {
|
Future<void> saveContacts(List<Contact> contacts) async {
|
||||||
|
if (publicKeyHex.isEmpty) {
|
||||||
|
appLogger.warn('Public key hex is not set. Cannot save contacts.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
final prefs = PrefsManager.instance;
|
final prefs = PrefsManager.instance;
|
||||||
final jsonList = contacts.map(_toJson).toList();
|
final jsonList = contacts.map(_toJson).toList();
|
||||||
await prefs.setString(_key, jsonEncode(jsonList));
|
await prefs.setString(keyFor, jsonEncode(jsonList));
|
||||||
}
|
}
|
||||||
|
|
||||||
Map<String, dynamic> _toJson(Contact contact) {
|
Map<String, dynamic> _toJson(Contact contact) {
|
||||||
@@ -44,6 +76,10 @@ class ContactStore {
|
|||||||
'longitude': contact.longitude,
|
'longitude': contact.longitude,
|
||||||
'lastSeen': contact.lastSeen.millisecondsSinceEpoch,
|
'lastSeen': contact.lastSeen.millisecondsSinceEpoch,
|
||||||
'lastMessageAt': contact.lastMessageAt.millisecondsSinceEpoch,
|
'lastMessageAt': contact.lastMessageAt.millisecondsSinceEpoch,
|
||||||
|
'isActive': contact.isActive,
|
||||||
|
'rawPacket': contact.rawPacket != null
|
||||||
|
? base64Encode(contact.rawPacket!)
|
||||||
|
: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,6 +107,10 @@ class ContactStore {
|
|||||||
lastMessageAt: DateTime.fromMillisecondsSinceEpoch(
|
lastMessageAt: DateTime.fromMillisecondsSinceEpoch(
|
||||||
lastMessageMs ?? lastSeenMs,
|
lastMessageMs ?? lastSeenMs,
|
||||||
),
|
),
|
||||||
|
isActive: json['isActive'] as bool? ?? true,
|
||||||
|
rawPacket: json['rawPacket'] != null
|
||||||
|
? Uint8List.fromList(base64Decode(json['rawPacket'] as String))
|
||||||
|
: null,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,26 +2,59 @@ import 'dart:convert';
|
|||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
import '../models/message.dart';
|
import '../models/message.dart';
|
||||||
import '../helpers/smaz.dart';
|
import '../helpers/smaz.dart';
|
||||||
|
import '../utils/app_logger.dart';
|
||||||
import 'prefs_manager.dart';
|
import 'prefs_manager.dart';
|
||||||
|
|
||||||
class MessageStore {
|
class MessageStore {
|
||||||
static const String _keyPrefix = 'messages_';
|
static const String _keyPrefix = 'messages_';
|
||||||
|
|
||||||
|
String publicKeyHex = '';
|
||||||
|
set setPublicKeyHex(String value) =>
|
||||||
|
publicKeyHex = value.length > 10 ? value.substring(0, 10) : '';
|
||||||
|
|
||||||
|
String get keyFor => '$_keyPrefix$publicKeyHex';
|
||||||
|
|
||||||
Future<void> saveMessages(
|
Future<void> saveMessages(
|
||||||
String contactKeyHex,
|
String contactKeyHex,
|
||||||
List<Message> messages,
|
List<Message> messages,
|
||||||
) async {
|
) async {
|
||||||
|
if (publicKeyHex.isEmpty) {
|
||||||
|
appLogger.warn('Public key hex is not set. Cannot save messages.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
final prefs = PrefsManager.instance;
|
final prefs = PrefsManager.instance;
|
||||||
final key = '$_keyPrefix$contactKeyHex';
|
final key = '$keyFor$contactKeyHex';
|
||||||
final jsonList = messages.map(_messageToJson).toList();
|
final jsonList = messages.map(_messageToJson).toList();
|
||||||
await prefs.setString(key, jsonEncode(jsonList));
|
await prefs.setString(key, jsonEncode(jsonList));
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<List<Message>> loadMessages(String contactKeyHex) async {
|
Future<List<Message>> loadMessages(String contactKeyHex) async {
|
||||||
|
if (publicKeyHex.isEmpty) {
|
||||||
|
appLogger.warn('Public key hex is not set. Cannot load messages.');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
final prefs = PrefsManager.instance;
|
final prefs = PrefsManager.instance;
|
||||||
final key = '$_keyPrefix$contactKeyHex';
|
final key = '$keyFor$contactKeyHex';
|
||||||
final jsonString = prefs.getString(key);
|
final oldKey = '$_keyPrefix$contactKeyHex';
|
||||||
if (jsonString == null) return [];
|
String? jsonString = prefs.getString(key);
|
||||||
|
if (jsonString == null || jsonString.isEmpty) {
|
||||||
|
// Attempt migration from legacy unscoped key on first load
|
||||||
|
final legacyJsonString = prefs.getString(oldKey);
|
||||||
|
prefs.remove(oldKey);
|
||||||
|
if (legacyJsonString != null && legacyJsonString.isNotEmpty) {
|
||||||
|
appLogger.info(
|
||||||
|
'Migrating messages from legacy key $oldKey to scoped key $key',
|
||||||
|
);
|
||||||
|
await prefs.setString(key, legacyJsonString);
|
||||||
|
jsonString = legacyJsonString;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (jsonString == null || jsonString.isEmpty) {
|
||||||
|
jsonString = prefs.getString(keyFor);
|
||||||
|
}
|
||||||
|
if (jsonString == null || jsonString.isEmpty) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final jsonList = jsonDecode(jsonString) as List<dynamic>;
|
final jsonList = jsonDecode(jsonString) as List<dynamic>;
|
||||||
@@ -32,8 +65,12 @@ class MessageStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> clearMessages(String contactKeyHex) async {
|
Future<void> clearMessages(String contactKeyHex) async {
|
||||||
|
if (publicKeyHex.isEmpty) {
|
||||||
|
appLogger.warn('Public key hex is not set. Cannot clear messages.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
final prefs = PrefsManager.instance;
|
final prefs = PrefsManager.instance;
|
||||||
final key = '$_keyPrefix$contactKeyHex';
|
final key = '$keyFor$contactKeyHex';
|
||||||
await prefs.remove(key);
|
await prefs.remove(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,18 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import '../utils/app_logger.dart';
|
||||||
import 'prefs_manager.dart';
|
import 'prefs_manager.dart';
|
||||||
|
|
||||||
/// Storage for unread message tracking with debounced writes to reduce I/O.
|
/// Storage for unread message tracking with debounced writes to reduce I/O.
|
||||||
class UnreadStore {
|
class UnreadStore {
|
||||||
static const String _contactUnreadCountKey = 'contact_unread_count';
|
static const String _keyPrefix = 'contact_unread_count';
|
||||||
|
|
||||||
|
String publicKeyHex = '';
|
||||||
|
set setPublicKeyHex(String value) =>
|
||||||
|
publicKeyHex = value.length >= 10 ? value.substring(0, 10) : '';
|
||||||
|
|
||||||
|
String get keyFor => '$_keyPrefix$publicKeyHex';
|
||||||
|
|
||||||
// Debounce timers to batch rapid writes
|
// Debounce timers to batch rapid writes
|
||||||
Timer? _contactUnreadSaveTimer;
|
Timer? _contactUnreadSaveTimer;
|
||||||
@@ -20,12 +27,33 @@ class UnreadStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<Map<String, int>> loadContactUnreadCount() async {
|
Future<Map<String, int>> loadContactUnreadCount() async {
|
||||||
|
if (publicKeyHex.isEmpty) {
|
||||||
|
appLogger.warn('Public key hex is not set. Cannot load unread counts.');
|
||||||
|
return {};
|
||||||
|
}
|
||||||
final prefs = PrefsManager.instance;
|
final prefs = PrefsManager.instance;
|
||||||
final jsonStr = prefs.getString(_contactUnreadCountKey);
|
String? jsonString = prefs.getString(keyFor);
|
||||||
if (jsonStr == null) return {};
|
if (jsonString == null || jsonString.isEmpty) {
|
||||||
|
// Attempt migration from legacy unscoped key on first load
|
||||||
|
final legacyJsonString = prefs.getString(_keyPrefix);
|
||||||
|
prefs.remove(_keyPrefix);
|
||||||
|
if (legacyJsonString != null && legacyJsonString.isNotEmpty) {
|
||||||
|
appLogger.info(
|
||||||
|
'Migrating channel messages from legacy key $_keyPrefix to scoped key $keyFor',
|
||||||
|
);
|
||||||
|
await prefs.setString(keyFor, legacyJsonString);
|
||||||
|
jsonString = legacyJsonString;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (jsonString == null || jsonString.isEmpty) {
|
||||||
|
jsonString = prefs.getString(keyFor);
|
||||||
|
}
|
||||||
|
if (jsonString == null || jsonString.isEmpty) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final json = jsonDecode(jsonStr) as Map<String, dynamic>;
|
final json = jsonDecode(jsonString) as Map<String, dynamic>;
|
||||||
return json.map((key, value) => MapEntry(key, value as int));
|
return json.map((key, value) => MapEntry(key, value as int));
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
return {};
|
return {};
|
||||||
@@ -33,6 +61,10 @@ class UnreadStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void saveContactUnreadCount(Map<String, int> counts) {
|
void saveContactUnreadCount(Map<String, int> counts) {
|
||||||
|
if (publicKeyHex.isEmpty) {
|
||||||
|
appLogger.warn('Public key hex is not set. Cannot save unread counts.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
_pendingContactUnreadCount = counts;
|
_pendingContactUnreadCount = counts;
|
||||||
|
|
||||||
_contactUnreadSaveTimer?.cancel();
|
_contactUnreadSaveTimer?.cancel();
|
||||||
@@ -49,7 +81,7 @@ class UnreadStore {
|
|||||||
|
|
||||||
final prefs = PrefsManager.instance;
|
final prefs = PrefsManager.instance;
|
||||||
final jsonStr = jsonEncode(_pendingContactUnreadCount);
|
final jsonStr = jsonEncode(_pendingContactUnreadCount);
|
||||||
await prefs.setString(_contactUnreadCountKey, jsonStr);
|
await prefs.setString(keyFor, jsonStr);
|
||||||
_pendingContactUnreadCount = null;
|
_pendingContactUnreadCount = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import 'package:meshcore_open/models/discovery_contact.dart';
|
|
||||||
|
|
||||||
import '../models/contact.dart';
|
import '../models/contact.dart';
|
||||||
|
|
||||||
bool matchesContactQuery(Contact contact, String query) {
|
bool matchesContactQuery(Contact contact, String query) {
|
||||||
@@ -16,7 +14,7 @@ bool matchesContactQuery(Contact contact, String query) {
|
|||||||
return contact.publicKeyHex.toLowerCase().startsWith(hexPrefix);
|
return contact.publicKeyHex.toLowerCase().startsWith(hexPrefix);
|
||||||
}
|
}
|
||||||
|
|
||||||
bool matchesDiscoveryContactQuery(DiscoveryContact contact, String query) {
|
bool matchesDiscoveryContactQuery(Contact contact, String query) {
|
||||||
final normalizedQuery = query.trim().toLowerCase();
|
final normalizedQuery = query.trim().toLowerCase();
|
||||||
if (normalizedQuery.isEmpty) return true;
|
if (normalizedQuery.isEmpty) return true;
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../connector/meshcore_connector.dart';
|
import '../connector/meshcore_connector.dart';
|
||||||
import '../l10n/l10n.dart';
|
import '../l10n/l10n.dart';
|
||||||
|
import 'app_logger.dart';
|
||||||
|
|
||||||
/// Shows a confirmation dialog before disconnecting from the device.
|
/// Shows a confirmation dialog before disconnecting from the device.
|
||||||
/// Returns true if user confirmed and disconnect completed, false otherwise.
|
/// Returns true if user confirmed and disconnect completed, false otherwise.
|
||||||
@@ -28,6 +29,7 @@ Future<bool> showDisconnectDialog(
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (confirmed == true) {
|
if (confirmed == true) {
|
||||||
|
appLogger.info('Disconnect confirmed from popup', tag: 'Connection');
|
||||||
await connector.disconnect();
|
await connector.disconnect();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
/// Queries the macOS IOKit registry via [ioreg] to build a map of serial port
|
||||||
|
/// callout device paths to human-readable USB device names.
|
||||||
|
///
|
||||||
|
/// The [flserial] native library uses the deprecated [IOUSBDevice] IOKit class
|
||||||
|
/// to resolve device names, but macOS 10.15+ renamed it to [IOUSBHostDevice].
|
||||||
|
/// As a result flserial always returns "n/a" for USB product/vendor info on
|
||||||
|
/// modern macOS. This utility bypasses that limitation by invoking ioreg
|
||||||
|
/// directly and parsing its output.
|
||||||
|
///
|
||||||
|
/// Returns a Map of e.g. `"/dev/cu.usbmodem1101"` → `"Nordic NRF52 DK"`.
|
||||||
|
/// Devices without a USB product name are not included in the map.
|
||||||
|
Future<Map<String, String>> queryMacOsUsbDeviceNames() async {
|
||||||
|
assert(Platform.isMacOS);
|
||||||
|
try {
|
||||||
|
final result = await Process.run('ioreg', [
|
||||||
|
'-r',
|
||||||
|
'-c',
|
||||||
|
'IOUSBHostDevice',
|
||||||
|
'-l',
|
||||||
|
], stdoutEncoding: const SystemEncoding());
|
||||||
|
if (result.exitCode != 0) return const <String, String>{};
|
||||||
|
return _parseIoregOutput(result.stdout as String);
|
||||||
|
} catch (_) {
|
||||||
|
return const <String, String>{};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, String> _parseIoregOutput(String output) {
|
||||||
|
final lines = output.split('\n');
|
||||||
|
final result = <String, String>{};
|
||||||
|
|
||||||
|
// We accumulate the current device block's properties.
|
||||||
|
// A new block starts at a line beginning with "+-o " which indicates a
|
||||||
|
// top-level IOUSBHostDevice entry in the ioreg tree.
|
||||||
|
String? currentVendor;
|
||||||
|
String? currentProduct;
|
||||||
|
final List<String> currentPorts = <String>[];
|
||||||
|
|
||||||
|
void flushBlock() {
|
||||||
|
if (currentPorts.isNotEmpty &&
|
||||||
|
(currentVendor != null || currentProduct != null)) {
|
||||||
|
final parts = <String>[
|
||||||
|
if (currentVendor != null && currentVendor!.isNotEmpty) currentVendor!,
|
||||||
|
if (currentProduct != null && currentProduct!.isNotEmpty)
|
||||||
|
currentProduct!,
|
||||||
|
];
|
||||||
|
final name = parts.join(' ');
|
||||||
|
for (final port in currentPorts) {
|
||||||
|
result[port] = name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
currentVendor = null;
|
||||||
|
currentProduct = null;
|
||||||
|
currentPorts.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
for (final line in lines) {
|
||||||
|
// A new top-level device block begins here.
|
||||||
|
if (line.startsWith('+-o ')) {
|
||||||
|
flushBlock();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// USB Product Name (appears at multiple depths in the tree, first wins)
|
||||||
|
final productMatch = _kProductName.firstMatch(line);
|
||||||
|
if (productMatch != null && currentProduct == null) {
|
||||||
|
currentProduct = productMatch.group(1)?.trim();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// USB Vendor Name
|
||||||
|
final vendorMatch = _kVendorName.firstMatch(line);
|
||||||
|
if (vendorMatch != null && currentVendor == null) {
|
||||||
|
currentVendor = vendorMatch.group(1)?.trim();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// IOCalloutDevice — the /dev/cu.xxx path our app uses
|
||||||
|
final calloutMatch = _kCalloutDevice.firstMatch(line);
|
||||||
|
if (calloutMatch != null) {
|
||||||
|
final port = calloutMatch.group(1)?.trim();
|
||||||
|
if (port != null && port.isNotEmpty) {
|
||||||
|
currentPorts.add(port);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flushBlock();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
final RegExp _kProductName = RegExp(r'"USB Product Name" = "([^"]*)"');
|
||||||
|
final RegExp _kVendorName = RegExp(r'"USB Vendor Name" = "([^"]*)"');
|
||||||
|
final RegExp _kCalloutDevice = RegExp(r'"IOCalloutDevice" = "([^"]*)"');
|
||||||
@@ -33,4 +33,15 @@ class PlatformInfo {
|
|||||||
|
|
||||||
/// Whether the app is running on a desktop platform (macOS, Windows, or Linux).
|
/// Whether the app is running on a desktop platform (macOS, Windows, or Linux).
|
||||||
static bool get isDesktop => isMacOS || isWindows || isLinux;
|
static bool get isDesktop => isMacOS || isWindows || isLinux;
|
||||||
|
|
||||||
|
/// Whether the current platform supports a native USB serial backend.
|
||||||
|
static bool get supportsNativeUsbSerial =>
|
||||||
|
isAndroid || isWindows || isLinux || isMacOS;
|
||||||
|
|
||||||
|
/// Whether the current browser supports the Web Serial backend.
|
||||||
|
static bool get supportsWebSerial => isWeb && isChrome;
|
||||||
|
|
||||||
|
/// Whether USB serial is expected to be available on the current platform.
|
||||||
|
static bool get supportsUsbSerial =>
|
||||||
|
supportsNativeUsbSerial || supportsWebSerial;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
String normalizeUsbPortName(String portLabel) {
|
||||||
|
final separatorIndex = portLabel.indexOf(' - ');
|
||||||
|
final normalized = separatorIndex >= 0
|
||||||
|
? portLabel.substring(0, separatorIndex)
|
||||||
|
: portLabel;
|
||||||
|
return normalized.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a human-readable name for a serial port label.
|
||||||
|
///
|
||||||
|
/// The native flserial library encodes port info as a ` - `-separated string:
|
||||||
|
/// `"<port> - <description> - <hardware_id>"`
|
||||||
|
///
|
||||||
|
/// This function extracts the *description* field (index 1) and discards the
|
||||||
|
/// raw hardware_id, which is not user-friendly. If the description is missing
|
||||||
|
/// or unhelpful (e.g. "n/a"), it falls back to the raw port name.
|
||||||
|
String friendlyUsbPortName(String portLabel) {
|
||||||
|
final parts = portLabel.split(' - ');
|
||||||
|
if (parts.length < 2) {
|
||||||
|
return portLabel.trim();
|
||||||
|
}
|
||||||
|
// parts[0] = port name, parts[1] = description, parts[2+] = hardware id
|
||||||
|
final description = parts[1].trim();
|
||||||
|
if (description.isEmpty || description.toLowerCase() == 'n/a') {
|
||||||
|
return parts[0].trim();
|
||||||
|
}
|
||||||
|
return description;
|
||||||
|
}
|
||||||
|
|
||||||
|
String describeWebUsbPort({
|
||||||
|
required int? vendorId,
|
||||||
|
required int? productId,
|
||||||
|
String requestPortLabel = 'Choose USB Device',
|
||||||
|
String fallbackDeviceName = 'Web Serial Device',
|
||||||
|
Map<String, String> knownUsbNames = const <String, String>{},
|
||||||
|
}) {
|
||||||
|
if (vendorId == null && productId == null) {
|
||||||
|
return requestPortLabel;
|
||||||
|
}
|
||||||
|
|
||||||
|
final vendorHex = vendorId?.toRadixString(16).padLeft(4, '0').toUpperCase();
|
||||||
|
final productHex = productId?.toRadixString(16).padLeft(4, '0').toUpperCase();
|
||||||
|
final knownName = (vendorHex != null && productHex != null)
|
||||||
|
? knownUsbNames['${vendorHex.toLowerCase()}:${productHex.toLowerCase()}']
|
||||||
|
: null;
|
||||||
|
|
||||||
|
final parts = <String>[knownName ?? fallbackDeviceName];
|
||||||
|
if (vendorHex != null) {
|
||||||
|
parts.add('VID:$vendorHex');
|
||||||
|
}
|
||||||
|
if (productHex != null) {
|
||||||
|
parts.add('PID:$productHex');
|
||||||
|
}
|
||||||
|
return '${parts.first} (${parts.skip(1).join(' ')})';
|
||||||
|
}
|
||||||
|
|
||||||
|
String buildUsbDisplayLabel({
|
||||||
|
required String basePortLabel,
|
||||||
|
String? deviceName,
|
||||||
|
}) {
|
||||||
|
final trimmedName = deviceName?.trim() ?? '';
|
||||||
|
if (trimmedName.isEmpty) {
|
||||||
|
return basePortLabel;
|
||||||
|
}
|
||||||
|
return '$basePortLabel - $trimmedName';
|
||||||
|
}
|
||||||
@@ -78,40 +78,36 @@ class _SNRIndicatorState extends State<SNRIndicator> {
|
|||||||
widget.connector.currentSf,
|
widget.connector.currentSf,
|
||||||
);
|
);
|
||||||
|
|
||||||
return InkWell(
|
return ConstrainedBox(
|
||||||
onTap: () {
|
constraints: const BoxConstraints(minWidth: 40, minHeight: 40),
|
||||||
if (directRepeater != null) {
|
child: InkWell(
|
||||||
_showFullPathDialog(context, directBestRepeaters);
|
onTap: directRepeater != null
|
||||||
}
|
? () => _showFullPathDialog(context, directBestRepeaters)
|
||||||
},
|
: null,
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8),
|
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Column(
|
Icon(snrUi.icon, size: 18, color: snrUi.color),
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Icon(snrUi.icon, size: 18, color: snrUi.color),
|
|
||||||
Text(
|
|
||||||
snrUi.text,
|
|
||||||
style: TextStyle(fontSize: 12, color: snrUi.color),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
if (directRepeater != null)
|
|
||||||
Text(
|
Text(
|
||||||
'${directRepeaters.length}: ${directRepeater.pubkeyFirstByte.toRadixString(16).padLeft(2, '0')}: ${_formatLastUpdated(directRepeater.lastUpdated)}',
|
snrUi.text,
|
||||||
style: TextStyle(
|
style: TextStyle(fontSize: 12, color: snrUi.color),
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
color: Colors.grey,
|
|
||||||
),
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
),
|
||||||
],
|
if (directRepeater != null)
|
||||||
|
Text(
|
||||||
|
'${directRepeaters.length}: ${directRepeater.pubkeyFirstByte.toRadixString(16).padLeft(2, '0')}: ${_formatLastUpdated(directRepeater.lastUpdated)}',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: Colors.grey,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -148,8 +144,10 @@ class _SNRIndicatorState extends State<SNRIndicator> {
|
|||||||
builder: (context) => AlertDialog(
|
builder: (context) => AlertDialog(
|
||||||
title: Text(l10n.snrIndicator_nearByRepeaters),
|
title: Text(l10n.snrIndicator_nearByRepeaters),
|
||||||
content: SizedBox(
|
content: SizedBox(
|
||||||
|
width: double.maxFinite,
|
||||||
child: Scrollbar(
|
child: Scrollbar(
|
||||||
child: ListView.separated(
|
child: ListView.separated(
|
||||||
|
shrinkWrap: true,
|
||||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||||
itemCount: directBestRepeaters.length,
|
itemCount: directBestRepeaters.length,
|
||||||
separatorBuilder: (_, _) => const Divider(height: 1),
|
separatorBuilder: (_, _) => const Divider(height: 1),
|
||||||
@@ -159,8 +157,11 @@ class _SNRIndicatorState extends State<SNRIndicator> {
|
|||||||
repeater.snr,
|
repeater.snr,
|
||||||
widget.connector.currentSf,
|
widget.connector.currentSf,
|
||||||
);
|
);
|
||||||
|
final allContacts = [
|
||||||
final name = widget.connector.contacts
|
...widget.connector.contacts,
|
||||||
|
...widget.connector.discoveredContacts,
|
||||||
|
];
|
||||||
|
final name = allContacts
|
||||||
.where((c) => c.publicKey.first == repeater.pubkeyFirstByte)
|
.where((c) => c.publicKey.first == repeater.pubkeyFirstByte)
|
||||||
.map((c) => c.name)
|
.map((c) => c.name)
|
||||||
.firstOrNull;
|
.firstOrNull;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
|
|||||||
)
|
)
|
||||||
|
|
||||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||||
|
flserial
|
||||||
)
|
)
|
||||||
|
|
||||||
set(PLUGIN_BUNDLED_LIBRARIES)
|
set(PLUGIN_BUNDLED_LIBRARIES)
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
PODS:
|
PODS:
|
||||||
|
- flserial (0.0.1):
|
||||||
|
- FlutterMacOS
|
||||||
- flutter_blue_plus_darwin (0.0.2):
|
- flutter_blue_plus_darwin (0.0.2):
|
||||||
- Flutter
|
- Flutter
|
||||||
- FlutterMacOS
|
- FlutterMacOS
|
||||||
@@ -24,6 +26,7 @@ PODS:
|
|||||||
- FlutterMacOS
|
- FlutterMacOS
|
||||||
|
|
||||||
DEPENDENCIES:
|
DEPENDENCIES:
|
||||||
|
- flserial (from `Flutter/ephemeral/.symlinks/plugins/flserial/macos`)
|
||||||
- flutter_blue_plus_darwin (from `Flutter/ephemeral/.symlinks/plugins/flutter_blue_plus_darwin/darwin`)
|
- flutter_blue_plus_darwin (from `Flutter/ephemeral/.symlinks/plugins/flutter_blue_plus_darwin/darwin`)
|
||||||
- flutter_local_notifications (from `Flutter/ephemeral/.symlinks/plugins/flutter_local_notifications/macos`)
|
- flutter_local_notifications (from `Flutter/ephemeral/.symlinks/plugins/flutter_local_notifications/macos`)
|
||||||
- FlutterMacOS (from `Flutter/ephemeral`)
|
- FlutterMacOS (from `Flutter/ephemeral`)
|
||||||
@@ -36,6 +39,8 @@ DEPENDENCIES:
|
|||||||
- wakelock_plus (from `Flutter/ephemeral/.symlinks/plugins/wakelock_plus/macos`)
|
- wakelock_plus (from `Flutter/ephemeral/.symlinks/plugins/wakelock_plus/macos`)
|
||||||
|
|
||||||
EXTERNAL SOURCES:
|
EXTERNAL SOURCES:
|
||||||
|
flserial:
|
||||||
|
:path: Flutter/ephemeral/.symlinks/plugins/flserial/macos
|
||||||
flutter_blue_plus_darwin:
|
flutter_blue_plus_darwin:
|
||||||
:path: Flutter/ephemeral/.symlinks/plugins/flutter_blue_plus_darwin/darwin
|
:path: Flutter/ephemeral/.symlinks/plugins/flutter_blue_plus_darwin/darwin
|
||||||
flutter_local_notifications:
|
flutter_local_notifications:
|
||||||
@@ -58,6 +63,7 @@ EXTERNAL SOURCES:
|
|||||||
:path: Flutter/ephemeral/.symlinks/plugins/wakelock_plus/macos
|
:path: Flutter/ephemeral/.symlinks/plugins/wakelock_plus/macos
|
||||||
|
|
||||||
SPEC CHECKSUMS:
|
SPEC CHECKSUMS:
|
||||||
|
flserial: 3c161e076dfc73458ec5803e7a9a9d2bb85fadf6
|
||||||
flutter_blue_plus_darwin: 20a08bfeaa0f7804d524858d3d8744bcc1b6dbc3
|
flutter_blue_plus_darwin: 20a08bfeaa0f7804d524858d3d8744bcc1b6dbc3
|
||||||
flutter_local_notifications: 4bf37a31afde695b56091b4ae3e4d9c7a7e6cda0
|
flutter_local_notifications: 4bf37a31afde695b56091b4ae3e4d9c7a7e6cda0
|
||||||
FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1
|
FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1
|
||||||
|
|||||||
@@ -12,6 +12,14 @@
|
|||||||
<true/>
|
<true/>
|
||||||
<key>com.apple.security.device.bluetooth</key>
|
<key>com.apple.security.device.bluetooth</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>com.apple.security.device.usb</key>
|
||||||
|
<true/>
|
||||||
|
<!-- USB serial ports (/dev/cu.* and /dev/tty.*) for LoRa device communication -->
|
||||||
|
<key>com.apple.security.temporary-exception.files.absolute-path.read-write</key>
|
||||||
|
<array>
|
||||||
|
<string>/dev/cu.</string>
|
||||||
|
<string>/dev/tty.</string>
|
||||||
|
</array>
|
||||||
<key>com.apple.security.device.camera</key>
|
<key>com.apple.security.device.camera</key>
|
||||||
<true/>
|
<true/>
|
||||||
</dict>
|
</dict>
|
||||||
|
|||||||
@@ -8,6 +8,14 @@
|
|||||||
<true/>
|
<true/>
|
||||||
<key>com.apple.security.device.bluetooth</key>
|
<key>com.apple.security.device.bluetooth</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>com.apple.security.device.usb</key>
|
||||||
|
<true/>
|
||||||
|
<!-- USB serial ports (/dev/cu.* and /dev/tty.*) for LoRa device communication -->
|
||||||
|
<key>com.apple.security.temporary-exception.files.absolute-path.read-write</key>
|
||||||
|
<array>
|
||||||
|
<string>/dev/cu.</string>
|
||||||
|
<string>/dev/tty.</string>
|
||||||
|
</array>
|
||||||
<key>com.apple.security.device.camera</key>
|
<key>com.apple.security.device.camera</key>
|
||||||
<true/>
|
<true/>
|
||||||
</dict>
|
</dict>
|
||||||
|
|||||||
@@ -38,6 +38,11 @@ dependencies:
|
|||||||
# Use with the CupertinoIcons class for iOS style icons.
|
# Use with the CupertinoIcons class for iOS style icons.
|
||||||
cupertino_icons: ^1.0.8
|
cupertino_icons: ^1.0.8
|
||||||
flutter_blue_plus: ^2.1.0
|
flutter_blue_plus: ^2.1.0
|
||||||
|
# TODO: Switch to official flserial repo once changes are upstreamed
|
||||||
|
flserial:
|
||||||
|
git:
|
||||||
|
url: https://github.com/MeshEnvy/flserial.git
|
||||||
|
ref: 48216310061efc8d5d217cc18014fc2cb501646e
|
||||||
provider: ^6.1.5+1
|
provider: ^6.1.5+1
|
||||||
shared_preferences: ^2.2.2
|
shared_preferences: ^2.2.2
|
||||||
uuid: ^4.3.3
|
uuid: ^4.3.3
|
||||||
@@ -130,6 +135,8 @@ flutter_launcher_icons:
|
|||||||
# For details regarding fonts from package dependencies,
|
# For details regarding fonts from package dependencies,
|
||||||
# see https://flutter.dev/to/font-from-package
|
# see https://flutter.dev/to/font-from-package
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
build_pipe:
|
build_pipe:
|
||||||
workflows:
|
workflows:
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:meshcore_open/connector/meshcore_connector.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('shouldIgnoreLateTcpConnectError', () {
|
||||||
|
test('returns true for manual cancel during disconnecting state', () {
|
||||||
|
final result = MeshCoreConnector.shouldIgnoreLateTcpConnectError(
|
||||||
|
manualDisconnect: true,
|
||||||
|
state: MeshCoreConnectionState.disconnecting,
|
||||||
|
activeTransport: MeshCoreTransportType.bluetooth,
|
||||||
|
tcpManagerConnected: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'returns true for manual cancel after reaching disconnected state',
|
||||||
|
() {
|
||||||
|
final result = MeshCoreConnector.shouldIgnoreLateTcpConnectError(
|
||||||
|
manualDisconnect: true,
|
||||||
|
state: MeshCoreConnectionState.disconnected,
|
||||||
|
activeTransport: MeshCoreTransportType.bluetooth,
|
||||||
|
tcpManagerConnected: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result, isTrue);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('returns false when not a manual disconnect', () {
|
||||||
|
final result = MeshCoreConnector.shouldIgnoreLateTcpConnectError(
|
||||||
|
manualDisconnect: false,
|
||||||
|
state: MeshCoreConnectionState.disconnecting,
|
||||||
|
activeTransport: MeshCoreTransportType.bluetooth,
|
||||||
|
tcpManagerConnected: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns false for connected state handshake failures', () {
|
||||||
|
final result = MeshCoreConnector.shouldIgnoreLateTcpConnectError(
|
||||||
|
manualDisconnect: true,
|
||||||
|
state: MeshCoreConnectionState.connected,
|
||||||
|
activeTransport: MeshCoreTransportType.tcp,
|
||||||
|
tcpManagerConnected: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns false when TCP is still active while disconnecting', () {
|
||||||
|
final result = MeshCoreConnector.shouldIgnoreLateTcpConnectError(
|
||||||
|
manualDisconnect: true,
|
||||||
|
state: MeshCoreConnectionState.disconnecting,
|
||||||
|
activeTransport: MeshCoreTransportType.tcp,
|
||||||
|
tcpManagerConnected: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result, isFalse);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('shouldResetStateAfterTcpConnectAbort', () {
|
||||||
|
test('returns true when TCP connect is still in connecting state', () {
|
||||||
|
final result = MeshCoreConnector.shouldResetStateAfterTcpConnectAbort(
|
||||||
|
state: MeshCoreConnectionState.connecting,
|
||||||
|
activeTransport: MeshCoreTransportType.tcp,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns false when state is already disconnected', () {
|
||||||
|
final result = MeshCoreConnector.shouldResetStateAfterTcpConnectAbort(
|
||||||
|
state: MeshCoreConnectionState.disconnected,
|
||||||
|
activeTransport: MeshCoreTransportType.tcp,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns false when transport switched away from TCP', () {
|
||||||
|
final result = MeshCoreConnector.shouldResetStateAfterTcpConnectAbort(
|
||||||
|
state: MeshCoreConnectionState.connecting,
|
||||||
|
activeTransport: MeshCoreTransportType.bluetooth,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result, isFalse);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import 'package:meshcore_open/connector/meshcore_connector.dart';
|
||||||
|
import 'package:meshcore_open/l10n/app_localizations.dart';
|
||||||
|
import 'package:meshcore_open/screens/scanner_screen.dart';
|
||||||
|
import 'package:meshcore_open/screens/tcp_screen.dart';
|
||||||
|
|
||||||
|
class _FakeMeshCoreConnector extends MeshCoreConnector {
|
||||||
|
_FakeMeshCoreConnector();
|
||||||
|
|
||||||
|
MeshCoreConnectionState initialState = MeshCoreConnectionState.disconnected;
|
||||||
|
MeshCoreTransportType initialTransport = MeshCoreTransportType.bluetooth;
|
||||||
|
String? initialEndpoint;
|
||||||
|
int connectTcpCalls = 0;
|
||||||
|
String? lastHost;
|
||||||
|
int? lastPort;
|
||||||
|
|
||||||
|
@override
|
||||||
|
MeshCoreConnectionState get state => initialState;
|
||||||
|
|
||||||
|
@override
|
||||||
|
MeshCoreTransportType get activeTransport => initialTransport;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get isTcpTransportConnected =>
|
||||||
|
initialState == MeshCoreConnectionState.connected &&
|
||||||
|
initialTransport == MeshCoreTransportType.tcp;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String? get activeTcpEndpoint => initialEndpoint;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> connectTcp({required String host, required int port}) async {
|
||||||
|
connectTcpCalls += 1;
|
||||||
|
lastHost = host;
|
||||||
|
lastPort = port;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildTestApp({
|
||||||
|
required MeshCoreConnector connector,
|
||||||
|
required Widget child,
|
||||||
|
Locale? locale,
|
||||||
|
}) {
|
||||||
|
return ChangeNotifierProvider<MeshCoreConnector>.value(
|
||||||
|
value: connector,
|
||||||
|
child: MaterialApp(
|
||||||
|
locale: locale,
|
||||||
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||||
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
|
home: child,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
testWidgets('TcpScreen uses localized TCP copy', (tester) async {
|
||||||
|
final connector = _FakeMeshCoreConnector();
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
_buildTestApp(
|
||||||
|
connector: connector,
|
||||||
|
child: const TcpScreen(),
|
||||||
|
locale: const Locale('en'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
final context = tester.element(find.byType(TcpScreen));
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
|
||||||
|
expect(find.text(l10n.tcpScreenTitle), findsOneWidget);
|
||||||
|
expect(find.text(l10n.tcpHostLabel), findsOneWidget);
|
||||||
|
expect(find.text(l10n.tcpPortLabel), findsOneWidget);
|
||||||
|
expect(find.text(l10n.tcpStatus_notConnected), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('TcpScreen validation errors are localized', (tester) async {
|
||||||
|
final connector = _FakeMeshCoreConnector();
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
_buildTestApp(
|
||||||
|
connector: connector,
|
||||||
|
child: const TcpScreen(),
|
||||||
|
locale: const Locale('en'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
final context = tester.element(find.byType(TcpScreen));
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
|
||||||
|
await tester.enterText(find.byType(TextField).first, '');
|
||||||
|
await tester.tap(find.byKey(const Key('tcp_connect_button')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text(l10n.tcpErrorHostRequired), findsOneWidget);
|
||||||
|
expect(connector.connectTcpCalls, 0);
|
||||||
|
|
||||||
|
await tester.enterText(find.byType(TextField).first, '192.168.1.50');
|
||||||
|
await tester.enterText(find.byType(TextField).at(1), '99999');
|
||||||
|
await tester.tap(find.byKey(const Key('tcp_connect_button')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(connector.connectTcpCalls, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('TCP Bluetooth action returns to existing scanner route', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final connector = _FakeMeshCoreConnector();
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
_buildTestApp(connector: connector, child: const ScannerScreen()),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.widgetWithText(FloatingActionButton, 'TCP'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.byType(TcpScreen), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.widgetWithText(FloatingActionButton, 'Bluetooth'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.byType(TcpScreen), findsNothing);
|
||||||
|
expect(find.byType(ScannerScreen), findsOneWidget);
|
||||||
|
final navigatorState = tester.state<NavigatorState>(find.byType(Navigator));
|
||||||
|
expect(navigatorState.canPop(), isFalse);
|
||||||
|
|
||||||
|
// ScannerScreen.dispose() schedules disconnect work that debounces notify.
|
||||||
|
// Drain that debounce timer before test teardown.
|
||||||
|
await tester.pumpWidget(const SizedBox.shrink());
|
||||||
|
await tester.pump(const Duration(milliseconds: 60));
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('TcpScreen disables connect button while connector is scanning', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final connector = _FakeMeshCoreConnector()
|
||||||
|
..initialState = MeshCoreConnectionState.scanning;
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
_buildTestApp(
|
||||||
|
connector: connector,
|
||||||
|
child: const TcpScreen(),
|
||||||
|
locale: const Locale('en'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
final button = tester.widget<ButtonStyleButton>(
|
||||||
|
find.byKey(const Key('tcp_connect_button')),
|
||||||
|
);
|
||||||
|
expect(button.onPressed, isNull);
|
||||||
|
expect(connector.connectTcpCalls, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('TcpScreen narrow width long status text does not overflow', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await tester.binding.setSurfaceSize(const Size(320, 700));
|
||||||
|
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||||
|
|
||||||
|
final connector = _FakeMeshCoreConnector()
|
||||||
|
..initialState = MeshCoreConnectionState.connected
|
||||||
|
..initialTransport = MeshCoreTransportType.tcp
|
||||||
|
..initialEndpoint = 'meshcore-room-server-very-long-hostname.local:5000';
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
_buildTestApp(
|
||||||
|
connector: connector,
|
||||||
|
child: const TcpScreen(),
|
||||||
|
locale: const Locale('en'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
|
||||||
|
final context = tester.element(find.byType(TcpScreen));
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
expect(
|
||||||
|
find.text(l10n.scanner_connectedTo(connector.initialEndpoint!)),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpWidget(const SizedBox.shrink());
|
||||||
|
await tester.pump(const Duration(milliseconds: 60));
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,277 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import 'package:meshcore_open/connector/meshcore_connector.dart';
|
||||||
|
import 'package:meshcore_open/l10n/app_localizations.dart';
|
||||||
|
import 'package:meshcore_open/screens/scanner_screen.dart';
|
||||||
|
import 'package:meshcore_open/screens/usb_screen.dart';
|
||||||
|
import 'package:meshcore_open/utils/platform_info.dart';
|
||||||
|
|
||||||
|
class _FakeMeshCoreConnector extends MeshCoreConnector {
|
||||||
|
_FakeMeshCoreConnector({
|
||||||
|
this.initialState = MeshCoreConnectionState.disconnected,
|
||||||
|
List<String>? ports,
|
||||||
|
}) : _ports = ports ?? <String>[];
|
||||||
|
|
||||||
|
final MeshCoreConnectionState initialState;
|
||||||
|
final List<String> _ports;
|
||||||
|
|
||||||
|
String? requestPortLabel;
|
||||||
|
String? fallbackDeviceName;
|
||||||
|
int connectUsbCalls = 0;
|
||||||
|
String? lastConnectPortName;
|
||||||
|
String? fakeActiveUsbPort;
|
||||||
|
String? fakeActiveUsbPortDisplayLabel;
|
||||||
|
bool fakeUsbTransportConnected = false;
|
||||||
|
Future<List<String>> Function()? listUsbPortsImpl;
|
||||||
|
Future<void> Function({required String portName})? connectUsbImpl;
|
||||||
|
|
||||||
|
@override
|
||||||
|
MeshCoreConnectionState get state => initialState;
|
||||||
|
|
||||||
|
@override
|
||||||
|
MeshCoreTransportType get activeTransport => MeshCoreTransportType.usb;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String? get activeUsbPort => fakeActiveUsbPort;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String? get activeUsbPortDisplayLabel =>
|
||||||
|
fakeActiveUsbPortDisplayLabel ?? fakeActiveUsbPort;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get isUsbTransportConnected => fakeUsbTransportConnected;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<String>> listUsbPorts() async {
|
||||||
|
if (listUsbPortsImpl != null) {
|
||||||
|
return listUsbPortsImpl!();
|
||||||
|
}
|
||||||
|
return List<String>.from(_ports);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> connectUsb({
|
||||||
|
required String portName,
|
||||||
|
int baudRate = 115200,
|
||||||
|
}) async {
|
||||||
|
if (connectUsbImpl != null) {
|
||||||
|
return connectUsbImpl!(portName: portName);
|
||||||
|
}
|
||||||
|
connectUsbCalls += 1;
|
||||||
|
lastConnectPortName = portName;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void setUsbRequestPortLabel(String label) {
|
||||||
|
requestPortLabel = label;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void setUsbFallbackDeviceName(String label) {
|
||||||
|
fallbackDeviceName = label;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildTestApp({
|
||||||
|
required MeshCoreConnector connector,
|
||||||
|
required Widget child,
|
||||||
|
}) {
|
||||||
|
return ChangeNotifierProvider<MeshCoreConnector>.value(
|
||||||
|
value: connector,
|
||||||
|
child: MaterialApp(
|
||||||
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||||
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
|
home: child,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
testWidgets('UsbScreen passes localized chooser label to connector', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final connector = _FakeMeshCoreConnector();
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
_buildTestApp(connector: connector, child: const UsbScreen()),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(connector.requestPortLabel, 'Select a USB device');
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'UsbScreen does not call connectUsb when connector is not disconnected',
|
||||||
|
(tester) async {
|
||||||
|
final connector = _FakeMeshCoreConnector(
|
||||||
|
initialState: MeshCoreConnectionState.connected,
|
||||||
|
ports: <String>['COM6 - USB Serial Device (COM6)'],
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
_buildTestApp(connector: connector, child: const UsbScreen()),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.byType(ListTile).first);
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(connector.connectUsbCalls, 0);
|
||||||
|
|
||||||
|
// UsbScreen.dispose() schedules disconnect work that debounces notify.
|
||||||
|
// Drain that debounce timer before test teardown.
|
||||||
|
await tester.pumpWidget(const SizedBox.shrink());
|
||||||
|
await tester.pump(const Duration(milliseconds: 60));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
testWidgets('UsbScreen sends raw port name when tapping Connect', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final connector = _FakeMeshCoreConnector(
|
||||||
|
ports: <String>['COM6 - USB Serial Device (COM6)'],
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
_buildTestApp(connector: connector, child: const UsbScreen()),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.byType(ListTile).first);
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(connector.connectUsbCalls, 1);
|
||||||
|
expect(connector.lastConnectPortName, 'COM6');
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('ScannerScreen USB action reflects platform support', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final connector = _FakeMeshCoreConnector();
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
_buildTestApp(connector: connector, child: const ScannerScreen()),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
if (PlatformInfo.supportsUsbSerial) {
|
||||||
|
expect(find.widgetWithText(FloatingActionButton, 'USB'), findsOneWidget);
|
||||||
|
} else {
|
||||||
|
expect(find.widgetWithText(FloatingActionButton, 'USB'), findsNothing);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScannerScreen.dispose() schedules disconnect work that debounces notify.
|
||||||
|
// Drain that debounce timer before test teardown.
|
||||||
|
await tester.pumpWidget(const SizedBox.shrink());
|
||||||
|
await tester.pump(const Duration(milliseconds: 60));
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('ScannerScreen narrow width keeps actions without overflow', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await tester.binding.setSurfaceSize(const Size(320, 700));
|
||||||
|
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||||
|
|
||||||
|
final connector = _FakeMeshCoreConnector();
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
_buildTestApp(connector: connector, child: const ScannerScreen()),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
|
||||||
|
final context = tester.element(find.byType(ScannerScreen));
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
expect(find.text(l10n.scanner_scan), findsOneWidget);
|
||||||
|
|
||||||
|
if (PlatformInfo.supportsUsbSerial) {
|
||||||
|
expect(find.text(l10n.connectionChoiceUsbLabel), findsOneWidget);
|
||||||
|
}
|
||||||
|
if (!PlatformInfo.isWeb) {
|
||||||
|
expect(find.text(l10n.connectionChoiceTcpLabel), findsOneWidget);
|
||||||
|
}
|
||||||
|
|
||||||
|
await tester.pumpWidget(const SizedBox.shrink());
|
||||||
|
await tester.pump(const Duration(milliseconds: 60));
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('UsbScreen narrow width long status text does not overflow', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await tester.binding.setSurfaceSize(const Size(320, 700));
|
||||||
|
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||||
|
|
||||||
|
final connector =
|
||||||
|
_FakeMeshCoreConnector(initialState: MeshCoreConnectionState.connected)
|
||||||
|
..fakeUsbTransportConnected = true
|
||||||
|
..fakeActiveUsbPortDisplayLabel =
|
||||||
|
'/dev/bus/usb/001/002 - KD3CGK mesh-utility.org very long label';
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
_buildTestApp(connector: connector, child: const UsbScreen()),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
|
||||||
|
final context = tester.element(find.byType(UsbScreen));
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
expect(
|
||||||
|
find.text(
|
||||||
|
l10n.scanner_connectedTo(connector.fakeActiveUsbPortDisplayLabel!),
|
||||||
|
),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpWidget(const SizedBox.shrink());
|
||||||
|
await tester.pump(const Duration(milliseconds: 60));
|
||||||
|
});
|
||||||
|
|
||||||
|
group('Error Handling', () {
|
||||||
|
testWidgets('shows error SnackBar when listing ports fails', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final connector = _FakeMeshCoreConnector();
|
||||||
|
connector.listUsbPortsImpl = () async {
|
||||||
|
throw PlatformException(
|
||||||
|
code: 'usb_permission_denied',
|
||||||
|
message: 'Permission denied',
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
_buildTestApp(connector: connector, child: const UsbScreen()),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('USB permission was denied.'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('connection failure shows SnackBar error', (tester) async {
|
||||||
|
final connector = _FakeMeshCoreConnector(ports: <String>['COM1']);
|
||||||
|
var connectAttempted = false;
|
||||||
|
connector.connectUsbImpl = ({required String portName}) async {
|
||||||
|
connectAttempted = true;
|
||||||
|
throw PlatformException(code: 'usb_busy', message: 'Device is busy');
|
||||||
|
};
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
_buildTestApp(connector: connector, child: const UsbScreen()),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.byType(ListTile).first);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(connectAttempted, isTrue);
|
||||||
|
expect(
|
||||||
|
find.text('Another USB connection request is already in progress.'),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:meshcore_open/services/tcp_transport_service_native.dart';
|
||||||
|
import 'package:meshcore_open/services/usb_serial_frame_codec.dart';
|
||||||
|
|
||||||
|
final class _DelayedConnectOverrides extends IOOverrides {
|
||||||
|
_DelayedConnectOverrides(this.delay);
|
||||||
|
|
||||||
|
final Duration delay;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Socket> socketConnect(
|
||||||
|
host,
|
||||||
|
int port, {
|
||||||
|
sourceAddress,
|
||||||
|
int sourcePort = 0,
|
||||||
|
Duration? timeout,
|
||||||
|
}) async {
|
||||||
|
await Future<void>.delayed(delay);
|
||||||
|
return super.socketConnect(
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
sourceAddress: sourceAddress,
|
||||||
|
sourcePort: sourcePort,
|
||||||
|
timeout: timeout,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
test('connect/disconnect updates TCP transport state', () async {
|
||||||
|
final server = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0);
|
||||||
|
final service = TcpTransportService();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await service.connect(
|
||||||
|
host: InternetAddress.loopbackIPv4.address,
|
||||||
|
port: server.port,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(service.isConnected, isTrue);
|
||||||
|
expect(
|
||||||
|
service.activeEndpoint,
|
||||||
|
'${InternetAddress.loopbackIPv4.address}:${server.port}',
|
||||||
|
);
|
||||||
|
|
||||||
|
await service.disconnect();
|
||||||
|
|
||||||
|
expect(service.isConnected, isFalse);
|
||||||
|
expect(service.activeEndpoint, isNull);
|
||||||
|
} finally {
|
||||||
|
await service.disconnect();
|
||||||
|
await server.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('disconnect is safe when already disconnected', () async {
|
||||||
|
final service = TcpTransportService();
|
||||||
|
|
||||||
|
await service.disconnect();
|
||||||
|
await service.disconnect();
|
||||||
|
|
||||||
|
expect(service.isConnected, isFalse);
|
||||||
|
expect(service.activeEndpoint, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('emits only RX frames from socket stream', () async {
|
||||||
|
final server = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0);
|
||||||
|
final acceptedSocket = Completer<Socket>();
|
||||||
|
final service = TcpTransportService();
|
||||||
|
final receivedFrames = <Uint8List>[];
|
||||||
|
|
||||||
|
final serverSub = server.listen((socket) {
|
||||||
|
if (!acceptedSocket.isCompleted) {
|
||||||
|
acceptedSocket.complete(socket);
|
||||||
|
} else {
|
||||||
|
socket.destroy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
final frameSub = service.frameStream.listen(receivedFrames.add);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await service.connect(
|
||||||
|
host: InternetAddress.loopbackIPv4.address,
|
||||||
|
port: server.port,
|
||||||
|
);
|
||||||
|
|
||||||
|
final socket = await acceptedSocket.future.timeout(
|
||||||
|
const Duration(seconds: 2),
|
||||||
|
);
|
||||||
|
|
||||||
|
socket.add(<int>[usbSerialTxFrameStart, 0x01, 0x00, 0x11]);
|
||||||
|
socket.add(<int>[usbSerialRxFrameStart, 0x02, 0x00, 0x33, 0x44]);
|
||||||
|
await socket.flush();
|
||||||
|
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 20));
|
||||||
|
|
||||||
|
expect(receivedFrames, hasLength(1));
|
||||||
|
expect(receivedFrames.single, orderedEquals(<int>[0x33, 0x44]));
|
||||||
|
} finally {
|
||||||
|
await service.disconnect();
|
||||||
|
await frameSub.cancel();
|
||||||
|
await serverSub.cancel();
|
||||||
|
await server.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'disconnect during in-flight connect keeps transport disconnected',
|
||||||
|
() async {
|
||||||
|
final server = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0);
|
||||||
|
final service = TcpTransportService();
|
||||||
|
final host = InternetAddress.loopbackIPv4.address;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await IOOverrides.runWithIOOverrides(() async {
|
||||||
|
final connectFuture = service.connect(host: host, port: server.port);
|
||||||
|
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 10));
|
||||||
|
await service.disconnect();
|
||||||
|
await connectFuture;
|
||||||
|
|
||||||
|
expect(service.isConnected, isFalse);
|
||||||
|
expect(service.status, TcpTransportStatus.disconnected);
|
||||||
|
expect(service.activeEndpoint, isNull);
|
||||||
|
}, _DelayedConnectOverrides(const Duration(milliseconds: 120)));
|
||||||
|
} finally {
|
||||||
|
await service.disconnect();
|
||||||
|
await server.close();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:meshcore_open/services/usb_serial_frame_codec.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
test('wrapUsbSerialTxFrame prefixes tx header and payload length', () {
|
||||||
|
final packet = wrapUsbSerialTxFrame(Uint8List.fromList(<int>[0x16, 0x03]));
|
||||||
|
|
||||||
|
expect(
|
||||||
|
packet,
|
||||||
|
orderedEquals(<int>[usbSerialTxFrameStart, 0x02, 0x00, 0x16, 0x03]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('wrapUsbSerialTxFrame rejects payloads above protocol maximum', () {
|
||||||
|
final payload = Uint8List(usbSerialMaxPayloadLength + 1);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
() => wrapUsbSerialTxFrame(payload),
|
||||||
|
throwsA(
|
||||||
|
isA<ArgumentError>().having(
|
||||||
|
(error) => error.name,
|
||||||
|
'name',
|
||||||
|
'payload.length',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('UsbSerialFrameDecoder buffers partial frames until complete', () {
|
||||||
|
final decoder = UsbSerialFrameDecoder();
|
||||||
|
|
||||||
|
final firstChunk = decoder.ingest(
|
||||||
|
Uint8List.fromList(<int>[usbSerialRxFrameStart, 0x03]),
|
||||||
|
);
|
||||||
|
final secondChunk = decoder.ingest(
|
||||||
|
Uint8List.fromList(<int>[0x00, 0x05, 0x06, 0x07]),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(firstChunk, isEmpty);
|
||||||
|
expect(secondChunk, hasLength(1));
|
||||||
|
expect(secondChunk.single.isRxFrame, isTrue);
|
||||||
|
expect(secondChunk.single.payload, orderedEquals(<int>[0x05, 0x06, 0x07]));
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'UsbSerialFrameDecoder drops leading noise and parses multiple frames',
|
||||||
|
() {
|
||||||
|
final decoder = UsbSerialFrameDecoder();
|
||||||
|
|
||||||
|
final packets = decoder.ingest(
|
||||||
|
Uint8List.fromList(<int>[
|
||||||
|
0x00,
|
||||||
|
0x01,
|
||||||
|
usbSerialRxFrameStart,
|
||||||
|
0x01,
|
||||||
|
0x00,
|
||||||
|
0x55,
|
||||||
|
usbSerialRxFrameStart,
|
||||||
|
0x02,
|
||||||
|
0x00,
|
||||||
|
0x66,
|
||||||
|
0x77,
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(packets, hasLength(2));
|
||||||
|
expect(packets[0].payload, orderedEquals(<int>[0x55]));
|
||||||
|
expect(packets[1].payload, orderedEquals(<int>[0x66, 0x77]));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'UsbSerialFrameDecoder preserves tx packets so caller can ignore them',
|
||||||
|
() {
|
||||||
|
final decoder = UsbSerialFrameDecoder();
|
||||||
|
|
||||||
|
final packets = decoder.ingest(
|
||||||
|
Uint8List.fromList(<int>[
|
||||||
|
usbSerialTxFrameStart,
|
||||||
|
0x01,
|
||||||
|
0x00,
|
||||||
|
0x22,
|
||||||
|
usbSerialRxFrameStart,
|
||||||
|
0x01,
|
||||||
|
0x00,
|
||||||
|
0x33,
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(packets, hasLength(2));
|
||||||
|
expect(packets[0].isRxFrame, isFalse);
|
||||||
|
expect(packets[0].payload, orderedEquals(<int>[0x22]));
|
||||||
|
expect(packets[1].isRxFrame, isTrue);
|
||||||
|
expect(packets[1].payload, orderedEquals(<int>[0x33]));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'UsbSerialFrameDecoder drops oversized frames and resyncs on the next valid packet',
|
||||||
|
() {
|
||||||
|
final decoder = UsbSerialFrameDecoder();
|
||||||
|
|
||||||
|
final packets = decoder.ingest(
|
||||||
|
Uint8List.fromList(<int>[
|
||||||
|
usbSerialRxFrameStart,
|
||||||
|
0xAD,
|
||||||
|
0x00,
|
||||||
|
0x99,
|
||||||
|
usbSerialRxFrameStart,
|
||||||
|
0x01,
|
||||||
|
0x00,
|
||||||
|
0x44,
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(packets, hasLength(1));
|
||||||
|
expect(packets.single.isRxFrame, isTrue);
|
||||||
|
expect(packets.single.payload, orderedEquals(<int>[0x44]));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('UsbSerialFrameDecoder reset clears buffered partial data', () {
|
||||||
|
final decoder = UsbSerialFrameDecoder();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
decoder.ingest(Uint8List.fromList(<int>[usbSerialRxFrameStart, 0x02])),
|
||||||
|
isEmpty,
|
||||||
|
);
|
||||||
|
|
||||||
|
decoder.reset();
|
||||||
|
|
||||||
|
final packets = decoder.ingest(
|
||||||
|
Uint8List.fromList(<int>[usbSerialRxFrameStart, 0x01, 0x00, 0x55]),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(packets, hasLength(1));
|
||||||
|
expect(packets.single.payload, orderedEquals(<int>[0x55]));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('recovers from invalid frame header', () {
|
||||||
|
final decoder = UsbSerialFrameDecoder();
|
||||||
|
|
||||||
|
final packets = decoder.ingest(
|
||||||
|
Uint8List.fromList(<int>[
|
||||||
|
// First, a malformed frame (e.g. from a partial TX echo)
|
||||||
|
usbSerialRxFrameStart,
|
||||||
|
usbSerialTxFrameStart,
|
||||||
|
// Then, a valid frame
|
||||||
|
usbSerialRxFrameStart,
|
||||||
|
0x01,
|
||||||
|
0x00,
|
||||||
|
0x88,
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(packets, hasLength(1));
|
||||||
|
expect(packets.single.isRxFrame, isTrue);
|
||||||
|
expect(packets.single.payload, orderedEquals(<int>[0x88]));
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:meshcore_open/utils/usb_port_labels.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
test('normalizeUsbPortName strips friendly suffix from composite label', () {
|
||||||
|
expect(
|
||||||
|
normalizeUsbPortName(
|
||||||
|
'COM6 - USB Serial Device (COM6) - USB\\VID_2886&PID_1667',
|
||||||
|
),
|
||||||
|
'COM6',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'friendlyUsbPortName returns only description, not hardware_id (3-part label)',
|
||||||
|
() {
|
||||||
|
expect(
|
||||||
|
friendlyUsbPortName(
|
||||||
|
'COM6 - USB Serial Device (COM6) - USB\\VID_2886&PID_1667',
|
||||||
|
),
|
||||||
|
'USB Serial Device (COM6)',
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'friendlyUsbPortName works for macOS-style 3-part label with USB product name',
|
||||||
|
() {
|
||||||
|
expect(
|
||||||
|
friendlyUsbPortName(
|
||||||
|
'/dev/cu.usbmodem1101 - Nordic Semiconductor nRF52 DK - USB VID:PID=1915:520f SNR=ABCDEF',
|
||||||
|
),
|
||||||
|
'Nordic Semiconductor nRF52 DK',
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('friendlyUsbPortName works for Linux-style label', () {
|
||||||
|
expect(
|
||||||
|
friendlyUsbPortName(
|
||||||
|
'/dev/ttyACM0 - RAK4631 - USB VID:PID=239A:8029 SER=xxxxxxxx',
|
||||||
|
),
|
||||||
|
'RAK4631',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('friendlyUsbPortName trims whitespace from label parts', () {
|
||||||
|
expect(
|
||||||
|
friendlyUsbPortName(' /dev/ttyS0 - My Serial Port - n/a '),
|
||||||
|
'My Serial Port',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'friendlyUsbPortName falls back to port name when description is n/a',
|
||||||
|
() {
|
||||||
|
expect(
|
||||||
|
friendlyUsbPortName('/dev/cu.Bluetooth-Incoming-Port - n/a - n/a'),
|
||||||
|
'/dev/cu.Bluetooth-Incoming-Port',
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'friendlyUsbPortName handles 2-part label (no hardware_id) correctly',
|
||||||
|
() {
|
||||||
|
expect(
|
||||||
|
friendlyUsbPortName('COM6 - USB Serial Device (COM6)'),
|
||||||
|
'USB Serial Device (COM6)',
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('describeWebUsbPort uses known VID/PID names when available', () {
|
||||||
|
expect(
|
||||||
|
describeWebUsbPort(
|
||||||
|
vendorId: 0x2886,
|
||||||
|
productId: 0x1667,
|
||||||
|
knownUsbNames: const <String, String>{
|
||||||
|
'2886:1667': 'Seeed Wio Tracker L1',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
'Seeed Wio Tracker L1 (VID:2886 PID:1667)',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('describeWebUsbPort falls back to generic label for unknown device', () {
|
||||||
|
expect(
|
||||||
|
describeWebUsbPort(vendorId: 0x1234, productId: 0x5678),
|
||||||
|
'Web Serial Device (VID:1234 PID:5678)',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('describeWebUsbPort returns chooser label when no usb ids exist', () {
|
||||||
|
expect(
|
||||||
|
describeWebUsbPort(vendorId: null, productId: null),
|
||||||
|
'Choose USB Device',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('describeWebUsbPort uses caller-provided chooser label', () {
|
||||||
|
expect(
|
||||||
|
describeWebUsbPort(
|
||||||
|
vendorId: null,
|
||||||
|
productId: null,
|
||||||
|
requestPortLabel: 'Select a USB device',
|
||||||
|
),
|
||||||
|
'Select a USB device',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildUsbDisplayLabel appends device-reported name when available', () {
|
||||||
|
expect(
|
||||||
|
buildUsbDisplayLabel(
|
||||||
|
basePortLabel: 'Seeed Wio Tracker L1 (VID:2886 PID:1667)',
|
||||||
|
deviceName: 'KD3CGK mesh-utility.org',
|
||||||
|
),
|
||||||
|
'Seeed Wio Tracker L1 (VID:2886 PID:1667) - KD3CGK mesh-utility.org',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildUsbDisplayLabel keeps base label when custom name is blank', () {
|
||||||
|
expect(
|
||||||
|
buildUsbDisplayLabel(
|
||||||
|
basePortLabel: 'Seeed Wio Tracker L1 (VID:2886 PID:1667)',
|
||||||
|
deviceName: ' ',
|
||||||
|
),
|
||||||
|
'Seeed Wio Tracker L1 (VID:2886 PID:1667)',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -89,9 +89,11 @@ endif()
|
|||||||
|
|
||||||
# Copy the native assets provided by the build.dart from all packages.
|
# Copy the native assets provided by the build.dart from all packages.
|
||||||
set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/windows/")
|
set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/windows/")
|
||||||
install(DIRECTORY "${NATIVE_ASSETS_DIR}"
|
if(EXISTS "${NATIVE_ASSETS_DIR}")
|
||||||
DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
|
install(DIRECTORY "${NATIVE_ASSETS_DIR}"
|
||||||
COMPONENT Runtime)
|
DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
|
||||||
|
COMPONENT Runtime)
|
||||||
|
endif()
|
||||||
|
|
||||||
# Fully re-copy the assets directory on each build to avoid having stale files
|
# Fully re-copy the assets directory on each build to avoid having stale files
|
||||||
# from a previous install.
|
# from a previous install.
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
|
|||||||
)
|
)
|
||||||
|
|
||||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||||
|
flserial
|
||||||
flutter_local_notifications_windows
|
flutter_local_notifications_windows
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user