udp socket binding istead of process binding to allow internet over network for maps etc

This commit is contained in:
Constantin Leue
2026-07-31 20:49:08 +02:00
parent 3b3451b8a2
commit 5e9ef43f2f
5 changed files with 365 additions and 167 deletions
@@ -18,5 +18,9 @@ class MainActivity : FlutterActivity() {
flutterEngine.dartExecutor.binaryMessenger, flutterEngine.dartExecutor.binaryMessenger,
MlrsNetworkPlugin.EVENT_CHANNEL, MlrsNetworkPlugin.EVENT_CHANNEL,
).setStreamHandler(plugin) ).setStreamHandler(plugin)
EventChannel(
flutterEngine.dartExecutor.binaryMessenger,
MlrsNetworkPlugin.SOCKET_EVENT_CHANNEL,
).setStreamHandler(plugin.socketStreamHandler)
} }
} }
@@ -15,6 +15,12 @@ import android.os.PatternMatcher
import io.flutter.plugin.common.EventChannel import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel
import java.net.BindException
import java.net.DatagramPacket
import java.net.DatagramSocket
import java.net.InetAddress
import java.net.InetSocketAddress
import java.util.concurrent.Executors
/** /**
* Verbindungsaufbau zur mLRS-WLAN-Bruecke ausschliesslich aus der App heraus * Verbindungsaufbau zur mLRS-WLAN-Bruecke ausschliesslich aus der App heraus
@@ -25,12 +31,17 @@ import io.flutter.plugin.common.MethodChannel
* Ablauf: [requestNetwork] loest `ConnectivityManager.requestNetwork()` aus, * Ablauf: [requestNetwork] loest `ConnectivityManager.requestNetwork()` aus,
* das System zeigt dabei automatisch einen Bestaetigungsdialog fuer die * das System zeigt dabei automatisch einen Bestaetigungsdialog fuer die
* passende SSID. `onAvailable`/`onUnavailable`/`onLost` werden ueber den * passende SSID. `onAvailable`/`onUnavailable`/`onLost` werden ueber den
* EventChannel nach Dart gemeldet, das den restlichen Ablauf (Socket binden, * EventChannel nach Dart gemeldet.
* lauschen, Fehleranzeige) uebernimmt.
* *
* [bindProcessToNetwork]/[unbindProcessFromNetwork] setzen/loeschen die * Der UDP-Socket selbst wird ebenfalls hier (nativ) gehalten und per
* Prozessbindung fuer das kurze Fenster, in dem Dart den UDP-Socket erzeugt - * `Network.bindSocket()` **socket-genau** an das mLRS-Netz gebunden (Doku
* siehe UdpTransport.connect() fuer die Gegenseite dieses Musters. * Abschnitt 4, Stufe 3) - bewusst NICHT ueber
* `ConnectivityManager.bindProcessToNetwork()`, das den gesamten Prozess
* (inkl. gleichzeitiger Kartenkacheln-/Wetter-HTTP-Requests) betrifft und in
* der Praxis auf Geraeten ohne STA-Concurrency zu genau diesen Requests
* gefuehrt hat, die "auf dem falschen Netz" haengen blieben. Socket-genaue
* Bindung ist Androids eigene Empfehlung fuer Apps, die gleichzeitig ein
* spezielles lokales Netz UND normales Internet brauchen.
*/ */
class MlrsNetworkPlugin( class MlrsNetworkPlugin(
private val context: Context, private val context: Context,
@@ -39,23 +50,41 @@ class MlrsNetworkPlugin(
companion object { companion object {
const val METHOD_CHANNEL = "com.dmc.dmc_app/mlrs_network" const val METHOD_CHANNEL = "com.dmc.dmc_app/mlrs_network"
const val EVENT_CHANNEL = "com.dmc.dmc_app/mlrs_network_events" const val EVENT_CHANNEL = "com.dmc.dmc_app/mlrs_network_events"
const val SOCKET_EVENT_CHANNEL = "com.dmc.dmc_app/mlrs_socket_events"
private const val RECEIVE_BUFFER_SIZE = 2048
} }
private val connectivityManager = private val connectivityManager =
context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
// NetworkCallback-Methoden laufen ohne expliziten Handler auf einem // NetworkCallback-Methoden und der Socket-Empfangs-Thread laufen nicht auf
// internen ConnectivityThread, nicht auf dem Main-Thread - Flutters // dem Main-Thread - Flutters EventChannel.EventSink.success()/
// EventChannel.EventSink.success() verlangt aber @UiThread (schlaegt // MethodChannel.Result verlangen aber @UiThread (siehe Crash-Historie:
// sonst mit "Methods marked with @UiThread must be executed on the main // "Methods marked with @UiThread must be executed on the main thread").
// thread" fehl). Deshalb Callback-Dispatch explizit auf den Main-Looper // Deshalb jeden Ruecksprung nach Dart explizit ueber den Main-Looper.
// legen.
private val mainHandler = Handler(Looper.getMainLooper()) private val mainHandler = Handler(Looper.getMainLooper())
private val socketExecutor = Executors.newSingleThreadExecutor()
private var eventSink: EventChannel.EventSink? = null private var eventSink: EventChannel.EventSink? = null
private var socketEventSink: EventChannel.EventSink? = null
private var networkCallback: ConnectivityManager.NetworkCallback? = null private var networkCallback: ConnectivityManager.NetworkCallback? = null
private var currentNetwork: Network? = null private var currentNetwork: Network? = null
private var socket: DatagramSocket? = null
@Volatile
private var receiveLoopRunning = false
/** Separater StreamHandler fuer den Socket-Daten-Kanal (siehe MainActivity). */
val socketStreamHandler = object : EventChannel.StreamHandler {
override fun onListen(arguments: Any?, sink: EventChannel.EventSink?) {
socketEventSink = sink
}
override fun onCancel(arguments: Any?) {
socketEventSink = null
}
}
override fun onListen(arguments: Any?, sink: EventChannel.EventSink?) { override fun onListen(arguments: Any?, sink: EventChannel.EventSink?) {
eventSink = sink eventSink = sink
} }
@@ -79,21 +108,27 @@ class MlrsNetworkPlugin(
releaseNetwork() releaseNetwork()
result.success(null) 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" -> { "isStaConcurrencySupported" -> {
result.success(isStaConcurrencySupported()) result.success(isStaConcurrencySupported())
} }
"openSocket" -> {
val port = call.argument<Int>("port") ?: 0
openSocket(port, result)
}
"closeSocket" -> {
closeSocket()
result.success(null)
}
"sendPacket" -> {
val data = call.argument<ByteArray>("data")
val host = call.argument<String>("host")
val port = call.argument<Int>("port")
if (data == null || host == null || port == null) {
result.error("invalid_argument", "data/host/port erforderlich", null)
return
}
sendPacket(data, host, port, result)
}
else -> result.notImplemented() else -> result.notImplemented()
} }
} }
@@ -161,4 +196,103 @@ class MlrsNetworkPlugin(
?: return null ?: return null
return wifiManager.isStaConcurrencyForLocalOnlyConnectionsSupported return wifiManager.isStaConcurrencyForLocalOnlyConnectionsSupported
} }
private fun openSocket(requestedPort: Int, result: MethodChannel.Result) {
val network = currentNetwork
if (network == null) {
result.error("no_network", "Kein mLRS-Netz verfuegbar, um den Socket zu binden.", null)
return
}
socketExecutor.execute {
try {
val newSocket = DatagramSocket(null)
// reuseAddress: false, damit ein tatsaechlich belegter Port
// hier erkennbar mit BindException scheitert (Doku 3/9: "ist
// 14550 belegt, ausweichen, aber protokollieren").
newSocket.reuseAddress = false
var fallbackFromPort: Int? = null
try {
newSocket.bind(InetSocketAddress(requestedPort))
} catch (e: BindException) {
newSocket.bind(InetSocketAddress(0))
fallbackFromPort = requestedPort
}
// Socket-genaue Bindung an das mLRS-Netz (siehe Klassendoku
// oben) - betrifft ausschliesslich diesen einen Socket, nicht
// den restlichen Prozess.
network.bindSocket(newSocket)
socket = newSocket
startReceiveLoop(newSocket)
val boundPort = newSocket.localPort
mainHandler.post {
result.success(
mapOf("boundPort" to boundPort, "fallbackFromPort" to fallbackFromPort),
)
}
} catch (e: Exception) {
mainHandler.post {
result.error("socket_error", e.message, null)
}
}
}
}
private fun startReceiveLoop(socket: DatagramSocket) {
receiveLoopRunning = true
Thread {
val buffer = ByteArray(RECEIVE_BUFFER_SIZE)
while (receiveLoopRunning) {
try {
val packet = DatagramPacket(buffer, buffer.size)
socket.receive(packet)
val data = packet.data.copyOfRange(packet.offset, packet.offset + packet.length)
val address = packet.address.hostAddress
val port = packet.port
mainHandler.post {
socketEventSink?.success(
mapOf("address" to address, "port" to port, "data" to data),
)
}
} catch (e: Exception) {
// Socket wurde geschlossen (closeSocket()) oder das Netz
// ist weg - Netzverlust wird bereits ueber den
// Network-Events-Kanal gemeldet, hier reicht es, die
// Empfangsschleife zu beenden statt in eine Busy-Loop zu
// laufen.
break
}
}
}.apply {
isDaemon = true
start()
}
}
private fun closeSocket() {
receiveLoopRunning = false
socket?.close()
socket = null
}
private fun sendPacket(data: ByteArray, host: String, port: Int, result: MethodChannel.Result) {
val currentSocket = socket
if (currentSocket == null) {
mainHandler.post {
result.error("no_socket", "Kein offener Socket zum Senden.", null)
}
return
}
socketExecutor.execute {
try {
val address = InetAddress.getByName(host)
currentSocket.send(DatagramPacket(data, data.size, address, port))
mainHandler.post { result.success(null) }
} catch (e: Exception) {
mainHandler.post { result.error("send_failed", e.message, null) }
}
}
}
} }
@@ -29,12 +29,48 @@ class MlrsNetworkLost extends MlrsNetworkEvent {
const MlrsNetworkLost(); const MlrsNetworkLost();
} }
/// Ein per [MlrsNetworkController.openSocket] nativ gebundener UDP-Socket
/// erfolgreich geoeffnet. [boundPort] ist der tatsaechlich gebundene Port;
/// [fallbackFromPort] ist gesetzt, wenn der urspruenglich gewuenschte Port
/// belegt war und auf einen fluechtigen ausgewichen wurde (Doku 3/9).
class UdpSocketBindResult {
const UdpSocketBindResult({required this.boundPort, this.fallbackFromPort});
final int boundPort;
final int? fallbackFromPort;
}
/// Ein vom nativen Socket empfangenes UDP-Paket.
class MlrsSocketPacket {
const MlrsSocketPacket({
required this.address,
required this.port,
required this.data,
});
final String address;
final int port;
final Uint8List data;
}
/// Plattform-Bruecke zum app-initiierten WLAN-Verbindungsaufbau /// Plattform-Bruecke zum app-initiierten WLAN-Verbindungsaufbau
/// (`WifiNetworkSpecifier`/`ConnectivityManager`, Doku Kommunikationsschicht /// (`WifiNetworkSpecifier`/`ConnectivityManager`) UND zum darauf gebundenen
/// v2 Abschnitt 4) - kapselt den einzigen Platform-Channel-Zugriff dieses /// UDP-Socket (Doku Kommunikationsschicht v2 Abschnitt 4) - kapselt den
/// Features. [UdpTransport] programmiert ausschliesslich gegen dieses /// einzigen Platform-Channel-Zugriff dieses Features. [UdpTransport]
/// Interface, damit es in Tests durch eine Fake-Implementierung ersetzt /// programmiert ausschliesslich gegen dieses Interface, damit es in Tests
/// werden kann, ohne einen echten Android-Kanal zu brauchen. /// durch eine Fake-Implementierung ersetzt werden kann, ohne einen echten
/// Android-Kanal zu brauchen.
///
/// Der UDP-Socket wird bewusst nativ gehalten und per `Network.bindSocket()`
/// **socket-genau** an das mLRS-Netz gebunden statt per
/// `ConnectivityManager.bindProcessToNetwork()` den gesamten Prozess zu
/// binden (Doku Abschnitt 4, Stufe 3) - Letzteres hat in der Praxis dazu
/// gefuehrt, dass gleichzeitige Kartenkacheln-/Wetter-HTTP-Requests, die
/// waehrend des kurzen Bindungsfensters neue Verbindungen aufbauten, auf dem
/// internetlosen mLRS-Netz haengen blieben (besonders auf Geraeten ohne
/// STA-Concurrency). Socket-genaue Bindung ist Androids eigene Empfehlung
/// fuer Apps, die gleichzeitig ein spezielles lokales Netz UND normales
/// Internet brauchen.
abstract class MlrsNetworkController { abstract class MlrsNetworkController {
/// Ergebnisse zur zuletzt per [requestNetwork] gestellten Anfrage - /// Ergebnisse zur zuletzt per [requestNetwork] gestellten Anfrage -
/// broadcast, damit sowohl [UdpTransport.connect] (wartet auf das erste /// broadcast, damit sowohl [UdpTransport.connect] (wartet auf das erste
@@ -51,15 +87,21 @@ abstract class MlrsNetworkController {
/// zurueck - keine weiteren [events] danach, bis erneut angefragt wird. /// zurueck - keine weiteren [events] danach, bis erneut angefragt wird.
Future<void> releaseNetwork(); Future<void> releaseNetwork();
/// Bindet den App-Prozess kurzzeitig an das zuletzt verfuegbare Netz /// Oeffnet einen nativ erzeugten UDP-Socket auf [port] und bindet ihn per
/// (Doku Abschnitt 4: "Bindungsfenster"). Liefert false, wenn aktuell kein /// `Network.bindSocket()` an das zuletzt verfuegbare Netz. Weicht bei
/// Netz verfuegbar ist. /// belegtem Port auf einen fluechtigen aus (siehe [UdpSocketBindResult]).
Future<bool> bindProcessToNetwork(); /// Setzt voraus, dass zuvor ein [MlrsNetworkAvailable]-Ereignis empfangen
/// wurde.
Future<UdpSocketBindResult> openSocket(int port);
/// Loest die Prozessbindung wieder - muss unmittelbar nach dem Erzeugen /// Schliesst den nativen Socket und beendet dessen Empfangsschleife.
/// des UDP-Sockets aufgerufen werden, sonst laufen auch Karten/Wetter Future<void> closeSocket();
/// ueber das mLRS-Netz statt der Standardroute.
Future<void> unbindProcessFromNetwork(); /// Sendet [data] ueber den nativen Socket an [host]:[port].
Future<void> send(Uint8List data, String host, int port);
/// Eingehende UDP-Pakete vom nativen Socket.
Stream<MlrsSocketPacket> get incomingPackets;
/// Ergebnis von /// Ergebnis von
/// `WifiManager.isStaConcurrencyForLocalOnlyConnectionsSupported()` (Doku /// `WifiManager.isStaConcurrencyForLocalOnlyConnectionsSupported()` (Doku
@@ -76,15 +118,22 @@ class MethodChannelMlrsNetworkController implements MlrsNetworkController {
MethodChannelMlrsNetworkController() MethodChannelMlrsNetworkController()
: _methodChannel = const MethodChannel('com.dmc.dmc_app/mlrs_network'), : _methodChannel = const MethodChannel('com.dmc.dmc_app/mlrs_network'),
_eventChannel = _eventChannel =
const EventChannel('com.dmc.dmc_app/mlrs_network_events'); const EventChannel('com.dmc.dmc_app/mlrs_network_events'),
_socketEventChannel =
const EventChannel('com.dmc.dmc_app/mlrs_socket_events');
final MethodChannel _methodChannel; final MethodChannel _methodChannel;
final EventChannel _eventChannel; final EventChannel _eventChannel;
final EventChannel _socketEventChannel;
@override @override
late final Stream<MlrsNetworkEvent> events = late final Stream<MlrsNetworkEvent> events =
_eventChannel.receiveBroadcastStream().map(_toEvent); _eventChannel.receiveBroadcastStream().map(_toEvent);
@override
late final Stream<MlrsSocketPacket> incomingPackets =
_socketEventChannel.receiveBroadcastStream().map(_toPacket);
MlrsNetworkEvent _toEvent(dynamic raw) { MlrsNetworkEvent _toEvent(dynamic raw) {
final map = Map<Object?, Object?>.from(raw as Map); final map = Map<Object?, Object?>.from(raw as Map);
return switch (map['type']) { return switch (map['type']) {
@@ -96,6 +145,15 @@ class MethodChannelMlrsNetworkController implements MlrsNetworkController {
}; };
} }
MlrsSocketPacket _toPacket(dynamic raw) {
final map = Map<Object?, Object?>.from(raw as Map);
return MlrsSocketPacket(
address: map['address'] as String,
port: map['port'] as int,
data: map['data'] as Uint8List,
);
}
@override @override
Future<void> requestNetwork(String ssidPrefix) => _methodChannel Future<void> requestNetwork(String ssidPrefix) => _methodChannel
.invokeMethod('requestNetwork', {'ssidPrefix': ssidPrefix}); .invokeMethod('requestNetwork', {'ssidPrefix': ssidPrefix});
@@ -104,13 +162,26 @@ class MethodChannelMlrsNetworkController implements MlrsNetworkController {
Future<void> releaseNetwork() => _methodChannel.invokeMethod('releaseNetwork'); Future<void> releaseNetwork() => _methodChannel.invokeMethod('releaseNetwork');
@override @override
Future<bool> bindProcessToNetwork() async => Future<UdpSocketBindResult> openSocket(int port) async {
(await _methodChannel.invokeMethod<bool>('bindProcessToNetwork')) ?? final result = await _methodChannel
false; .invokeMapMethod<String, Object?>('openSocket', {'port': port});
final map = result!;
return UdpSocketBindResult(
boundPort: map['boundPort'] as int,
fallbackFromPort: map['fallbackFromPort'] as int?,
);
}
@override @override
Future<void> unbindProcessFromNetwork() => Future<void> closeSocket() => _methodChannel.invokeMethod('closeSocket');
_methodChannel.invokeMethod('unbindProcessFromNetwork');
@override
Future<void> send(Uint8List data, String host, int port) =>
_methodChannel.invokeMethod('sendPacket', {
'data': data,
'host': host,
'port': port,
});
@override @override
Future<bool?> isStaConcurrencySupported() => Future<bool?> isStaConcurrencySupported() =>
@@ -118,7 +189,7 @@ class MethodChannelMlrsNetworkController implements MlrsNetworkController {
@override @override
void dispose() { void dispose() {
// Keine eigenen Ressourcen - Abonnenten von [events] verwalten ihre // Keine eigenen Ressourcen - Abonnenten von [events]/[incomingPackets]
// StreamSubscription selbst (siehe UdpTransport.dispose). // verwalten ihre StreamSubscription selbst (siehe UdpTransport.dispose).
} }
} }
+49 -64
View File
@@ -1,5 +1,5 @@
import 'dart:async'; import 'dart:async';
import 'dart:io'; import 'dart:io' show InternetAddress;
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
@@ -17,9 +17,11 @@ import 'mlrs_network_controller.dart';
/// `unavailable` warten (Doku Abschnitt 4). /// `unavailable` warten (Doku Abschnitt 4).
/// 2. Zieladresse vorbelegen: konfigurierter Host, sonst die vom Netz /// 2. Zieladresse vorbelegen: konfigurierter Host, sonst die vom Netz
/// gemeldete Gateway-Adresse, sonst ungelernt (Doku Abschnitt 3). /// gemeldete Gateway-Adresse, sonst ungelernt (Doku Abschnitt 3).
/// 3. Kurzes Bindungsfenster: Prozess an das Netz binden, UDP-Socket /// 3. Nativen UDP-Socket oeffnen, der **socket-genau** (nicht prozessweit)
/// erzeugen, Prozessbindung sofort wieder loesen (Doku Abschnitt 4) - der /// per `Network.bindSocket()` an das mLRS-Netz gebunden ist (Doku
/// Socket selbst bleibt danach am WLAN gebunden. /// Abschnitt 4, Stufe 3) - siehe [MlrsNetworkController]-Doku fuer die
/// Begruendung, warum Prozessbindung hier in der Praxis Kartenkacheln/
/// Wetter-Requests beschaedigt hat.
/// 4. Weiterhin auf das Netz hoeren: geht es verloren (`lost`), ist der /// 4. Weiterhin auf das Netz hoeren: geht es verloren (`lost`), ist der
/// Socket ungueltig (Doku Abschnitt 8) und die Verbindung wird mit /// Socket ungueltig (Doku Abschnitt 8) und die Verbindung wird mit
/// [LinkErrorReason.networkLost] beendet. Automatisches Wiederverbinden /// [LinkErrorReason.networkLost] beendet. Automatisches Wiederverbinden
@@ -45,12 +47,12 @@ class UdpTransport implements LinkTransport {
final _incomingController = StreamController<Uint8List>.broadcast(); final _incomingController = StreamController<Uint8List>.broadcast();
final _stateController = StreamController<LinkState>.broadcast(); final _stateController = StreamController<LinkState>.broadcast();
RawDatagramSocket? _socket; StreamSubscription<MlrsSocketPacket>? _packetSub;
StreamSubscription<RawSocketEvent>? _socketSub;
StreamSubscription<MlrsNetworkEvent>? _networkLossSub; StreamSubscription<MlrsNetworkEvent>? _networkLossSub;
InternetAddress? _peerAddress; InternetAddress? _peerAddress;
int? _peerPort; int? _peerPort;
int? _boundPort; int? _boundPort;
bool _socketOpen = false;
bool? _staConcurrencySupported; bool? _staConcurrencySupported;
LinkState _state = LinkState.disconnected; LinkState _state = LinkState.disconnected;
@@ -109,8 +111,8 @@ class UdpTransport implements LinkTransport {
} }
final available = event; final available = event;
// Ab hier auf Netzverlust reagieren - auch waehrend des kurzen // Ab hier auf Netzverlust reagieren - auch waehrend des Socket-Aufbaus
// Bindungsfensters unten, nicht erst danach (Doku Abschnitt 8). // unten, nicht erst danach (Doku Abschnitt 8).
_networkLossSub = _networkController.events.listen(_onNetworkEvent); _networkLossSub = _networkController.events.listen(_onNetworkEvent);
final configuredHost = await _getHost(); final configuredHost = await _getHost();
@@ -126,45 +128,23 @@ class UdpTransport implements LinkTransport {
} }
} }
await _networkController.bindProcessToNetwork(); // Nativer, socket-genau gebundener UDP-Socket statt Prozessbindung
RawDatagramSocket socket; // (siehe Klassendoku) - der eigentliche RawDatagramSocket lebt in
try { // MlrsNetworkPlugin.kt, hier kommen nur noch Bytes ueber den
// reuseAddress: false, damit ein tatsaechlich belegter Port hier // EventChannel an.
// erkennbar mit SocketException scheitert - Darts Standard final bindResult = await _networkController.openSocket(configuredPort);
// (reuseAddress: true) wuerde den Konflikt sonst still zulassen. _socketOpen = true;
socket = await RawDatagramSocket.bind( _boundPort = bindResult.boundPort;
InternetAddress.anyIPv4, if (bindResult.fallbackFromPort != null) {
configuredPort, _lastErrorMessage = 'Port ${bindResult.fallbackFromPort} in use, '
reuseAddress: false, 'fell back to ${bindResult.boundPort}.';
);
} on SocketException {
// 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();
} }
_packetSub = _networkController.incomingPackets.listen(_onPacket);
_socket = socket;
_boundPort = socket.port;
_socketSub = socket.listen(
_onSocketEvent,
onError: (Object e) =>
_setError(LinkErrorReason.connectionFailed, e.toString()),
);
_staConcurrencySupported = _staConcurrencySupported =
await _networkController.isStaConcurrencySupported(); await _networkController.isStaConcurrencySupported();
_setState(LinkState.connected); _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 { } on MissingPluginException {
await requestSub.cancel(); await requestSub.cancel();
_fail( _fail(
@@ -173,9 +153,12 @@ class UdpTransport implements LinkTransport {
); );
} on PlatformException catch (e) { } on PlatformException catch (e) {
await requestSub.cancel(); await requestSub.cancel();
await _networkLossSub?.cancel();
_networkLossSub = null;
await _networkController.releaseNetwork();
_fail( _fail(
LinkErrorReason.unknown, LinkErrorReason.connectionFailed,
e.message ?? 'Platform error requesting the mLRS network.', e.message ?? 'Platform error while opening the mLRS socket.',
); );
} }
} }
@@ -184,59 +167,61 @@ class UdpTransport implements LinkTransport {
if (event is! MlrsNetworkLost) return; if (event is! MlrsNetworkLost) return;
_networkLossSub?.cancel(); _networkLossSub?.cancel();
_networkLossSub = null; _networkLossSub = null;
_socketSub?.cancel(); _packetSub?.cancel();
_socketSub = null; _packetSub = null;
_socket?.close();
_socket = null;
_boundPort = null; _boundPort = null;
if (_socketOpen) {
_socketOpen = false;
unawaited(_networkController.closeSocket());
}
_setError( _setError(
LinkErrorReason.networkLost, LinkErrorReason.networkLost,
'The mLRS WiFi network was lost.', 'The mLRS WiFi network was lost.',
); );
} }
void _onSocketEvent(RawSocketEvent event) { void _onPacket(MlrsSocketPacket packet) {
if (event != RawSocketEvent.read) return; _peerAddress = InternetAddress.tryParse(packet.address) ?? _peerAddress;
final datagram = _socket?.receive(); _peerPort = packet.port;
if (datagram == null) return; _incomingController.add(packet.data);
_peerAddress = datagram.address;
_peerPort = datagram.port;
_incomingController.add(datagram.data);
} }
@override @override
Future<void> disconnect() async { Future<void> disconnect() async {
await _networkLossSub?.cancel(); await _networkLossSub?.cancel();
_networkLossSub = null; _networkLossSub = null;
await _socketSub?.cancel(); await _packetSub?.cancel();
_socketSub = null; _packetSub = null;
_socket?.close();
_socket = null;
_boundPort = null; _boundPort = null;
if (_socketOpen) {
_socketOpen = false;
await _networkController.closeSocket();
}
await _networkController.releaseNetwork(); await _networkController.releaseNetwork();
_setState(LinkState.disconnected); _setState(LinkState.disconnected);
} }
@override @override
Future<void> send(Uint8List data) async { Future<void> send(Uint8List data) async {
final socket = _socket;
final peer = _peerAddress; final peer = _peerAddress;
final port = _peerPort; final port = _peerPort;
if (socket == null || peer == null || port == null) { if (!_socketOpen || peer == null || port == null) {
throw const LinkConnectException( throw const LinkConnectException(
LinkErrorReason.connectionFailed, LinkErrorReason.connectionFailed,
'send() ohne aktive Verbindung oder bekannte Gegenstelle aufgerufen.', 'send() ohne aktive Verbindung oder bekannte Gegenstelle aufgerufen.',
); );
} }
socket.send(data, peer, port); await _networkController.send(data, peer.address, port);
} }
@override @override
void dispose() { void dispose() {
_networkLossSub?.cancel(); _networkLossSub?.cancel();
_socketSub?.cancel(); _packetSub?.cancel();
_socket?.close(); if (_socketOpen) {
_socket = null; _socketOpen = false;
unawaited(_networkController.closeSocket());
}
unawaited(_networkController.releaseNetwork()); unawaited(_networkController.releaseNetwork());
_networkController.dispose(); _networkController.dispose();
_incomingController.close(); _incomingController.close();
+63 -59
View File
@@ -1,5 +1,4 @@
import 'dart:async'; import 'dart:async';
import 'dart:io';
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
@@ -9,24 +8,34 @@ import 'package:dmc_app/transport/udp/mlrs_network_controller.dart';
import 'package:dmc_app/transport/udp/udp_transport.dart'; import 'package:dmc_app/transport/udp/udp_transport.dart';
/// Test-Doppel fuer [MlrsNetworkController] (Doku Kommunikationsschicht v2, /// Test-Doppel fuer [MlrsNetworkController] (Doku Kommunikationsschicht v2,
/// Abschnitt 4): simuliert Netzanfrage-Ergebnisse ueber [emit], ohne einen /// Abschnitt 4): simuliert Netzanfrage- und Socket-Ergebnisse rein in Dart,
/// echten Android-Platform-Channel zu brauchen. Zeichnet Aufrufe auf, damit /// ohne einen echten Android-Platform-Channel/nativen Socket zu brauchen.
/// Tests den Bindungsfenster-Ablauf (bind -> Socket -> unbind) pruefen /// Der eigentliche `DatagramSocket` lebt seit der Umstellung auf
/// koennen. /// socket-genaue Bindung (statt Prozessbindung) nativ in
/// MlrsNetworkPlugin.kt - dieses Fake deckt daher nur noch die
/// Orchestrierung in UdpTransport ab, nicht mehr echtes UDP-Verhalten.
class FakeMlrsNetworkController implements MlrsNetworkController { class FakeMlrsNetworkController implements MlrsNetworkController {
final _eventsController = StreamController<MlrsNetworkEvent>.broadcast(); final _eventsController = StreamController<MlrsNetworkEvent>.broadcast();
final _packetsController = StreamController<MlrsSocketPacket>.broadcast();
final List<String> requestedSsidPrefixes = []; final List<String> requestedSsidPrefixes = [];
final List<({Uint8List data, String host, int port})> sentPackets = [];
int releaseCount = 0; int releaseCount = 0;
int bindCount = 0; int openSocketCount = 0;
int unbindCount = 0; int closeSocketCount = 0;
bool bindResult = true;
bool? staConcurrencyResult; bool? staConcurrencyResult;
int nextBoundPort = 14550;
int? nextFallbackFromPort;
@override @override
Stream<MlrsNetworkEvent> get events => _eventsController.stream; Stream<MlrsNetworkEvent> get events => _eventsController.stream;
@override
Stream<MlrsSocketPacket> get incomingPackets => _packetsController.stream;
void emit(MlrsNetworkEvent event) => _eventsController.add(event); void emit(MlrsNetworkEvent event) => _eventsController.add(event);
void emitPacket(MlrsSocketPacket packet) => _packetsController.add(packet);
@override @override
Future<void> requestNetwork(String ssidPrefix) async { Future<void> requestNetwork(String ssidPrefix) async {
@@ -39,14 +48,22 @@ class FakeMlrsNetworkController implements MlrsNetworkController {
} }
@override @override
Future<bool> bindProcessToNetwork() async { Future<UdpSocketBindResult> openSocket(int port) async {
bindCount++; openSocketCount++;
return bindResult; return UdpSocketBindResult(
boundPort: nextBoundPort,
fallbackFromPort: nextFallbackFromPort,
);
} }
@override @override
Future<void> unbindProcessFromNetwork() async { Future<void> closeSocket() async {
unbindCount++; closeSocketCount++;
}
@override
Future<void> send(Uint8List data, String host, int port) async {
sentPackets.add((data: data, host: host, port: port));
} }
@override @override
@@ -55,6 +72,7 @@ class FakeMlrsNetworkController implements MlrsNetworkController {
@override @override
void dispose() { void dispose() {
_eventsController.close(); _eventsController.close();
_packetsController.close();
} }
} }
@@ -96,16 +114,14 @@ void main() {
transport.dispose(); transport.dispose();
}); });
test( test('connect() opens the native socket and reports the bound port',
'connect() binds process to network, creates the socket, then unbinds',
() async { () async {
final fake = FakeMlrsNetworkController(); final fake = FakeMlrsNetworkController()..nextBoundPort = 14550;
final transport = await connectWithFake(fake); final transport = await connectWithFake(fake);
expect(transport.currentState, LinkState.connected); expect(transport.currentState, LinkState.connected);
expect(transport.boundPort, isNotNull); expect(transport.boundPort, 14550);
expect(fake.bindCount, 1); expect(fake.openSocketCount, 1);
expect(fake.unbindCount, 1);
transport.dispose(); transport.dispose();
}); });
@@ -130,6 +146,7 @@ void main() {
); );
expect(transport.lastErrorReason, LinkErrorReason.networkUnavailable); expect(transport.lastErrorReason, LinkErrorReason.networkUnavailable);
expect(transport.currentState, LinkState.error); expect(transport.currentState, LinkState.error);
expect(fake.openSocketCount, 0);
transport.dispose(); transport.dispose();
}); });
@@ -145,6 +162,7 @@ void main() {
expect(transport.currentState, LinkState.error); expect(transport.currentState, LinkState.error);
expect(transport.lastErrorReason, LinkErrorReason.networkLost); expect(transport.lastErrorReason, LinkErrorReason.networkLost);
expect(transport.boundPort, isNull); expect(transport.boundPort, isNull);
expect(fake.closeSocketCount, 1);
transport.dispose(); transport.dispose();
}); });
@@ -178,24 +196,22 @@ void main() {
test('incoming packets arrive via incoming and learn the peer', () async { test('incoming packets arrive via incoming and learn the peer', () async {
final fake = FakeMlrsNetworkController(); final fake = FakeMlrsNetworkController();
final transport = await connectWithFake(fake); final transport = await connectWithFake(fake);
final sender = await RawDatagramSocket.bind(InternetAddress.loopbackIPv4, 0);
final received = <Uint8List>[]; final received = <Uint8List>[];
transport.incoming.listen(received.add); transport.incoming.listen(received.add);
sender.send( fake.emitPacket(MlrsSocketPacket(
Uint8List.fromList([1, 2, 3]), address: '192.168.4.100',
InternetAddress.loopbackIPv4, port: 5555,
transport.boundPort!, data: Uint8List.fromList([1, 2, 3]),
); ));
await Future<void>.delayed(const Duration(milliseconds: 50)); await pump();
expect(received, [ expect(received, [
Uint8List.fromList([1, 2, 3]), Uint8List.fromList([1, 2, 3]),
]); ]);
expect(transport.peerDescription, contains(sender.port.toString())); expect(transport.peerDescription, '192.168.4.100:5555');
sender.close();
transport.dispose(); transport.dispose();
}); });
@@ -211,37 +227,25 @@ void main() {
transport.dispose(); transport.dispose();
}); });
test('send() sends bytes to the learned peer', () async { test('send() forwards bytes to the learned peer via the native socket',
() async {
final fake = FakeMlrsNetworkController(); final fake = FakeMlrsNetworkController();
final transport = await connectWithFake(fake); final transport = await connectWithFake(fake);
final peer = await RawDatagramSocket.bind(InternetAddress.loopbackIPv4, 0);
// Erst "sprechen" lassen, damit der Transport die Gegenstelle lernt fake.emitPacket(MlrsSocketPacket(
// (Doku Kommunikationsschicht v2 Abschnitt 3: "Gegenstelle aus dem address: '192.168.4.100',
// Verkehr lernen"). port: 5555,
peer.send( data: Uint8List.fromList([9]),
Uint8List.fromList([9]), ));
InternetAddress.loopbackIPv4, await pump();
transport.boundPort!,
);
await Future<void>.delayed(const Duration(milliseconds: 50));
final receivedByPeer = <Uint8List>[];
peer.listen((event) {
if (event == RawSocketEvent.read) {
final datagram = peer.receive();
if (datagram != null) receivedByPeer.add(datagram.data);
}
});
await transport.send(Uint8List.fromList([7, 7, 7])); await transport.send(Uint8List.fromList([7, 7, 7]));
await Future<void>.delayed(const Duration(milliseconds: 50));
expect(receivedByPeer, [ expect(fake.sentPackets, hasLength(1));
Uint8List.fromList([7, 7, 7]), expect(fake.sentPackets.single.data, Uint8List.fromList([7, 7, 7]));
]); expect(fake.sentPackets.single.host, '192.168.4.100');
expect(fake.sentPackets.single.port, 5555);
peer.close();
transport.dispose(); transport.dispose();
}); });
@@ -254,23 +258,23 @@ void main() {
expect(transport.currentState, LinkState.disconnected); expect(transport.currentState, LinkState.disconnected);
expect(transport.boundPort, isNull); expect(transport.boundPort, isNull);
expect(fake.closeSocketCount, 1);
expect(fake.releaseCount, greaterThanOrEqualTo(1)); expect(fake.releaseCount, greaterThanOrEqualTo(1));
transport.dispose(); transport.dispose();
}); });
test( test('reports a fallback port when the configured one was occupied natively',
'falls back to an ephemeral port when the configured one is occupied',
() async { () async {
final occupied = await RawDatagramSocket.bind(InternetAddress.anyIPv4, 0); final fake = FakeMlrsNetworkController()
final fake = FakeMlrsNetworkController(); ..nextBoundPort = 51000
final transport = await connectWithFake(fake, port: occupied.port); ..nextFallbackFromPort = 14550;
final transport = await connectWithFake(fake, port: 14550);
expect(transport.currentState, LinkState.connected); expect(transport.currentState, LinkState.connected);
expect(transport.boundPort, isNot(occupied.port)); expect(transport.boundPort, 51000);
expect(transport.lastErrorMessage, contains('in use')); expect(transport.lastErrorMessage, contains('in use'));
occupied.close();
transport.dispose(); transport.dispose();
}); });