diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index 6f799b87..c8010ef9 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -9025,6 +9025,13 @@ class MeshCoreConnector extends ChangeNotifier { return; } + _appDebugLogService?.info( + 'App ${appLogger.appVersion} connected to radio ' + '${AppSettingsService.radioScopePrefix(selfPublicKeyHex)} ' + '"${_selfName ?? ''}"', + tag: 'Connector', + ); + //set all the stores' public key so they can load the correct data _channelMessageStore.setPublicKeyHex = selfPublicKeyHex; // Key channel history by the channel's PSK identity (not its reusable slot @@ -10510,17 +10517,29 @@ class MeshCoreConnector extends ChangeNotifier { String? channelName, TranslationResult? translationResult, }) { - if (message.isOutgoing || _appSettingsService == null) return; + if (message.isOutgoing) return; final channelIndex = message.channelIndex; - if (channelIndex == null) return; + if (_appSettingsService == null || channelIndex == null) { + _appDebugLogService?.error( + 'CHAN NOTIFY not evaluated: ' + '${_appSettingsService == null ? 'settings service missing' : 'message has no channel slot'}', + tag: 'Notification', + ); + return; + } + final label = channelName ?? _channelDisplayName(channelIndex); final settings = _appSettingsService!.settings; if (!settings.notificationsEnabled || !settings.notifyOnNewChannelMessage) { + _appDebugLogService?.info( + 'CHAN NOTIFY slot $channelIndex "$label": suppressed, app setting ' + 'off (notifications=${settings.notificationsEnabled}, ' + 'channel messages=${settings.notifyOnNewChannelMessage})', + tag: 'Notification', + ); return; } - final label = channelName ?? _channelDisplayName(channelIndex); - // Reuse translation result only if completed and non-empty; else use original text final resolvedText = (translationResult != null && @@ -10545,24 +10564,38 @@ class MeshCoreConnector extends ChangeNotifier { channelNotifyProbeForTest?.call(channelIndex, label, null, null); return; } - final mode = _appSettingsService!.channelNotifyMode( + final resolved = _appSettingsService!.resolveChannelNotifyMode( devicePrefix: selfPublicKeyHex, pskHex: slotChannel?.pskHex, ); + final mode = resolved.mode; channelNotifyProbeForTest?.call( channelIndex, label, slotChannel?.pskHex, mode, ); - switch (mode) { - case ChannelNotifyMode.off: - return; - case ChannelNotifyMode.mentionsOnly: - if (!_mentionsSelf(resolvedText)) return; - case ChannelNotifyMode.all: - break; - } + final String? suppressed = switch (mode) { + ChannelNotifyMode.off => 'mode off', + ChannelNotifyMode.mentionsOnly when !_mentionsSelf(resolvedText) => + 'mentions only, no mention', + _ => null, + }; + final slotSource = slotChannel == null + ? 'no channel in slot' + : _channels.any((c) => c.index == channelIndex) + ? 'live list' + : 'cached list'; + _appDebugLogService?.info( + 'CHAN NOTIFY slot $channelIndex "$label": radio ' + '${AppSettingsService.radioScopePrefix(selfPublicKeyHex)}, slot holds ' + '"${slotChannel?.name ?? ''}" psk ' + '${AppSettingsService.pskFingerprint(slotChannel?.pskHex)} ' + '($slotSource), mode ${mode.name} (${resolved.source}) -> ' + '${suppressed == null ? 'posting' : 'suppressed, $suppressed'}', + tag: 'Notification', + ); + if (suppressed != null) return; unawaited( () async { diff --git a/lib/main.dart b/lib/main.dart index 51ad37be..cc033518 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; +import 'package:package_info_plus/package_info_plus.dart'; import 'l10n/app_localizations.dart'; import 'package:provider/provider.dart'; @@ -115,6 +116,10 @@ void main() async { appDebugLogService, enabled: appSettingsService.settings.appDebugLogEnabled, ); + final packageInfo = await PackageInfo.fromPlatform(); + appLogger.appVersion = '${packageInfo.version}+${packageInfo.buildNumber}'; + appLogger.info('App ${appLogger.appVersion} started', tag: 'App'); + appSettingsService.logLoadReport(); // Initialize notification service final notificationService = NotificationService(); diff --git a/lib/screens/channels_screen.dart b/lib/screens/channels_screen.dart index 0c723f7d..569f2dc3 100644 --- a/lib/screens/channels_screen.dart +++ b/lib/screens/channels_screen.dart @@ -16,6 +16,7 @@ import '../services/ui_view_state_service.dart'; import '../models/channel.dart'; import '../models/community.dart'; import '../storage/community_store.dart'; +import '../utils/app_logger.dart'; import '../utils/dialog_utils.dart'; import '../utils/disconnect_navigation_mixin.dart'; import '../utils/route_transitions.dart'; @@ -551,10 +552,19 @@ class _ChannelsScreenState extends State ) { final parentContext = context; final settingsService = context.read(); - final notifyMode = settingsService.channelNotifyMode( + final resolvedNotify = settingsService.resolveChannelNotifyMode( devicePrefix: connector.selfPublicKeyHex, pskHex: channel.pskHex, ); + final notifyMode = resolvedNotify.mode; + appLogger.info( + 'Channel actions opened: radio ' + '${AppSettingsService.radioScopePrefix(connector.selfPublicKeyHex)} ' + 'slot ${channel.index} "${channel.name}" psk ' + '${AppSettingsService.pskFingerprint(channel.pskHex)} shows notify ' + '${notifyMode.name} (${resolvedNotify.source})', + tag: 'NotifyMode', + ); showModalBottomSheet( context: parentContext, diff --git a/lib/services/app_settings_service.dart b/lib/services/app_settings_service.dart index 1df64d43..86882c18 100644 --- a/lib/services/app_settings_service.dart +++ b/lib/services/app_settings_service.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import 'package:crypto/crypto.dart'; import 'package:flutter/foundation.dart'; import '../models/app_settings.dart'; import '../models/translation_support.dart'; @@ -25,9 +26,15 @@ class AppSettingsService extends ChangeNotifier { return stored ?? 'nmc'; } + // loadSettings runs before the app logger exists, so its outcome is kept + // here and written by [logLoadReport] once logging is up. + String? _loadError; + bool _loadFoundStored = false; + Future loadSettings() async { final prefs = PrefsManager.instance; final jsonStr = prefs.getString(_settingsKey); + _loadFoundStored = jsonStr != null; if (jsonStr != null) { try { @@ -36,7 +43,7 @@ class AppSettingsService extends ChangeNotifier { Cyr2Lat.setCharMap(_settings.cyr2latCharMap); notifyListeners(); } catch (e) { - // If parsing fails, use defaults + _loadError = '$e'; _settings = AppSettings(); Cyr2Lat.setCharMap(_settings.cyr2latCharMap); } @@ -46,14 +53,76 @@ class AppSettingsService extends ChangeNotifier { } } + void logLoadReport() { + if (_loadError != null) { + appLogger.error( + 'Saved settings could not be read ($_loadError); every setting is ' + 'back at its default, including channel notify modes', + tag: 'Settings', + ); + } else if (!_loadFoundStored) { + appLogger.info('No saved settings; using defaults', tag: 'Settings'); + } + appLogger.info( + 'Channel notify modes loaded (migrated=${_settings.channelNotifyMigrated}): ' + '${describeNotifyModes(_settings.channelNotifyModesByRadio)}', + tag: 'NotifyMode', + ); + } + Future updateSettings(AppSettings newSettings) async { + final notifyBefore = describeNotifyModes( + _settings.channelNotifyModesByRadio, + ); _settings = newSettings; Cyr2Lat.setCharMap(_settings.cyr2latCharMap); + final notifyAfter = describeNotifyModes( + _settings.channelNotifyModesByRadio, + ); + if (notifyAfter != notifyBefore) { + appLogger.info( + 'Channel notify table changed: $notifyBefore -> $notifyAfter', + tag: 'NotifyMode', + ); + } notifyListeners(); final prefs = PrefsManager.instance; final jsonStr = jsonEncode(_settings.toJson()); - await prefs.setString(_settingsKey, jsonStr); + final written = await prefs.setString(_settingsKey, jsonStr); + if (!written) { + appLogger.error( + 'Saving settings to storage failed; changes hold only until the app ' + 'closes', + tag: 'Settings', + ); + } + } + + /// Short one-way tag for a channel PSK, so the log can tell channels apart + /// without carrying the key. + static String pskFingerprint(String? pskHex) { + if (pskHex == null || pskHex.isEmpty) return 'none'; + return sha256.convert(utf8.encode(pskHex)).toString().substring(0, 8); + } + + /// The notify table as one stable line: radio prefix, then each channel's + /// PSK fingerprint and mode, sorted so two equal tables print identically. + static String describeNotifyModes( + Map> byRadio, + ) { + if (byRadio.isEmpty) return '{}'; + final radios = byRadio.keys.toList()..sort(); + return radios + .map((radio) { + final entries = + byRadio[radio]!.entries + .map((e) => '${pskFingerprint(e.key)}=${e.value.name}') + .toList() + ..sort(); + return '$radio[${entries.join(',')}]'; + }) + .join(' '); } Future setClearPathOnMaxRetry(bool value) async { @@ -239,7 +308,7 @@ class AppSettingsService extends ChangeNotifier { /// First 10 hex of the connected radio's key, matching every other per-radio /// store. Empty when no radio is connected -> the caller gets the default. - static String _scopePrefix(String devicePrefix) => + static String radioScopePrefix(String devicePrefix) => devicePrefix.length > 10 ? devicePrefix.substring(0, 10) : devicePrefix; /// Resolve a channel's notify mode for the CONNECTED radio ([devicePrefix]) @@ -249,27 +318,56 @@ class AppSettingsService extends ChangeNotifier { ChannelNotifyMode channelNotifyMode({ required String devicePrefix, required String? pskHex, + }) => + resolveChannelNotifyMode(devicePrefix: devicePrefix, pskHex: pskHex).mode; + + /// [channelNotifyMode] plus where the answer came from, for the log. + ({ChannelNotifyMode mode, String source}) resolveChannelNotifyMode({ + required String devicePrefix, + required String? pskHex, }) { - final prefix = _scopePrefix(devicePrefix); - if (prefix.isEmpty || pskHex == null || pskHex.isEmpty) { - return ChannelNotifyMode.all; + final prefix = radioScopePrefix(devicePrefix); + if (prefix.isEmpty) { + return (mode: ChannelNotifyMode.all, source: 'default, no radio key'); } - return _settings.channelNotifyModesByRadio[prefix]?[pskHex] ?? - ChannelNotifyMode.all; + if (pskHex == null || pskHex.isEmpty) { + return (mode: ChannelNotifyMode.all, source: 'default, no channel PSK'); + } + final stored = _settings.channelNotifyModesByRadio[prefix]?[pskHex]; + if (stored == null) { + return (mode: ChannelNotifyMode.all, source: 'default, nothing saved'); + } + return (mode: stored, source: 'saved'); } /// Store a channel's notify mode for the connected radio. Keyed only by - /// ([devicePrefix], [pskHex]); when either is unknown this is a no-op so we - /// never write an unscoped or index-keyed entry. `all` removes the single - /// entry (it is the default), so set-and-back genuinely resets (#839). + /// ([devicePrefix], [pskHex]); when either is unknown nothing is written, so + /// we never write an unscoped or index-keyed entry, and the refusal is + /// logged as an error. `all` removes the single entry (it is the default), + /// so set-and-back genuinely resets (#839). Future setChannelNotifyMode({ required String devicePrefix, required String? pskHex, required String channelName, required ChannelNotifyMode mode, + int? channelIndex, }) async { - final prefix = _scopePrefix(devicePrefix); - if (prefix.isEmpty || pskHex == null || pskHex.isEmpty) return; + final prefix = radioScopePrefix(devicePrefix); + final target = + 'radio ${prefix.isEmpty ? '(none)' : prefix} slot ' + '${channelIndex ?? '?'} "$channelName" psk ${pskFingerprint(pskHex)}'; + if (prefix.isEmpty || pskHex == null || pskHex.isEmpty) { + appLogger.error( + 'Notify mode NOT saved for $target, wanted ${mode.name}: ' + '${prefix.isEmpty ? 'no radio key' : 'channel has no PSK'}', + tag: 'NotifyMode', + ); + return; + } + final before = resolveChannelNotifyMode( + devicePrefix: devicePrefix, + pskHex: pskHex, + ); final byRadio = _copyByRadio(); final radioModes = byRadio.putIfAbsent(prefix, () => {}); @@ -295,15 +393,28 @@ class AppSettingsService extends ChangeNotifier { mutedChannels: muted, ), ); + final after = resolveChannelNotifyMode( + devicePrefix: devicePrefix, + pskHex: pskHex, + ); + appLogger.info( + 'Notify mode saved for $target: ${before.mode.name} (${before.source}) ' + '-> ${mode.name}; reads back ${after.mode.name} (${after.source})', + tag: 'NotifyMode', + ); } /// Drops [devicePrefix]'s notify modes, the per-radio part of this global /// blob, after that radio was factory reset (#1054). Future forgetRadio(String devicePrefix) async { - final prefix = _scopePrefix(devicePrefix); + final prefix = radioScopePrefix(devicePrefix); if (prefix.isEmpty) return; final byRadio = _copyByRadio(); if (byRadio.remove(prefix) == null) return; + appLogger.info( + 'Dropping notify modes of factory-reset radio $prefix', + tag: 'NotifyMode', + ); await updateSettings( _settings.copyWith(channelNotifyModesByRadio: byRadio), ); @@ -320,8 +431,12 @@ class AppSettingsService extends ChangeNotifier { required Map pskByChannelName, }) async { if (_settings.channelNotifyMigrated) return; - final prefix = _scopePrefix(devicePrefix); + final prefix = radioScopePrefix(devicePrefix); if (prefix.isEmpty) return; + appLogger.info( + 'Migrating legacy channel notify settings into radio $prefix', + tag: 'NotifyMode', + ); final byRadio = _copyByRadio(); final radioModes = byRadio.putIfAbsent(prefix, () => {}); diff --git a/lib/services/notification_service.dart b/lib/services/notification_service.dart index ffb759c5..40f82851 100644 --- a/lib/services/notification_service.dart +++ b/lib/services/notification_service.dart @@ -2,12 +2,12 @@ import 'dart:io' show Platform, File; import 'dart:ui'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; -import 'package:flutter/foundation.dart'; import '../helpers/gif_helper.dart'; import '../helpers/pocketmesh_reaction.dart'; import '../helpers/reaction_helper.dart'; import '../l10n/app_localizations.dart'; +import '../utils/app_logger.dart'; import '../utils/platform_info.dart'; import 'windows_taskbar_service.dart'; @@ -40,14 +40,34 @@ class NotificationService { bool _isBatchingActive = false; bool _suppressNotifications = false; + static const _tag = 'Notification'; + /// Temporarily suppress all notifications (e.g., during sync) void suppressNotifications(bool suppress) { + if (suppress != _suppressNotifications) { + appLogger.info( + suppress + ? 'Suppressing all notifications, dropping ' + '${_pendingNotifications.length} pending' + : 'Notifications no longer suppressed', + tag: _tag, + ); + } _suppressNotifications = suppress; if (suppress) { _pendingNotifications.clear(); } } + bool _droppedWhileSuppressed(_NotificationType type, String identifier) { + if (!_suppressNotifications) return false; + appLogger.info( + 'Dropped while suppressed: ${type.name} ($identifier)', + tag: _tag, + ); + return true; + } + Future initialize() async { if (_isInitialized) return; @@ -86,7 +106,10 @@ class NotificationService { // SocketException when the bus socket is missing (e.g. running as // root or inside a container without a session bus). if (PlatformInfo.isLinux && !_isDbusSessionAvailable()) { - debugPrint('Skipping notification init: D-Bus session bus unavailable'); + appLogger.warn( + 'Skipping notification init: D-Bus session bus unavailable', + tag: _tag, + ); return; } @@ -97,7 +120,7 @@ class NotificationService { ); _isInitialized = true; } catch (e) { - debugPrint('Error initializing notifications: $e'); + appLogger.error('Error initializing notifications: $e', tag: _tag); } } @@ -114,6 +137,12 @@ class NotificationService { if (!_isInitialized) { await initialize(); } + if (!_isInitialized) { + appLogger.error( + 'Notification not shown: notifications are not initialized', + tag: _tag, + ); + } return _isInitialized; } @@ -221,7 +250,7 @@ class NotificationService { payload: 'message:$contactId', ); } catch (e) { - debugPrint('Failed to show message notification: $e'); + appLogger.error('Failed to show message notification: $e', tag: _tag); } } @@ -270,7 +299,7 @@ class NotificationService { payload: 'advert:$contactId', ); } catch (e) { - debugPrint('Failed to show advert notification: $e'); + appLogger.error('Failed to show advert notification: $e', tag: _tag); } } @@ -327,7 +356,7 @@ class NotificationService { payload: 'channel:$channelIndex', ); } catch (e) { - debugPrint('Failed to show channel notification: $e'); + appLogger.error('Failed to show channel notification: $e', tag: _tag); } } @@ -349,7 +378,7 @@ class NotificationService { void _onNotificationTapped(NotificationResponse response) { final payload = response.payload; if (payload != null) { - debugPrint('Notification tapped: $payload'); + appLogger.info('Notification tapped: $payload', tag: _tag); // Handle navigation based on payload // This can be extended to navigate to specific screens } @@ -428,7 +457,12 @@ class NotificationService { String? contactId, int? badgeCount, }) async { - if (_suppressNotifications) return; + if (_droppedWhileSuppressed( + _NotificationType.message, + 'from: $contactName', + )) { + return; + } _queueNotification( _PendingNotification( @@ -446,7 +480,7 @@ class NotificationService { required String contactType, String? contactId, }) async { - if (_suppressNotifications) return; + if (_droppedWhileSuppressed(_NotificationType.advert, contactName)) return; _queueNotification( _PendingNotification( @@ -465,7 +499,12 @@ class NotificationService { int? channelIndex, int? badgeCount, }) async { - if (_suppressNotifications) return; + if (_droppedWhileSuppressed( + _NotificationType.channelMessage, + 'in: $channelName', + )) { + return; + } _queueNotification( _PendingNotification( @@ -485,8 +524,9 @@ class NotificationService { if (_lastNotificationTime != null && now.difference(_lastNotificationTime!) < _minNotificationInterval) { _pendingNotifications.add(notification); - debugPrint( - '[Notification] queued: ${notification.type.name} (${_getNotificationIdentifier(notification)})', + appLogger.info( + 'Queued for batch: ${notification.type.name} (${_getNotificationIdentifier(notification)})', + tag: _tag, ); // Start batch timer if not already running @@ -498,8 +538,9 @@ class NotificationService { } // Show immediately if enough time has passed - debugPrint( - '[Notification] sent immediately: ${notification.type.name} (${_getNotificationIdentifier(notification)})', + appLogger.info( + 'Showing now: ${notification.type.name} (${_getNotificationIdentifier(notification)})', + tag: _tag, ); _showNotificationImmediately(notification); _lastNotificationTime = now; @@ -554,7 +595,7 @@ class NotificationService { break; } } catch (e) { - debugPrint('Failed to show immediate notification: $e'); + appLogger.error('Failed to show immediate notification: $e', tag: _tag); } } @@ -590,7 +631,11 @@ class NotificationService { final deviceInfo = adverts.isNotEmpty ? ' (${adverts.take(5).map((n) => n.body).join(', ')}${adverts.length > 5 ? ', ...' : ''})' : ''; - debugPrint('[Notification] batch summary: ${parts.join(", ")}$deviceInfo'); + appLogger.info( + 'Showing batch summary: ${parts.join(", ")}$deviceInfo; channels: ' + '${channelMsgs.map((n) => n.title).toSet().join(', ')}', + tag: _tag, + ); const androidDetails = AndroidNotificationDetails( 'batch_summary', @@ -612,7 +657,10 @@ class NotificationService { payload: 'batch', ); } catch (e) { - debugPrint('Failed to show batch summary notification: $e'); + appLogger.error( + 'Failed to show batch summary notification: $e', + tag: _tag, + ); } } } diff --git a/lib/utils/app_logger.dart b/lib/utils/app_logger.dart index 1f34a5eb..c431ca8c 100644 --- a/lib/utils/app_logger.dart +++ b/lib/utils/app_logger.dart @@ -6,6 +6,10 @@ class AppLogger { AppDebugLogService? _service; bool _enabled = false; + /// "version+build" of the running app, stamped into the log at startup and + /// on every connect so an exported log names the build that wrote it. + String appVersion = 'unknown'; + /// Initialize the logger with the debug log service void initialize(AppDebugLogService service, {bool enabled = false}) { _service = service; @@ -22,25 +26,23 @@ class AppLogger { /// Check if logging is currently enabled bool get isEnabled => _enabled; + // Every message goes to the service even when the in-app log view is off: + // the service always writes the log file that exports carry, and applies + // the view toggle itself. Gating here dropped these lines from exports. + /// Log an info message void info(String message, {String tag = 'App', bool noNotify = false}) { - if (_enabled && _service != null) { - _service!.info(message, tag: tag, noNotify: noNotify); - } + _service?.info(message, tag: tag, noNotify: noNotify); } /// Log a warning message void warn(String message, {String tag = 'App', bool noNotify = false}) { - if (_enabled && _service != null) { - _service!.warn(message, tag: tag, noNotify: noNotify); - } + _service?.warn(message, tag: tag, noNotify: noNotify); } /// Log an error message void error(String message, {String tag = 'App', bool noNotify = false}) { - if (_enabled && _service != null) { - _service!.error(message, tag: tag, noNotify: noNotify); - } + _service?.error(message, tag: tag, noNotify: noNotify); } /// Log a message with custom level @@ -50,9 +52,7 @@ class AppLogger { AppDebugLogLevel level = AppDebugLogLevel.info, bool noNotify = false, }) { - if (_enabled && _service != null) { - _service!.log(message, tag: tag, level: level, noNotify: noNotify); - } + _service?.log(message, tag: tag, level: level, noNotify: noNotify); } } diff --git a/lib/widgets/channel_notify_mode.dart b/lib/widgets/channel_notify_mode.dart index b58b13e9..bd3ae035 100644 --- a/lib/widgets/channel_notify_mode.dart +++ b/lib/widgets/channel_notify_mode.dart @@ -6,6 +6,7 @@ import '../l10n/l10n.dart'; import '../models/app_settings.dart'; import '../models/channel.dart'; import '../services/app_settings_service.dart'; +import '../utils/app_logger.dart'; /// Shared per-channel notify-mode affordance (#262), reachable both from the /// Channels list and from inside a channel's chat (#275). Single implementation @@ -36,10 +37,18 @@ String channelNotifyModeLabel(BuildContext context, ChannelNotifyMode mode) { void showChannelNotifyModeDialog(BuildContext context, Channel channel) { final settingsService = context.read(); final devicePrefix = context.read().selfPublicKeyHex; - final current = settingsService.channelNotifyMode( + final resolved = settingsService.resolveChannelNotifyMode( devicePrefix: devicePrefix, pskHex: channel.pskHex, ); + final current = resolved.mode; + appLogger.info( + 'Notify mode dialog opened: radio ' + '${AppSettingsService.radioScopePrefix(devicePrefix)} slot ${channel.index} ' + '"${channel.name}" psk ${AppSettingsService.pskFingerprint(channel.pskHex)} ' + 'shows ${current.name} (${resolved.source})', + tag: 'NotifyMode', + ); showDialog( context: context, @@ -61,6 +70,7 @@ void showChannelNotifyModeDialog(BuildContext context, Channel channel) { pskHex: channel.pskHex, channelName: channel.name, mode: mode, + channelIndex: channel.index, ); }, ), diff --git a/test/services/notify_logging_test.dart b/test/services/notify_logging_test.dart new file mode 100644 index 00000000..1b48cf8a --- /dev/null +++ b/test/services/notify_logging_test.dart @@ -0,0 +1,261 @@ +// #1122: the exported log must show what the client did with a channel +// notify mode (every save, every refused save, every change to the table by +// any path) and why each incoming channel message did or did not notify. + +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/models/app_settings.dart'; +import 'package:meshcore_open/models/channel.dart'; +import 'package:meshcore_open/services/app_debug_log_service.dart'; +import 'package:meshcore_open/services/app_settings_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:meshcore_open/utils/app_logger.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +Uint8List _channelInfoFrame(int index, String name, int seed) { + final frame = Uint8List(50); + frame[0] = respCodeChannelInfo; + frame[1] = index; + for (var i = 0; i < name.length; i++) { + frame[2 + i] = name.codeUnitAt(i); + } + for (var i = 0; i < 16; i++) { + frame[34 + i] = (seed + i) & 0xFF; + } + return frame; +} + +Uint8List _channelMsgFrame(int channelIdx, String text) => Uint8List.fromList([ + respCodeChannelMsgRecvV3, + 0, + 0, + 0, + channelIdx, + 1, + txtTypePlain, + 0x40, + 0xE2, + 0x01, + 0x00, + ...text.codeUnits, +]); + +Channel _channel(int index, String name, int seed) => Channel( + index: index, + name: name, + psk: Uint8List.fromList(List.generate(16, (i) => (seed + i) & 0xFF)), +); + +Future _settle([int turns = 25]) async { + for (var i = 0; i < turns; i++) { + await Future.delayed(Duration.zero); + } +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + const prefix = 'aabbccddee'; + const psk = '00112233445566778899aabbccddeeff'; + + late AppDebugLogService log; + + List tagged(String tag) => + log.entries.where((e) => e.tag == tag).toList(); + + setUp(() async { + SharedPreferences.setMockInitialValues({}); + PrefsManager.reset(); + await PrefsManager.initialize(); + log = AppDebugLogService()..setEnabled(true); + appLogger.initialize(log, enabled: true); + }); + + group('settings side', () { + test('a save logs old mode, new mode and the read-back', () async { + final s = AppSettingsService(); + await s.loadSettings(); + + await s.setChannelNotifyMode( + devicePrefix: prefix, + pskHex: psk, + channelName: 'test', + mode: ChannelNotifyMode.off, + channelIndex: 1, + ); + + final saved = tagged('NotifyMode').map((e) => e.message).toList(); + expect( + saved, + contains( + 'Notify mode saved for radio $prefix slot 1 "test" psk ' + '${AppSettingsService.pskFingerprint(psk)}: all (default, nothing ' + 'saved) -> off; reads back off (saved)', + ), + ); + expect( + saved.any((m) => m.startsWith('Channel notify table changed: {} ->')), + isTrue, + ); + }); + + test('a save with no PSK is refused as an error, not silently', () async { + final s = AppSettingsService(); + await s.loadSettings(); + + await s.setChannelNotifyMode( + devicePrefix: prefix, + pskHex: null, + channelName: 'test', + mode: ChannelNotifyMode.off, + channelIndex: 1, + ); + + final errors = tagged( + 'NotifyMode', + ).where((e) => e.level == AppDebugLogLevel.error).toList(); + expect(errors, hasLength(1)); + expect(errors.single.message, contains('NOT saved')); + expect(errors.single.message, contains('channel has no PSK')); + expect(s.settings.channelNotifyModesByRadio, isEmpty); + }); + + test('a save with no radio key is refused as an error', () async { + final s = AppSettingsService(); + await s.loadSettings(); + + await s.setChannelNotifyMode( + devicePrefix: '', + pskHex: psk, + channelName: 'test', + mode: ChannelNotifyMode.off, + ); + + final errors = tagged( + 'NotifyMode', + ).where((e) => e.level == AppDebugLogLevel.error); + expect(errors.single.message, contains('no radio key')); + }); + + test('unreadable saved settings are reported as an error', () async { + SharedPreferences.setMockInitialValues({'app_settings': '{not json'}); + PrefsManager.reset(); + await PrefsManager.initialize(); + final s = AppSettingsService(); + await s.loadSettings(); + + s.logLoadReport(); + + expect( + tagged('Settings').where((e) => e.level == AppDebugLogLevel.error), + hasLength(1), + ); + }); + + test('the PSK fingerprint is short and does not carry the PSK', () { + final fp = AppSettingsService.pskFingerprint(psk); + expect(fp, matches(RegExp(r'^[0-9a-f]{8}$'))); + expect(psk.contains(fp), isFalse); + expect(AppSettingsService.pskFingerprint(null), 'none'); + }); + }); + + group('notify decision', () { + late OffbandDatabase db; + late AppSettingsService settings; + late MeshCoreConnector c; + + setUp(() async { + db = OffbandDatabase(NativeDatabase.memory()); + BlobStore.overrideForTest(BlobStore(db)); + settings = AppSettingsService(); + await settings.loadSettings(); + c = MeshCoreConnector(); + c.initialize( + retryService: MessageRetryService(), + pathHistoryService: PathHistoryService(StorageService()), + appSettingsService: settings, + appDebugLogService: log, + ); + c.sendFrameOverrideForTest = (_) {}; + c.setConnectedForTest(); + c.setSelfPublicKeyForTest( + Uint8List.fromList(List.generate(32, (i) => i)), + ); + c.wireMessageStoreForTest(); + await c.getChannels(force: true, maxChannels: 2); + c.handleFrameForTest(_channelInfoFrame(0, 'Public', 10)); + await _settle(2); + c.handleFrameForTest(_channelInfoFrame(1, 'test', 50)); + await _settle(); + }); + + tearDown(() async { + BlobStore.clearTestOverride(); + await db.close(); + }); + + List decisions() => tagged( + 'Notification', + ).map((e) => e.message).where((m) => m.startsWith('CHAN NOTIFY')).toList(); + + test('an Off channel logs suppressed, with the saved mode', () async { + final testCh = _channel(1, 'test', 50); + await settings.setChannelNotifyMode( + devicePrefix: c.selfPublicKeyHex, + pskHex: testCh.pskHex, + channelName: 'test', + mode: ChannelNotifyMode.off, + channelIndex: 1, + ); + + c.handleFrameForTest(_channelMsgFrame(1, 'B: hi')); + await _settle(); + + final line = decisions().single; + expect(line, contains('slot 1 "test"')); + expect( + line, + contains('psk ${AppSettingsService.pskFingerprint(testCh.pskHex)}'), + ); + expect(line, contains('(live list)')); + expect(line, endsWith('mode off (saved) -> suppressed, mode off')); + }); + + test( + 'a channel with nothing saved logs posting, with the default', + () async { + c.handleFrameForTest(_channelMsgFrame(0, 'B: hi')); + await _settle(); + + expect( + decisions().single, + endsWith('mode all (default, nothing saved) -> posting'), + ); + }, + ); + + test( + 'app-wide channel notifications off is logged as the reason', + () async { + await settings.updateSettings( + settings.settings.copyWith(notifyOnNewChannelMessage: false), + ); + + c.handleFrameForTest(_channelMsgFrame(0, 'B: hi')); + await _settle(); + + expect(decisions().single, contains('suppressed, app setting off')); + }, + ); + }); +}