mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 16:30:28 +00:00
- Updated localization files for French, Croatian, Italian, Slovenian to include translations for German, Spanish, French, and Italian. - Added new methods for handling drawing messages sent to the public channel in multiple languages. - Enhanced the Contact model to identify public channels using a dedicated method. - Implemented echo detection for public channel messages, including tracking and reporting of echoes. - Updated BLE response handling to support echo detection and tracking of sent messages. - Modified UI components to reflect new localization strings and echo statuses.
340 lines
11 KiB
Dart
340 lines
11 KiB
Dart
import 'dart:typed_data';
|
|
import 'package:latlong2/latlong.dart';
|
|
import 'sar_marker.dart';
|
|
|
|
/// 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 SarMarkerType? sarMarkerType;
|
|
final LatLng? sarGpsCoordinates;
|
|
final String? sarNotes; // Optional message/notes for SAR marker
|
|
|
|
// 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
|
|
|
|
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.sarMarkerType,
|
|
this.sarGpsCoordinates,
|
|
this.sarNotes,
|
|
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,
|
|
});
|
|
|
|
/// 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
|
|
SarMarker? toSarMarker() {
|
|
if (!isSarMarker || sarMarkerType == null || sarGpsCoordinates == null) {
|
|
return null;
|
|
}
|
|
|
|
return SarMarker(
|
|
id: id,
|
|
type: sarMarkerType!,
|
|
location: sarGpsCoordinates!,
|
|
timestamp: sentAt,
|
|
senderPublicKey: senderPublicKeyPrefix,
|
|
senderName: senderName,
|
|
notes: sarNotes, // Use dedicated notes field instead of full text
|
|
);
|
|
}
|
|
|
|
/// 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;
|
|
}
|
|
|
|
Message copyWith({
|
|
String? id,
|
|
MessageType? messageType,
|
|
Uint8List? senderPublicKeyPrefix,
|
|
int? channelIdx,
|
|
int? pathLen,
|
|
MessageTextType? textType,
|
|
int? senderTimestamp,
|
|
String? text,
|
|
bool? isSarMarker,
|
|
SarMarkerType? sarMarkerType,
|
|
LatLng? sarGpsCoordinates,
|
|
String? sarNotes,
|
|
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,
|
|
}) {
|
|
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,
|
|
sarMarkerType: sarMarkerType ?? this.sarMarkerType,
|
|
sarGpsCoordinates: sarGpsCoordinates ?? this.sarGpsCoordinates,
|
|
sarNotes: sarNotes ?? this.sarNotes,
|
|
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,
|
|
);
|
|
}
|
|
|
|
@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;
|
|
}
|