mirror of
https://github.com/zjs81/meshcore-open.git
synced 2026-04-20 22:13:48 +00:00
Merge remote-tracking branch 'origin/main' into ez_group_dropdown3
# Conflicts: # lib/main.dart
This commit is contained in:
commit
3dd9037be3
46 changed files with 1188 additions and 149 deletions
1
.swift-version
Normal file
1
.swift-version
Normal file
|
|
@ -0,0 +1 @@
|
|||
6.2.4
|
||||
|
|
@ -19,6 +19,7 @@ import '../services/message_retry_service.dart';
|
|||
import '../services/path_history_service.dart';
|
||||
import '../services/app_settings_service.dart';
|
||||
import '../services/background_service.dart';
|
||||
import '../services/timeout_prediction_service.dart';
|
||||
import '../services/notification_service.dart';
|
||||
import 'meshcore_connector_usb.dart';
|
||||
import 'meshcore_connector_tcp.dart';
|
||||
|
|
@ -166,6 +167,10 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||
bool _isLoadingContacts = false;
|
||||
bool _isLoadingChannels = false;
|
||||
bool _hasLoadedChannels = false;
|
||||
TimeoutPredictionService? _timeoutPredictionService;
|
||||
// Intentionally global (not per-contact): tracks overall network activity.
|
||||
// Frequent RX from any source indicates a busy network with more collisions.
|
||||
DateTime _lastRxTime = DateTime.now();
|
||||
bool _batteryRequested = false;
|
||||
bool _awaitingSelfInfo = false;
|
||||
bool _hasReceivedDeviceInfo = false;
|
||||
|
|
@ -199,6 +204,9 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||
int _queueSyncRetries = 0;
|
||||
static const int _maxQueueSyncRetries = 3;
|
||||
static const int _queueSyncTimeoutMs = 5000; // 5 second timeout
|
||||
// Serializes path operations (setContactPath/clearContactPath) to prevent
|
||||
// interleaved async calls from leaving in-memory state inconsistent with device.
|
||||
Future<void> _pathOpLock = Future.value();
|
||||
Map<String, String>? _currentCustomVars;
|
||||
|
||||
// Channel syncing state (sequential pattern)
|
||||
|
|
@ -558,6 +566,10 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||
_unreadStore.saveContactUnreadCount(
|
||||
Map<String, int>.from(_contactUnreadCount),
|
||||
);
|
||||
_notificationService.clearContactNotification(
|
||||
contactKeyHex,
|
||||
getTotalUnreadCount(),
|
||||
);
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
|
@ -576,6 +588,10 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||
_channels.isNotEmpty ? _channels : _cachedChannels,
|
||||
),
|
||||
);
|
||||
_notificationService.clearChannelNotification(
|
||||
channelIndex,
|
||||
getTotalUnreadCount(),
|
||||
);
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
|
@ -657,6 +673,7 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||
BleDebugLogService? bleDebugLogService,
|
||||
AppDebugLogService? appDebugLogService,
|
||||
BackgroundService? backgroundService,
|
||||
TimeoutPredictionService? timeoutPredictionService,
|
||||
}) {
|
||||
_retryService = retryService;
|
||||
_pathHistoryService = pathHistoryService;
|
||||
|
|
@ -664,6 +681,7 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||
_bleDebugLogService = bleDebugLogService;
|
||||
_appDebugLogService = appDebugLogService;
|
||||
_backgroundService = backgroundService;
|
||||
_timeoutPredictionService = timeoutPredictionService;
|
||||
_usbManager.setDebugLogService(_appDebugLogService);
|
||||
_tcpConnector.setDebugLogService(_appDebugLogService);
|
||||
|
||||
|
|
@ -678,13 +696,28 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||
updateMessageCallback: _updateMessage,
|
||||
clearContactPathCallback: clearContactPath,
|
||||
setContactPathCallback: setContactPath,
|
||||
calculateTimeoutCallback: (pathLength, messageBytes) =>
|
||||
calculateTimeout(pathLength: pathLength, messageBytes: messageBytes),
|
||||
calculateTimeoutCallback:
|
||||
(pathLength, messageBytes, {String? contactKey}) => calculateTimeout(
|
||||
pathLength: pathLength,
|
||||
messageBytes: messageBytes,
|
||||
contactKey: contactKey,
|
||||
),
|
||||
getSelfPublicKeyCallback: () => _selfPublicKey,
|
||||
prepareContactOutboundTextCallback: prepareContactOutboundText,
|
||||
appSettingsService: appSettingsService,
|
||||
debugLogService: _appDebugLogService,
|
||||
recordPathResultCallback: _recordPathResult,
|
||||
onDeliveryObservedCallback:
|
||||
(contactKey, pathLength, messageBytes, tripTimeMs) {
|
||||
final secSinceRx = DateTime.now().difference(_lastRxTime).inSeconds;
|
||||
_timeoutPredictionService?.recordObservation(
|
||||
contactKey: contactKey,
|
||||
pathLength: pathLength,
|
||||
messageBytes: messageBytes,
|
||||
tripTimeMs: tripTimeMs,
|
||||
secondsSinceLastRx: secSinceRx,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -1740,18 +1773,33 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||
Uint8List customPath,
|
||||
int pathLen,
|
||||
) async {
|
||||
if (!isConnected) return;
|
||||
// Serialize path operations to prevent interleaved async calls from
|
||||
// leaving in-memory state inconsistent with the device.
|
||||
final prev = _pathOpLock;
|
||||
final completer = Completer<void>();
|
||||
_pathOpLock = completer.future;
|
||||
await prev;
|
||||
try {
|
||||
if (!isConnected) return;
|
||||
|
||||
await sendFrame(
|
||||
buildUpdateContactPathFrame(
|
||||
contact.publicKey,
|
||||
customPath,
|
||||
pathLen,
|
||||
type: contact.type,
|
||||
flags: contact.flags,
|
||||
name: contact.name,
|
||||
),
|
||||
);
|
||||
await sendFrame(
|
||||
buildUpdateContactPathFrame(
|
||||
contact.publicKey,
|
||||
customPath,
|
||||
pathLen,
|
||||
type: contact.type,
|
||||
flags: contact.flags,
|
||||
name: contact.name,
|
||||
),
|
||||
);
|
||||
// USB writes return instantly (no BLE flow control), so give the firmware
|
||||
// time to persist the path change before subsequent commands.
|
||||
if (_activeTransport == MeshCoreTransportType.usb) {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||
}
|
||||
} finally {
|
||||
completer.complete();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> setContactFavorite(Contact contact, bool isFavorite) async {
|
||||
|
|
@ -2136,25 +2184,34 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||
}
|
||||
|
||||
Future<void> clearContactPath(Contact contact) async {
|
||||
if (!isConnected) return;
|
||||
// Serialize path operations to prevent interleaved async calls.
|
||||
final prev = _pathOpLock;
|
||||
final completer = Completer<void>();
|
||||
_pathOpLock = completer.future;
|
||||
await prev;
|
||||
try {
|
||||
if (!isConnected) return;
|
||||
|
||||
await sendFrame(buildResetPathFrame(contact.publicKey));
|
||||
final existingIndex = _contacts.indexWhere(
|
||||
(c) => c.publicKeyHex == contact.publicKeyHex,
|
||||
);
|
||||
if (existingIndex >= 0) {
|
||||
final existing = _contacts[existingIndex];
|
||||
// Use copyWith to preserve pathOverride and pathOverrideBytes
|
||||
_contacts[existingIndex] = existing.copyWith(
|
||||
pathOverride: null,
|
||||
pathOverrideBytes: null,
|
||||
pathLength: -1,
|
||||
path: Uint8List(0),
|
||||
await sendFrame(buildResetPathFrame(contact.publicKey));
|
||||
if (_activeTransport == MeshCoreTransportType.usb) {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||
}
|
||||
final existingIndex = _contacts.indexWhere(
|
||||
(c) => c.publicKeyHex == contact.publicKeyHex,
|
||||
);
|
||||
notifyListeners();
|
||||
unawaited(_persistContacts());
|
||||
if (existingIndex >= 0) {
|
||||
final existing = _contacts[existingIndex];
|
||||
// Preserve pathOverride and pathOverrideBytes — only reset device path
|
||||
_contacts[existingIndex] = existing.copyWith(
|
||||
pathLength: -1,
|
||||
path: Uint8List(0),
|
||||
);
|
||||
notifyListeners();
|
||||
unawaited(_persistContacts());
|
||||
}
|
||||
} finally {
|
||||
completer.complete();
|
||||
}
|
||||
// The device will send updated contact info with path_len = -1
|
||||
}
|
||||
|
||||
void updateContactInMemory(
|
||||
|
|
@ -2463,6 +2520,7 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||
|
||||
void _handleFrame(List<int> data) {
|
||||
if (data.isEmpty) return;
|
||||
_lastRxTime = DateTime.now();
|
||||
|
||||
final frame = Uint8List.fromList(data);
|
||||
_receivedFramesController.add(frame);
|
||||
|
|
@ -2490,6 +2548,9 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||
_isLoadingContacts = true;
|
||||
notifyListeners();
|
||||
break;
|
||||
case pushCodeAdvert:
|
||||
// Known contact was seen again - just a pub key, no action needed
|
||||
break;
|
||||
case pushCodeNewAdvert:
|
||||
debugPrint('Got New CONTACT');
|
||||
// It's the same format as respCodeContact, so we can reuse the handler
|
||||
|
|
@ -2836,38 +2897,68 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||
}
|
||||
}
|
||||
|
||||
/// Calculate timeout for a message based on radio settings and path length
|
||||
/// Returns timeout in milliseconds, considering number of hops
|
||||
int calculateTimeout({required int pathLength, int messageBytes = 100}) {
|
||||
// If we have radio settings, use them for accurate calculation
|
||||
/// Estimate single-packet airtime in ms from radio settings, or a fallback.
|
||||
int _estimateAirtimeMs(int messageBytes) {
|
||||
if (_currentFreqHz != null &&
|
||||
_currentBwHz != null &&
|
||||
_currentSf != null &&
|
||||
_currentCr != null) {
|
||||
final cr = _currentCr! <= 4 ? _currentCr! : _currentCr! - 4;
|
||||
return calculateMessageTimeout(
|
||||
freqHz: _currentFreqHz!,
|
||||
bwHz: _currentBwHz!,
|
||||
sf: _currentSf!,
|
||||
cr: cr,
|
||||
pathLength: pathLength,
|
||||
messageBytes: messageBytes,
|
||||
return calculateLoRaAirtime(
|
||||
payloadBytes: messageBytes,
|
||||
spreadingFactor: _currentSf!,
|
||||
bandwidthHz: _currentBwHz!,
|
||||
codingRate: cr,
|
||||
lowDataRateOptimize: _currentSf! >= 11,
|
||||
);
|
||||
}
|
||||
return 50; // fallback: ~SF7/BW125 for 100 bytes
|
||||
}
|
||||
|
||||
// Fallback: Conservative estimates based on typical settings
|
||||
// Assume SF7, BW125, which gives ~50ms airtime for 100 bytes
|
||||
const estimatedAirtime = 50;
|
||||
|
||||
/// Physics-based worst-case timeout (ceiling).
|
||||
int _physicsMaxTimeout(int pathLength, int airtime) {
|
||||
if (pathLength < 0) {
|
||||
// Flood mode: Base delay + 16× airtime
|
||||
return 500 + (16 * estimatedAirtime);
|
||||
return 500 + (16 * airtime);
|
||||
} else {
|
||||
// Direct path: Base delay + ((airtime×6 + 250ms)×(hops+1))
|
||||
return 500 + ((estimatedAirtime * 6 + 250) * (pathLength + 1));
|
||||
return 500 + ((airtime * 6 + 250) * (pathLength + 1));
|
||||
}
|
||||
}
|
||||
|
||||
/// Physics-based minimum timeout (floor): raw traversal time.
|
||||
int _physicsMinTimeout(int pathLength, int airtime) {
|
||||
if (pathLength < 0) {
|
||||
return airtime;
|
||||
} else {
|
||||
return airtime * (pathLength + 1);
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate timeout for a message based on radio settings and path length.
|
||||
/// Returns timeout in milliseconds, considering number of hops.
|
||||
int calculateTimeout({
|
||||
required int pathLength,
|
||||
int messageBytes = 100,
|
||||
String? contactKey,
|
||||
}) {
|
||||
final airtime = _estimateAirtimeMs(messageBytes);
|
||||
final physicsMin = _physicsMinTimeout(pathLength, airtime);
|
||||
final physicsMax = _physicsMaxTimeout(pathLength, airtime);
|
||||
|
||||
// Try ML-based prediction, clamped between physics bounds
|
||||
final secSinceRx = DateTime.now().difference(_lastRxTime).inSeconds;
|
||||
final mlTimeout = _timeoutPredictionService?.predictTimeout(
|
||||
contactKey: contactKey,
|
||||
pathLength: pathLength,
|
||||
messageBytes: messageBytes,
|
||||
secondsSinceLastRx: secSinceRx,
|
||||
);
|
||||
if (mlTimeout != null) {
|
||||
return mlTimeout.clamp(physicsMin, physicsMax);
|
||||
}
|
||||
|
||||
return physicsMax;
|
||||
}
|
||||
|
||||
void _handleContact(Uint8List frame, {bool isContact = true}) {
|
||||
final contact = Contact.fromFrame(frame);
|
||||
if (contact != null) {
|
||||
|
|
|
|||
|
|
@ -1888,5 +1888,6 @@
|
|||
"tcpErrorUnsupported": "Транспортът чрез TCP не се поддържа на тази платформа.",
|
||||
"tcpErrorTimedOut": "Връзката TCP изтекла.",
|
||||
"tcpConnectionFailed": "Неуспешно е установено TCP връзката: {error}",
|
||||
"map_showDiscoveryContacts": "Покажи контакти за откриване"
|
||||
"map_showDiscoveryContacts": "Покажи контакти за откриване",
|
||||
"map_setAsMyLocation": "Задайте като моя местоположение"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1916,5 +1916,6 @@
|
|||
"tcpErrorUnsupported": "Die TCP-Übertragung wird auf dieser Plattform nicht unterstützt.",
|
||||
"tcpErrorTimedOut": "Die TCP-Verbindung ist abgelaufen.",
|
||||
"tcpConnectionFailed": "Fehler beim TCP-Verbindungsaufbau: {error}",
|
||||
"map_showDiscoveryContacts": "Entdeckungs-Kontakte anzeigen"
|
||||
"map_showDiscoveryContacts": "Entdeckungs-Kontakte anzeigen",
|
||||
"map_setAsMyLocation": "Als meine aktuelle Position festlegen"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -808,6 +808,7 @@
|
|||
"map_source": "Source",
|
||||
"map_flags": "Flags",
|
||||
"map_shareMarkerHere": "Share marker here",
|
||||
"map_setAsMyLocation": "Set as my location",
|
||||
"map_pinLabel": "Pin label",
|
||||
"map_label": "Label",
|
||||
"map_pointOfInterest": "Point of interest",
|
||||
|
|
|
|||
|
|
@ -1916,5 +1916,6 @@
|
|||
"tcpErrorUnsupported": "El protocolo de transporte TCP no está soportado en esta plataforma.",
|
||||
"tcpErrorTimedOut": "La conexión TCP ha caducado.",
|
||||
"tcpConnectionFailed": "Error en la conexión TCP: {error}",
|
||||
"map_showDiscoveryContacts": "Mostrar Contactos de Descubrimiento"
|
||||
"map_showDiscoveryContacts": "Mostrar Contactos de Descubrimiento",
|
||||
"map_setAsMyLocation": "Establecer mi ubicación"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1888,5 +1888,6 @@
|
|||
"tcpErrorUnsupported": "Le protocole TCP n'est pas pris en charge sur cette plateforme.",
|
||||
"tcpErrorTimedOut": "La connexion TCP a expiré.",
|
||||
"tcpConnectionFailed": "Échec de la connexion TCP : {error}",
|
||||
"map_showDiscoveryContacts": "Afficher les contacts de découverte"
|
||||
"map_showDiscoveryContacts": "Afficher les contacts de découverte",
|
||||
"map_setAsMyLocation": "Définir comme ma localisation"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1888,5 +1888,6 @@
|
|||
"tcpErrorUnsupported": "Il protocollo TCP non è supportato su questa piattaforma.",
|
||||
"tcpErrorTimedOut": "La connessione TCP è scaduta.",
|
||||
"tcpConnectionFailed": "Impossibile stabilire la connessione TCP: {error}",
|
||||
"map_showDiscoveryContacts": "Mostra Contatti di Discovery"
|
||||
"map_showDiscoveryContacts": "Mostra Contatti di Discovery",
|
||||
"map_setAsMyLocation": "Imposta come la mia posizione"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2752,6 +2752,12 @@ abstract class AppLocalizations {
|
|||
/// **'Share marker here'**
|
||||
String get map_shareMarkerHere;
|
||||
|
||||
/// No description provided for @map_setAsMyLocation.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Set as my location'**
|
||||
String get map_setAsMyLocation;
|
||||
|
||||
/// No description provided for @map_pinLabel.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
|
|
|||
|
|
@ -1514,6 +1514,9 @@ class AppLocalizationsBg extends AppLocalizations {
|
|||
@override
|
||||
String get map_shareMarkerHere => 'Споделете маркер тук';
|
||||
|
||||
@override
|
||||
String get map_setAsMyLocation => 'Задайте като моя местоположение';
|
||||
|
||||
@override
|
||||
String get map_pinLabel => 'Етикетиране на пин';
|
||||
|
||||
|
|
|
|||
|
|
@ -1516,6 +1516,9 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
@override
|
||||
String get map_shareMarkerHere => 'Teilen Sie den Marker hier.';
|
||||
|
||||
@override
|
||||
String get map_setAsMyLocation => 'Als meine aktuelle Position festlegen';
|
||||
|
||||
@override
|
||||
String get map_pinLabel => 'Pin Name';
|
||||
|
||||
|
|
|
|||
|
|
@ -1490,6 +1490,9 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
@override
|
||||
String get map_shareMarkerHere => 'Share marker here';
|
||||
|
||||
@override
|
||||
String get map_setAsMyLocation => 'Set as my location';
|
||||
|
||||
@override
|
||||
String get map_pinLabel => 'Pin label';
|
||||
|
||||
|
|
|
|||
|
|
@ -1513,6 +1513,9 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||
@override
|
||||
String get map_shareMarkerHere => 'Compartir marcador aquí';
|
||||
|
||||
@override
|
||||
String get map_setAsMyLocation => 'Establecer mi ubicación';
|
||||
|
||||
@override
|
||||
String get map_pinLabel => 'Etiqueta de marcador';
|
||||
|
||||
|
|
|
|||
|
|
@ -1521,6 +1521,9 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||
@override
|
||||
String get map_shareMarkerHere => 'Partager le marqueur ici';
|
||||
|
||||
@override
|
||||
String get map_setAsMyLocation => 'Définir comme ma localisation';
|
||||
|
||||
@override
|
||||
String get map_pinLabel => 'Étiquete de repin';
|
||||
|
||||
|
|
|
|||
|
|
@ -1513,6 +1513,9 @@ class AppLocalizationsIt extends AppLocalizations {
|
|||
@override
|
||||
String get map_shareMarkerHere => 'Condividi marcatore qui';
|
||||
|
||||
@override
|
||||
String get map_setAsMyLocation => 'Imposta come la mia posizione';
|
||||
|
||||
@override
|
||||
String get map_pinLabel => 'Etichetta PIN';
|
||||
|
||||
|
|
|
|||
|
|
@ -1505,6 +1505,9 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||
@override
|
||||
String get map_shareMarkerHere => 'Deel marker hier';
|
||||
|
||||
@override
|
||||
String get map_setAsMyLocation => 'Stel dit in als mijn locatie';
|
||||
|
||||
@override
|
||||
String get map_pinLabel => 'Label vastzetten';
|
||||
|
||||
|
|
|
|||
|
|
@ -1515,6 +1515,9 @@ class AppLocalizationsPl extends AppLocalizations {
|
|||
@override
|
||||
String get map_shareMarkerHere => 'Udostępnij znacznik tutaj';
|
||||
|
||||
@override
|
||||
String get map_setAsMyLocation => 'Ustaw jako moje lokalizację';
|
||||
|
||||
@override
|
||||
String get map_pinLabel => 'Oznacz etykietę';
|
||||
|
||||
|
|
|
|||
|
|
@ -1514,6 +1514,9 @@ class AppLocalizationsPt extends AppLocalizations {
|
|||
@override
|
||||
String get map_shareMarkerHere => 'Compartilhar marcador aqui';
|
||||
|
||||
@override
|
||||
String get map_setAsMyLocation => 'Defina minha localização';
|
||||
|
||||
@override
|
||||
String get map_pinLabel => 'Rótulo de marcador';
|
||||
|
||||
|
|
|
|||
|
|
@ -1516,6 +1516,9 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||
@override
|
||||
String get map_shareMarkerHere => 'Поделиться меткой здесь';
|
||||
|
||||
@override
|
||||
String get map_setAsMyLocation => 'Установить мое местоположение';
|
||||
|
||||
@override
|
||||
String get map_pinLabel => 'Метка';
|
||||
|
||||
|
|
|
|||
|
|
@ -1507,6 +1507,9 @@ class AppLocalizationsSk extends AppLocalizations {
|
|||
@override
|
||||
String get map_shareMarkerHere => 'Zdieľte značku tu';
|
||||
|
||||
@override
|
||||
String get map_setAsMyLocation => 'Nastavte ako moju polohu';
|
||||
|
||||
@override
|
||||
String get map_pinLabel => 'Označka upozornenia';
|
||||
|
||||
|
|
|
|||
|
|
@ -1501,6 +1501,9 @@ class AppLocalizationsSl extends AppLocalizations {
|
|||
@override
|
||||
String get map_shareMarkerHere => 'Delite točke tukaj.';
|
||||
|
||||
@override
|
||||
String get map_setAsMyLocation => 'Nastavite to kot mojo lokacijo';
|
||||
|
||||
@override
|
||||
String get map_pinLabel => 'Oznaka za pritrditev';
|
||||
|
||||
|
|
|
|||
|
|
@ -1497,6 +1497,9 @@ class AppLocalizationsSv extends AppLocalizations {
|
|||
@override
|
||||
String get map_shareMarkerHere => 'Dela markeringen här';
|
||||
|
||||
@override
|
||||
String get map_setAsMyLocation => 'Ange som min plats';
|
||||
|
||||
@override
|
||||
String get map_pinLabel => 'Fästetikett';
|
||||
|
||||
|
|
|
|||
|
|
@ -1513,6 +1513,9 @@ class AppLocalizationsUk extends AppLocalizations {
|
|||
@override
|
||||
String get map_shareMarkerHere => 'Поділитися маркером тут';
|
||||
|
||||
@override
|
||||
String get map_setAsMyLocation => 'Встановити моє місцезнаходження';
|
||||
|
||||
@override
|
||||
String get map_pinLabel => 'Мітка піна';
|
||||
|
||||
|
|
|
|||
|
|
@ -1424,6 +1424,9 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||
@override
|
||||
String get map_shareMarkerHere => '在此分享标记';
|
||||
|
||||
@override
|
||||
String get map_setAsMyLocation => '设置为我的位置';
|
||||
|
||||
@override
|
||||
String get map_pinLabel => '标签';
|
||||
|
||||
|
|
|
|||
|
|
@ -1888,5 +1888,6 @@
|
|||
"tcpErrorUnsupported": "TCP-transport wordt niet ondersteund op deze platform.",
|
||||
"tcpErrorTimedOut": "De TCP-verbinding is verlopen.",
|
||||
"tcpConnectionFailed": "Verbinding met TCP mislukt: {error}",
|
||||
"map_showDiscoveryContacts": "Ontdek contacten weergeven"
|
||||
"map_showDiscoveryContacts": "Ontdek contacten weergeven",
|
||||
"map_setAsMyLocation": "Stel dit in als mijn locatie"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1888,5 +1888,6 @@
|
|||
"tcpErrorUnsupported": "Transport protokoł TCP nie jest obsługiwany na tym urządzeniu.",
|
||||
"tcpErrorTimedOut": "Połączenie TCP zakończyło się bez powodzenia.",
|
||||
"tcpConnectionFailed": "Błąd połączenia TCP: {error}",
|
||||
"map_showDiscoveryContacts": "Pokaż kontakty odkrywania"
|
||||
"map_showDiscoveryContacts": "Pokaż kontakty odkrywania",
|
||||
"map_setAsMyLocation": "Ustaw jako moje lokalizację"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1888,5 +1888,6 @@
|
|||
"tcpErrorUnsupported": "O protocolo TCP não é suportado nesta plataforma.",
|
||||
"tcpErrorTimedOut": "A conexão TCP expirou.",
|
||||
"tcpConnectionFailed": "Falha na conexão TCP: {error}",
|
||||
"map_showDiscoveryContacts": "Mostrar Contatos de Descoberta"
|
||||
"map_showDiscoveryContacts": "Mostrar Contatos de Descoberta",
|
||||
"map_setAsMyLocation": "Defina minha localização"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1128,5 +1128,6 @@
|
|||
"tcpErrorUnsupported": "Протокол TCP не поддерживается на этой платформе.",
|
||||
"tcpErrorTimedOut": "Соединение TCP не удалось установить.",
|
||||
"tcpConnectionFailed": "Не удалось установить соединение TCP: {error}",
|
||||
"map_showDiscoveryContacts": "Показать контакты Discovery"
|
||||
"map_showDiscoveryContacts": "Показать контакты Discovery",
|
||||
"map_setAsMyLocation": "Установить мое местоположение"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1888,5 +1888,6 @@
|
|||
"tcpErrorUnsupported": "Prevoz prostredníctvom protokolu TCP nie je na tejto platforme podporovaný.",
|
||||
"tcpErrorTimedOut": "Pripojenie TCP vypršalo.",
|
||||
"tcpConnectionFailed": "Neúspešné vytvorenie TCP spojenia: {error}",
|
||||
"map_showDiscoveryContacts": "Zobraziť kontakty objavov"
|
||||
"map_showDiscoveryContacts": "Zobraziť kontakty objavov",
|
||||
"map_setAsMyLocation": "Nastavte ako moju polohu"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1888,5 +1888,6 @@
|
|||
"tcpErrorUnsupported": "Transport preko protokola TCP ni podprt na tej platformi.",
|
||||
"tcpErrorTimedOut": "Povezava TCP je presegla časovno obdobje.",
|
||||
"tcpConnectionFailed": "Napaka pri povezavi TCP: {error}",
|
||||
"map_showDiscoveryContacts": "Prikaži odkritja kontaktov"
|
||||
"map_showDiscoveryContacts": "Prikaži odkritja kontaktov",
|
||||
"map_setAsMyLocation": "Nastavite to kot mojo lokacijo"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1888,5 +1888,6 @@
|
|||
"tcpErrorUnsupported": "TCP-transport fungerar inte på denna plattform.",
|
||||
"tcpErrorTimedOut": "TCP-anslutningen har tidsut gått.",
|
||||
"tcpConnectionFailed": "Fel vid TCP-anslutning: {error}",
|
||||
"map_showDiscoveryContacts": "Visa Discovery-kontakter"
|
||||
"map_showDiscoveryContacts": "Visa Discovery-kontakter",
|
||||
"map_setAsMyLocation": "Ange som min plats"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1888,5 +1888,6 @@
|
|||
"tcpErrorUnsupported": "Транспорт TCP не підтримується на цій платформі.",
|
||||
"tcpErrorTimedOut": "З'єднання TCP завершилося через закінчення часу очікування.",
|
||||
"tcpConnectionFailed": "Не вдалося встановити з'єднання TCP: {error}",
|
||||
"map_showDiscoveryContacts": "Показати контакти Відкриття"
|
||||
"map_showDiscoveryContacts": "Показати контакти Відкриття",
|
||||
"map_setAsMyLocation": "Встановити моє місцезнаходження"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1893,5 +1893,6 @@
|
|||
"tcpErrorUnsupported": "此平台不支持 TCP 传输。",
|
||||
"tcpErrorTimedOut": "TCP 连接超时。",
|
||||
"tcpConnectionFailed": "TCP 连接失败:{error}",
|
||||
"map_showDiscoveryContacts": "显示发现联系人"
|
||||
"map_showDiscoveryContacts": "显示发现联系人",
|
||||
"map_setAsMyLocation": "设置为我的位置"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import 'services/background_service.dart';
|
|||
import 'services/map_tile_cache_service.dart';
|
||||
import 'services/chat_text_scale_service.dart';
|
||||
import 'services/ui_view_state_service.dart';
|
||||
import 'services/timeout_prediction_service.dart';
|
||||
import 'storage/prefs_manager.dart';
|
||||
import 'utils/app_logger.dart';
|
||||
|
||||
|
|
@ -41,6 +42,7 @@ void main() async {
|
|||
final mapTileCacheService = MapTileCacheService();
|
||||
final chatTextScaleService = ChatTextScaleService();
|
||||
final uiViewStateService = UiViewStateService();
|
||||
final timeoutPredictionService = TimeoutPredictionService(storage);
|
||||
|
||||
// Load settings
|
||||
await appSettingsService.loadSettings();
|
||||
|
|
@ -59,6 +61,7 @@ void main() async {
|
|||
|
||||
await chatTextScaleService.initialize();
|
||||
await uiViewStateService.initialize();
|
||||
await timeoutPredictionService.initialize();
|
||||
|
||||
// Wire up connector with services
|
||||
connector.initialize(
|
||||
|
|
@ -68,6 +71,7 @@ void main() async {
|
|||
bleDebugLogService: bleDebugLogService,
|
||||
appDebugLogService: appDebugLogService,
|
||||
backgroundService: backgroundService,
|
||||
timeoutPredictionService: timeoutPredictionService,
|
||||
);
|
||||
|
||||
await connector.loadContactCache();
|
||||
|
|
@ -90,6 +94,7 @@ void main() async {
|
|||
mapTileCacheService: mapTileCacheService,
|
||||
chatTextScaleService: chatTextScaleService,
|
||||
uiViewStateService: uiViewStateService,
|
||||
timeoutPredictionService: timeoutPredictionService,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -126,6 +131,7 @@ class MeshCoreApp extends StatelessWidget {
|
|||
final MapTileCacheService mapTileCacheService;
|
||||
final ChatTextScaleService chatTextScaleService;
|
||||
final UiViewStateService uiViewStateService;
|
||||
final TimeoutPredictionService timeoutPredictionService;
|
||||
|
||||
const MeshCoreApp({
|
||||
super.key,
|
||||
|
|
@ -139,6 +145,7 @@ class MeshCoreApp extends StatelessWidget {
|
|||
required this.mapTileCacheService,
|
||||
required this.chatTextScaleService,
|
||||
required this.uiViewStateService,
|
||||
required this.timeoutPredictionService,
|
||||
});
|
||||
|
||||
@override
|
||||
|
|
@ -155,6 +162,7 @@ class MeshCoreApp extends StatelessWidget {
|
|||
ChangeNotifierProvider.value(value: uiViewStateService),
|
||||
Provider.value(value: storage),
|
||||
Provider.value(value: mapTileCacheService),
|
||||
ChangeNotifierProvider.value(value: timeoutPredictionService),
|
||||
],
|
||||
child: Consumer<AppSettingsService>(
|
||||
builder: (context, settingsService, child) {
|
||||
|
|
|
|||
43
lib/models/delivery_observation.dart
Normal file
43
lib/models/delivery_observation.dart
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
class DeliveryObservation {
|
||||
final String contactKey;
|
||||
final int pathLength;
|
||||
final int messageBytes;
|
||||
final int secondsSinceLastRx;
|
||||
final bool isFlood;
|
||||
final int deliveryMs;
|
||||
final DateTime timestamp;
|
||||
|
||||
DeliveryObservation({
|
||||
required this.contactKey,
|
||||
required this.pathLength,
|
||||
required this.messageBytes,
|
||||
required this.secondsSinceLastRx,
|
||||
required this.isFlood,
|
||||
required this.deliveryMs,
|
||||
required this.timestamp,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'contact_key': contactKey,
|
||||
'path_length': pathLength,
|
||||
'message_bytes': messageBytes,
|
||||
'seconds_since_last_rx': secondsSinceLastRx,
|
||||
'is_flood': isFlood,
|
||||
'delivery_ms': deliveryMs,
|
||||
'timestamp': timestamp.toIso8601String(),
|
||||
};
|
||||
}
|
||||
|
||||
factory DeliveryObservation.fromJson(Map<String, dynamic> json) {
|
||||
return DeliveryObservation(
|
||||
contactKey: json['contact_key'] as String,
|
||||
pathLength: json['path_length'] as int,
|
||||
messageBytes: json['message_bytes'] as int,
|
||||
secondsSinceLastRx: json['seconds_since_last_rx'] as int? ?? 0,
|
||||
isFlood: json['is_flood'] as bool,
|
||||
deliveryMs: json['delivery_ms'] as int,
|
||||
timestamp: DateTime.parse(json['timestamp'] as String),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -106,10 +106,9 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||
final unreadLabel = context.l10n.chat_unread(unreadCount);
|
||||
final pathLabel = _currentPathLabel(contact);
|
||||
|
||||
// Show path details if we have path data (from device or override)
|
||||
final hasPathData =
|
||||
contact.path.isNotEmpty || contact.pathOverrideBytes != null;
|
||||
// Show path details if we have non-empty path data (from device or override)
|
||||
final effectivePath = contact.pathOverrideBytes ?? contact.path;
|
||||
final hasPathData = effectivePath.isNotEmpty;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
|
|
@ -143,12 +142,25 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||
final contact = _resolveContact(connector);
|
||||
final isFloodMode = contact.pathOverride == -1;
|
||||
|
||||
final isDirectMode = contact.pathOverride == 0;
|
||||
final activeMode = isFloodMode
|
||||
? 'flood'
|
||||
: isDirectMode
|
||||
? 'direct'
|
||||
: 'auto';
|
||||
|
||||
return PopupMenuButton<String>(
|
||||
icon: Icon(isFloodMode ? Icons.waves : Icons.route),
|
||||
tooltip: context.l10n.chat_routingMode,
|
||||
onSelected: (mode) async {
|
||||
if (mode == 'flood') {
|
||||
await connector.setPathOverride(contact, pathLen: -1);
|
||||
} else if (mode == 'direct') {
|
||||
await connector.setPathOverride(
|
||||
contact,
|
||||
pathLen: 0,
|
||||
pathBytes: Uint8List(0),
|
||||
);
|
||||
} else {
|
||||
await connector.setPathOverride(contact, pathLen: null);
|
||||
}
|
||||
|
|
@ -161,7 +173,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||
Icon(
|
||||
Icons.auto_mode,
|
||||
size: 20,
|
||||
color: !isFloodMode
|
||||
color: activeMode == 'auto'
|
||||
? Theme.of(context).primaryColor
|
||||
: null,
|
||||
),
|
||||
|
|
@ -169,7 +181,30 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||
Text(
|
||||
context.l10n.chat_autoUseSavedPath,
|
||||
style: TextStyle(
|
||||
fontWeight: !isFloodMode
|
||||
fontWeight: activeMode == 'auto'
|
||||
? FontWeight.bold
|
||||
: FontWeight.normal,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: 'direct',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.near_me,
|
||||
size: 20,
|
||||
color: activeMode == 'direct'
|
||||
? Theme.of(context).primaryColor
|
||||
: null,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
context.l10n.chat_direct,
|
||||
style: TextStyle(
|
||||
fontWeight: activeMode == 'direct'
|
||||
? FontWeight.bold
|
||||
: FontWeight.normal,
|
||||
),
|
||||
|
|
@ -184,7 +219,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||
Icon(
|
||||
Icons.waves,
|
||||
size: 20,
|
||||
color: isFloodMode
|
||||
color: activeMode == 'flood'
|
||||
? Theme.of(context).primaryColor
|
||||
: null,
|
||||
),
|
||||
|
|
@ -192,7 +227,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||
Text(
|
||||
context.l10n.chat_forceFloodMode,
|
||||
style: TextStyle(
|
||||
fontWeight: isFloodMode
|
||||
fontWeight: activeMode == 'flood'
|
||||
? FontWeight.bold
|
||||
: FontWeight.normal,
|
||||
),
|
||||
|
|
@ -251,7 +286,9 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
context.l10n.chat_sendMessageTo(widget.contact.name),
|
||||
context.l10n.chat_sendMessageTo(
|
||||
_resolveContact(context.read<MeshCoreConnector>()).name,
|
||||
),
|
||||
style: TextStyle(fontSize: 14, color: Colors.grey[500]),
|
||||
),
|
||||
],
|
||||
|
|
@ -269,6 +306,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||
|
||||
// Auto-scroll to bottom if user is already at bottom
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
_scrollController.scrollToBottomIfAtBottom();
|
||||
});
|
||||
|
||||
|
|
@ -293,10 +331,10 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||
);
|
||||
}
|
||||
final messageIndex = index;
|
||||
Contact contact = widget.contact;
|
||||
Contact contact = _resolveContact(connector);
|
||||
final message = reversedMessages[messageIndex];
|
||||
String fourByteHex = '';
|
||||
if (widget.contact.type == advTypeRoom) {
|
||||
if (contact.type == advTypeRoom) {
|
||||
contact = _resolveContactFrom4Bytes(
|
||||
connector,
|
||||
message.fourByteRoomContactKey.isEmpty
|
||||
|
|
@ -314,12 +352,13 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||
final textScale = context.select<ChatTextScaleService, double>(
|
||||
(service) => service.scale,
|
||||
);
|
||||
final resolvedContact = _resolveContact(connector);
|
||||
return _MessageBubble(
|
||||
message: message,
|
||||
senderName: widget.contact.type == advTypeRoom
|
||||
senderName: resolvedContact.type == advTypeRoom
|
||||
? "${contact.name} [$fourByteHex]"
|
||||
: contact.name,
|
||||
isRoomServer: widget.contact.type == advTypeRoom,
|
||||
isRoomServer: resolvedContact.type == advTypeRoom,
|
||||
textScale: textScale,
|
||||
onTap: () => _openMessagePath(message, contact),
|
||||
onLongPress: () => _showMessageActions(message, contact),
|
||||
|
|
@ -457,7 +496,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||
return;
|
||||
}
|
||||
|
||||
connector.sendMessage(widget.contact, text);
|
||||
connector.sendMessage(_resolveContact(connector), text);
|
||||
_textController.clear();
|
||||
_textFieldFocusNode.requestFocus();
|
||||
}
|
||||
|
|
@ -654,7 +693,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||
|
||||
// Set the path override to persist user's choice
|
||||
await connector.setPathOverride(
|
||||
widget.contact,
|
||||
_resolveContact(connector),
|
||||
pathLen: pathLength,
|
||||
pathBytes: pathBytes,
|
||||
);
|
||||
|
|
@ -663,7 +702,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||
Navigator.pop(context);
|
||||
await _notifyPathSet(
|
||||
connector,
|
||||
widget.contact,
|
||||
_resolveContact(connector),
|
||||
pathBytes,
|
||||
path.hopCount,
|
||||
);
|
||||
|
|
@ -722,7 +761,9 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||
style: const TextStyle(fontSize: 11),
|
||||
),
|
||||
onTap: () async {
|
||||
await connector.clearContactPath(widget.contact);
|
||||
await connector.clearContactPath(
|
||||
_resolveContact(connector),
|
||||
);
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
|
|
@ -750,7 +791,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||
),
|
||||
onTap: () async {
|
||||
await connector.setPathOverride(
|
||||
widget.contact,
|
||||
_resolveContact(connector),
|
||||
pathLen: -1,
|
||||
);
|
||||
if (!context.mounted) return;
|
||||
|
|
@ -1005,11 +1046,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||
);
|
||||
|
||||
if (result == null) {
|
||||
appLogger.info(
|
||||
'PathSelectionDialog was cancelled or returned null',
|
||||
tag: 'ChatScreen',
|
||||
);
|
||||
return;
|
||||
return; // Cancelled — keep existing path
|
||||
}
|
||||
|
||||
if (!mounted) {
|
||||
|
|
@ -1025,14 +1062,19 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||
tag: 'ChatScreen',
|
||||
);
|
||||
await connector.setPathOverride(
|
||||
widget.contact,
|
||||
_resolveContact(connector),
|
||||
pathLen: result.length,
|
||||
pathBytes: result,
|
||||
);
|
||||
appLogger.info('setPathOverride completed', tag: 'ChatScreen');
|
||||
|
||||
if (!mounted) return;
|
||||
await _notifyPathSet(connector, widget.contact, result, result.length);
|
||||
await _notifyPathSet(
|
||||
connector,
|
||||
_resolveContact(connector),
|
||||
result,
|
||||
result.length,
|
||||
);
|
||||
}
|
||||
|
||||
void _openMessagePath(Message message, Contact contact) {
|
||||
|
|
@ -1044,10 +1086,10 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||
final String senderName;
|
||||
if (message.isOutgoing) {
|
||||
senderName = connector.selfName ?? context.l10n.chat_me;
|
||||
} else if (widget.contact.type == advTypeRoom) {
|
||||
} else if (_resolveContact(connector).type == advTypeRoom) {
|
||||
senderName = "${contact.name} [$fourByteHex]";
|
||||
} else {
|
||||
senderName = widget.contact.name;
|
||||
senderName = _resolveContact(connector).name;
|
||||
}
|
||||
final pathMessage = ChannelMessage(
|
||||
senderKey: null,
|
||||
|
|
@ -1110,7 +1152,8 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||
_retryMessage(message);
|
||||
},
|
||||
),
|
||||
if (widget.contact.type == advTypeRoom)
|
||||
if (_resolveContact(context.read<MeshCoreConnector>()).type ==
|
||||
advTypeRoom)
|
||||
ListTile(
|
||||
leading: const Icon(Icons.chat),
|
||||
title: Text(context.l10n.contacts_openChat),
|
||||
|
|
@ -1148,7 +1191,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||
void _retryMessage(Message message) {
|
||||
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
||||
// Retry using the contact's current path override setting
|
||||
connector.sendMessage(widget.contact, message.text);
|
||||
connector.sendMessage(_resolveContact(connector), message.text);
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(context.l10n.chat_retryingMessage)));
|
||||
|
|
@ -1174,7 +1217,8 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||
|
||||
// For room servers, include sender name (like channels) since multiple users
|
||||
// For 1:1 chats, sender is implicit (null)
|
||||
final senderName = widget.contact.type == advTypeRoom
|
||||
final liveContact = _resolveContact(connector);
|
||||
final senderName = liveContact.type == advTypeRoom
|
||||
? senderContact.name
|
||||
: null;
|
||||
final hash = ReactionHelper.computeReactionHash(
|
||||
|
|
@ -1183,7 +1227,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||
message.text,
|
||||
);
|
||||
final reactionText = 'r:$hash:$emojiIndex';
|
||||
connector.sendMessage(widget.contact, reactionText);
|
||||
connector.sendMessage(_resolveContact(connector), reactionText);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import 'dart:async';
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:meshcore_open/screens/path_trace_map.dart';
|
||||
import 'package:meshcore_open/services/notification_service.dart';
|
||||
import 'package:meshcore_open/utils/app_logger.dart';
|
||||
import 'package:meshcore_open/widgets/app_bar.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
|
@ -66,6 +67,13 @@ class _ContactsScreenState extends State<ContactsScreen>
|
|||
.contactsSearchText;
|
||||
_loadGroups();
|
||||
_setupFrameListener();
|
||||
_clearAdvertNotifications();
|
||||
}
|
||||
|
||||
void _clearAdvertNotifications() {
|
||||
final connector = context.read<MeshCoreConnector>();
|
||||
final contactIds = connector.contacts.map((c) => c.publicKeyHex).toList();
|
||||
NotificationService().clearAdvertNotifications(contactIds);
|
||||
}
|
||||
|
||||
@override
|
||||
|
|
|
|||
|
|
@ -1509,6 +1509,23 @@ class _MapScreenState extends State<MapScreen> {
|
|||
);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.my_location),
|
||||
title: Text(context.l10n.map_setAsMyLocation),
|
||||
onTap: () async {
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final successMsg = context.l10n.settings_locationUpdated;
|
||||
Navigator.pop(sheetContext);
|
||||
if (!connector.isConnected) return;
|
||||
await connector.setNodeLocation(
|
||||
lat: position.latitude,
|
||||
lon: position.longitude,
|
||||
);
|
||||
await connector.refreshDeviceInfo();
|
||||
if (!mounted) return;
|
||||
messenger.showSnackBar(SnackBar(content: Text(successMsg)));
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.close),
|
||||
title: Text(context.l10n.common_cancel),
|
||||
|
|
|
|||
|
|
@ -44,6 +44,12 @@ class MessageRetryService extends ChangeNotifier {
|
|||
[]; // Rolling buffer of recent ACK hashes
|
||||
final Map<String, List<String>> _pendingMessageQueuePerContact =
|
||||
{}; // contactPubKeyHex → FIFO queue of messageIds (DEPRECATED - will be removed)
|
||||
final Map<String, List<String>> _sendQueue =
|
||||
{}; // contactPubKeyHex → ordered list of messageIds awaiting send
|
||||
final Set<String> _activeMessages =
|
||||
{}; // messageIds currently in-flight (sent/retrying)
|
||||
final Set<String> _resolvedMessages =
|
||||
{}; // messageIds already resolved (prevents double _onMessageResolved)
|
||||
final Map<String, String> _expectedHashToMessageId =
|
||||
{}; // expectedAckHashHex → messageId (for matching RESP_CODE_SENT by hash)
|
||||
|
||||
|
|
@ -52,12 +58,13 @@ class MessageRetryService extends ChangeNotifier {
|
|||
Function(Message)? _updateMessageCallback;
|
||||
Function(Contact)? _clearContactPathCallback;
|
||||
Function(Contact, Uint8List, int)? _setContactPathCallback;
|
||||
Function(int, int)? _calculateTimeoutCallback;
|
||||
Function(int, int, {String? contactKey})? _calculateTimeoutCallback;
|
||||
Uint8List? Function()? _getSelfPublicKeyCallback;
|
||||
String Function(Contact, String)? _prepareContactOutboundTextCallback;
|
||||
AppSettingsService? _appSettingsService;
|
||||
AppDebugLogService? _debugLogService;
|
||||
Function(String, PathSelection, bool, int?)? _recordPathResultCallback;
|
||||
Function(String, int, int, int)? _onDeliveryObservedCallback;
|
||||
|
||||
MessageRetryService();
|
||||
|
||||
|
|
@ -67,12 +74,20 @@ class MessageRetryService extends ChangeNotifier {
|
|||
required Function(Message) updateMessageCallback,
|
||||
Function(Contact)? clearContactPathCallback,
|
||||
Function(Contact, Uint8List, int)? setContactPathCallback,
|
||||
Function(int pathLength, int messageBytes)? calculateTimeoutCallback,
|
||||
Function(int pathLength, int messageBytes, {String? contactKey})?
|
||||
calculateTimeoutCallback,
|
||||
Uint8List? Function()? getSelfPublicKeyCallback,
|
||||
String Function(Contact, String)? prepareContactOutboundTextCallback,
|
||||
AppSettingsService? appSettingsService,
|
||||
AppDebugLogService? debugLogService,
|
||||
Function(String, PathSelection, bool, int?)? recordPathResultCallback,
|
||||
Function(
|
||||
String contactKey,
|
||||
int pathLength,
|
||||
int messageBytes,
|
||||
int tripTimeMs,
|
||||
)?
|
||||
onDeliveryObservedCallback,
|
||||
}) {
|
||||
_sendMessageCallback = sendMessageCallback;
|
||||
_addMessageCallback = addMessageCallback;
|
||||
|
|
@ -85,6 +100,7 @@ class MessageRetryService extends ChangeNotifier {
|
|||
_appSettingsService = appSettingsService;
|
||||
_debugLogService = debugLogService;
|
||||
_recordPathResultCallback = recordPathResultCallback;
|
||||
_onDeliveryObservedCallback = onDeliveryObservedCallback;
|
||||
}
|
||||
|
||||
/// Compute expected ACK hash using same algorithm as firmware:
|
||||
|
|
@ -156,7 +172,49 @@ class MessageRetryService extends ChangeNotifier {
|
|||
_addMessageCallback!(contact.publicKeyHex, message);
|
||||
}
|
||||
|
||||
await _attemptSend(messageId);
|
||||
// Queue per contact — only one message in-flight at a time to avoid
|
||||
// overflowing the firmware's 8-entry expected_ack_table.
|
||||
final contactKey = contact.publicKeyHex;
|
||||
_sendQueue[contactKey] ??= [];
|
||||
_sendQueue[contactKey]!.add(messageId);
|
||||
|
||||
if (!_activeMessages.any(
|
||||
(id) => _pendingContacts[id]?.publicKeyHex == contactKey,
|
||||
)) {
|
||||
_sendNextForContact(contactKey);
|
||||
}
|
||||
}
|
||||
|
||||
void _sendNextForContact(String contactKey) {
|
||||
final queue = _sendQueue[contactKey];
|
||||
if (queue == null) return;
|
||||
|
||||
// Drain stale entries iteratively instead of recursing.
|
||||
while (queue.isNotEmpty) {
|
||||
final messageId = queue.removeAt(0);
|
||||
if (_pendingMessages.containsKey(messageId)) {
|
||||
_activeMessages.add(messageId);
|
||||
_attemptSend(messageId).catchError((e) {
|
||||
debugPrint('_attemptSend threw for $messageId: $e');
|
||||
final msg = _pendingMessages[messageId];
|
||||
if (msg != null) {
|
||||
final failed = msg.copyWith(status: MessageStatus.failed);
|
||||
_pendingMessages[messageId] = failed;
|
||||
_updateMessageCallback?.call(failed);
|
||||
}
|
||||
_onMessageResolved(messageId, contactKey);
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Message was cancelled/cleaned up while queued — try next
|
||||
}
|
||||
}
|
||||
|
||||
void _onMessageResolved(String messageId, String contactKey) {
|
||||
if (_resolvedMessages.contains(messageId)) return;
|
||||
_resolvedMessages.add(messageId);
|
||||
_activeMessages.remove(messageId);
|
||||
_sendNextForContact(contactKey);
|
||||
}
|
||||
|
||||
Future<void> _attemptSend(String messageId) async {
|
||||
|
|
@ -169,13 +227,11 @@ class MessageRetryService extends ChangeNotifier {
|
|||
// Use the path that was captured when the message was first sent
|
||||
if (_setContactPathCallback != null && _clearContactPathCallback != null) {
|
||||
if (message.pathLength != null && message.pathLength! < 0) {
|
||||
// Flood mode - clear the path
|
||||
debugPrint(
|
||||
'Setting flood mode for retry attempt ${message.retryCount}',
|
||||
);
|
||||
_clearContactPathCallback!(contact);
|
||||
await _clearContactPathCallback!(contact);
|
||||
} else if (message.pathLength != null && message.pathLength! >= 0) {
|
||||
// Specific path (including direct neighbor with pathLength=0)
|
||||
final pathStr = message.pathBytes.isEmpty
|
||||
? 'direct'
|
||||
: message.pathBytes
|
||||
|
|
@ -192,6 +248,24 @@ class MessageRetryService extends ChangeNotifier {
|
|||
}
|
||||
}
|
||||
|
||||
// Re-validate after async gap — a timer or ACK could have resolved/retried
|
||||
// this message while we were awaiting the path callback.
|
||||
final currentMessage = _pendingMessages[messageId];
|
||||
if (currentMessage == null || _resolvedMessages.contains(messageId)) {
|
||||
debugPrint(
|
||||
'_attemptSend: message $messageId resolved during path sync, aborting',
|
||||
);
|
||||
return;
|
||||
}
|
||||
// If the message was retried by a timer during our await, the retryCount
|
||||
// will have advanced. Only proceed if it still matches the attempt we started.
|
||||
if (currentMessage.retryCount != message.retryCount) {
|
||||
debugPrint(
|
||||
'_attemptSend: message $messageId retryCount changed during path sync, aborting',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final attempt = message.retryCount.clamp(0, 3);
|
||||
final timestampSeconds = message.timestamp.millisecondsSinceEpoch ~/ 1000;
|
||||
|
||||
|
|
@ -231,6 +305,15 @@ class MessageRetryService extends ChangeNotifier {
|
|||
|
||||
if (_sendMessageCallback != null) {
|
||||
_sendMessageCallback!(contact, message.text, attempt, timestampSeconds);
|
||||
} else {
|
||||
// No send callback — message would be stuck forever. Fail it immediately.
|
||||
debugPrint(
|
||||
'_attemptSend: no sendMessageCallback, failing message $messageId',
|
||||
);
|
||||
final failedMessage = message.copyWith(status: MessageStatus.failed);
|
||||
_pendingMessages[messageId] = failedMessage;
|
||||
_updateMessageCallback?.call(failedMessage);
|
||||
_onMessageResolved(messageId, contact.publicKeyHex);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -281,6 +364,7 @@ class MessageRetryService extends ChangeNotifier {
|
|||
}
|
||||
|
||||
// FALLBACK: Old queue-based matching (for messages sent before hash computation was added)
|
||||
// Only match within a single contact's queue to avoid cross-contact mismatches.
|
||||
if (messageId == null && allowQueueFallback) {
|
||||
_debugLogService?.warn(
|
||||
'RESP_CODE_SENT: ACK hash $ackHashHex not found in hash table, falling back to queue',
|
||||
|
|
@ -290,13 +374,16 @@ class MessageRetryService extends ChangeNotifier {
|
|||
'Hash-based match failed for $ackHashHex, falling back to queue-based matching',
|
||||
);
|
||||
|
||||
for (var entry in _pendingMessageQueuePerContact.entries) {
|
||||
// Search all contact queues so concurrent chats don't miss matches.
|
||||
final queuesToSearch = _pendingMessageQueuePerContact;
|
||||
|
||||
for (var entry in queuesToSearch.entries) {
|
||||
final contactKey = entry.key;
|
||||
final queue = entry.value;
|
||||
|
||||
if (queue.isNotEmpty) {
|
||||
// Drain stale entries until we find a valid one or exhaust the queue.
|
||||
while (queue.isNotEmpty) {
|
||||
final candidateMessageId = queue.removeAt(0);
|
||||
|
||||
if (_pendingMessages.containsKey(candidateMessageId)) {
|
||||
messageId = candidateMessageId;
|
||||
contact = _pendingContacts[candidateMessageId];
|
||||
|
|
@ -304,21 +391,10 @@ class MessageRetryService extends ChangeNotifier {
|
|||
'Queue-based match (fallback): $ackHashHex → message $messageId for $contactKey',
|
||||
);
|
||||
break;
|
||||
} else {
|
||||
debugPrint('Dequeued stale message $candidateMessageId - skipping');
|
||||
if (queue.isNotEmpty) {
|
||||
final nextMessageId = queue.removeAt(0);
|
||||
if (_pendingMessages.containsKey(nextMessageId)) {
|
||||
messageId = nextMessageId;
|
||||
contact = _pendingContacts[nextMessageId];
|
||||
debugPrint(
|
||||
'Queue-based match (fallback): $ackHashHex → message $messageId',
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
debugPrint('Dequeued stale message $candidateMessageId - skipping');
|
||||
}
|
||||
if (messageId != null) break;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -357,25 +433,33 @@ class MessageRetryService extends ChangeNotifier {
|
|||
);
|
||||
}
|
||||
|
||||
// Use device-provided timeout, or calculate from radio settings if timeout is 0 or invalid
|
||||
// Calculate timeout: prefer ML prediction, then device-provided, then physics fallback
|
||||
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 = contact.pathLength;
|
||||
}
|
||||
|
||||
int actualTimeout = timeoutMs;
|
||||
if (timeoutMs <= 0 && _calculateTimeoutCallback != 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 = contact.pathLength;
|
||||
}
|
||||
actualTimeout = _calculateTimeoutCallback!(
|
||||
if (_calculateTimeoutCallback != null) {
|
||||
final calculated = _calculateTimeoutCallback!(
|
||||
pathLengthValue,
|
||||
message.text.length,
|
||||
contactKey: contact.publicKeyHex,
|
||||
);
|
||||
debugPrint(
|
||||
'Using calculated timeout: ${actualTimeout}ms for path length $pathLengthValue',
|
||||
);
|
||||
// calculateTimeout tries ML first, falls back to physics.
|
||||
// Use calculated value if device didn't provide one, or if ML
|
||||
// produced a tighter prediction than the device's estimate.
|
||||
if (timeoutMs <= 0 || calculated < timeoutMs) {
|
||||
actualTimeout = calculated;
|
||||
debugPrint(
|
||||
'Using calculated timeout: ${actualTimeout}ms for path length $pathLengthValue',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final updatedMessage = message.copyWith(
|
||||
|
|
@ -463,22 +547,7 @@ class MessageRetryService extends ChangeNotifier {
|
|||
} else {
|
||||
// Max retries reached - mark as failed
|
||||
final failedMessage = message.copyWith(status: MessageStatus.failed);
|
||||
|
||||
// Move ACK hashes to history before removing
|
||||
_moveAckHashesToHistory(messageId);
|
||||
|
||||
_pendingMessages.remove(messageId);
|
||||
_pendingContacts.remove(messageId);
|
||||
_pendingPathSelections.remove(messageId);
|
||||
_timeoutTimers[messageId]?.cancel();
|
||||
_timeoutTimers.remove(messageId);
|
||||
|
||||
// Clean up the queue entry for this contact
|
||||
_pendingMessageQueuePerContact[contact.publicKeyHex]?.remove(messageId);
|
||||
if (_pendingMessageQueuePerContact[contact.publicKeyHex]?.isEmpty ??
|
||||
false) {
|
||||
_pendingMessageQueuePerContact.remove(contact.publicKeyHex);
|
||||
}
|
||||
_pendingMessages[messageId] = failedMessage;
|
||||
|
||||
// Check if we should clear the path on max retry
|
||||
if (_appSettingsService?.settings.clearPathOnMaxRetry == true &&
|
||||
|
|
@ -499,6 +568,30 @@ class MessageRetryService extends ChangeNotifier {
|
|||
}
|
||||
|
||||
notifyListeners();
|
||||
|
||||
// Message is done retrying — send next queued message for this contact
|
||||
_onMessageResolved(messageId, contact.publicKeyHex);
|
||||
|
||||
// Keep message in pending maps for 30s grace period so late ACKs
|
||||
// can still match and update the message to delivered.
|
||||
_timeoutTimers[messageId] = Timer(const Duration(seconds: 30), () {
|
||||
_moveAckHashesToHistory(messageId);
|
||||
// Clean up ALL hash mappings for this message
|
||||
_ackHashToMessageId.removeWhere(
|
||||
(_, mapping) => mapping.messageId == messageId,
|
||||
);
|
||||
_expectedHashToMessageId.removeWhere((_, msgId) => msgId == messageId);
|
||||
_pendingMessages.remove(messageId);
|
||||
_pendingContacts.remove(messageId);
|
||||
_pendingPathSelections.remove(messageId);
|
||||
_timeoutTimers.remove(messageId);
|
||||
_resolvedMessages.remove(messageId);
|
||||
final contactKey = contact.publicKeyHex;
|
||||
_pendingMessageQueuePerContact[contactKey]?.remove(messageId);
|
||||
if (_pendingMessageQueuePerContact[contactKey]?.isEmpty ?? false) {
|
||||
_pendingMessageQueuePerContact.remove(contactKey);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -594,7 +687,15 @@ class MessageRetryService extends ChangeNotifier {
|
|||
}
|
||||
|
||||
if (matchedMessageId != null) {
|
||||
final message = _pendingMessages[matchedMessageId]!;
|
||||
final message = _pendingMessages[matchedMessageId];
|
||||
if (message == null) {
|
||||
// Message was already cleaned up (e.g. grace period expired)
|
||||
_ackHashToMessageId.remove(ackHashHex);
|
||||
debugPrint(
|
||||
'ACK matched $matchedMessageId but message already cleaned up',
|
||||
);
|
||||
return;
|
||||
}
|
||||
final contact = _pendingContacts[matchedMessageId];
|
||||
final selection = _pendingPathSelections[matchedMessageId];
|
||||
|
||||
|
|
@ -616,12 +717,21 @@ class MessageRetryService extends ChangeNotifier {
|
|||
tripTimeMs: tripTimeMs,
|
||||
);
|
||||
|
||||
// Clean up ALL hash mappings for this message (from all retry attempts)
|
||||
_ackHashToMessageId.removeWhere(
|
||||
(_, mapping) => mapping.messageId == matchedMessageId,
|
||||
);
|
||||
_expectedHashToMessageId.removeWhere(
|
||||
(_, msgId) => msgId == matchedMessageId,
|
||||
);
|
||||
|
||||
// Move ACK hashes to history before removing
|
||||
_moveAckHashesToHistory(matchedMessageId);
|
||||
|
||||
_pendingMessages.remove(matchedMessageId);
|
||||
_pendingContacts.remove(matchedMessageId);
|
||||
_pendingPathSelections.remove(matchedMessageId);
|
||||
_resolvedMessages.remove(matchedMessageId);
|
||||
|
||||
// Clean up the queue entry for this contact (remove any remaining references to this message)
|
||||
if (contact != null) {
|
||||
|
|
@ -646,6 +756,17 @@ class MessageRetryService extends ChangeNotifier {
|
|||
true,
|
||||
tripTimeMs,
|
||||
);
|
||||
if (_onDeliveryObservedCallback != null &&
|
||||
tripTimeMs > 0 &&
|
||||
message.pathLength != null) {
|
||||
_onDeliveryObservedCallback!(
|
||||
contact.publicKeyHex,
|
||||
message.pathLength!,
|
||||
message.text.length,
|
||||
tripTimeMs,
|
||||
);
|
||||
}
|
||||
_onMessageResolved(matchedMessageId, contact.publicKeyHex);
|
||||
}
|
||||
|
||||
notifyListeners();
|
||||
|
|
@ -783,6 +904,9 @@ class MessageRetryService extends ChangeNotifier {
|
|||
_ackHistory.clear();
|
||||
_ackHashToMessageId.clear();
|
||||
_pendingMessageQueuePerContact.clear();
|
||||
_sendQueue.clear();
|
||||
_activeMessages.clear();
|
||||
_resolvedMessages.clear();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -232,7 +232,9 @@ class NotificationService {
|
|||
|
||||
try {
|
||||
await _notifications.show(
|
||||
id: contactId?.hashCode ?? DateTime.now().millisecondsSinceEpoch,
|
||||
id: contactId != null
|
||||
? 'advert:$contactId'.hashCode
|
||||
: DateTime.now().millisecondsSinceEpoch,
|
||||
title: _l10n.notification_newTypeDiscovered(contactType),
|
||||
body: contactName,
|
||||
notificationDetails: notificationDetails,
|
||||
|
|
@ -331,6 +333,61 @@ class NotificationService {
|
|||
await _notifications.cancel(id: id);
|
||||
}
|
||||
|
||||
/// Cancel the notification for a specific contact and update the app badge.
|
||||
Future<void> clearContactNotification(
|
||||
String contactId,
|
||||
int totalUnreadCount,
|
||||
) async {
|
||||
if (!await _ensureInitialized()) return;
|
||||
await _notifications.cancel(id: contactId.hashCode);
|
||||
await _updateBadge(totalUnreadCount);
|
||||
}
|
||||
|
||||
/// Cancel the notification for a specific channel and update the app badge.
|
||||
Future<void> clearChannelNotification(
|
||||
int channelIndex,
|
||||
int totalUnreadCount,
|
||||
) async {
|
||||
if (!await _ensureInitialized()) return;
|
||||
await _notifications.cancel(id: channelIndex.hashCode);
|
||||
await _updateBadge(totalUnreadCount);
|
||||
}
|
||||
|
||||
/// Cancel advert notifications for the given contact public key hexes.
|
||||
Future<void> clearAdvertNotifications(List<String> contactIds) async {
|
||||
if (!await _ensureInitialized()) return;
|
||||
for (final id in contactIds) {
|
||||
await _notifications.cancel(id: 'advert:$id'.hashCode);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _updateBadge(int count) async {
|
||||
if (PlatformInfo.isIOS || PlatformInfo.isMacOS) {
|
||||
// On Apple platforms, set the badge number directly via a silent update.
|
||||
final darwinDetails = DarwinNotificationDetails(
|
||||
presentAlert: false,
|
||||
presentSound: false,
|
||||
presentBadge: true,
|
||||
badgeNumber: count,
|
||||
);
|
||||
final details = NotificationDetails(
|
||||
iOS: darwinDetails,
|
||||
macOS: darwinDetails,
|
||||
);
|
||||
// Use a fixed ID so each update replaces the previous one.
|
||||
await _notifications.show(
|
||||
id: 'badge_update'.hashCode,
|
||||
title: null,
|
||||
body: null,
|
||||
notificationDetails: details,
|
||||
);
|
||||
// Immediately cancel the silent notification so it doesn't appear in tray.
|
||||
await _notifications.cancel(id: 'badge_update'.hashCode);
|
||||
}
|
||||
// On Android, badge count is derived from active notifications,
|
||||
// so cancelling the specific notification above is sufficient.
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
// Public notification methods (rate limiting is enforced automatically)
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import 'dart:convert';
|
||||
import '../models/delivery_observation.dart';
|
||||
import '../models/path_history.dart';
|
||||
import '../storage/prefs_manager.dart';
|
||||
|
||||
|
|
@ -6,6 +7,7 @@ class StorageService {
|
|||
static const String _pathHistoryPrefix = 'path_history_';
|
||||
static const String _pendingMessagesKey = 'pending_messages';
|
||||
static const String _repeaterPasswordsKey = 'repeater_passwords';
|
||||
static const String _deliveryObservationsKey = 'delivery_observations';
|
||||
|
||||
Future<void> savePathHistory(
|
||||
String contactPubKeyHex,
|
||||
|
|
@ -122,4 +124,33 @@ class StorageService {
|
|||
final prefs = PrefsManager.instance;
|
||||
await prefs.remove(_repeaterPasswordsKey);
|
||||
}
|
||||
|
||||
Future<void> saveDeliveryObservations(
|
||||
List<DeliveryObservation> observations,
|
||||
) async {
|
||||
final prefs = PrefsManager.instance;
|
||||
final jsonStr = jsonEncode(observations.map((o) => o.toJson()).toList());
|
||||
await prefs.setString(_deliveryObservationsKey, jsonStr);
|
||||
}
|
||||
|
||||
Future<List<DeliveryObservation>> loadDeliveryObservations() async {
|
||||
final prefs = PrefsManager.instance;
|
||||
final jsonStr = prefs.getString(_deliveryObservationsKey);
|
||||
|
||||
if (jsonStr == null) return [];
|
||||
|
||||
try {
|
||||
final list = jsonDecode(jsonStr) as List;
|
||||
return list
|
||||
.map((e) => DeliveryObservation.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> clearDeliveryObservations() async {
|
||||
final prefs = PrefsManager.instance;
|
||||
await prefs.remove(_deliveryObservationsKey);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
229
lib/services/timeout_prediction_service.dart
Normal file
229
lib/services/timeout_prediction_service.dart
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
import 'dart:async';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:ml_algo/ml_algo.dart';
|
||||
import 'package:ml_dataframe/ml_dataframe.dart';
|
||||
import '../models/delivery_observation.dart';
|
||||
import 'storage_service.dart';
|
||||
|
||||
class _ContactStats {
|
||||
int count = 0;
|
||||
double _sum = 0;
|
||||
|
||||
void add(double ms) {
|
||||
count++;
|
||||
_sum += ms;
|
||||
}
|
||||
|
||||
double get mean => _sum / count;
|
||||
}
|
||||
|
||||
class TimeoutPredictionService extends ChangeNotifier {
|
||||
final StorageService? _storage;
|
||||
|
||||
static const int minObservations = 10;
|
||||
static const int maxObservations = 100;
|
||||
static const int _retrainInterval = 5;
|
||||
// 1.5x multiplier on raw prediction to account for variance in delivery
|
||||
// times — tight enough to improve on worst-case physics, loose enough
|
||||
// to avoid premature timeouts from model noise.
|
||||
static const double _safetyMargin = 1.5;
|
||||
static const int _minContactObservations = 10;
|
||||
|
||||
List<DeliveryObservation> _observations = [];
|
||||
LinearRegressor? _model;
|
||||
List<String> _activeFeatures = [];
|
||||
int _observationsSinceLastTrain = 0;
|
||||
final Map<String, _ContactStats> _contactStats = {};
|
||||
Timer? _persistTimer;
|
||||
|
||||
TimeoutPredictionService(StorageService storage) : _storage = storage;
|
||||
TimeoutPredictionService.noStorage() : _storage = null;
|
||||
|
||||
int get observationCount => _observations.length;
|
||||
bool get hasModel => _model != null;
|
||||
|
||||
Future<void> initialize() async {
|
||||
_observations = await _storage?.loadDeliveryObservations() ?? [];
|
||||
_rebuildContactStats();
|
||||
|
||||
if (_observations.length >= minObservations) {
|
||||
_trainModel();
|
||||
}
|
||||
|
||||
debugPrint(
|
||||
'TimeoutPrediction: initialized with ${_observations.length} observations, '
|
||||
'model=${_model != null ? "ready" : "waiting for data"}',
|
||||
);
|
||||
}
|
||||
|
||||
void recordObservation({
|
||||
required String contactKey,
|
||||
required int pathLength,
|
||||
required int messageBytes,
|
||||
required int tripTimeMs,
|
||||
int secondsSinceLastRx = 0,
|
||||
}) {
|
||||
final observation = DeliveryObservation(
|
||||
contactKey: contactKey,
|
||||
pathLength: pathLength,
|
||||
messageBytes: messageBytes,
|
||||
secondsSinceLastRx: secondsSinceLastRx,
|
||||
isFlood: pathLength < 0,
|
||||
deliveryMs: tripTimeMs,
|
||||
timestamp: DateTime.now(),
|
||||
);
|
||||
|
||||
_observations.add(observation);
|
||||
if (_observations.length > maxObservations) {
|
||||
_observations.removeAt(0);
|
||||
}
|
||||
|
||||
_contactStats.putIfAbsent(contactKey, () => _ContactStats());
|
||||
_contactStats[contactKey]!.add(tripTimeMs.toDouble());
|
||||
|
||||
_observationsSinceLastTrain++;
|
||||
if (_observationsSinceLastTrain >= _retrainInterval &&
|
||||
_observations.length >= minObservations) {
|
||||
_trainModel();
|
||||
}
|
||||
|
||||
_persistTimer?.cancel();
|
||||
_persistTimer = Timer(const Duration(seconds: 2), () {
|
||||
_storage?.saveDeliveryObservations(_observations);
|
||||
});
|
||||
debugPrint(
|
||||
'TimeoutPrediction: recorded ${tripTimeMs}ms for $pathLength hops '
|
||||
'(${_observations.length} total)',
|
||||
);
|
||||
}
|
||||
|
||||
int? predictTimeout({
|
||||
String? contactKey,
|
||||
required int pathLength,
|
||||
required int messageBytes,
|
||||
int secondsSinceLastRx = 0,
|
||||
}) {
|
||||
if (_model == null) return null;
|
||||
|
||||
try {
|
||||
if (_activeFeatures.isEmpty) return null;
|
||||
|
||||
final allFeatures = {
|
||||
'pathLength': pathLength.toDouble(),
|
||||
'messageBytes': messageBytes.toDouble(),
|
||||
'secSinceRx': secondsSinceLastRx.toDouble(),
|
||||
'isFlood': pathLength < 0 ? 1.0 : 0.0,
|
||||
};
|
||||
final row = _activeFeatures.map((f) => allFeatures[f]!).toList();
|
||||
|
||||
final features = DataFrame(
|
||||
[row],
|
||||
headerExists: false,
|
||||
header: _activeFeatures,
|
||||
);
|
||||
|
||||
final prediction = _model!.predict(features);
|
||||
final rawValue = prediction.rows.first.first;
|
||||
var predictedMs = (rawValue is double)
|
||||
? rawValue
|
||||
: (rawValue as num).toDouble();
|
||||
|
||||
debugPrint(
|
||||
'TimeoutPrediction: raw prediction=$predictedMs for '
|
||||
'pathLength=$pathLength, messageBytes=$messageBytes, '
|
||||
'features=$_activeFeatures',
|
||||
);
|
||||
|
||||
// Sanity check: if prediction is negative or zero, fall back
|
||||
if (predictedMs <= 0) return null;
|
||||
|
||||
// Blend with per-contact mean if enough data
|
||||
if (contactKey != null) {
|
||||
final stats = _contactStats[contactKey];
|
||||
if (stats != null && stats.count >= _minContactObservations) {
|
||||
predictedMs = 0.5 * predictedMs + 0.5 * stats.mean;
|
||||
}
|
||||
}
|
||||
|
||||
// Connector clamps this between physics min/max bounds
|
||||
final timeout = (predictedMs * _safetyMargin).ceil();
|
||||
debugPrint(
|
||||
'TimeoutPrediction: ML timeout ${timeout}ms '
|
||||
'(raw: ${predictedMs.round()}ms, contact: $contactKey)',
|
||||
);
|
||||
return timeout;
|
||||
} catch (e) {
|
||||
debugPrint('TimeoutPrediction: prediction failed: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
void _trainModel() {
|
||||
try {
|
||||
// Build feature columns, then exclude any with zero variance
|
||||
// (ml_algo's OLS produces all-zero coefficients for singular matrices)
|
||||
final allNames = ['pathLength', 'messageBytes', 'secSinceRx', 'isFlood'];
|
||||
final allExtractors = <double Function(DeliveryObservation)>[
|
||||
(o) => o.pathLength.toDouble(),
|
||||
(o) => o.messageBytes.toDouble(),
|
||||
(o) => o.secondsSinceLastRx.toDouble(),
|
||||
(o) => o.isFlood ? 1.0 : 0.0,
|
||||
];
|
||||
|
||||
_activeFeatures = [];
|
||||
for (var i = 0; i < allNames.length; i++) {
|
||||
final values = _observations.map(allExtractors[i]).toSet();
|
||||
if (values.length > 1) _activeFeatures.add(allNames[i]);
|
||||
}
|
||||
|
||||
if (_activeFeatures.isEmpty) {
|
||||
debugPrint(
|
||||
'TimeoutPrediction: no features with variance, skipping training',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final header = [..._activeFeatures, 'deliveryMs'];
|
||||
final rows = _observations.map((o) {
|
||||
final row = <double>[];
|
||||
for (var i = 0; i < allNames.length; i++) {
|
||||
if (_activeFeatures.contains(allNames[i])) {
|
||||
row.add(allExtractors[i](o));
|
||||
}
|
||||
}
|
||||
row.add(o.deliveryMs.toDouble());
|
||||
return row;
|
||||
});
|
||||
|
||||
final data = DataFrame([header, ...rows], headerExists: true);
|
||||
|
||||
_model = LinearRegressor(data, 'deliveryMs');
|
||||
_observationsSinceLastTrain = 0;
|
||||
|
||||
// Log training summary with sample predictions
|
||||
final avgMs =
|
||||
_observations.map((o) => o.deliveryMs).reduce((a, b) => a + b) /
|
||||
_observations.length;
|
||||
debugPrint(
|
||||
'TimeoutPrediction: trained on ${_observations.length} observations '
|
||||
'(avg: ${avgMs.round()}ms, features: $_activeFeatures)',
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('TimeoutPrediction: training failed: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_persistTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _rebuildContactStats() {
|
||||
_contactStats.clear();
|
||||
for (final obs in _observations) {
|
||||
_contactStats.putIfAbsent(obs.contactKey, () => _ContactStats());
|
||||
_contactStats[obs.contactKey]!.add(obs.deliveryMs.toDouble());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -9,7 +9,6 @@ import flutter_blue_plus_darwin
|
|||
import flutter_local_notifications
|
||||
import mobile_scanner
|
||||
import package_info_plus
|
||||
import path_provider_foundation
|
||||
import share_plus
|
||||
import shared_preferences_foundation
|
||||
import sqflite_darwin
|
||||
|
|
@ -21,7 +20,6 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
|||
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
|
||||
MobileScannerPlugin.register(with: registry.registrar(forPlugin: "MobileScannerPlugin"))
|
||||
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
|
||||
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
|
||||
SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin"))
|
||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
|
||||
|
|
|
|||
|
|
@ -69,6 +69,8 @@ dependencies:
|
|||
material_symbols_icons: ^4.2906.0
|
||||
web: ^1.1.1
|
||||
flutter_svg: ^2.0.10+1
|
||||
ml_algo: ^16.0.0
|
||||
ml_dataframe: ^1.0.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
|
|
|||
156
test/services/ml_algo_sanity_test.dart
Normal file
156
test/services/ml_algo_sanity_test.dart
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:ml_algo/ml_algo.dart';
|
||||
import 'package:ml_dataframe/ml_dataframe.dart';
|
||||
|
||||
void main() {
|
||||
test('LinearRegressor basic sanity check', () {
|
||||
// Simple: y = 2x + 100
|
||||
final data = DataFrame(
|
||||
[
|
||||
[1.0, 102.0],
|
||||
[2.0, 104.0],
|
||||
[3.0, 106.0],
|
||||
[4.0, 108.0],
|
||||
[5.0, 110.0],
|
||||
[10.0, 120.0],
|
||||
[20.0, 140.0],
|
||||
[50.0, 200.0],
|
||||
[0.0, 100.0],
|
||||
[100.0, 300.0],
|
||||
],
|
||||
headerExists: false,
|
||||
header: ['x', 'y'],
|
||||
);
|
||||
|
||||
debugPrint('Training data columns: ${data.header}');
|
||||
debugPrint('Training data rows: ${data.rows.length}');
|
||||
|
||||
final model = LinearRegressor(data, 'y');
|
||||
|
||||
final testDf = DataFrame(
|
||||
[
|
||||
[25.0],
|
||||
],
|
||||
headerExists: false,
|
||||
header: ['x'],
|
||||
);
|
||||
|
||||
final prediction = model.predict(testDf);
|
||||
final value = prediction.rows.first.first;
|
||||
debugPrint('Predict x=25 → y=$value (expected ~150)');
|
||||
expect((value as num).toDouble(), closeTo(150, 5));
|
||||
});
|
||||
|
||||
test('LinearRegressor multi-feature with constant column produces zeros', () {
|
||||
// isFlood=0 for all rows → zero-variance column → singular matrix
|
||||
final data = DataFrame(
|
||||
[
|
||||
[0.0, 50.0, 14.0, 0.0, 1900.0],
|
||||
[0.0, 80.0, 14.0, 0.0, 2200.0],
|
||||
[2.0, 50.0, 14.0, 0.0, 5000.0],
|
||||
[4.0, 50.0, 14.0, 0.0, 9500.0],
|
||||
],
|
||||
headerExists: false,
|
||||
header: [
|
||||
'pathLength',
|
||||
'messageBytes',
|
||||
'hourOfDay',
|
||||
'isFlood',
|
||||
'deliveryMs',
|
||||
],
|
||||
);
|
||||
|
||||
final model = LinearRegressor(data, 'deliveryMs');
|
||||
final testDf = DataFrame(
|
||||
[
|
||||
[2.0, 50.0, 14.0, 0.0],
|
||||
],
|
||||
headerExists: false,
|
||||
header: ['pathLength', 'messageBytes', 'hourOfDay', 'isFlood'],
|
||||
);
|
||||
final pred = model.predict(testDf).rows.first.first;
|
||||
debugPrint(
|
||||
'With constant isFlood column: hops=2 → ${(pred as num).round()}ms (likely 0)',
|
||||
);
|
||||
});
|
||||
|
||||
test('LinearRegressor 2-feature works correctly', () {
|
||||
// Just pathLength + messageBytes → deliveryMs
|
||||
final data = DataFrame(
|
||||
[
|
||||
[0.0, 50.0, 1900.0],
|
||||
[0.0, 80.0, 2200.0],
|
||||
[2.0, 50.0, 5000.0],
|
||||
[2.0, 80.0, 5500.0],
|
||||
[4.0, 50.0, 9500.0],
|
||||
[4.0, 80.0, 10000.0],
|
||||
[0.0, 30.0, 1800.0],
|
||||
[2.0, 30.0, 4800.0],
|
||||
[4.0, 30.0, 9000.0],
|
||||
[0.0, 60.0, 2000.0],
|
||||
],
|
||||
headerExists: false,
|
||||
header: ['pathLength', 'messageBytes', 'deliveryMs'],
|
||||
);
|
||||
|
||||
final model = LinearRegressor(data, 'deliveryMs');
|
||||
|
||||
for (final hops in [0.0, 2.0, 4.0]) {
|
||||
final testDf = DataFrame(
|
||||
[
|
||||
[hops, 50.0],
|
||||
],
|
||||
headerExists: false,
|
||||
header: ['pathLength', 'messageBytes'],
|
||||
);
|
||||
final pred = model.predict(testDf).rows.first.first;
|
||||
debugPrint('2-feature: hops=$hops → ${(pred as num).round()}ms');
|
||||
}
|
||||
});
|
||||
|
||||
test('LinearRegressor multi-feature with variance in all columns', () {
|
||||
// Mix flood and direct so isFlood has variance
|
||||
final data = DataFrame(
|
||||
[
|
||||
[0.0, 50.0, 14.0, 0.0, 1900.0],
|
||||
[0.0, 80.0, 10.0, 0.0, 2200.0],
|
||||
[2.0, 50.0, 16.0, 0.0, 5000.0],
|
||||
[2.0, 80.0, 20.0, 0.0, 5500.0],
|
||||
[4.0, 50.0, 8.0, 0.0, 9500.0],
|
||||
[4.0, 80.0, 12.0, 0.0, 10000.0],
|
||||
[-1.0, 40.0, 14.0, 1.0, 5000.0],
|
||||
[-1.0, 60.0, 18.0, 1.0, 6500.0],
|
||||
[-1.0, 30.0, 10.0, 1.0, 4000.0],
|
||||
[-1.0, 80.0, 22.0, 1.0, 7000.0],
|
||||
],
|
||||
headerExists: false,
|
||||
header: [
|
||||
'pathLength',
|
||||
'messageBytes',
|
||||
'hourOfDay',
|
||||
'isFlood',
|
||||
'deliveryMs',
|
||||
],
|
||||
);
|
||||
|
||||
final model = LinearRegressor(data, 'deliveryMs');
|
||||
|
||||
for (final tc in [
|
||||
[0.0, 50.0, 14.0, 0.0],
|
||||
[2.0, 50.0, 14.0, 0.0],
|
||||
[4.0, 50.0, 14.0, 0.0],
|
||||
[-1.0, 50.0, 14.0, 1.0],
|
||||
]) {
|
||||
final testDf = DataFrame(
|
||||
[tc],
|
||||
headerExists: false,
|
||||
header: ['pathLength', 'messageBytes', 'hourOfDay', 'isFlood'],
|
||||
);
|
||||
final pred = model.predict(testDf).rows.first.first;
|
||||
debugPrint(
|
||||
'4-feature: hops=${tc[0]} flood=${tc[3]} → ${(pred as num).round()}ms',
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
164
test/services/timeout_prediction_service_test.dart
Normal file
164
test/services/timeout_prediction_service_test.dart
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:meshcore_open/models/delivery_observation.dart';
|
||||
import 'package:meshcore_open/services/timeout_prediction_service.dart';
|
||||
|
||||
void main() {
|
||||
late TimeoutPredictionService service;
|
||||
|
||||
setUp(() {
|
||||
service = TimeoutPredictionService.noStorage();
|
||||
});
|
||||
|
||||
test('trains on sample data and predicts sensible timeouts', () {
|
||||
// Simulate realistic delivery data:
|
||||
// Direct 0-hop messages: ~1500-2500ms
|
||||
// 2-hop messages: ~4000-6000ms
|
||||
// 4-hop messages: ~8000-12000ms
|
||||
// Flood messages: ~3000-8000ms
|
||||
final sampleData = [
|
||||
// 0-hop direct
|
||||
_obs(pathLength: 0, messageBytes: 20, deliveryMs: 1800),
|
||||
_obs(pathLength: 0, messageBytes: 50, deliveryMs: 2100),
|
||||
_obs(pathLength: 0, messageBytes: 80, deliveryMs: 2400),
|
||||
_obs(pathLength: 0, messageBytes: 30, deliveryMs: 1925),
|
||||
// 2-hop direct
|
||||
_obs(pathLength: 2, messageBytes: 40, deliveryMs: 4500),
|
||||
_obs(pathLength: 2, messageBytes: 60, deliveryMs: 5200),
|
||||
_obs(pathLength: 2, messageBytes: 25, deliveryMs: 4100),
|
||||
// 4-hop direct
|
||||
_obs(pathLength: 4, messageBytes: 50, deliveryMs: 9800),
|
||||
_obs(pathLength: 4, messageBytes: 30, deliveryMs: 8500),
|
||||
_obs(pathLength: 4, messageBytes: 70, deliveryMs: 10570),
|
||||
// Flood
|
||||
_obs(pathLength: -1, messageBytes: 40, deliveryMs: 5000),
|
||||
_obs(pathLength: -1, messageBytes: 60, deliveryMs: 6500),
|
||||
];
|
||||
|
||||
// Feed all observations
|
||||
for (final obs in sampleData) {
|
||||
service.recordObservation(
|
||||
contactKey: obs.contactKey,
|
||||
pathLength: obs.pathLength,
|
||||
messageBytes: obs.messageBytes,
|
||||
tripTimeMs: obs.deliveryMs,
|
||||
);
|
||||
}
|
||||
|
||||
expect(service.hasModel, isTrue);
|
||||
expect(service.observationCount, equals(12));
|
||||
|
||||
// Predict for different scenarios
|
||||
final direct0 = service.predictTimeout(pathLength: 0, messageBytes: 50);
|
||||
final direct2 = service.predictTimeout(pathLength: 2, messageBytes: 50);
|
||||
final direct4 = service.predictTimeout(pathLength: 4, messageBytes: 50);
|
||||
final flood = service.predictTimeout(pathLength: -1, messageBytes: 50);
|
||||
|
||||
// All should return non-null (model is trained)
|
||||
expect(direct0, isNotNull);
|
||||
expect(direct2, isNotNull);
|
||||
expect(direct4, isNotNull);
|
||||
expect(flood, isNotNull);
|
||||
|
||||
// More hops should predict longer timeouts
|
||||
expect(direct4!, greaterThan(direct2!));
|
||||
expect(direct2, greaterThan(direct0!));
|
||||
|
||||
// All should be positive
|
||||
expect(direct0, greaterThan(0));
|
||||
expect(direct4, greaterThan(0));
|
||||
|
||||
// Print predictions for visibility
|
||||
debugPrint('Predictions (with 1.5x safety margin):');
|
||||
debugPrint(' 0-hop direct: ${direct0}ms');
|
||||
debugPrint(' 2-hop direct: ${direct2}ms');
|
||||
debugPrint(' 4-hop direct: ${direct4}ms');
|
||||
debugPrint(' flood: ${flood}ms');
|
||||
});
|
||||
|
||||
test('returns null before minimum observations', () {
|
||||
for (var i = 0; i < TimeoutPredictionService.minObservations - 1; i++) {
|
||||
service.recordObservation(
|
||||
contactKey: 'abc',
|
||||
pathLength: 0,
|
||||
messageBytes: 50,
|
||||
tripTimeMs: 2000,
|
||||
);
|
||||
}
|
||||
|
||||
expect(service.hasModel, isFalse);
|
||||
expect(service.predictTimeout(pathLength: 0, messageBytes: 50), isNull);
|
||||
});
|
||||
|
||||
test('caps observations at maxObservations', () {
|
||||
for (var i = 0; i < TimeoutPredictionService.maxObservations + 20; i++) {
|
||||
service.recordObservation(
|
||||
contactKey: 'abc',
|
||||
pathLength: 0,
|
||||
messageBytes: 50,
|
||||
tripTimeMs: 2000 + i,
|
||||
);
|
||||
}
|
||||
|
||||
expect(
|
||||
service.observationCount,
|
||||
equals(TimeoutPredictionService.maxObservations),
|
||||
);
|
||||
});
|
||||
|
||||
test('blends per-contact stats after enough observations', () {
|
||||
// Train with mixed contacts and varied features:
|
||||
// contactA is fast (0-hop), contactB is slow (2-hop)
|
||||
for (var i = 0; i < 12; i++) {
|
||||
service.recordObservation(
|
||||
contactKey: 'contactA',
|
||||
pathLength: 0,
|
||||
messageBytes: 30 + i,
|
||||
tripTimeMs: 1500,
|
||||
);
|
||||
service.recordObservation(
|
||||
contactKey: 'contactB',
|
||||
pathLength: 2,
|
||||
messageBytes: 30 + i,
|
||||
tripTimeMs: 8000,
|
||||
);
|
||||
}
|
||||
|
||||
final predA = service.predictTimeout(
|
||||
contactKey: 'contactA',
|
||||
pathLength: 0,
|
||||
messageBytes: 50,
|
||||
);
|
||||
final predB = service.predictTimeout(
|
||||
contactKey: 'contactB',
|
||||
pathLength: 0,
|
||||
messageBytes: 50,
|
||||
);
|
||||
|
||||
expect(predA, isNotNull);
|
||||
expect(predB, isNotNull);
|
||||
// Contact B (slow) should have a higher predicted timeout than A (fast)
|
||||
expect(predB!, greaterThan(predA!));
|
||||
|
||||
debugPrint('Per-contact blending:');
|
||||
debugPrint(' contactA (fast): ${predA}ms');
|
||||
debugPrint(' contactB (slow): ${predB}ms');
|
||||
});
|
||||
}
|
||||
|
||||
DeliveryObservation _obs({
|
||||
required int pathLength,
|
||||
required int messageBytes,
|
||||
required int deliveryMs,
|
||||
String contactKey = 'test_contact',
|
||||
}) {
|
||||
return DeliveryObservation(
|
||||
contactKey: contactKey,
|
||||
pathLength: pathLength,
|
||||
messageBytes: messageBytes,
|
||||
secondsSinceLastRx: 5,
|
||||
isFlood: pathLength < 0,
|
||||
deliveryMs: deliveryMs,
|
||||
timestamp: DateTime.now(),
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue