Initial commit: MeshCore Open Flutter client

Open-source Flutter client for MeshCore LoRa mesh networking devices.

Features:
- BLE device scanning and connection
- Nordic UART Service (NUS) integration
- Material 3 design with system theme support
- Provider-based state management
- Placeholder screens for chat, contacts, and settings

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
zach
2025-12-26 11:42:02 -07:00
commit e7a5b9e209
177 changed files with 20129 additions and 0 deletions
+101
View File
@@ -0,0 +1,101 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../models/app_settings.dart';
class AppSettingsService extends ChangeNotifier {
static const String _settingsKey = 'app_settings';
AppSettings _settings = AppSettings();
AppSettings get settings => _settings;
String batteryChemistryForDevice(String deviceId) {
final stored = _settings.batteryChemistryByDeviceId[deviceId];
if (stored == 'liion') return 'nmc';
return stored ?? 'nmc';
}
Future<void> loadSettings() async {
final prefs = await SharedPreferences.getInstance();
final jsonStr = prefs.getString(_settingsKey);
if (jsonStr != null) {
try {
final json = jsonDecode(jsonStr) as Map<String, dynamic>;
_settings = AppSettings.fromJson(json);
notifyListeners();
} catch (e) {
// If parsing fails, use defaults
_settings = AppSettings();
}
}
}
Future<void> updateSettings(AppSettings newSettings) async {
_settings = newSettings;
notifyListeners();
final prefs = await SharedPreferences.getInstance();
final jsonStr = jsonEncode(_settings.toJson());
await prefs.setString(_settingsKey, jsonStr);
}
Future<void> setClearPathOnMaxRetry(bool value) async {
await updateSettings(_settings.copyWith(clearPathOnMaxRetry: value));
}
Future<void> setMapShowRepeaters(bool value) async {
await updateSettings(_settings.copyWith(mapShowRepeaters: value));
}
Future<void> setMapShowChatNodes(bool value) async {
await updateSettings(_settings.copyWith(mapShowChatNodes: value));
}
Future<void> setMapShowOtherNodes(bool value) async {
await updateSettings(_settings.copyWith(mapShowOtherNodes: value));
}
Future<void> setMapTimeFilterHours(double value) async {
await updateSettings(_settings.copyWith(mapTimeFilterHours: value));
}
Future<void> setMapKeyPrefixEnabled(bool value) async {
await updateSettings(_settings.copyWith(mapKeyPrefixEnabled: value));
}
Future<void> setMapKeyPrefix(String value) async {
await updateSettings(_settings.copyWith(mapKeyPrefix: value));
}
Future<void> setMapShowMarkers(bool value) async {
await updateSettings(_settings.copyWith(mapShowMarkers: value));
}
Future<void> setNotificationsEnabled(bool value) async {
await updateSettings(_settings.copyWith(notificationsEnabled: value));
}
Future<void> setNotifyOnNewMessage(bool value) async {
await updateSettings(_settings.copyWith(notifyOnNewMessage: value));
}
Future<void> setNotifyOnNewAdvert(bool value) async {
await updateSettings(_settings.copyWith(notifyOnNewAdvert: value));
}
Future<void> setAutoRouteRotationEnabled(bool value) async {
await updateSettings(_settings.copyWith(autoRouteRotationEnabled: value));
}
Future<void> setThemeMode(String value) async {
await updateSettings(_settings.copyWith(themeMode: value));
}
Future<void> setBatteryChemistryForDevice(String deviceId, String chemistry) async {
final updated = Map<String, String>.from(_settings.batteryChemistryByDeviceId);
updated[deviceId] = chemistry;
await updateSettings(_settings.copyWith(batteryChemistryByDeviceId: updated));
}
}
+220
View File
@@ -0,0 +1,220 @@
import 'dart:typed_data';
import 'package:flutter/foundation.dart';
import '../connector/meshcore_protocol.dart';
class BleDebugLogEntry {
final DateTime timestamp;
final bool outgoing;
final String description;
final Uint8List payload;
BleDebugLogEntry({
required this.timestamp,
required this.outgoing,
required this.description,
required this.payload,
});
String get hexPreview {
const maxBytes = 64;
final bytes = payload.length > maxBytes ? payload.sublist(0, maxBytes) : payload;
final hex = bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ');
return payload.length > maxBytes ? '$hex' : hex;
}
}
class BleRawLogRxEntry {
final DateTime timestamp;
final Uint8List payload;
BleRawLogRxEntry({
required this.timestamp,
required this.payload,
});
String get hexPreview {
const maxBytes = 64;
final bytes = payload.length > maxBytes ? payload.sublist(0, maxBytes) : payload;
final hex = bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ');
return payload.length > maxBytes ? '$hex' : hex;
}
}
class BleDebugLogService extends ChangeNotifier {
static const int maxEntries = 500;
final List<BleDebugLogEntry> _entries = [];
final List<BleRawLogRxEntry> _rawLogRxEntries = [];
List<BleDebugLogEntry> get entries => List.unmodifiable(_entries);
List<BleRawLogRxEntry> get rawLogRxEntries => List.unmodifiable(_rawLogRxEntries);
void logFrame(Uint8List frame, {required bool outgoing, String? note}) {
if (frame.isEmpty) return;
final code = frame[0];
final description = _describeFrame(code, frame, outgoing, note);
_entries.add(
BleDebugLogEntry(
timestamp: DateTime.now(),
outgoing: outgoing,
description: description,
payload: Uint8List.fromList(frame),
),
);
if (_entries.length > maxEntries) {
_entries.removeRange(0, _entries.length - maxEntries);
}
if (!outgoing && code == pushCodeLogRxData && frame.length > 3) {
_rawLogRxEntries.add(
BleRawLogRxEntry(
timestamp: DateTime.now(),
payload: Uint8List.fromList(frame.sublist(3)),
),
);
if (_rawLogRxEntries.length > maxEntries) {
_rawLogRxEntries.removeRange(0, _rawLogRxEntries.length - maxEntries);
}
}
notifyListeners();
}
void clear() {
_entries.clear();
_rawLogRxEntries.clear();
notifyListeners();
}
String _describeFrame(int code, Uint8List frame, bool outgoing, String? note) {
final label = _codeLabel(code);
final prefix = outgoing ? 'TX' : 'RX';
final extra = _frameDetail(code, frame);
final noteText = note != null ? '$note' : '';
return '$prefix $label$extra$noteText';
}
String _codeLabel(int code) {
switch (code) {
case cmdAppStart:
return 'CMD_APP_START';
case cmdSendTxtMsg:
return 'CMD_SEND_TXT_MSG';
case cmdSendChannelTxtMsg:
return 'CMD_SEND_CHANNEL_TXT_MSG';
case cmdGetContacts:
return 'CMD_GET_CONTACTS';
case cmdGetDeviceTime:
return 'CMD_GET_DEVICE_TIME';
case cmdSetDeviceTime:
return 'CMD_SET_DEVICE_TIME';
case cmdSendSelfAdvert:
return 'CMD_SEND_SELF_ADVERT';
case cmdSetAdvertName:
return 'CMD_SET_ADVERT_NAME';
case cmdAddUpdateContact:
return 'CMD_ADD_UPDATE_CONTACT';
case cmdSyncNextMessage:
return 'CMD_SYNC_NEXT_MESSAGE';
case cmdSetRadioParams:
return 'CMD_SET_RADIO_PARAMS';
case cmdSetRadioTxPower:
return 'CMD_SET_RADIO_TX_POWER';
case cmdResetPath:
return 'CMD_RESET_PATH';
case cmdRemoveContact:
return 'CMD_REMOVE_CONTACT';
case cmdReboot:
return 'CMD_REBOOT';
case cmdGetBattAndStorage:
return 'CMD_GET_BATT_AND_STORAGE';
case cmdSendLogin:
return 'CMD_SEND_LOGIN';
case cmdGetChannel:
return 'CMD_GET_CHANNEL';
case cmdSetChannel:
return 'CMD_SET_CHANNEL';
case cmdGetRadioSettings:
return 'CMD_GET_RADIO_SETTINGS';
case respCodeOk:
return 'RESP_CODE_OK';
case respCodeErr:
return 'RESP_CODE_ERR';
case respCodeContactsStart:
return 'RESP_CODE_CONTACTS_START';
case respCodeContact:
return 'RESP_CODE_CONTACT';
case respCodeEndOfContacts:
return 'RESP_CODE_END_OF_CONTACTS';
case respCodeSelfInfo:
return 'RESP_CODE_SELF_INFO';
case respCodeSent:
return 'RESP_CODE_SENT';
case respCodeContactMsgRecv:
return 'RESP_CODE_CONTACT_MSG_RECV';
case respCodeChannelMsgRecv:
return 'RESP_CODE_CHANNEL_MSG_RECV';
case respCodeCurrTime:
return 'RESP_CODE_CURR_TIME';
case respCodeNoMoreMessages:
return 'RESP_CODE_NO_MORE_MESSAGES';
case respCodeBattAndStorage:
return 'RESP_CODE_BATT_AND_STORAGE';
case respCodeContactMsgRecvV3:
return 'RESP_CODE_CONTACT_MSG_RECV_V3';
case respCodeChannelMsgRecvV3:
return 'RESP_CODE_CHANNEL_MSG_RECV_V3';
case respCodeChannelInfo:
return 'RESP_CODE_CHANNEL_INFO';
case respCodeRadioSettings:
return 'RESP_CODE_RADIO_SETTINGS';
case pushCodeAdvert:
return 'PUSH_CODE_ADVERT';
case pushCodePathUpdated:
return 'PUSH_CODE_PATH_UPDATED';
case pushCodeSendConfirmed:
return 'PUSH_CODE_SEND_CONFIRMED';
case pushCodeMsgWaiting:
return 'PUSH_CODE_MSG_WAITING';
case pushCodeLoginSuccess:
return 'PUSH_CODE_LOGIN_SUCCESS';
case pushCodeLoginFail:
return 'PUSH_CODE_LOGIN_FAIL';
case pushCodeLogRxData:
return 'PUSH_CODE_LOG_RX_DATA';
case pushCodeNewAdvert:
return 'PUSH_CODE_NEW_ADVERT';
default:
return 'CODE_$code';
}
}
String _frameDetail(int code, Uint8List frame) {
switch (code) {
case respCodeSent:
if (frame.length >= 10) {
final timeoutMs = readUint32LE(frame, 6);
return ' • timeout=${timeoutMs}ms';
}
return '';
case pushCodeSendConfirmed:
if (frame.length >= 9) {
final tripMs = readUint32LE(frame, 5);
return ' • trip=${tripMs}ms';
}
return '';
case pushCodeLoginSuccess:
return ' • login ok';
case pushCodeLoginFail:
return ' • login fail';
case respCodeBattAndStorage:
if (frame.length >= 3) {
final mv = readUint16LE(frame, 1);
return '${mv}mV';
}
return '';
default:
return '';
}
}
}
+16
View File
@@ -0,0 +1,16 @@
import 'package:shared_preferences/shared_preferences.dart';
class MapMarkerService {
static const String _removedKey = 'map_removed_marker_ids';
Future<Set<String>> loadRemovedIds() async {
final prefs = await SharedPreferences.getInstance();
final items = prefs.getStringList(_removedKey) ?? const [];
return items.toSet();
}
Future<void> saveRemovedIds(Set<String> ids) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setStringList(_removedKey, ids.toList());
}
}
+348
View File
@@ -0,0 +1,348 @@
import 'dart:async';
import 'dart:typed_data';
import 'package:flutter/foundation.dart';
import 'package:uuid/uuid.dart';
import '../models/contact.dart';
import '../models/message.dart';
import '../models/path_selection.dart';
import 'storage_service.dart';
import 'app_settings_service.dart';
class MessageRetryService extends ChangeNotifier {
static const int maxRetries = 5;
final StorageService _storage;
final Map<String, Timer> _timeoutTimers = {};
final Map<String, Message> _pendingMessages = {};
final Map<String, Contact> _pendingContacts = {};
final Map<String, PathSelection> _pendingPathSelections = {};
Function(Contact, String, bool, int, int)? _sendMessageCallback;
Function(String, Message)? _addMessageCallback;
Function(Message)? _updateMessageCallback;
Function(Contact)? _clearContactPathCallback;
Function(int, int)? _calculateTimeoutCallback;
AppSettingsService? _appSettingsService;
Function(String, PathSelection, bool, int?)? _recordPathResultCallback;
MessageRetryService(this._storage);
void initialize({
required Function(Contact, String, bool, int, int) sendMessageCallback,
required Function(String, Message) addMessageCallback,
required Function(Message) updateMessageCallback,
Function(Contact)? clearContactPathCallback,
Function(int pathLength, int messageBytes)? calculateTimeoutCallback,
AppSettingsService? appSettingsService,
Function(String, PathSelection, bool, int?)? recordPathResultCallback,
}) {
_sendMessageCallback = sendMessageCallback;
_addMessageCallback = addMessageCallback;
_updateMessageCallback = updateMessageCallback;
_clearContactPathCallback = clearContactPathCallback;
_calculateTimeoutCallback = calculateTimeoutCallback;
_appSettingsService = appSettingsService;
_recordPathResultCallback = recordPathResultCallback;
}
Future<void> sendMessageWithRetry({
required Contact contact,
required String text,
bool forceFlood = false,
PathSelection? pathSelection,
Uint8List? pathBytes,
int? pathLength,
}) async {
final messageId = const Uuid().v4();
final effectiveForceFlood = forceFlood || (pathSelection?.useFlood ?? false);
final messagePathBytes =
pathBytes ?? _resolveMessagePathBytes(contact, effectiveForceFlood, pathSelection);
final messagePathLength =
pathLength ?? _resolveMessagePathLength(contact, effectiveForceFlood, pathSelection);
final message = Message(
senderKey: contact.publicKey,
text: text,
timestamp: DateTime.now(),
isOutgoing: true,
status: MessageStatus.pending,
messageId: messageId,
retryCount: 0,
forceFlood: effectiveForceFlood,
pathLength: messagePathLength,
pathBytes: messagePathBytes,
);
_pendingMessages[messageId] = message;
_pendingContacts[messageId] = contact;
if (pathSelection != null) {
_pendingPathSelections[messageId] = pathSelection;
}
if (_addMessageCallback != null) {
_addMessageCallback!(contact.publicKeyHex, message);
}
await _attemptSend(messageId);
}
Future<void> _attemptSend(String messageId) async {
final message = _pendingMessages[messageId];
final contact = _pendingContacts[messageId];
if (message == null || contact == null) return;
Contact sendContact = contact;
final attempt = message.retryCount.clamp(0, 3);
if (message.forceFlood && contact.pathLength >= 0) {
sendContact = Contact(
publicKey: contact.publicKey,
name: contact.name,
type: contact.type,
pathLength: -1,
path: contact.path,
latitude: contact.latitude,
longitude: contact.longitude,
lastSeen: contact.lastSeen,
);
}
if (_sendMessageCallback != null) {
final timestampSeconds = message.timestamp.millisecondsSinceEpoch ~/ 1000;
_sendMessageCallback!(
sendContact,
message.text,
message.forceFlood,
attempt,
timestampSeconds,
);
}
}
void updateMessageFromSent(Uint8List ackHash, int timeoutMs) {
for (var entry in _pendingMessages.entries) {
final message = entry.value;
if (message.status == MessageStatus.pending) {
final contact = _pendingContacts[entry.key];
final selection = _pendingPathSelections[entry.key];
// Use device-provided timeout, or calculate from radio settings if timeout is 0 or invalid
int actualTimeout = timeoutMs;
if (timeoutMs <= 0 && _calculateTimeoutCallback != null && contact != null) {
int pathLengthValue;
if (selection != null) {
pathLengthValue = selection.useFlood ? -1 : selection.hopCount;
if (pathLengthValue < 0) pathLengthValue = contact.pathLength;
} else if (message.pathLength != null) {
pathLengthValue = message.pathLength!;
} else {
pathLengthValue = message.forceFlood ? -1 : contact.pathLength;
}
actualTimeout = _calculateTimeoutCallback!(pathLengthValue, message.text.length);
debugPrint('Using calculated timeout: ${actualTimeout}ms for ${contact.pathLength} hops');
}
final updatedMessage = message.copyWith(
status: MessageStatus.sent,
expectedAckHash: ackHash,
estimatedTimeoutMs: actualTimeout,
sentAt: DateTime.now(),
);
_pendingMessages[entry.key] = updatedMessage;
if (_updateMessageCallback != null) {
_updateMessageCallback!(updatedMessage);
}
_startTimeoutTimer(entry.key, actualTimeout);
return;
}
}
}
void _startTimeoutTimer(String messageId, int timeoutMs) {
_timeoutTimers[messageId]?.cancel();
_timeoutTimers[messageId] = Timer(Duration(milliseconds: timeoutMs), () {
_handleTimeout(messageId);
});
}
void _handleTimeout(String messageId) {
final message = _pendingMessages[messageId];
final contact = _pendingContacts[messageId];
final selection = _pendingPathSelections[messageId];
if (message == null || contact == null) return;
if (message.retryCount < maxRetries - 1) {
final backoffMs = 1000 * (1 << message.retryCount);
final updatedMessage = message.copyWith(
retryCount: message.retryCount + 1,
status: MessageStatus.pending,
);
_pendingMessages[messageId] = updatedMessage;
if (_updateMessageCallback != null) {
_updateMessageCallback!(updatedMessage);
}
Timer(Duration(milliseconds: backoffMs), () {
_attemptSend(messageId);
});
} else {
// Max retries reached - mark as failed
final failedMessage = message.copyWith(status: MessageStatus.failed);
_pendingMessages.remove(messageId);
_pendingContacts.remove(messageId);
_pendingPathSelections.remove(messageId);
_timeoutTimers[messageId]?.cancel();
_timeoutTimers.remove(messageId);
// Check if we should clear the path on max retry
if (_appSettingsService?.settings.clearPathOnMaxRetry == true &&
_clearContactPathCallback != null) {
_clearContactPathCallback!(contact);
}
_recordPathResultFromMessage(contact.publicKeyHex, message, selection, false, null);
if (_updateMessageCallback != null) {
_updateMessageCallback!(failedMessage);
}
notifyListeners();
}
}
void handleAckReceived(Uint8List ackHash, int tripTimeMs) {
String? matchedMessageId;
for (var entry in _pendingMessages.entries) {
final message = entry.value;
if (message.expectedAckHash != null &&
listEquals(message.expectedAckHash, ackHash)) {
matchedMessageId = entry.key;
break;
}
}
if (matchedMessageId != null) {
final message = _pendingMessages[matchedMessageId]!;
final contact = _pendingContacts[matchedMessageId];
final selection = _pendingPathSelections[matchedMessageId];
_timeoutTimers[matchedMessageId]?.cancel();
_timeoutTimers.remove(matchedMessageId);
final deliveredMessage = message.copyWith(
status: MessageStatus.delivered,
deliveredAt: DateTime.now(),
tripTimeMs: tripTimeMs,
);
_pendingMessages.remove(matchedMessageId);
_pendingContacts.remove(matchedMessageId);
_pendingPathSelections.remove(matchedMessageId);
if (_updateMessageCallback != null) {
_updateMessageCallback!(deliveredMessage);
}
if (contact != null) {
_recordPathResultFromMessage(contact.publicKeyHex, message, selection, true, tripTimeMs);
}
notifyListeners();
}
}
Uint8List _resolveMessagePathBytes(
Contact contact,
bool forceFlood,
PathSelection? selection,
) {
if (forceFlood || contact.pathLength < 0 || selection?.useFlood == true) {
return Uint8List(0);
}
if (selection != null && selection.pathBytes.isNotEmpty) {
return Uint8List.fromList(selection.pathBytes);
}
return contact.path;
}
int? _resolveMessagePathLength(
Contact contact,
bool forceFlood,
PathSelection? selection,
) {
if (forceFlood || contact.pathLength < 0 || selection?.useFlood == true) {
return -1;
}
if (selection != null && selection.pathBytes.isNotEmpty) {
return selection.hopCount;
}
return contact.pathLength;
}
String? getContactKeyForAckHash(Uint8List ackHash) {
for (var entry in _pendingMessages.entries) {
final message = entry.value;
if (message.expectedAckHash != null &&
listEquals(message.expectedAckHash, ackHash)) {
final contact = _pendingContacts[entry.key];
return contact?.publicKeyHex;
}
}
return null;
}
int calculateDefaultTimeout(Contact contact) {
if (contact.pathLength < 0) {
return 15000;
} else {
return 3000 + (3000 * contact.pathLength);
}
}
void _recordPathResultFromMessage(
String contactKey,
Message message,
PathSelection? selection,
bool success,
int? tripTimeMs,
) {
if (_recordPathResultCallback == null) return;
final recordSelection = selection ?? _selectionFromMessage(message);
if (recordSelection == null) return;
_recordPathResultCallback!(contactKey, recordSelection, success, tripTimeMs);
}
PathSelection? _selectionFromMessage(Message message) {
if (message.forceFlood || (message.pathLength != null && message.pathLength! < 0)) {
return const PathSelection(pathBytes: [], hopCount: -1, useFlood: true);
}
if (message.pathBytes.isEmpty && message.pathLength == null) {
return null;
}
return PathSelection(
pathBytes: message.pathBytes,
hopCount: message.pathLength ?? message.pathBytes.length,
useFlood: false,
);
}
@override
void dispose() {
for (var timer in _timeoutTimers.values) {
timer.cancel();
}
_timeoutTimers.clear();
_pendingMessages.clear();
_pendingContacts.clear();
_pendingPathSelections.clear();
super.dispose();
}
}
+158
View File
@@ -0,0 +1,158 @@
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:flutter/foundation.dart';
class NotificationService {
static final NotificationService _instance = NotificationService._internal();
factory NotificationService() => _instance;
NotificationService._internal();
final FlutterLocalNotificationsPlugin _notifications = FlutterLocalNotificationsPlugin();
bool _isInitialized = false;
Future<void> initialize() async {
if (_isInitialized) return;
const androidSettings = AndroidInitializationSettings('@mipmap/ic_launcher');
const iosSettings = DarwinInitializationSettings(
requestAlertPermission: true,
requestBadgePermission: true,
requestSoundPermission: true,
);
const initSettings = InitializationSettings(
android: androidSettings,
iOS: iosSettings,
);
try {
await _notifications.initialize(
initSettings,
onDidReceiveNotificationResponse: _onNotificationTapped,
);
_isInitialized = true;
} catch (e) {
debugPrint('Error initializing notifications: $e');
}
}
Future<bool> requestPermissions() async {
if (!_isInitialized) {
await initialize();
}
// Request Android 13+ notification permission
final androidPlugin = _notifications.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin>();
if (androidPlugin != null) {
final granted = await androidPlugin.requestNotificationsPermission();
return granted ?? false;
}
// iOS permissions are requested during initialization
final iosPlugin = _notifications.resolvePlatformSpecificImplementation<
IOSFlutterLocalNotificationsPlugin>();
if (iosPlugin != null) {
final granted = await iosPlugin.requestPermissions(
alert: true,
badge: true,
sound: true,
);
return granted ?? false;
}
return true;
}
Future<void> showMessageNotification({
required String contactName,
required String message,
String? contactId,
}) async {
if (!_isInitialized) {
await initialize();
}
const androidDetails = AndroidNotificationDetails(
'messages',
'Messages',
channelDescription: 'New message notifications',
importance: Importance.high,
priority: Priority.high,
icon: '@mipmap/ic_launcher',
);
const iosDetails = DarwinNotificationDetails(
presentAlert: true,
presentBadge: true,
presentSound: true,
);
const notificationDetails = NotificationDetails(
android: androidDetails,
iOS: iosDetails,
);
await _notifications.show(
contactId?.hashCode ?? 0,
'New message from $contactName',
message.length > 100 ? '${message.substring(0, 100)}...' : message,
notificationDetails,
payload: 'message:$contactId',
);
}
Future<void> showAdvertNotification({
required String contactName,
required String contactType,
String? contactId,
}) async {
if (!_isInitialized) {
await initialize();
}
const androidDetails = AndroidNotificationDetails(
'adverts',
'Advertisements',
channelDescription: 'New node advertisement notifications',
importance: Importance.defaultImportance,
priority: Priority.defaultPriority,
icon: '@mipmap/ic_launcher',
);
const iosDetails = DarwinNotificationDetails(
presentAlert: true,
presentBadge: true,
presentSound: true,
);
const notificationDetails = NotificationDetails(
android: androidDetails,
iOS: iosDetails,
);
await _notifications.show(
contactId?.hashCode ?? DateTime.now().millisecondsSinceEpoch,
'New $contactType discovered',
contactName,
notificationDetails,
payload: 'advert:$contactId',
);
}
void _onNotificationTapped(NotificationResponse response) {
final payload = response.payload;
if (payload != null) {
debugPrint('Notification tapped: $payload');
// Handle navigation based on payload
// This can be extended to navigate to specific screens
}
}
Future<void> cancelAll() async {
await _notifications.cancelAll();
}
Future<void> cancel(int id) async {
await _notifications.cancel(id);
}
}
+324
View File
@@ -0,0 +1,324 @@
import 'package:flutter/foundation.dart';
import '../models/contact.dart';
import '../models/path_history.dart';
import '../models/path_selection.dart';
import 'storage_service.dart';
class PathHistoryService extends ChangeNotifier {
final StorageService _storage;
final Map<String, ContactPathHistory> _cache = {};
final Map<String, int> _autoRotationIndex = {};
final Map<String, _FloodStats> _floodStats = {};
static const int _maxHistoryEntries = 100;
static const int _autoRotationTopCount = 3;
PathHistoryService(this._storage);
Future<void> initialize() async {
// Load cached path histories on startup if needed
}
void handlePathUpdated(Contact contact) {
if (contact.pathLength < 0) return;
_addPathRecord(
contactPubKeyHex: contact.publicKeyHex,
hopCount: contact.pathLength,
tripTimeMs: 0,
wasFloodDiscovery: true,
pathBytes: contact.path,
successCount: 0,
failureCount: 0,
);
}
void recordPathAttempt(String contactPubKeyHex, PathSelection selection) {
if (selection.useFlood) {
_updateFloodStats(contactPubKeyHex);
return;
}
_addPathRecord(
contactPubKeyHex: contactPubKeyHex,
hopCount: selection.hopCount,
tripTimeMs: 0,
wasFloodDiscovery: false,
pathBytes: selection.pathBytes,
successCount: 0,
failureCount: 0,
);
}
void recordPathResult(
String contactPubKeyHex,
PathSelection selection, {
required bool success,
int? tripTimeMs,
}) {
if (selection.useFlood) {
final stats = _floodStats.putIfAbsent(contactPubKeyHex, () => _FloodStats());
if (success) {
stats.successCount += 1;
if (tripTimeMs != null) stats.lastTripTimeMs = tripTimeMs;
} else {
stats.failureCount += 1;
}
stats.lastUsed = DateTime.now();
return;
}
final existing = _findPathRecord(contactPubKeyHex, selection.pathBytes);
final successCount = (existing?.successCount ?? 0) + (success ? 1 : 0);
final failureCount = (existing?.failureCount ?? 0) + (success ? 0 : 1);
_addPathRecord(
contactPubKeyHex: contactPubKeyHex,
hopCount: selection.hopCount,
tripTimeMs: success ? (tripTimeMs ?? 0) : (existing?.tripTimeMs ?? 0),
wasFloodDiscovery: existing?.wasFloodDiscovery ?? false,
pathBytes: selection.pathBytes,
successCount: successCount,
failureCount: failureCount,
);
}
PathSelection getNextAutoPathSelection(String contactPubKeyHex) {
final ranked = _getRankedPaths(contactPubKeyHex)
.take(_autoRotationTopCount)
.toList();
if (ranked.isEmpty) {
return const PathSelection(pathBytes: [], hopCount: -1, useFlood: true);
}
final selections = ranked
.map((path) => PathSelection(
pathBytes: path.pathBytes,
hopCount: path.hopCount,
useFlood: false,
))
.toList()
..add(const PathSelection(pathBytes: [], hopCount: -1, useFlood: true));
final currentIndex = _autoRotationIndex[contactPubKeyHex] ?? 0;
final selection = selections[currentIndex % selections.length];
_autoRotationIndex[contactPubKeyHex] = currentIndex + 1;
return selection;
}
void _addPathRecord({
required String contactPubKeyHex,
required int hopCount,
required int tripTimeMs,
required bool wasFloodDiscovery,
required List<int> pathBytes,
required int successCount,
required int failureCount,
}) {
var history = _cache[contactPubKeyHex];
if (history == null) {
_loadHistoryFromStorage(contactPubKeyHex).then((loaded) {
if (loaded != null) {
_cache[contactPubKeyHex] = loaded;
_addPathRecordInternal(
contactPubKeyHex,
hopCount,
tripTimeMs,
wasFloodDiscovery,
pathBytes,
successCount,
failureCount,
);
} else {
_cache[contactPubKeyHex] = ContactPathHistory(
contactPubKeyHex: contactPubKeyHex,
recentPaths: [],
);
_addPathRecordInternal(
contactPubKeyHex,
hopCount,
tripTimeMs,
wasFloodDiscovery,
pathBytes,
successCount,
failureCount,
);
}
});
return;
}
_addPathRecordInternal(
contactPubKeyHex,
hopCount,
tripTimeMs,
wasFloodDiscovery,
pathBytes,
successCount,
failureCount,
);
}
void _addPathRecordInternal(
String contactPubKeyHex,
int hopCount,
int tripTimeMs,
bool wasFloodDiscovery,
List<int> pathBytes,
int successCount,
int failureCount,
) {
var history = _cache[contactPubKeyHex];
if (history == null) return;
final existing = _findPathRecord(contactPubKeyHex, pathBytes);
if (existing != null) {
successCount = successCount == 0 ? existing.successCount : successCount;
failureCount = failureCount == 0 ? existing.failureCount : failureCount;
if (tripTimeMs == 0) {
tripTimeMs = existing.tripTimeMs;
}
wasFloodDiscovery = existing.wasFloodDiscovery || wasFloodDiscovery;
}
final newRecord = PathRecord(
hopCount: hopCount,
tripTimeMs: tripTimeMs,
timestamp: DateTime.now(),
wasFloodDiscovery: wasFloodDiscovery,
pathBytes: pathBytes,
successCount: successCount,
failureCount: failureCount,
);
final updatedPaths = List<PathRecord>.from(history.recentPaths);
updatedPaths.removeWhere((p) => _pathsEqual(p.pathBytes, pathBytes));
if (existing == null && updatedPaths.length >= _maxHistoryEntries) {
return;
}
updatedPaths.insert(0, newRecord);
final updatedHistory = ContactPathHistory(
contactPubKeyHex: contactPubKeyHex,
recentPaths: updatedPaths,
);
_cache[contactPubKeyHex] = updatedHistory;
_storage.savePathHistory(contactPubKeyHex, updatedHistory);
notifyListeners();
}
List<PathRecord> getRecentPaths(String contactPubKeyHex) {
final history = _cache[contactPubKeyHex];
if (history != null) {
return history.recentPaths;
}
_loadHistoryFromStorage(contactPubKeyHex).then((loaded) {
if (loaded != null) {
_cache[contactPubKeyHex] = loaded;
notifyListeners();
}
});
return [];
}
Future<ContactPathHistory?> _loadHistoryFromStorage(
String contactPubKeyHex) async {
return await _storage.loadPathHistory(contactPubKeyHex);
}
PathRecord? getFastestPath(String contactPubKeyHex) {
final history = _cache[contactPubKeyHex];
return history?.fastest;
}
PathRecord? getMostRecentPath(String contactPubKeyHex) {
final history = _cache[contactPubKeyHex];
return history?.mostRecent;
}
Future<void> clearPathHistory(String contactPubKeyHex) async {
_cache.remove(contactPubKeyHex);
_autoRotationIndex.remove(contactPubKeyHex);
_floodStats.remove(contactPubKeyHex);
await _storage.clearPathHistory(contactPubKeyHex);
notifyListeners();
}
Future<void> removePathRecord(
String contactPubKeyHex,
List<int> pathBytes,
) async {
final history = _cache[contactPubKeyHex];
if (history == null) return;
final updatedPaths = List<PathRecord>.from(history.recentPaths)
..removeWhere((p) => _pathsEqual(p.pathBytes, pathBytes));
_cache[contactPubKeyHex] = ContactPathHistory(
contactPubKeyHex: contactPubKeyHex,
recentPaths: updatedPaths,
);
await _storage.savePathHistory(contactPubKeyHex, _cache[contactPubKeyHex]!);
notifyListeners();
}
PathRecord? _findPathRecord(String contactPubKeyHex, List<int> pathBytes) {
final history = _cache[contactPubKeyHex];
if (history == null) return null;
for (final record in history.recentPaths) {
if (_pathsEqual(record.pathBytes, pathBytes)) {
return record;
}
}
return null;
}
List<PathRecord> _getRankedPaths(String contactPubKeyHex) {
final history = _cache[contactPubKeyHex];
if (history == null) return [];
final ranked = List<PathRecord>.from(history.recentPaths)
..removeWhere((p) => p.pathBytes.isEmpty);
ranked.sort((a, b) {
final aRate = (a.successCount + 1) / (a.successCount + a.failureCount + 2);
final bRate = (b.successCount + 1) / (b.successCount + b.failureCount + 2);
if (aRate != bRate) return bRate.compareTo(aRate);
if (a.successCount != b.successCount) {
return b.successCount.compareTo(a.successCount);
}
final aTrip = a.tripTimeMs == 0 ? 999999 : a.tripTimeMs;
final bTrip = b.tripTimeMs == 0 ? 999999 : b.tripTimeMs;
if (aTrip != bTrip) return aTrip.compareTo(bTrip);
return b.timestamp.compareTo(a.timestamp);
});
return ranked;
}
bool _pathsEqual(List<int> a, List<int> b) {
return listEquals(a, b);
}
void _updateFloodStats(String contactPubKeyHex) {
final stats = _floodStats.putIfAbsent(contactPubKeyHex, () => _FloodStats());
stats.lastUsed = DateTime.now();
}
}
class _FloodStats {
int successCount = 0;
int failureCount = 0;
int lastTripTimeMs = 0;
DateTime? lastUsed;
}
+133
View File
@@ -0,0 +1,133 @@
import 'dart:async';
import 'dart:typed_data';
import '../models/contact.dart';
import '../connector/meshcore_connector.dart';
import '../connector/meshcore_protocol.dart';
class RepeaterCommandService {
final MeshCoreConnector _connector;
final Map<String, Completer<String>> _pendingCommands = {};
final Map<String, Timer> _commandTimeouts = {};
final Map<String, String> _commandPrefixes = {};
final Map<String, String> _pendingByPrefix = {};
int _prefixCounter = 0;
static const int timeoutSeconds = 10; // Flood mode timeout
static const int maxRetries = 5;
RepeaterCommandService(this._connector);
/// Send a CLI command to a repeater with automatic retries
/// Returns a future that completes when a response is received or after max retries
Future<String> sendCommand(
Contact repeater,
String command, {
Function(String)? onResponse,
Function(int)? onAttempt,
}) async {
final repeaterKey = repeater.publicKeyHex;
final hasPending = _pendingCommands.keys.any((id) => id.startsWith(repeaterKey));
if (hasPending) {
throw Exception('Another command is still awaiting a response.');
}
// Create completer for this command
final commandId = '${repeaterKey}_${DateTime.now().millisecondsSinceEpoch}';
final completer = Completer<String>();
_pendingCommands[commandId] = completer;
onAttempt?.call(0);
// Send frame once (no retries)
try {
final prefix = _nextPrefixToken();
_commandPrefixes[commandId] = prefix;
_pendingByPrefix[prefix] = commandId;
final framedCommand = '$prefix$command';
final frame = buildSendCliCommandFrame(repeater.publicKey, framedCommand, attempt: 0);
await _connector.sendFrame(frame);
} catch (e) {
_cleanup(commandId);
throw Exception('Failed to send command: $e');
}
// Set timeout for this attempt
_commandTimeouts[commandId]?.cancel();
_commandTimeouts[commandId] = Timer(
Duration(seconds: timeoutSeconds),
() {
final completer = _pendingCommands[commandId];
if (completer != null && !completer.isCompleted) {
completer.completeError('Command timeout after $timeoutSeconds seconds');
_cleanup(commandId);
}
},
);
// Wait for response or timeout
try {
final response = await completer.future;
return response;
} finally {
_cleanup(commandId);
}
}
/// Call this when a text message response is received from a repeater
void handleResponse(Contact repeater, String responseText) {
// Find pending command for this repeater and complete it
final repeaterKey = repeater.publicKeyHex;
String? commandId;
String responsePayload = responseText;
if (responseText.length >= 3 && responseText[2] == '|') {
final prefix = responseText.substring(0, 3);
commandId = _pendingByPrefix[prefix];
responsePayload = responseText.substring(3).trimLeft();
}
commandId ??= _pendingCommands.keys.firstWhere(
(id) => id.startsWith(repeaterKey),
orElse: () => '',
);
if (commandId.isEmpty) return;
final completer = _pendingCommands[commandId];
if (completer != null && !completer.isCompleted) {
completer.complete(responsePayload);
_cleanup(commandId);
}
}
void _cleanup(String commandId) {
_commandTimeouts[commandId]?.cancel();
_commandTimeouts.remove(commandId);
_pendingCommands.remove(commandId);
final prefix = _commandPrefixes.remove(commandId);
if (prefix != null) {
_pendingByPrefix.remove(prefix);
}
}
void dispose() {
for (final timer in _commandTimeouts.values) {
timer.cancel();
}
_commandTimeouts.clear();
_pendingCommands.clear();
_commandPrefixes.clear();
_pendingByPrefix.clear();
}
String _nextPrefixToken() {
for (var i = 0; i < 256; i++) {
final value = _prefixCounter++ & 0xFF;
final token = '${value.toRadixString(16).padLeft(2, '0').toUpperCase()}|';
if (!_pendingByPrefix.containsKey(token)) {
return token;
}
}
return '00|';
}
}
+120
View File
@@ -0,0 +1,120 @@
import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
import '../models/path_history.dart';
class StorageService {
static const String _pathHistoryPrefix = 'path_history_';
static const String _pendingMessagesKey = 'pending_messages';
static const String _repeaterPasswordsKey = 'repeater_passwords';
Future<void> savePathHistory(
String contactPubKeyHex, ContactPathHistory history) async {
final prefs = await SharedPreferences.getInstance();
final key = '$_pathHistoryPrefix$contactPubKeyHex';
final jsonStr = jsonEncode(history.toJson());
await prefs.setString(key, jsonStr);
}
Future<ContactPathHistory?> loadPathHistory(String contactPubKeyHex) async {
final prefs = await SharedPreferences.getInstance();
final key = '$_pathHistoryPrefix$contactPubKeyHex';
final jsonStr = prefs.getString(key);
if (jsonStr == null) return null;
try {
final json = jsonDecode(jsonStr) as Map<String, dynamic>;
return ContactPathHistory.fromJson(contactPubKeyHex, json);
} catch (e) {
return null;
}
}
Future<void> clearPathHistory(String contactPubKeyHex) async {
final prefs = await SharedPreferences.getInstance();
final key = '$_pathHistoryPrefix$contactPubKeyHex';
await prefs.remove(key);
}
Future<void> clearAllPathHistories() async {
final prefs = await SharedPreferences.getInstance();
final keys = prefs.getKeys();
final pathHistoryKeys =
keys.where((key) => key.startsWith(_pathHistoryPrefix));
for (final key in pathHistoryKeys) {
await prefs.remove(key);
}
}
Future<Map<String, String>> loadPendingMessages() async {
final prefs = await SharedPreferences.getInstance();
final jsonStr = prefs.getString(_pendingMessagesKey);
if (jsonStr == null) return {};
try {
final json = jsonDecode(jsonStr) as Map<String, dynamic>;
return json.map((key, value) => MapEntry(key, value as String));
} catch (e) {
return {};
}
}
Future<void> savePendingMessages(Map<String, String> pending) async {
final prefs = await SharedPreferences.getInstance();
final jsonStr = jsonEncode(pending);
await prefs.setString(_pendingMessagesKey, jsonStr);
}
Future<void> clearPendingMessages() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_pendingMessagesKey);
}
/// Save a repeater password by public key hex
Future<void> saveRepeaterPassword(
String repeaterPubKeyHex, String password) async {
final prefs = await SharedPreferences.getInstance();
final passwords = await loadRepeaterPasswords();
passwords[repeaterPubKeyHex] = password;
final jsonStr = jsonEncode(passwords);
await prefs.setString(_repeaterPasswordsKey, jsonStr);
}
/// Load all saved repeater passwords (map of pubKeyHex -> password)
Future<Map<String, String>> loadRepeaterPasswords() async {
final prefs = await SharedPreferences.getInstance();
final jsonStr = prefs.getString(_repeaterPasswordsKey);
if (jsonStr == null) return {};
try {
final json = jsonDecode(jsonStr) as Map<String, dynamic>;
return json.map((key, value) => MapEntry(key, value as String));
} catch (e) {
return {};
}
}
/// Get a specific repeater's saved password
Future<String?> getRepeaterPassword(String repeaterPubKeyHex) async {
final passwords = await loadRepeaterPasswords();
return passwords[repeaterPubKeyHex];
}
/// Remove a saved repeater password
Future<void> removeRepeaterPassword(String repeaterPubKeyHex) async {
final prefs = await SharedPreferences.getInstance();
final passwords = await loadRepeaterPasswords();
passwords.remove(repeaterPubKeyHex);
final jsonStr = jsonEncode(passwords);
await prefs.setString(_repeaterPasswordsKey, jsonStr);
}
/// Clear all saved repeater passwords
Future<void> clearAllRepeaterPasswords() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_repeaterPasswordsKey);
}
}