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
+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();
}