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