diff --git a/.gitignore b/.gitignore
index b9181133..157c7ece 100644
--- a/.gitignore
+++ b/.gitignore
@@ -30,6 +30,7 @@ migrate_working_dir/
.flutter-plugins-dependencies
.pub-cache/
.pub/
+pubspec.lock
/build/
/coverage/
@@ -65,6 +66,7 @@ secrets.dart
**/ios/Flutter/Flutter.podspec
# Android
+.gradle/
**/android/.gradle/
**/android/captures/
**/android/local.properties
diff --git a/.ruby-version b/.ruby-version
new file mode 100644
index 00000000..fcdb2e10
--- /dev/null
+++ b/.ruby-version
@@ -0,0 +1 @@
+4.0.0
diff --git a/README.md b/README.md
index bad9b6ca..10fb0a57 100644
--- a/README.md
+++ b/README.md
@@ -78,6 +78,7 @@ MeshCore Open is a cross-platform mobile application for communicating with Mesh
- ✅ **Android**: Full support (API 21+)
- ✅ **iOS**: Full support (iOS 12+)
- 🚧 **Desktop**: Limited support (macOS/Linux/Windows)
+- 🚧 **Web**: Under construction (Chrome)
### Dependencies
diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
index 740451b9..e0a80290 100644
--- a/android/app/build.gradle.kts
+++ b/android/app/build.gradle.kts
@@ -19,13 +19,13 @@ android {
ndkVersion = flutter.ndkVersion
compileOptions {
- sourceCompatibility = JavaVersion.VERSION_11
- targetCompatibility = JavaVersion.VERSION_11
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
isCoreLibraryDesugaringEnabled = true
}
kotlinOptions {
- jvmTarget = JavaVersion.VERSION_11.toString()
+ jvmTarget = JavaVersion.VERSION_17.toString()
}
defaultConfig {
@@ -83,5 +83,5 @@ flutter {
}
dependencies {
- coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.4")
+ coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
}
diff --git a/assets/icons/done_all.svg b/assets/icons/done_all.svg
new file mode 100644
index 00000000..bfeeec0d
--- /dev/null
+++ b/assets/icons/done_all.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/flake.lock b/flake.lock
new file mode 100644
index 00000000..4d0355b2
--- /dev/null
+++ b/flake.lock
@@ -0,0 +1,61 @@
+{
+ "nodes": {
+ "flake-utils": {
+ "inputs": {
+ "systems": "systems"
+ },
+ "locked": {
+ "lastModified": 1731533236,
+ "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
+ "owner": "numtide",
+ "repo": "flake-utils",
+ "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
+ "type": "github"
+ },
+ "original": {
+ "owner": "numtide",
+ "repo": "flake-utils",
+ "type": "github"
+ }
+ },
+ "nixpkgs": {
+ "locked": {
+ "lastModified": 1770562336,
+ "narHash": "sha256-ub1gpAONMFsT/GU2hV6ZWJjur8rJ6kKxdm9IlCT0j84=",
+ "owner": "nixos",
+ "repo": "nixpkgs",
+ "rev": "d6c71932130818840fc8fe9509cf50be8c64634f",
+ "type": "github"
+ },
+ "original": {
+ "owner": "nixos",
+ "ref": "nixos-unstable",
+ "repo": "nixpkgs",
+ "type": "github"
+ }
+ },
+ "root": {
+ "inputs": {
+ "flake-utils": "flake-utils",
+ "nixpkgs": "nixpkgs"
+ }
+ },
+ "systems": {
+ "locked": {
+ "lastModified": 1681028828,
+ "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
+ "owner": "nix-systems",
+ "repo": "default",
+ "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
+ "type": "github"
+ },
+ "original": {
+ "owner": "nix-systems",
+ "repo": "default",
+ "type": "github"
+ }
+ }
+ },
+ "root": "root",
+ "version": 7
+}
diff --git a/flake.nix b/flake.nix
new file mode 100644
index 00000000..16711455
--- /dev/null
+++ b/flake.nix
@@ -0,0 +1,86 @@
+{
+ description = "MeshCore Flutter Application";
+
+ inputs = {
+ nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
+ flake-utils.url = "github:numtide/flake-utils";
+ };
+
+ outputs = { self, nixpkgs, flake-utils }:
+ flake-utils.lib.eachDefaultSystem (system:
+ let
+ pkgs = nixpkgs.legacyPackages.${system};
+ in
+ {
+ devShells.default = pkgs.mkShell {
+ buildInputs = with pkgs; [
+ # Flutter and Dart
+ flutter
+ dart
+
+ # Java (required for Android development)
+ jdk17
+
+ # Android development tools
+ android-tools
+ gradle
+
+ # For the shell hook to set up the environment for Flutter development
+ gtk3
+ glib
+ sysprof
+ libclang
+ cmake
+ ninja
+ pkg-config
+ libdatrie
+
+ # Additional tools for installing Android SDK if not present
+ curl
+ unzip
+ ];
+
+ shellHook = ''
+ echo "MeshCore Flutter Development Environment"
+ export PKG_CONFIG_PATH="${pkgs.gtk3}/lib/pkgconfig:${pkgs.glib}/lib/pkgconfig:${pkgs.sysprof}/lib/pkgconfig:$PKG_CONFIG_PATH"
+ export LD_LIBRARY_PATH="${pkgs.lib.makeLibraryPath [pkgs.gtk3 pkgs.glib pkgs.sysprof pkgs.libdatrie]}:$LD_LIBRARY_PATH"
+ export CMAKE_INSTALL_PREFIX="$PWD/build/bundle"
+
+ # Setup Android SDK in home directory (standard location)
+ export ANDROID_HOME="$HOME/Android/Sdk"
+ export ANDROID_SDK_ROOT="$ANDROID_HOME"
+ export PATH="$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/platform-tools:$ANDROID_HOME/tools/bin:$PATH"
+
+ echo "Android SDK: $ANDROID_HOME"
+ echo ""
+
+ # Check if Android SDK exists and offer to download if not
+ if [ ! -d "$ANDROID_HOME" ]; then
+ echo "WARNING: Android SDK not found at $ANDROID_HOME"
+ echo ""
+ echo "To download and set up the Android SDK, run this command:"
+ echo ""
+ cat << 'EOF'
+mkdir -p ~/Android/Sdk && cd ~/Android/Sdk && \
+curl -o cmdline-tools.zip ${if pkgs.stdenv.isDarwin then "https://dl.google.com/android/repository/commandlinetools-mac-10406996_latest.zip" else "https://dl.google.com/android/repository/commandlinetools-linux-10406996_latest.zip"} && \
+unzip -q cmdline-tools.zip && \
+mkdir -p cmdline-tools/latest && \
+mv cmdline-tools/* cmdline-tools/latest/ 2>/dev/null || echo "Warning: failed to move Android cmdline-tools into 'latest' directory; please check your SDK layout." >&2 && \
+rm cmdline-tools.zip && \
+cd cmdline-tools/latest/bin && \
+yes | ./sdkmanager --sdk_root=~/Android/Sdk 'platform-tools' && \
+echo "Android SDK setup complete!"
+EOF
+ echo ""
+ echo "Then run 'flutter doctor' again to verify."
+ echo ""
+ else
+ echo "Android SDK found at $ANDROID_HOME"
+ fi
+
+ echo "To check that everything is set up correctly, run 'flutter doctor' and ensure there are no issues."
+ '';
+ };
+ }
+ );
+}
diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart
index 34ab744d..e1740461 100644
--- a/lib/connector/meshcore_connector.dart
+++ b/lib/connector/meshcore_connector.dart
@@ -5,7 +5,6 @@ import 'package:crypto/crypto.dart' as crypto;
import 'package:pointycastle/export.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
-import 'package:wakelock_plus/wakelock_plus.dart';
import '../models/channel.dart';
import '../models/channel_message.dart';
@@ -30,6 +29,7 @@ import '../storage/contact_store.dart';
import '../storage/message_store.dart';
import '../storage/unread_store.dart';
import '../utils/app_logger.dart';
+import '../utils/battery_utils.dart';
import 'meshcore_protocol.dart';
class MeshCoreUuids {
@@ -38,6 +38,42 @@ class MeshCoreUuids {
static const String txCharacteristic = "6e400003-b5a3-f393-e0a9-e50e24dcca9e";
}
+class DirectRepeater {
+ static const int maxAgeMinutes = 30; // Max age for direct repeater info
+ final int pubkeyFirstByte;
+ double snr;
+ DateTime lastUpdated;
+
+ DirectRepeater({
+ required this.pubkeyFirstByte,
+ required this.snr,
+ DateTime? lastUpdated,
+ }) : lastUpdated = lastUpdated ?? DateTime.now();
+
+ void update(double newSNR) {
+ snr = newSNR;
+ lastUpdated = DateTime.now();
+ }
+
+ int get ranking {
+ if (isStale()) {
+ return -1; // Stale repeaters get lowest rank
+ }
+ // Higher SNR gets higher rank and recency within maxAgeMinutes breaks ties.
+ final ageMs =
+ DateTime.now().millisecondsSinceEpoch -
+ lastUpdated.millisecondsSinceEpoch;
+ final maxAgeMs = maxAgeMinutes * 60 * 1000;
+ final recencyScore = (maxAgeMs - ageMs).clamp(0, maxAgeMs);
+ return ((snr - 31.75) * 1000).round() + recencyScore;
+ }
+
+ bool isStale() {
+ return DateTime.now().difference(lastUpdated) >
+ const Duration(minutes: maxAgeMinutes);
+ }
+}
+
enum MeshCoreConnectionState {
disconnected,
scanning,
@@ -46,6 +82,18 @@ enum MeshCoreConnectionState {
disconnecting,
}
+class RepeaterBatterySnapshot {
+ final int millivolts;
+ final DateTime updatedAt;
+ final String source;
+
+ const RepeaterBatterySnapshot({
+ required this.millivolts,
+ required this.updatedAt,
+ required this.source,
+ });
+}
+
class MeshCoreConnector extends ChangeNotifier {
// Message windowing to limit memory usage
static const int _messageWindowSize = 200;
@@ -66,6 +114,10 @@ class MeshCoreConnector extends ChangeNotifier {
final List _channels = [];
final Map> _conversations = {};
final Map> _channelMessages = {};
+ final List _pendingChannelSentQueue = [];
+ final List<_PendingCommandAck> _pendingGenericAckQueue = [];
+ static const String _reactionSendQueuePrefix = '__reaction_send__';
+ int _reactionSendQueueSequence = 0;
final Set _loadedConversationKeys = {};
final Map> _processedChannelReactions =
{}; // channelIndex -> Set of "targetHash_emoji"
@@ -91,9 +143,12 @@ class MeshCoreConnector extends ChangeNotifier {
int? _currentBwHz;
int? _currentSf;
int? _currentCr;
+ bool? _clientRepeat;
+ int? _firmwareVerCode;
int? _batteryMillivolts;
double? _selfLatitude;
double? _selfLongitude;
+ final List _directRepeaters = List.empty(growable: true);
bool _isLoadingContacts = false;
bool _isLoadingChannels = false;
bool _hasLoadedChannels = false;
@@ -149,6 +204,7 @@ class MeshCoreConnector extends ChangeNotifier {
final Map _contactSmazEnabled = {};
final Set _knownContactKeys = {};
final Map _contactUnreadCount = {};
+ final Map _repeaterBatterySnapshots = {};
bool _unreadStateLoaded = false;
int _cachedContactsUnreadTotal = 0;
int _cachedChannelsUnreadTotal = 0;
@@ -197,12 +253,15 @@ class MeshCoreConnector extends ChangeNotifier {
String? get selfName => _selfName;
double? get selfLatitude => _selfLatitude;
double? get selfLongitude => _selfLongitude;
+ List get directRepeaters => _directRepeaters;
int? get currentTxPower => _currentTxPower;
int? get maxTxPower => _maxTxPower;
int? get currentFreqHz => _currentFreqHz;
int? get currentBwHz => _currentBwHz;
int? get currentSf => _currentSf;
int? get currentCr => _currentCr;
+ bool? get clientRepeat => _clientRepeat;
+ int? get firmwareVerCode => _firmwareVerCode;
Map? get currentCustomVars => _currentCustomVars;
int? get batteryMillivolts => _batteryMillivolts;
int get maxContacts => _maxContacts;
@@ -215,10 +274,32 @@ class MeshCoreConnector extends ChangeNotifier {
: 0;
int? get batteryPercent => _batteryMillivolts == null
? null
- : _estimateBatteryPercent(
+ : estimateBatteryPercentFromMillivolts(
_batteryMillivolts!,
_batteryChemistryForDevice(),
);
+ RepeaterBatterySnapshot? getRepeaterBatterySnapshot(String contactKeyHex) =>
+ _repeaterBatterySnapshots[contactKeyHex];
+ int? getRepeaterBatteryMillivolts(String contactKeyHex) =>
+ _repeaterBatterySnapshots[contactKeyHex]?.millivolts;
+
+ void updateRepeaterBatterySnapshot(
+ String contactKeyHex,
+ int millivolts, {
+ String source = 'unknown',
+ }) {
+ if (contactKeyHex.isEmpty || millivolts <= 0) return;
+ final previous = _repeaterBatterySnapshots[contactKeyHex];
+ final snapshot = RepeaterBatterySnapshot(
+ millivolts: millivolts,
+ updatedAt: DateTime.now(),
+ source: source,
+ );
+ _repeaterBatterySnapshots[contactKeyHex] = snapshot;
+ if (previous?.millivolts != millivolts) {
+ notifyListeners();
+ }
+ }
String _batteryChemistryForDevice() {
final deviceId = _device?.remoteId.toString();
@@ -226,27 +307,6 @@ class MeshCoreConnector extends ChangeNotifier {
return _appSettingsService!.batteryChemistryForDevice(deviceId);
}
- int _estimateBatteryPercent(int millivolts, String chemistry) {
- final range = _batteryVoltageRange(chemistry);
- final minMv = range.$1;
- final maxMv = range.$2;
- if (millivolts <= minMv) return 0;
- if (millivolts >= maxMv) return 100;
- return (((millivolts - minMv) * 100) / (maxMv - minMv)).round();
- }
-
- (int, int) _batteryVoltageRange(String chemistry) {
- switch (chemistry) {
- case 'lifepo4':
- return (2600, 3650);
- case 'lipo':
- return (3000, 4200);
- case 'nmc':
- default:
- return (3000, 4200);
- }
- }
-
List getMessages(Contact contact) {
return _conversations[contact.publicKeyHex] ?? [];
}
@@ -638,6 +698,7 @@ class MeshCoreConnector extends ChangeNotifier {
publicKey: contact.publicKey,
name: contact.name,
type: contact.type,
+ flags: contact.flags,
pathLength: selection.hopCount >= 0
? selection.hopCount
: contact.pathLength,
@@ -687,7 +748,8 @@ class MeshCoreConnector extends ChangeNotifier {
_scanResults.clear();
for (var result in results) {
if (result.device.platformName.startsWith("MeshCore-") ||
- result.advertisementData.advName.startsWith("MeshCore-")) {
+ result.advertisementData.advName.startsWith("MeshCore-") ||
+ result.advertisementData.advName.startsWith("Whisper-")) {
_scanResults.add(result);
}
}
@@ -804,9 +866,6 @@ class MeshCoreConnector extends ChangeNotifier {
_setState(MeshCoreConnectionState.connected);
- // Enable wake lock to prevent BLE disconnection when screen turns off
- await WakelockPlus.enable();
-
await _requestDeviceInfo();
_startBatteryPolling();
final gotSelfInfo = await _waitForSelfInfo(
@@ -915,9 +974,6 @@ class MeshCoreConnector extends ChangeNotifier {
_setState(MeshCoreConnectionState.disconnecting);
_stopBatteryPolling();
- // Disable wake lock when disconnecting
- await WakelockPlus.disable();
-
await _notifySubscription?.cancel();
_notifySubscription = null;
@@ -951,7 +1007,10 @@ class MeshCoreConnector extends ChangeNotifier {
_selfName = null;
_selfLatitude = null;
_selfLongitude = null;
+ _clientRepeat = null;
+ _firmwareVerCode = null;
_batteryMillivolts = null;
+ _repeaterBatterySnapshots.clear();
_batteryRequested = false;
_awaitingSelfInfo = false;
_maxContacts = _defaultMaxContacts;
@@ -963,6 +1022,9 @@ class MeshCoreConnector extends ChangeNotifier {
_isSyncingChannels = false;
_channelSyncInFlight = false;
_hasLoadedChannels = false;
+ _pendingChannelSentQueue.clear();
+ _pendingGenericAckQueue.clear();
+ _reactionSendQueueSequence = 0;
_setState(MeshCoreConnectionState.disconnected);
if (!manual) {
@@ -970,7 +1032,11 @@ class MeshCoreConnector extends ChangeNotifier {
}
}
- Future sendFrame(Uint8List data) async {
+ Future sendFrame(
+ Uint8List data, {
+ String? channelSendQueueId,
+ bool expectsGenericAck = false,
+ }) async {
if (!isConnected || _rxCharacteristic == null) {
throw Exception("Not connected to a MeshCore device");
}
@@ -989,6 +1055,11 @@ class MeshCoreConnector extends ChangeNotifier {
data.toList(),
withoutResponse: canWriteWithoutResponse,
);
+ _trackPendingGenericAck(
+ data,
+ channelSendQueueId: channelSendQueueId,
+ expectsGenericAck: expectsGenericAck,
+ );
}
Future requestBatteryStatus({bool force = false}) async {
@@ -1144,11 +1215,78 @@ class MeshCoreConnector extends ChangeNotifier {
customPath,
pathLen,
type: contact.type,
+ flags: contact.flags,
name: contact.name,
),
);
}
+ Future setContactFavorite(Contact contact, bool isFavorite) async {
+ if (!isConnected) return;
+ final latestContact =
+ await _fetchContactSnapshotFromDevice(contact.publicKey) ?? contact;
+ final updatedFlags = isFavorite
+ ? (latestContact.flags | contactFlagFavorite)
+ : (latestContact.flags & ~contactFlagFavorite);
+
+ await sendFrame(
+ buildUpdateContactPathFrame(
+ latestContact.publicKey,
+ latestContact.path,
+ latestContact.pathLength,
+ type: latestContact.type,
+ flags: updatedFlags,
+ name: latestContact.name,
+ ),
+ );
+
+ final index = _contacts.indexWhere(
+ (c) => c.publicKeyHex == contact.publicKeyHex,
+ );
+ if (index >= 0) {
+ _contacts[index] = _contacts[index].copyWith(
+ type: latestContact.type,
+ name: latestContact.name,
+ pathLength: latestContact.pathLength,
+ path: latestContact.path,
+ flags: updatedFlags,
+ );
+ notifyListeners();
+ unawaited(_persistContacts());
+ }
+ }
+
+ Future _fetchContactSnapshotFromDevice(
+ Uint8List pubKey, {
+ Duration timeout = const Duration(seconds: 3),
+ }) async {
+ if (!isConnected) return null;
+ final expectedKeyHex = pubKeyToHex(pubKey);
+ final completer = Completer();
+
+ void finish(Contact? result) {
+ if (!completer.isCompleted) {
+ completer.complete(result);
+ }
+ }
+
+ final subscription = receivedFrames.listen((frame) {
+ if (frame.isEmpty || frame[0] != respCodeContact) return;
+ final parsed = Contact.fromFrame(frame);
+ if (parsed == null || parsed.publicKeyHex != expectedKeyHex) return;
+ finish(parsed);
+ });
+
+ final timer = Timer(timeout, () => finish(null));
+ try {
+ await getContactByKey(pubKey);
+ return await completer.future;
+ } finally {
+ timer.cancel();
+ await subscription.cancel();
+ }
+ }
+
/// Set path override for a contact (persists across contact refreshes)
/// pathLen: -1 = force flood, null = auto (use device path), >= 0 = specific path
Future setPathOverride(
@@ -1344,7 +1482,13 @@ class MeshCoreConnector extends ChangeNotifier {
notifyListeners();
// Send the reaction to the device (don't add as a visible message)
- await sendFrame(buildSendChannelTextMsgFrame(channel.index, text));
+ final reactionQueueId = _nextReactionSendQueueId();
+ _pendingChannelSentQueue.add(reactionQueueId);
+ await sendFrame(
+ buildSendChannelTextMsgFrame(channel.index, text),
+ channelSendQueueId: reactionQueueId,
+ expectsGenericAck: true,
+ );
return;
}
@@ -1354,6 +1498,7 @@ class MeshCoreConnector extends ChangeNotifier {
channel.index,
);
_addChannelMessage(channel.index, message);
+ _pendingChannelSentQueue.add(message.messageId);
notifyListeners();
final trimmed = text.trim();
@@ -1363,7 +1508,11 @@ class MeshCoreConnector extends ChangeNotifier {
(isChannelSmazEnabled(channel.index) && !isStructuredPayload)
? Smaz.encodeIfSmaller(text)
: text;
- await sendFrame(buildSendChannelTextMsgFrame(channel.index, outboundText));
+ await sendFrame(
+ buildSendChannelTextMsgFrame(channel.index, outboundText),
+ channelSendQueueId: message.messageId,
+ expectsGenericAck: true,
+ );
}
Future removeContact(Contact contact) async {
@@ -1711,6 +1860,9 @@ class MeshCoreConnector extends ChangeNotifier {
debugPrint('RX frame: code=$code len=${frame.length}');
switch (code) {
+ case respCodeOk:
+ _handleOk();
+ break;
case respCodeDeviceInfo:
_handleDeviceInfo(frame);
break;
@@ -1726,6 +1878,11 @@ class MeshCoreConnector extends ChangeNotifier {
_isLoadingContacts = true;
notifyListeners();
break;
+ case pushCodeNewAdvert:
+ debugPrint('Got New CONTACT');
+ // It's the same format as respCodeContact, so we can reuse the handler
+ _handleContact(frame);
+ break;
case respCodeContact:
debugPrint('Got CONTACT');
_handleContact(frame);
@@ -1770,6 +1927,7 @@ class MeshCoreConnector extends ChangeNotifier {
case pushCodeStatusResponse:
break;
case pushCodeLogRxData:
+ _handleRxData(frame);
_handleLogRxData(frame);
break;
case respCodeChannelInfo:
@@ -1783,11 +1941,35 @@ class MeshCoreConnector extends ChangeNotifier {
break;
case respCodeCustomVars:
_handleCustomVars(frame);
+ break;
+ // RESP_CODE_ERR is a defined firmware response (code 1), not an unknown frame.
+ case respCodeErr:
+ _handleErrorFrame(frame);
+ break;
default:
debugPrint('Unknown frame code: $code');
}
}
+ void _handleErrorFrame(Uint8List frame) {
+ final errCode = frame.length > 1 ? frame[1] : -1;
+ _appDebugLogService?.warn(
+ 'Firmware responded with error code: $errCode',
+ tag: 'Protocol',
+ );
+
+ if (_pendingGenericAckQueue.isEmpty) {
+ return;
+ }
+
+ final failedAck = _pendingGenericAckQueue.removeAt(0);
+ if (failedAck.commandCode != cmdSendChannelTxtMsg ||
+ failedAck.channelSendQueueId == null) {
+ return;
+ }
+ _pendingChannelSentQueue.remove(failedAck.channelSendQueueId);
+ }
+
void _handlePathUpdated(Uint8List frame) {
// Frame format: [0]=code, [1-32]=pub_key
if (frame.length >= 33 && _pathHistoryService != null) {
@@ -1856,6 +2038,13 @@ class MeshCoreConnector extends ChangeNotifier {
void _handleDeviceInfo(Uint8List frame) {
if (frame.length < 4) return;
+ _firmwareVerCode = frame[1];
+
+ // Parse client_repeat from firmware v9+ (byte 80)
+ if (frame.length >= 81) {
+ _clientRepeat = frame[80] != 0;
+ }
+
// Firmware reports MAX_CONTACTS / 2 for v3+ device info.
final reportedContacts = frame[2];
final reportedChannels = frame[3];
@@ -1876,8 +2065,8 @@ class MeshCoreConnector extends ChangeNotifier {
unawaited(getChannels(maxChannels: nextMaxChannels));
}
}
- notifyListeners();
}
+ notifyListeners();
}
void _handleNoMoreMessages() {
@@ -2038,6 +2227,80 @@ class MeshCoreConnector extends ChangeNotifier {
}
}
+ void _handleContactAdvert(Contact contact) {
+ if (listEquals(contact.publicKey, _selfPublicKey)) {
+ return;
+ }
+
+ if (contact.type == advTypeRepeater) {
+ _contactUnreadCount.remove(contact.publicKeyHex);
+ _unreadStore.saveContactUnreadCount(
+ Map.from(_contactUnreadCount),
+ );
+ }
+ // Check if this is a new contact
+ final isNewContact = !_knownContactKeys.contains(contact.publicKeyHex);
+ final existingIndex = _contacts.indexWhere(
+ (c) => c.publicKeyHex == contact.publicKeyHex,
+ );
+
+ if (existingIndex >= 0) {
+ final existing = _contacts[existingIndex];
+ final mergedLastMessageAt =
+ existing.lastMessageAt.isAfter(contact.lastMessageAt)
+ ? existing.lastMessageAt
+ : contact.lastMessageAt;
+
+ appLogger.info(
+ 'Refreshing contact ${contact.name}: devicePath=${contact.pathLength}, existingOverride=${existing.pathOverride}',
+ tag: 'Connector',
+ );
+
+ // CRITICAL: Preserve user's path override when contact is refreshed from device
+ _contacts[existingIndex] = contact.copyWith(
+ lastMessageAt: mergedLastMessageAt,
+ pathOverride: existing.pathOverride, // Preserve user's path choice
+ pathOverrideBytes: existing.pathOverrideBytes,
+ );
+
+ appLogger.info(
+ 'After merge: pathOverride=${_contacts[existingIndex].pathOverride}, devicePath=${_contacts[existingIndex].pathLength}',
+ tag: 'Connector',
+ );
+ } else {
+ _contacts.add(contact);
+ appLogger.info(
+ 'Added new contact ${contact.name}: pathLen=${contact.pathLength}',
+ tag: 'Connector',
+ );
+ }
+ _knownContactKeys.add(contact.publicKeyHex);
+ _loadMessagesForContact(contact.publicKeyHex);
+
+ // Add path to history if we have a valid path
+ if (_pathHistoryService != null && contact.pathLength >= 0) {
+ _pathHistoryService!.handlePathUpdated(contact);
+ }
+
+ notifyListeners();
+
+ // Show notification for new contact (advertisement)
+ if (isNewContact && _appSettingsService != null) {
+ final settings = _appSettingsService!.settings;
+ if (settings.notificationsEnabled && settings.notifyOnNewAdvert) {
+ _notificationService.showAdvertNotification(
+ contactName: contact.name,
+ contactType: contact.typeLabel,
+ contactId: contact.publicKeyHex,
+ );
+ }
+ }
+
+ if (!_isLoadingContacts) {
+ unawaited(_persistContacts());
+ }
+ }
+
Future _persistContacts() async {
await _contactStore.saveContacts(_contacts);
}
@@ -2364,6 +2627,8 @@ class MeshCoreConnector extends ChangeNotifier {
}
final label = channelName ?? _channelDisplayName(channelIndex);
+ if (_appSettingsService!.isChannelMuted(label)) return;
+
_notificationService.showChannelMessageNotification(
channelName: label,
message: message.text,
@@ -2487,8 +2752,22 @@ class MeshCoreConnector extends ChangeNotifier {
return;
}
- if (_retryService != null) {
- _retryService!.updateMessageFromSent(ackHash, timeoutMs);
+ final retryService = _retryService;
+ if (retryService != null &&
+ retryService.updateMessageFromSent(
+ ackHash,
+ timeoutMs,
+ allowQueueFallback: false,
+ )) {
+ return;
+ }
+
+ if (_markNextPendingChannelMessageSent()) {
+ return;
+ }
+
+ if (retryService != null) {
+ retryService.updateMessageFromSent(ackHash, timeoutMs);
}
} else {
// Fallback to old behavior
@@ -2505,6 +2784,64 @@ class MeshCoreConnector extends ChangeNotifier {
}
}
+ bool _markNextPendingChannelMessageSent() {
+ while (_pendingChannelSentQueue.isNotEmpty) {
+ final queuedMessageId = _pendingChannelSentQueue.removeAt(0);
+ if (_isReactionSendQueueId(queuedMessageId)) {
+ return true;
+ }
+ if (_markPendingChannelMessageSentById(queuedMessageId)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ bool _markPendingChannelMessageSentById(String messageId) {
+ for (final entry in _channelMessages.entries) {
+ final channelMessages = entry.value;
+ for (int i = channelMessages.length - 1; i >= 0; i--) {
+ final message = channelMessages[i];
+ if (message.messageId != messageId) {
+ continue;
+ }
+ if (!message.isOutgoing ||
+ message.status != ChannelMessageStatus.pending) {
+ return false;
+ }
+ channelMessages[i] = message.copyWith(
+ status: ChannelMessageStatus.sent,
+ );
+ _pendingChannelSentQueue.remove(messageId);
+ unawaited(
+ _channelMessageStore.saveChannelMessages(entry.key, channelMessages),
+ );
+ notifyListeners();
+ return true;
+ }
+ }
+ return false;
+ }
+
+ void _handleOk() {
+ if (_pendingGenericAckQueue.isEmpty) {
+ return;
+ }
+
+ final pendingAck = _pendingGenericAckQueue.removeAt(0);
+ if (pendingAck.commandCode != cmdSendChannelTxtMsg ||
+ pendingAck.channelSendQueueId == null) {
+ return;
+ }
+
+ final queueId = pendingAck.channelSendQueueId!;
+ _pendingChannelSentQueue.remove(queueId);
+ if (_isReactionSendQueueId(queueId)) {
+ return;
+ }
+ _markPendingChannelMessageSentById(queueId);
+ }
+
void _handleSendConfirmed(Uint8List frame) {
// Frame format from C++:
// [0] = PUSH_CODE_SEND_CONFIRMED
@@ -3085,18 +3422,22 @@ class MeshCoreConnector extends ChangeNotifier {
mergedPathBytes.length,
);
final newRepeatCount = existing.repeatCount + 1;
+ final promotedFromPending =
+ newRepeatCount == 1 &&
+ existing.status == ChannelMessageStatus.pending;
messages[existingIndex] = existing.copyWith(
repeatCount: newRepeatCount,
pathLength: mergedPathLength,
pathBytes: mergedPathBytes,
pathVariants: mergedPathVariants,
// Mark as sent when first repeat is heard
- status:
- newRepeatCount == 1 &&
- existing.status == ChannelMessageStatus.pending
+ status: promotedFromPending
? ChannelMessageStatus.sent
: existing.status,
);
+ if (promotedFromPending) {
+ _pendingChannelSentQueue.remove(existing.messageId);
+ }
} else {
messages.add(processedMessage);
}
@@ -3246,8 +3587,6 @@ class MeshCoreConnector extends ChangeNotifier {
}
void _handleDisconnection() {
- // Disable wake lock when connection is lost
- WakelockPlus.disable();
_stopBatteryPolling();
for (final entry in _pendingRepeaterAcks.values) {
@@ -3271,11 +3610,37 @@ class MeshCoreConnector extends ChangeNotifier {
_queuedMessageSyncInFlight = false;
_isSyncingChannels = false;
_channelSyncInFlight = false;
+ _pendingChannelSentQueue.clear();
+ _pendingGenericAckQueue.clear();
+ _reactionSendQueueSequence = 0;
_setState(MeshCoreConnectionState.disconnected);
_scheduleReconnect();
}
+ void _trackPendingGenericAck(
+ Uint8List data, {
+ String? channelSendQueueId,
+ required bool expectsGenericAck,
+ }) {
+ if (!expectsGenericAck || data.isEmpty) return;
+ _pendingGenericAckQueue.add(
+ _PendingCommandAck(
+ commandCode: data[0],
+ channelSendQueueId: channelSendQueueId,
+ ),
+ );
+ }
+
+ String _nextReactionSendQueueId() {
+ _reactionSendQueueSequence++;
+ return '$_reactionSendQueuePrefix$_reactionSendQueueSequence';
+ }
+
+ bool _isReactionSendQueueId(String queueId) {
+ return queueId.startsWith(_reactionSendQueuePrefix);
+ }
+
Map _parseKeyValueString(String input) {
final result = {};
@@ -3301,7 +3666,11 @@ class MeshCoreConnector extends ChangeNotifier {
void _handleCustomVars(Uint8List frame) {
final buf = BufferReader(frame.sublist(1));
- _currentCustomVars = _parseKeyValueString(buf.readString());
+ try {
+ _currentCustomVars = _parseKeyValueString(buf.readString());
+ } catch (e) {
+ appLogger.warn('Malformed custom vars frame: $e', tag: 'Connector');
+ }
}
void _setState(MeshCoreConnectionState newState) {
@@ -3325,6 +3694,191 @@ class MeshCoreConnector extends ChangeNotifier {
super.dispose();
}
+
+ void _handleRxData(Uint8List frame) {
+ final packet = BufferReader(frame);
+ double snr = 0.0;
+ int routeType = 0;
+ int payloadType = 0;
+ Uint8List pathBytes = Uint8List(0);
+ Uint8List payload = Uint8List(0);
+ try {
+ packet.skipBytes(1); // Skip frame type byte
+ snr = packet.readInt8() / 4.0;
+ packet.skipBytes(1); // Skip RSSI byte
+ //final rssi = packet.readByte();
+ final header = packet.readByte();
+ routeType = header & 0x03;
+ payloadType = (header >> 2) & 0x0F;
+ //final payloadVer = (header >> 6) & 0x03;
+ final pathLen = packet.readByte();
+ pathBytes = packet.readBytes(pathLen);
+ payload = packet.readBytes(packet.remaining);
+ } catch (e) {
+ appLogger.warn('Malformed RX frame: $e', tag: 'Connector');
+ return;
+ }
+
+ switch (payloadType) {
+ case payloadTypeADVERT:
+ _handlePayloadAdvertReceived(payload, pathBytes, routeType, snr);
+ break;
+ default:
+ }
+ }
+
+ void _handlePayloadAdvertReceived(
+ Uint8List frame,
+ Uint8List path,
+ int routeType,
+ double snr,
+ ) {
+ final advert = BufferReader(frame);
+ double latitude = 0.0;
+ double longitude = 0.0;
+ String name = '';
+ String contactKeyHex = '';
+ Uint8List publicKey = Uint8List(0);
+ int type = 0;
+ int timestamp = 0;
+ bool hasLocation = false;
+ bool hasName = false;
+ try {
+ publicKey = advert.readBytes(32);
+ contactKeyHex = publicKey
+ .map((b) => b.toRadixString(16).padLeft(2, '0'))
+ .join();
+
+ timestamp = advert.readInt32LE();
+ //TODO add signature verification
+ advert.skipBytes(64); // Skip signature for now
+ final flags = advert.readByte();
+ type = flags & 0x0F;
+ hasLocation = (flags & 0x10) != 0;
+ // For future use:
+ //final hasFeature1 = (flags & 0x20) != 0;
+ //final hasFeature2 = (flags & 0x40) != 0;
+ hasName = (flags & 0x80) != 0;
+ if (hasLocation && advert.remaining >= 8) {
+ latitude = advert.readInt32LE() / 1e6;
+ longitude = advert.readInt32LE() / 1e6;
+ }
+ if (hasName && advert.remaining > 0) {
+ name = advert.readString();
+ }
+ } catch (e) {
+ appLogger.warn('Malformed advert frame: $e', tag: 'Connector');
+ return;
+ }
+
+ if (listEquals(publicKey, _selfPublicKey)) {
+ return;
+ }
+
+ // Check if this is a new contact
+ final isNewContact = !_knownContactKeys.contains(contactKeyHex);
+
+ if (isNewContact) {
+ final newContact = Contact(
+ publicKey: publicKey,
+ name: name,
+ type: type,
+ pathLength: path.length,
+ path: Uint8List.fromList(
+ path.reversed.toList(),
+ ), // Store path in reverse for easier use in outgoing messages
+ latitude: latitude,
+ longitude: longitude,
+ lastSeen: DateTime.fromMillisecondsSinceEpoch(timestamp * 1000),
+ );
+ _handleContactAdvert(newContact);
+ _updateDirectRepeater(newContact, snr, path);
+ return;
+ }
+
+ final existingIndex = _contacts.indexWhere(
+ (c) => c.publicKeyHex == contactKeyHex,
+ );
+
+ if (existingIndex >= 0) {
+ final existing = _contacts[existingIndex];
+ final mergedLastMessageAt = existing.lastMessageAt.isAfter(DateTime.now())
+ ? DateTime.now()
+ : existing.lastMessageAt;
+
+ appLogger.info(
+ 'Refreshing contact ${existing.name}: devicePath=${existing.pathLength}, existingOverride=${existing.pathOverride}',
+ tag: 'Connector',
+ );
+
+ // CRITICAL: Preserve user's path override when contact is refreshed from device
+ _contacts[existingIndex] = existing.copyWith(
+ latitude: hasLocation ? latitude : existing.latitude,
+ longitude: hasLocation ? longitude : existing.longitude,
+ name: hasName ? name : existing.name,
+ path: Uint8List.fromList(path.reversed.toList()),
+ pathLength: path.length,
+ lastMessageAt: mergedLastMessageAt,
+ lastSeen: DateTime.fromMillisecondsSinceEpoch(timestamp * 1000),
+ pathOverride: existing.pathOverride, // Preserve user's path choice
+ pathOverrideBytes: existing.pathOverrideBytes,
+ );
+
+ // Add path to history if we have a valid path
+ if (_pathHistoryService != null &&
+ _contacts[existingIndex].pathLength >= 0) {
+ _pathHistoryService!.handlePathUpdated(_contacts[existingIndex]);
+ }
+
+ _updateDirectRepeater(_contacts[existingIndex], snr, path);
+
+ appLogger.info(
+ 'After merge: pathOverride=${_contacts[existingIndex].pathOverride}, devicePath=${_contacts[existingIndex].pathLength}',
+ tag: 'Connector',
+ );
+ }
+ }
+
+ void _updateDirectRepeater(Contact contact, double snr, Uint8List path) {
+ final pubkeyFirstByte = path.isNotEmpty
+ ? path.last
+ : contact.publicKey.first;
+
+ _directRepeaters.removeWhere((r) => r.isStale());
+
+ //We can use adverts from chat and sensor nodes, but only if the advert has a path to get the last hop.
+ if ((contact.type == advTypeChat || contact.type == advTypeSensor) &&
+ path.isEmpty) {
+ notifyListeners();
+ return;
+ }
+
+ final isTracked = _directRepeaters.where(
+ (r) => r.pubkeyFirstByte == pubkeyFirstByte,
+ );
+
+ final sortedRepeaters = List.from(_directRepeaters)
+ ..sort((a, b) => b.snr.compareTo(a.snr));
+ final weakestRepeater = sortedRepeaters.isNotEmpty
+ ? sortedRepeaters.last
+ : null;
+
+ if (_directRepeaters.length >= 5 &&
+ weakestRepeater != null &&
+ isTracked.isEmpty) {
+ _directRepeaters.remove(weakestRepeater);
+ }
+
+ if (isTracked.isNotEmpty) {
+ final repeater = isTracked.first;
+ repeater.update(snr);
+ } else if (_directRepeaters.length < 5) {
+ _directRepeaters.add(
+ DirectRepeater(pubkeyFirstByte: pubkeyFirstByte, snr: snr),
+ );
+ }
+ notifyListeners();
+ }
}
const int _phRouteMask = 0x03;
@@ -3382,3 +3936,10 @@ class _RepeaterAckContext {
required this.messageBytes,
});
}
+
+class _PendingCommandAck {
+ final int commandCode;
+ final String? channelSendQueueId;
+
+ _PendingCommandAck({required this.commandCode, this.channelSendQueueId});
+}
diff --git a/lib/connector/meshcore_protocol.dart b/lib/connector/meshcore_protocol.dart
index 25359a8d..d5ce9ee1 100644
--- a/lib/connector/meshcore_protocol.dart
+++ b/lib/connector/meshcore_protocol.dart
@@ -13,12 +13,22 @@ class BufferReader {
int readByte() => readBytes(1)[0];
Uint8List readBytes(int count) {
+ if (_pointer + count > _buffer.length) {
+ throw RangeError(
+ 'Attempted to read $count bytes at offset $_pointer, but only $remaining bytes remaining in buffer of length ${_buffer.length}',
+ );
+ }
final data = _buffer.sublist(_pointer, _pointer + count);
_pointer += count;
return data;
}
void skipBytes(int count) {
+ if (_pointer + count > _buffer.length) {
+ throw RangeError(
+ 'Attempted to skip $count bytes at offset $_pointer, but only $remaining bytes remaining in buffer of length ${_buffer.length}',
+ );
+ }
_pointer += count;
}
@@ -151,6 +161,7 @@ const int cmdGetContactByKey = 30;
const int cmdGetChannel = 31;
const int cmdSetChannel = 32;
const int cmdSendTracePath = 36;
+const int cmdSetOtherParams = 38;
const int cmdGetRadioSettings = 57;
const int cmdGetTelemetryReq = 39;
const int cmdGetCustomVar = 40;
@@ -166,7 +177,7 @@ const int reqTypeGetStatus = 0x01;
const int reqTypeKeepAlive = 0x02;
const int reqTypeGetTelemetry = 0x03;
const int reqTypeGetAccessList = 0x05;
-const int reqTypeGetNeighbours = 0x06;
+const int reqTypeGetNeighbors = 0x06;
// Repeater response codes
const int respServerLoginOk = 0;
@@ -212,6 +223,30 @@ const int advTypeRepeater = 2;
const int advTypeRoom = 3;
const int advTypeSensor = 4;
+// Payload Types
+const int payloadTypeREQ =
+ 0x00; // request (prefixed with dest/src hashes, MAC) (enc data: timestamp, blob)
+const int payloadTypeRESPONSE =
+ 0x01; // response to REQ or ANON_REQ (prefixed with dest/src hashes, MAC) (enc data: timestamp, blob)
+const int payloadTypeTXTMSG =
+ 0x02; // a plain text message (prefixed with dest/src hashes, MAC) (enc data: timestamp, text)
+const int payloadTypeACK = 0x03; // a simple ack
+const int payloadTypeADVERT = 0x04; // a node advertising its Identity
+const int payloadTypeGRPTXT =
+ 0x05; // an (unverified) group text message (prefixed with channel hash, MAC) (enc data: timestamp, "name: msg")
+const int payloadTypeGRPDATA =
+ 0x06; // an (unverified) group datagram (prefixed with channel hash, MAC) (enc data: timestamp, blob)
+const int payloadTypeANONREQ =
+ 0x07; // generic request (prefixed with dest_hash, ephemeral pub_key, MAC) (enc data: ...)
+const int payloadTypePATH =
+ 0x08; // returned path (prefixed with dest/src hashes, MAC) (enc data: path, extra)
+const int payloadTypeTRACE = 0x09; // trace a path, collecting SNI for each hop
+const int payloadTypeMULTIPART = 0x0A; // packet is one of a set of packets
+const int payloadTypeCONTROL = 0x0B; // a control/discovery packet
+//...
+const int payloadTypeRawCustom =
+ 0x0F; // custom packet as raw bytes, for applications with custom encryption, payloads, etc
+
// Sizes
const int pubKeySize = 32;
const int maxPathSize = 64;
@@ -255,6 +290,7 @@ int _minPositive(int a, int b) {
const int contactPubKeyOffset = 1;
const int contactTypeOffset = 33;
const int contactFlagsOffset = 34;
+const int contactFlagFavorite = 0x01;
const int contactPathLenOffset = 35;
const int contactPathOffset = 36;
const int contactNameOffset = 100;
@@ -550,18 +586,29 @@ Uint8List buildSetChannelFrame(int channelIndex, String name, Uint8List psk) {
}
// Build CMD_SET_RADIO_PARAMS frame
-// Format: [cmd][freq x4][bw x4][sf][cr]
+// Format: [cmd][freq x4][bw x4][sf][cr] (pre-v9)
+// [cmd][freq x4][bw x4][sf][cr][repeat] (firmware v9+)
// freq: frequency in Hz (300000-2500000)
// bw: bandwidth in Hz (7000-500000)
// sf: spreading factor (5-12)
// cr: coding rate (5-8)
-Uint8List buildSetRadioParamsFrame(int freqHz, int bwHz, int sf, int cr) {
+// clientRepeat: enable off-grid packet repeat (firmware v9+, omit for older)
+Uint8List buildSetRadioParamsFrame(
+ int freqHz,
+ int bwHz,
+ int sf,
+ int cr, {
+ bool? clientRepeat,
+}) {
final writer = BufferWriter();
writer.writeByte(cmdSetRadioParams);
writer.writeUInt32LE(freqHz);
writer.writeUInt32LE(bwHz);
writer.writeByte(sf);
writer.writeByte(cr);
+ if (clientRepeat != null) {
+ writer.writeByte(clientRepeat ? 1 : 0);
+ }
return writer.toBytes();
}
@@ -777,3 +824,22 @@ Uint8List buildZeroHopContact(Uint8List pubKey) {
writer.writeBytes(pubKey);
return writer.toBytes();
}
+
+// Build CMD_SET_OTHER_PARAMS frame
+// Format: [cmd][allowAutoAddContacts][allowTelemetryFlags][advertLocationPolicy][multiAcks]
+Uint8List buildSetOtherParamsFrame(
+ bool allowAutoAddContacts,
+ int allowTelemetryFlags,
+ int advertLocationPolicy,
+ int multiAcks,
+) {
+ final writer = BufferWriter();
+ writer.writeByte(cmdSetOtherParams);
+ writer.writeByte(
+ allowAutoAddContacts ? 0x00 : 0x01,
+ ); // Allow Auto Add Contacts
+ writer.writeByte(allowTelemetryFlags); // Allow Telemetry Flags
+ writer.writeByte(advertLocationPolicy); // Advertisement Location Policy
+ writer.writeByte(multiAcks); // Multi Acknowledgements
+ return writer.toBytes();
+}
diff --git a/lib/helpers/cayenne_lpp.dart b/lib/helpers/cayenne_lpp.dart
index bf9b8e77..07909e63 100644
--- a/lib/helpers/cayenne_lpp.dart
+++ b/lib/helpers/cayenne_lpp.dart
@@ -1,4 +1,6 @@
import 'dart:typed_data';
+import 'package:meshcore_open/utils/app_logger.dart';
+
import '../connector/meshcore_protocol.dart';
class CayenneLpp {
@@ -84,180 +86,192 @@ class CayenneLpp {
static List