diff --git a/app/android/app/src/main/kotlin/com/dmc/dmc_app/MlrsNetworkPlugin.kt b/app/android/app/src/main/kotlin/com/dmc/dmc_app/MlrsNetworkPlugin.kt index 4a607b7..1116946 100644 --- a/app/android/app/src/main/kotlin/com/dmc/dmc_app/MlrsNetworkPlugin.kt +++ b/app/android/app/src/main/kotlin/com/dmc/dmc_app/MlrsNetworkPlugin.kt @@ -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("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 = diff --git a/app/lib/services/database/app_database.dart b/app/lib/services/database/app_database.dart index 25a8d1d..aa3e534 100644 --- a/app/lib/services/database/app_database.dart +++ b/app/lib/services/database/app_database.dart @@ -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 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, + ); + } }, ); diff --git a/app/lib/services/database/app_database.g.dart b/app/lib/services/database/app_database.g.dart index 8d152b6..b2a0cc9 100644 --- a/app/lib/services/database/app_database.g.dart +++ b/app/lib/services/database/app_database.g.dart @@ -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 connectionWifiLastSsid = + GeneratedColumn( + 'connection_wifi_last_ssid', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); @override List 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 toColumns(bool nullToAbsent) { @@ -1613,6 +1646,11 @@ class AppSettingsTableData extends DataClass map['connection_wifi_ssid_prefix'] = Variable( connectionWifiSsidPrefix, ); + if (!nullToAbsent || connectionWifiLastSsid != null) { + map['connection_wifi_last_ssid'] = Variable( + 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( json['connectionWifiSsidPrefix'], ), + connectionWifiLastSsid: serializer.fromJson( + json['connectionWifiLastSsid'], + ), ); } @override @@ -1682,6 +1726,9 @@ class AppSettingsTableData extends DataClass 'connectionWifiSsidPrefix': serializer.toJson( connectionWifiSsidPrefix, ), + 'connectionWifiLastSsid': serializer.toJson( + connectionWifiLastSsid, + ), }; } @@ -1695,6 +1742,7 @@ class AppSettingsTableData extends DataClass Value connectionWifiHost = const Value.absent(), int? connectionWifiPort, String? connectionWifiSsidPrefix, + Value 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 { @@ -1798,6 +1855,7 @@ class AppSettingsTableCompanion extends UpdateCompanion { final Value connectionWifiHost; final Value connectionWifiPort; final Value connectionWifiSsidPrefix; + final Value connectionWifiLastSsid; const AppSettingsTableCompanion({ this.id = const Value.absent(), this.activeDroneProfileId = const Value.absent(), @@ -1808,6 +1866,7 @@ class AppSettingsTableCompanion extends UpdateCompanion { 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 { this.connectionWifiHost = const Value.absent(), this.connectionWifiPort = const Value.absent(), this.connectionWifiSsidPrefix = const Value.absent(), + this.connectionWifiLastSsid = const Value.absent(), }); static Insertable custom({ Expression? id, @@ -1830,6 +1890,7 @@ class AppSettingsTableCompanion extends UpdateCompanion { Expression? connectionWifiHost, Expression? connectionWifiPort, Expression? connectionWifiSsidPrefix, + Expression? connectionWifiLastSsid, }) { return RawValuesInsertable({ if (id != null) 'id': id, @@ -1847,6 +1908,8 @@ class AppSettingsTableCompanion extends UpdateCompanion { '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 { Value? connectionWifiHost, Value? connectionWifiPort, Value? connectionWifiSsidPrefix, + Value? connectionWifiLastSsid, }) { return AppSettingsTableCompanion( id: id ?? this.id, @@ -1874,6 +1938,8 @@ class AppSettingsTableCompanion extends UpdateCompanion { connectionWifiPort: connectionWifiPort ?? this.connectionWifiPort, connectionWifiSsidPrefix: connectionWifiSsidPrefix ?? this.connectionWifiSsidPrefix, + connectionWifiLastSsid: + connectionWifiLastSsid ?? this.connectionWifiLastSsid, ); } @@ -1915,6 +1981,11 @@ class AppSettingsTableCompanion extends UpdateCompanion { connectionWifiSsidPrefix.value, ); } + if (connectionWifiLastSsid.present) { + map['connection_wifi_last_ssid'] = Variable( + connectionWifiLastSsid.value, + ); + } return map; } @@ -1929,7 +2000,8 @@ class AppSettingsTableCompanion extends UpdateCompanion { ..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 connectionWifiHost, Value connectionWifiPort, Value connectionWifiSsidPrefix, + Value connectionWifiLastSsid, }); typedef $$AppSettingsTableTableUpdateCompanionBuilder = AppSettingsTableCompanion Function({ @@ -2586,6 +2659,7 @@ typedef $$AppSettingsTableTableUpdateCompanionBuilder = Value connectionWifiHost, Value connectionWifiPort, Value connectionWifiSsidPrefix, + Value connectionWifiLastSsid, }); class $$AppSettingsTableTableFilterComposer @@ -2641,6 +2715,11 @@ class $$AppSettingsTableTableFilterComposer column: $table.connectionWifiSsidPrefix, builder: (column) => ColumnFilters(column), ); + + ColumnFilters 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 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 get connectionWifiLastSsid => $composableBuilder( + column: $table.connectionWifiLastSsid, + builder: (column) => column, + ); } class $$AppSettingsTableTableTableManager @@ -2797,6 +2886,7 @@ class $$AppSettingsTableTableTableManager Value connectionWifiHost = const Value.absent(), Value connectionWifiPort = const Value.absent(), Value connectionWifiSsidPrefix = const Value.absent(), + Value 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 connectionWifiHost = const Value.absent(), Value connectionWifiPort = const Value.absent(), Value connectionWifiSsidPrefix = const Value.absent(), + Value 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))) diff --git a/app/lib/services/database/app_settings_repository.dart b/app/lib/services/database/app_settings_repository.dart index dc1ec6f..ce9c9b0 100644 --- a/app/lib/services/database/app_settings_repository.dart +++ b/app/lib/services/database/app_settings_repository.dart @@ -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 loadConnectionWifiLastSsid() async => + (await load()).connectionWifiLastSsid; + + Future setConnectionWifiLastSsid(String? ssid) async { + await _upsert(AppSettingsTableCompanion( + id: const Value(_rowId), + connectionWifiLastSsid: Value(ssid), + )); + } + Future _upsert(AppSettingsTableCompanion companion) async { await _db.into(_db.appSettingsTable).insertOnConflictUpdate(companion); } diff --git a/app/lib/transport/udp/mlrs_network_controller.dart b/app/lib/transport/udp/mlrs_network_controller.dart index 12271f6..90bd6f3 100644 --- a/app/lib/transport/udp/mlrs_network_controller.dart +++ b/app/lib/transport/udp/mlrs_network_controller.dart @@ -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 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 requestNetwork(String ssidPrefix); + Future 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 requestNetwork(String ssidPrefix) => _methodChannel - .invokeMethod('requestNetwork', {'ssidPrefix': ssidPrefix}); + Future requestNetwork(String ssidPrefix, {String? preferredSsid}) => + _methodChannel.invokeMethod('requestNetwork', { + 'ssidPrefix': ssidPrefix, + 'exactSsid': preferredSsid, + }); @override Future releaseNetwork() => _methodChannel.invokeMethod('releaseNetwork'); diff --git a/app/lib/transport/udp/udp_transport.dart b/app/lib/transport/udp/udp_transport.dart index 8395804..18824be 100644 --- a/app/lib/transport/udp/udp_transport.dart +++ b/app/lib/transport/udp/udp_transport.dart @@ -32,16 +32,22 @@ class UdpTransport implements LinkTransport { required Future Function() getHost, required Future Function() getPort, required Future Function() getSsidPrefix, + required Future Function() getRememberedSsid, + required Future Function(String ssid) rememberSsid, MlrsNetworkController? networkController, }) : _getHost = getHost, _getPort = getPort, _getSsidPrefix = getSsidPrefix, + _getRememberedSsid = getRememberedSsid, + _rememberSsid = rememberSsid, _networkController = networkController ?? MethodChannelMlrsNetworkController(); final Future Function() _getHost; final Future Function() _getPort; final Future Function() _getSsidPrefix; + final Future Function() _getRememberedSsid; + final Future Function(String ssid) _rememberSsid; final MlrsNetworkController _networkController; final _incomingController = StreamController.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 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(); 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(); diff --git a/app/lib/ui/dmc_colors.dart b/app/lib/ui/dmc_colors.dart index 988a836..7a3af9b 100644 --- a/app/lib/ui/dmc_colors.dart +++ b/app/lib/ui/dmc_colors.dart @@ -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); } diff --git a/app/lib/ui/providers/wifi_connection_provider.dart b/app/lib/ui/providers/wifi_connection_provider.dart index e729df6..24241d0 100644 --- a/app/lib/ui/providers/wifi_connection_provider.dart +++ b/app/lib/ui/providers/wifi_connection_provider.dart @@ -16,6 +16,8 @@ final wifiTransportProvider = Provider((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'}', }; diff --git a/app/lib/ui/screens/settings/settings_screen.dart b/app/lib/ui/screens/settings/settings_screen.dart index 41295fc..83b8b0e 100644 --- a/app/lib/ui/screens/settings/settings_screen.dart +++ b/app/lib/ui/screens/settings/settings_screen.dart @@ -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)', diff --git a/app/lib/ui/widgets/top_mode_bar.dart b/app/lib/ui/widgets/top_mode_bar.dart index 138d885..9691cac 100644 --- a/app/lib/ui/widgets/top_mode_bar.dart +++ b/app/lib/ui/widgets/top_mode_bar.dart @@ -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 _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, diff --git a/app/test/services/app_settings_repository_test.dart b/app/test/services/app_settings_repository_test.dart index 0913487..d7561da 100644 --- a/app/test/services/app_settings_repository_test.dart +++ b/app/test/services/app_settings_repository_test.dart @@ -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); + }); } diff --git a/app/test/transport/udp_transport_test.dart b/app/test/transport/udp_transport_test.dart index f1ded2a..b5b8b97 100644 --- a/app/test/transport/udp_transport_test.dart +++ b/app/test/transport/udp_transport_test.dart @@ -19,7 +19,9 @@ class FakeMlrsNetworkController implements MlrsNetworkController { final _packetsController = StreamController.broadcast(); final List requestedSsidPrefixes = []; + final List requestedPreferredSsids = []; final List<({Uint8List data, String host, int port})> sentPackets = []; + final List 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 requestNetwork(String ssidPrefix) async { + Future 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(); + }); } diff --git a/app/test/widget_test.dart b/app/test/widget_test.dart index 0d3c804..cf9b55b 100644 --- a/app/test/widget_test.dart +++ b/app/test/widget_test.dart @@ -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 _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()));