From d83cbfae0c998e1efdb0192fd825f56142c1e2ea Mon Sep 17 00:00:00 2001 From: diffwireauto <330573775+diffwireauto@users.noreply.github.com> Date: Thu, 8 Oct 2026 02:39:39 -0400 Subject: [PATCH] feat(#829): per-channel region-scope store (Epic #815 C1) ChannelRegionScopeStore persists a channel-index -> region-name map per device (first 10 hex of the device key, matching the other channel stores). set/clear/scopeFor, null = unscoped. Tests cover round-trip, per-device and per-channel isolation, persistence, and no-device-key guard. Co-Authored-By: Claude Opus 4.8 --- lib/storage/channel_region_scope_store.dart | 39 ++++++++++++ .../channel_region_scope_store_test.dart | 60 +++++++++++++++++++ 2 files changed, 99 insertions(+) create mode 100644 lib/storage/channel_region_scope_store.dart create mode 100644 test/storage/channel_region_scope_store_test.dart diff --git a/lib/storage/channel_region_scope_store.dart b/lib/storage/channel_region_scope_store.dart new file mode 100644 index 00000000..89995266 --- /dev/null +++ b/lib/storage/channel_region_scope_store.dart @@ -0,0 +1,39 @@ +import '../utils/app_logger.dart'; +import 'prefs_manager.dart'; + +/// Per-channel region scope, persisted per device (Feature #812, Epic #815). +/// +/// Stores the region name a channel's outgoing messages should ride, keyed by +/// the first 10 hex chars of the connected device's public key (the same +/// scoping the other channel stores use). The name is the `#`-stripped region +/// name from discovery; null/absent means the channel is unscoped. +class ChannelRegionScopeStore { + static const String _keyPrefix = 'channel_region_scope_'; + + String publicKeyHex = ''; + set setPublicKeyHex(String value) => + publicKeyHex = value.length > 10 ? value.substring(0, 10) : ''; + + String _key(int channelIndex) => '$_keyPrefix${publicKeyHex}_$channelIndex'; + + /// The region name scoping [channelIndex], or null when unscoped. + Future scopeFor(int channelIndex) async { + if (publicKeyHex.isEmpty) return null; + return PrefsManager.instance.getString(_key(channelIndex)); + } + + /// Scope [channelIndex] to [regionName]. + Future setScope(int channelIndex, String regionName) async { + if (publicKeyHex.isEmpty) { + appLogger.warn('No device key set; cannot save channel region scope.'); + return; + } + await PrefsManager.instance.setString(_key(channelIndex), regionName); + } + + /// Remove the region scope on [channelIndex] (back to unscoped). + Future clearScope(int channelIndex) async { + if (publicKeyHex.isEmpty) return; + await PrefsManager.instance.remove(_key(channelIndex)); + } +} diff --git a/test/storage/channel_region_scope_store_test.dart b/test/storage/channel_region_scope_store_test.dart new file mode 100644 index 00000000..9ca28c8a --- /dev/null +++ b/test/storage/channel_region_scope_store_test.dart @@ -0,0 +1,60 @@ +// Epic #815 T C1: per-channel region-scope store. +// Device-key scoped (first 10 hex chars of the device public key), channel -> region name. + +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_open/storage/channel_region_scope_store.dart'; +import 'package:meshcore_open/storage/prefs_manager.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +const _devA = 'aabbccddee0011223344'; +const _devB = 'ffeeddccbb0011223344'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() async { + SharedPreferences.setMockInitialValues({}); + PrefsManager.reset(); + await PrefsManager.initialize(); + }); + + ChannelRegionScopeStore storeFor(String deviceKeyHex) { + final s = ChannelRegionScopeStore(); + s.setPublicKeyHex = deviceKeyHex; + return s; + } + + test('set / get / clear round-trip', () async { + final store = storeFor(_devA); + expect(await store.scopeFor(0), isNull); + await store.setScope(0, 'oki'); + expect(await store.scopeFor(0), 'oki'); + await store.clearScope(0); + expect(await store.scopeFor(0), isNull); + }); + + test('scopes are isolated per device key', () async { + await storeFor(_devA).setScope(2, 'oki'); + expect(await storeFor(_devB).scopeFor(2), isNull); + expect(await storeFor(_devA).scopeFor(2), 'oki'); + }); + + test('scopes are isolated per channel index', () async { + final store = storeFor(_devA); + await store.setScope(1, 'oki'); + expect(await store.scopeFor(3), isNull); + expect(await store.scopeFor(1), 'oki'); + }); + + test('survives a fresh store instance (persisted to prefs)', () async { + await storeFor(_devA).setScope(5, 'test'); + final reopened = storeFor(_devA); + expect(await reopened.scopeFor(5), 'test'); + }); + + test('with no device key set, reads null and writes are refused', () async { + final store = ChannelRegionScopeStore(); + await store.setScope(0, 'oki'); + expect(await store.scopeFor(0), isNull); + }); +}