Files
meshcore-sar_android/lib/models/message.dart
Janez T 1688d6f538 feat: Enhance message handling for drawings
- Added properties to the Message model to track drawing status and associated drawing ID.
- Updated AppProvider to mark messages as drawings and link them to their respective drawing IDs.
- Enhanced DrawingProvider to manage received drawings and their visibility.
- Implemented navigation to drawings in MapProvider, including dynamic zoom levels based on drawing size.
- Updated MessagesProvider to parse drawing messages and manage linked drawings.
- Enhanced MessagesTab to support drawing message interactions, including navigation and coordinate copying.
- Created DrawingMinimapPreview widget for visual representation of drawings in message bubbles.
- Improved DrawingToolbar to handle sharing of drawings, ensuring only unshared drawings are sent.
- Added utility functions in DrawingMessageParser for extracting drawing metadata.
2025-10-23 20:14:51 +02:00

405 lines
13 KiB
Dart

import 'dart:typed_data';
import 'package:flutter/foundation.dart';
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 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({
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,
});
/// Get SAR marker type by inferring from message content
/// Returns the type inferred from sarCustomEmoji or by parsing the message text
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);
}
/// 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;
}
// Debug: Check what's in sarNotes
debugPrint('📍 [Message.toSarMarker] Converting to marker:');
debugPrint(' message.text: "$text"');
debugPrint(' message.sarNotes: "$sarNotes"');
debugPrint(' message.sarMarkerType: $sarMarkerType');
debugPrint(' message.sarCustomEmoji: "$sarCustomEmoji"');
return SarMarker(
id: id,
type: sarMarkerType!,
location: sarGpsCoordinates!,
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
);
}
/// 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,
}) {
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,
);
}
@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;
}