diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index 841eba39..b773b640 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -5873,6 +5873,7 @@ class MeshCoreConnector extends ChangeNotifier { @visibleForTesting void setFirmwareVerCodeForTest(int? value) => _firmwareVerCode = value; + void setOffbandCapsForTest(int? value) => _offbandCaps = value; void _guardRegionScope() { if (!supportsRegionScope) { @@ -6245,6 +6246,21 @@ class MeshCoreConnector extends ChangeNotifier { final toImport = nodeSet.difference(local).difference(touched); final toPush = local.difference(nodeSet).difference(touched); unawaited(blockService.importKeys(toImport)); + // #471 Option A: a radio that reports an EMPTY block list is treated as + // blank. Do NOT auto-offload the client's blocks onto it, which made + // fresh/erased radios come up pre-blocked. The client stays the source of + // truth; blocks reach a radio only by an explicit user action. A radio that + // already holds blocks still unions normally. + if (nodeSet.isEmpty) { + if (toPush.isNotEmpty) { + _appDebugLogService?.info( + 'Radio reports no blocks; not offloading ${toPush.length} ' + 'client block(s) onto a blank radio (#471)', + tag: 'Block', + ); + } + return; + } for (final key in toPush) { _pushBlockChange(key, true); } diff --git a/test/connector/block_offload_fresh_radio_test.dart b/test/connector/block_offload_fresh_radio_test.dart new file mode 100644 index 00000000..d422ae93 --- /dev/null +++ b/test/connector/block_offload_fresh_radio_test.dart @@ -0,0 +1,110 @@ +import 'dart:typed_data'; + +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_open/connector/meshcore_connector.dart'; +import 'package:meshcore_open/connector/meshcore_protocol.dart'; +import 'package:meshcore_open/services/block_service.dart'; +import 'package:meshcore_open/services/message_retry_service.dart'; +import 'package:meshcore_open/services/path_history_service.dart'; +import 'package:meshcore_open/services/storage_service.dart'; +import 'package:meshcore_open/storage/drift/blob_store.dart'; +import 'package:meshcore_open/storage/drift/offband_database.dart'; +import 'package:meshcore_open/storage/prefs_manager.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +// #471 Option A: connecting to a radio that reports an EMPTY block list must not +// offload the client's block list onto it (that made fresh/erased radios come +// up pre-blocked). A radio that already holds blocks still unions normally. +void main() { + final clientKey = 'cc' * 32; // 32-byte key, all 0xcc + final extraKey = 'ee' * 32; + + late OffbandDatabase db; + late MeshCoreConnector connector; + late BlockService block; + late List sent; + + // Block-list dump framing: START [0xC2 0x03 0xFF count], KEY [0xC2 0x03 idx key32], END [0xC2 0x03 0xFE]. + Uint8List start(int count) => + Uint8List.fromList([cmdOffbandBlock, offbandBlockList, 0xFF, count]); + Uint8List key(int idx, int keyByte) => Uint8List.fromList([ + cmdOffbandBlock, + offbandBlockList, + idx, + ...List.filled(32, keyByte), + ]); + Uint8List end() => + Uint8List.fromList([cmdOffbandBlock, offbandBlockList, 0xFE]); + + List blockAddFrames() => sent + .where( + (f) => + f.length > 1 && f[0] == cmdOffbandBlock && f[1] == offbandBlockAdd, + ) + .toList(); + + setUp(() async { + SharedPreferences.setMockInitialValues({}); + PrefsManager.reset(); + await PrefsManager.initialize(); + db = OffbandDatabase(NativeDatabase.memory()); + BlobStore.overrideForTest(BlobStore(db)); + + connector = MeshCoreConnector(); + block = BlockService(); + connector.initialize( + retryService: MessageRetryService(), + pathHistoryService: PathHistoryService(StorageService()), + blockService: block, + ); + sent = []; + connector.sendFrameOverrideForTest = sent.add; + connector.setConnectedForTest(); + // Make the radio block-capable so a push WOULD fire but for the guard. + connector.setFirmwareVerCodeForTest(15); + connector.setOffbandCapsForTest(offbandCapBlock); + }); + + tearDown(() async { + BlobStore.clearTestOverride(); + await db.close(); + }); + + test( + 'a radio reporting no blocks is NOT offloaded the client list', + () async { + await block.importKeys({clientKey}); // client has a block + sent.clear(); + + connector.handleFrameForTest(start(0)); + connector.handleFrameForTest(end()); + await Future.delayed(Duration.zero); + + expect( + blockAddFrames(), + isEmpty, + reason: 'a blank radio must not inherit the client block list', + ); + }, + ); + + test( + 'a radio that already holds blocks still unions the client extras', + () async { + await block.importKeys({clientKey, extraKey}); + sent.clear(); + + // Radio holds clientKey (0xcc) already, but not extraKey (0xee). + connector.handleFrameForTest(start(1)); + connector.handleFrameForTest(key(0, 0xcc)); + connector.handleFrameForTest(end()); + await Future.delayed(Duration.zero); + + final pushed = blockAddFrames(); + expect(pushed.length, 1, reason: 'only the extra block is offloaded'); + // The pushed key is extraKey (0xee...), not the one the radio already had. + expect(pushed.single.sublist(2), List.filled(32, 0xee)); + }, + ); +}