From d63f57a69884d8f1d9ce1cdb712b27d6ea1091a2 Mon Sep 17 00:00:00 2001 From: Strycher Date: Wed, 1 Jul 2026 03:12:55 -0400 Subject: [PATCH] feat(#168): BlockStore + BlockService (global block-list foundation) App-global (NOT per-device) block list, source of truth for the client: - BlockStore persists blockedKeys (pubkey hex) + blockedNames (name->first-seen epoch, for A7 promote-and-prune) via PrefsManager, global keys. - BlockService (ChangeNotifier): isBlocked/isNameBlocked/block/unblock/ blockName/unblockName + unmodifiable accessors. - Wired into main.dart MultiProvider. Epic A #165 / A1 #168. Design: docs/architecture/block-contract-as-built.md. Co-Authored-By: Claude Opus 4.8 --- lib/main.dart | 7 ++++ lib/services/block_service.dart | 64 +++++++++++++++++++++++++++++++++ lib/storage/block_store.dart | 43 ++++++++++++++++++++++ 3 files changed, 114 insertions(+) create mode 100644 lib/services/block_service.dart create mode 100644 lib/storage/block_store.dart diff --git a/lib/main.dart b/lib/main.dart index b106dd3..892ae99 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -26,6 +26,7 @@ import 'services/translation_service.dart'; import 'services/ui_view_state_service.dart'; import 'services/timeout_prediction_service.dart'; import 'services/observer_config_service.dart'; +import 'services/block_service.dart'; import 'storage/prefs_manager.dart'; import 'utils/app_logger.dart'; @@ -53,6 +54,7 @@ void main() async { final translationService = TranslationService(appSettingsService); final uiViewStateService = UiViewStateService(); final timeoutPredictionService = TimeoutPredictionService(storage); + final blockService = BlockService(); // Load settings await appSettingsService.loadSettings(); @@ -76,6 +78,7 @@ void main() async { await translationService.refreshDownloadedModels(); await uiViewStateService.initialize(); await timeoutPredictionService.initialize(); + await blockService.load(); // Wire up connector with services connector.initialize( @@ -113,6 +116,7 @@ void main() async { translationService: translationService, uiViewStateService: uiViewStateService, timeoutPredictionService: timeoutPredictionService, + blockService: blockService, ), ); } @@ -152,6 +156,7 @@ class MeshCoreApp extends StatelessWidget { final TranslationService translationService; final UiViewStateService uiViewStateService; final TimeoutPredictionService timeoutPredictionService; + final BlockService blockService; const MeshCoreApp({ super.key, @@ -168,6 +173,7 @@ class MeshCoreApp extends StatelessWidget { required this.translationService, required this.uiViewStateService, required this.timeoutPredictionService, + required this.blockService, }); @override @@ -187,6 +193,7 @@ class MeshCoreApp extends StatelessWidget { Provider.value(value: storage), Provider.value(value: mapTileCacheService), ChangeNotifierProvider.value(value: timeoutPredictionService), + ChangeNotifierProvider.value(value: blockService), ChangeNotifierProvider(create: (_) => ObserverConfigService(connector)), ], child: Consumer( diff --git a/lib/services/block_service.dart b/lib/services/block_service.dart new file mode 100644 index 0000000..f3df396 --- /dev/null +++ b/lib/services/block_service.dart @@ -0,0 +1,64 @@ +import 'package:flutter/foundation.dart'; + +import '../storage/block_store.dart'; + +/// App-global block list, backed by [BlockStore] and exposed to the UI. +/// +/// Source of truth for the client: DMs + adverts are filtered by public key, +/// channel posts by resolved key or claimed name. Always active regardless of +/// firmware; the firmware offload (Epic B) mirrors this, never replaces it. +/// See `docs/architecture/block-contract-as-built.md`. +class BlockService extends ChangeNotifier { + BlockService({BlockStore? store}) : _store = store ?? BlockStore(); + + final BlockStore _store; + + final Set _blockedKeys = {}; + final Map _blockedNames = {}; + + /// Load persisted state. Call once during app startup. + Future load() async { + _blockedKeys + ..clear() + ..addAll(await _store.loadKeys()); + _blockedNames + ..clear() + ..addAll(await _store.loadNames()); + notifyListeners(); + } + + Set get blockedKeys => Set.unmodifiable(_blockedKeys); + Map get blockedNames => Map.unmodifiable(_blockedNames); + + bool isBlocked(String publicKeyHex) => + _blockedKeys.contains(publicKeyHex.toLowerCase()); + + bool isNameBlocked(String name) => + _blockedNames.containsKey(name.trim().toLowerCase()); + + Future block(String publicKeyHex) async { + if (!_blockedKeys.add(publicKeyHex.toLowerCase())) return; + await _store.saveKeys(_blockedKeys); + notifyListeners(); + } + + Future unblock(String publicKeyHex) async { + if (!_blockedKeys.remove(publicKeyHex.toLowerCase())) return; + await _store.saveKeys(_blockedKeys); + notifyListeners(); + } + + Future blockName(String name) async { + final n = name.trim().toLowerCase(); + if (n.isEmpty || _blockedNames.containsKey(n)) return; + _blockedNames[n] = DateTime.now().millisecondsSinceEpoch; + await _store.saveNames(_blockedNames); + notifyListeners(); + } + + Future unblockName(String name) async { + if (_blockedNames.remove(name.trim().toLowerCase()) == null) return; + await _store.saveNames(_blockedNames); + notifyListeners(); + } +} diff --git a/lib/storage/block_store.dart b/lib/storage/block_store.dart new file mode 100644 index 0000000..22c892b --- /dev/null +++ b/lib/storage/block_store.dart @@ -0,0 +1,43 @@ +import 'dart:convert'; + +import '../utils/app_logger.dart'; +import 'prefs_manager.dart'; + +/// Persistence for the app-global block list. +/// +/// Deliberately **global** — unlike the other stores it is NOT scoped by the +/// connected device key. A block is "I don't want to see this person," keyed by +/// their public key, and must hold regardless of which radio is connected. +/// See `docs/architecture/block-contract-as-built.md`. +class BlockStore { + static const String _keysKey = 'block_keys_v1'; + static const String _namesKey = 'block_names_v1'; + + /// Blocked public keys (lowercased hex). + Future> loadKeys() async { + final list = PrefsManager.instance.getStringList(_keysKey) ?? const []; + return list.map((k) => k.toLowerCase()).toSet(); + } + + Future saveKeys(Set keys) async { + await PrefsManager.instance.setStringList(_keysKey, keys.toList()); + } + + /// Name-only blocks: lowercased claimed name -> first-blocked epoch millis. + /// The timestamp feeds the promote-and-prune age-out (Epic A / A7). + Future> loadNames() async { + final raw = PrefsManager.instance.getString(_namesKey); + if (raw == null || raw.isEmpty) return {}; + try { + final decoded = jsonDecode(raw) as Map; + return decoded.map((k, v) => MapEntry(k, (v as num).toInt())); + } catch (e) { + appLogger.error('Failed to decode blocked names: $e'); + return {}; + } + } + + Future saveNames(Map names) async { + await PrefsManager.instance.setString(_namesKey, jsonEncode(names)); + } +}