Merge pull request #293 from zjs81/map-set-location-and-connector-improvements

feat: add set-as-my-location from map long-press, connector and UI
This commit is contained in:
zjs81
2026-03-14 09:55:02 -07:00
committed by GitHub
38 changed files with 436 additions and 109 deletions
+1
View File
@@ -0,0 +1 @@
6.2.4
+65 -27
View File
@@ -199,6 +199,9 @@ class MeshCoreConnector extends ChangeNotifier {
int _queueSyncRetries = 0; int _queueSyncRetries = 0;
static const int _maxQueueSyncRetries = 3; static const int _maxQueueSyncRetries = 3;
static const int _queueSyncTimeoutMs = 5000; // 5 second timeout 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; Map<String, String>? _currentCustomVars;
// Channel syncing state (sequential pattern) // Channel syncing state (sequential pattern)
@@ -558,6 +561,10 @@ class MeshCoreConnector extends ChangeNotifier {
_unreadStore.saveContactUnreadCount( _unreadStore.saveContactUnreadCount(
Map<String, int>.from(_contactUnreadCount), Map<String, int>.from(_contactUnreadCount),
); );
_notificationService.clearContactNotification(
contactKeyHex,
getTotalUnreadCount(),
);
notifyListeners(); notifyListeners();
} }
} }
@@ -576,6 +583,10 @@ class MeshCoreConnector extends ChangeNotifier {
_channels.isNotEmpty ? _channels : _cachedChannels, _channels.isNotEmpty ? _channels : _cachedChannels,
), ),
); );
_notificationService.clearChannelNotification(
channelIndex,
getTotalUnreadCount(),
);
notifyListeners(); notifyListeners();
} }
} }
@@ -1740,18 +1751,33 @@ class MeshCoreConnector extends ChangeNotifier {
Uint8List customPath, Uint8List customPath,
int pathLen, int pathLen,
) async { ) 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( await sendFrame(
buildUpdateContactPathFrame( buildUpdateContactPathFrame(
contact.publicKey, contact.publicKey,
customPath, customPath,
pathLen, pathLen,
type: contact.type, type: contact.type,
flags: contact.flags, flags: contact.flags,
name: contact.name, 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 { Future<void> setContactFavorite(Contact contact, bool isFavorite) async {
@@ -2136,25 +2162,34 @@ class MeshCoreConnector extends ChangeNotifier {
} }
Future<void> clearContactPath(Contact contact) async { 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)); await sendFrame(buildResetPathFrame(contact.publicKey));
final existingIndex = _contacts.indexWhere( if (_activeTransport == MeshCoreTransportType.usb) {
(c) => c.publicKeyHex == contact.publicKeyHex, await Future<void>.delayed(const Duration(milliseconds: 100));
); }
if (existingIndex >= 0) { final existingIndex = _contacts.indexWhere(
final existing = _contacts[existingIndex]; (c) => c.publicKeyHex == contact.publicKeyHex,
// Use copyWith to preserve pathOverride and pathOverrideBytes
_contacts[existingIndex] = existing.copyWith(
pathOverride: null,
pathOverrideBytes: null,
pathLength: -1,
path: Uint8List(0),
); );
notifyListeners(); if (existingIndex >= 0) {
unawaited(_persistContacts()); 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( void updateContactInMemory(
@@ -2490,6 +2525,9 @@ class MeshCoreConnector extends ChangeNotifier {
_isLoadingContacts = true; _isLoadingContacts = true;
notifyListeners(); notifyListeners();
break; break;
case pushCodeAdvert:
// Known contact was seen again - just a pub key, no action needed
break;
case pushCodeNewAdvert: case pushCodeNewAdvert:
debugPrint('Got New CONTACT'); debugPrint('Got New CONTACT');
// It's the same format as respCodeContact, so we can reuse the handler // It's the same format as respCodeContact, so we can reuse the handler
+2 -1
View File
@@ -1887,5 +1887,6 @@
"tcpErrorUnsupported": "Транспортът чрез TCP не се поддържа на тази платформа.", "tcpErrorUnsupported": "Транспортът чрез TCP не се поддържа на тази платформа.",
"tcpErrorTimedOut": "Връзката TCP изтекла.", "tcpErrorTimedOut": "Връзката TCP изтекла.",
"tcpConnectionFailed": "Неуспешно е установено TCP връзката: {error}", "tcpConnectionFailed": "Неуспешно е установено TCP връзката: {error}",
"map_showDiscoveryContacts": "Покажи контакти за откриване" "map_showDiscoveryContacts": "Покажи контакти за откриване",
"map_setAsMyLocation": "Задайте като моя местоположение"
} }
+2 -1
View File
@@ -1915,5 +1915,6 @@
"tcpErrorUnsupported": "Die TCP-Übertragung wird auf dieser Plattform nicht unterstützt.", "tcpErrorUnsupported": "Die TCP-Übertragung wird auf dieser Plattform nicht unterstützt.",
"tcpErrorTimedOut": "Die TCP-Verbindung ist abgelaufen.", "tcpErrorTimedOut": "Die TCP-Verbindung ist abgelaufen.",
"tcpConnectionFailed": "Fehler beim TCP-Verbindungsaufbau: {error}", "tcpConnectionFailed": "Fehler beim TCP-Verbindungsaufbau: {error}",
"map_showDiscoveryContacts": "Entdeckungs-Kontakte anzeigen" "map_showDiscoveryContacts": "Entdeckungs-Kontakte anzeigen",
"map_setAsMyLocation": "Als meine aktuelle Position festlegen"
} }
+1
View File
@@ -807,6 +807,7 @@
"map_source": "Source", "map_source": "Source",
"map_flags": "Flags", "map_flags": "Flags",
"map_shareMarkerHere": "Share marker here", "map_shareMarkerHere": "Share marker here",
"map_setAsMyLocation": "Set as my location",
"map_pinLabel": "Pin label", "map_pinLabel": "Pin label",
"map_label": "Label", "map_label": "Label",
"map_pointOfInterest": "Point of interest", "map_pointOfInterest": "Point of interest",
+2 -1
View File
@@ -1915,5 +1915,6 @@
"tcpErrorUnsupported": "El protocolo de transporte TCP no está soportado en esta plataforma.", "tcpErrorUnsupported": "El protocolo de transporte TCP no está soportado en esta plataforma.",
"tcpErrorTimedOut": "La conexión TCP ha caducado.", "tcpErrorTimedOut": "La conexión TCP ha caducado.",
"tcpConnectionFailed": "Error en la conexión TCP: {error}", "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"
} }
+2 -1
View File
@@ -1887,5 +1887,6 @@
"tcpErrorUnsupported": "Le protocole TCP n'est pas pris en charge sur cette plateforme.", "tcpErrorUnsupported": "Le protocole TCP n'est pas pris en charge sur cette plateforme.",
"tcpErrorTimedOut": "La connexion TCP a expiré.", "tcpErrorTimedOut": "La connexion TCP a expiré.",
"tcpConnectionFailed": "Échec de la connexion TCP : {error}", "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"
} }
+2 -1
View File
@@ -1887,5 +1887,6 @@
"tcpErrorUnsupported": "Il protocollo TCP non è supportato su questa piattaforma.", "tcpErrorUnsupported": "Il protocollo TCP non è supportato su questa piattaforma.",
"tcpErrorTimedOut": "La connessione TCP è scaduta.", "tcpErrorTimedOut": "La connessione TCP è scaduta.",
"tcpConnectionFailed": "Impossibile stabilire la connessione TCP: {error}", "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"
} }
+6
View File
@@ -2746,6 +2746,12 @@ abstract class AppLocalizations {
/// **'Share marker here'** /// **'Share marker here'**
String get map_shareMarkerHere; 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. /// No description provided for @map_pinLabel.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
+3
View File
@@ -1511,6 +1511,9 @@ class AppLocalizationsBg extends AppLocalizations {
@override @override
String get map_shareMarkerHere => 'Споделете маркер тук'; String get map_shareMarkerHere => 'Споделете маркер тук';
@override
String get map_setAsMyLocation => 'Задайте като моя местоположение';
@override @override
String get map_pinLabel => 'Етикетиране на пин'; String get map_pinLabel => 'Етикетиране на пин';
+3
View File
@@ -1513,6 +1513,9 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get map_shareMarkerHere => 'Teilen Sie den Marker hier.'; String get map_shareMarkerHere => 'Teilen Sie den Marker hier.';
@override
String get map_setAsMyLocation => 'Als meine aktuelle Position festlegen';
@override @override
String get map_pinLabel => 'Pin Name'; String get map_pinLabel => 'Pin Name';
+3
View File
@@ -1487,6 +1487,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get map_shareMarkerHere => 'Share marker here'; String get map_shareMarkerHere => 'Share marker here';
@override
String get map_setAsMyLocation => 'Set as my location';
@override @override
String get map_pinLabel => 'Pin label'; String get map_pinLabel => 'Pin label';
+3
View File
@@ -1509,6 +1509,9 @@ class AppLocalizationsEs extends AppLocalizations {
@override @override
String get map_shareMarkerHere => 'Compartir marcador aquí'; String get map_shareMarkerHere => 'Compartir marcador aquí';
@override
String get map_setAsMyLocation => 'Establecer mi ubicación';
@override @override
String get map_pinLabel => 'Etiqueta de marcador'; String get map_pinLabel => 'Etiqueta de marcador';
+3
View File
@@ -1518,6 +1518,9 @@ class AppLocalizationsFr extends AppLocalizations {
@override @override
String get map_shareMarkerHere => 'Partager le marqueur ici'; String get map_shareMarkerHere => 'Partager le marqueur ici';
@override
String get map_setAsMyLocation => 'Définir comme ma localisation';
@override @override
String get map_pinLabel => 'Étiquete de repin'; String get map_pinLabel => 'Étiquete de repin';
+3
View File
@@ -1510,6 +1510,9 @@ class AppLocalizationsIt extends AppLocalizations {
@override @override
String get map_shareMarkerHere => 'Condividi marcatore qui'; String get map_shareMarkerHere => 'Condividi marcatore qui';
@override
String get map_setAsMyLocation => 'Imposta come la mia posizione';
@override @override
String get map_pinLabel => 'Etichetta PIN'; String get map_pinLabel => 'Etichetta PIN';
+3
View File
@@ -1502,6 +1502,9 @@ class AppLocalizationsNl extends AppLocalizations {
@override @override
String get map_shareMarkerHere => 'Deel marker hier'; String get map_shareMarkerHere => 'Deel marker hier';
@override
String get map_setAsMyLocation => 'Stel dit in als mijn locatie';
@override @override
String get map_pinLabel => 'Label vastzetten'; String get map_pinLabel => 'Label vastzetten';
+3
View File
@@ -1512,6 +1512,9 @@ class AppLocalizationsPl extends AppLocalizations {
@override @override
String get map_shareMarkerHere => 'Udostępnij znacznik tutaj'; String get map_shareMarkerHere => 'Udostępnij znacznik tutaj';
@override
String get map_setAsMyLocation => 'Ustaw jako moje lokalizację';
@override @override
String get map_pinLabel => 'Oznacz etykietę'; String get map_pinLabel => 'Oznacz etykietę';
+3
View File
@@ -1511,6 +1511,9 @@ class AppLocalizationsPt extends AppLocalizations {
@override @override
String get map_shareMarkerHere => 'Compartilhar marcador aqui'; String get map_shareMarkerHere => 'Compartilhar marcador aqui';
@override
String get map_setAsMyLocation => 'Defina minha localização';
@override @override
String get map_pinLabel => 'Rótulo de marcador'; String get map_pinLabel => 'Rótulo de marcador';
+3
View File
@@ -1513,6 +1513,9 @@ class AppLocalizationsRu extends AppLocalizations {
@override @override
String get map_shareMarkerHere => 'Поделиться меткой здесь'; String get map_shareMarkerHere => 'Поделиться меткой здесь';
@override
String get map_setAsMyLocation => 'Установить мое местоположение';
@override @override
String get map_pinLabel => 'Метка'; String get map_pinLabel => 'Метка';
+3
View File
@@ -1504,6 +1504,9 @@ class AppLocalizationsSk extends AppLocalizations {
@override @override
String get map_shareMarkerHere => 'Zdieľte značku tu'; String get map_shareMarkerHere => 'Zdieľte značku tu';
@override
String get map_setAsMyLocation => 'Nastavte ako moju polohu';
@override @override
String get map_pinLabel => 'Označka upozornenia'; String get map_pinLabel => 'Označka upozornenia';
+3
View File
@@ -1498,6 +1498,9 @@ class AppLocalizationsSl extends AppLocalizations {
@override @override
String get map_shareMarkerHere => 'Delite točke tukaj.'; String get map_shareMarkerHere => 'Delite točke tukaj.';
@override
String get map_setAsMyLocation => 'Nastavite to kot mojo lokacijo';
@override @override
String get map_pinLabel => 'Oznaka za pritrditev'; String get map_pinLabel => 'Oznaka za pritrditev';
+3
View File
@@ -1494,6 +1494,9 @@ class AppLocalizationsSv extends AppLocalizations {
@override @override
String get map_shareMarkerHere => 'Dela markeringen här'; String get map_shareMarkerHere => 'Dela markeringen här';
@override
String get map_setAsMyLocation => 'Ange som min plats';
@override @override
String get map_pinLabel => 'Fästetikett'; String get map_pinLabel => 'Fästetikett';
+3
View File
@@ -1510,6 +1510,9 @@ class AppLocalizationsUk extends AppLocalizations {
@override @override
String get map_shareMarkerHere => 'Поділитися маркером тут'; String get map_shareMarkerHere => 'Поділитися маркером тут';
@override
String get map_setAsMyLocation => 'Встановити моє місцезнаходження';
@override @override
String get map_pinLabel => 'Мітка піна'; String get map_pinLabel => 'Мітка піна';
+3
View File
@@ -1421,6 +1421,9 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String get map_shareMarkerHere => '在此分享标记'; String get map_shareMarkerHere => '在此分享标记';
@override
String get map_setAsMyLocation => '设置为我的位置';
@override @override
String get map_pinLabel => '标签'; String get map_pinLabel => '标签';
+2 -1
View File
@@ -1887,5 +1887,6 @@
"tcpErrorUnsupported": "TCP-transport wordt niet ondersteund op deze platform.", "tcpErrorUnsupported": "TCP-transport wordt niet ondersteund op deze platform.",
"tcpErrorTimedOut": "De TCP-verbinding is verlopen.", "tcpErrorTimedOut": "De TCP-verbinding is verlopen.",
"tcpConnectionFailed": "Verbinding met TCP mislukt: {error}", "tcpConnectionFailed": "Verbinding met TCP mislukt: {error}",
"map_showDiscoveryContacts": "Ontdek contacten weergeven" "map_showDiscoveryContacts": "Ontdek contacten weergeven",
"map_setAsMyLocation": "Stel dit in als mijn locatie"
} }
+2 -1
View File
@@ -1887,5 +1887,6 @@
"tcpErrorUnsupported": "Transport protokoł TCP nie jest obsługiwany na tym urządzeniu.", "tcpErrorUnsupported": "Transport protokoł TCP nie jest obsługiwany na tym urządzeniu.",
"tcpErrorTimedOut": "Połączenie TCP zakończyło się bez powodzenia.", "tcpErrorTimedOut": "Połączenie TCP zakończyło się bez powodzenia.",
"tcpConnectionFailed": "Błąd połączenia TCP: {error}", "tcpConnectionFailed": "Błąd połączenia TCP: {error}",
"map_showDiscoveryContacts": "Pokaż kontakty odkrywania" "map_showDiscoveryContacts": "Pokaż kontakty odkrywania",
"map_setAsMyLocation": "Ustaw jako moje lokalizację"
} }
+2 -1
View File
@@ -1887,5 +1887,6 @@
"tcpErrorUnsupported": "O protocolo TCP não é suportado nesta plataforma.", "tcpErrorUnsupported": "O protocolo TCP não é suportado nesta plataforma.",
"tcpErrorTimedOut": "A conexão TCP expirou.", "tcpErrorTimedOut": "A conexão TCP expirou.",
"tcpConnectionFailed": "Falha na conexão TCP: {error}", "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"
} }
+2 -1
View File
@@ -1127,5 +1127,6 @@
"tcpErrorUnsupported": "Протокол TCP не поддерживается на этой платформе.", "tcpErrorUnsupported": "Протокол TCP не поддерживается на этой платформе.",
"tcpErrorTimedOut": "Соединение TCP не удалось установить.", "tcpErrorTimedOut": "Соединение TCP не удалось установить.",
"tcpConnectionFailed": "Не удалось установить соединение TCP: {error}", "tcpConnectionFailed": "Не удалось установить соединение TCP: {error}",
"map_showDiscoveryContacts": "Показать контакты Discovery" "map_showDiscoveryContacts": "Показать контакты Discovery",
"map_setAsMyLocation": "Установить мое местоположение"
} }
+2 -1
View File
@@ -1887,5 +1887,6 @@
"tcpErrorUnsupported": "Prevoz prostredníctvom protokolu TCP nie je na tejto platforme podporovaný.", "tcpErrorUnsupported": "Prevoz prostredníctvom protokolu TCP nie je na tejto platforme podporovaný.",
"tcpErrorTimedOut": "Pripojenie TCP vypršalo.", "tcpErrorTimedOut": "Pripojenie TCP vypršalo.",
"tcpConnectionFailed": "Neúspešné vytvorenie TCP spojenia: {error}", "tcpConnectionFailed": "Neúspešné vytvorenie TCP spojenia: {error}",
"map_showDiscoveryContacts": "Zobraziť kontakty objavov" "map_showDiscoveryContacts": "Zobraziť kontakty objavov",
"map_setAsMyLocation": "Nastavte ako moju polohu"
} }
+2 -1
View File
@@ -1887,5 +1887,6 @@
"tcpErrorUnsupported": "Transport preko protokola TCP ni podprt na tej platformi.", "tcpErrorUnsupported": "Transport preko protokola TCP ni podprt na tej platformi.",
"tcpErrorTimedOut": "Povezava TCP je presegla časovno obdobje.", "tcpErrorTimedOut": "Povezava TCP je presegla časovno obdobje.",
"tcpConnectionFailed": "Napaka pri povezavi TCP: {error}", "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"
} }
+2 -1
View File
@@ -1887,5 +1887,6 @@
"tcpErrorUnsupported": "TCP-transport fungerar inte på denna plattform.", "tcpErrorUnsupported": "TCP-transport fungerar inte på denna plattform.",
"tcpErrorTimedOut": "TCP-anslutningen har tidsut gått.", "tcpErrorTimedOut": "TCP-anslutningen har tidsut gått.",
"tcpConnectionFailed": "Fel vid TCP-anslutning: {error}", "tcpConnectionFailed": "Fel vid TCP-anslutning: {error}",
"map_showDiscoveryContacts": "Visa Discovery-kontakter" "map_showDiscoveryContacts": "Visa Discovery-kontakter",
"map_setAsMyLocation": "Ange som min plats"
} }
+2 -1
View File
@@ -1887,5 +1887,6 @@
"tcpErrorUnsupported": "Транспорт TCP не підтримується на цій платформі.", "tcpErrorUnsupported": "Транспорт TCP не підтримується на цій платформі.",
"tcpErrorTimedOut": "З'єднання TCP завершилося через закінчення часу очікування.", "tcpErrorTimedOut": "З'єднання TCP завершилося через закінчення часу очікування.",
"tcpConnectionFailed": "Не вдалося встановити з'єднання TCP: {error}", "tcpConnectionFailed": "Не вдалося встановити з'єднання TCP: {error}",
"map_showDiscoveryContacts": "Показати контакти Відкриття" "map_showDiscoveryContacts": "Показати контакти Відкриття",
"map_setAsMyLocation": "Встановити моє місцезнаходження"
} }
+2 -1
View File
@@ -1892,5 +1892,6 @@
"tcpErrorUnsupported": "此平台不支持 TCP 传输。", "tcpErrorUnsupported": "此平台不支持 TCP 传输。",
"tcpErrorTimedOut": "TCP 连接超时。", "tcpErrorTimedOut": "TCP 连接超时。",
"tcpConnectionFailed": "TCP 连接失败:{error}", "tcpConnectionFailed": "TCP 连接失败:{error}",
"map_showDiscoveryContacts": "显示发现联系人" "map_showDiscoveryContacts": "显示发现联系人",
"map_setAsMyLocation": "设置为我的位置"
} }
+74 -30
View File
@@ -106,10 +106,9 @@ class _ChatScreenState extends State<ChatScreen> {
final unreadLabel = context.l10n.chat_unread(unreadCount); final unreadLabel = context.l10n.chat_unread(unreadCount);
final pathLabel = _currentPathLabel(contact); final pathLabel = _currentPathLabel(contact);
// Show path details if we have path data (from device or override) // Show path details if we have non-empty path data (from device or override)
final hasPathData =
contact.path.isNotEmpty || contact.pathOverrideBytes != null;
final effectivePath = contact.pathOverrideBytes ?? contact.path; final effectivePath = contact.pathOverrideBytes ?? contact.path;
final hasPathData = effectivePath.isNotEmpty;
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -143,12 +142,25 @@ class _ChatScreenState extends State<ChatScreen> {
final contact = _resolveContact(connector); final contact = _resolveContact(connector);
final isFloodMode = contact.pathOverride == -1; final isFloodMode = contact.pathOverride == -1;
final isDirectMode = contact.pathOverride == 0;
final activeMode = isFloodMode
? 'flood'
: isDirectMode
? 'direct'
: 'auto';
return PopupMenuButton<String>( return PopupMenuButton<String>(
icon: Icon(isFloodMode ? Icons.waves : Icons.route), icon: Icon(isFloodMode ? Icons.waves : Icons.route),
tooltip: context.l10n.chat_routingMode, tooltip: context.l10n.chat_routingMode,
onSelected: (mode) async { onSelected: (mode) async {
if (mode == 'flood') { if (mode == 'flood') {
await connector.setPathOverride(contact, pathLen: -1); await connector.setPathOverride(contact, pathLen: -1);
} else if (mode == 'direct') {
await connector.setPathOverride(
contact,
pathLen: 0,
pathBytes: Uint8List(0),
);
} else { } else {
await connector.setPathOverride(contact, pathLen: null); await connector.setPathOverride(contact, pathLen: null);
} }
@@ -161,7 +173,7 @@ class _ChatScreenState extends State<ChatScreen> {
Icon( Icon(
Icons.auto_mode, Icons.auto_mode,
size: 20, size: 20,
color: !isFloodMode color: activeMode == 'auto'
? Theme.of(context).primaryColor ? Theme.of(context).primaryColor
: null, : null,
), ),
@@ -169,7 +181,30 @@ class _ChatScreenState extends State<ChatScreen> {
Text( Text(
context.l10n.chat_autoUseSavedPath, context.l10n.chat_autoUseSavedPath,
style: TextStyle( 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.bold
: FontWeight.normal, : FontWeight.normal,
), ),
@@ -184,7 +219,7 @@ class _ChatScreenState extends State<ChatScreen> {
Icon( Icon(
Icons.waves, Icons.waves,
size: 20, size: 20,
color: isFloodMode color: activeMode == 'flood'
? Theme.of(context).primaryColor ? Theme.of(context).primaryColor
: null, : null,
), ),
@@ -192,7 +227,7 @@ class _ChatScreenState extends State<ChatScreen> {
Text( Text(
context.l10n.chat_forceFloodMode, context.l10n.chat_forceFloodMode,
style: TextStyle( style: TextStyle(
fontWeight: isFloodMode fontWeight: activeMode == 'flood'
? FontWeight.bold ? FontWeight.bold
: FontWeight.normal, : FontWeight.normal,
), ),
@@ -251,7 +286,9 @@ class _ChatScreenState extends State<ChatScreen> {
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( 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]), 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 // Auto-scroll to bottom if user is already at bottom
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
_scrollController.scrollToBottomIfAtBottom(); _scrollController.scrollToBottomIfAtBottom();
}); });
@@ -293,10 +331,10 @@ class _ChatScreenState extends State<ChatScreen> {
); );
} }
final messageIndex = index; final messageIndex = index;
Contact contact = widget.contact; Contact contact = _resolveContact(connector);
final message = reversedMessages[messageIndex]; final message = reversedMessages[messageIndex];
String fourByteHex = ''; String fourByteHex = '';
if (widget.contact.type == advTypeRoom) { if (contact.type == advTypeRoom) {
contact = _resolveContactFrom4Bytes( contact = _resolveContactFrom4Bytes(
connector, connector,
message.fourByteRoomContactKey.isEmpty message.fourByteRoomContactKey.isEmpty
@@ -314,12 +352,13 @@ class _ChatScreenState extends State<ChatScreen> {
final textScale = context.select<ChatTextScaleService, double>( final textScale = context.select<ChatTextScaleService, double>(
(service) => service.scale, (service) => service.scale,
); );
final resolvedContact = _resolveContact(connector);
return _MessageBubble( return _MessageBubble(
message: message, message: message,
senderName: widget.contact.type == advTypeRoom senderName: resolvedContact.type == advTypeRoom
? "${contact.name} [$fourByteHex]" ? "${contact.name} [$fourByteHex]"
: contact.name, : contact.name,
isRoomServer: widget.contact.type == advTypeRoom, isRoomServer: resolvedContact.type == advTypeRoom,
textScale: textScale, textScale: textScale,
onTap: () => _openMessagePath(message, contact), onTap: () => _openMessagePath(message, contact),
onLongPress: () => _showMessageActions(message, contact), onLongPress: () => _showMessageActions(message, contact),
@@ -457,7 +496,7 @@ class _ChatScreenState extends State<ChatScreen> {
return; return;
} }
connector.sendMessage(widget.contact, text); connector.sendMessage(_resolveContact(connector), text);
_textController.clear(); _textController.clear();
_textFieldFocusNode.requestFocus(); _textFieldFocusNode.requestFocus();
} }
@@ -654,7 +693,7 @@ class _ChatScreenState extends State<ChatScreen> {
// Set the path override to persist user's choice // Set the path override to persist user's choice
await connector.setPathOverride( await connector.setPathOverride(
widget.contact, _resolveContact(connector),
pathLen: pathLength, pathLen: pathLength,
pathBytes: pathBytes, pathBytes: pathBytes,
); );
@@ -663,7 +702,7 @@ class _ChatScreenState extends State<ChatScreen> {
Navigator.pop(context); Navigator.pop(context);
await _notifyPathSet( await _notifyPathSet(
connector, connector,
widget.contact, _resolveContact(connector),
pathBytes, pathBytes,
path.hopCount, path.hopCount,
); );
@@ -722,7 +761,9 @@ class _ChatScreenState extends State<ChatScreen> {
style: const TextStyle(fontSize: 11), style: const TextStyle(fontSize: 11),
), ),
onTap: () async { onTap: () async {
await connector.clearContactPath(widget.contact); await connector.clearContactPath(
_resolveContact(connector),
);
if (!context.mounted) return; if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
@@ -750,7 +791,7 @@ class _ChatScreenState extends State<ChatScreen> {
), ),
onTap: () async { onTap: () async {
await connector.setPathOverride( await connector.setPathOverride(
widget.contact, _resolveContact(connector),
pathLen: -1, pathLen: -1,
); );
if (!context.mounted) return; if (!context.mounted) return;
@@ -1005,11 +1046,7 @@ class _ChatScreenState extends State<ChatScreen> {
); );
if (result == null) { if (result == null) {
appLogger.info( return; // Cancelled keep existing path
'PathSelectionDialog was cancelled or returned null',
tag: 'ChatScreen',
);
return;
} }
if (!mounted) { if (!mounted) {
@@ -1025,14 +1062,19 @@ class _ChatScreenState extends State<ChatScreen> {
tag: 'ChatScreen', tag: 'ChatScreen',
); );
await connector.setPathOverride( await connector.setPathOverride(
widget.contact, _resolveContact(connector),
pathLen: result.length, pathLen: result.length,
pathBytes: result, pathBytes: result,
); );
appLogger.info('setPathOverride completed', tag: 'ChatScreen'); appLogger.info('setPathOverride completed', tag: 'ChatScreen');
if (!mounted) return; 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) { void _openMessagePath(Message message, Contact contact) {
@@ -1044,10 +1086,10 @@ class _ChatScreenState extends State<ChatScreen> {
final String senderName; final String senderName;
if (message.isOutgoing) { if (message.isOutgoing) {
senderName = connector.selfName ?? context.l10n.chat_me; senderName = connector.selfName ?? context.l10n.chat_me;
} else if (widget.contact.type == advTypeRoom) { } else if (_resolveContact(connector).type == advTypeRoom) {
senderName = "${contact.name} [$fourByteHex]"; senderName = "${contact.name} [$fourByteHex]";
} else { } else {
senderName = widget.contact.name; senderName = _resolveContact(connector).name;
} }
final pathMessage = ChannelMessage( final pathMessage = ChannelMessage(
senderKey: null, senderKey: null,
@@ -1110,7 +1152,8 @@ class _ChatScreenState extends State<ChatScreen> {
_retryMessage(message); _retryMessage(message);
}, },
), ),
if (widget.contact.type == advTypeRoom) if (_resolveContact(context.read<MeshCoreConnector>()).type ==
advTypeRoom)
ListTile( ListTile(
leading: const Icon(Icons.chat), leading: const Icon(Icons.chat),
title: Text(context.l10n.contacts_openChat), title: Text(context.l10n.contacts_openChat),
@@ -1148,7 +1191,7 @@ class _ChatScreenState extends State<ChatScreen> {
void _retryMessage(Message message) { void _retryMessage(Message message) {
final connector = Provider.of<MeshCoreConnector>(context, listen: false); final connector = Provider.of<MeshCoreConnector>(context, listen: false);
// Retry using the contact's current path override setting // Retry using the contact's current path override setting
connector.sendMessage(widget.contact, message.text); connector.sendMessage(_resolveContact(connector), message.text);
ScaffoldMessenger.of( ScaffoldMessenger.of(
context, context,
).showSnackBar(SnackBar(content: Text(context.l10n.chat_retryingMessage))); ).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 room servers, include sender name (like channels) since multiple users
// For 1:1 chats, sender is implicit (null) // 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 ? senderContact.name
: null; : null;
final hash = ReactionHelper.computeReactionHash( final hash = ReactionHelper.computeReactionHash(
@@ -1183,7 +1227,7 @@ class _ChatScreenState extends State<ChatScreen> {
message.text, message.text,
); );
final reactionText = 'r:$hash:$emojiIndex'; final reactionText = 'r:$hash:$emojiIndex';
connector.sendMessage(widget.contact, reactionText); connector.sendMessage(_resolveContact(connector), reactionText);
} }
} }
+8
View File
@@ -3,6 +3,7 @@ import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:meshcore_open/screens/path_trace_map.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/utils/app_logger.dart';
import 'package:meshcore_open/widgets/app_bar.dart'; import 'package:meshcore_open/widgets/app_bar.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@@ -64,6 +65,13 @@ class _ContactsScreenState extends State<ContactsScreen>
super.initState(); super.initState();
_loadGroups(); _loadGroups();
_setupFrameListener(); _setupFrameListener();
_clearAdvertNotifications();
}
void _clearAdvertNotifications() {
final connector = context.read<MeshCoreConnector>();
final contactIds = connector.contacts.map((c) => c.publicKeyHex).toList();
NotificationService().clearAdvertNotifications(contactIds);
} }
@override @override
+17
View File
@@ -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( ListTile(
leading: const Icon(Icons.close), leading: const Icon(Icons.close),
title: Text(context.l10n.common_cancel), title: Text(context.l10n.common_cancel),
+133 -37
View File
@@ -44,6 +44,12 @@ class MessageRetryService extends ChangeNotifier {
[]; // Rolling buffer of recent ACK hashes []; // Rolling buffer of recent ACK hashes
final Map<String, List<String>> _pendingMessageQueuePerContact = final Map<String, List<String>> _pendingMessageQueuePerContact =
{}; // contactPubKeyHex FIFO queue of messageIds (DEPRECATED - will be removed) {}; // 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 = final Map<String, String> _expectedHashToMessageId =
{}; // expectedAckHashHex messageId (for matching RESP_CODE_SENT by hash) {}; // expectedAckHashHex messageId (for matching RESP_CODE_SENT by hash)
@@ -156,7 +162,49 @@ class MessageRetryService extends ChangeNotifier {
_addMessageCallback!(contact.publicKeyHex, message); _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 { Future<void> _attemptSend(String messageId) async {
@@ -169,13 +217,11 @@ class MessageRetryService extends ChangeNotifier {
// Use the path that was captured when the message was first sent // Use the path that was captured when the message was first sent
if (_setContactPathCallback != null && _clearContactPathCallback != null) { if (_setContactPathCallback != null && _clearContactPathCallback != null) {
if (message.pathLength != null && message.pathLength! < 0) { if (message.pathLength != null && message.pathLength! < 0) {
// Flood mode - clear the path
debugPrint( debugPrint(
'Setting flood mode for retry attempt ${message.retryCount}', 'Setting flood mode for retry attempt ${message.retryCount}',
); );
_clearContactPathCallback!(contact); await _clearContactPathCallback!(contact);
} else if (message.pathLength != null && message.pathLength! >= 0) { } else if (message.pathLength != null && message.pathLength! >= 0) {
// Specific path (including direct neighbor with pathLength=0)
final pathStr = message.pathBytes.isEmpty final pathStr = message.pathBytes.isEmpty
? 'direct' ? 'direct'
: message.pathBytes : message.pathBytes
@@ -192,6 +238,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 attempt = message.retryCount.clamp(0, 3);
final timestampSeconds = message.timestamp.millisecondsSinceEpoch ~/ 1000; final timestampSeconds = message.timestamp.millisecondsSinceEpoch ~/ 1000;
@@ -231,6 +295,15 @@ class MessageRetryService extends ChangeNotifier {
if (_sendMessageCallback != null) { if (_sendMessageCallback != null) {
_sendMessageCallback!(contact, message.text, attempt, timestampSeconds); _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 +354,7 @@ class MessageRetryService extends ChangeNotifier {
} }
// FALLBACK: Old queue-based matching (for messages sent before hash computation was added) // 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) { if (messageId == null && allowQueueFallback) {
_debugLogService?.warn( _debugLogService?.warn(
'RESP_CODE_SENT: ACK hash $ackHashHex not found in hash table, falling back to queue', 'RESP_CODE_SENT: ACK hash $ackHashHex not found in hash table, falling back to queue',
@@ -290,13 +364,16 @@ class MessageRetryService extends ChangeNotifier {
'Hash-based match failed for $ackHashHex, falling back to queue-based matching', '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 contactKey = entry.key;
final queue = entry.value; 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); final candidateMessageId = queue.removeAt(0);
if (_pendingMessages.containsKey(candidateMessageId)) { if (_pendingMessages.containsKey(candidateMessageId)) {
messageId = candidateMessageId; messageId = candidateMessageId;
contact = _pendingContacts[candidateMessageId]; contact = _pendingContacts[candidateMessageId];
@@ -304,21 +381,10 @@ class MessageRetryService extends ChangeNotifier {
'Queue-based match (fallback): $ackHashHex → message $messageId for $contactKey', 'Queue-based match (fallback): $ackHashHex → message $messageId for $contactKey',
); );
break; 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;
} }
} }
@@ -463,22 +529,7 @@ class MessageRetryService extends ChangeNotifier {
} else { } else {
// Max retries reached - mark as failed // Max retries reached - mark as failed
final failedMessage = message.copyWith(status: MessageStatus.failed); final failedMessage = message.copyWith(status: MessageStatus.failed);
_pendingMessages[messageId] = failedMessage;
// 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);
}
// Check if we should clear the path on max retry // Check if we should clear the path on max retry
if (_appSettingsService?.settings.clearPathOnMaxRetry == true && if (_appSettingsService?.settings.clearPathOnMaxRetry == true &&
@@ -499,6 +550,30 @@ class MessageRetryService extends ChangeNotifier {
} }
notifyListeners(); 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 +669,15 @@ class MessageRetryService extends ChangeNotifier {
} }
if (matchedMessageId != null) { 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 contact = _pendingContacts[matchedMessageId];
final selection = _pendingPathSelections[matchedMessageId]; final selection = _pendingPathSelections[matchedMessageId];
@@ -616,12 +699,21 @@ class MessageRetryService extends ChangeNotifier {
tripTimeMs: tripTimeMs, 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 // Move ACK hashes to history before removing
_moveAckHashesToHistory(matchedMessageId); _moveAckHashesToHistory(matchedMessageId);
_pendingMessages.remove(matchedMessageId); _pendingMessages.remove(matchedMessageId);
_pendingContacts.remove(matchedMessageId); _pendingContacts.remove(matchedMessageId);
_pendingPathSelections.remove(matchedMessageId); _pendingPathSelections.remove(matchedMessageId);
_resolvedMessages.remove(matchedMessageId);
// Clean up the queue entry for this contact (remove any remaining references to this message) // Clean up the queue entry for this contact (remove any remaining references to this message)
if (contact != null) { if (contact != null) {
@@ -646,6 +738,7 @@ class MessageRetryService extends ChangeNotifier {
true, true,
tripTimeMs, tripTimeMs,
); );
_onMessageResolved(matchedMessageId, contact.publicKeyHex);
} }
notifyListeners(); notifyListeners();
@@ -783,6 +876,9 @@ class MessageRetryService extends ChangeNotifier {
_ackHistory.clear(); _ackHistory.clear();
_ackHashToMessageId.clear(); _ackHashToMessageId.clear();
_pendingMessageQueuePerContact.clear(); _pendingMessageQueuePerContact.clear();
_sendQueue.clear();
_activeMessages.clear();
_resolvedMessages.clear();
super.dispose(); super.dispose();
} }
} }
+58 -1
View File
@@ -232,7 +232,9 @@ class NotificationService {
try { try {
await _notifications.show( await _notifications.show(
id: contactId?.hashCode ?? DateTime.now().millisecondsSinceEpoch, id: contactId != null
? 'advert:$contactId'.hashCode
: DateTime.now().millisecondsSinceEpoch,
title: _l10n.notification_newTypeDiscovered(contactType), title: _l10n.notification_newTypeDiscovered(contactType),
body: contactName, body: contactName,
notificationDetails: notificationDetails, notificationDetails: notificationDetails,
@@ -331,6 +333,61 @@ class NotificationService {
await _notifications.cancel(id: id); 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) // Public notification methods (rate limiting is enforced automatically)
// //