diff --git a/.swift-version b/.swift-version new file mode 100644 index 0000000..31b44b0 --- /dev/null +++ b/.swift-version @@ -0,0 +1 @@ +6.2.4 \ No newline at end of file diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index d974b7b..7cf32ef 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -199,6 +199,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 _pathOpLock = Future.value(); Map? _currentCustomVars; // Channel syncing state (sequential pattern) @@ -558,6 +561,10 @@ class MeshCoreConnector extends ChangeNotifier { _unreadStore.saveContactUnreadCount( Map.from(_contactUnreadCount), ); + _notificationService.clearContactNotification( + contactKeyHex, + getTotalUnreadCount(), + ); notifyListeners(); } } @@ -576,6 +583,10 @@ class MeshCoreConnector extends ChangeNotifier { _channels.isNotEmpty ? _channels : _cachedChannels, ), ); + _notificationService.clearChannelNotification( + channelIndex, + getTotalUnreadCount(), + ); notifyListeners(); } } @@ -1740,18 +1751,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(); + _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.delayed(const Duration(milliseconds: 100)); + } + } finally { + completer.complete(); + } } Future setContactFavorite(Contact contact, bool isFavorite) async { @@ -2136,25 +2162,34 @@ class MeshCoreConnector extends ChangeNotifier { } Future clearContactPath(Contact contact) async { - if (!isConnected) return; + // Serialize path operations to prevent interleaved async calls. + final prev = _pathOpLock; + final completer = Completer(); + _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.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( @@ -2490,6 +2525,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 diff --git a/lib/l10n/app_bg.arb b/lib/l10n/app_bg.arb index c5acba9..07c6ae9 100644 --- a/lib/l10n/app_bg.arb +++ b/lib/l10n/app_bg.arb @@ -1887,5 +1887,6 @@ "tcpErrorUnsupported": "Транспортът чрез TCP не се поддържа на тази платформа.", "tcpErrorTimedOut": "Връзката TCP изтекла.", "tcpConnectionFailed": "Неуспешно е установено TCP връзката: {error}", - "map_showDiscoveryContacts": "Покажи контакти за откриване" + "map_showDiscoveryContacts": "Покажи контакти за откриване", + "map_setAsMyLocation": "Задайте като моя местоположение" } diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 2eed922..0e3b5f4 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -1915,5 +1915,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" } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 58569e4..712a164 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -807,6 +807,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", diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 25e0345..53d7b70 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -1915,5 +1915,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" } diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 5d586f4..ef19d15 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -1887,5 +1887,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" } diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index 4de9e9d..a9f659b 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -1887,5 +1887,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" } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 3690bf2..fdc89d4 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -2746,6 +2746,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: diff --git a/lib/l10n/app_localizations_bg.dart b/lib/l10n/app_localizations_bg.dart index 785f373..e17056c 100644 --- a/lib/l10n/app_localizations_bg.dart +++ b/lib/l10n/app_localizations_bg.dart @@ -1511,6 +1511,9 @@ class AppLocalizationsBg extends AppLocalizations { @override String get map_shareMarkerHere => 'Споделете маркер тук'; + @override + String get map_setAsMyLocation => 'Задайте като моя местоположение'; + @override String get map_pinLabel => 'Етикетиране на пин'; diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 708e0ab..68317c8 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -1513,6 +1513,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'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 4938dd4..95e8130 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -1487,6 +1487,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'; diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index 2d4e2fb..fad2737 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -1509,6 +1509,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'; diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index 28bbab3..b11b373 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -1518,6 +1518,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'; diff --git a/lib/l10n/app_localizations_it.dart b/lib/l10n/app_localizations_it.dart index b510bc1..d1b2d95 100644 --- a/lib/l10n/app_localizations_it.dart +++ b/lib/l10n/app_localizations_it.dart @@ -1510,6 +1510,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'; diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index 7c054dd..47e2341 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -1502,6 +1502,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'; diff --git a/lib/l10n/app_localizations_pl.dart b/lib/l10n/app_localizations_pl.dart index dec6583..a8abd13 100644 --- a/lib/l10n/app_localizations_pl.dart +++ b/lib/l10n/app_localizations_pl.dart @@ -1512,6 +1512,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ę'; diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index 4d8d20e..54facda 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -1511,6 +1511,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'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 60aa486..beb37de 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -1513,6 +1513,9 @@ class AppLocalizationsRu extends AppLocalizations { @override String get map_shareMarkerHere => 'Поделиться меткой здесь'; + @override + String get map_setAsMyLocation => 'Установить мое местоположение'; + @override String get map_pinLabel => 'Метка'; diff --git a/lib/l10n/app_localizations_sk.dart b/lib/l10n/app_localizations_sk.dart index 4e11719..c59014c 100644 --- a/lib/l10n/app_localizations_sk.dart +++ b/lib/l10n/app_localizations_sk.dart @@ -1504,6 +1504,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'; diff --git a/lib/l10n/app_localizations_sl.dart b/lib/l10n/app_localizations_sl.dart index f967db4..4746550 100644 --- a/lib/l10n/app_localizations_sl.dart +++ b/lib/l10n/app_localizations_sl.dart @@ -1498,6 +1498,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'; diff --git a/lib/l10n/app_localizations_sv.dart b/lib/l10n/app_localizations_sv.dart index 200bdbe..d86f994 100644 --- a/lib/l10n/app_localizations_sv.dart +++ b/lib/l10n/app_localizations_sv.dart @@ -1494,6 +1494,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'; diff --git a/lib/l10n/app_localizations_uk.dart b/lib/l10n/app_localizations_uk.dart index 8dfe123..e40dd88 100644 --- a/lib/l10n/app_localizations_uk.dart +++ b/lib/l10n/app_localizations_uk.dart @@ -1510,6 +1510,9 @@ class AppLocalizationsUk extends AppLocalizations { @override String get map_shareMarkerHere => 'Поділитися маркером тут'; + @override + String get map_setAsMyLocation => 'Встановити моє місцезнаходження'; + @override String get map_pinLabel => 'Мітка піна'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index ecd6813..d5f4f1d 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -1421,6 +1421,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get map_shareMarkerHere => '在此分享标记'; + @override + String get map_setAsMyLocation => '设置为我的位置'; + @override String get map_pinLabel => '标签'; diff --git a/lib/l10n/app_nl.arb b/lib/l10n/app_nl.arb index d38fb4c..9512f18 100644 --- a/lib/l10n/app_nl.arb +++ b/lib/l10n/app_nl.arb @@ -1887,5 +1887,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" } diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb index 9dc3b33..88856aa 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -1887,5 +1887,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ę" } diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index cded31f..3a5fe3b 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -1887,5 +1887,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" } diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 43e1b9a..8fec7fa 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -1127,5 +1127,6 @@ "tcpErrorUnsupported": "Протокол TCP не поддерживается на этой платформе.", "tcpErrorTimedOut": "Соединение TCP не удалось установить.", "tcpConnectionFailed": "Не удалось установить соединение TCP: {error}", - "map_showDiscoveryContacts": "Показать контакты Discovery" + "map_showDiscoveryContacts": "Показать контакты Discovery", + "map_setAsMyLocation": "Установить мое местоположение" } diff --git a/lib/l10n/app_sk.arb b/lib/l10n/app_sk.arb index f03d276..374e9e4 100644 --- a/lib/l10n/app_sk.arb +++ b/lib/l10n/app_sk.arb @@ -1887,5 +1887,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" } diff --git a/lib/l10n/app_sl.arb b/lib/l10n/app_sl.arb index 4a4b5cb..2436c78 100644 --- a/lib/l10n/app_sl.arb +++ b/lib/l10n/app_sl.arb @@ -1887,5 +1887,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" } diff --git a/lib/l10n/app_sv.arb b/lib/l10n/app_sv.arb index 6a33e11..75231d0 100644 --- a/lib/l10n/app_sv.arb +++ b/lib/l10n/app_sv.arb @@ -1887,5 +1887,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" } diff --git a/lib/l10n/app_uk.arb b/lib/l10n/app_uk.arb index c179ca3..35d7bd2 100644 --- a/lib/l10n/app_uk.arb +++ b/lib/l10n/app_uk.arb @@ -1887,5 +1887,6 @@ "tcpErrorUnsupported": "Транспорт TCP не підтримується на цій платформі.", "tcpErrorTimedOut": "З'єднання TCP завершилося через закінчення часу очікування.", "tcpConnectionFailed": "Не вдалося встановити з'єднання TCP: {error}", - "map_showDiscoveryContacts": "Показати контакти Відкриття" + "map_showDiscoveryContacts": "Показати контакти Відкриття", + "map_setAsMyLocation": "Встановити моє місцезнаходження" } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index cac4b79..9e7c155 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -1892,5 +1892,6 @@ "tcpErrorUnsupported": "此平台不支持 TCP 传输。", "tcpErrorTimedOut": "TCP 连接超时。", "tcpConnectionFailed": "TCP 连接失败:{error}", - "map_showDiscoveryContacts": "显示发现联系人" + "map_showDiscoveryContacts": "显示发现联系人", + "map_setAsMyLocation": "设置为我的位置" } diff --git a/lib/screens/chat_screen.dart b/lib/screens/chat_screen.dart index 0075040..96203ea 100644 --- a/lib/screens/chat_screen.dart +++ b/lib/screens/chat_screen.dart @@ -106,10 +106,9 @@ class _ChatScreenState extends State { 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 { final contact = _resolveContact(connector); final isFloodMode = contact.pathOverride == -1; + final isDirectMode = contact.pathOverride == 0; + final activeMode = isFloodMode + ? 'flood' + : isDirectMode + ? 'direct' + : 'auto'; + return PopupMenuButton( 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 { Icon( Icons.auto_mode, size: 20, - color: !isFloodMode + color: activeMode == 'auto' ? Theme.of(context).primaryColor : null, ), @@ -169,7 +181,30 @@ class _ChatScreenState extends State { 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 { Icon( Icons.waves, size: 20, - color: isFloodMode + color: activeMode == 'flood' ? Theme.of(context).primaryColor : null, ), @@ -192,7 +227,7 @@ class _ChatScreenState extends State { Text( context.l10n.chat_forceFloodMode, style: TextStyle( - fontWeight: isFloodMode + fontWeight: activeMode == 'flood' ? FontWeight.bold : FontWeight.normal, ), @@ -251,7 +286,9 @@ class _ChatScreenState extends State { ), const SizedBox(height: 8), Text( - context.l10n.chat_sendMessageTo(widget.contact.name), + context.l10n.chat_sendMessageTo( + _resolveContact(context.read()).name, + ), style: TextStyle(fontSize: 14, color: Colors.grey[500]), ), ], @@ -269,6 +306,7 @@ class _ChatScreenState extends State { // 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 { ); } 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 { final textScale = context.select( (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 { return; } - connector.sendMessage(widget.contact, text); + connector.sendMessage(_resolveContact(connector), text); _textController.clear(); _textFieldFocusNode.requestFocus(); } @@ -654,7 +693,7 @@ class _ChatScreenState extends State { // 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 { Navigator.pop(context); await _notifyPathSet( connector, - widget.contact, + _resolveContact(connector), pathBytes, path.hopCount, ); @@ -722,7 +761,9 @@ class _ChatScreenState extends State { 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 { ), onTap: () async { await connector.setPathOverride( - widget.contact, + _resolveContact(connector), pathLen: -1, ); if (!context.mounted) return; @@ -1005,11 +1046,7 @@ class _ChatScreenState extends State { ); 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 { 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 { 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 { _retryMessage(message); }, ), - if (widget.contact.type == advTypeRoom) + if (_resolveContact(context.read()).type == + advTypeRoom) ListTile( leading: const Icon(Icons.chat), title: Text(context.l10n.contacts_openChat), @@ -1148,7 +1191,7 @@ class _ChatScreenState extends State { void _retryMessage(Message message) { final connector = Provider.of(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 { // 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 { message.text, ); final reactionText = 'r:$hash:$emojiIndex'; - connector.sendMessage(widget.contact, reactionText); + connector.sendMessage(_resolveContact(connector), reactionText); } } diff --git a/lib/screens/contacts_screen.dart b/lib/screens/contacts_screen.dart index 3fef9ec..243c8c4 100644 --- a/lib/screens/contacts_screen.dart +++ b/lib/screens/contacts_screen.dart @@ -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'; @@ -64,6 +65,13 @@ class _ContactsScreenState extends State super.initState(); _loadGroups(); _setupFrameListener(); + _clearAdvertNotifications(); + } + + void _clearAdvertNotifications() { + final connector = context.read(); + final contactIds = connector.contacts.map((c) => c.publicKeyHex).toList(); + NotificationService().clearAdvertNotifications(contactIds); } @override diff --git a/lib/screens/map_screen.dart b/lib/screens/map_screen.dart index 7ffec56..1dd3a5f 100644 --- a/lib/screens/map_screen.dart +++ b/lib/screens/map_screen.dart @@ -1509,6 +1509,23 @@ class _MapScreenState extends State { ); }, ), + 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), diff --git a/lib/services/message_retry_service.dart b/lib/services/message_retry_service.dart index 694a616..db4475f 100644 --- a/lib/services/message_retry_service.dart +++ b/lib/services/message_retry_service.dart @@ -44,6 +44,12 @@ class MessageRetryService extends ChangeNotifier { []; // Rolling buffer of recent ACK hashes final Map> _pendingMessageQueuePerContact = {}; // contactPubKeyHex → FIFO queue of messageIds (DEPRECATED - will be removed) + final Map> _sendQueue = + {}; // contactPubKeyHex → ordered list of messageIds awaiting send + final Set _activeMessages = + {}; // messageIds currently in-flight (sent/retrying) + final Set _resolvedMessages = + {}; // messageIds already resolved (prevents double _onMessageResolved) final Map _expectedHashToMessageId = {}; // expectedAckHashHex → messageId (for matching RESP_CODE_SENT by hash) @@ -156,7 +162,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 _attemptSend(String messageId) async { @@ -169,13 +217,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 +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 timestampSeconds = message.timestamp.millisecondsSinceEpoch ~/ 1000; @@ -231,6 +295,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 +354,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 +364,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 +381,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; } } @@ -463,22 +529,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 +550,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 +669,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 +699,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 +738,7 @@ class MessageRetryService extends ChangeNotifier { true, tripTimeMs, ); + _onMessageResolved(matchedMessageId, contact.publicKeyHex); } notifyListeners(); @@ -783,6 +876,9 @@ class MessageRetryService extends ChangeNotifier { _ackHistory.clear(); _ackHashToMessageId.clear(); _pendingMessageQueuePerContact.clear(); + _sendQueue.clear(); + _activeMessages.clear(); + _resolvedMessages.clear(); super.dispose(); } } diff --git a/lib/services/notification_service.dart b/lib/services/notification_service.dart index 95326d2..62d3796 100644 --- a/lib/services/notification_service.dart +++ b/lib/services/notification_service.dart @@ -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 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 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 clearAdvertNotifications(List contactIds) async { + if (!await _ensureInitialized()) return; + for (final id in contactIds) { + await _notifications.cancel(id: 'advert:$id'.hashCode); + } + } + + Future _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) // ─────────────────────────────────────────────────────────────────