From 0ca96ce5b116166011943b4f2e73fbbeaa1ea735 Mon Sep 17 00:00:00 2001 From: HDDen <62592944+HDDen@users.noreply.github.com> Date: Sun, 31 May 2026 16:47:22 +0300 Subject: [PATCH] Regions discovery from nearby repeaters --- lib/connector/meshcore_connector.dart | 92 +++++- lib/connector/meshcore_protocol.dart | 69 +++++ lib/l10n/app_en.arb | 3 + lib/l10n/app_localizations.dart | 18 ++ lib/l10n/app_localizations_bg.dart | 10 + lib/l10n/app_localizations_de.dart | 10 + lib/l10n/app_localizations_en.dart | 10 + lib/l10n/app_localizations_es.dart | 10 + lib/l10n/app_localizations_fr.dart | 10 + lib/l10n/app_localizations_hu.dart | 10 + lib/l10n/app_localizations_it.dart | 10 + lib/l10n/app_localizations_ja.dart | 10 + lib/l10n/app_localizations_ko.dart | 10 + lib/l10n/app_localizations_nl.dart | 10 + lib/l10n/app_localizations_pl.dart | 10 + lib/l10n/app_localizations_pt.dart | 10 + lib/l10n/app_localizations_ru.dart | 10 + lib/l10n/app_localizations_sk.dart | 10 + lib/l10n/app_localizations_sl.dart | 10 + lib/l10n/app_localizations_sv.dart | 10 + lib/l10n/app_localizations_uk.dart | 10 + lib/l10n/app_localizations_zh.dart | 10 + lib/screens/region_management_screen.dart | 334 ++++++++++++++++++++++ untranslated.json | 54 ++++ 24 files changed, 737 insertions(+), 13 deletions(-) diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index 28010ca1..f95725e3 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -264,6 +264,10 @@ class MeshCoreConnector extends ChangeNotifier { // Serializes path operations (setContactPath/clearContactPath) to prevent // interleaved async calls from leaving in-memory state inconsistent with device. Future _pathOpLock = Future.value(); + // Flood scope is a global firmware setting, so scoped channel sends must not + // overlap or a message may inherit another channel's region. + Future _channelScopedSendLock = Future.value(); + static const Duration _commandAckTimeout = Duration(seconds: 5); Map? _currentCustomVars; /// Maps repeater pubkey-prefix hex (12 hex chars = first 6 bytes) → the @@ -679,7 +683,7 @@ class MeshCoreConnector extends ChangeNotifier { } bool hasChannelRegion(int channelIndex) { - return _channelRegions[channelIndex] != ''; + return (_channelRegions[channelIndex] ?? '').isNotEmpty; } Region getChannelRegion(int channelIndex) { @@ -3217,19 +3221,14 @@ class MeshCoreConnector extends ChangeNotifier { // Send the reaction to the device (don't add as a visible message) final reactionQueueId = _nextReactionSendQueueId(); _pendingChannelSentQueue.add(reactionQueueId); - try { - await sendFrame( - buildSetFloodScopeFrame(getChannelRegion(channel.index)), - ); + await _runScopedChannelSend(() async { await _waitForRadioQuiet(lastInboundRxTime: _lastChannelMsgRxTime); - await sendFrame( + await _sendFrameAndWaitForCommandAck( buildSendChannelTextMsgFrame(channel.index, text), channelSendQueueId: reactionQueueId, expectsGenericAck: true, ); - } finally { - await sendFrame(buildSetFloodScopeFrame('')); - } + }, region: getChannelRegion(channel.index)); return; } @@ -3246,16 +3245,80 @@ class MeshCoreConnector extends ChangeNotifier { notifyListeners(); final outboundText = prepareChannelOutboundText(channel.index, text); - try { - await sendFrame(buildSetFloodScopeFrame(getChannelRegion(channel.index))); + await _runScopedChannelSend(() async { await _waitForRadioQuiet(lastInboundRxTime: _lastChannelMsgRxTime); - await sendFrame( + await _sendFrameAndWaitForCommandAck( buildSendChannelTextMsgFrame(channel.index, outboundText), channelSendQueueId: message.messageId, expectsGenericAck: true, ); + }, region: getChannelRegion(channel.index)); + } + + Future _runScopedChannelSend( + Future Function() action, { + required String region, + }) async { + final prev = _channelScopedSendLock; + final completer = Completer(); + _channelScopedSendLock = completer.future; + await prev; + + try { + await _sendFrameAndWaitForCommandAck(buildSetFloodScopeFrame(region)); + try { + await action(); + } finally { + if (isConnected) { + await _sendFrameAndWaitForCommandAck(buildSetFloodScopeFrame('')); + } + } } finally { - await sendFrame(buildSetFloodScopeFrame('')); + completer.complete(); + } + } + + Future _sendFrameAndWaitForCommandAck( + Uint8List data, { + String? channelSendQueueId, + bool expectsGenericAck = false, + }) async { + final completer = Completer(); + late final StreamSubscription subscription; + late final Timer timeout; + + void complete() { + if (!completer.isCompleted) completer.complete(); + } + + void completeError(Object error) { + if (!completer.isCompleted) completer.completeError(error); + } + + subscription = receivedFrames.listen((frame) { + if (frame.isEmpty) return; + if (frame[0] == respCodeOk) { + complete(); + } else if (frame[0] == respCodeErr) { + final errCode = frame.length > 1 ? frame[1] : -1; + completeError(Exception('Command failed with error code $errCode')); + } + }); + + timeout = Timer(_commandAckTimeout, () { + completeError(TimeoutException('Command ACK timed out')); + }); + + try { + await sendFrame( + data, + channelSendQueueId: channelSendQueueId, + expectsGenericAck: expectsGenericAck, + ); + await completer.future; + } finally { + timeout.cancel(); + await subscription.cancel(); } } @@ -3858,6 +3921,9 @@ class MeshCoreConnector extends ChangeNotifier { case pushCodePathUpdated: _handlePathUpdated(frame); break; + case pushCodeControlData: + // Optional feature-specific services listen to receivedFrames directly. + break; case pushCodeLoginSuccess: _handleLoginSuccess(frame); break; diff --git a/lib/connector/meshcore_protocol.dart b/lib/connector/meshcore_protocol.dart index fdd90498..0f2deefa 100644 --- a/lib/connector/meshcore_protocol.dart +++ b/lib/connector/meshcore_protocol.dart @@ -207,6 +207,7 @@ const int cmdSendTelemetryReq = 39; const int cmdGetCustomVar = 40; const int cmdSetCustomVar = 41; const int cmdSendBinaryReq = 50; +const int cmdSendControlData = 55; const int cmdGetStats = 56; const int cmdSendAnonReq = 57; const int cmdSetAutoAddConfig = 58; @@ -226,6 +227,12 @@ const int reqTypeGetTelemetry = 0x03; const int reqTypeGetAccessList = 0x05; const int reqTypeGetNeighbors = 0x06; +const int anonReqTypeRegions = 0x01; + +// Control data sub-types used by MeshCore discovery packets. +const int controlSubtypeDiscoverReq = 0x08; +const int controlSubtypeDiscoverResp = 0x09; + // Repeater response codes const int respServerLoginOk = 0; @@ -268,6 +275,7 @@ const int pushCodeTraceData = 0x89; const int pushCodeNewAdvert = 0x8A; const int pushCodeTelemetryResponse = 0x8B; const int pushCodeBinaryResponse = 0x8C; +const int pushCodeControlData = 0x8E; // Contact/advertisement types const int advTypeChat = 1; @@ -857,6 +865,67 @@ Uint8List buildSendBinaryReq(Uint8List repeaterPubKey, {Uint8List? payload}) { return writer.toBytes(); } +Uint8List buildSendControlDataFrame(Uint8List payload) { + final writer = BufferWriter(); + writer.writeByte(cmdSendControlData); + writer.writeBytes(payload); + return writer.toBytes(); +} + +Uint8List buildDiscoveryRequestPayload( + int tag, { + bool prefixOnly = false, + int typeMask = 1 << advTypeRepeater, +}) { + final writer = BufferWriter(); + // The high bit must be set for CMD_SEND_CONTROL_DATA; DISCOVER_REQ uses + // subtype 0x8, with the low bit selecting short/full public keys in replies. + writer.writeByte( + (controlSubtypeDiscoverReq << 4) | (prefixOnly ? 0x01 : 0x00), + ); + writer.writeByte(typeMask); + writer.writeUInt32LE(tag); + writer.writeUInt32LE(0); // since=0 asks nearby nodes for any recent advert. + return writer.toBytes(); +} + +Uint8List _reversePathByHop(Uint8List path, int pathHashWidth) { + if (path.isEmpty) return Uint8List(0); + final width = pathHashWidth.clamp(1, 4).toInt(); + if (path.length % width != 0) { + return Uint8List.fromList(path.reversed.toList()); + } + + final reversed = Uint8List(path.length); + final hops = path.length ~/ width; + for (var i = 0; i < hops; i++) { + final from = (hops - 1 - i) * width; + reversed.setRange(i * width, (i + 1) * width, path, from); + } + return reversed; +} + +// Build CMD_SEND_ANON_REQ frame. +// Payload format for regions: [anon_req_type][reply_path_len][reply_path...]. +Uint8List buildSendAnonReqFrame( + Uint8List repeaterPubKey, { + required int requestType, + Uint8List? replyPath, + int replyHopCount = 0, + int pathHashWidth = pathHashSize, +}) { + final width = pathHashWidth.clamp(1, 4).toInt(); + final path = replyPath ?? Uint8List(0); + final encodedPathLen = ((width - 1) << 6) | (replyHopCount & 0x3F); + final writer = BufferWriter(); + writer.writeByte(cmdSendAnonReq); + writer.writeBytes(repeaterPubKey); + writer.writeByte(requestType); + writer.writeByte(encodedPathLen); + writer.writeBytes(_reversePathByHop(path, width)); + return writer.toBytes(); +} + //Build a trace request frame //[cmd][tag x4][auth x4][flag][payload] Uint8List buildTraceReq(int tag, int auth, int flag, {Uint8List? payload}) { diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 900e1b83..ddcdd075 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -154,6 +154,9 @@ "settings_regionManagement_screenTitle": "Region Management", "settings_regionNameHint": "Enter region name", "settings_regionAddRegion": "Add region", + "settings_regionFetchRegions": "Fetch regions from repeaters", + "settings_regionFetchRegionsFail": "No regions were found", + "settings_regionFetchRegionsAlreadyExists": "This region has already been added", "settings_regionName": "Region Name", "settings_regionDeleted": "Region deleted", "settings_deleteRegion": "Delete Region", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index cc87e09f..542b69e4 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -760,6 +760,24 @@ abstract class AppLocalizations { /// **'Add region'** String get settings_regionAddRegion; + /// No description provided for @settings_regionFetchRegions. + /// + /// In en, this message translates to: + /// **'Fetch regions from repeaters'** + String get settings_regionFetchRegions; + + /// No description provided for @settings_regionFetchRegionsFail. + /// + /// In en, this message translates to: + /// **'No regions were found'** + String get settings_regionFetchRegionsFail; + + /// No description provided for @settings_regionFetchRegionsAlreadyExists. + /// + /// In en, this message translates to: + /// **'This region has already been added'** + String get settings_regionFetchRegionsAlreadyExists; + /// No description provided for @settings_regionName. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_bg.dart b/lib/l10n/app_localizations_bg.dart index a0f0e131..88958b0b 100644 --- a/lib/l10n/app_localizations_bg.dart +++ b/lib/l10n/app_localizations_bg.dart @@ -354,6 +354,16 @@ class AppLocalizationsBg extends AppLocalizations { @override String get settings_regionAddRegion => 'Add region'; + @override + String get settings_regionFetchRegions => 'Fetch regions from repeaters'; + + @override + String get settings_regionFetchRegionsFail => 'No regions were found'; + + @override + String get settings_regionFetchRegionsAlreadyExists => + 'This region has already been added'; + @override String get settings_regionName => 'Region Name'; diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index e6b1d82b..44d82f16 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -357,6 +357,16 @@ class AppLocalizationsDe extends AppLocalizations { @override String get settings_regionAddRegion => 'Region hinzufügen'; + @override + String get settings_regionFetchRegions => 'Fetch regions from repeaters'; + + @override + String get settings_regionFetchRegionsFail => 'No regions were found'; + + @override + String get settings_regionFetchRegionsAlreadyExists => + 'This region has already been added'; + @override String get settings_regionName => 'Regions-Name'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 01d401f0..389026e0 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -350,6 +350,16 @@ class AppLocalizationsEn extends AppLocalizations { @override String get settings_regionAddRegion => 'Add region'; + @override + String get settings_regionFetchRegions => 'Fetch regions from repeaters'; + + @override + String get settings_regionFetchRegionsFail => 'No regions were found'; + + @override + String get settings_regionFetchRegionsAlreadyExists => + 'This region has already been added'; + @override String get settings_regionName => 'Region Name'; diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index a9f52b98..999d9893 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -354,6 +354,16 @@ class AppLocalizationsEs extends AppLocalizations { @override String get settings_regionAddRegion => 'Add region'; + @override + String get settings_regionFetchRegions => 'Fetch regions from repeaters'; + + @override + String get settings_regionFetchRegionsFail => 'No regions were found'; + + @override + String get settings_regionFetchRegionsAlreadyExists => + 'This region has already been added'; + @override String get settings_regionName => 'Region Name'; diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index 3e1d59b9..8aa80d86 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -356,6 +356,16 @@ class AppLocalizationsFr extends AppLocalizations { @override String get settings_regionAddRegion => 'Add region'; + @override + String get settings_regionFetchRegions => 'Fetch regions from repeaters'; + + @override + String get settings_regionFetchRegionsFail => 'No regions were found'; + + @override + String get settings_regionFetchRegionsAlreadyExists => + 'This region has already been added'; + @override String get settings_regionName => 'Region Name'; diff --git a/lib/l10n/app_localizations_hu.dart b/lib/l10n/app_localizations_hu.dart index 34166d9d..065e011e 100644 --- a/lib/l10n/app_localizations_hu.dart +++ b/lib/l10n/app_localizations_hu.dart @@ -353,6 +353,16 @@ class AppLocalizationsHu extends AppLocalizations { @override String get settings_regionAddRegion => 'Add region'; + @override + String get settings_regionFetchRegions => 'Fetch regions from repeaters'; + + @override + String get settings_regionFetchRegionsFail => 'No regions were found'; + + @override + String get settings_regionFetchRegionsAlreadyExists => + 'This region has already been added'; + @override String get settings_regionName => 'Region Name'; diff --git a/lib/l10n/app_localizations_it.dart b/lib/l10n/app_localizations_it.dart index 9d9e4d18..a4c98591 100644 --- a/lib/l10n/app_localizations_it.dart +++ b/lib/l10n/app_localizations_it.dart @@ -356,6 +356,16 @@ class AppLocalizationsIt extends AppLocalizations { @override String get settings_regionAddRegion => 'Add region'; + @override + String get settings_regionFetchRegions => 'Fetch regions from repeaters'; + + @override + String get settings_regionFetchRegionsFail => 'No regions were found'; + + @override + String get settings_regionFetchRegionsAlreadyExists => + 'This region has already been added'; + @override String get settings_regionName => 'Region Name'; diff --git a/lib/l10n/app_localizations_ja.dart b/lib/l10n/app_localizations_ja.dart index 8b52409c..5df46482 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -341,6 +341,16 @@ class AppLocalizationsJa extends AppLocalizations { @override String get settings_regionAddRegion => 'Add region'; + @override + String get settings_regionFetchRegions => 'Fetch regions from repeaters'; + + @override + String get settings_regionFetchRegionsFail => 'No regions were found'; + + @override + String get settings_regionFetchRegionsAlreadyExists => + 'This region has already been added'; + @override String get settings_regionName => 'Region Name'; diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index a94fcec4..7656e97a 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -340,6 +340,16 @@ class AppLocalizationsKo extends AppLocalizations { @override String get settings_regionAddRegion => 'Add region'; + @override + String get settings_regionFetchRegions => 'Fetch regions from repeaters'; + + @override + String get settings_regionFetchRegionsFail => 'No regions were found'; + + @override + String get settings_regionFetchRegionsAlreadyExists => + 'This region has already been added'; + @override String get settings_regionName => 'Region Name'; diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index b711f484..95304df7 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -352,6 +352,16 @@ class AppLocalizationsNl extends AppLocalizations { @override String get settings_regionAddRegion => 'Add region'; + @override + String get settings_regionFetchRegions => 'Fetch regions from repeaters'; + + @override + String get settings_regionFetchRegionsFail => 'No regions were found'; + + @override + String get settings_regionFetchRegionsAlreadyExists => + 'This region has already been added'; + @override String get settings_regionName => 'Region Name'; diff --git a/lib/l10n/app_localizations_pl.dart b/lib/l10n/app_localizations_pl.dart index de06324b..01be4ec4 100644 --- a/lib/l10n/app_localizations_pl.dart +++ b/lib/l10n/app_localizations_pl.dart @@ -358,6 +358,16 @@ class AppLocalizationsPl extends AppLocalizations { @override String get settings_regionAddRegion => 'Add region'; + @override + String get settings_regionFetchRegions => 'Fetch regions from repeaters'; + + @override + String get settings_regionFetchRegionsFail => 'No regions were found'; + + @override + String get settings_regionFetchRegionsAlreadyExists => + 'This region has already been added'; + @override String get settings_regionName => 'Region Name'; diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index 99195e36..5ca75ce9 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -356,6 +356,16 @@ class AppLocalizationsPt extends AppLocalizations { @override String get settings_regionAddRegion => 'Add region'; + @override + String get settings_regionFetchRegions => 'Fetch regions from repeaters'; + + @override + String get settings_regionFetchRegionsFail => 'No regions were found'; + + @override + String get settings_regionFetchRegionsAlreadyExists => + 'This region has already been added'; + @override String get settings_regionName => 'Region Name'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index cebab7ce..fb6f20f5 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -355,6 +355,16 @@ class AppLocalizationsRu extends AppLocalizations { @override String get settings_regionAddRegion => 'Add region'; + @override + String get settings_regionFetchRegions => 'Fetch regions from repeaters'; + + @override + String get settings_regionFetchRegionsFail => 'No regions were found'; + + @override + String get settings_regionFetchRegionsAlreadyExists => + 'This region has already been added'; + @override String get settings_regionName => 'Region Name'; diff --git a/lib/l10n/app_localizations_sk.dart b/lib/l10n/app_localizations_sk.dart index 64d596f0..0c3d8b84 100644 --- a/lib/l10n/app_localizations_sk.dart +++ b/lib/l10n/app_localizations_sk.dart @@ -354,6 +354,16 @@ class AppLocalizationsSk extends AppLocalizations { @override String get settings_regionAddRegion => 'Add region'; + @override + String get settings_regionFetchRegions => 'Fetch regions from repeaters'; + + @override + String get settings_regionFetchRegionsFail => 'No regions were found'; + + @override + String get settings_regionFetchRegionsAlreadyExists => + 'This region has already been added'; + @override String get settings_regionName => 'Region Name'; diff --git a/lib/l10n/app_localizations_sl.dart b/lib/l10n/app_localizations_sl.dart index f9e886ec..c9ccfc0e 100644 --- a/lib/l10n/app_localizations_sl.dart +++ b/lib/l10n/app_localizations_sl.dart @@ -352,6 +352,16 @@ class AppLocalizationsSl extends AppLocalizations { @override String get settings_regionAddRegion => 'Add region'; + @override + String get settings_regionFetchRegions => 'Fetch regions from repeaters'; + + @override + String get settings_regionFetchRegionsFail => 'No regions were found'; + + @override + String get settings_regionFetchRegionsAlreadyExists => + 'This region has already been added'; + @override String get settings_regionName => 'Region Name'; diff --git a/lib/l10n/app_localizations_sv.dart b/lib/l10n/app_localizations_sv.dart index 9b9e8166..d13a4a99 100644 --- a/lib/l10n/app_localizations_sv.dart +++ b/lib/l10n/app_localizations_sv.dart @@ -352,6 +352,16 @@ class AppLocalizationsSv extends AppLocalizations { @override String get settings_regionAddRegion => 'Add region'; + @override + String get settings_regionFetchRegions => 'Fetch regions from repeaters'; + + @override + String get settings_regionFetchRegionsFail => 'No regions were found'; + + @override + String get settings_regionFetchRegionsAlreadyExists => + 'This region has already been added'; + @override String get settings_regionName => 'Region Name'; diff --git a/lib/l10n/app_localizations_uk.dart b/lib/l10n/app_localizations_uk.dart index efd9766c..ae67b5d4 100644 --- a/lib/l10n/app_localizations_uk.dart +++ b/lib/l10n/app_localizations_uk.dart @@ -354,6 +354,16 @@ class AppLocalizationsUk extends AppLocalizations { @override String get settings_regionAddRegion => 'Add region'; + @override + String get settings_regionFetchRegions => 'Fetch regions from repeaters'; + + @override + String get settings_regionFetchRegionsFail => 'No regions were found'; + + @override + String get settings_regionFetchRegionsAlreadyExists => + 'This region has already been added'; + @override String get settings_regionName => 'Region Name'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 3f2dcf73..fa626e44 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -338,6 +338,16 @@ class AppLocalizationsZh extends AppLocalizations { @override String get settings_regionAddRegion => 'Add region'; + @override + String get settings_regionFetchRegions => 'Fetch regions from repeaters'; + + @override + String get settings_regionFetchRegionsFail => 'No regions were found'; + + @override + String get settings_regionFetchRegionsAlreadyExists => + 'This region has already been added'; + @override String get settings_regionName => 'Region Name'; diff --git a/lib/screens/region_management_screen.dart b/lib/screens/region_management_screen.dart index 5ebe6327..33a13b9c 100644 --- a/lib/screens/region_management_screen.dart +++ b/lib/screens/region_management_screen.dart @@ -1,7 +1,12 @@ +import 'dart:async'; +import 'dart:convert'; + import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:meshcore_open/connector/meshcore_connector.dart'; +import 'package:meshcore_open/connector/meshcore_protocol.dart'; import 'package:meshcore_open/l10n/l10n.dart'; +import 'package:meshcore_open/models/contact.dart'; import 'package:meshcore_open/storage/region_store.dart'; import 'package:provider/provider.dart'; @@ -22,8 +27,11 @@ class RegionManagementScreen extends StatefulWidget { } class _RegionManagementScreenState extends State { + static final RegExp _validFetchedRegion = RegExp(r'^[a-z0-9-]{1,30}$'); + final RegionStore _regionStore = RegionStore(); List _regions = []; + bool _isFetchingRegions = false; String region = ''; @@ -59,6 +67,17 @@ class _RegionManagementScreenState extends State { icon: const Icon(Icons.add), onPressed: () => _showAddRegionDialog(context), ), + IconButton( + tooltip: l10n.settings_regionFetchRegions, + icon: _isFetchingRegions + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.travel_explore), + onPressed: _isFetchingRegions ? null : _showFetchRegionsDialog, + ), ], ), body: ListView.builder( @@ -107,6 +126,321 @@ class _RegionManagementScreenState extends State { ); } + Future _showFetchRegionsDialog() async { + if (_isFetchingRegions) return; + + setState(() { + _isFetchingRegions = true; + }); + + Set fetchedRegions = {}; + try { + fetchedRegions = await _fetchRegionsFromRepeaters(); + } finally { + if (mounted) { + setState(() { + _isFetchingRegions = false; + }); + } + } + + if (!mounted) return; + final l10n = context.l10n; + final sortedRegions = fetchedRegions.toList()..sort(); + await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: Text(l10n.settings_regionFetchRegions), + content: sortedRegions.isEmpty + ? Text(l10n.settings_regionFetchRegionsFail) + : StatefulBuilder( + builder: (context, setDialogState) { + return SizedBox( + width: double.maxFinite, + child: ListView.builder( + shrinkWrap: true, + itemCount: sortedRegions.length, + itemBuilder: (context, index) { + final fetchedRegion = sortedRegions[index]; + final alreadyExists = _regions.contains(fetchedRegion); + return Card( + child: ListTile( + title: Text(fetchedRegion), + trailing: TextButton( + style: alreadyExists + ? TextButton.styleFrom( + foregroundColor: Theme.of( + context, + ).disabledColor, + ) + : null, + onPressed: () { + if (alreadyExists) { + _showDialogSnackBar( + context, + l10n.settings_regionFetchRegionsAlreadyExists, + ); + return; + } + + _regionStore.addRegion(fetchedRegion); + _loadRegions(); + setDialogState(() {}); + }, + child: Text(l10n.common_add), + ), + ), + ); + }, + ), + ); + }, + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext), + child: Text(l10n.common_close), + ), + ], + ), + ); + } + + void _showDialogSnackBar(BuildContext context, String message) { + final overlay = Overlay.maybeOf(context); + if (overlay == null) return; + + final theme = Theme.of(context); + final entry = OverlayEntry( + builder: (context) => Positioned( + left: 16, + right: 16, + bottom: 32, + child: SafeArea( + child: Material( + color: theme.colorScheme.inverseSurface, + elevation: 6, + borderRadius: BorderRadius.circular(4), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + child: Text( + message, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onInverseSurface, + ), + ), + ), + ), + ), + ), + ); + + overlay.insert(entry); + Timer(const Duration(seconds: 3), entry.remove); + } + + Future> _fetchRegionsFromRepeaters() async { + final connector = context.read(); + final repeaters = await _discoverNearbyRepeaters(connector); + final regions = {}; + + for (final repeater in repeaters) { + if (!mounted || !connector.isConnected) break; + regions.addAll(await _requestRegionsFromRepeater(connector, repeater)); + } + + return regions; + } + + Future> _discoverNearbyRepeaters( + MeshCoreConnector connector, + ) async { + final repeaters = connector.contacts + .where((contact) => contact.type == advTypeRepeater) + .toList(); + if (repeaters.isEmpty || !connector.isConnected) return []; + + StreamSubscription? subscription; + Timer? timeout; + final completer = Completer>(); + final respondingPrefixes = {}; + final tag = DateTime.now().microsecondsSinceEpoch & 0xFFFFFFFF; + + void complete() { + if (completer.isCompleted) return; + timeout?.cancel(); + subscription?.cancel(); + completer.complete(respondingPrefixes); + } + + subscription = connector.receivedFrames.listen((frame) { + if (frame.isEmpty || completer.isCompleted) return; + + final reader = BufferReader(frame); + try { + if (reader.readByte() != pushCodeControlData) return; + if (reader.remaining < 9) return; + reader.skipBytes(3); // SNR, RSSI, path_len from companion firmware. + + final payloadType = reader.readByte(); + if (((payloadType >> 4) & 0x0F) != controlSubtypeDiscoverResp || + (payloadType & 0x0F) != advTypeRepeater) { + return; + } + + reader.skipBytes(1); // Inbound SNR reported by the responding repeater. + if (reader.readUInt32LE() != tag) return; + + final publicKeyPrefix = reader.readRemainingBytes(); + if (publicKeyPrefix.isEmpty) return; + respondingPrefixes.add(pubKeyToHex(publicKeyPrefix)); + } catch (_) { + // Ignore malformed discovery frames; another response may still arrive. + } + }); + + try { + final payload = buildDiscoveryRequestPayload(tag, prefixOnly: true); + await connector.sendFrame(buildSendControlDataFrame(payload)); + timeout = Timer(const Duration(seconds: 10), complete); + final prefixes = await completer.future; + return repeaters.where((contact) { + final contactKey = contact.publicKeyHex.toLowerCase(); + return prefixes.any((prefix) => contactKey.startsWith(prefix)); + }).toList(); + } catch (_) { + timeout?.cancel(); + await subscription.cancel(); + return []; + } + } + + Future> _requestRegionsFromRepeater( + MeshCoreConnector connector, + Contact repeater, + ) async { + StreamSubscription? subscription; + Timer? timeout; + final completer = Completer>(); + int? expectedTag; + final originalPath = Uint8List.fromList(repeater.path); + final originalPathLength = repeater.pathLength; + var pathChangedForRequest = false; + + void complete(Set regions) { + if (completer.isCompleted) return; + timeout?.cancel(); + subscription?.cancel(); + completer.complete(regions); + } + + void restartTimeout(Duration duration) { + timeout?.cancel(); + timeout = Timer(duration, () => complete({})); + } + + try { + final replyPath = Uint8List(0); + const replyHopCount = 0; + await connector.setContactPath(repeater, replyPath, replyHopCount); + pathChangedForRequest = true; + + subscription = connector.receivedFrames.listen((frame) { + if (frame.isEmpty || completer.isCompleted) return; + + final reader = BufferReader(frame); + try { + final cmd = reader.readByte(); + if (cmd == respCodeSent) { + reader.skipBytes(1); + expectedTag = reader.readUInt32LE(); + final estimatedTimeoutMs = reader.readUInt32LE(); + restartTimeout( + Duration( + milliseconds: estimatedTimeoutMs > 0 + ? estimatedTimeoutMs + 2000 + : 10000, + ), + ); + return; + } + + if (cmd == respCodeErr) { + complete({}); + return; + } + + if (cmd != pushCodeBinaryResponse || expectedTag == null) return; + + reader.skipBytes(1); + final tag = reader.readUInt32LE(); + if (tag != expectedTag) return; + + complete(_parseRegionsResponse(reader.readRemainingBytes())); + } catch (_) { + complete({}); + } + }); + + restartTimeout(const Duration(seconds: 10)); + final frame = buildSendAnonReqFrame( + repeater.publicKey, + requestType: anonReqTypeRegions, + replyPath: replyPath, + replyHopCount: replyHopCount, + pathHashWidth: connector.pathHashByteWidth, + ); + await connector.sendFrame(frame); + final regions = await completer.future; + if (pathChangedForRequest && connector.isConnected) { + await _restoreRepeaterPath( + connector, + repeater, + originalPathLength, + originalPath, + ); + } + return regions; + } catch (_) { + timeout?.cancel(); + subscription?.cancel(); + if (pathChangedForRequest && connector.isConnected) { + await _restoreRepeaterPath( + connector, + repeater, + originalPathLength, + originalPath, + ); + } + return {}; + } + } + + Future _restoreRepeaterPath( + MeshCoreConnector connector, + Contact repeater, + int originalPathLength, + Uint8List originalPath, + ) async { + if (originalPathLength < 0) { + await connector.clearContactPath(repeater); + return; + } + await connector.setContactPath(repeater, originalPath, originalPathLength); + } + + Set _parseRegionsResponse(Uint8List frame) { + if (frame.length <= 4) return {}; + final names = utf8 + .decode(frame.sublist(4), allowMalformed: true) + .replaceAll('\x00', '') + .split(','); + return names + .map((name) => name.trim()) + .where((name) => _validFetchedRegion.hasMatch(name)) + .toSet(); + } + void _handleAddRegion(Region region, BuildContext context) { Navigator.pop(context); _regionStore.addRegion(region); diff --git a/untranslated.json b/untranslated.json index d6a65bfb..f42fd2c3 100644 --- a/untranslated.json +++ b/untranslated.json @@ -5,6 +5,9 @@ "settings_regionManagement_screenTitle", "settings_regionNameHint", "settings_regionAddRegion", + "settings_regionFetchRegions", + "settings_regionFetchRegionsFail", + "settings_regionFetchRegionsAlreadyExists", "settings_regionName", "settings_regionDeleted", "settings_deleteRegion", @@ -15,12 +18,21 @@ "channels_clearRegion" ], + "de": [ + "settings_regionFetchRegions", + "settings_regionFetchRegionsFail", + "settings_regionFetchRegionsAlreadyExists" + ], + "es": [ "settings_regionSettings", "settings_regionSettingsSubtitle", "settings_regionManagement_screenTitle", "settings_regionNameHint", "settings_regionAddRegion", + "settings_regionFetchRegions", + "settings_regionFetchRegionsFail", + "settings_regionFetchRegionsAlreadyExists", "settings_regionName", "settings_regionDeleted", "settings_deleteRegion", @@ -37,6 +49,9 @@ "settings_regionManagement_screenTitle", "settings_regionNameHint", "settings_regionAddRegion", + "settings_regionFetchRegions", + "settings_regionFetchRegionsFail", + "settings_regionFetchRegionsAlreadyExists", "settings_regionName", "settings_regionDeleted", "settings_deleteRegion", @@ -53,6 +68,9 @@ "settings_regionManagement_screenTitle", "settings_regionNameHint", "settings_regionAddRegion", + "settings_regionFetchRegions", + "settings_regionFetchRegionsFail", + "settings_regionFetchRegionsAlreadyExists", "settings_regionName", "settings_regionDeleted", "settings_deleteRegion", @@ -69,6 +87,9 @@ "settings_regionManagement_screenTitle", "settings_regionNameHint", "settings_regionAddRegion", + "settings_regionFetchRegions", + "settings_regionFetchRegionsFail", + "settings_regionFetchRegionsAlreadyExists", "settings_regionName", "settings_regionDeleted", "settings_deleteRegion", @@ -85,6 +106,9 @@ "settings_regionManagement_screenTitle", "settings_regionNameHint", "settings_regionAddRegion", + "settings_regionFetchRegions", + "settings_regionFetchRegionsFail", + "settings_regionFetchRegionsAlreadyExists", "settings_regionName", "settings_regionDeleted", "settings_deleteRegion", @@ -101,6 +125,9 @@ "settings_regionManagement_screenTitle", "settings_regionNameHint", "settings_regionAddRegion", + "settings_regionFetchRegions", + "settings_regionFetchRegionsFail", + "settings_regionFetchRegionsAlreadyExists", "settings_regionName", "settings_regionDeleted", "settings_deleteRegion", @@ -117,6 +144,9 @@ "settings_regionManagement_screenTitle", "settings_regionNameHint", "settings_regionAddRegion", + "settings_regionFetchRegions", + "settings_regionFetchRegionsFail", + "settings_regionFetchRegionsAlreadyExists", "settings_regionName", "settings_regionDeleted", "settings_deleteRegion", @@ -133,6 +163,9 @@ "settings_regionManagement_screenTitle", "settings_regionNameHint", "settings_regionAddRegion", + "settings_regionFetchRegions", + "settings_regionFetchRegionsFail", + "settings_regionFetchRegionsAlreadyExists", "settings_regionName", "settings_regionDeleted", "settings_deleteRegion", @@ -149,6 +182,9 @@ "settings_regionManagement_screenTitle", "settings_regionNameHint", "settings_regionAddRegion", + "settings_regionFetchRegions", + "settings_regionFetchRegionsFail", + "settings_regionFetchRegionsAlreadyExists", "settings_regionName", "settings_regionDeleted", "settings_deleteRegion", @@ -165,6 +201,9 @@ "settings_regionManagement_screenTitle", "settings_regionNameHint", "settings_regionAddRegion", + "settings_regionFetchRegions", + "settings_regionFetchRegionsFail", + "settings_regionFetchRegionsAlreadyExists", "settings_regionName", "settings_regionDeleted", "settings_deleteRegion", @@ -181,6 +220,9 @@ "settings_regionManagement_screenTitle", "settings_regionNameHint", "settings_regionAddRegion", + "settings_regionFetchRegions", + "settings_regionFetchRegionsFail", + "settings_regionFetchRegionsAlreadyExists", "settings_regionName", "settings_regionDeleted", "settings_deleteRegion", @@ -197,6 +239,9 @@ "settings_regionManagement_screenTitle", "settings_regionNameHint", "settings_regionAddRegion", + "settings_regionFetchRegions", + "settings_regionFetchRegionsFail", + "settings_regionFetchRegionsAlreadyExists", "settings_regionName", "settings_regionDeleted", "settings_deleteRegion", @@ -213,6 +258,9 @@ "settings_regionManagement_screenTitle", "settings_regionNameHint", "settings_regionAddRegion", + "settings_regionFetchRegions", + "settings_regionFetchRegionsFail", + "settings_regionFetchRegionsAlreadyExists", "settings_regionName", "settings_regionDeleted", "settings_deleteRegion", @@ -229,6 +277,9 @@ "settings_regionManagement_screenTitle", "settings_regionNameHint", "settings_regionAddRegion", + "settings_regionFetchRegions", + "settings_regionFetchRegionsFail", + "settings_regionFetchRegionsAlreadyExists", "settings_regionName", "settings_regionDeleted", "settings_deleteRegion", @@ -245,6 +296,9 @@ "settings_regionManagement_screenTitle", "settings_regionNameHint", "settings_regionAddRegion", + "settings_regionFetchRegions", + "settings_regionFetchRegionsFail", + "settings_regionFetchRegionsAlreadyExists", "settings_regionName", "settings_regionDeleted", "settings_deleteRegion",