From ac64a7bdd1ae00ce94d4fbf62e7a57b4bdebfa12 Mon Sep 17 00:00:00 2001 From: Strycher Date: Tue, 14 Jul 2026 13:03:24 -0400 Subject: [PATCH] feat(#179): union sync on connect + push block/unblock to firmware MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BlockService gains a firmwareSync sink (fired on real block/unblock/promote) and importKeys (pull side, no echo). Connector sets the sink in initialize, pushes ADD/REMOVE via 0xC2 when supportsOffbandBlock, and on device-info (caps landed) pulls BLOCK_LIST. On dump completion: UNION — import node keys missing locally (no echo), push local keys the node lacks; never remove (only explicit unblock sends REMOVE). Non-Offband radios skip all of it (app-local only). Epic B #166 / B4 #179. Co-Authored-By: Claude Opus 4.8 --- lib/connector/meshcore_connector.dart | 48 +++++++++++++++++++++++++++ lib/services/block_service.dart | 31 +++++++++++++++-- 2 files changed, 76 insertions(+), 3 deletions(-) diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index 4670a60..0f8d55f 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -1049,6 +1049,7 @@ class MeshCoreConnector extends ChangeNotifier { _backgroundService = backgroundService; _timeoutPredictionService = timeoutPredictionService; _blockService = blockService; + blockService?.firmwareSync = _pushBlockChange; _usbManager.setDebugLogService(_appDebugLogService); _tcpConnector.setDebugLogService(_appDebugLogService); @@ -4120,7 +4121,52 @@ class MeshCoreConnector extends ChangeNotifier { unawaited(sendFrame(buildOffbandBlockListFrame())); } }); + return; + } + // Complete dump — UNION. Import node keys missing locally (no echo back), + // then push local keys the node is missing. Never remove (union): only an + // explicit user unblock issues a REMOVE. + final blockService = _blockService; + if (blockService == null) return; + final nodeSet = nodeKeys.map((k) => k.toLowerCase()).toSet(); + final local = blockService.blockedKeys; + unawaited(blockService.importKeys(nodeSet.difference(local))); + for (final key in local.difference(nodeSet)) { + _pushBlockChange(key, true); + } + } + + /// On (re)connect to a block-capable radio, pull its block list; the union + /// reconcile runs when the dump completes. + void _reconcileFirmwareBlock() { + if (!supportsOffbandBlock) return; + unawaited(sendFrame(buildOffbandBlockListFrame())); + } + + /// Push one local block change to the firmware store (Offband, capability + /// gated). Wired as `BlockService.firmwareSync`. + void _pushBlockChange(String keyHex, bool blocked) { + if (!supportsOffbandBlock) return; + final key = _pubKeyHexToBytes(keyHex); + if (key.length != pubKeySize) return; + unawaited( + sendFrame( + blocked + ? buildOffbandBlockAddFrame(key) + : buildOffbandBlockRemoveFrame(key), + ), + ); + } + + Uint8List _pubKeyHexToBytes(String hex) { + final clean = hex.length >= pubKeySize * 2 + ? hex.substring(0, pubKeySize * 2) + : hex; + final out = Uint8List(clean.length ~/ 2); + for (var i = 0; i < out.length; i++) { + out[i] = int.parse(clean.substring(i * 2, i * 2 + 2), radix: 16); } + return out; } void _handleFrame(List data) { @@ -4485,6 +4531,8 @@ class MeshCoreConnector extends ChangeNotifier { // Caps just landed; (re)evaluate GPS polling in case a `gps=1` custom-var // frame arrived before this device-info reply set support. (#144) _reconcileGpsPolling(); + // Caps landed — if the radio speaks block, pull its list and union-reconcile. + _reconcileFirmwareBlock(); // Firmware reports MAX_CONTACTS / 2 for v3+ device info. final reportedContacts = frame[2]; diff --git a/lib/services/block_service.dart b/lib/services/block_service.dart index 78920e4..07d7403 100644 --- a/lib/services/block_service.dart +++ b/lib/services/block_service.dart @@ -16,6 +16,12 @@ class BlockService extends ChangeNotifier { final Set _blockedKeys = {}; final Map _blockedNames = {}; + /// Set by the connector: pushes a single key change to the firmware block + /// store when the connected radio supports it (Epic B). `blocked` = add vs + /// remove. Fired only on an actual local change; NOT fired by [importKeys] + /// (the pull side), so a synced key never echoes back to the radio. + void Function(String keyHex, bool blocked)? firmwareSync; + /// Load persisted state. Call once during app startup. Future load() async { _blockedKeys @@ -38,13 +44,30 @@ class BlockService extends ChangeNotifier { _blockedNames.containsKey(name.trim().toLowerCase()); Future block(String publicKeyHex) async { - if (!_blockedKeys.add(publicKeyHex.toLowerCase())) return; + final key = publicKeyHex.toLowerCase(); + if (!_blockedKeys.add(key)) return; await _store.saveKeys(_blockedKeys); + firmwareSync?.call(key, true); notifyListeners(); } Future unblock(String publicKeyHex) async { - if (!_blockedKeys.remove(publicKeyHex.toLowerCase())) return; + final key = publicKeyHex.toLowerCase(); + if (!_blockedKeys.remove(key)) return; + await _store.saveKeys(_blockedKeys); + firmwareSync?.call(key, false); + notifyListeners(); + } + + /// Merge keys learned from the firmware block list into the local set WITHOUT + /// echoing them back to the radio (used by the connect-time union pull). + /// Never removes — the union only adds. + Future importKeys(Iterable keysHex) async { + var changed = false; + for (final k in keysHex) { + if (_blockedKeys.add(k.toLowerCase())) changed = true; + } + if (!changed) return; await _store.saveKeys(_blockedKeys); notifyListeners(); } @@ -72,10 +95,12 @@ class BlockService extends ChangeNotifier { Future maybePromote(String name, String publicKeyHex) async { final n = name.trim().toLowerCase(); if (n.isEmpty || !_blockedNames.containsKey(n)) return; + final key = publicKeyHex.toLowerCase(); _blockedNames.remove(n); - _blockedKeys.add(publicKeyHex.toLowerCase()); + final added = _blockedKeys.add(key); await _store.saveNames(_blockedNames); await _store.saveKeys(_blockedKeys); + if (added) firmwareSync?.call(key, true); notifyListeners(); }