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
+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),