autoconnect on fly mode (does not work yet)

This commit is contained in:
Constantin Leue
2026-08-02 08:25:23 +02:00
parent 5e9ef43f2f
commit 607dbb6584
13 changed files with 430 additions and 29 deletions
@@ -6,6 +6,7 @@ import android.net.LinkProperties
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import android.net.wifi.WifiInfo
import android.net.wifi.WifiManager
import android.net.wifi.WifiNetworkSpecifier
import android.os.Build
@@ -101,7 +102,8 @@ class MlrsNetworkPlugin(
result.error("invalid_argument", "ssidPrefix darf nicht leer sein", null)
return
}
requestNetwork(ssidPrefix)
val exactSsid = call.argument<String>("exactSsid")
requestNetwork(ssidPrefix, exactSsid)
result.success(null)
}
"releaseNetwork" -> {
@@ -133,14 +135,24 @@ class MlrsNetworkPlugin(
}
}
private fun requestNetwork(ssidPrefix: String) {
private fun requestNetwork(ssidPrefix: String, exactSsid: 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()
// Ist eine zuvor erfolgreich verbundene SSID bekannt, exakt statt per
// Praefix anfragen: Android merkt sich die Nutzerzustimmung nur fuer
// exakte WifiNetworkSpecifier-Treffer und kann dann kuenftig ohne
// erneuten Systemdialog automatisch verbinden. Praefix-Anfragen
// zeigen dagegen praktisch immer den Dialog, weil die aufgeloeste
// SSID (zufaellige Endung) sich aendern koennte.
val specifierBuilder = WifiNetworkSpecifier.Builder()
if (!exactSsid.isNullOrEmpty()) {
specifierBuilder.setSsid(exactSsid)
} else {
specifierBuilder.setSsidPattern(PatternMatcher(ssidPrefix, PatternMatcher.PATTERN_PREFIX))
}
val specifier = specifierBuilder.build()
val request = NetworkRequest.Builder()
.addTransportType(NetworkCapabilities.TRANSPORT_WIFI)
@@ -152,8 +164,9 @@ class MlrsNetworkPlugin(
override fun onAvailable(network: Network) {
currentNetwork = network
val gateway = gatewayAddressOf(connectivityManager.getLinkProperties(network))
val ssid = ssidOf(connectivityManager.getNetworkCapabilities(network))
eventSink?.success(
mapOf("type" to "available", "gatewayAddress" to gateway),
mapOf("type" to "available", "gatewayAddress" to gateway, "ssid" to ssid),
)
}
@@ -189,6 +202,20 @@ class MlrsNetworkPlugin(
return route?.gateway?.hostAddress
}
/**
* Ermittelt die tatsaechliche SSID des verbundenen Netzes. Erfordert
* normalerweise ACCESS_FINE_LOCATION, ausser fuer die anfragende App
* selbst bei einem ueber WifiNetworkSpecifier bewilligten Netz - genau
* unser Fall, siehe `WifiInfo.getSSID()`-Dokumentation.
*/
private fun ssidOf(capabilities: NetworkCapabilities?): String? {
val wifiInfo = capabilities?.transportInfo as? WifiInfo ?: return null
val ssid = wifiInfo.ssid
if (ssid.isNullOrEmpty() || ssid == WifiManager.UNKNOWN_SSID) return null
// WifiInfo.getSSID() liefert UTF-8-SSIDs in Anfuehrungszeichen.
return ssid.removeSurrounding("\"")
}
private fun isStaConcurrencySupported(): Boolean? {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return null
val wifiManager =
+14 -1
View File
@@ -84,6 +84,13 @@ class AppSettingsTable extends Table {
TextColumn get connectionWifiSsidPrefix =>
text().withDefault(const Constant(kDefaultWifiSsidPrefix))();
/// Exakte SSID der zuletzt erfolgreich verbundenen mLRS-Bruecke (Doku
/// Abschnitt 8: "verbundenes WLAN merken") - null, solange noch nie
/// erfolgreich verbunden wurde. Wird fuer kuenftige Verbindungsversuche
/// als exakter WifiNetworkSpecifier statt Praefix verwendet, damit Android
/// ohne erneuten Systemdialog automatisch verbinden kann.
TextColumn get connectionWifiLastSsid => text().nullable()();
@override
Set<Column> get primaryKey => {id};
}
@@ -95,7 +102,7 @@ class AppDatabase extends _$AppDatabase {
AppDatabase.forTesting(super.executor);
@override
int get schemaVersion => 5;
int get schemaVersion => 6;
@override
MigrationStrategy get migration => MigrationStrategy(
@@ -133,6 +140,12 @@ class AppDatabase extends _$AppDatabase {
appSettingsTable.connectionWifiSsidPrefix,
);
}
if (from < 6) {
await m.addColumn(
appSettingsTable,
appSettingsTable.connectionWifiLastSsid,
);
}
},
);
+96 -3
View File
@@ -1394,6 +1394,17 @@ class $AppSettingsTableTable extends AppSettingsTable
requiredDuringInsert: false,
defaultValue: const Constant(kDefaultWifiSsidPrefix),
);
static const VerificationMeta _connectionWifiLastSsidMeta =
const VerificationMeta('connectionWifiLastSsid');
@override
late final GeneratedColumn<String> connectionWifiLastSsid =
GeneratedColumn<String>(
'connection_wifi_last_ssid',
aliasedName,
true,
type: DriftSqlType.string,
requiredDuringInsert: false,
);
@override
List<GeneratedColumn> get $columns => [
id,
@@ -1405,6 +1416,7 @@ class $AppSettingsTableTable extends AppSettingsTable
connectionWifiHost,
connectionWifiPort,
connectionWifiSsidPrefix,
connectionWifiLastSsid,
];
@override
String get aliasedName => _alias ?? actualTableName;
@@ -1493,6 +1505,15 @@ class $AppSettingsTableTable extends AppSettingsTable
),
);
}
if (data.containsKey('connection_wifi_last_ssid')) {
context.handle(
_connectionWifiLastSsidMeta,
connectionWifiLastSsid.isAcceptableOrUnknown(
data['connection_wifi_last_ssid']!,
_connectionWifiLastSsidMeta,
),
);
}
return context;
}
@@ -1538,6 +1559,10 @@ class $AppSettingsTableTable extends AppSettingsTable
DriftSqlType.string,
data['${effectivePrefix}connection_wifi_ssid_prefix'],
)!,
connectionWifiLastSsid: attachedDatabase.typeMapping.read(
DriftSqlType.string,
data['${effectivePrefix}connection_wifi_last_ssid'],
),
);
}
@@ -1578,6 +1603,13 @@ class AppSettingsTableData extends DataClass
/// `WifiNetworkSpecifier` (Doku Kommunikationsschicht v2 Abschnitt 4/8),
/// Standard "mLRS-", ueberschreibbar.
final String connectionWifiSsidPrefix;
/// Exakte SSID der zuletzt erfolgreich verbundenen mLRS-Bruecke (Doku
/// Abschnitt 8: "verbundenes WLAN merken") - null, solange noch nie
/// erfolgreich verbunden wurde. Wird fuer kuenftige Verbindungsversuche
/// als exakter WifiNetworkSpecifier statt Praefix verwendet, damit Android
/// ohne erneuten Systemdialog automatisch verbinden kann.
final String? connectionWifiLastSsid;
const AppSettingsTableData({
required this.id,
this.activeDroneProfileId,
@@ -1588,6 +1620,7 @@ class AppSettingsTableData extends DataClass
this.connectionWifiHost,
required this.connectionWifiPort,
required this.connectionWifiSsidPrefix,
this.connectionWifiLastSsid,
});
@override
Map<String, Expression> toColumns(bool nullToAbsent) {
@@ -1613,6 +1646,11 @@ class AppSettingsTableData extends DataClass
map['connection_wifi_ssid_prefix'] = Variable<String>(
connectionWifiSsidPrefix,
);
if (!nullToAbsent || connectionWifiLastSsid != null) {
map['connection_wifi_last_ssid'] = Variable<String>(
connectionWifiLastSsid,
);
}
return map;
}
@@ -1635,6 +1673,9 @@ class AppSettingsTableData extends DataClass
: Value(connectionWifiHost),
connectionWifiPort: Value(connectionWifiPort),
connectionWifiSsidPrefix: Value(connectionWifiSsidPrefix),
connectionWifiLastSsid: connectionWifiLastSsid == null && nullToAbsent
? const Value.absent()
: Value(connectionWifiLastSsid),
);
}
@@ -1663,6 +1704,9 @@ class AppSettingsTableData extends DataClass
connectionWifiSsidPrefix: serializer.fromJson<String>(
json['connectionWifiSsidPrefix'],
),
connectionWifiLastSsid: serializer.fromJson<String?>(
json['connectionWifiLastSsid'],
),
);
}
@override
@@ -1682,6 +1726,9 @@ class AppSettingsTableData extends DataClass
'connectionWifiSsidPrefix': serializer.toJson<String>(
connectionWifiSsidPrefix,
),
'connectionWifiLastSsid': serializer.toJson<String?>(
connectionWifiLastSsid,
),
};
}
@@ -1695,6 +1742,7 @@ class AppSettingsTableData extends DataClass
Value<String?> connectionWifiHost = const Value.absent(),
int? connectionWifiPort,
String? connectionWifiSsidPrefix,
Value<String?> connectionWifiLastSsid = const Value.absent(),
}) => AppSettingsTableData(
id: id ?? this.id,
activeDroneProfileId: activeDroneProfileId.present
@@ -1714,6 +1762,9 @@ class AppSettingsTableData extends DataClass
connectionWifiPort: connectionWifiPort ?? this.connectionWifiPort,
connectionWifiSsidPrefix:
connectionWifiSsidPrefix ?? this.connectionWifiSsidPrefix,
connectionWifiLastSsid: connectionWifiLastSsid.present
? connectionWifiLastSsid.value
: this.connectionWifiLastSsid,
);
AppSettingsTableData copyWithCompanion(AppSettingsTableCompanion data) {
return AppSettingsTableData(
@@ -1742,6 +1793,9 @@ class AppSettingsTableData extends DataClass
connectionWifiSsidPrefix: data.connectionWifiSsidPrefix.present
? data.connectionWifiSsidPrefix.value
: this.connectionWifiSsidPrefix,
connectionWifiLastSsid: data.connectionWifiLastSsid.present
? data.connectionWifiLastSsid.value
: this.connectionWifiLastSsid,
);
}
@@ -1756,7 +1810,8 @@ class AppSettingsTableData extends DataClass
..write('connectionType: $connectionType, ')
..write('connectionWifiHost: $connectionWifiHost, ')
..write('connectionWifiPort: $connectionWifiPort, ')
..write('connectionWifiSsidPrefix: $connectionWifiSsidPrefix')
..write('connectionWifiSsidPrefix: $connectionWifiSsidPrefix, ')
..write('connectionWifiLastSsid: $connectionWifiLastSsid')
..write(')'))
.toString();
}
@@ -1772,6 +1827,7 @@ class AppSettingsTableData extends DataClass
connectionWifiHost,
connectionWifiPort,
connectionWifiSsidPrefix,
connectionWifiLastSsid,
);
@override
bool operator ==(Object other) =>
@@ -1785,7 +1841,8 @@ class AppSettingsTableData extends DataClass
other.connectionType == this.connectionType &&
other.connectionWifiHost == this.connectionWifiHost &&
other.connectionWifiPort == this.connectionWifiPort &&
other.connectionWifiSsidPrefix == this.connectionWifiSsidPrefix);
other.connectionWifiSsidPrefix == this.connectionWifiSsidPrefix &&
other.connectionWifiLastSsid == this.connectionWifiLastSsid);
}
class AppSettingsTableCompanion extends UpdateCompanion<AppSettingsTableData> {
@@ -1798,6 +1855,7 @@ class AppSettingsTableCompanion extends UpdateCompanion<AppSettingsTableData> {
final Value<String?> connectionWifiHost;
final Value<int> connectionWifiPort;
final Value<String> connectionWifiSsidPrefix;
final Value<String?> connectionWifiLastSsid;
const AppSettingsTableCompanion({
this.id = const Value.absent(),
this.activeDroneProfileId = const Value.absent(),
@@ -1808,6 +1866,7 @@ class AppSettingsTableCompanion extends UpdateCompanion<AppSettingsTableData> {
this.connectionWifiHost = const Value.absent(),
this.connectionWifiPort = const Value.absent(),
this.connectionWifiSsidPrefix = const Value.absent(),
this.connectionWifiLastSsid = const Value.absent(),
});
AppSettingsTableCompanion.insert({
this.id = const Value.absent(),
@@ -1819,6 +1878,7 @@ class AppSettingsTableCompanion extends UpdateCompanion<AppSettingsTableData> {
this.connectionWifiHost = const Value.absent(),
this.connectionWifiPort = const Value.absent(),
this.connectionWifiSsidPrefix = const Value.absent(),
this.connectionWifiLastSsid = const Value.absent(),
});
static Insertable<AppSettingsTableData> custom({
Expression<int>? id,
@@ -1830,6 +1890,7 @@ class AppSettingsTableCompanion extends UpdateCompanion<AppSettingsTableData> {
Expression<String>? connectionWifiHost,
Expression<int>? connectionWifiPort,
Expression<String>? connectionWifiSsidPrefix,
Expression<String>? connectionWifiLastSsid,
}) {
return RawValuesInsertable({
if (id != null) 'id': id,
@@ -1847,6 +1908,8 @@ class AppSettingsTableCompanion extends UpdateCompanion<AppSettingsTableData> {
'connection_wifi_port': connectionWifiPort,
if (connectionWifiSsidPrefix != null)
'connection_wifi_ssid_prefix': connectionWifiSsidPrefix,
if (connectionWifiLastSsid != null)
'connection_wifi_last_ssid': connectionWifiLastSsid,
});
}
@@ -1860,6 +1923,7 @@ class AppSettingsTableCompanion extends UpdateCompanion<AppSettingsTableData> {
Value<String?>? connectionWifiHost,
Value<int>? connectionWifiPort,
Value<String>? connectionWifiSsidPrefix,
Value<String?>? connectionWifiLastSsid,
}) {
return AppSettingsTableCompanion(
id: id ?? this.id,
@@ -1874,6 +1938,8 @@ class AppSettingsTableCompanion extends UpdateCompanion<AppSettingsTableData> {
connectionWifiPort: connectionWifiPort ?? this.connectionWifiPort,
connectionWifiSsidPrefix:
connectionWifiSsidPrefix ?? this.connectionWifiSsidPrefix,
connectionWifiLastSsid:
connectionWifiLastSsid ?? this.connectionWifiLastSsid,
);
}
@@ -1915,6 +1981,11 @@ class AppSettingsTableCompanion extends UpdateCompanion<AppSettingsTableData> {
connectionWifiSsidPrefix.value,
);
}
if (connectionWifiLastSsid.present) {
map['connection_wifi_last_ssid'] = Variable<String>(
connectionWifiLastSsid.value,
);
}
return map;
}
@@ -1929,7 +2000,8 @@ class AppSettingsTableCompanion extends UpdateCompanion<AppSettingsTableData> {
..write('connectionType: $connectionType, ')
..write('connectionWifiHost: $connectionWifiHost, ')
..write('connectionWifiPort: $connectionWifiPort, ')
..write('connectionWifiSsidPrefix: $connectionWifiSsidPrefix')
..write('connectionWifiSsidPrefix: $connectionWifiSsidPrefix, ')
..write('connectionWifiLastSsid: $connectionWifiLastSsid')
..write(')'))
.toString();
}
@@ -2574,6 +2646,7 @@ typedef $$AppSettingsTableTableCreateCompanionBuilder =
Value<String?> connectionWifiHost,
Value<int> connectionWifiPort,
Value<String> connectionWifiSsidPrefix,
Value<String?> connectionWifiLastSsid,
});
typedef $$AppSettingsTableTableUpdateCompanionBuilder =
AppSettingsTableCompanion Function({
@@ -2586,6 +2659,7 @@ typedef $$AppSettingsTableTableUpdateCompanionBuilder =
Value<String?> connectionWifiHost,
Value<int> connectionWifiPort,
Value<String> connectionWifiSsidPrefix,
Value<String?> connectionWifiLastSsid,
});
class $$AppSettingsTableTableFilterComposer
@@ -2641,6 +2715,11 @@ class $$AppSettingsTableTableFilterComposer
column: $table.connectionWifiSsidPrefix,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<String> get connectionWifiLastSsid => $composableBuilder(
column: $table.connectionWifiLastSsid,
builder: (column) => ColumnFilters(column),
);
}
class $$AppSettingsTableTableOrderingComposer
@@ -2696,6 +2775,11 @@ class $$AppSettingsTableTableOrderingComposer
column: $table.connectionWifiSsidPrefix,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<String> get connectionWifiLastSsid => $composableBuilder(
column: $table.connectionWifiLastSsid,
builder: (column) => ColumnOrderings(column),
);
}
class $$AppSettingsTableTableAnnotationComposer
@@ -2749,6 +2833,11 @@ class $$AppSettingsTableTableAnnotationComposer
column: $table.connectionWifiSsidPrefix,
builder: (column) => column,
);
GeneratedColumn<String> get connectionWifiLastSsid => $composableBuilder(
column: $table.connectionWifiLastSsid,
builder: (column) => column,
);
}
class $$AppSettingsTableTableTableManager
@@ -2797,6 +2886,7 @@ class $$AppSettingsTableTableTableManager
Value<String?> connectionWifiHost = const Value.absent(),
Value<int> connectionWifiPort = const Value.absent(),
Value<String> connectionWifiSsidPrefix = const Value.absent(),
Value<String?> connectionWifiLastSsid = const Value.absent(),
}) => AppSettingsTableCompanion(
id: id,
activeDroneProfileId: activeDroneProfileId,
@@ -2807,6 +2897,7 @@ class $$AppSettingsTableTableTableManager
connectionWifiHost: connectionWifiHost,
connectionWifiPort: connectionWifiPort,
connectionWifiSsidPrefix: connectionWifiSsidPrefix,
connectionWifiLastSsid: connectionWifiLastSsid,
),
createCompanionCallback:
({
@@ -2819,6 +2910,7 @@ class $$AppSettingsTableTableTableManager
Value<String?> connectionWifiHost = const Value.absent(),
Value<int> connectionWifiPort = const Value.absent(),
Value<String> connectionWifiSsidPrefix = const Value.absent(),
Value<String?> connectionWifiLastSsid = const Value.absent(),
}) => AppSettingsTableCompanion.insert(
id: id,
activeDroneProfileId: activeDroneProfileId,
@@ -2829,6 +2921,7 @@ class $$AppSettingsTableTableTableManager
connectionWifiHost: connectionWifiHost,
connectionWifiPort: connectionWifiPort,
connectionWifiSsidPrefix: connectionWifiSsidPrefix,
connectionWifiLastSsid: connectionWifiLastSsid,
),
withReferenceMapper: (p0) => p0
.map((e) => (e.readTable(table), BaseReferences(db, table, e)))
@@ -23,6 +23,7 @@ class AppSettingsRepository {
String? connectionWifiHost,
int connectionWifiPort,
String connectionWifiSsidPrefix,
String? connectionWifiLastSsid,
})> load() async {
final row = await (_db.select(_db.appSettingsTable)
..where((s) => s.id.equals(_rowId)))
@@ -40,6 +41,7 @@ class AppSettingsRepository {
connectionWifiPort: row?.connectionWifiPort ?? kDefaultWifiPort,
connectionWifiSsidPrefix:
row?.connectionWifiSsidPrefix ?? kDefaultWifiSsidPrefix,
connectionWifiLastSsid: row?.connectionWifiLastSsid,
);
}
@@ -119,6 +121,18 @@ class AppSettingsRepository {
));
}
/// Exakte SSID der zuletzt erfolgreich verbundenen mLRS-Bruecke (Doku
/// Abschnitt 8), oder null solange noch nie erfolgreich verbunden wurde.
Future<String?> loadConnectionWifiLastSsid() async =>
(await load()).connectionWifiLastSsid;
Future<void> setConnectionWifiLastSsid(String? ssid) async {
await _upsert(AppSettingsTableCompanion(
id: const Value(_rowId),
connectionWifiLastSsid: Value(ssid),
));
}
Future<void> _upsert(AppSettingsTableCompanion companion) async {
await _db.into(_db.appSettingsTable).insertOnConflictUpdate(companion);
}
@@ -6,14 +6,19 @@ sealed class MlrsNetworkEvent {
const MlrsNetworkEvent();
}
/// Das Netz ist verfuegbar (Nutzer hat den Systemdialog bestaetigt).
/// Das Netz ist verfuegbar (Nutzer hat den Systemdialog bestaetigt, oder
/// Android hat eine zuvor bewilligte exakte SSID still erneut verbunden).
/// [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).
/// Verkehr gelernt wurde (Doku Abschnitt 3). [ssid] ist die tatsaechliche
/// SSID des verbundenen Netzes, falls ermittelbar - wird gemerkt, damit
/// kuenftige Verbindungen exakt (statt per Praefix) angefragt werden koennen
/// (siehe [MlrsNetworkController.requestNetwork]).
class MlrsNetworkAvailable extends MlrsNetworkEvent {
const MlrsNetworkAvailable({this.gatewayAddress});
const MlrsNetworkAvailable({this.gatewayAddress, this.ssid});
final String? gatewayAddress;
final String? ssid;
}
/// Die Anfrage konnte nicht erfuellt werden (Nutzer hat den Systemdialog
@@ -79,9 +84,13 @@ abstract class MlrsNetworkController {
Stream<MlrsNetworkEvent> get events;
/// Loest `ConnectivityManager.requestNetwork()` mit einem
/// `WifiNetworkSpecifier` aus, dessen SSID-Muster auf [ssidPrefix] passt.
/// `WifiNetworkSpecifier` aus. Ist [preferredSsid] gesetzt (zuvor
/// erfolgreich verbundene, gemerkte SSID), wird exakt danach gefragt -
/// Android kann das dann ohne erneuten Systemdialog automatisch bewilligen
/// (Doku Abschnitt 8: "Verbindung automatisch herstellen"). Sonst wird
/// [ssidPrefix] als Praefix-Muster verwendet (zeigt immer den Dialog).
/// Das Ergebnis kommt asynchron ueber [events] (available/unavailable).
Future<void> requestNetwork(String ssidPrefix);
Future<void> requestNetwork(String ssidPrefix, {String? preferredSsid});
/// Meldet den Callback ab und geht damit von der aktuellen Anfrage
/// zurueck - keine weiteren [events] danach, bis erneut angefragt wird.
@@ -139,6 +148,7 @@ class MethodChannelMlrsNetworkController implements MlrsNetworkController {
return switch (map['type']) {
'available' => MlrsNetworkAvailable(
gatewayAddress: map['gatewayAddress'] as String?,
ssid: map['ssid'] as String?,
),
'lost' => const MlrsNetworkLost(),
_ => const MlrsNetworkUnavailable(),
@@ -155,8 +165,11 @@ class MethodChannelMlrsNetworkController implements MlrsNetworkController {
}
@override
Future<void> requestNetwork(String ssidPrefix) => _methodChannel
.invokeMethod('requestNetwork', {'ssidPrefix': ssidPrefix});
Future<void> requestNetwork(String ssidPrefix, {String? preferredSsid}) =>
_methodChannel.invokeMethod('requestNetwork', {
'ssidPrefix': ssidPrefix,
'exactSsid': preferredSsid,
});
@override
Future<void> releaseNetwork() => _methodChannel.invokeMethod('releaseNetwork');
+33 -3
View File
@@ -32,16 +32,22 @@ class UdpTransport implements LinkTransport {
required Future<String?> Function() getHost,
required Future<int> Function() getPort,
required Future<String> Function() getSsidPrefix,
required Future<String?> Function() getRememberedSsid,
required Future<void> Function(String ssid) rememberSsid,
MlrsNetworkController? networkController,
}) : _getHost = getHost,
_getPort = getPort,
_getSsidPrefix = getSsidPrefix,
_getRememberedSsid = getRememberedSsid,
_rememberSsid = rememberSsid,
_networkController =
networkController ?? MethodChannelMlrsNetworkController();
final Future<String?> Function() _getHost;
final Future<int> Function() _getPort;
final Future<String> Function() _getSsidPrefix;
final Future<String?> Function() _getRememberedSsid;
final Future<void> Function(String ssid) _rememberSsid;
final MlrsNetworkController _networkController;
final _incomingController = StreamController<Uint8List>.broadcast();
@@ -54,6 +60,7 @@ class UdpTransport implements LinkTransport {
int? _boundPort;
bool _socketOpen = false;
bool? _staConcurrencySupported;
String? _connectedSsid;
LinkState _state = LinkState.disconnected;
LinkErrorReason? _lastErrorReason;
@@ -85,6 +92,13 @@ class UdpTransport implements LinkTransport {
/// nicht ermittelbar (API < 30, Doku Abschnitt 4/8).
bool? get staConcurrencySupported => _staConcurrencySupported;
/// Tatsaechliche SSID des verbundenen Netzes, sobald von Android
/// ermittelbar (Doku Abschnitt 8: "verbundenes WLAN anzeigen"). Wird bei
/// jedem erfolgreichen [connect] auch dauerhaft gemerkt (siehe
/// [_rememberSsid]), damit kuenftige Verbindungen exakt statt per Praefix
/// angefragt werden koennen.
String? get connectedSsid => _connectedSsid;
@override
Future<void> connect() async {
_lastErrorReason = null;
@@ -92,13 +106,17 @@ class UdpTransport implements LinkTransport {
_setState(LinkState.connecting);
final ssidPrefix = await _getSsidPrefix();
final rememberedSsid = await _getRememberedSsid();
final firstEvent = Completer<MlrsNetworkEvent>();
final requestSub = _networkController.events.listen((event) {
if (!firstEvent.isCompleted) firstEvent.complete(event);
});
try {
await _networkController.requestNetwork(ssidPrefix);
await _networkController.requestNetwork(
ssidPrefix,
preferredSsid: rememberedSsid,
);
final event = await firstEvent.future;
await requestSub.cancel();
@@ -115,6 +133,11 @@ class UdpTransport implements LinkTransport {
// unten, nicht erst danach (Doku Abschnitt 8).
_networkLossSub = _networkController.events.listen(_onNetworkEvent);
_connectedSsid = available.ssid;
if (available.ssid != null) {
await _rememberSsid(available.ssid!);
}
final configuredHost = await _getHost();
final configuredPort = await _getPort();
final prefillHost = (configuredHost != null && configuredHost.isNotEmpty)
@@ -170,6 +193,7 @@ class UdpTransport implements LinkTransport {
_packetSub?.cancel();
_packetSub = null;
_boundPort = null;
_connectedSsid = null;
if (_socketOpen) {
_socketOpen = false;
unawaited(_networkController.closeSocket());
@@ -193,6 +217,7 @@ class UdpTransport implements LinkTransport {
await _packetSub?.cancel();
_packetSub = null;
_boundPort = null;
_connectedSsid = null;
if (_socketOpen) {
_socketOpen = false;
await _networkController.closeSocket();
@@ -220,9 +245,14 @@ class UdpTransport implements LinkTransport {
_packetSub?.cancel();
if (_socketOpen) {
_socketOpen = false;
unawaited(_networkController.closeSocket());
// dispose() darf nie eine unbehandelte Future-Exception hinterlassen -
// z.B. wirft der Platform-Channel MissingPluginException, wenn
// ueberhaupt kein natives Gegenstueck existiert (Tests, Web/Desktop-
// UI-Vorschau). Der Aufrufer kann an dieser Stelle ohnehin nicht mehr
// reagieren.
unawaited(_networkController.closeSocket().catchError((_) {}));
}
unawaited(_networkController.releaseNetwork());
unawaited(_networkController.releaseNetwork().catchError((_) {}));
_networkController.dispose();
_incomingController.close();
_stateController.close();
+2 -2
View File
@@ -9,6 +9,6 @@ abstract final class DmcColors {
static const liveGreen = Color(0xFF3ECF8E);
static const warnRed = Color(0xFFE2574A);
static const pafAmber = Color(0xFFF0C86E);
static const editModeGreen = Color(0xFF50C878);
static const flyModeRed = Color(0xFFE2574A);
static const planModeColor = Color(0xFFE2574A);
static const flyModeColor = Color(0xFF50C878);
}
@@ -16,6 +16,8 @@ final wifiTransportProvider = Provider<UdpTransport>((ref) {
getHost: settingsRepo.loadConnectionWifiHost,
getPort: settingsRepo.loadConnectionWifiPort,
getSsidPrefix: settingsRepo.loadConnectionWifiSsidPrefix,
getRememberedSsid: settingsRepo.loadConnectionWifiLastSsid,
rememberSsid: settingsRepo.setConnectionWifiLastSsid,
);
ref.onDispose(transport.dispose);
return transport;
@@ -134,9 +136,10 @@ class WifiConnectionLogNotifier
final message = switch (linkState) {
LinkState.disconnected => 'Disconnected',
LinkState.connecting => 'Requesting mLRS network...',
LinkState.connected =>
'Network available, listening on port ${transport.boundPort}'
' (STA concurrency: ${_formatStaConcurrency(transport.staConcurrencySupported)})',
LinkState.connected => 'Connected to '
'${transport.connectedSsid ?? 'network'}, listening on port '
'${transport.boundPort} (STA concurrency: '
'${_formatStaConcurrency(transport.staConcurrencySupported)})',
LinkState.error => 'Error: '
'${transport.lastErrorMessage ?? transport.lastErrorReason?.name ?? 'unknown'}',
};
@@ -150,6 +150,12 @@ class _WifiPanelState extends ConsumerState<_WifiPanel> {
final connectionSettings = ref.watch(connectionSettingsProvider).value;
final log = ref.watch(wifiConnectionLogProvider);
final packetCount = ref.watch(wifiConnectionLogProvider.notifier).packetCount;
// Kein eigener Reaktivitaets-Provider fuer connectedSsid noetig - der
// Rebuild wird bereits durch das Beobachten von wifiLinkStateProvider
// oben ausgeloest, und das Feld wird synchron gesetzt, bevor der
// Transport auf LinkState.connected wechselt (siehe UdpTransport.connect).
final connectedSsid = ref.read(wifiTransportProvider).connectedSsid;
final rememberedSsid = connectionSettings?.connectionWifiLastSsid;
// Controller einmalig aus den gespeicherten Einstellungen befuellen -
// nicht bei jedem Rebuild, sonst wird waehrend der Nutzereingabe
@@ -188,6 +194,23 @@ class _WifiPanelState extends ConsumerState<_WifiPanel> {
'break maps/weather.',
style: TextStyle(fontSize: 12, color: Colors.grey),
),
const SizedBox(height: 8),
if (connectedSsid != null)
Text(
'Connected to: $connectedSsid',
style: const TextStyle(fontSize: 12, color: DmcColors.liveGreen),
)
else if (rememberedSsid != null)
Text(
'Remembered network: $rememberedSsid',
style: const TextStyle(fontSize: 12, color: Colors.grey),
)
else
const Text(
'No network remembered yet - connect once to remember it for '
'automatic reconnection.',
style: TextStyle(fontSize: 12, color: Colors.grey),
),
if (errorReason != null) ...[
const SizedBox(height: 12),
_WifiErrorHint(reason: errorReason),
@@ -253,6 +276,22 @@ class _WifiPanelState extends ConsumerState<_WifiPanel> {
),
],
),
const SizedBox(height: 8),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Auto-connect when entering Fly mode'),
subtitle: const Text(
'Only once a network has been remembered (see above).',
style: TextStyle(fontSize: 11),
),
value: connectionSettings?.connectionAutoConnect ?? true,
onChanged: (value) async {
await ref
.read(appSettingsRepositoryProvider)
.setConnectionAutoConnect(value);
ref.invalidate(connectionSettingsProvider);
},
),
const SizedBox(height: 16),
Text(
'Connection log ($packetCount packets received)',
+91 -3
View File
@@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/material.dart';
@@ -5,8 +6,12 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../app_mode/app_mode_cubit.dart';
import '../../transport/connection_type.dart';
import '../../transport/link_transport.dart';
import '../dmc_colors.dart';
import '../providers/connection_settings_provider.dart';
import '../providers/flight_mode_provider.dart';
import '../providers/wifi_connection_provider.dart';
import 'flight_mode_pill.dart';
import 'fly_map_controls.dart';
import 'header_wind_pill.dart';
@@ -35,7 +40,7 @@ class TopModeBar extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final isFly = context.select((AppModeCubit c) => c.state.mode == AppMode.fly);
final activeTint = isFly ? DmcColors.flyModeRed : DmcColors.editModeGreen;
final activeTint = isFly ? DmcColors.flyModeColor : DmcColors.planModeColor;
// Kein SafeArea: das wendet links/rechts die jeweils tatsaechlichen
// (oft asymmetrischen) Insets an, wodurch die Leiste sichtbar von der
@@ -72,7 +77,7 @@ class TopModeBar extends ConsumerWidget {
_ModeButton(
label: 'Plan',
isActive: !isFly,
inactiveColor: DmcColors.editModeGreen,
inactiveColor: DmcColors.planModeColor,
cornerRadius: const BorderRadius.only(
bottomLeft: Radius.circular(26),
),
@@ -82,6 +87,8 @@ class TopModeBar extends ConsumerWidget {
const SizedBox(width: 8),
const HeaderWindPill(),
const SizedBox(width: 8),
const _WifiConnectionProgressPill(),
const SizedBox(width: 8),
const Expanded(child: FlightModePill()),
const SizedBox(width: 8),
const FlyMapControls(),
@@ -96,7 +103,7 @@ class TopModeBar extends ConsumerWidget {
_ModeButton(
label: 'Fly',
isActive: isFly,
inactiveColor: DmcColors.flyModeRed,
inactiveColor: DmcColors.flyModeColor,
cornerRadius: const BorderRadius.only(
bottomRight: Radius.circular(26),
),
@@ -105,6 +112,7 @@ class TopModeBar extends ConsumerWidget {
// Eintritt in den Fly-Modus setzt immer auf Waypoint
// zurueck (Doku 3.11).
ref.read(flightModeProvider.notifier).reset();
_maybeAutoConnectWifi(ref);
},
),
],
@@ -115,6 +123,86 @@ class TopModeBar extends ConsumerWidget {
}
}
/// Verbindet automatisch zur mLRS-Bruecke, wenn in den Fly-Modus gewechselt
/// wird (Doku Kommunikationsschicht v2 Abschnitt 8: "Verbindung automatisch
/// herstellen bei Wechsel in Fly, wenn WLAN gemerkt"). Voraussetzungen:
/// Verbindungsart ist WLAN, "Auto-connect"-Einstellung ist an, und es gibt
/// bereits eine gemerkte SSID aus einer frueheren erfolgreichen Verbindung -
/// ohne gemerkte SSID muesste ohnehin der Systemdialog erscheinen, das ist
/// dann kein "automatisches" Verbinden mehr. Der Fortschritt ist ueber
/// [_WifiConnectionProgressPill] sichtbar.
Future<void> _maybeAutoConnectWifi(WidgetRef ref) async {
final settings = ref.read(connectionSettingsProvider).value;
if (settings == null) return;
if (settings.connectionType != ConnectionType.wifi) return;
if (!settings.connectionAutoConnect) return;
if (settings.connectionWifiLastSsid == null) return;
final transport = ref.read(wifiTransportProvider);
if (transport.currentState == LinkState.connected ||
transport.currentState == LinkState.connecting) {
return;
}
try {
await transport.connect();
} on LinkConnectException {
// Grund/Nachricht sind bereits ueber den State-Stream gemeldet, siehe
// _WifiConnectionProgressPill.
}
}
/// Kompakte Fortschrittsanzeige fuer den automatischen WLAN-Verbindungsaufbau
/// (siehe [_maybeAutoConnectWifi]) - nur sichtbar, solange die Verbindungsart
/// WLAN ist und gerade etwas passiert bzw. ein Fehler vorliegt. Bleibt im
/// Ruhezustand (disconnected, kein vorheriger Fehler) unsichtbar, damit die
/// Kopfleiste nicht dauerhaft mit einer Statuspille zugestellt ist.
class _WifiConnectionProgressPill extends ConsumerWidget {
const _WifiConnectionProgressPill();
@override
Widget build(BuildContext context, WidgetRef ref) {
final connectionType = ref.watch(connectionSettingsProvider).value?.connectionType;
final linkState = ref.watch(wifiLinkStateProvider).value;
if (connectionType != ConnectionType.wifi) return const SizedBox.shrink();
if (linkState == null || linkState == LinkState.disconnected) {
return const SizedBox.shrink();
}
final (label, color) = switch (linkState) {
LinkState.connecting => ('Connecting…', DmcColors.pafAmber),
LinkState.connected => ('WiFi connected', DmcColors.liveGreen),
LinkState.error => ('WiFi error', DmcColors.warnRed),
LinkState.disconnected => ('', Colors.grey),
};
return Padding(
padding: const EdgeInsets.only(right: 8),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: color),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (linkState == LinkState.connecting) ...[
SizedBox(
width: 10,
height: 10,
child: CircularProgressIndicator(strokeWidth: 1.5, color: color),
),
const SizedBox(width: 6),
],
Text(label, style: TextStyle(color: color, fontSize: 11)),
],
),
),
);
}
}
class _ModeButton extends StatelessWidget {
const _ModeButton({
required this.label,
@@ -117,4 +117,23 @@ void main() {
expect(await repository.loadConnectionWifiSsidPrefix(), 'mLRS-1234');
});
test('gemerkte SSID: Standard ist null (noch nie verbunden)', () async {
final settings = await repository.load();
expect(settings.connectionWifiLastSsid, isNull);
});
test('setConnectionWifiLastSsid merkt sich die verbundene SSID', () async {
await repository.setConnectionWifiLastSsid('mLRS-ABCD');
expect(await repository.loadConnectionWifiLastSsid(), 'mLRS-ABCD');
});
test('setConnectionWifiLastSsid(null) loescht die Erinnerung wieder',
() async {
await repository.setConnectionWifiLastSsid('mLRS-ABCD');
await repository.setConnectionWifiLastSsid(null);
expect(await repository.loadConnectionWifiLastSsid(), isNull);
});
}
+49 -1
View File
@@ -19,7 +19,9 @@ class FakeMlrsNetworkController implements MlrsNetworkController {
final _packetsController = StreamController<MlrsSocketPacket>.broadcast();
final List<String> requestedSsidPrefixes = [];
final List<String?> requestedPreferredSsids = [];
final List<({Uint8List data, String host, int port})> sentPackets = [];
final List<String> rememberedSsids = [];
int releaseCount = 0;
int openSocketCount = 0;
int closeSocketCount = 0;
@@ -38,8 +40,9 @@ class FakeMlrsNetworkController implements MlrsNetworkController {
void emitPacket(MlrsSocketPacket packet) => _packetsController.add(packet);
@override
Future<void> requestNetwork(String ssidPrefix) async {
Future<void> requestNetwork(String ssidPrefix, {String? preferredSsid}) async {
requestedSsidPrefixes.add(ssidPrefix);
requestedPreferredSsids.add(preferredSsid);
}
@override
@@ -88,12 +91,15 @@ void main() {
String? host,
int port = 0,
String ssidPrefix = 'mLRS-',
String? rememberedSsid,
MlrsNetworkEvent event = const MlrsNetworkAvailable(),
}) async {
final transport = UdpTransport(
getHost: () async => host,
getPort: () async => port,
getSsidPrefix: () async => ssidPrefix,
getRememberedSsid: () async => rememberedSsid,
rememberSsid: (ssid) async => fake.rememberedSsids.add(ssid),
networkController: fake,
);
final connectFuture = transport.connect();
@@ -133,6 +139,8 @@ void main() {
getHost: () async => null,
getPort: () async => 0,
getSsidPrefix: () async => 'mLRS-',
getRememberedSsid: () async => null,
rememberSsid: (_) async {},
networkController: fake,
);
@@ -286,4 +294,44 @@ void main() {
transport.dispose();
});
test('passes the remembered SSID to requestNetwork for a silent reconnect',
() async {
final fake = FakeMlrsNetworkController();
final transport = await connectWithFake(
fake,
rememberedSsid: 'mLRS-1234',
);
expect(fake.requestedPreferredSsids, ['mLRS-1234']);
transport.dispose();
});
test('remembers the connected SSID reported by the platform', () async {
final fake = FakeMlrsNetworkController();
final transport = await connectWithFake(
fake,
event: const MlrsNetworkAvailable(ssid: 'mLRS-5678'),
);
expect(transport.connectedSsid, 'mLRS-5678');
expect(fake.rememberedSsids, ['mLRS-5678']);
transport.dispose();
});
test('connectedSsid is cleared on disconnect and network loss', () async {
final fake = FakeMlrsNetworkController();
final transport = await connectWithFake(
fake,
event: const MlrsNetworkAvailable(ssid: 'mLRS-5678'),
);
expect(transport.connectedSsid, 'mLRS-5678');
await transport.disconnect();
expect(transport.connectedSsid, isNull);
transport.dispose();
});
}
+14
View File
@@ -2,6 +2,7 @@ import 'dart:convert';
import 'package:drift/native.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -103,6 +104,19 @@ Future<ProviderContainer> _pumpWithContainer(WidgetTester tester) async {
}
void main() {
// TopModeBar instanziiert beim Wechsel in den Fly-Modus wifiTransportProvider
// (Fortschritts-Pille/Auto-Connect-Pruefung, siehe top_mode_bar.dart) - das
// erzeugt standardmaessig einen echten MethodChannelMlrsNetworkController.
// Ohne diesen Mock-Handler wirft schon der reine dispose()-Aufruf am
// Testende MissingPluginException, weil kein natives Android-Plugin
// existiert.
TestWidgetsFlutterBinding.ensureInitialized();
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(
const MethodChannel('com.dmc.dmc_app/mlrs_network'),
(call) async => null,
);
testWidgets('AppShell zeigt die Karte und den Plan/Fly-Umschalter im Startzustand',
(tester) async {
await tester.pumpWidget(_wrap(const AppShell()));