mirror of
https://github.com/zjs81/meshcore-open.git
synced 2026-07-04 16:01:07 +10:00
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:
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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 '';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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|';
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user