refactor: extract BLE protocol stack into meshcore_client package

Move BLE communication layer (command queue, frame parser/builder,
protocol constants, data models) into a standalone reusable Dart package
at ../meshcore_client. App model files become thin re-export wrappers,
keeping all existing consumers working without import changes.
This commit is contained in:
Janez T
2026-02-28 14:09:30 +01:00
parent 6bd7517180
commit c39e0e1344
27 changed files with 46 additions and 5524 deletions

View File

@@ -1,40 +1 @@
import 'package:latlong2/latlong.dart';
/// Single advertisement location point in a contact's movement history
class AdvertLocation {
final LatLng location;
final DateTime timestamp;
AdvertLocation({
required this.location,
required this.timestamp,
});
/// Get friendly time ago display
String get timeAgo {
final diff = DateTime.now().difference(timestamp);
if (diff.inMinutes < 1) return 'Just now';
if (diff.inMinutes < 60) return '${diff.inMinutes}m ago';
if (diff.inHours < 24) return '${diff.inHours}h ago';
return '${diff.inDays}d ago';
}
@override
String toString() {
return 'AdvertLocation(lat: ${location.latitude.toStringAsFixed(6)}, '
'lon: ${location.longitude.toStringAsFixed(6)}, '
'time: ${timestamp.toIso8601String()})';
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is AdvertLocation &&
other.location.latitude == location.latitude &&
other.location.longitude == location.longitude &&
other.timestamp == timestamp;
}
@override
int get hashCode => Object.hash(location.latitude, location.longitude, timestamp);
}
export 'package:meshcore_client/meshcore_client.dart' show AdvertLocation;

View File

@@ -1,121 +1,2 @@
import 'dart:typed_data';
import '../services/meshcore_opcode_names.dart';
/// Decoded LOG_RX_DATA packet structure
class LogRxDataInfo {
final int? airtimeMs;
final Uint8List? senderPublicKey;
final int? ackCode;
final List<String> embeddedStrings;
final double entropy;
final bool isLikelyEncrypted;
final double? snrDb; // Signal-to-Noise Ratio in dB
final int? rssiDbm; // Received Signal Strength Indicator in dBm
LogRxDataInfo({
this.airtimeMs,
this.senderPublicKey,
this.ackCode,
this.embeddedStrings = const [],
required this.entropy,
required this.isLikelyEncrypted,
this.snrDb,
this.rssiDbm,
});
/// Get sender public key as hex string (short)
String? get senderKeyShort {
if (senderPublicKey == null || senderPublicKey!.length < 6) return null;
return senderPublicKey!
.sublist(0, 6)
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join(':');
}
String get summary {
final parts = <String>[];
if (rssiDbm != null) parts.add('RSSI:${rssiDbm}dBm');
final snr = snrDb;
if (snr != null) parts.add('SNR:${snr.toStringAsFixed(1)}dB');
if (airtimeMs != null) parts.add('airtime:${airtimeMs}ms');
if (ackCode != null) parts.add('ACK:$ackCode');
if (senderKeyShort != null) parts.add('from:$senderKeyShort');
if (embeddedStrings.isNotEmpty) parts.add('strings:${embeddedStrings.length}');
if (isLikelyEncrypted) parts.add('encrypted');
return parts.join(', ');
}
}
/// Represents a logged BLE packet with timestamp and metadata
class BlePacketLog {
final DateTime timestamp;
final Uint8List rawData;
final PacketDirection direction;
final int? responseCode;
final String? description;
final LogRxDataInfo? logRxDataInfo; // Decoded LOG_RX_DATA information
BlePacketLog({
required this.timestamp,
required this.rawData,
required this.direction,
this.responseCode,
this.description,
this.logRxDataInfo,
});
/// Convert raw data to hex string for display
String get hexData {
return rawData.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ');
}
/// Get opcode name for this packet
String get opcodeName {
if (responseCode == null) return 'N/A';
return MeshCoreOpcodeNames.getOpcodeName(
responseCode!,
isTx: direction == PacketDirection.tx,
);
}
/// Get full opcode description (name + hex code)
String get opcodeDescription {
if (responseCode == null) return 'N/A';
return MeshCoreOpcodeNames.getOpcodeDescription(
responseCode!,
isTx: direction == PacketDirection.tx,
);
}
/// Get short summary of the packet
String get summary {
final dir = direction == PacketDirection.rx ? 'RX' : 'TX';
final code = responseCode != null ? '0x${responseCode!.toRadixString(16).padLeft(2, '0')}' : 'N/A';
final name = responseCode != null ? opcodeName : '';
return '[$dir] $name Code: $code, Size: ${rawData.length} bytes';
}
/// Convert to CSV format for export
String toCsvRow() {
final dir = direction == PacketDirection.rx ? 'RX' : 'TX';
final code = responseCode?.toString() ?? '';
final name = responseCode != null ? opcodeName : '';
final hex = hexData;
final desc = description ?? '';
return '${timestamp.toIso8601String()},$dir,${rawData.length},$name,$code,"$hex","$desc"';
}
/// Convert to human-readable log format
String toLogString() {
final dir = direction == PacketDirection.rx ? 'RX' : 'TX';
final code = responseCode != null ? ' [$opcodeDescription]' : '';
final desc = description != null ? ' - $description' : '';
final logRxInfo = logRxDataInfo != null ? ' [${logRxDataInfo!.summary}]' : '';
return '${timestamp.toIso8601String()} [$dir]$code ${rawData.length} bytes: $hexData$desc$logRxInfo';
}
}
enum PacketDirection {
rx, // Received from device
tx, // Sent to device
}
export 'package:meshcore_client/meshcore_client.dart'
show BlePacketLog, PacketDirection, LogRxDataInfo;

View File

@@ -1,364 +1,17 @@
import 'dart:math';
import 'dart:typed_data';
import 'package:latlong2/latlong.dart';
export 'package:meshcore_client/meshcore_client.dart'
show Contact, ContactType, ContactTelemetry, AdvertLocation;
import 'package:flutter/material.dart';
import 'contact_telemetry.dart';
import 'advert_location.dart';
import '../l10n/app_localizations.dart';
import 'package:meshcore_client/meshcore_client.dart';
/// MeshCore contact types
enum ContactType {
none(0),
chat(1),
repeater(2),
room(3),
channel(99); // Virtual type for public channel (not from protocol)
const ContactType(this.value);
final int value;
static ContactType fromValue(int value) {
return ContactType.values.firstWhere(
(e) => e.value == value,
orElse: () => ContactType.none,
);
}
String get displayName {
switch (this) {
case ContactType.chat:
return 'Chat';
case ContactType.repeater:
return 'Repeater';
case ContactType.room:
return 'Room';
case ContactType.channel:
return 'Channel';
default:
return 'Unknown';
}
}
}
/// MeshCore contact model
class Contact {
final Uint8List publicKey;
final ContactType type;
final int flags;
final int outPathLen;
final Uint8List outPath;
final String advName;
final int lastAdvert; // Unix timestamp
final int advLat; // Latitude as int32
final int advLon; // Longitude as int32
final int lastMod; // Unix timestamp
// Telemetry data (updated separately)
ContactTelemetry? telemetry;
// Advertisement location history (most recent first)
final List<AdvertLocation> advertHistory;
// UI state tracking
final bool isNew; // Whether contact is newly added and not yet viewed
Contact({
required this.publicKey,
required this.type,
required this.flags,
required this.outPathLen,
required this.outPath,
required this.advName,
required this.lastAdvert,
required this.advLat,
required this.advLon,
required this.lastMod,
this.telemetry,
List<AdvertLocation>? advertHistory,
this.isNew = false,
}) : advertHistory = advertHistory ?? [];
/// Get public key as hex string (first 8 bytes)
String get publicKeyShort {
if (publicKey.length < 8) return '';
return publicKey.sublist(0, 8).map((b) => b.toRadixString(16).padLeft(2, '0')).join('');
}
/// Get full public key as hex string
String get publicKeyHex {
return publicKey.map((b) => b.toRadixString(16).padLeft(2, '0')).join('');
}
/// Get public key prefix (first 6 bytes) for room login matching
Uint8List get publicKeyPrefix {
if (publicKey.length < 6) return publicKey;
return publicKey.sublist(0, 6);
}
/// Convert advLat/advLon to LatLng
LatLng? get advertLocation {
if (advLat == 0 && advLon == 0) return null;
// Convert from int32 to double (degrees)
final lat = advLat / 1e6;
final lon = advLon / 1e6;
return LatLng(lat, lon);
}
/// Get display location (prefer telemetry over advert)
LatLng? get displayLocation {
if (telemetry?.gpsLocation != null && telemetry!.isRecent) {
return telemetry!.gpsLocation;
}
return advertLocation;
}
/// Get display battery (from telemetry or null)
double? get displayBattery {
return telemetry?.batteryPercentage;
}
/// Check if contact is a chat type (team member)
bool get isChat => type == ContactType.chat;
/// Check if contact is a repeater
bool get isRepeater => type == ContactType.repeater;
/// Check if contact is a room (persistent storage)
bool get isRoom => type == ContactType.room;
/// Check if contact is a channel (ephemeral broadcast)
bool get isChannel => type == ContactType.channel;
/// Get last seen time
DateTime get lastSeenTime {
return DateTime.fromMillisecondsSinceEpoch(lastAdvert * 1000);
}
/// Get last modified time
DateTime get lastModifiedTime {
return DateTime.fromMillisecondsSinceEpoch(lastMod * 1000);
}
/// Check if contact was seen recently (within last 10 minutes)
bool get isRecentlySeen {
return DateTime.now().difference(lastSeenTime).inMinutes < 10;
}
/// Get friendly time since last seen
String get timeSinceLastSeen {
final diff = DateTime.now().difference(lastSeenTime);
if (diff.inMinutes < 1) return 'Just now';
if (diff.inMinutes < 60) return '${diff.inMinutes}m ago';
if (diff.inHours < 24) return '${diff.inHours}h ago';
return '${diff.inDays}d ago';
}
/// Get time when location was last updated
DateTime? get locationUpdateTime {
// Prefer telemetry timestamp if available
if (telemetry?.gpsLocation != null) {
return telemetry!.timestamp;
}
// Fall back to lastAdvert time if using advertised location
if (advertLocation != null) {
return lastSeenTime;
}
return null;
}
/// Get friendly time since location was last updated
String get timeSinceLocationUpdate {
final updateTime = locationUpdateTime;
if (updateTime == null) return 'Unknown';
final diff = DateTime.now().difference(updateTime);
if (diff.inMinutes < 1) return 'Now';
if (diff.inMinutes < 60) return '${diff.inMinutes}m';
if (diff.inHours < 24) return '${diff.inHours}h';
return '${diff.inDays}d';
}
/// Extract role emoji from name (e.g., "🧑🏻🚒Janez" → "🧑🏻‍🚒")
/// Returns null if no emoji at start of name
String? get roleEmoji {
if (advName.isEmpty) return null;
// Get the first character/grapheme cluster (which could be a complex emoji)
final firstChar = advName.characters.first;
// Check if it's an emoji (basic check - emojis are typically in certain Unicode ranges)
final firstCodeUnit = firstChar.runes.first;
// Emoji ranges (simplified check):
// 0x1F300-0x1F9FF: Misc Symbols and Pictographs, Emoticons, Transport, etc.
// 0x2600-0x26FF: Misc symbols
// 0x2700-0x27BF: Dingbats
// 0xFE00-0xFE0F: Variation Selectors
// 0x1F900-0x1F9FF: Supplemental Symbols and Pictographs
if ((firstCodeUnit >= 0x1F300 && firstCodeUnit <= 0x1F9FF) ||
(firstCodeUnit >= 0x2600 && firstCodeUnit <= 0x27BF) ||
(firstCodeUnit >= 0x1F600 && firstCodeUnit <= 0x1F64F)) {
return firstChar;
}
return null;
}
/// Get display name without role emoji (e.g., "🧑🏻🚒Janez" → "Janez")
/// If no emoji, returns full advName
String get displayName {
final emoji = roleEmoji;
if (emoji == null) return advName;
// Remove the emoji from the beginning
return advName.substring(emoji.length).trim();
}
/// Check if this contact is the Public Channel (all-zeros public key)
bool get isPublicChannel =>
publicKeyHex == '0000000000000000000000000000000000000000000000000000000000000000';
/// Get localized display name (for Public Channel and other special contacts)
extension ContactLocalization on Contact {
/// Returns the localized display name for special contacts (e.g. Public Channel).
/// For all other contacts, returns [displayName].
String getLocalizedDisplayName(BuildContext context) {
// Check if this is the Public Channel (all-zeros public key)
if (isPublicChannel) {
return AppLocalizations.of(context)!.publicChannel;
}
// For all other contacts, use the regular display name
return displayName;
}
/// Check if contact has a learned routing path
/// When true, messages will use direct routing. When false, messages will use flood mode.
/// outPathLen: -1 = unknown/not learned, 0 = direct (zero hops), 1+ = multi-hop path
bool get hasPath => outPathLen >= 0 && outPathLen <= 64;
/// Get path description for UI display
String get pathDescription {
if (!hasPath) {
// -1 (0xFF) indicates path not learned yet
return 'No path (flood mode)';
}
// outPathLen = 0 means direct connection with zero hops
// outPathLen >= 1 means path with N hops
if (outPathLen == 0) {
return 'Direct (0 hops)';
} else if (outPathLen == 1) {
return 'Direct (1 hop)';
} else if (outPathLen <= 3) {
return 'Good path ($outPathLen hops)';
} else if (outPathLen <= 5) {
return 'Medium path ($outPathLen hops)';
} else {
return 'Long path ($outPathLen hops)';
}
}
/// Get path quality indicator (0-5 scale, higher is better)
/// -1 means no path (will use flood mode)
int get pathQuality {
if (!hasPath) return -1;
if (outPathLen == 0) return 5; // Direct connection (0 hops)
if (outPathLen == 1) return 4; // 1 hop
if (outPathLen <= 2) return 3; // 2 hops
if (outPathLen <= 3) return 2; // 3 hops
if (outPathLen <= 4) return 1; // 4 hops
return 0; // 5+ hops
}
/// Add a new advertisement location to history (maintains max 1000 points)
///
/// Implements location dithering to avoid storing redundant points:
/// - Only stores points that are ≥1 meter apart (max meter accuracy)
/// - Prevents trail clutter when contact is stationary or moving slowly
/// - Maintains chronological order (most recent first)
Contact addAdvertLocation(LatLng location, DateTime timestamp) {
final newPoint = AdvertLocation(location: location, timestamp: timestamp);
// Dithering: Skip points within 1 meter of the last recorded position
// This provides max meter accuracy while avoiding redundant data
if (advertHistory.isNotEmpty) {
final lastPoint = advertHistory.first;
final distance = _calculateDistance(lastPoint.location, location);
// If less than 1 meter apart, skip this point (location dithering)
if (distance < 1.0) {
return this;
}
}
// Add new point at the beginning (most recent first)
final updatedHistory = [newPoint, ...advertHistory];
// Keep only the most recent 1000 points to limit memory usage
final trimmedHistory = updatedHistory.length > 1000
? updatedHistory.sublist(0, 1000)
: updatedHistory;
return copyWith(advertHistory: trimmedHistory);
}
/// Calculate distance between two points in meters (Haversine formula)
double _calculateDistance(LatLng point1, LatLng point2) {
const double earthRadius = 6371000; // meters
final lat1 = point1.latitude * (pi / 180);
final lat2 = point2.latitude * (pi / 180);
final dLat = (point2.latitude - point1.latitude) * (pi / 180);
final dLon = (point2.longitude - point1.longitude) * (pi / 180);
final a = sin(dLat / 2) * sin(dLat / 2) +
cos(lat1) * cos(lat2) *
sin(dLon / 2) * sin(dLon / 2);
final c = 2 * atan2(sqrt(a), sqrt(1 - a));
return earthRadius * c;
}
Contact copyWith({
Uint8List? publicKey,
ContactType? type,
int? flags,
int? outPathLen,
Uint8List? outPath,
String? advName,
int? lastAdvert,
int? advLat,
int? advLon,
int? lastMod,
ContactTelemetry? telemetry,
List<AdvertLocation>? advertHistory,
bool? isNew,
}) {
return Contact(
publicKey: publicKey ?? this.publicKey,
type: type ?? this.type,
flags: flags ?? this.flags,
outPathLen: outPathLen ?? this.outPathLen,
outPath: outPath ?? this.outPath,
advName: advName ?? this.advName,
lastAdvert: lastAdvert ?? this.lastAdvert,
advLat: advLat ?? this.advLat,
advLon: advLon ?? this.advLon,
lastMod: lastMod ?? this.lastMod,
telemetry: telemetry ?? this.telemetry,
advertHistory: advertHistory ?? this.advertHistory,
isNew: isNew ?? this.isNew,
);
}
@override
String toString() {
return 'Contact(name: $advName, type: ${type.displayName}, key: $publicKeyShort)';
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is Contact &&
publicKeyHex == other.publicKeyHex;
}
@override
int get hashCode => publicKeyHex.hashCode;
}

View File

@@ -1,76 +1 @@
import 'package:latlong2/latlong.dart';
/// Contact telemetry data from MeshCore device
class ContactTelemetry {
final LatLng? gpsLocation;
final double? batteryPercentage;
final double? batteryMilliVolts;
final double? temperature;
final DateTime timestamp;
// Additional sensor data
final double? humidity;
final double? pressure;
final Map<String, dynamic>? extraSensorData;
ContactTelemetry({
this.gpsLocation,
this.batteryPercentage,
this.batteryMilliVolts,
this.temperature,
required this.timestamp,
this.humidity,
this.pressure,
this.extraSensorData,
});
/// Check if telemetry data is recent (within last 5 minutes)
bool get isRecent {
return DateTime.now().difference(timestamp).inMinutes < 5;
}
/// Check if battery level is low (< 20%)
bool get isLowBattery {
return batteryPercentage != null && batteryPercentage! < 20.0;
}
/// Check if battery level is critical (< 10%)
bool get isCriticalBattery {
return batteryPercentage != null && batteryPercentage! < 10.0;
}
/// Get battery status color indicator
String get batteryStatus {
if (batteryPercentage == null) return 'unknown';
if (batteryPercentage! > 50) return 'good';
if (batteryPercentage! > 20) return 'medium';
return 'low';
}
ContactTelemetry copyWith({
LatLng? gpsLocation,
double? batteryPercentage,
double? batteryMilliVolts,
double? temperature,
DateTime? timestamp,
double? humidity,
double? pressure,
Map<String, dynamic>? extraSensorData,
}) {
return ContactTelemetry(
gpsLocation: gpsLocation ?? this.gpsLocation,
batteryPercentage: batteryPercentage ?? this.batteryPercentage,
batteryMilliVolts: batteryMilliVolts ?? this.batteryMilliVolts,
temperature: temperature ?? this.temperature,
timestamp: timestamp ?? this.timestamp,
humidity: humidity ?? this.humidity,
pressure: pressure ?? this.pressure,
extraSensorData: extraSensorData ?? this.extraSensorData,
);
}
@override
String toString() {
return 'ContactTelemetry(gps: $gpsLocation, battery: $batteryPercentage%, temp: $temperature°C, time: $timestamp)';
}
}
export 'package:meshcore_client/meshcore_client.dart' show ContactTelemetry;

View File

@@ -1,264 +1,40 @@
export 'package:meshcore_client/meshcore_client.dart'
show
Message,
MessageType,
MessageTextType,
MessageDeliveryStatus,
MessageRecipient;
import 'package:flutter/foundation.dart';
import 'package:latlong2/latlong.dart';
import 'package:meshcore_client/meshcore_client.dart';
import 'sar_marker.dart';
/// Message recipient tracking for grouped messages
class MessageRecipient {
final Uint8List publicKey; // Full public key
final String displayName; // Contact display name
final MessageDeliveryStatus deliveryStatus;
final int? expectedAckTag;
final int? roundTripTimeMs;
final DateTime? deliveredAt;
final DateTime sentAt;
const MessageRecipient({
required this.publicKey,
required this.displayName,
required this.deliveryStatus,
this.expectedAckTag,
this.roundTripTimeMs,
this.deliveredAt,
required this.sentAt,
});
MessageRecipient copyWith({
Uint8List? publicKey,
String? displayName,
MessageDeliveryStatus? deliveryStatus,
int? expectedAckTag,
int? roundTripTimeMs,
DateTime? deliveredAt,
DateTime? sentAt,
}) {
return MessageRecipient(
publicKey: publicKey ?? this.publicKey,
displayName: displayName ?? this.displayName,
deliveryStatus: deliveryStatus ?? this.deliveryStatus,
expectedAckTag: expectedAckTag ?? this.expectedAckTag,
roundTripTimeMs: roundTripTimeMs ?? this.roundTripTimeMs,
deliveredAt: deliveredAt ?? this.deliveredAt,
sentAt: sentAt ?? this.sentAt,
);
}
String get publicKeyShort {
return publicKey
.sublist(0, publicKey.length < 6 ? publicKey.length : 6)
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join('');
}
}
/// Message text types from MeshCore protocol
enum MessageTextType {
plain(0),
cliData(1),
signedPlain(2);
const MessageTextType(this.value);
final int value;
static MessageTextType fromValue(int value) {
return MessageTextType.values.firstWhere(
(e) => e.value == value,
orElse: () => MessageTextType.plain,
);
}
}
/// Message type (contact, channel, or system)
enum MessageType {
contact,
channel,
system, // System messages (log entries, status updates)
}
/// Message delivery status
enum MessageDeliveryStatus {
sending, // Message is being sent
sent, // Message queued with expected ACK
delivered, // Delivery confirmed (ACK received)
failed, // Delivery failed
received, // Message received from another contact
}
/// MeshCore message model
class Message {
final String id;
final MessageType messageType;
final Uint8List? senderPublicKeyPrefix; // 6 bytes for contact messages
final int? channelIdx; // For channel messages
final int pathLen;
final MessageTextType textType;
final int senderTimestamp; // Unix timestamp
final String text;
// SAR marker data (if this is a SAR message)
final bool isSarMarker;
final LatLng? sarGpsCoordinates;
final String? sarNotes; // Optional message/notes for SAR marker
final String? sarCustomEmoji; // Custom emoji for unknown SAR marker types
final int? sarColorIndex; // Color index (0-7) from standard palette
// Display metadata
final DateTime receivedAt;
final String? senderName;
// Delivery tracking (for sent messages)
final MessageDeliveryStatus deliveryStatus;
final int? expectedAckTag; // Expected ACK/TAG from SENT response
final int? suggestedTimeoutMs; // Suggested timeout from SENT response
final int? roundTripTimeMs; // RTT from SEND_CONFIRMED
final DateTime? deliveredAt; // When delivery was confirmed
final Uint8List?
recipientPublicKey; // Full 32-byte public key of recipient (for retry)
// Retry tracking (for automatic retry with progressive timeouts)
final int retryAttempt; // Current retry attempt (0-3), 0 = first send
final DateTime? lastRetryAt; // When last retry was sent
final bool
usedFloodFallback; // Whether message fell back to flood mode after retries
// Read status tracking
final bool isRead; // Whether message has been read by user
// Echo detection for public channel messages
final int echoCount; // Number of times message was detected being rebroadcast
final DateTime? firstEchoAt; // When first echo was detected
// Drawing message tracking
final bool isDrawing; // Whether this message contains a map drawing
final String? drawingId; // ID of the associated drawing (for navigation)
// Message grouping for bulk sends (same message to multiple recipients)
final String? groupId; // Shared ID for messages in the same bulk send
final List<MessageRecipient>?
recipients; // List of recipients (for group leader message)
Message({
required this.id,
required this.messageType,
this.senderPublicKeyPrefix,
this.channelIdx,
required this.pathLen,
required this.textType,
required this.senderTimestamp,
required this.text,
this.isSarMarker = false,
this.sarGpsCoordinates,
this.sarNotes,
this.sarCustomEmoji,
this.sarColorIndex,
required this.receivedAt,
this.senderName,
this.deliveryStatus = MessageDeliveryStatus.received,
this.expectedAckTag,
this.suggestedTimeoutMs,
this.roundTripTimeMs,
this.deliveredAt,
this.recipientPublicKey,
this.retryAttempt = 0,
this.lastRetryAt,
this.usedFloodFallback = false,
this.isRead = false,
this.echoCount = 0,
this.firstEchoAt,
this.isDrawing = false,
this.drawingId,
this.groupId,
this.recipients,
});
/// Get SAR marker type by inferring from message content
/// Returns the type inferred from sarCustomEmoji or by parsing the message text
extension MessageSarExtension on Message {
/// Infer the [SarMarkerType] from stored SAR fields.
/// Returns null if this is not a SAR marker message.
SarMarkerType? get sarMarkerType {
if (!isSarMarker) return null;
// If we have a custom emoji stored, infer type from it
if (sarCustomEmoji != null && sarCustomEmoji!.isNotEmpty) {
return SarMarkerType.fromEmoji(sarCustomEmoji!);
}
// Otherwise, parse the message text to extract the emoji
final trimmed = text.trim();
if (!trimmed.startsWith('S:')) return null;
// Extract emoji from format: S:<emoji>:... or S:<emoji>:<colorIndex>:...
final parts = trimmed.split(':');
if (parts.length < 3) return null;
final emoji = parts[1];
return SarMarkerType.fromEmoji(emoji);
return SarMarkerType.fromEmoji(parts[1]);
}
/// Get sender public key as hex string
String? get senderKeyShort {
if (senderPublicKeyPrefix == null) return null;
return senderPublicKeyPrefix!
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join('');
}
/// Get sender timestamp as DateTime
DateTime get sentAt {
return DateTime.fromMillisecondsSinceEpoch(senderTimestamp * 1000);
}
/// Check if message is from a channel
bool get isChannelMessage => messageType == MessageType.channel;
/// Check if message is from a contact
bool get isContactMessage => messageType == MessageType.contact;
/// Check if message is a system message
bool get isSystemMessage => messageType == MessageType.system;
/// Get friendly time since message was sent
String get timeAgo {
final diff = DateTime.now().difference(sentAt);
if (diff.inMinutes < 1) return 'Just now';
if (diff.inMinutes < 60) return '${diff.inMinutes}m ago';
if (diff.inHours < 24) return '${diff.inHours}h ago';
return '${diff.inDays}d ago';
}
/// Get display name for sender (basic fallback without contact info)
String get displaySender {
if (senderName != null && senderName!.isNotEmpty) {
return senderName!;
}
if (senderKeyShort != null) {
return senderKeyShort!.substring(0, 8);
}
if (isChannelMessage && channelIdx != null) {
return 'Channel $channelIdx';
}
return 'Unknown';
}
/// Get rich display name for sender using contact information
/// Returns emoji + display name if available, otherwise falls back to displaySender
String getRichDisplayName(dynamic contact) {
if (contact == null) return displaySender;
// If contact has roleEmoji, use it with displayName
final roleEmoji = contact.roleEmoji;
if (roleEmoji != null && roleEmoji.isNotEmpty) {
return '$roleEmoji ${contact.displayName}';
}
// Otherwise just use advName or displayName
return contact.displayName ?? contact.advName ?? displaySender;
}
/// Convert to SAR marker if applicable
/// Convert to a [SarMarker] if this message contains SAR data.
SarMarker? toSarMarker() {
if (!isSarMarker || sarMarkerType == null || sarGpsCoordinates == null) {
return null;
}
// Debug: Check what's in sarNotes
debugPrint('📍 [Message.toSarMarker] Converting to marker:');
debugPrint(' message.text: "$text"');
debugPrint(' message.sarNotes: "$sarNotes"');
@@ -272,220 +48,9 @@ class Message {
timestamp: sentAt,
senderPublicKey: senderPublicKeyPrefix,
senderName: senderName,
notes: sarNotes, // Use dedicated notes field instead of full text
customEmoji: sarCustomEmoji, // Preserve custom emoji for unknown types
colorIndex: sarColorIndex, // Pass through color index
notes: sarNotes,
customEmoji: sarCustomEmoji,
colorIndex: sarColorIndex,
);
}
/// Get echo status text for channel messages
String get echoStatusText {
if (!isChannelMessage) return '';
if (echoCount == 0) {
return 'Broadcast (no echoes)';
} else if (echoCount == 1) {
return 'Rebroadcast by 1 node';
} else {
return 'Rebroadcast by $echoCount nodes';
}
}
/// Get friendly delivery status description
String get deliveryStatusText {
// For channel messages, show echo status instead
if (isChannelMessage && isSentMessage) {
return echoStatusText;
}
switch (deliveryStatus) {
case MessageDeliveryStatus.sending:
if (retryAttempt > 0) {
return 'Retrying ($retryAttempt/3)...';
}
return 'Sending...';
case MessageDeliveryStatus.sent:
if (retryAttempt > 0) {
return 'Sent (retry $retryAttempt)';
}
return 'Sent';
case MessageDeliveryStatus.delivered:
final rttText = roundTripTimeMs != null ? '${roundTripTimeMs}ms' : '';
if (retryAttempt > 0 && rttText.isNotEmpty) {
return 'Delivered ($rttText) [retry $retryAttempt]';
} else if (retryAttempt > 0) {
return 'Delivered [retry $retryAttempt]';
} else if (rttText.isNotEmpty) {
return 'Delivered ($rttText)';
}
return 'Delivered';
case MessageDeliveryStatus.failed:
if (usedFloodFallback) {
return 'Failed (tried flood)';
}
if (retryAttempt > 0) {
final retryWord = retryAttempt == 1 ? 'retry' : 'retries';
return 'Failed (after $retryAttempt $retryWord)';
}
return 'Failed';
case MessageDeliveryStatus.received:
return '';
}
}
/// Check if this is a sent message (not received)
bool get isSentMessage => deliveryStatus != MessageDeliveryStatus.received;
/// Check if this message is from self (own message)
/// [selfPublicKey] - the device's own public key (first 6 bytes)
bool isFromSelf(Uint8List? selfPublicKey) {
if (selfPublicKey == null || selfPublicKey.length < 6) return false;
// Compare sender public key prefix with self public key prefix
if (senderPublicKeyPrefix != null && senderPublicKeyPrefix!.length >= 6) {
return senderPublicKeyPrefix![0] == selfPublicKey[0] &&
senderPublicKeyPrefix![1] == selfPublicKey[1] &&
senderPublicKeyPrefix![2] == selfPublicKey[2] &&
senderPublicKeyPrefix![3] == selfPublicKey[3] &&
senderPublicKeyPrefix![4] == selfPublicKey[4] &&
senderPublicKeyPrefix![5] == selfPublicKey[5];
}
return false;
}
/// Get drawing metadata from message text (returns null if not a drawing)
/// Extracts basic info for display in message bubbles
Map<String, dynamic>? get drawingMetadata {
if (!isDrawing || !text.startsWith('D:')) return null;
try {
// Return basic metadata (actual parsing happens in DrawingMessageParser)
return {'hasDrawing': true, 'drawingId': drawingId};
} catch (e) {
return null;
}
}
Message copyWith({
String? id,
MessageType? messageType,
Uint8List? senderPublicKeyPrefix,
int? channelIdx,
int? pathLen,
MessageTextType? textType,
int? senderTimestamp,
String? text,
bool? isSarMarker,
LatLng? sarGpsCoordinates,
String? sarNotes,
String? sarCustomEmoji,
int? sarColorIndex,
DateTime? receivedAt,
String? senderName,
MessageDeliveryStatus? deliveryStatus,
int? expectedAckTag,
int? suggestedTimeoutMs,
int? roundTripTimeMs,
DateTime? deliveredAt,
Uint8List? recipientPublicKey,
int? retryAttempt,
DateTime? lastRetryAt,
bool? usedFloodFallback,
bool? isRead,
int? echoCount,
DateTime? firstEchoAt,
bool? isDrawing,
String? drawingId,
String? groupId,
List<MessageRecipient>? recipients,
}) {
return Message(
id: id ?? this.id,
messageType: messageType ?? this.messageType,
senderPublicKeyPrefix:
senderPublicKeyPrefix ?? this.senderPublicKeyPrefix,
channelIdx: channelIdx ?? this.channelIdx,
pathLen: pathLen ?? this.pathLen,
textType: textType ?? this.textType,
senderTimestamp: senderTimestamp ?? this.senderTimestamp,
text: text ?? this.text,
isSarMarker: isSarMarker ?? this.isSarMarker,
sarGpsCoordinates: sarGpsCoordinates ?? this.sarGpsCoordinates,
sarNotes: sarNotes ?? this.sarNotes,
sarCustomEmoji: sarCustomEmoji ?? this.sarCustomEmoji,
sarColorIndex: sarColorIndex ?? this.sarColorIndex,
receivedAt: receivedAt ?? this.receivedAt,
senderName: senderName ?? this.senderName,
deliveryStatus: deliveryStatus ?? this.deliveryStatus,
expectedAckTag: expectedAckTag ?? this.expectedAckTag,
suggestedTimeoutMs: suggestedTimeoutMs ?? this.suggestedTimeoutMs,
roundTripTimeMs: roundTripTimeMs ?? this.roundTripTimeMs,
deliveredAt: deliveredAt ?? this.deliveredAt,
recipientPublicKey: recipientPublicKey ?? this.recipientPublicKey,
retryAttempt: retryAttempt ?? this.retryAttempt,
lastRetryAt: lastRetryAt ?? this.lastRetryAt,
usedFloodFallback: usedFloodFallback ?? this.usedFloodFallback,
isRead: isRead ?? this.isRead,
echoCount: echoCount ?? this.echoCount,
firstEchoAt: firstEchoAt ?? this.firstEchoAt,
isDrawing: isDrawing ?? this.isDrawing,
drawingId: drawingId ?? this.drawingId,
groupId: groupId ?? this.groupId,
recipients: recipients ?? this.recipients,
);
}
/// Check if this is a grouped message (sent to multiple recipients)
bool get isGroupedMessage =>
groupId != null && recipients != null && recipients!.isNotEmpty;
/// Get count of recipients who have received/delivered the message
int get deliveredRecipientsCount {
if (recipients == null) return 0;
return recipients!
.where((r) => r.deliveryStatus == MessageDeliveryStatus.delivered)
.length;
}
/// Get count of recipients who are pending (sending/sent)
int get pendingRecipientsCount {
if (recipients == null) return 0;
return recipients!
.where(
(r) =>
r.deliveryStatus == MessageDeliveryStatus.sending ||
r.deliveryStatus == MessageDeliveryStatus.sent,
)
.length;
}
/// Get count of recipients who failed to receive
int get failedRecipientsCount {
if (recipients == null) return 0;
return recipients!
.where((r) => r.deliveryStatus == MessageDeliveryStatus.failed)
.length;
}
@override
String toString() {
if (isSarMarker) {
return 'Message(SAR: ${sarMarkerType?.displayName}, from: $displaySender)';
}
return 'Message(from: $displaySender, text: ${text.length > 30 ? '${text.substring(0, 30)}...' : text})';
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is Message && id == other.id;
}
@override
int get hashCode => id.hashCode;
}

View File

@@ -1,83 +1 @@
import 'dart:typed_data';
/// Tracks sent public channel messages for echo detection
///
/// When a message is sent to the public channel, it's encrypted with AES128-ECB
/// which is deterministic. When another node receives and rebroadcasts it,
/// the raw packet will be byte-for-byte identical. We can detect these echoes
/// by comparing the raw packet data from PUSH_CODE_LOG_RX_DATA (0x88) against
/// packets we've sent.
class SentMessageTracker {
/// Unique identifier for the message (timestamp-based)
final String messageId;
/// SHA256 hash of the encrypted packet for fast O(1) lookup
final String packetHashHex;
/// Original raw encrypted packet bytes (for verification)
final Uint8List? rawPacket;
/// When the message was sent
final DateTime sentTime;
/// When this tracker expires (default: 5 minutes)
final DateTime expiryTime;
/// Number of times we've detected this message being rebroadcast
int echoCount;
/// Unique echo paths detected (SNR/RSSI signatures)
/// Format: "snr_rssi" e.g., "20_-56" means SNR=5.0dB (20/4), RSSI=-56dBm
final Set<String> uniqueEchoPaths;
/// Timestamps when echoes were detected
final List<DateTime> echoTimestamps;
SentMessageTracker({
required this.messageId,
required this.packetHashHex,
this.rawPacket,
required this.sentTime,
required this.expiryTime,
this.echoCount = 0,
Set<String>? uniqueEchoPaths,
List<DateTime>? echoTimestamps,
}) : uniqueEchoPaths = uniqueEchoPaths ?? {},
echoTimestamps = echoTimestamps ?? [];
/// Check if this tracker has expired
bool get isExpired => DateTime.now().isAfter(expiryTime);
/// Time until expiry
Duration get timeUntilExpiry => expiryTime.difference(DateTime.now());
/// Add an echo detection
void addEcho(int snrRaw, int rssiDbm) {
echoCount++;
uniqueEchoPaths.add('${snrRaw}_$rssiDbm');
echoTimestamps.add(DateTime.now());
}
/// Get the SNR in dB from raw value
static double snrRawToDb(int snrRaw) {
return snrRaw.toSigned(8) / 4.0;
}
/// Get formatted echo statistics
String get echoStats {
if (echoCount == 0) return 'No echoes detected';
if (echoCount == 1) return '1 echo from ${uniqueEchoPaths.length} path(s)';
return '$echoCount echoes from ${uniqueEchoPaths.length} path(s)';
}
/// Get average time to first echo
Duration? get timeToFirstEcho {
if (echoTimestamps.isEmpty) return null;
return echoTimestamps.first.difference(sentTime);
}
@override
String toString() {
return 'SentMessageTracker(id=$messageId, echoes=$echoCount, paths=${uniqueEchoPaths.length}, expired=$isExpired)';
}
}
export 'package:meshcore_client/meshcore_client.dart' show SentMessageTracker;