wifi connection established by app, process socket binding, primary os connection untouched

This commit is contained in:
Constantin Leue
2026-07-31 15:25:45 +02:00
parent 222d8743fb
commit 3b3451b8a2
16 changed files with 1188 additions and 89 deletions
+333
View File
@@ -0,0 +1,333 @@
# DMC — Kommunikationsschicht
Erweiterung der bestehenden Flutter-App um die Verbindung zum Fluggerät.
Betrifft **nur** Transport, MSP und die zugehörige Bedienung. Planung, Karte,
Wind und Gelände sind vorhanden und werden nicht berührt.
Stand: nach dem ersten Gerätetest auf Pixel 10a. Die Erkenntnisse daraus sind
in Abschnitt 4 eingearbeitet und haben die ursprüngliche Herangehensweise
korrigiert.
---
## 1. Aufgabe und Aufbau
```
Handy ──WLAN (UDP)──► RadioMaster Pocket (mLRS Tx + Backpack ESP)
│ LoRa 2,4 GHz
Funke ──CRSF (intern)─────────┤
▼
XR1 (mLRS Rx) ──UART/MspX──► FC (iNAV, MSP)
```
RC und MSP teilen sich dieselbe LoRa-Strecke. Die Fernsteuerung läuft parallel
unverändert weiter.
**WLAN über UDP ist der einzige Weg auf dieser Hardware.** Bluetooth scheidet
aus: Die mLRS Wireless Bridge läuft auf dem Backpack-Chip, und ESP8266/ESP8285
sowie die ESP32-Cx-Reihe bieten kein klassisches Bluetooth. Bei
RadioMaster-Modulen ist der Backpack ein ESP8285 — WLAN, sonst nichts. Das mit
„Bluetooth" beworbene Merkmal der Funke betrifft den Haupt-Chip des Moduls
(Simulator-Joystick), nicht die Bridge.
Später möglich: USB-Seriell als kabelgebundene Rückfallebene.
---
## 2. Transportschnittstelle
Zuerst bauen. Alles Weitere wird ausschließlich dagegen programmiert.
```dart
enum LinkState { disconnected, connecting, connected, error }
abstract class LinkTransport {
Stream<Uint8List> get incoming;
Stream<LinkState> get state;
Future<void> send(Uint8List data);
Future<void> connect();
Future<void> disconnect();
void dispose();
}
```
Implementierungen: `UdpTransport` (aktuell), `LoopbackTransport` (Tests ohne
Hardware), später `UsbSerialTransport`.
**Regel, als Abnahmekriterium prüfbar:** kein Netzwerk- oder Plattform-Import
außerhalb von `transport/`. Der MSP-Code kennt nur `LinkTransport`.
---
## 3. UDP-Transport
Die Bridge erzeugt `mLRS-xxxx AP UDP`, ohne Passwort, Port **14550**.
### Das Umsetzungsmuster
Kite GC und Mission Planner kommen unabhängig voneinander zum selben Ergebnis.
Beide Punkte sind nicht optional — ohne sie empfängt die App **kein einziges Byte**:
**1. Lokal auf denselben Port binden, den man ansprechen will (14550).**
Nicht auf einen flüchtigen Port. WLAN-Telemetriebrücken senden an einen festen
Port; ein zufälliger lokaler Port verwirft das stillschweigend. Ist 14550 belegt,
ausweichen, aber protokollieren.
**2. Kein `connect()`. Gegenstelle aus dem Verkehr lernen.**
Ziel mit der konfigurierten Adresse vorbelegen (damit das erste Paket rausgeht),
danach auf die Quelladresse umstellen, von der tatsächlich Daten kommen. Deckt
Lausch-, Client- und Broadcast-Betrieb gleichermaßen ab. Mission Planner führt
zusätzlich eine Liste aller Gegenstellen — für uns nicht nötig, aber der Grund
ist bedenkenswert: Es kann mehr als eine geben.
**Zieladresse niemals fest eincodieren.** Vorbelegung aus der Gateway-Adresse
des angeforderten Netzes, danach gilt die gelernte Adresse.
**Lesezeitgrenze kurz halten** (etwa 50 ms). Sie begrenzt, wie lange ein
ausgehender Befehl wartet, bis die laufende Leseoperation zurückkehrt.
---
## 4. Verbindungsaufbau — der kritische Teil
### Was der Gerätetest ergeben hat
Getestet auf Pixel 10a: Verbindung mit dem mLRS-Netz über die **Systemeinstellungen**,
danach in der App verbunden.
- UDP funktionierte, empfangene Pakete zählten hoch ✓
- **Kartenkacheln und Wetterabruf fielen aus** ✗
Ursache: Bei Verbindung über die Systemeinstellungen wird das mLRS-Netz zur
**Standardroute des Geräts** — für alles. Dass es kein Internet hat, hindert
Android nicht daran; bestätigt der Nutzer den „kein Internet"-Hinweis, bleibt
das Netz aktiv und bevorzugt.
### Die Lösung: Verbindung ausschließlich aus der App
`WifiNetworkSpecifier` ist genau dafür gebaut. Aus der Android-Referenz:
Diese Spezifizierer können ausschließlich für ein lokales WLAN ohne
Internet-Fähigkeit verwendet werden; das Gerät wechselt seine Standardroute
daher **nicht** auf WLAN, wenn andere Übertragungswege wie Mobilfunk verfügbar
sind.
Das ist keine Nebenwirkung, sondern der Zweck der Schnittstelle.
```kotlin
val specifier = WifiNetworkSpecifier.Builder()
.setSsidPattern(PatternMatcher("mLRS-", PatternMatcher.PATTERN_PREFIX))
.build()
val request = NetworkRequest.Builder()
.addTransportType(NetworkCapabilities.TRANSPORT_WIFI)
.removeCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.setNetworkSpecifier(specifier)
.build()
connectivityManager.requestNetwork(request, callback)
```
- **Präfix-Muster** statt fester SSID, weil die SSID eine Zufallszahl aus der
MAC-Adresse enthält. Der Systemdialog zeigt dann nur die Bridge, der Nutzer
bestätigt einmal.
- **`removeCapability(NET_CAPABILITY_INTERNET)` ist zwingend** — ohne sie kommt
die Anfrage nie zustande, weil das Netz diese Fähigkeit nicht besitzt.
- Braucht `CHANGE_NETWORK_STATE` im Manifest.
- Die Verbindung ist an die App gebunden und endet, wenn der Request
freigegeben wird. Für eine Bodenstation eher praktisch.
### Bindung ist damit verpflichtend
Weil das angeforderte Netz **nicht** die Standardroute ist, erreicht ein
ungebundener Socket die Bridge nicht. Ablauf im `onAvailable`-Rückruf:
1. Prozess an das erhaltene `Network` binden
2. UDP-Socket in Dart erzeugen (`RawDatagramSocket.bind`)
3. Prozessbindung **sofort wieder lösen**
Android bindet laut Dokumentation alle **künftig erzeugten** Sockets an das
gesetzte Netz, und die Bindung haftet am Socket. Der UDP-Socket bleibt daher am
WLAN, während spätere Verbindungen — Karten, Wetter — wieder über Mobilfunk
laufen. Plattform-Kanal mit zwei Methoden (`bindToNetwork`, `unbind`).
**Noch unbestätigt:** ob die Prozessmarkierung auch für Sockets greift, die Dart
auf eigenen I/O-Threads erzeugt. Der Mechanismus spricht dafür, dokumentiert ist
es nicht. Deshalb der Abnahmetest in Abschnitt 8, der genau das prüft.
**Falls es nicht greift:** UDP-Socket nativ in Kotlin öffnen, per
`Network.bindSocket(DatagramSocket)` einzeln binden, Daten über einen
EventChannel nach Dart. Android empfiehlt die Socket-genaue Bindung ohnehin
gegenüber der Prozessbindung; aus Dart ist sie nur nicht erreichbar, weil weder
Socket-Objekt noch Deskriptor herausgegeben werden.
### Zwei Fallstricke beim Testen
**Das mLRS-Netz muss in den Systemeinstellungen entfernt werden.** Bleibt es
gespeichert, verbindet sich das Telefon automatisch wieder darauf, es wird
erneut zur Standardroute, und der Fehler tritt auf, obwohl der Code stimmt.
**Der Test braucht aktive mobile Daten.** Ohne zweites Netz funktioniert alles
auch ohne Bindung, und man misst nichts.
### Gleichzeitige Nutzung prüfen
Ab Android 12 können Geräte parallel mit einem lokalen Gerät und dem primären
Netz verbunden bleiben. Abfragbar über
`WifiManager.isStaConcurrencyForLocalOnlyConnectionsSupported()`. Wird das
unterstützt (auf aktuellen Pixel-Geräten der Fall), bleibt sogar das heimische
WLAN nutzbar. Nicht voraussetzen, aber abfragen und im Verbindungsprotokoll
festhalten — es erklärt Verhaltensunterschiede zwischen Geräten.
---
## 5. Berechtigungen
```xml
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE"/>
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE"/>
<uses-permission android:name="android.permission.WAKE_LOCK"/>
```
`CHANGE_NETWORK_STATE` für `requestNetwork` — die fehlt in Mission Planner,
weshalb dieser Weg dort gar nicht möglich ist.
`WAKE_LOCK` ist im Flug nötig, sonst schläft das Gerät während der Telemetrie ein.
`CHANGE_WIFI_MULTICAST_STATE` erst ergänzen, wenn sich zeigt, dass die Bridge
per Broadcast sendet — dann wird zusätzlich ein MulticastLock gebraucht.
`android:usesCleartextTraffic="true"` nur nötig, falls die Bridge je über HTTP
angesprochen wird. Für UDP nicht erforderlich.
---
## 6. MSP
### Rahmenformat
iNAV erwartet **MSPv2**. MSPv1 reicht nicht.
```
'$' 'X' <dir> <flag:1> <function:2 LE> <payloadSize:2 LE> <payload> <crc:1>
dir: '<' Anfrage, '>' Antwort, '!' Fehler
crc: CRC8 DVB-S2 über flag..payload
```
Zu bauen: Kodierer, **Streaming-Dekodierer als Zustandsautomat** (der Byte-Strom
kommt fragmentiert an — mehrere Pakete in einem Lesevorgang, ein Paket über
mehrere Lesevorgänge), CRC-Prüfung, Verwerfen fehlerhafter Rahmen ohne den
Strom zu verlieren.
### Polling
MSP ist Frage und Antwort — von selbst kommt nichts. Eigener Takt:
| Zweck | Rate |
|---|---|
| Position, Lage, Höhe | 5–10 Hz |
| Status, Flugmodus | 2 Hz |
| Spannung, Strom | 1 Hz |
Immer nur **eine offene Anfrage**, mit Zeitgrenze (etwa 500 ms) und Wiederholung.
Sonst laufen bei schwacher Strecke die Antworten durcheinander.
**Befehlsnummern nicht aus dem Gedächtnis übernehmen.** Gegen `msp_protocol.h`
und `msp_protocol_v2_inav.h` im iNAV-Repository prüfen und als Konstanten an
einer Stelle sammeln.
### Reihenfolge
1. Rahmen kodieren/dekodieren, Tests gegen aufgezeichnete Bytefolgen
2. Telemetrie lesen, in die vorhandenen `LIVE`-Zeilen einspeisen
3. Erst danach Missionsupload
---
## 7. Gegenstelle (keine App-Arbeit, aber Voraussetzung)
Damit beim Testen klar ist, woran es liegt, wenn nichts ankommt.
**mLRS Rx:** `Rx Ser Link Mode` = `mspX`, `Rx Ser Baudrate` = 115200,
`Rx Snd RcChannel` = `rc override` oder `rc channels`.
**mLRS Tx:** `Tx Ch Source` = `crsf`. In EdgeTX internes Modul auf CRSF mit
**400 k** Baud (nicht 5,25 M wie bei ELRS).
**iNAV:** MSP auf dem UART aktivieren, an dem der Empfänger hängt (UART2
empfohlen), **„Serial RX" auf diesem Port aus**. Baudrate ≥ 115200 — darunter
gehen Nachrichten verloren. Receiver-Tab: Receiver Mode `MSP`, RSSI Source `MSP`.
Bekannte Grenze: Im 2,4-GHz-FLRC-Modus ist die RC-Rate über MSP auf 37 Hz
begrenzt. Für eine autonome Fixed-Wing unkritisch.
---
## 8. Bedienung
### Fly-Modus
Beim Wechsel: Netz anfordern, nach `onAvailable` Socket erzeugen und binden,
Polling starten. Beim Verlassen Socket schließen und den Netz-Request freigeben.
**Telemetriealter dauerhaft sichtbar**, nicht nur ein Verbindungssymbol. Im Feld
ist das der einzige verlässliche Hinweis, ob die Werte noch stimmen. Vorschlag:
grün < 2 s, bernstein < 5 s, rot darüber.
Sicherheitsrelevante Anzeigen bei ausbleibender Telemetrie kennzeichnen,
**nicht** den letzten Wert stehenlassen.
Wiederverbinden mit ansteigender Wartezeit (1, 2, 4 … max. 30 s). Nach
Netzabriss den Socket verwerfen und samt Bindungsfenster neu erzeugen — laut
Dokumentation hören so gebundene Sockets bei Netztrennung bewusst auf zu
funktionieren.
### Einstellungen, Abschnitt „Verbindung"
- **Art:** WLAN · USB (später)
- **SSID-Präfix:** Vorgabe `mLRS-`, überschreibbar
- **Port:** Vorgabe 14550, überschreibbar
- **Beim Wechsel in Fly automatisch verbinden:** an/aus, Standard an
- **Verbindungsprotokoll:** Textansicht der letzten Ereignisse — Netz angefordert,
verfügbar, Socket gebunden, gelernte Gegenstelle, Paketzähler, Fehler. Dazu
einmalig das Ergebnis von `isStaConcurrencyForLocalOnlyConnectionsSupported()`.
Spart im Feld viel Ratearbeit.
- **Hinweistext:** dass das mLRS-Netz nicht in den Systemeinstellungen
gespeichert sein soll.
---
## 9. Abnahmekriterien
- [ ] Verbindung erfolgt ausschließlich über `WifiNetworkSpecifier` aus der App,
nicht über die Systemeinstellungen
- [ ] **Während aktiver Telemetrie funktionieren Kartenkacheln und Wetterabruf
weiter** — das ist der eigentliche Prüfstein, mit aktiven mobilen Daten testen
- [ ] Socket bindet auf 14550; ist der Port belegt, wird ausgewichen und geloggt
- [ ] Gegenstelle wird aus dem ersten eingehenden Paket gelernt und im Protokoll
angezeigt
- [ ] Keine feste IP-Adresse im Quelltext
- [ ] MSP-Dekodierer verarbeitet beliebig fragmentierte Eingaben korrekt
(Test: dieselbe Bytefolge in Stücken von 1, 7 und 512 Byte)
- [ ] Fehlerhafte Rahmen werden verworfen, ohne den Strom zu verlieren
- [ ] Nur eine offene Anfrage gleichzeitig; Zeitüberschreitung führt zu
Wiederholung, nicht zum Hängen
- [ ] Telemetriealter immer sichtbar und farblich abgestuft
- [ ] Verbindungsabriss → Socket wird verworfen und mit Bindungsfenster neu
erzeugt, Zustand sichtbar
- [ ] Verlassen des Fly-Modus gibt den Netz-Request frei
- [ ] Kein Netzwerk-Import außerhalb von `transport/`
---
## 10. Hinweise
- **Ohne Hardware entwickelbar:** `LoopbackTransport` plus aufgezeichnete
MSP-Antworten. Erst danach am echten Gerät gegenprüfen.
- Die App steuert das Fluggerät nicht. RC bleibt vollständig bei der
Fernsteuerung; MSP dient dem Lesen und dem Übertragen von Missionen.
- Referenzen zum Nachschlagen: Kite GC `src-tauri/src/transport/udp.rs`
(der Kommentar erklärt das Portbindungs- und Peer-Lernen-Problem ausführlich),
Mission Planner `ExtLibs/Comms/CommsUdpSerial.cs` (dieselbe Lösung in C#).
Beide lösen das Routing-Problem **nicht** — dort verbindet sich der Nutzer
über die Systemeinstellungen, mit demselben Nebeneffekt, den unser Test gezeigt hat.
+5 -1
View File
@@ -19,7 +19,11 @@ android {
applicationId = "com.dmc.dmc_app"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion
// minSdk 29 (statt Flutter-Default 24): WifiNetworkSpecifier/
// ConnectivityManager.requestNetwork() fuer den app-initiierten
// mLRS-Verbindungsaufbau (Doku Kommunikationsschicht v2, Abschnitt 4)
// gibt es erst ab Android 10.
minSdk = 29
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
@@ -5,6 +5,14 @@
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE"/>
<!-- Fuer WifiNetworkSpecifier/ConnectivityManager.requestNetwork() (Doku
Kommunikationsschicht v2, Abschnitt 4/5) - die App baut die
WLAN-Verbindung zur mLRS-Bruecke selbst auf, statt sich auf eine
manuelle Verbindung ueber die Systemeinstellungen zu verlassen (das
wuerde das mLRS-Netz zur Standardroute machen und Kartenkacheln/
Wetter kappen). Fehlt Mission Planner, weshalb dieser Weg dort nicht
moeglich ist. -->
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE"/>
<!-- Haelt das Geraet waehrend des Flugs wach, sonst schlaeft es ein und
die Telemetrie reisst ab (Doku Kommunikationsschicht Abschnitt 3).
CHANGE_WIFI_MULTICAST_STATE erst ergaenzen, wenn sich zeigt, dass die
@@ -1,5 +1,22 @@
package com.dmc.dmc_app
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodChannel
class MainActivity : FlutterActivity()
class MainActivity : FlutterActivity() {
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
val plugin = MlrsNetworkPlugin(applicationContext)
MethodChannel(
flutterEngine.dartExecutor.binaryMessenger,
MlrsNetworkPlugin.METHOD_CHANNEL,
).setMethodCallHandler(plugin)
EventChannel(
flutterEngine.dartExecutor.binaryMessenger,
MlrsNetworkPlugin.EVENT_CHANNEL,
).setStreamHandler(plugin)
}
}
@@ -0,0 +1,164 @@
package com.dmc.dmc_app
import android.content.Context
import android.net.ConnectivityManager
import android.net.LinkProperties
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import android.net.wifi.WifiManager
import android.net.wifi.WifiNetworkSpecifier
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.os.PatternMatcher
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
/**
* Verbindungsaufbau zur mLRS-WLAN-Bruecke ausschliesslich aus der App heraus
* (Doku Kommunikationsschicht v2, Abschnitt 4): das WLAN darf nicht die
* Standardroute des Geraets werden (sonst fallen Kartenkacheln/Wetter aus),
* daher `WifiNetworkSpecifier` statt Verbindung ueber die Systemeinstellungen.
*
* Ablauf: [requestNetwork] loest `ConnectivityManager.requestNetwork()` aus,
* das System zeigt dabei automatisch einen Bestaetigungsdialog fuer die
* passende SSID. `onAvailable`/`onUnavailable`/`onLost` werden ueber den
* EventChannel nach Dart gemeldet, das den restlichen Ablauf (Socket binden,
* lauschen, Fehleranzeige) uebernimmt.
*
* [bindProcessToNetwork]/[unbindProcessFromNetwork] setzen/loeschen die
* Prozessbindung fuer das kurze Fenster, in dem Dart den UDP-Socket erzeugt -
* siehe UdpTransport.connect() fuer die Gegenseite dieses Musters.
*/
class MlrsNetworkPlugin(
private val context: Context,
) : MethodChannel.MethodCallHandler, EventChannel.StreamHandler {
companion object {
const val METHOD_CHANNEL = "com.dmc.dmc_app/mlrs_network"
const val EVENT_CHANNEL = "com.dmc.dmc_app/mlrs_network_events"
}
private val connectivityManager =
context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
// NetworkCallback-Methoden laufen ohne expliziten Handler auf einem
// internen ConnectivityThread, nicht auf dem Main-Thread - Flutters
// EventChannel.EventSink.success() verlangt aber @UiThread (schlaegt
// sonst mit "Methods marked with @UiThread must be executed on the main
// thread" fehl). Deshalb Callback-Dispatch explizit auf den Main-Looper
// legen.
private val mainHandler = Handler(Looper.getMainLooper())
private var eventSink: EventChannel.EventSink? = null
private var networkCallback: ConnectivityManager.NetworkCallback? = null
private var currentNetwork: Network? = null
override fun onListen(arguments: Any?, sink: EventChannel.EventSink?) {
eventSink = sink
}
override fun onCancel(arguments: Any?) {
eventSink = null
}
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"requestNetwork" -> {
val ssidPrefix = call.argument<String>("ssidPrefix")
if (ssidPrefix.isNullOrEmpty()) {
result.error("invalid_argument", "ssidPrefix darf nicht leer sein", null)
return
}
requestNetwork(ssidPrefix)
result.success(null)
}
"releaseNetwork" -> {
releaseNetwork()
result.success(null)
}
"bindProcessToNetwork" -> {
val network = currentNetwork
if (network == null) {
result.success(false)
} else {
result.success(connectivityManager.bindProcessToNetwork(network))
}
}
"unbindProcessFromNetwork" -> {
connectivityManager.bindProcessToNetwork(null)
result.success(null)
}
"isStaConcurrencySupported" -> {
result.success(isStaConcurrencySupported())
}
else -> result.notImplemented()
}
}
private fun requestNetwork(ssidPrefix: String) {
// Vorherigen Callback zuerst abmelden - sonst haeuften sich bei
// mehrfachem Connect-Versuch mehrere registrierte Callbacks an.
releaseNetwork()
val specifier = WifiNetworkSpecifier.Builder()
.setSsidPattern(PatternMatcher(ssidPrefix, PatternMatcher.PATTERN_PREFIX))
.build()
val request = NetworkRequest.Builder()
.addTransportType(NetworkCapabilities.TRANSPORT_WIFI)
.removeCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.setNetworkSpecifier(specifier)
.build()
val callback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
currentNetwork = network
val gateway = gatewayAddressOf(connectivityManager.getLinkProperties(network))
eventSink?.success(
mapOf("type" to "available", "gatewayAddress" to gateway),
)
}
override fun onUnavailable() {
currentNetwork = null
eventSink?.success(mapOf("type" to "unavailable"))
}
override fun onLost(network: Network) {
currentNetwork = null
eventSink?.success(mapOf("type" to "lost"))
}
}
networkCallback = callback
connectivityManager.requestNetwork(request, callback, mainHandler)
}
private fun releaseNetwork() {
networkCallback?.let {
try {
connectivityManager.unregisterNetworkCallback(it)
} catch (_: IllegalArgumentException) {
// Callback war bereits abgemeldet (z.B. durch onUnavailable
// gefolgt von einem expliziten releaseNetwork()) - unkritisch.
}
}
networkCallback = null
currentNetwork = null
}
private fun gatewayAddressOf(linkProperties: LinkProperties?): String? {
val route = linkProperties?.routes?.firstOrNull { it.isDefaultRoute }
return route?.gateway?.hostAddress
}
private fun isStaConcurrencySupported(): Boolean? {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return null
val wifiManager =
context.applicationContext.getSystemService(Context.WIFI_SERVICE) as? WifiManager
?: return null
return wifiManager.isStaConcurrencyForLocalOnlyConnectionsSupported
}
}
+13 -1
View File
@@ -78,6 +78,12 @@ class AppSettingsTable extends Table {
IntColumn get connectionWifiPort =>
integer().withDefault(const Constant(kDefaultWifiPort))();
/// SSID-Praefix fuer den app-initiierten WLAN-Verbindungsaufbau per
/// `WifiNetworkSpecifier` (Doku Kommunikationsschicht v2 Abschnitt 4/8),
/// Standard "mLRS-", ueberschreibbar.
TextColumn get connectionWifiSsidPrefix =>
text().withDefault(const Constant(kDefaultWifiSsidPrefix))();
@override
Set<Column> get primaryKey => {id};
}
@@ -89,7 +95,7 @@ class AppDatabase extends _$AppDatabase {
AppDatabase.forTesting(super.executor);
@override
int get schemaVersion => 4;
int get schemaVersion => 5;
@override
MigrationStrategy get migration => MigrationStrategy(
@@ -121,6 +127,12 @@ class AppDatabase extends _$AppDatabase {
appSettingsTable.connectionWifiPort,
);
}
if (from < 5) {
await m.addColumn(
appSettingsTable,
appSettingsTable.connectionWifiSsidPrefix,
);
}
},
);
+93 -6
View File
@@ -1358,7 +1358,7 @@ class $AppSettingsTableTable extends AppSettingsTable
false,
type: DriftSqlType.string,
requiredDuringInsert: false,
defaultValue: const Constant('bluetooth'),
defaultValue: const Constant('wifi'),
);
static const VerificationMeta _connectionWifiHostMeta =
const VerificationMeta('connectionWifiHost');
@@ -1382,6 +1382,18 @@ class $AppSettingsTableTable extends AppSettingsTable
requiredDuringInsert: false,
defaultValue: const Constant(kDefaultWifiPort),
);
static const VerificationMeta _connectionWifiSsidPrefixMeta =
const VerificationMeta('connectionWifiSsidPrefix');
@override
late final GeneratedColumn<String> connectionWifiSsidPrefix =
GeneratedColumn<String>(
'connection_wifi_ssid_prefix',
aliasedName,
false,
type: DriftSqlType.string,
requiredDuringInsert: false,
defaultValue: const Constant(kDefaultWifiSsidPrefix),
);
@override
List<GeneratedColumn> get $columns => [
id,
@@ -1392,6 +1404,7 @@ class $AppSettingsTableTable extends AppSettingsTable
connectionType,
connectionWifiHost,
connectionWifiPort,
connectionWifiSsidPrefix,
];
@override
String get aliasedName => _alias ?? actualTableName;
@@ -1471,6 +1484,15 @@ class $AppSettingsTableTable extends AppSettingsTable
),
);
}
if (data.containsKey('connection_wifi_ssid_prefix')) {
context.handle(
_connectionWifiSsidPrefixMeta,
connectionWifiSsidPrefix.isAcceptableOrUnknown(
data['connection_wifi_ssid_prefix']!,
_connectionWifiSsidPrefixMeta,
),
);
}
return context;
}
@@ -1512,6 +1534,10 @@ class $AppSettingsTableTable extends AppSettingsTable
DriftSqlType.int,
data['${effectivePrefix}connection_wifi_port'],
)!,
connectionWifiSsidPrefix: attachedDatabase.typeMapping.read(
DriftSqlType.string,
data['${effectivePrefix}connection_wifi_ssid_prefix'],
)!,
);
}
@@ -1536,8 +1562,8 @@ class AppSettingsTableData extends DataClass
final bool connectionAutoConnect;
/// Gewaehlte Verbindungsart (Doku 7: "Art: WLAN * USB (spaeter)"),
/// gespeichert als [ConnectionType.name]. Standard bleibt Bluetooth, damit
/// bestehende Installationen ihr bisheriges Verhalten behalten.
/// gespeichert als [ConnectionType.name]. App-Default ist WLAN; die
/// Nutzerauswahl wird danach dauerhaft gemerkt.
final String connectionType;
/// Vom Nutzer eingetragene Ziel-IP der mLRS-WLAN-Bruecke (Doku 3:
@@ -1547,6 +1573,11 @@ class AppSettingsTableData extends DataClass
/// Lokaler UDP-Port fuer die mLRS-WLAN-Bruecke (Doku 3), Standard 14550.
final int connectionWifiPort;
/// SSID-Praefix fuer den app-initiierten WLAN-Verbindungsaufbau per
/// `WifiNetworkSpecifier` (Doku Kommunikationsschicht v2 Abschnitt 4/8),
/// Standard "mLRS-", ueberschreibbar.
final String connectionWifiSsidPrefix;
const AppSettingsTableData({
required this.id,
this.activeDroneProfileId,
@@ -1556,6 +1587,7 @@ class AppSettingsTableData extends DataClass
required this.connectionType,
this.connectionWifiHost,
required this.connectionWifiPort,
required this.connectionWifiSsidPrefix,
});
@override
Map<String, Expression> toColumns(bool nullToAbsent) {
@@ -1578,6 +1610,9 @@ class AppSettingsTableData extends DataClass
map['connection_wifi_host'] = Variable<String>(connectionWifiHost);
}
map['connection_wifi_port'] = Variable<int>(connectionWifiPort);
map['connection_wifi_ssid_prefix'] = Variable<String>(
connectionWifiSsidPrefix,
);
return map;
}
@@ -1599,6 +1634,7 @@ class AppSettingsTableData extends DataClass
? const Value.absent()
: Value(connectionWifiHost),
connectionWifiPort: Value(connectionWifiPort),
connectionWifiSsidPrefix: Value(connectionWifiSsidPrefix),
);
}
@@ -1624,6 +1660,9 @@ class AppSettingsTableData extends DataClass
json['connectionWifiHost'],
),
connectionWifiPort: serializer.fromJson<int>(json['connectionWifiPort']),
connectionWifiSsidPrefix: serializer.fromJson<String>(
json['connectionWifiSsidPrefix'],
),
);
}
@override
@@ -1640,6 +1679,9 @@ class AppSettingsTableData extends DataClass
'connectionType': serializer.toJson<String>(connectionType),
'connectionWifiHost': serializer.toJson<String?>(connectionWifiHost),
'connectionWifiPort': serializer.toJson<int>(connectionWifiPort),
'connectionWifiSsidPrefix': serializer.toJson<String>(
connectionWifiSsidPrefix,
),
};
}
@@ -1652,6 +1694,7 @@ class AppSettingsTableData extends DataClass
String? connectionType,
Value<String?> connectionWifiHost = const Value.absent(),
int? connectionWifiPort,
String? connectionWifiSsidPrefix,
}) => AppSettingsTableData(
id: id ?? this.id,
activeDroneProfileId: activeDroneProfileId.present
@@ -1669,6 +1712,8 @@ class AppSettingsTableData extends DataClass
? connectionWifiHost.value
: this.connectionWifiHost,
connectionWifiPort: connectionWifiPort ?? this.connectionWifiPort,
connectionWifiSsidPrefix:
connectionWifiSsidPrefix ?? this.connectionWifiSsidPrefix,
);
AppSettingsTableData copyWithCompanion(AppSettingsTableCompanion data) {
return AppSettingsTableData(
@@ -1694,6 +1739,9 @@ class AppSettingsTableData extends DataClass
connectionWifiPort: data.connectionWifiPort.present
? data.connectionWifiPort.value
: this.connectionWifiPort,
connectionWifiSsidPrefix: data.connectionWifiSsidPrefix.present
? data.connectionWifiSsidPrefix.value
: this.connectionWifiSsidPrefix,
);
}
@@ -1707,7 +1755,8 @@ class AppSettingsTableData extends DataClass
..write('connectionAutoConnect: $connectionAutoConnect, ')
..write('connectionType: $connectionType, ')
..write('connectionWifiHost: $connectionWifiHost, ')
..write('connectionWifiPort: $connectionWifiPort')
..write('connectionWifiPort: $connectionWifiPort, ')
..write('connectionWifiSsidPrefix: $connectionWifiSsidPrefix')
..write(')'))
.toString();
}
@@ -1722,6 +1771,7 @@ class AppSettingsTableData extends DataClass
connectionType,
connectionWifiHost,
connectionWifiPort,
connectionWifiSsidPrefix,
);
@override
bool operator ==(Object other) =>
@@ -1734,7 +1784,8 @@ class AppSettingsTableData extends DataClass
other.connectionAutoConnect == this.connectionAutoConnect &&
other.connectionType == this.connectionType &&
other.connectionWifiHost == this.connectionWifiHost &&
other.connectionWifiPort == this.connectionWifiPort);
other.connectionWifiPort == this.connectionWifiPort &&
other.connectionWifiSsidPrefix == this.connectionWifiSsidPrefix);
}
class AppSettingsTableCompanion extends UpdateCompanion<AppSettingsTableData> {
@@ -1746,6 +1797,7 @@ class AppSettingsTableCompanion extends UpdateCompanion<AppSettingsTableData> {
final Value<String> connectionType;
final Value<String?> connectionWifiHost;
final Value<int> connectionWifiPort;
final Value<String> connectionWifiSsidPrefix;
const AppSettingsTableCompanion({
this.id = const Value.absent(),
this.activeDroneProfileId = const Value.absent(),
@@ -1755,6 +1807,7 @@ class AppSettingsTableCompanion extends UpdateCompanion<AppSettingsTableData> {
this.connectionType = const Value.absent(),
this.connectionWifiHost = const Value.absent(),
this.connectionWifiPort = const Value.absent(),
this.connectionWifiSsidPrefix = const Value.absent(),
});
AppSettingsTableCompanion.insert({
this.id = const Value.absent(),
@@ -1765,6 +1818,7 @@ class AppSettingsTableCompanion extends UpdateCompanion<AppSettingsTableData> {
this.connectionType = const Value.absent(),
this.connectionWifiHost = const Value.absent(),
this.connectionWifiPort = const Value.absent(),
this.connectionWifiSsidPrefix = const Value.absent(),
});
static Insertable<AppSettingsTableData> custom({
Expression<int>? id,
@@ -1775,6 +1829,7 @@ class AppSettingsTableCompanion extends UpdateCompanion<AppSettingsTableData> {
Expression<String>? connectionType,
Expression<String>? connectionWifiHost,
Expression<int>? connectionWifiPort,
Expression<String>? connectionWifiSsidPrefix,
}) {
return RawValuesInsertable({
if (id != null) 'id': id,
@@ -1790,6 +1845,8 @@ class AppSettingsTableCompanion extends UpdateCompanion<AppSettingsTableData> {
'connection_wifi_host': connectionWifiHost,
if (connectionWifiPort != null)
'connection_wifi_port': connectionWifiPort,
if (connectionWifiSsidPrefix != null)
'connection_wifi_ssid_prefix': connectionWifiSsidPrefix,
});
}
@@ -1802,6 +1859,7 @@ class AppSettingsTableCompanion extends UpdateCompanion<AppSettingsTableData> {
Value<String>? connectionType,
Value<String?>? connectionWifiHost,
Value<int>? connectionWifiPort,
Value<String>? connectionWifiSsidPrefix,
}) {
return AppSettingsTableCompanion(
id: id ?? this.id,
@@ -1814,6 +1872,8 @@ class AppSettingsTableCompanion extends UpdateCompanion<AppSettingsTableData> {
connectionType: connectionType ?? this.connectionType,
connectionWifiHost: connectionWifiHost ?? this.connectionWifiHost,
connectionWifiPort: connectionWifiPort ?? this.connectionWifiPort,
connectionWifiSsidPrefix:
connectionWifiSsidPrefix ?? this.connectionWifiSsidPrefix,
);
}
@@ -1850,6 +1910,11 @@ class AppSettingsTableCompanion extends UpdateCompanion<AppSettingsTableData> {
if (connectionWifiPort.present) {
map['connection_wifi_port'] = Variable<int>(connectionWifiPort.value);
}
if (connectionWifiSsidPrefix.present) {
map['connection_wifi_ssid_prefix'] = Variable<String>(
connectionWifiSsidPrefix.value,
);
}
return map;
}
@@ -1863,7 +1928,8 @@ class AppSettingsTableCompanion extends UpdateCompanion<AppSettingsTableData> {
..write('connectionAutoConnect: $connectionAutoConnect, ')
..write('connectionType: $connectionType, ')
..write('connectionWifiHost: $connectionWifiHost, ')
..write('connectionWifiPort: $connectionWifiPort')
..write('connectionWifiPort: $connectionWifiPort, ')
..write('connectionWifiSsidPrefix: $connectionWifiSsidPrefix')
..write(')'))
.toString();
}
@@ -2507,6 +2573,7 @@ typedef $$AppSettingsTableTableCreateCompanionBuilder =
Value<String> connectionType,
Value<String?> connectionWifiHost,
Value<int> connectionWifiPort,
Value<String> connectionWifiSsidPrefix,
});
typedef $$AppSettingsTableTableUpdateCompanionBuilder =
AppSettingsTableCompanion Function({
@@ -2518,6 +2585,7 @@ typedef $$AppSettingsTableTableUpdateCompanionBuilder =
Value<String> connectionType,
Value<String?> connectionWifiHost,
Value<int> connectionWifiPort,
Value<String> connectionWifiSsidPrefix,
});
class $$AppSettingsTableTableFilterComposer
@@ -2568,6 +2636,11 @@ class $$AppSettingsTableTableFilterComposer
column: $table.connectionWifiPort,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<String> get connectionWifiSsidPrefix => $composableBuilder(
column: $table.connectionWifiSsidPrefix,
builder: (column) => ColumnFilters(column),
);
}
class $$AppSettingsTableTableOrderingComposer
@@ -2618,6 +2691,11 @@ class $$AppSettingsTableTableOrderingComposer
column: $table.connectionWifiPort,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<String> get connectionWifiSsidPrefix => $composableBuilder(
column: $table.connectionWifiSsidPrefix,
builder: (column) => ColumnOrderings(column),
);
}
class $$AppSettingsTableTableAnnotationComposer
@@ -2666,6 +2744,11 @@ class $$AppSettingsTableTableAnnotationComposer
column: $table.connectionWifiPort,
builder: (column) => column,
);
GeneratedColumn<String> get connectionWifiSsidPrefix => $composableBuilder(
column: $table.connectionWifiSsidPrefix,
builder: (column) => column,
);
}
class $$AppSettingsTableTableTableManager
@@ -2713,6 +2796,7 @@ class $$AppSettingsTableTableTableManager
Value<String> connectionType = const Value.absent(),
Value<String?> connectionWifiHost = const Value.absent(),
Value<int> connectionWifiPort = const Value.absent(),
Value<String> connectionWifiSsidPrefix = const Value.absent(),
}) => AppSettingsTableCompanion(
id: id,
activeDroneProfileId: activeDroneProfileId,
@@ -2722,6 +2806,7 @@ class $$AppSettingsTableTableTableManager
connectionType: connectionType,
connectionWifiHost: connectionWifiHost,
connectionWifiPort: connectionWifiPort,
connectionWifiSsidPrefix: connectionWifiSsidPrefix,
),
createCompanionCallback:
({
@@ -2733,6 +2818,7 @@ class $$AppSettingsTableTableTableManager
Value<String> connectionType = const Value.absent(),
Value<String?> connectionWifiHost = const Value.absent(),
Value<int> connectionWifiPort = const Value.absent(),
Value<String> connectionWifiSsidPrefix = const Value.absent(),
}) => AppSettingsTableCompanion.insert(
id: id,
activeDroneProfileId: activeDroneProfileId,
@@ -2742,6 +2828,7 @@ class $$AppSettingsTableTableTableManager
connectionType: connectionType,
connectionWifiHost: connectionWifiHost,
connectionWifiPort: connectionWifiPort,
connectionWifiSsidPrefix: connectionWifiSsidPrefix,
),
withReferenceMapper: (p0) => p0
.map((e) => (e.readTable(table), BaseReferences(db, table, e)))
@@ -22,6 +22,7 @@ class AppSettingsRepository {
ConnectionType connectionType,
String? connectionWifiHost,
int connectionWifiPort,
String connectionWifiSsidPrefix,
})> load() async {
final row = await (_db.select(_db.appSettingsTable)
..where((s) => s.id.equals(_rowId)))
@@ -37,6 +38,8 @@ class AppSettingsRepository {
),
connectionWifiHost: row?.connectionWifiHost,
connectionWifiPort: row?.connectionWifiPort ?? kDefaultWifiPort,
connectionWifiSsidPrefix:
row?.connectionWifiSsidPrefix ?? kDefaultWifiSsidPrefix,
);
}
@@ -104,6 +107,18 @@ class AppSettingsRepository {
));
}
/// SSID-Praefix fuer den app-initiierten WLAN-Verbindungsaufbau (Doku
/// Kommunikationsschicht v2 Abschnitt 4/8), Standard "mLRS-".
Future<String> loadConnectionWifiSsidPrefix() async =>
(await load()).connectionWifiSsidPrefix;
Future<void> setConnectionWifiSsidPrefix(String prefix) async {
await _upsert(AppSettingsTableCompanion(
id: const Value(_rowId),
connectionWifiSsidPrefix: Value(prefix),
));
}
Future<void> _upsert(AppSettingsTableCompanion companion) async {
await _db.into(_db.appSettingsTable).insertOnConflictUpdate(companion);
}
+5
View File
@@ -13,3 +13,8 @@ enum ConnectionType {
/// Vorgabe-Port der mLRS-WLAN-Bruecke (Doku Abschnitt 3: "Port 14550").
const int kDefaultWifiPort = 14550;
/// Vorgabe-SSID-Praefix der mLRS-WLAN-Bruecke (Doku Kommunikationsschicht v2
/// Abschnitt 3/8: "mLRS-xxxx AP UDP" - die SSID enthaelt eine Zufallszahl,
/// daher Praefixvergleich statt exakter SSID).
const String kDefaultWifiSsidPrefix = 'mLRS-';
+12
View File
@@ -30,6 +30,18 @@ enum LinkErrorReason {
/// getrennt.
connectionFailed,
/// Die per WifiNetworkSpecifier angeforderte mLRS-Netz konnte nicht
/// zustande kommen (Doku Kommunikationsschicht v2, Abschnitt 4) - z.B.
/// Nutzer hat den Systemdialog abgelehnt/keine passende SSID in Reichweite.
/// Android meldet das ueber `onUnavailable()` statt einer Exception.
networkUnavailable,
/// Das zuvor verfuegbare mLRS-Netz wurde waehrend einer bestehenden
/// Verbindung getrennt (`onLost()`) - z.B. ausser Reichweite oder Bridge
/// ausgeschaltet. Der Socket ist damit ungueltig und muss samt
/// Bindungsfenster neu erzeugt werden (Doku Abschnitt 8).
networkLost,
/// Sonstiger, nicht naeher klassifizierter Fehler.
unknown,
}
@@ -0,0 +1,124 @@
import 'package:flutter/services.dart';
/// Ereignis der per [MlrsNetworkController.requestNetwork] angeforderten
/// mLRS-WLAN-Verbindung (Doku Kommunikationsschicht v2, Abschnitt 4).
sealed class MlrsNetworkEvent {
const MlrsNetworkEvent();
}
/// Das Netz ist verfuegbar (Nutzer hat den Systemdialog bestaetigt).
/// [gatewayAddress] ist die Gateway-Adresse des Netzes, falls ermittelbar -
/// dient als Vorbelegung der Zieladresse, bevor eine Gegenstelle aus dem
/// Verkehr gelernt wurde (Doku Abschnitt 3).
class MlrsNetworkAvailable extends MlrsNetworkEvent {
const MlrsNetworkAvailable({this.gatewayAddress});
final String? gatewayAddress;
}
/// Die Anfrage konnte nicht erfuellt werden (Nutzer hat den Systemdialog
/// abgelehnt, keine passende SSID in Reichweite, oder Systemtimeout).
class MlrsNetworkUnavailable extends MlrsNetworkEvent {
const MlrsNetworkUnavailable();
}
/// Ein zuvor verfuegbares Netz wurde waehrend einer bestehenden Verbindung
/// getrennt (Doku Abschnitt 8: "Bindung haftet am Socket ... hoeren bewusst
/// auf zu funktionieren").
class MlrsNetworkLost extends MlrsNetworkEvent {
const MlrsNetworkLost();
}
/// Plattform-Bruecke zum app-initiierten WLAN-Verbindungsaufbau
/// (`WifiNetworkSpecifier`/`ConnectivityManager`, Doku Kommunikationsschicht
/// v2 Abschnitt 4) - kapselt den einzigen Platform-Channel-Zugriff dieses
/// Features. [UdpTransport] programmiert ausschliesslich gegen dieses
/// Interface, damit es in Tests durch eine Fake-Implementierung ersetzt
/// werden kann, ohne einen echten Android-Kanal zu brauchen.
abstract class MlrsNetworkController {
/// Ergebnisse zur zuletzt per [requestNetwork] gestellten Anfrage -
/// broadcast, damit sowohl [UdpTransport.connect] (wartet auf das erste
/// Ereignis) als auch ein laufender Verbindungsueberwacher (reagiert auf
/// ein spaeteres [MlrsNetworkLost]) gleichzeitig zuhoeren koennen.
Stream<MlrsNetworkEvent> get events;
/// Loest `ConnectivityManager.requestNetwork()` mit einem
/// `WifiNetworkSpecifier` aus, dessen SSID-Muster auf [ssidPrefix] passt.
/// Das Ergebnis kommt asynchron ueber [events] (available/unavailable).
Future<void> requestNetwork(String ssidPrefix);
/// Meldet den Callback ab und geht damit von der aktuellen Anfrage
/// zurueck - keine weiteren [events] danach, bis erneut angefragt wird.
Future<void> releaseNetwork();
/// Bindet den App-Prozess kurzzeitig an das zuletzt verfuegbare Netz
/// (Doku Abschnitt 4: "Bindungsfenster"). Liefert false, wenn aktuell kein
/// Netz verfuegbar ist.
Future<bool> bindProcessToNetwork();
/// Loest die Prozessbindung wieder - muss unmittelbar nach dem Erzeugen
/// des UDP-Sockets aufgerufen werden, sonst laufen auch Karten/Wetter
/// ueber das mLRS-Netz statt der Standardroute.
Future<void> unbindProcessFromNetwork();
/// Ergebnis von
/// `WifiManager.isStaConcurrencyForLocalOnlyConnectionsSupported()` (Doku
/// Abschnitt 4/8) - null falls nicht ermittelbar (API < 30). Nur zur
/// Anzeige/zum Verbindungsprotokoll, nicht handlungsrelevant.
Future<bool?> isStaConcurrencySupported();
void dispose();
}
/// Echte Implementierung ueber MethodChannel/EventChannel gegen
/// `MlrsNetworkPlugin.kt` (android/app/.../MlrsNetworkPlugin.kt).
class MethodChannelMlrsNetworkController implements MlrsNetworkController {
MethodChannelMlrsNetworkController()
: _methodChannel = const MethodChannel('com.dmc.dmc_app/mlrs_network'),
_eventChannel =
const EventChannel('com.dmc.dmc_app/mlrs_network_events');
final MethodChannel _methodChannel;
final EventChannel _eventChannel;
@override
late final Stream<MlrsNetworkEvent> events =
_eventChannel.receiveBroadcastStream().map(_toEvent);
MlrsNetworkEvent _toEvent(dynamic raw) {
final map = Map<Object?, Object?>.from(raw as Map);
return switch (map['type']) {
'available' => MlrsNetworkAvailable(
gatewayAddress: map['gatewayAddress'] as String?,
),
'lost' => const MlrsNetworkLost(),
_ => const MlrsNetworkUnavailable(),
};
}
@override
Future<void> requestNetwork(String ssidPrefix) => _methodChannel
.invokeMethod('requestNetwork', {'ssidPrefix': ssidPrefix});
@override
Future<void> releaseNetwork() => _methodChannel.invokeMethod('releaseNetwork');
@override
Future<bool> bindProcessToNetwork() async =>
(await _methodChannel.invokeMethod<bool>('bindProcessToNetwork')) ??
false;
@override
Future<void> unbindProcessFromNetwork() =>
_methodChannel.invokeMethod('unbindProcessFromNetwork');
@override
Future<bool?> isStaConcurrencySupported() =>
_methodChannel.invokeMethod<bool>('isStaConcurrencySupported');
@override
void dispose() {
// Keine eigenen Ressourcen - Abonnenten von [events] verwalten ihre
// StreamSubscription selbst (siehe UdpTransport.dispose).
}
}
+120 -25
View File
@@ -1,37 +1,57 @@
import 'dart:async';
import 'dart:io';
import 'dart:typed_data';
import 'package:flutter/services.dart';
import '../link_transport.dart';
import 'mlrs_network_controller.dart';
/// UDP-Transport zur mLRS-WLAN-Bruecke (Doku Kommunikationsschicht Abschnitt
/// 3). Erste, einfachste Ausbaustufe: lokal auf [_getPort] binden und auf
/// eingehende Pakete hoeren (Stufe 1 - ohne Netzwerk-Bindung, siehe Doku
/// Abschnitt 4; Stufe 2/3 sind bewusst noch nicht umgesetzt).
/// UDP-Transport zur mLRS-WLAN-Bruecke (Doku Kommunikationsschicht v2,
/// Abschnitt 3/4). Die Verbindung zum WLAN wird ausschliesslich von der App
/// selbst aufgebaut (`WifiNetworkSpecifier` ueber [MlrsNetworkController]),
/// nicht ueber die Systemeinstellungen - sonst wuerde das mLRS-Netz zur
/// Standardroute des Geraets und Kartenkacheln/Wetter fielen aus.
///
/// Setzt das in Abschnitt 3 beschriebene Muster um: lokal auf dem Zielport
/// binden statt auf einen fluechtigen Port (sonst kommt kein Byte an), und
/// die Gegenstelle ausschliesslich aus dem Verkehr lernen statt sie fest
/// einzucodieren. Eine vom Nutzer eingetragene Host-Vorgabe fuellt nur das
/// allererste ausgehende Paket vor.
/// Ablauf von [connect]:
/// 1. Netz mit passendem SSID-Praefix anfordern, auf `available`/
/// `unavailable` warten (Doku Abschnitt 4).
/// 2. Zieladresse vorbelegen: konfigurierter Host, sonst die vom Netz
/// gemeldete Gateway-Adresse, sonst ungelernt (Doku Abschnitt 3).
/// 3. Kurzes Bindungsfenster: Prozess an das Netz binden, UDP-Socket
/// erzeugen, Prozessbindung sofort wieder loesen (Doku Abschnitt 4) - der
/// Socket selbst bleibt danach am WLAN gebunden.
/// 4. Weiterhin auf das Netz hoeren: geht es verloren (`lost`), ist der
/// Socket ungueltig (Doku Abschnitt 8) und die Verbindung wird mit
/// [LinkErrorReason.networkLost] beendet. Automatisches Wiederverbinden
/// mit ansteigender Wartezeit (Doku Abschnitt 8) ist noch nicht
/// umgesetzt - der Nutzer stoesst es aktuell ueber "Connect" erneut an.
class UdpTransport implements LinkTransport {
UdpTransport({
required Future<String?> Function() getHost,
required Future<int> Function() getPort,
required Future<String> Function() getSsidPrefix,
MlrsNetworkController? networkController,
}) : _getHost = getHost,
_getPort = getPort;
_getPort = getPort,
_getSsidPrefix = getSsidPrefix,
_networkController =
networkController ?? MethodChannelMlrsNetworkController();
final Future<String?> Function() _getHost;
final Future<int> Function() _getPort;
final Future<String> Function() _getSsidPrefix;
final MlrsNetworkController _networkController;
final _incomingController = StreamController<Uint8List>.broadcast();
final _stateController = StreamController<LinkState>.broadcast();
RawDatagramSocket? _socket;
StreamSubscription<RawSocketEvent>? _socketSub;
StreamSubscription<MlrsNetworkEvent>? _networkLossSub;
InternetAddress? _peerAddress;
int? _peerPort;
int? _boundPort;
bool? _staConcurrencySupported;
LinkState _state = LinkState.disconnected;
LinkErrorReason? _lastErrorReason;
@@ -50,31 +70,63 @@ class UdpTransport implements LinkTransport {
String? get lastErrorMessage => _lastErrorMessage;
/// Tatsaechlich gebundener lokaler Port, erst nach [connect] bekannt -
/// weicht von der Vorgabe ab, wenn diese belegt war (Doku 3/8).
/// weicht von der Vorgabe ab, wenn diese belegt war (Doku 3/9).
int? get boundPort => _boundPort;
/// Gegenstelle, sobald aus dem ersten eingehenden Paket gelernt bzw. aus
/// der Host-Vorgabe vorbelegt (Doku 3).
/// Host-Vorgabe/Gateway-Adresse vorbelegt (Doku Abschnitt 3).
String? get peerDescription =>
_peerAddress == null ? null : '${_peerAddress!.address}:$_peerPort';
/// Ergebnis von `isStaConcurrencyForLocalOnlyConnectionsSupported()`, ab
/// dem ersten erfolgreichen [connect] verfuegbar - null vorher oder falls
/// nicht ermittelbar (API < 30, Doku Abschnitt 4/8).
bool? get staConcurrencySupported => _staConcurrencySupported;
@override
Future<void> connect() async {
_lastErrorReason = null;
_lastErrorMessage = null;
_setState(LinkState.connecting);
final configuredPort = await _getPort();
final host = await _getHost();
if (host != null && host.isNotEmpty) {
final parsed = InternetAddress.tryParse(host);
if (parsed != null) {
_peerAddress = parsed;
_peerPort = configuredPort;
}
}
final ssidPrefix = await _getSsidPrefix();
final firstEvent = Completer<MlrsNetworkEvent>();
final requestSub = _networkController.events.listen((event) {
if (!firstEvent.isCompleted) firstEvent.complete(event);
});
try {
await _networkController.requestNetwork(ssidPrefix);
final event = await firstEvent.future;
await requestSub.cancel();
if (event is! MlrsNetworkAvailable) {
_fail(
LinkErrorReason.networkUnavailable,
'No WiFi network matching SSID prefix "$ssidPrefix" found, or the '
'system connection dialog was dismissed.',
);
}
final available = event;
// Ab hier auf Netzverlust reagieren - auch waehrend des kurzen
// Bindungsfensters unten, nicht erst danach (Doku Abschnitt 8).
_networkLossSub = _networkController.events.listen(_onNetworkEvent);
final configuredHost = await _getHost();
final configuredPort = await _getPort();
final prefillHost = (configuredHost != null && configuredHost.isNotEmpty)
? configuredHost
: available.gatewayAddress;
if (prefillHost != null) {
final parsed = InternetAddress.tryParse(prefillHost);
if (parsed != null) {
_peerAddress = parsed;
_peerPort = configuredPort;
}
}
await _networkController.bindProcessToNetwork();
RawDatagramSocket socket;
try {
// reuseAddress: false, damit ein tatsaechlich belegter Port hier
@@ -86,13 +138,15 @@ class UdpTransport implements LinkTransport {
reuseAddress: false,
);
} on SocketException {
// Port belegt (Doku 3/8: "ist 14550 belegt, auf einen fluechtigen
// ausweichen, aber das protokollieren") - Ausweichen ist kein Grund,
// die Verbindung abzubrechen.
// Port belegt (Doku 3/9: "ist 14550 belegt, ausweichen, aber
// protokollieren") - Ausweichen ist kein Grund, abzubrechen.
socket = await RawDatagramSocket.bind(InternetAddress.anyIPv4, 0);
_lastErrorMessage =
'Port $configuredPort in use, fell back to ${socket.port}.';
} finally {
await _networkController.unbindProcessFromNetwork();
}
_socket = socket;
_boundPort = socket.port;
_socketSub = socket.listen(
@@ -100,12 +154,47 @@ class UdpTransport implements LinkTransport {
onError: (Object e) =>
_setError(LinkErrorReason.connectionFailed, e.toString()),
);
_staConcurrencySupported =
await _networkController.isStaConcurrencySupported();
_setState(LinkState.connected);
} on SocketException catch (e) {
await requestSub.cancel();
await _networkLossSub?.cancel();
_networkLossSub = null;
await _networkController.releaseNetwork();
_fail(LinkErrorReason.connectionFailed, e.message);
} on MissingPluginException {
await requestSub.cancel();
_fail(
LinkErrorReason.unknown,
'mLRS network channel unavailable on this platform.',
);
} on PlatformException catch (e) {
await requestSub.cancel();
_fail(
LinkErrorReason.unknown,
e.message ?? 'Platform error requesting the mLRS network.',
);
}
}
void _onNetworkEvent(MlrsNetworkEvent event) {
if (event is! MlrsNetworkLost) return;
_networkLossSub?.cancel();
_networkLossSub = null;
_socketSub?.cancel();
_socketSub = null;
_socket?.close();
_socket = null;
_boundPort = null;
_setError(
LinkErrorReason.networkLost,
'The mLRS WiFi network was lost.',
);
}
void _onSocketEvent(RawSocketEvent event) {
if (event != RawSocketEvent.read) return;
final datagram = _socket?.receive();
@@ -117,11 +206,14 @@ class UdpTransport implements LinkTransport {
@override
Future<void> disconnect() async {
await _networkLossSub?.cancel();
_networkLossSub = null;
await _socketSub?.cancel();
_socketSub = null;
_socket?.close();
_socket = null;
_boundPort = null;
await _networkController.releaseNetwork();
_setState(LinkState.disconnected);
}
@@ -141,9 +233,12 @@ class UdpTransport implements LinkTransport {
@override
void dispose() {
_networkLossSub?.cancel();
_socketSub?.cancel();
_socket?.close();
_socket = null;
unawaited(_networkController.releaseNetwork());
_networkController.dispose();
_incomingController.close();
_stateController.close();
}
@@ -15,6 +15,7 @@ final wifiTransportProvider = Provider<UdpTransport>((ref) {
final transport = UdpTransport(
getHost: settingsRepo.loadConnectionWifiHost,
getPort: settingsRepo.loadConnectionWifiPort,
getSsidPrefix: settingsRepo.loadConnectionWifiSsidPrefix,
);
ref.onDispose(transport.dispose);
return transport;
@@ -96,6 +97,7 @@ class WifiConnectionLogNotifier
StreamSubscription<LinkState>? _stateSub;
StreamSubscription<void>? _dataSub;
int _packetCount = 0;
String? _lastLoggedPeer;
int get packetCount => _packetCount;
@@ -105,8 +107,9 @@ class WifiConnectionLogNotifier
_stateSub?.cancel();
_dataSub?.cancel();
_packetCount = 0;
_lastLoggedPeer = null;
_stateSub = transport.state.listen(_onStateChanged);
_dataSub = transport.incoming.listen((_) => _packetCount++);
_dataSub = transport.incoming.listen((_) => _onPacket(transport));
ref.onDispose(() {
_stateSub?.cancel();
_dataSub?.cancel();
@@ -114,18 +117,38 @@ class WifiConnectionLogNotifier
return const [];
}
void _onPacket(UdpTransport transport) {
_packetCount++;
// Gegenstelle wird aus dem ersten eingehenden Paket gelernt (Doku
// Kommunikationsschicht v2, Abschnitt 3/9) - nur beim ersten Mal bzw.
// bei Aenderung protokollieren, nicht pro Paket.
final peer = transport.peerDescription;
if (peer != null && peer != _lastLoggedPeer) {
_lastLoggedPeer = peer;
_append('Peer learned: $peer');
}
}
void _onStateChanged(LinkState linkState) {
final transport = ref.read(wifiTransportProvider);
final message = switch (linkState) {
LinkState.disconnected => 'Disconnected',
LinkState.connecting => 'Connecting...',
LinkState.connected => 'Listening on port ${transport.boundPort}',
LinkState.connecting => 'Requesting mLRS network...',
LinkState.connected =>
'Network available, listening on port ${transport.boundPort}'
' (STA concurrency: ${_formatStaConcurrency(transport.staConcurrencySupported)})',
LinkState.error => 'Error: '
'${transport.lastErrorMessage ?? transport.lastErrorReason?.name ?? 'unknown'}',
};
_append(message);
}
String _formatStaConcurrency(bool? supported) => switch (supported) {
true => 'supported',
false => 'not supported',
null => 'unknown',
};
void _append(String message) {
final entry = WifiConnectionLogEntry(DateTime.now(), message);
final next = [...state, entry];
@@ -121,6 +121,7 @@ class _WifiPanel extends ConsumerStatefulWidget {
}
class _WifiPanelState extends ConsumerState<_WifiPanel> {
late final TextEditingController _ssidPrefixController;
late final TextEditingController _hostController;
late final TextEditingController _portController;
bool _controllersSeeded = false;
@@ -128,12 +129,14 @@ class _WifiPanelState extends ConsumerState<_WifiPanel> {
@override
void initState() {
super.initState();
_ssidPrefixController = TextEditingController();
_hostController = TextEditingController();
_portController = TextEditingController();
}
@override
void dispose() {
_ssidPrefixController.dispose();
_hostController.dispose();
_portController.dispose();
super.dispose();
@@ -153,6 +156,7 @@ class _WifiPanelState extends ConsumerState<_WifiPanel> {
// ueberschrieben.
if (!_controllersSeeded && connectionSettings != null) {
_controllersSeeded = true;
_ssidPrefixController.text = connectionSettings.connectionWifiSsidPrefix;
_hostController.text = connectionSettings.connectionWifiHost ?? '';
_portController.text = connectionSettings.connectionWifiPort.toString();
}
@@ -171,9 +175,17 @@ class _WifiPanelState extends ConsumerState<_WifiPanel> {
),
const SizedBox(height: 4),
const Text(
'Opens a local UDP socket and listens for the mLRS bridge '
'(port 14550 by default). The peer address is learned from the '
'first incoming packet.',
'The app connects to the mLRS bridge WiFi itself (a system dialog '
'will ask you to confirm) so your normal mobile data connection '
'keeps working for maps and weather. The peer address is learned '
'from the first incoming packet.',
style: TextStyle(fontSize: 12, color: Colors.grey),
),
const SizedBox(height: 4),
const Text(
'Do not manually connect to or save the mLRS network in your '
'system WiFi settings - that would make it the default route and '
'break maps/weather.',
style: TextStyle(fontSize: 12, color: Colors.grey),
),
if (errorReason != null) ...[
@@ -187,15 +199,15 @@ class _WifiPanelState extends ConsumerState<_WifiPanel> {
Expanded(
flex: 2,
child: TextField(
controller: _hostController,
controller: _ssidPrefixController,
decoration: const InputDecoration(
labelText: 'Host (optional)',
hintText: 'auto (learned from traffic)',
labelText: 'SSID prefix',
isDense: true,
border: OutlineInputBorder(),
),
onSubmitted: (value) => _saveHost(value),
onEditingComplete: () => _saveHost(_hostController.text),
onSubmitted: (value) => _saveSsidPrefix(value),
onEditingComplete: () =>
_saveSsidPrefix(_ssidPrefixController.text),
),
),
const SizedBox(width: 8),
@@ -215,6 +227,18 @@ class _WifiPanelState extends ConsumerState<_WifiPanel> {
),
],
),
const SizedBox(height: 8),
TextField(
controller: _hostController,
decoration: const InputDecoration(
labelText: 'Host (optional)',
hintText: 'auto (network gateway, then learned from traffic)',
isDense: true,
border: OutlineInputBorder(),
),
onSubmitted: (value) => _saveHost(value),
onEditingComplete: () => _saveHost(_hostController.text),
),
const SizedBox(height: 16),
Row(
children: [
@@ -258,6 +282,15 @@ class _WifiPanelState extends ConsumerState<_WifiPanel> {
);
}
Future<void> _saveSsidPrefix(String value) async {
final trimmed = value.trim();
if (trimmed.isEmpty) return;
await ref
.read(appSettingsRepositoryProvider)
.setConnectionWifiSsidPrefix(trimmed);
ref.invalidate(connectionSettingsProvider);
}
Future<void> _saveHost(String value) async {
final trimmed = value.trim();
await ref
@@ -293,16 +326,25 @@ class _WifiErrorHint extends StatelessWidget {
@override
Widget build(BuildContext context) {
final text = switch (reason) {
LinkErrorReason.networkUnavailable =>
'No matching WiFi network found, or the system connection dialog '
'was dismissed. Make sure the mLRS bridge is powered on and not '
'saved/connected in system WiFi settings, then tap "Connect" '
'again.',
LinkErrorReason.networkLost =>
'The mLRS WiFi network was lost (out of range or bridge powered '
'off). Tap "Connect" to reconnect.',
LinkErrorReason.connectionFailed => 'Connection to the bridge failed.',
_ => 'Connection error: ${reason.name}.',
};
return Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: DmcColors.warnRed.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: Text(
'Connection error: ${reason.name}.',
style: const TextStyle(fontSize: 12),
),
child: Text(text, style: const TextStyle(fontSize: 12)),
);
}
}
@@ -543,7 +585,13 @@ class _ErrorHint extends StatelessWidget {
'Connection to the device failed.',
null,
),
LinkErrorReason.unknown => ('Unknown connection error.', null),
// networkUnavailable/networkLost betreffen nur den WLAN-Transport
// (siehe _WifiErrorHint) - hier nur der Vollstaendigkeit halber, damit
// der gemeinsame LinkErrorReason-Enum exhaustiv bleibt.
LinkErrorReason.networkUnavailable ||
LinkErrorReason.networkLost ||
LinkErrorReason.unknown =>
('Unknown connection error.', null),
};
return Container(
padding: const EdgeInsets.all(8),
@@ -106,4 +106,15 @@ void main() {
expect(await repository.loadConnectionWifiHost(), isNull);
});
test('SSID-Praefix: Standard ist "mLRS-"', () async {
final settings = await repository.load();
expect(settings.connectionWifiSsidPrefix, 'mLRS-');
});
test('setConnectionWifiSsidPrefix merkt sich den Praefix', () async {
await repository.setConnectionWifiSsidPrefix('mLRS-1234');
expect(await repository.loadConnectionWifiSsidPrefix(), 'mLRS-1234');
});
}
+180 -39
View File
@@ -1,39 +1,183 @@
import 'dart:async';
import 'dart:io';
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:dmc_app/transport/link_transport.dart';
import 'package:dmc_app/transport/udp/mlrs_network_controller.dart';
import 'package:dmc_app/transport/udp/udp_transport.dart';
/// Test-Doppel fuer [MlrsNetworkController] (Doku Kommunikationsschicht v2,
/// Abschnitt 4): simuliert Netzanfrage-Ergebnisse ueber [emit], ohne einen
/// echten Android-Platform-Channel zu brauchen. Zeichnet Aufrufe auf, damit
/// Tests den Bindungsfenster-Ablauf (bind -> Socket -> unbind) pruefen
/// koennen.
class FakeMlrsNetworkController implements MlrsNetworkController {
final _eventsController = StreamController<MlrsNetworkEvent>.broadcast();
final List<String> requestedSsidPrefixes = [];
int releaseCount = 0;
int bindCount = 0;
int unbindCount = 0;
bool bindResult = true;
bool? staConcurrencyResult;
@override
Stream<MlrsNetworkEvent> get events => _eventsController.stream;
void emit(MlrsNetworkEvent event) => _eventsController.add(event);
@override
Future<void> requestNetwork(String ssidPrefix) async {
requestedSsidPrefixes.add(ssidPrefix);
}
@override
Future<void> releaseNetwork() async {
releaseCount++;
}
@override
Future<bool> bindProcessToNetwork() async {
bindCount++;
return bindResult;
}
@override
Future<void> unbindProcessFromNetwork() async {
unbindCount++;
}
@override
Future<bool?> isStaConcurrencySupported() async => staConcurrencyResult;
@override
void dispose() {
_eventsController.close();
}
}
void main() {
// Port 0 laesst das Betriebssystem einen freien Port waehlen, damit die
// Tests nicht mit einer echten mLRS-Bruecke oder parallelen Testlaeufen
// um Port 14550 konkurrieren.
Future<UdpTransport> openTransport({String? host}) async {
// Echter Timer-Tick statt nur eines Microtasks: laesst die
// async/await-Kette in UdpTransport.connect() (getSsidPrefix ->
// requestNetwork -> Listener-Aufbau) vollstaendig ablaufen, bevor der Test
// ein Netzwerk-Ereignis einspeist.
Future<void> pump() => Future<void>.delayed(const Duration(milliseconds: 10));
Future<UdpTransport> connectWithFake(
FakeMlrsNetworkController fake, {
String? host,
int port = 0,
String ssidPrefix = 'mLRS-',
MlrsNetworkEvent event = const MlrsNetworkAvailable(),
}) async {
final transport = UdpTransport(
getHost: () async => host,
getPort: () async => 0,
getPort: () async => port,
getSsidPrefix: () async => ssidPrefix,
networkController: fake,
);
await transport.connect();
final connectFuture = transport.connect();
await pump();
fake.emit(event);
await connectFuture;
return transport;
}
test('connect() bindet einen lokalen Port und meldet connected', () async {
final transport = await openTransport();
final states = <LinkState>[];
transport.state.listen(states.add);
test('connect() requests the network with the configured SSID prefix',
() async {
final fake = FakeMlrsNetworkController();
final transport =
await connectWithFake(fake, ssidPrefix: 'mLRS-1234');
expect(transport.currentState, LinkState.connected);
expect(transport.boundPort, isNotNull);
expect(transport.boundPort, greaterThan(0));
expect(fake.requestedSsidPrefixes, ['mLRS-1234']);
transport.dispose();
});
test('eingehende Pakete kommen ueber incoming an und lernen die Gegenstelle',
test(
'connect() binds process to network, creates the socket, then unbinds',
() async {
final transport = await openTransport();
final fake = FakeMlrsNetworkController();
final transport = await connectWithFake(fake);
expect(transport.currentState, LinkState.connected);
expect(transport.boundPort, isNotNull);
expect(fake.bindCount, 1);
expect(fake.unbindCount, 1);
transport.dispose();
});
test('connect() fails with networkUnavailable when the request is denied',
() async {
final fake = FakeMlrsNetworkController();
final transport = UdpTransport(
getHost: () async => null,
getPort: () async => 0,
getSsidPrefix: () async => 'mLRS-',
networkController: fake,
);
final connectFuture = transport.connect();
await pump();
fake.emit(const MlrsNetworkUnavailable());
await expectLater(
connectFuture,
throwsA(isA<LinkConnectException>()),
);
expect(transport.lastErrorReason, LinkErrorReason.networkUnavailable);
expect(transport.currentState, LinkState.error);
transport.dispose();
});
test('losing the network while connected closes the socket and reports '
'networkLost', () async {
final fake = FakeMlrsNetworkController();
final transport = await connectWithFake(fake);
fake.emit(const MlrsNetworkLost());
await pump();
expect(transport.currentState, LinkState.error);
expect(transport.lastErrorReason, LinkErrorReason.networkLost);
expect(transport.boundPort, isNull);
transport.dispose();
});
test('uses the reported gateway address as initial peer when no host is '
'configured', () async {
final fake = FakeMlrsNetworkController();
final transport = await connectWithFake(
fake,
event: const MlrsNetworkAvailable(gatewayAddress: '192.168.4.1'),
);
expect(transport.peerDescription, startsWith('192.168.4.1'));
transport.dispose();
});
test('a configured host overrides the reported gateway address', () async {
final fake = FakeMlrsNetworkController();
final transport = await connectWithFake(
fake,
host: '10.0.0.5',
event: const MlrsNetworkAvailable(gatewayAddress: '192.168.4.1'),
);
expect(transport.peerDescription, startsWith('10.0.0.5'));
transport.dispose();
});
test('incoming packets arrive via incoming and learn the peer', () async {
final fake = FakeMlrsNetworkController();
final transport = await connectWithFake(fake);
final sender = await RawDatagramSocket.bind(InternetAddress.loopbackIPv4, 0);
final received = <Uint8List>[];
@@ -55,8 +199,9 @@ void main() {
transport.dispose();
});
test('send() ohne bekannte Gegenstelle wirft LinkConnectException', () async {
final transport = await openTransport();
test('send() without a known peer throws LinkConnectException', () async {
final fake = FakeMlrsNetworkController();
final transport = await connectWithFake(fake);
expect(
() => transport.send(Uint8List.fromList([1])),
@@ -66,13 +211,14 @@ void main() {
transport.dispose();
});
test('send() schickt Bytes an die gelernte Gegenstelle', () async {
final transport = await openTransport();
test('send() sends bytes to the learned peer', () async {
final fake = FakeMlrsNetworkController();
final transport = await connectWithFake(fake);
final peer = await RawDatagramSocket.bind(InternetAddress.loopbackIPv4, 0);
// Erst "sprechen" lassen, damit der Transport die Gegenstelle lernt
// (Doku Kommunikationsschicht Abschnitt 3: "Gegenstelle aus dem Verkehr
// lernen").
// (Doku Kommunikationsschicht v2 Abschnitt 3: "Gegenstelle aus dem
// Verkehr lernen").
peer.send(
Uint8List.fromList([9]),
InternetAddress.loopbackIPv4,
@@ -99,26 +245,26 @@ void main() {
transport.dispose();
});
test('disconnect() setzt LinkState.disconnected', () async {
final transport = await openTransport();
test('disconnect() closes the socket and releases the network request',
() async {
final fake = FakeMlrsNetworkController();
final transport = await connectWithFake(fake);
await transport.disconnect();
expect(transport.currentState, LinkState.disconnected);
expect(transport.boundPort, isNull);
expect(fake.releaseCount, greaterThanOrEqualTo(1));
transport.dispose();
});
test('weicht auf einen fluechtigen Port aus, wenn der konfigurierte belegt ist',
test(
'falls back to an ephemeral port when the configured one is occupied',
() async {
final occupied = await RawDatagramSocket.bind(InternetAddress.anyIPv4, 0);
final transport = UdpTransport(
getHost: () async => null,
getPort: () async => occupied.port,
);
await transport.connect();
final fake = FakeMlrsNetworkController();
final transport = await connectWithFake(fake, port: occupied.port);
expect(transport.currentState, LinkState.connected);
expect(transport.boundPort, isNot(occupied.port));
@@ -128,16 +274,11 @@ void main() {
transport.dispose();
});
test('konfigurierte Host-Vorgabe wird als initiale Gegenstelle uebernommen',
() async {
final transport = UdpTransport(
getHost: () async => '127.0.0.1',
getPort: () async => 0,
);
await transport.connect();
test('reports isStaConcurrencySupported() once connected', () async {
final fake = FakeMlrsNetworkController()..staConcurrencyResult = true;
final transport = await connectWithFake(fake);
expect(transport.peerDescription, isNotNull);
expect(transport.peerDescription, startsWith('127.0.0.1'));
expect(transport.staConcurrencySupported, isTrue);
transport.dispose();
});