fix(#1122): log every notify-mode save and notify decision

The exported log could not show what the client did when a channel's
phone notify mode was set, or why a channel message notified, so #1121
could not be diagnosed from a log.

- Every notify-mode save logs radio, slot, channel, a one-way PSK
  fingerprint, old mode with its source, new mode, and the read-back.
  A save refused for a missing radio key or PSK logs as an error.
- Any change to the notify table, by any path, logs before and after.
  Settings that fail to parse at startup, or fail to write, log errors.
- The notify dialog and the channel action sheet log what they show.
- Every incoming channel message logs its notify decision: which
  channel the slot held, from the live or cached list, the mode and
  where it came from, and posted or suppressed with the reason.
- NotificationService queue, batch, suppress and failure paths go to
  the app log instead of debugPrint.
- The app version is logged at startup and on every connect.
- appLogger no longer drops messages when the in-app log view is off;
  the log file always receives them, as connector lines already did.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
fix/1118-factory-reset-delete-on-send
diffwireauto 5 days ago committed by Strycher
parent 6b8356fc87
commit 43ec4d2fa3

@ -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 {

@ -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();

@ -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<ChannelsScreen>
) {
final parentContext = context;
final settingsService = context.read<AppSettingsService>();
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,

@ -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<void> 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<void> 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<String, Map<String, ChannelNotifyMode>> 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<void> 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<void> 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<void> 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<String, String> 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, () => {});

@ -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<void> 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,
);
}
}
}

@ -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);
}
}

@ -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<AppSettingsService>();
final devicePrefix = context.read<MeshCoreConnector>().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<void>(
context: context,
@ -61,6 +70,7 @@ void showChannelNotifyModeDialog(BuildContext context, Channel channel) {
pskHex: channel.pskHex,
channelName: channel.name,
mode: mode,
channelIndex: channel.index,
);
},
),

@ -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<int>.generate(16, (i) => (seed + i) & 0xFF)),
);
Future<void> _settle([int turns = 25]) async {
for (var i = 0; i < turns; i++) {
await Future<void>.delayed(Duration.zero);
}
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
const prefix = 'aabbccddee';
const psk = '00112233445566778899aabbccddeeff';
late AppDebugLogService log;
List<AppDebugLogEntry> 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<int>.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<String> 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'));
},
);
});
}
Loading…
Cancel
Save

Powered by TurnKey Linux.