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 <noreply@anthropic.com>
pull/209/head
Strycher 3 weeks ago
parent dc20cc17f1
commit d63f57a698

@ -26,6 +26,7 @@ import 'services/translation_service.dart';
import 'services/ui_view_state_service.dart'; import 'services/ui_view_state_service.dart';
import 'services/timeout_prediction_service.dart'; import 'services/timeout_prediction_service.dart';
import 'services/observer_config_service.dart'; import 'services/observer_config_service.dart';
import 'services/block_service.dart';
import 'storage/prefs_manager.dart'; import 'storage/prefs_manager.dart';
import 'utils/app_logger.dart'; import 'utils/app_logger.dart';
@ -53,6 +54,7 @@ void main() async {
final translationService = TranslationService(appSettingsService); final translationService = TranslationService(appSettingsService);
final uiViewStateService = UiViewStateService(); final uiViewStateService = UiViewStateService();
final timeoutPredictionService = TimeoutPredictionService(storage); final timeoutPredictionService = TimeoutPredictionService(storage);
final blockService = BlockService();
// Load settings // Load settings
await appSettingsService.loadSettings(); await appSettingsService.loadSettings();
@ -76,6 +78,7 @@ void main() async {
await translationService.refreshDownloadedModels(); await translationService.refreshDownloadedModels();
await uiViewStateService.initialize(); await uiViewStateService.initialize();
await timeoutPredictionService.initialize(); await timeoutPredictionService.initialize();
await blockService.load();
// Wire up connector with services // Wire up connector with services
connector.initialize( connector.initialize(
@ -113,6 +116,7 @@ void main() async {
translationService: translationService, translationService: translationService,
uiViewStateService: uiViewStateService, uiViewStateService: uiViewStateService,
timeoutPredictionService: timeoutPredictionService, timeoutPredictionService: timeoutPredictionService,
blockService: blockService,
), ),
); );
} }
@ -152,6 +156,7 @@ class MeshCoreApp extends StatelessWidget {
final TranslationService translationService; final TranslationService translationService;
final UiViewStateService uiViewStateService; final UiViewStateService uiViewStateService;
final TimeoutPredictionService timeoutPredictionService; final TimeoutPredictionService timeoutPredictionService;
final BlockService blockService;
const MeshCoreApp({ const MeshCoreApp({
super.key, super.key,
@ -168,6 +173,7 @@ class MeshCoreApp extends StatelessWidget {
required this.translationService, required this.translationService,
required this.uiViewStateService, required this.uiViewStateService,
required this.timeoutPredictionService, required this.timeoutPredictionService,
required this.blockService,
}); });
@override @override
@ -187,6 +193,7 @@ class MeshCoreApp extends StatelessWidget {
Provider.value(value: storage), Provider.value(value: storage),
Provider.value(value: mapTileCacheService), Provider.value(value: mapTileCacheService),
ChangeNotifierProvider.value(value: timeoutPredictionService), ChangeNotifierProvider.value(value: timeoutPredictionService),
ChangeNotifierProvider.value(value: blockService),
ChangeNotifierProvider(create: (_) => ObserverConfigService(connector)), ChangeNotifierProvider(create: (_) => ObserverConfigService(connector)),
], ],
child: Consumer<AppSettingsService>( child: Consumer<AppSettingsService>(

@ -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<String> _blockedKeys = {};
final Map<String, int> _blockedNames = {};
/// Load persisted state. Call once during app startup.
Future<void> load() async {
_blockedKeys
..clear()
..addAll(await _store.loadKeys());
_blockedNames
..clear()
..addAll(await _store.loadNames());
notifyListeners();
}
Set<String> get blockedKeys => Set.unmodifiable(_blockedKeys);
Map<String, int> get blockedNames => Map.unmodifiable(_blockedNames);
bool isBlocked(String publicKeyHex) =>
_blockedKeys.contains(publicKeyHex.toLowerCase());
bool isNameBlocked(String name) =>
_blockedNames.containsKey(name.trim().toLowerCase());
Future<void> block(String publicKeyHex) async {
if (!_blockedKeys.add(publicKeyHex.toLowerCase())) return;
await _store.saveKeys(_blockedKeys);
notifyListeners();
}
Future<void> unblock(String publicKeyHex) async {
if (!_blockedKeys.remove(publicKeyHex.toLowerCase())) return;
await _store.saveKeys(_blockedKeys);
notifyListeners();
}
Future<void> 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<void> unblockName(String name) async {
if (_blockedNames.remove(name.trim().toLowerCase()) == null) return;
await _store.saveNames(_blockedNames);
notifyListeners();
}
}

@ -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<Set<String>> loadKeys() async {
final list = PrefsManager.instance.getStringList(_keysKey) ?? const [];
return list.map((k) => k.toLowerCase()).toSet();
}
Future<void> saveKeys(Set<String> 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<Map<String, int>> loadNames() async {
final raw = PrefsManager.instance.getString(_namesKey);
if (raw == null || raw.isEmpty) return {};
try {
final decoded = jsonDecode(raw) as Map<String, dynamic>;
return decoded.map((k, v) => MapEntry(k, (v as num).toInt()));
} catch (e) {
appLogger.error('Failed to decode blocked names: $e');
return {};
}
}
Future<void> saveNames(Map<String, int> names) async {
await PrefsManager.instance.setString(_namesKey, jsonEncode(names));
}
}
Loading…
Cancel
Save

Powered by TurnKey Linux.