Compare commits

...

8 Commits

Author SHA1 Message Date
Janez T
30cac1bcbf Remove 100ms BLE command delay 2026-03-07 09:30:10 +01:00
Janez T
95e6aa35e2 Remove 100ms BLE command delay 2026-03-07 09:26:13 +01:00
Janez T
2bc0e21cf0 Remove 100ms BLE command delay 2026-03-07 09:23:29 +01:00
Janez T
f6c5a3a4ca Add transmission timing details 2026-03-07 09:05:55 +01:00
Janez T
810897d348 Refactor message header pill 2026-03-07 08:39:32 +01:00
Janez T
bb1c1f455d Refine message composer layout 2026-03-07 08:31:09 +01:00
Janez T
b0eded3780 Fix message bubble layout 2026-03-07 08:28:22 +01:00
Janez T
f1d56e2000 Refactor messages tab UI 2026-03-07 08:16:28 +01:00
38 changed files with 3318 additions and 1700 deletions

View File

@@ -23,6 +23,7 @@
<!-- Microphone for voice messages -->
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.CAMERA" />
<!-- Notifications -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />

View File

@@ -489,7 +489,7 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = 94;
CURRENT_PROJECT_VERSION = 95;
DEVELOPMENT_TEAM = JND55328G8;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@@ -511,7 +511,7 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 94;
CURRENT_PROJECT_VERSION = 95;
DEVELOPMENT_TEAM = JND55328G8;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
@@ -530,7 +530,7 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 94;
CURRENT_PROJECT_VERSION = 95;
DEVELOPMENT_TEAM = JND55328G8;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
@@ -547,7 +547,7 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 94;
CURRENT_PROJECT_VERSION = 95;
DEVELOPMENT_TEAM = JND55328G8;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
@@ -679,7 +679,7 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = 94;
CURRENT_PROJECT_VERSION = 95;
DEVELOPMENT_TEAM = JND55328G8;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@@ -702,7 +702,7 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = 94;
CURRENT_PROJECT_VERSION = 95;
DEVELOPMENT_TEAM = JND55328G8;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;

View File

@@ -43,7 +43,7 @@
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>94</string>
<string>95</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSBluetoothAlwaysUsageDescription</key>

View File

@@ -5,22 +5,22 @@
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000555">
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000239">
</testcase>
<testcase classname="fastlane.lanes" name="1: increment_build_number" time="0.42343">
<testcase classname="fastlane.lanes" name="1: increment_build_number" time="0.427601">
</testcase>
<testcase classname="fastlane.lanes" name="2: build_app" time="91.987634">
<testcase classname="fastlane.lanes" name="2: build_app" time="110.117264">
</testcase>
<testcase classname="fastlane.lanes" name="3: upload_to_app_store" time="184.536223">
<testcase classname="fastlane.lanes" name="3: upload_to_app_store" time="269.473315">
</testcase>

View File

@@ -0,0 +1,86 @@
const int _transmitEstimateToleranceMs = 1500;
int? sanitizeEstimatedTransmitMs({
required int? estimatedTransmitMs,
required int? senderToReceiptMs,
}) {
if (estimatedTransmitMs == null || estimatedTransmitMs <= 0) {
return null;
}
if (senderToReceiptMs == null || senderToReceiptMs <= 0) {
return estimatedTransmitMs;
}
// Sender timestamps are second-granularity, so allow a small cushion before
// treating the estimate as impossible for the observed delivery time.
if (estimatedTransmitMs > senderToReceiptMs + _transmitEstimateToleranceMs) {
return null;
}
return estimatedTransmitMs;
}
class MessageReceptionDetails {
final DateTime capturedAt;
final DateTime? packetLoggedAt;
final int? rssiDbm;
final double? snrDb;
final List<int>? pathBytes;
final int? senderToReceiptMs;
final int? estimatedTransmitMs;
final int? postTransmitDelayMs;
const MessageReceptionDetails({
required this.capturedAt,
this.packetLoggedAt,
this.rssiDbm,
this.snrDb,
this.pathBytes,
this.senderToReceiptMs,
this.estimatedTransmitMs,
this.postTransmitDelayMs,
});
String? get pathBytesHex => pathBytes == null || pathBytes!.isEmpty
? null
: pathBytes!.map((b) => b.toRadixString(16).padLeft(2, '0')).join(':');
Map<String, dynamic> toJson() {
return {
'capturedAtMillis': capturedAt.millisecondsSinceEpoch,
'packetLoggedAtMillis': packetLoggedAt?.millisecondsSinceEpoch,
'rssiDbm': rssiDbm,
'snrDb': snrDb,
'pathBytes': pathBytes,
'senderToReceiptMs': senderToReceiptMs,
'estimatedTransmitMs': estimatedTransmitMs,
'postTransmitDelayMs': postTransmitDelayMs,
};
}
static MessageReceptionDetails? fromJson(Map<String, dynamic> json) {
final capturedAtMillis = json['capturedAtMillis'];
if (capturedAtMillis is! int) {
return null;
}
final pathBytes = json['pathBytes'];
return MessageReceptionDetails(
capturedAt: DateTime.fromMillisecondsSinceEpoch(capturedAtMillis),
packetLoggedAt: json['packetLoggedAtMillis'] is int
? DateTime.fromMillisecondsSinceEpoch(
json['packetLoggedAtMillis'] as int,
)
: null,
rssiDbm: json['rssiDbm'] as int?,
snrDb: (json['snrDb'] as num?)?.toDouble(),
pathBytes: pathBytes is List
? pathBytes.whereType<num>().map((b) => b.toInt()).toList()
: null,
senderToReceiptMs: json['senderToReceiptMs'] as int?,
estimatedTransmitMs: json['estimatedTransmitMs'] as int?,
postTransmitDelayMs: json['postTransmitDelayMs'] as int?,
);
}
}

View File

@@ -1,4 +1,5 @@
import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'connection_provider.dart';
@@ -16,9 +17,12 @@ import '../services/packet_capture_storage_service.dart';
import '../models/contact.dart';
import '../models/message.dart';
import '../models/ble_packet_log.dart';
import '../models/message_reception_details.dart';
import '../utils/drawing_message_parser.dart';
import '../utils/raw_route_probe.dart';
import '../utils/voice_message_parser.dart';
import '../utils/image_message_parser.dart';
import '../utils/message_airtime_estimator.dart';
/// Main App Provider - coordinates all other providers
class AppProvider with ChangeNotifier {
@@ -64,10 +68,10 @@ class AppProvider with ChangeNotifier {
final Map<String, String> _imageSessionSenderKey6 = {};
final Map<String, Timer> _voiceMissingRetryTimers = {};
final Map<String, int> _voiceMissingRetryAttempts = {};
final FragmentAckWaitRegistry _voiceFragmentAckWaiters =
FragmentAckWaitRegistry();
final FragmentAckWaitRegistry _imageFragmentAckWaiters =
FragmentAckWaitRegistry();
final Map<String, Timer> _imageMissingRetryTimers = {};
final Map<String, int> _imageMissingRetryAttempts = {};
final FragmentAckWaitRegistry _rawProbeWaiters = FragmentAckWaitRegistry();
final Map<String, Future<bool>> _pendingRawRouteProbes = {};
Timer? _packetCaptureFlushTimer;
String? _lastPersistedPacketSignature;
bool _isPersistingPacketCapture = false;
@@ -460,27 +464,6 @@ class AppProvider with ChangeNotifier {
payload: payload,
);
};
voiceProvider.waitForFragmentAckCallback =
({
required sessionId,
required index,
timeout = const Duration(seconds: 8),
}) => _waitForVoiceFragmentAck(
sessionId: sessionId,
index: index,
timeout: timeout,
);
imageProvider.waitForFragmentAckCallback =
({
required sessionId,
required index,
timeout = const Duration(seconds: 8),
}) => _waitForImageFragmentAck(
sessionId: sessionId,
index: index,
timeout: timeout,
);
// When a contact is received from BLE
connectionProvider.onContactReceived = (contact) {
// Pass device public key to filter out our own contact
@@ -631,6 +614,9 @@ class AppProvider with ChangeNotifier {
capturedAt: enrichedMessage.receivedAt,
)
: null;
final receptionDetailsSnapshot = _buildReceptionDetailsSnapshot(
enrichedMessage,
);
// Check if message is a drawing broadcast
if (DrawingMessageParser.isDrawingMessage(enrichedMessage.text)) {
@@ -662,6 +648,7 @@ class AppProvider with ChangeNotifier {
updatedMessage,
contactLookup: (name) => '',
contactLocationSnapshot: contactLocationSnapshot,
receptionDetailsSnapshot: receptionDetailsSnapshot,
);
// Broadcast drawing message to SSE clients if server is running
@@ -698,6 +685,7 @@ class AppProvider with ChangeNotifier {
}
},
contactLocationSnapshot: contactLocationSnapshot,
receptionDetailsSnapshot: receptionDetailsSnapshot,
);
connectionProvider.broadcastMessageToSseClients(enrichedMessage);
return;
@@ -726,6 +714,7 @@ class AppProvider with ChangeNotifier {
}
},
contactLocationSnapshot: contactLocationSnapshot,
receptionDetailsSnapshot: receptionDetailsSnapshot,
);
connectionProvider.broadcastMessageToSseClients(enrichedMessage);
return;
@@ -763,12 +752,16 @@ class AppProvider with ChangeNotifier {
}
},
contactLocationSnapshot: contactLocationSnapshot,
receptionDetailsSnapshot: receptionDetailsSnapshot,
);
// Broadcast message to SSE clients if server is running
connectionProvider.broadcastMessageToSseClients(enrichedMessage);
};
// Keep a compact receive-time snapshot because packet logs roll over.
// This lets the UI still show timing/link details after app restarts.
// When telemetry is received via PUSH_CODE_TELEMETRY_RESPONSE (0x8B)
// Used by older firmware versions for telemetry responses
connectionProvider.onTelemetryReceived = (publicKey, lppData) {
@@ -794,11 +787,30 @@ class AppProvider with ChangeNotifier {
// Magic 0x72 'r' = voice fetch request; 0x69 'i' = image fetch request.
// Magic 0x56 'V' = voice packet; magic 0x49 'I' = image packet.
connectionProvider.onRawDataReceived = (payload, snrRaw, rssiDbm) {
final rawProbeRequest = RawRouteProbeRequest.tryParseBinary(payload);
if (rawProbeRequest != null) {
debugPrint(
'📡 [AppProvider] Incoming raw route probe: nonce=${rawProbeRequest.nonce.toRadixString(16)} requester=${rawProbeRequest.requesterKey6}',
);
_handleRawRouteProbeRequest(rawProbeRequest);
return;
}
final rawProbeAck = RawRouteProbeAck.tryParseBinary(payload);
if (rawProbeAck != null) {
debugPrint(
'📡 [AppProvider] Incoming raw route probe ACK: nonce=${rawProbeAck.nonce.toRadixString(16)}',
);
_completeRawRouteProbeAck(rawProbeAck.nonce);
return;
}
final voiceFetchRequest = VoiceFetchRequest.tryParseBinary(payload);
if (voiceFetchRequest != null) {
final requester = contactsProvider.findContactByPrefixHex(
voiceFetchRequest.requesterKey6,
debugPrint(
'🎙️ [AppProvider] Incoming voice fetch request: session=${voiceFetchRequest.sessionId} want=${voiceFetchRequest.want} requester=${voiceFetchRequest.requesterKey6}',
);
final requester = _resolveVoiceFetchRequester(voiceFetchRequest);
if (requester == null) {
debugPrint(
'⚠️ [AppProvider] Voice fetch requester contact not found (binary)',
@@ -878,18 +890,6 @@ class AppProvider with ChangeNotifier {
return;
}
final voiceAck = VoiceFragmentAck.tryParseBinary(payload);
if (voiceAck != null) {
_completeVoiceFragmentAck(voiceAck.sessionId, voiceAck.index);
return;
}
final imageAck = ImageFragmentAck.tryParseBinary(payload);
if (imageAck != null) {
_completeImageFragmentAck(imageAck.sessionId, imageAck.index);
return;
}
if (ImagePacket.isImageBinary(payload)) {
final frag = ImagePacket.tryParseBinary(payload);
if (frag == null) return;
@@ -900,7 +900,10 @@ class AppProvider with ChangeNotifier {
width: session?.width ?? 0,
height: session?.height ?? 0,
);
_sendImageFragmentAck(frag);
_scheduleImageMissingRetry(
frag.sessionId,
justComplete: imageProvider.isComplete(frag.sessionId),
);
return;
}
@@ -909,7 +912,6 @@ class AppProvider with ChangeNotifier {
if (pkt == null) return;
debugPrint('🎙️ [AppProvider] Binary voice packet received: $pkt');
final justComplete = voiceProvider.addPacket(pkt);
_sendVoiceFragmentAck(pkt);
_scheduleVoiceMissingRetry(pkt.sessionId, justComplete: justComplete);
// Insert or update the placeholder message in the chat list
_handleIncomingVoicePacket(pkt, justComplete: justComplete);
@@ -1268,15 +1270,51 @@ class AppProvider with ChangeNotifier {
return contactsProvider.findContactByPrefixHex(prefixHex.toLowerCase());
}
Contact? _resolveVoiceFetchRequester(VoiceFetchRequest request) {
final liveContact = _resolveContactByPrefixHex(request.requesterKey6);
if (liveContact != null) {
return liveContact;
}
return _resolveRequesterFromSentMessages(
sessionId: request.sessionId,
requesterKey6: request.requesterKey6,
tryParseEnvelope: VoiceEnvelope.tryParseText,
mediaLabel: 'voice',
);
}
Contact? _resolveImageFetchRequester(ImageFetchRequest request) {
final liveContact = _resolveContactByPrefixHex(request.requesterKey6);
if (liveContact != null) {
return liveContact;
}
return _resolveRequesterFromSentMessages(
sessionId: request.sessionId,
requesterKey6: request.requesterKey6,
tryParseEnvelope: ImageEnvelope.tryParse,
mediaLabel: 'image',
);
}
Contact? _resolveRequesterFromSentMessages<T>({
required String sessionId,
required String requesterKey6,
required T? Function(String text) tryParseEnvelope,
required String mediaLabel,
}) {
for (final message in messagesProvider.messages.reversed) {
final envelope = ImageEnvelope.tryParse(message.text);
if (envelope == null || envelope.sessionId != request.sessionId) {
final envelope = tryParseEnvelope(message.text);
if (envelope == null) {
continue;
}
final envelopeSessionId = switch (envelope) {
VoiceEnvelope voiceEnvelope => voiceEnvelope.sessionId,
ImageEnvelope imageEnvelope => imageEnvelope.sessionId,
_ => null,
};
if (envelopeSessionId != sessionId) {
continue;
}
final recipientKey = message.recipientPublicKey;
@@ -1291,12 +1329,13 @@ class AppProvider with ChangeNotifier {
.sublist(0, 6)
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join();
if (recipientKey6 != request.requesterKey6) {
if (recipientKey6 != requesterKey6) {
continue;
}
debugPrint(
'📷 [AppProvider] Resolved image requester from sent message metadata '
'for session ${request.sessionId}: ${recipient.advName}',
'${mediaLabel == 'voice' ? '🎙️' : '📷'} [AppProvider] Resolved '
'$mediaLabel requester from sent message metadata for session '
'$sessionId: ${recipient.advName}',
);
return recipient;
}
@@ -1308,9 +1347,12 @@ class AppProvider with ChangeNotifier {
String sessionId, {
required bool justComplete,
}) {
if (voiceProvider.isReceiveCanceled(sessionId)) {
_clearVoiceMissingRetry(sessionId);
return;
}
if (justComplete || voiceProvider.isComplete(sessionId)) {
_voiceMissingRetryTimers.remove(sessionId)?.cancel();
_voiceMissingRetryAttempts.remove(sessionId);
_clearVoiceMissingRetry(sessionId);
return;
}
@@ -1321,10 +1363,43 @@ class AppProvider with ChangeNotifier {
});
}
void _scheduleImageMissingRetry(
String sessionId, {
required bool justComplete,
}) {
if (imageProvider.isReceiveCanceled(sessionId)) {
_clearImageMissingRetry(sessionId);
return;
}
if (justComplete || imageProvider.isComplete(sessionId)) {
_clearImageMissingRetry(sessionId);
return;
}
_imageMissingRetryAttempts[sessionId] = 0;
_imageMissingRetryTimers[sessionId]?.cancel();
_imageMissingRetryTimers[sessionId] = Timer(_packetRetryDelay, () {
unawaited(_requestMissingImageFragments(sessionId));
});
}
void _clearVoiceMissingRetry(String sessionId) {
_voiceMissingRetryTimers.remove(sessionId)?.cancel();
_voiceMissingRetryAttempts.remove(sessionId);
}
void _clearImageMissingRetry(String sessionId) {
_imageMissingRetryTimers.remove(sessionId)?.cancel();
_imageMissingRetryAttempts.remove(sessionId);
}
Future<void> _requestMissingVoicePackets(String sessionId) async {
if (voiceProvider.isReceiveCanceled(sessionId)) {
_clearVoiceMissingRetry(sessionId);
return;
}
if (voiceProvider.isComplete(sessionId)) {
_voiceMissingRetryTimers.remove(sessionId)?.cancel();
_voiceMissingRetryAttempts.remove(sessionId);
_clearVoiceMissingRetry(sessionId);
return;
}
@@ -1333,7 +1408,7 @@ class AppProvider with ChangeNotifier {
debugPrint(
'⚠️ [AppProvider] Voice re-request limit reached for $sessionId',
);
_voiceMissingRetryTimers.remove(sessionId)?.cancel();
_clearVoiceMissingRetry(sessionId);
return;
}
@@ -1345,8 +1420,7 @@ class AppProvider with ChangeNotifier {
final missing = voiceProvider.missingPacketIndices(sessionId);
if (missing.isEmpty) {
_voiceMissingRetryTimers.remove(sessionId)?.cancel();
_voiceMissingRetryAttempts.remove(sessionId);
_clearVoiceMissingRetry(sessionId);
return;
}
@@ -1381,6 +1455,67 @@ class AppProvider with ChangeNotifier {
});
}
Future<void> _requestMissingImageFragments(String sessionId) async {
if (imageProvider.isReceiveCanceled(sessionId)) {
_clearImageMissingRetry(sessionId);
return;
}
if (imageProvider.isComplete(sessionId)) {
_clearImageMissingRetry(sessionId);
return;
}
final attempt = _imageMissingRetryAttempts[sessionId] ?? 0;
if (attempt >= _maxPacketRetryAttempts) {
debugPrint(
'⚠️ [AppProvider] Image re-request limit reached for $sessionId',
);
_clearImageMissingRetry(sessionId);
return;
}
final senderKey6 = _imageSessionSenderKey6[sessionId];
if (senderKey6 == null) return;
final sender = _resolveContactByPrefixHex(senderKey6);
final deviceKey = connectionProvider.deviceInfo.publicKey;
if (sender == null || deviceKey == null || deviceKey.length < 6) return;
final missing = imageProvider.missingFragmentIndices(sessionId);
if (missing.isEmpty) {
_clearImageMissingRetry(sessionId);
return;
}
final requesterKey6 = deviceKey
.sublist(0, 6)
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join('');
final request = ImageFetchRequest(
sessionId: sessionId,
want: 'missing',
missingIndices: missing,
requesterKey6: requesterKey6,
timestampSec: DateTime.now().millisecondsSinceEpoch ~/ 1000,
);
try {
await connectionProvider.sendRawVoicePacket(
contactPath: sender.outPath,
contactPathLen: sender.outPathLen,
payload: request.encodeBinary(),
);
} catch (_) {
return;
}
_imageMissingRetryAttempts[sessionId] = attempt + 1;
_imageMissingRetryTimers[sessionId]?.cancel();
_imageMissingRetryTimers[sessionId] = Timer(_packetRetryDelay, () {
unawaited(_requestMissingImageFragments(sessionId));
});
}
/// Insert or update a voice placeholder message for binary raw-data packets.
///
/// Binary voice packets arrive without a chat message, so we synthesise one
@@ -1421,94 +1556,206 @@ class AppProvider with ChangeNotifier {
messagesProvider.addMessage(placeholder, contactLookup: (_) => '');
}
String _fragmentAckKey(String sessionId, int index) => '$sessionId:$index';
String _rawProbeKey(int nonce) =>
nonce.toRadixString(16).padLeft(8, '0').toLowerCase();
Future<bool> _waitForVoiceFragmentAck({
required String sessionId,
required int index,
Future<bool> verifyRawTransportRoute(
Contact target, {
Duration timeout = const Duration(seconds: 8),
}) => _voiceFragmentAckWaiters.waitFor(
_fragmentAckKey(sessionId, index),
timeout: timeout,
);
}) async {
if (!connectionProvider.deviceInfo.isConnected) {
return false;
}
if (target.outPathLen < 0 || target.outPathLen > _maxDirectPayloadHops) {
return false;
}
if (target.outPath.isEmpty) {
return false;
}
void _completeVoiceFragmentAck(String sessionId, int index) {
final completed = _voiceFragmentAckWaiters.complete(
_fragmentAckKey(sessionId, index),
);
if (completed == 0) {
final probeKey = _routeProbeTargetKey(target);
final pendingProbe = _pendingRawRouteProbes[probeKey];
if (pendingProbe != null) {
return pendingProbe;
}
final deviceKey = connectionProvider.deviceInfo.publicKey;
if (deviceKey == null || deviceKey.length < 6) {
return false;
}
final requesterKey6 = deviceKey
.sublist(0, 6)
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join();
final future = () async {
final nonce = math.Random.secure().nextInt(0x100000000);
final ackFuture = _rawProbeWaiters.waitFor(
_rawProbeKey(nonce),
timeout: timeout,
);
try {
debugPrint(
'📡 [AppProvider] Outgoing raw route probe: target=${target.advName} hops=${target.outPathLen} nonce=${nonce.toRadixString(16)}',
);
await connectionProvider.sendRawVoicePacket(
contactPath: target.outPath,
contactPathLen: target.outPathLen,
payload: RawRouteProbeRequest(
nonce: nonce,
requesterKey6: requesterKey6,
).encodeBinary(),
);
return await ackFuture;
} catch (e) {
debugPrint(
'⚠️ [AppProvider] Raw route probe failed for ${target.advName}: $e',
);
_rawProbeWaiters.complete(_rawProbeKey(nonce));
return false;
}
}();
_pendingRawRouteProbes[probeKey] = future;
try {
return await future;
} finally {
_pendingRawRouteProbes.remove(probeKey);
}
}
String _routeProbeTargetKey(Contact target) {
if (target.publicKeyHex.isNotEmpty) {
return 'pk:${target.publicKeyHex}';
}
return 'name:${target.advName}:${target.outPathLen}:${target.outPath.map((b) => b.toRadixString(16).padLeft(2, '0')).join()}';
}
void _handleRawRouteProbeRequest(RawRouteProbeRequest request) {
final requester = _resolveContactByPrefixHex(request.requesterKey6);
if (requester == null) {
debugPrint(
' [AppProvider] Voice fragment ACK had no waiter: $sessionId#$index',
' [AppProvider] Raw route probe requester not found: ${request.requesterKey6}',
);
return;
}
debugPrint(
'✅ [AppProvider] Voice fragment ACK received for $sessionId#$index ($completed waiter(s))',
);
}
Future<bool> _waitForImageFragmentAck({
required String sessionId,
required int index,
Duration timeout = const Duration(seconds: 8),
}) => _imageFragmentAckWaiters.waitFor(
_fragmentAckKey(sessionId, index),
timeout: timeout,
);
void _completeImageFragmentAck(String sessionId, int index) {
final completed = _imageFragmentAckWaiters.complete(
_fragmentAckKey(sessionId, index),
);
if (completed == 0) {
if (requester.outPathLen < 0 ||
requester.outPathLen > _maxDirectPayloadHops) {
debugPrint(
' [AppProvider] Image fragment ACK had no waiter: $sessionId#$index',
' [AppProvider] Raw route probe requester out of range: ${requester.outPathLen}',
);
return;
}
debugPrint(
'✅ [AppProvider] Image fragment ACK received for $sessionId#$index ($completed waiter(s))',
);
}
void _sendVoiceFragmentAck(VoicePacket packet) {
final senderKey6 = _voiceSessionSenderKey6[packet.sessionId];
if (senderKey6 == null) return;
final sender = _resolveContactByPrefixHex(senderKey6);
if (sender == null) return;
if (sender.outPathLen < 0 || sender.outPathLen > _maxDirectPayloadHops) {
if (requester.outPath.isEmpty) {
return;
}
debugPrint(
'📡 [AppProvider] Outgoing raw route probe ACK: requester=${requester.advName} hops=${requester.outPathLen} nonce=${request.nonce.toRadixString(16)}',
);
unawaited(
connectionProvider.sendRawVoicePacket(
contactPath: sender.outPath,
contactPathLen: sender.outPathLen,
payload: VoiceFragmentAck(
sessionId: packet.sessionId,
index: packet.index,
).encodeBinary(),
contactPath: requester.outPath,
contactPathLen: requester.outPathLen,
payload: RawRouteProbeAck(nonce: request.nonce).encodeBinary(),
),
);
}
void _sendImageFragmentAck(ImagePacket fragment) {
final senderKey6 = _imageSessionSenderKey6[fragment.sessionId];
if (senderKey6 == null) return;
final sender = _resolveContactByPrefixHex(senderKey6);
if (sender == null) return;
if (sender.outPathLen < 0 || sender.outPathLen > _maxDirectPayloadHops) {
return;
}
unawaited(
connectionProvider.sendRawVoicePacket(
contactPath: sender.outPath,
contactPathLen: sender.outPathLen,
payload: ImageFragmentAck(
sessionId: fragment.sessionId,
index: fragment.index,
).encodeBinary(),
),
void _completeRawRouteProbeAck(int nonce) {
_rawProbeWaiters.complete(_rawProbeKey(nonce));
}
MessageReceptionDetails? _buildReceptionDetailsSnapshot(Message message) {
final matchedRxLog = _findBestMatchingRxLog(message);
final estimatedTx = estimateMessageTransmitDuration(
message,
radioBw: connectionProvider.deviceInfo.radioBw,
radioSf: connectionProvider.deviceInfo.radioSf,
radioCr: connectionProvider.deviceInfo.radioCr,
);
final senderToReceiptMs = _senderToReceiptMs(message);
final estimatedTransmitMs = sanitizeEstimatedTransmitMs(
estimatedTransmitMs: estimatedTx > Duration.zero
? estimatedTx.inMilliseconds
: null,
senderToReceiptMs: senderToReceiptMs,
);
final postTransmitDelayMs =
senderToReceiptMs != null && estimatedTransmitMs != null
? (senderToReceiptMs - estimatedTransmitMs).clamp(0, 86400000).toInt()
: null;
if (matchedRxLog == null &&
senderToReceiptMs == null &&
estimatedTransmitMs == null) {
return null;
}
return MessageReceptionDetails(
capturedAt: DateTime.now(),
packetLoggedAt: matchedRxLog?.timestamp,
rssiDbm: matchedRxLog?.logRxDataInfo?.rssiDbm,
snrDb: matchedRxLog?.logRxDataInfo?.snrDb,
pathBytes: _extractPathBytesFromLog(matchedRxLog),
senderToReceiptMs: senderToReceiptMs,
estimatedTransmitMs: estimatedTransmitMs,
postTransmitDelayMs: postTransmitDelayMs,
);
}
int? _senderToReceiptMs(Message message) {
if (message.senderTimestamp <= 0) return null;
final senderAt = DateTime.fromMillisecondsSinceEpoch(
message.senderTimestamp * 1000,
isUtc: true,
);
final deltaMs = message.receivedAt
.toUtc()
.difference(senderAt)
.inMilliseconds;
if (deltaMs < 0 || deltaMs > 86400000) return null;
return deltaMs;
}
BlePacketLog? _findBestMatchingRxLog(Message message) {
if (message.pathLen < 0 || message.pathLen >= 255) return null;
final expectedPayloadType = message.messageType == MessageType.channel
? 0x05
: 0x02;
BlePacketLog? bestLog;
var bestDeltaMs = 999999999;
for (final log in connectionProvider.bleService.packetLogs) {
if (log.responseCode != 0x88) continue;
if (log.rawData.length < 6) continue;
final raw = log.rawData;
final payloadType = (raw[3] >> 2) & 0x0F;
final pathLen = raw[4];
if (payloadType != expectedPayloadType) continue;
if (pathLen != message.pathLen) continue;
if (raw.length < 5 + pathLen) continue;
final deltaMs =
(log.timestamp.difference(message.receivedAt).inMilliseconds).abs();
if (deltaMs < bestDeltaMs) {
bestDeltaMs = deltaMs;
bestLog = log;
}
}
if (bestDeltaMs > 30000) return null;
return bestLog;
}
List<int>? _extractPathBytesFromLog(BlePacketLog? log) {
if (log == null) return null;
final raw = log.rawData;
if (raw.length < 6) return null;
final pathLen = raw[4];
if (pathLen <= 0 || raw.length < 5 + pathLen) return null;
return raw.sublist(5, 5 + pathLen);
}
// Removed _syncMessages() - messages are automatically synced via PUSH_CODE_MSG_WAITING events
@@ -1604,9 +1851,15 @@ class AppProvider with ChangeNotifier {
for (final timer in _voiceMissingRetryTimers.values) {
timer.cancel();
}
for (final timer in _imageMissingRetryTimers.values) {
timer.cancel();
}
_voiceMissingRetryTimers.clear();
_voiceMissingRetryAttempts.clear();
_imageMissingRetryTimers.clear();
_imageMissingRetryAttempts.clear();
_voiceSessionSenderKey6.clear();
_imageSessionSenderKey6.clear();
notifyListeners();
}
@@ -1640,6 +1893,9 @@ class AppProvider with ChangeNotifier {
for (final timer in _voiceMissingRetryTimers.values) {
timer.cancel();
}
for (final timer in _imageMissingRetryTimers.values) {
timer.cancel();
}
super.dispose();
}
}

View File

@@ -146,11 +146,15 @@ class ConnectionProvider with ChangeNotifier {
final MessageDeliveryTracker _messageDeliveryTracker =
MessageDeliveryTracker();
final PingTracker _pingTracker = PingTracker();
final Map<String, Future<PingResult>> _pendingSmartPings = {};
// Expose room login states
Map<String, RoomLoginState> get roomLoginStates =>
_roomLoginManager.roomLoginStates;
bool isPingInProgress(Uint8List publicKey) =>
_pendingSmartPings.containsKey(_publicKeyToHex(publicKey));
// Callbacks for other providers
Function(Contact)? onContactReceived;
Function(List<Contact>)? onContactsComplete;
@@ -1316,6 +1320,34 @@ class ConnectionProvider with ChangeNotifier {
required Uint8List contactPublicKey,
required bool hasPath,
Function()? onRetryWithFlooding,
}) async {
final pingKey = _publicKeyToHex(contactPublicKey);
final pendingPing = _pendingSmartPings[pingKey];
if (pendingPing != null) {
debugPrint(' [Provider] Joining in-flight ping for $pingKey');
return pendingPing;
}
final future = _runSmartPing(
contactPublicKey: contactPublicKey,
hasPath: hasPath,
onRetryWithFlooding: onRetryWithFlooding,
);
_pendingSmartPings[pingKey] = future;
notifyListeners();
try {
return await future;
} finally {
_pendingSmartPings.remove(pingKey);
notifyListeners();
}
}
Future<PingResult> _runSmartPing({
required Uint8List contactPublicKey,
required bool hasPath,
Function()? onRetryWithFlooding,
}) async {
if (!_activeService.isConnected) {
_error = 'Not connected to device';
@@ -1334,7 +1366,10 @@ class ConnectionProvider with ChangeNotifier {
);
// Send the ping
await _activeService.requestTelemetry(contactPublicKey, zeroHop: true);
await _activeService.requestTelemetry(
contactPublicKey,
zeroHop: firstAttemptDirect,
);
// Wait for response or timeout
final bool gotResponse = await pingFuture;
@@ -1361,8 +1396,8 @@ class ConnectionProvider with ChangeNotifier {
wasDirectAttempt: false,
);
// Retry with flooding (zeroHop=true acts as broadcast to neighbors)
await _activeService.requestTelemetry(contactPublicKey, zeroHop: true);
// Retry with flooding.
await _activeService.requestTelemetry(contactPublicKey, zeroHop: false);
// Wait for response or timeout
final bool gotRetryResponse = await retryFuture;
@@ -1384,6 +1419,10 @@ class ConnectionProvider with ChangeNotifier {
}
}
String _publicKeyToHex(Uint8List publicKey) {
return publicKey.map((b) => b.toRadixString(16).padLeft(2, '0')).join('');
}
/// Send binary request to contact (modern replacement for requestTelemetry)
///
/// Supports multiple request types:

View File

@@ -9,13 +9,6 @@ typedef RawPacketSender =
required Uint8List payload,
});
typedef FragmentAckWaiter =
Future<bool> Function({
required String sessionId,
required int index,
Duration timeout,
});
Future<bool> serveCachedSessionFragments<T>({
required String providerLabel,
required String sessionId,
@@ -25,9 +18,7 @@ Future<bool> serveCachedSessionFragments<T>({
required int Function(T fragment) indexOf,
required Uint8List Function(T fragment) encodeBinary,
required RawPacketSender? sendRawPacket,
FragmentAckWaiter? waitForFragmentAck,
Set<int>? requestedIndices,
Duration ackTimeout = const Duration(seconds: 8),
}) async {
if (fragments.isEmpty) {
debugPrint('⚠️ [$providerLabel] No cached fragments for $sessionId');
@@ -65,24 +56,12 @@ Future<bool> serveCachedSessionFragments<T>({
continue;
}
try {
final ackFuture = waitForFragmentAck?.call(
sessionId: sessionId,
index: index,
timeout: ackTimeout,
);
await sendRawPacket(
contactPath: requester.outPath,
contactPathLen: requester.outPathLen,
payload: encodeBinary(fragment),
);
servedCount++;
if (ackFuture != null) {
final acked = await ackFuture;
if (!acked) {
debugPrint('⚠️ [$providerLabel] ACK timeout for $sessionId#$index');
return false;
}
}
} catch (e, st) {
debugPrint(
'❌ [$providerLabel] Serve error for $sessionId#$index: $e\n$st',

View File

@@ -70,12 +70,6 @@ class ImageProvider with ChangeNotifier {
required Uint8List payload,
})?
sendRawPacketCallback;
Future<bool> Function({
required String sessionId,
required int index,
Duration timeout,
})?
waitForFragmentAckCallback;
ImageProvider() {
_restore();
@@ -264,7 +258,6 @@ class ImageProvider with ChangeNotifier {
indexOf: (fragment) => fragment.index,
encodeBinary: (fragment) => fragment.encodeBinary(),
sendRawPacket: sendRawPacketCallback,
waitForFragmentAck: waitForFragmentAckCallback,
requestedIndices: requestedIndices,
);
}

View File

@@ -3,6 +3,7 @@ import 'package:flutter/foundation.dart';
import '../models/message.dart';
import '../models/contact.dart';
import '../models/message_contact_location.dart';
import '../models/message_reception_details.dart';
import '../models/sar_marker.dart';
import '../models/map_drawing.dart';
import '../services/message_storage_service.dart';
@@ -22,6 +23,7 @@ class MessagesProvider with ChangeNotifier {
bool _isInitialized = false;
AppLocalizations? _localizations;
final Map<String, MessageContactLocation> _messageContactLocations = {};
final Map<String, MessageReceptionDetails> _messageReceptionDetails = {};
// Track pending sent messages by expected ACK/TAG
final Map<int, Message> _pendingSentMessages = {};
@@ -74,10 +76,7 @@ class MessagesProvider with ChangeNotifier {
})?
sendMessageCallback;
Future<void> Function({
required Contact contact,
required int failureStreak,
})?
Future<void> Function({required Contact contact, required int failureStreak})?
onDirectPathFailedCallback;
List<Message> get messages => List.unmodifiable(_messages);
@@ -115,6 +114,9 @@ class MessagesProvider with ChangeNotifier {
MessageContactLocation? getMessageContactLocation(String messageId) =>
_messageContactLocations[messageId];
MessageReceptionDetails? getMessageReceptionDetails(String messageId) =>
_messageReceptionDetails[messageId];
/// Set localizations for notifications
void setLocalizations(AppLocalizations localizations) {
_localizations = localizations;
@@ -145,9 +147,14 @@ class MessagesProvider with ChangeNotifier {
final storedMessages = await _storageService.loadMessages();
final storedContactLocations = await _storageService
.loadMessageContactLocations();
final storedReceptionDetails = await _storageService
.loadMessageReceptionDetails();
_messageContactLocations
..clear()
..addAll(storedContactLocations);
_messageReceptionDetails
..clear()
..addAll(storedReceptionDetails);
// Add stored messages with enhancement to ensure SAR detection
for (final message in storedMessages) {
@@ -311,6 +318,7 @@ class MessagesProvider with ChangeNotifier {
Message message, {
String Function(String name)? contactLookup,
MessageContactLocation? contactLocationSnapshot,
MessageReceptionDetails? receptionDetailsSnapshot,
}) {
// Always enhance message with SAR parser to detect SAR markers
var enhancedMessage = SarMessageParser.enhanceMessage(message);
@@ -397,6 +405,22 @@ class MessagesProvider with ChangeNotifier {
debugPrint(
' Text: ${finalMessage.text.substring(0, finalMessage.text.length > 50 ? 50 : finalMessage.text.length)}...',
);
final existingIndex = _messages.indexWhere(
(existing) =>
existing.messageType == finalMessage.messageType &&
existing.senderTimestamp == finalMessage.senderTimestamp &&
existing.text == finalMessage.text,
);
if (existingIndex != -1) {
final existingId = _messages[existingIndex].id;
if (contactLocationSnapshot != null) {
_messageContactLocations[existingId] = contactLocationSnapshot;
}
if (receptionDetailsSnapshot != null) {
_messageReceptionDetails[existingId] = receptionDetailsSnapshot;
}
_persistMessages();
}
return; // Skip duplicate
}
@@ -404,6 +428,9 @@ class MessagesProvider with ChangeNotifier {
if (contactLocationSnapshot != null) {
_messageContactLocations[finalMessage.id] = contactLocationSnapshot;
}
if (receptionDetailsSnapshot != null) {
_messageReceptionDetails[finalMessage.id] = receptionDetailsSnapshot;
}
// If it's a SAR marker message, extract and store the marker
if (finalMessage.isSarMarker) {
@@ -593,6 +620,7 @@ class MessagesProvider with ChangeNotifier {
await _storageService.saveMessages(
_messages,
messageContactLocations: _messageContactLocations,
messageReceptionDetails: _messageReceptionDetails,
);
} catch (e) {
debugPrint('❌ [MessagesProvider] Error persisting messages: $e');
@@ -709,6 +737,7 @@ class MessagesProvider with ChangeNotifier {
_messageContactMap.remove(messageId);
_groupedMessageMapping.remove(messageId);
_messageContactLocations.remove(messageId);
_messageReceptionDetails.remove(messageId);
debugPrint('🗑️ [MessagesProvider] Message $messageId deleted');
@@ -738,6 +767,7 @@ class MessagesProvider with ChangeNotifier {
_messages.clear();
_sarMarkers.clear();
_messageContactLocations.clear();
_messageReceptionDetails.clear();
_persistMessages();
notifyListeners();
}
@@ -753,6 +783,7 @@ class MessagesProvider with ChangeNotifier {
_messages.clear();
_sarMarkers.clear();
_messageContactLocations.clear();
_messageReceptionDetails.clear();
_persistMessages();
notifyListeners();
}
@@ -1062,10 +1093,11 @@ class MessagesProvider with ChangeNotifier {
' Message text preview: ${message.text.substring(0, message.text.length > 30 ? 30 : message.text.length)}...',
);
// Once the device accepts a direct message and returns an ACK tag, the
// send itself succeeded locally even if end-to-end delivery confirmation
// may still arrive later. Keep ACK tracking, but stop showing "waiting".
final updatedMessage = message.copyWith(
deliveryStatus: expectedAckTag > 0
? MessageDeliveryStatus.sending
: MessageDeliveryStatus.sent,
deliveryStatus: MessageDeliveryStatus.sent,
expectedAckTag: expectedAckTag > 0 ? expectedAckTag : null,
suggestedTimeoutMs: expectedAckTag > 0 ? effectiveTimeout : null,
);

View File

@@ -71,12 +71,6 @@ class VoiceProvider with ChangeNotifier {
required Uint8List payload,
})?
sendRawPacketCallback;
Future<bool> Function({
required String sessionId,
required int index,
Duration timeout,
})?
waitForFragmentAckCallback;
final Map<String, _OutgoingVoiceSession> _outgoingSessions = {};
@@ -217,7 +211,6 @@ class VoiceProvider with ChangeNotifier {
indexOf: (packet) => packet.index,
encodeBinary: (packet) => packet.encodeBinary(),
sendRawPacket: sendRawPacketCallback,
waitForFragmentAck: waitForFragmentAckCallback,
requestedIndices: requestedIndices,
);
}

View File

@@ -19,7 +19,9 @@ import '../models/message.dart';
import '../models/contact.dart';
import '../widgets/messages/sar_update_sheet.dart';
import '../widgets/messages/recipient_selector_sheet.dart';
import '../widgets/messages/message_bubble.dart';
import '../widgets/messages/messages_composer.dart';
import '../widgets/messages/messages_content.dart';
import '../widgets/common/contact_avatar.dart';
import '../services/message_destination_preferences.dart';
import '../services/voice_bitrate_preferences.dart';
import '../services/voice_recorder_service.dart';
@@ -47,6 +49,7 @@ class MessagesTab extends StatefulWidget {
class _MessagesTabState extends State<MessagesTab> {
static const int _maxContactMessageBytes = 156;
static const int _maxChannelMessageBytes = 127;
static const double _composerOverlayHeight = 148;
final TextEditingController _textController = TextEditingController();
final FocusNode _focusNode = FocusNode();
@@ -314,20 +317,6 @@ class _MessagesTabState extends State<MessagesTab> {
}
}
/// Get tooltip for destination button
String _getDestinationTooltip() {
if (_destinationType ==
MessageDestinationPreferences.destinationTypeChannel &&
_selectedRecipient != null) {
final channelName = _selectedRecipient!.getLocalizedDisplayName(context);
return '$channelName (tap to change)';
} else if (_selectedRecipient != null) {
final recipientName = _selectedRecipient!.displayName;
return '$recipientName (tap to change)';
}
return 'Select recipient';
}
String _getDestinationLabel() {
if (_destinationType ==
MessageDestinationPreferences.destinationTypeChannel &&
@@ -466,10 +455,7 @@ class _MessagesTabState extends State<MessagesTab> {
);
// Add to messages list with "sending" status
messagesProvider.addSentMessage(
sentMessage,
contact: _selectedRecipient,
);
messagesProvider.addSentMessage(sentMessage, contact: _selectedRecipient);
// Send message to selected recipient
final sentSuccessfully = await connectionProvider.sendTextMessage(
@@ -1115,6 +1101,16 @@ class _MessagesTabState extends State<MessagesTab> {
);
}
Future<void> _runAfterSheetDismissal(
BuildContext sheetContext,
Future<void> Function() action,
) async {
Navigator.pop(sheetContext);
await Future<void>.delayed(const Duration(milliseconds: 180));
if (!mounted) return;
await action();
}
void _showComposerActions() {
showModalBottomSheet(
context: context,
@@ -1126,9 +1122,10 @@ class _MessagesTabState extends State<MessagesTab> {
ListTile(
leading: const Icon(Icons.add_location_alt),
title: Text(AppLocalizations.of(context)!.sendSarMarker),
onTap: () {
Navigator.pop(sheetContext);
_showSarDialog();
onTap: () async {
await _runAfterSheetDismissal(sheetContext, () async {
_showSarDialog();
});
},
),
if (_voiceSupported)
@@ -1138,13 +1135,14 @@ class _MessagesTabState extends State<MessagesTab> {
title: Text(_isRecording ? 'Stop recording' : 'Record voice'),
onTap: _isSendingVoice
? null
: () {
Navigator.pop(sheetContext);
if (_isRecording) {
_stopAndSendVoice();
} else {
_startVoiceRecording();
}
: () async {
await _runAfterSheetDismissal(sheetContext, () async {
if (_isRecording) {
await _stopAndSendVoice();
} else {
await _startVoiceRecording();
}
});
},
),
ListTile(
@@ -1153,9 +1151,10 @@ class _MessagesTabState extends State<MessagesTab> {
title: const Text('Send image from gallery'),
onTap: _isSendingImage
? null
: () {
Navigator.pop(sheetContext);
_pickAndSendImage(source: ImageSource.gallery);
: () async {
await _runAfterSheetDismissal(sheetContext, () async {
await _pickAndSendImage(source: ImageSource.gallery);
});
},
),
ListTile(
@@ -1164,18 +1163,20 @@ class _MessagesTabState extends State<MessagesTab> {
title: const Text('Take photo'),
onTap: _isSendingImage
? null
: () {
Navigator.pop(sheetContext);
_pickAndSendImage(source: ImageSource.camera);
: () async {
await _runAfterSheetDismissal(sheetContext, () async {
await _pickAndSendImage(source: ImageSource.camera);
});
},
),
ListTile(
leading: const Icon(Icons.grid_3x3),
title: const Text('Start Tic-Tac-Toe'),
subtitle: const Text('DM only'),
onTap: () {
Navigator.pop(sheetContext);
_startTicTacToeGame();
onTap: () async {
await _runAfterSheetDismissal(sheetContext, () async {
await _startTicTacToeGame();
});
},
),
],
@@ -1185,6 +1186,23 @@ class _MessagesTabState extends State<MessagesTab> {
);
}
Widget _buildDestinationAvatar(BuildContext context) {
final recipient = _selectedRecipient;
if (recipient != null) {
return ContactAvatar(
contact: recipient,
radius: 14,
displayName: _getDestinationLabel(),
);
}
return Icon(
_getDestinationIcon(),
size: 17,
color: Theme.of(context).colorScheme.onSurfaceVariant,
);
}
Future<void> _sendSarMessage(
String emoji,
String name,
@@ -1496,6 +1514,28 @@ class _MessagesTabState extends State<MessagesTab> {
return filteredMessages;
}
void _handleMessageTap(Message message) {
if (widget.onNavigateToMap == null) return;
if (message.isSarMarker && message.sarGpsCoordinates != null) {
final mapProvider = context.read<MapProvider>();
mapProvider.navigateToLocation(
location: message.sarGpsCoordinates!,
zoom: 15.0,
);
widget.onNavigateToMap?.call();
return;
}
if (message.isDrawing && message.drawingId != null) {
debugPrint('🗺️ [MessagesTab] Drawing tapped! ID: ${message.drawingId}');
final mapProvider = context.read<MapProvider>();
final drawingProvider = context.read<DrawingProvider>();
mapProvider.navigateToDrawing(message.drawingId!, drawingProvider);
widget.onNavigateToMap?.call();
}
}
@override
Widget build(BuildContext context) {
return Consumer<MessagesProvider>(
@@ -1507,552 +1547,41 @@ class _MessagesTabState extends State<MessagesTab> {
return GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: () => FocusScope.of(context).unfocus(),
child: Column(
child: Stack(
children: [
// Messages list with pull-to-refresh
Expanded(
child: RefreshIndicator(
Positioned.fill(
child: MessagesContent(
messages: messages,
scrollController: _scrollController,
highlightedMessageId: _highlightedMessageId,
bottomContentPadding:
_composerOverlayHeight + composerBottomPadding,
onRefresh: _handleRefresh,
child: messages.isEmpty
? LayoutBuilder(
builder: (context, constraints) =>
SingleChildScrollView(
keyboardDismissBehavior:
ScrollViewKeyboardDismissBehavior.onDrag,
physics:
const AlwaysScrollableScrollPhysics(),
child: ConstrainedBox(
constraints: BoxConstraints(
minHeight: constraints.maxHeight,
),
child: Center(
child: Column(
mainAxisAlignment:
MainAxisAlignment.center,
children: [
Icon(
Icons.message_outlined,
size: 64,
color: Theme.of(
context,
).disabledColor,
),
const SizedBox(height: 16),
Text(
AppLocalizations.of(
context,
)!.noMessagesYet,
style: Theme.of(
context,
).textTheme.titleLarge,
),
const SizedBox(height: 8),
Text(
AppLocalizations.of(
context,
)!.pullDownToSync,
style: Theme.of(
context,
).textTheme.bodyMedium,
textAlign: TextAlign.center,
),
],
),
),
),
),
)
: ListView.builder(
controller: _scrollController,
keyboardDismissBehavior:
ScrollViewKeyboardDismissBehavior.onDrag,
reverse: true,
padding: const EdgeInsets.all(8),
itemCount: messages.length,
itemBuilder: (context, index) {
final message = messages[index];
final isHighlighted =
message.id == _highlightedMessageId;
return MessageBubble(
key: ValueKey(message.id),
message: message,
isHighlighted: isHighlighted,
onNavigateToMap: widget.onNavigateToMap,
onTap:
widget.onNavigateToMap != null &&
message.isSarMarker &&
message.sarGpsCoordinates != null
? () {
final mapProvider = context
.read<MapProvider>();
mapProvider.navigateToLocation(
location: message.sarGpsCoordinates!,
zoom: 15.0,
);
widget.onNavigateToMap?.call();
}
: widget.onNavigateToMap != null &&
message.isDrawing &&
message.drawingId != null
? () {
debugPrint(
'🗺️ [MessagesTab] Drawing tapped! ID: ${message.drawingId}',
);
final mapProvider = context
.read<MapProvider>();
final drawingProvider = context
.read<DrawingProvider>();
mapProvider.navigateToDrawing(
message.drawingId!,
drawingProvider,
);
widget.onNavigateToMap?.call();
}
: null,
);
},
),
onNavigateToMap: widget.onNavigateToMap,
onMessageTap: _handleMessageTap,
),
),
// Message input area
Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
SafeArea(
top: false,
child: Padding(
padding: EdgeInsets.fromLTRB(
10,
10,
10,
composerBottomPadding,
),
child: Container(
decoration: BoxDecoration(
color: Theme.of(
context,
).colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(28),
border: Border.all(
color: Theme.of(
context,
).dividerColor.withValues(alpha: 0.35),
),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.05),
blurRadius: 18,
offset: const Offset(0, 6),
),
],
),
child: Padding(
padding: const EdgeInsets.fromLTRB(10, 10, 10, 8),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Container(
width: 42,
height: 42,
decoration: BoxDecoration(
color: Theme.of(
context,
).colorScheme.surface,
shape: BoxShape.circle,
border: Border.all(
color: Theme.of(
context,
).dividerColor.withValues(alpha: 0.35),
),
),
child: IconButton(
icon: Icon(
_isRecording ? Icons.stop : Icons.add,
size: 22,
),
tooltip: _isRecording
? 'Stop recording'
: 'More actions',
onPressed: _isRecording
? _stopAndSendVoice
: _showComposerActions,
color: _isRecording
? Colors.red
: Theme.of(
context,
).colorScheme.primary,
),
),
const SizedBox(width: 8),
Expanded(
child: Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(20),
onTap: _showRecipientSelector,
child: Ink(
height: 42,
decoration: BoxDecoration(
color: Theme.of(
context,
).colorScheme.surface,
borderRadius: BorderRadius.circular(
20,
),
border: Border.all(
color: Theme.of(context)
.dividerColor
.withValues(alpha: 0.35),
),
),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 14,
),
child: Row(
children: [
Icon(
_getDestinationIcon(),
size: 17,
color: Theme.of(context)
.colorScheme
.onSurfaceVariant,
),
const SizedBox(width: 10),
Expanded(
child: Text(
_getDestinationLabel(),
overflow:
TextOverflow.ellipsis,
style: TextStyle(
fontSize: 15,
fontWeight:
FontWeight.w600,
color: Theme.of(
context,
).colorScheme.onSurface,
),
),
),
Icon(
Icons.expand_more_rounded,
size: 20,
color: Theme.of(context)
.colorScheme
.onSurfaceVariant,
),
],
),
),
),
),
),
),
],
),
const SizedBox(height: 8),
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: AnimatedContainer(
duration: const Duration(
milliseconds: 180,
),
constraints: const BoxConstraints(
minHeight: 46,
maxHeight: 132,
),
decoration: BoxDecoration(
color: Theme.of(
context,
).colorScheme.surface,
borderRadius: BorderRadius.circular(24),
border: Border.all(
color: _focusNode.hasFocus
? Theme.of(
context,
).colorScheme.primary
: Theme.of(context).dividerColor
.withValues(alpha: 0.35),
width: _focusNode.hasFocus ? 1.4 : 1,
),
boxShadow: _focusNode.hasFocus
? [
BoxShadow(
color: Theme.of(context)
.colorScheme
.primary
.withValues(alpha: 0.10),
blurRadius: 12,
offset: const Offset(0, 4),
),
]
: null,
),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
child: TextField(
controller: _textController,
focusNode: _focusNode,
minLines: 1,
maxLines: 4,
keyboardType: TextInputType.multiline,
inputFormatters: [
_messageByteLimiter,
],
style: const TextStyle(fontSize: 15),
textAlignVertical:
TextAlignVertical.center,
decoration: InputDecoration(
hintText: AppLocalizations.of(
context,
)!.typeYourMessage,
hintStyle: TextStyle(
fontSize: 15,
color: Theme.of(context)
.colorScheme
.onSurfaceVariant
.withValues(alpha: 0.9),
),
filled: false,
fillColor: Colors.transparent,
border: InputBorder.none,
isCollapsed: true,
),
textInputAction:
TextInputAction.newline,
),
),
),
),
const SizedBox(width: 8),
Builder(
builder: (context) {
final canSendText =
!_isRecording &&
!_isSendingVoice &&
_textController.text
.trim()
.isNotEmpty;
final semanticsLabel = _isRecording
? 'Recording... release to send voice'
: (_isSendingVoice
? 'Sending voice...'
: _voiceSupported
? 'Send (long press to record voice)'
: 'Send');
return Semantics(
button: true,
enabled:
canSendText ||
(_voiceSupported &&
!_isSendingVoice),
label: semanticsLabel,
onTap: canSendText
? _sendMessage
: null,
onLongPress:
(_voiceSupported &&
!_isSendingVoice)
? () {
if (_isRecording) {
_stopAndSendVoice();
return;
}
_startVoiceRecording();
}
: null,
child: Tooltip(
message: semanticsLabel,
excludeFromSemantics: true,
child: GestureDetector(
excludeFromSemantics: true,
onTap: canSendText
? () {
debugPrint(
'👆 [MessagesTab] Send button tapped '
'(canSendText=$canSendText, '
'textLength=${_textController.text.trim().length}, '
'recording=$_isRecording, '
'sendingVoice=$_isSendingVoice)',
);
_sendMessage();
}
: null,
onLongPressStart:
(_voiceSupported &&
!_isSendingVoice)
? (_) {
debugPrint(
'🎙️ [MessagesTab] Send button long-press start '
'(voiceSupported=$_voiceSupported, '
'sendingVoice=$_isSendingVoice, '
'recording=$_isRecording)',
);
_startVoiceRecording();
}
: null,
onLongPressEnd:
(_voiceSupported &&
_isRecording)
? (_) {
debugPrint(
'🎙️ [MessagesTab] Send button long-press end '
'(recording=$_isRecording)',
);
_stopAndSendVoice();
}
: null,
onLongPressCancel:
(_voiceSupported &&
_isRecording)
? () {
debugPrint(
'🎙️ [MessagesTab] Send button long-press cancel '
'(recording=$_isRecording)',
);
_stopAndSendVoice();
}
: null,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
AnimatedContainer(
duration: const Duration(
milliseconds: 180,
),
width: 46,
height: 46,
decoration: BoxDecoration(
color:
canSendText ||
_isRecording
? Theme.of(
context,
).colorScheme.primary
: Theme.of(
context,
).colorScheme.surface,
shape: BoxShape.circle,
border: Border.all(
color:
canSendText ||
_isRecording
? Colors.transparent
: Theme.of(context)
.dividerColor
.withValues(
alpha: 0.35,
),
),
boxShadow:
canSendText ||
_isRecording
? [
BoxShadow(
color:
Theme.of(
context,
)
.colorScheme
.primary
.withValues(
alpha:
0.22,
),
blurRadius: 14,
offset:
const Offset(
0,
6,
),
),
]
: null,
),
child: _isSendingVoice
? Center(
child: CircularProgressIndicator(
strokeWidth: 2,
color:
Theme.of(
context,
)
.colorScheme
.onPrimary,
),
)
: Icon(
_isRecording
? Icons
.mic_rounded
: Icons
.send_rounded,
size: 22,
color:
canSendText ||
_isRecording
? Theme.of(
context,
)
.colorScheme
.onPrimary
: Theme.of(
context,
)
.colorScheme
.onSurfaceVariant,
),
),
const SizedBox(height: 4),
Text(
'$_messageByteCount/$_maxMessageBytes',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w500,
color:
_messageByteCount >
_maxMessageBytes *
0.9
? Colors.orange.shade800
: Theme.of(context)
.colorScheme
.onSurfaceVariant
.withValues(
alpha: 0.9,
),
),
),
],
),
),
),
);
},
),
],
),
],
),
),
),
),
),
],
Positioned(
left: 0,
right: 0,
bottom: 0,
child: MessagesComposer(
textController: _textController,
focusNode: _focusNode,
messageByteLimiter: _messageByteLimiter,
messageByteCount: _messageByteCount,
maxMessageBytes: _maxMessageBytes,
isRecording: _isRecording,
isSendingVoice: _isSendingVoice,
voiceSupported: _voiceSupported,
bottomPadding: composerBottomPadding,
destinationLabel: _getDestinationLabel(),
destinationAvatar: _buildDestinationAvatar(context),
onShowComposerActions: _showComposerActions,
onShowRecipientSelector: _showRecipientSelector,
onStartVoiceRecording: _startVoiceRecording,
onStopAndSendVoice: _stopAndSendVoice,
onSendMessage: _sendMessage,
),
),
],

View File

@@ -1,10 +1,14 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import 'package:share_plus/share_plus.dart';
import 'dart:io';
import 'package:path_provider/path_provider.dart';
import 'package:meshcore_client/meshcore_client.dart';
import '../l10n/app_localizations.dart';
import '../providers/connection_provider.dart';
import '../providers/contacts_provider.dart';
import '../utils/log_rx_route_decoder.dart';
class PacketLogScreen extends StatefulWidget {
final MeshCoreBleService bleService;
@@ -456,6 +460,26 @@ class _PacketLogCard extends StatelessWidget {
final isRx = log.direction == PacketDirection.rx;
final directionColor = isRx ? Colors.green : Colors.blue;
final rxInfo = log.logRxDataInfo;
final contacts = context.watch<ContactsProvider>().contacts;
final connectionProvider = context.watch<ConnectionProvider>();
final decodedRoute = LogRxRouteDecoder.decode(log.rawData);
final ownPublicKey = connectionProvider.deviceInfo.publicKey;
final ownName =
connectionProvider.deviceInfo.selfName ??
connectionProvider.deviceInfo.displayName;
final resolvedPath = decodedRoute?.pathHashes
.map(
(hash) => LogRxRouteDecoder.resolveHash(
hash,
contacts: contacts,
ownPublicKey: ownPublicKey,
ownName: ownName,
),
)
.toList();
final originalSender = resolvedPath != null && resolvedPath.isNotEmpty
? resolvedPath.first
: null;
return Card(
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
@@ -580,6 +604,14 @@ class _PacketLogCard extends StatelessWidget {
),
),
],
if (isRx && decodedRoute != null) ...[
const SizedBox(height: 12),
_RouteSection(
route: decodedRoute,
path: resolvedPath ?? const [],
originalSender: originalSender,
),
],
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.all(12),
@@ -723,6 +755,162 @@ class _PacketLogCard extends StatelessWidget {
}
}
class _RouteSection extends StatelessWidget {
final DecodedLogRxRoute route;
final List<ResolvedNodeHash> path;
final ResolvedNodeHash? originalSender;
const _RouteSection({
required this.route,
required this.path,
required this.originalSender,
});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Theme.of(
context,
).colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(10),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Row(
children: [
Icon(Icons.alt_route, size: 16),
SizedBox(width: 6),
Text(
'Mesh Route',
style: TextStyle(fontWeight: FontWeight.w700, fontSize: 12),
),
],
),
const SizedBox(height: 10),
Wrap(
spacing: 10,
runSpacing: 10,
children: [
_FactCard(
icon: Icons.route,
label: 'Payload',
value: _payloadTypeLabel(route.payloadType),
),
_FactCard(
icon: Icons.hub,
label: 'Hops',
value: '${route.pathHashes.length}',
),
if (originalSender != null)
_FactCard(
icon: Icons.person_pin_circle,
label: 'Original sender',
value: _nodeLabel(originalSender!),
),
],
),
const SizedBox(height: 12),
if (path.isEmpty)
Text(
'Direct packet, no hop path attached.',
style: TextStyle(fontSize: 12, color: Colors.grey[700]),
)
else
Wrap(
spacing: 6,
runSpacing: 6,
children: [
for (var i = 0; i < path.length; i++) ...[
_RouteHopChip(index: i + 1, node: path[i]),
if (i < path.length - 1)
const Padding(
padding: EdgeInsets.symmetric(horizontal: 2),
child: Icon(Icons.arrow_right_alt, size: 16),
),
],
],
),
],
),
);
}
static String _payloadTypeLabel(int payloadType) {
switch (payloadType) {
case 0x00:
return 'REQ';
case 0x01:
return 'RESP';
case 0x02:
return 'TXT';
case 0x03:
return 'ACK';
case 0x04:
return 'ADVERT';
case 0x05:
return 'GRP_TXT';
case 0x06:
return 'GRP_DATA';
case 0x07:
return 'ANON_REQ';
case 0x08:
return 'PATH';
case 0x09:
return 'TRACE';
case 0x0A:
return 'MULTIPART';
case 0x0B:
return 'CONTROL';
default:
return '0x${payloadType.toRadixString(16).padLeft(2, '0')}';
}
}
static String _nodeLabel(ResolvedNodeHash node) {
if (node.isOwnNode) {
return '${node.label} (${node.hexLabel})';
}
if (node.matchCount == 0) {
return node.hexLabel;
}
if (node.isUniqueMatch) {
return '${node.label} (${node.hexLabel})';
}
return '${node.label} (${node.hexLabel}, ${node.matchCount} matches)';
}
}
class _RouteHopChip extends StatelessWidget {
final int index;
final ResolvedNodeHash node;
const _RouteHopChip({required this.index, required this.node});
@override
Widget build(BuildContext context) {
final color = node.isOwnNode
? Colors.blue
: node.isUniqueMatch
? Colors.green
: Colors.orange;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(999),
border: Border.all(color: color.withValues(alpha: 0.35)),
),
child: Text(
'$index. ${_RouteSection._nodeLabel(node)}',
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600),
),
);
}
}
class _FactCard extends StatelessWidget {
final IconData icon;
final String label;

View File

@@ -3,6 +3,7 @@ import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../models/message.dart';
import '../models/message_contact_location.dart';
import '../models/message_reception_details.dart';
import 'package:latlong2/latlong.dart';
/// Service for persisting messages to local storage
@@ -10,12 +11,15 @@ class MessageStorageService {
static const String _messagesKey = 'stored_messages';
static const String _messageContactLocationsKey =
'stored_message_contact_locations';
static const String _messageReceptionDetailsKey =
'stored_message_reception_details';
static const int _maxStoredMessages = 1000; // Store up to 1000 messages
/// Save messages to persistent storage
Future<void> saveMessages(
List<Message> messages, {
Map<String, MessageContactLocation> messageContactLocations = const {},
Map<String, MessageReceptionDetails> messageReceptionDetails = const {},
}) async {
try {
final prefs = await SharedPreferences.getInstance();
@@ -34,15 +38,25 @@ class MessageStorageService {
.map((entry) => entry['id'] as String)
.toSet();
final locationJson = <String, dynamic>{};
final receptionJson = <String, dynamic>{};
for (final entry in messageContactLocations.entries) {
if (retainedMessageIds.contains(entry.key)) {
locationJson[entry.key] = entry.value.toJson();
}
}
for (final entry in messageReceptionDetails.entries) {
if (retainedMessageIds.contains(entry.key)) {
receptionJson[entry.key] = entry.value.toJson();
}
}
await prefs.setString(
_messageContactLocationsKey,
jsonEncode(locationJson),
);
await prefs.setString(
_messageReceptionDetailsKey,
jsonEncode(receptionJson),
);
debugPrint(
'✅ [MessageStorage] Saved ${limitedList.length} messages to storage',
@@ -52,8 +66,8 @@ class MessageStorageService {
}
}
Future<Map<String, MessageContactLocation>> loadMessageContactLocations()
async {
Future<Map<String, MessageContactLocation>>
loadMessageContactLocations() async {
try {
final prefs = await SharedPreferences.getInstance();
final jsonString = prefs.getString(_messageContactLocationsKey);
@@ -82,6 +96,36 @@ class MessageStorageService {
}
}
Future<Map<String, MessageReceptionDetails>>
loadMessageReceptionDetails() async {
try {
final prefs = await SharedPreferences.getInstance();
final jsonString = prefs.getString(_messageReceptionDetailsKey);
if (jsonString == null || jsonString.isEmpty) {
return const {};
}
final decoded = jsonDecode(jsonString);
if (decoded is! Map<String, dynamic>) {
return const {};
}
final result = <String, MessageReceptionDetails>{};
for (final entry in decoded.entries) {
final value = entry.value;
if (value is! Map<String, dynamic>) continue;
final snapshot = MessageReceptionDetails.fromJson(value);
if (snapshot != null) {
result[entry.key] = snapshot;
}
}
return result;
} catch (e) {
debugPrint('❌ [MessageStorage] Error loading reception details: $e');
return const {};
}
}
/// Load messages from persistent storage
Future<List<Message>> loadMessages() async {
try {
@@ -116,6 +160,7 @@ class MessageStorageService {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_messagesKey);
await prefs.remove(_messageContactLocationsKey);
await prefs.remove(_messageReceptionDetailsKey);
debugPrint('✅ [MessageStorage] Cleared all stored messages');
} catch (e) {
debugPrint('❌ [MessageStorage] Error clearing messages: $e');

View File

@@ -18,7 +18,7 @@ class VoiceRecorderService {
/// Request microphone permission. Returns true if granted.
Future<bool> requestPermission() async {
return _recorder.hasPermission();
return _recorder.hasPermission(request: true);
}
/// Start capturing PCM audio.
@@ -38,9 +38,7 @@ class VoiceRecorderService {
throw StateError('VoiceRecorderService: already recording');
}
_controller = StreamController<Int16List>(
onCancel: () => _stopInternal(),
);
_controller = StreamController<Int16List>(onCancel: () => _stopInternal());
_isRecording = true;
_startRecording(
@@ -167,17 +165,17 @@ class _VoiceDynamicsProcessor {
required int sampleRate,
required bool enableCompressor,
required bool enableLimiter,
}) : _enableCompressor = enableCompressor,
_enableLimiter = enableLimiter,
_compressor = _SimpleCompressor(
sampleRate: sampleRate.toDouble(),
thresholdDb: -18.0,
ratio: 2.5,
attackMs: 8.0,
releaseMs: 120.0,
makeupGainDb: 4.0,
),
_limiter = _PeakLimiter(ceilingDb: -1.0);
}) : _enableCompressor = enableCompressor,
_enableLimiter = enableLimiter,
_compressor = _SimpleCompressor(
sampleRate: sampleRate.toDouble(),
thresholdDb: -18.0,
ratio: 2.5,
attackMs: 8.0,
releaseMs: 120.0,
makeupGainDb: 4.0,
),
_limiter = _PeakLimiter(ceilingDb: -1.0);
Int16List process(Int16List input) {
final output = Int16List(input.length);
@@ -214,11 +212,11 @@ class _SimpleCompressor {
required double attackMs,
required double releaseMs,
required double makeupGainDb,
}) : _thresholdDb = thresholdDb,
_ratio = ratio,
_makeupGain = math.pow(10.0, makeupGainDb / 20.0).toDouble(),
_attackCoeff = math.exp(-1.0 / (sampleRate * (attackMs / 1000.0))),
_releaseCoeff = math.exp(-1.0 / (sampleRate * (releaseMs / 1000.0)));
}) : _thresholdDb = thresholdDb,
_ratio = ratio,
_makeupGain = math.pow(10.0, makeupGainDb / 20.0).toDouble(),
_attackCoeff = math.exp(-1.0 / (sampleRate * (attackMs / 1000.0))),
_releaseCoeff = math.exp(-1.0 / (sampleRate * (releaseMs / 1000.0)));
double process(double x) {
final absX = x.abs();
@@ -266,14 +264,14 @@ class _VoiceBandPassFilter {
required int sampleRate,
required double lowCutHz,
required double highCutHz,
}) : _highPass = _BiquadFilter.highPass(
sampleRate: sampleRate.toDouble(),
cutoffHz: lowCutHz,
),
_lowPass = _BiquadFilter.lowPass(
sampleRate: sampleRate.toDouble(),
cutoffHz: highCutHz,
);
}) : _highPass = _BiquadFilter.highPass(
sampleRate: sampleRate.toDouble(),
cutoffHz: lowCutHz,
),
_lowPass = _BiquadFilter.lowPass(
sampleRate: sampleRate.toDouble(),
cutoffHz: highCutHz,
);
Int16List process(Int16List input) {
final output = Int16List(input.length);
@@ -306,11 +304,11 @@ class _BiquadFilter {
required double b2,
required double a1,
required double a2,
}) : _b0 = b0,
_b1 = b1,
_b2 = b2,
_a1 = a1,
_a2 = a2;
}) : _b0 = b0,
_b1 = b1,
_b2 = b2,
_a1 = a1,
_a2 = a2;
factory _BiquadFilter.lowPass({
required double sampleRate,

View File

@@ -0,0 +1,25 @@
String formatPlusCode(double lat, double lon) {
const base = '23456789CFGHJMPQRVWX';
var normalizedLat = (lat + 90) / 180;
var normalizedLon = (lon + 180) / 360;
final buffer = StringBuffer();
for (var i = 0; i < 8; i++) {
if (i == 4) {
buffer.write('+');
}
final latDigit = (normalizedLat * 20).floor() % 20;
final lonDigit = (normalizedLon * 20).floor() % 20;
buffer
..write(base[latDigit])
..write(base[lonDigit]);
normalizedLat = (normalizedLat * 20) % 1;
normalizedLon = (normalizedLon * 20) % 1;
}
return buffer.toString();
}

View File

@@ -0,0 +1,129 @@
import 'dart:typed_data';
import '../models/contact.dart';
class DecodedLogRxRoute {
final int payloadType;
final List<int> pathHashes;
const DecodedLogRxRoute({
required this.payloadType,
required this.pathHashes,
});
int? get originalSenderHash => pathHashes.isEmpty ? null : pathHashes.first;
}
class ResolvedNodeHash {
final int hash;
final String label;
final bool isOwnNode;
final bool isUniqueMatch;
final int matchCount;
const ResolvedNodeHash({
required this.hash,
required this.label,
required this.isOwnNode,
required this.isUniqueMatch,
required this.matchCount,
});
String get hexLabel => '0x${hash.toRadixString(16).padLeft(2, '0')}';
}
class LogRxRouteDecoder {
const LogRxRouteDecoder._();
static DecodedLogRxRoute? decode(Uint8List rawData) {
if (rawData.length < 5 || rawData[0] != 0x88) return null;
final rawPacketData = rawData.sublist(3);
if (rawPacketData.length < 2) return null;
final header = rawPacketData[0];
final routeType = header & 0x03;
final payloadType = (header >> 2) & 0x0F;
var index = 1;
if (routeType == 0x00 || routeType == 0x03) {
if (rawPacketData.length < index + 5) return null;
index += 4;
}
if (rawPacketData.length <= index) return null;
final pathLen = rawPacketData[index++];
if (rawPacketData.length < index + pathLen) return null;
return DecodedLogRxRoute(
payloadType: payloadType,
pathHashes: rawPacketData.sublist(index, index + pathLen),
);
}
static ResolvedNodeHash resolveHash(
int hash, {
required Iterable<Contact> contacts,
Uint8List? ownPublicKey,
String? ownName,
}) {
final ownHash = ownPublicKey != null && ownPublicKey.isNotEmpty
? ownPublicKey.first
: null;
if (ownHash == hash) {
final ownLabel = (ownName != null && ownName.trim().isNotEmpty)
? '$ownName (you)'
: 'You';
return ResolvedNodeHash(
hash: hash,
label: ownLabel,
isOwnNode: true,
isUniqueMatch: true,
matchCount: 1,
);
}
final matches = contacts.where((contact) {
return contact.publicKey.isNotEmpty && contact.publicKey.first == hash;
}).toList();
if (matches.isEmpty) {
return ResolvedNodeHash(
hash: hash,
label: 'Unknown',
isOwnNode: false,
isUniqueMatch: false,
matchCount: 0,
);
}
if (matches.length == 1) {
return ResolvedNodeHash(
hash: hash,
label: matches.first.displayName,
isOwnNode: false,
isUniqueMatch: true,
matchCount: 1,
);
}
final candidateNames = matches
.map((contact) => contact.displayName)
.where((name) => name.trim().isNotEmpty)
.take(2)
.join(', ');
final extraCount = matches.length - 2;
final label = candidateNames.isEmpty
? '${matches.length} contacts'
: extraCount > 0
? '$candidateNames +$extraCount'
: candidateNames;
return ResolvedNodeHash(
hash: hash,
label: label,
isOwnNode: false,
isUniqueMatch: false,
matchCount: matches.length,
);
}
}

View File

@@ -0,0 +1,149 @@
import '../models/message.dart';
import 'image_message_parser.dart';
import 'voice_message_parser.dart';
const int _defaultLoRaSf = 10;
const int _defaultLoRaCr = 5;
const int _defaultLoRaBwHz = 250000;
const int _defaultLoRaPreambleSymbols = 8;
const int _defaultLoRaCrcEnabled = 1;
const int _defaultLoRaExplicitHeader = 1;
const double _defaultAirtimeBudgetFactor = 1.0;
const int _meshPacketHeaderBytes = 2;
const int _textFrameBaseBytes = 10;
Duration estimateMessageTransmitDuration(
Message message, {
int? radioBw,
int? radioSf,
int? radioCr,
}) {
final imageEnvelope = ImageEnvelope.tryParse(message.text);
if (imageEnvelope != null) {
return estimateImageTransmitDuration(
fragmentCount: imageEnvelope.total,
sizeBytes: imageEnvelope.sizeBytes,
pathLen: message.pathLen,
radioBw: radioBw,
radioSf: radioSf,
radioCr: radioCr,
);
}
final voiceEnvelope = VoiceEnvelope.tryParseText(message.text);
if (voiceEnvelope != null) {
return estimateVoiceTransmitDuration(
mode: voiceEnvelope.mode,
packetCount: voiceEnvelope.total,
durationMs: voiceEnvelope.durationMs,
pathLen: message.pathLen,
radioBw: radioBw,
radioSf: radioSf,
radioCr: radioCr,
);
}
final voicePacket = VoicePacket.tryParseText(message.text);
if (voicePacket != null) {
return estimateVoiceTransmitDuration(
mode: voicePacket.mode,
packetCount: voicePacket.total,
durationMs: voicePacket.durationMs * voicePacket.total,
pathLen: message.pathLen,
radioBw: radioBw,
radioSf: radioSf,
radioCr: radioCr,
);
}
final normalizedPathLen = _normalizedPathLen(message.pathLen);
final payloadBytes = _textFrameBaseBytes + message.text.length;
final hops = normalizedPathLen + 1;
final airtimeMs = _estimateLoRaAirtimeMs(
_meshPacketHeaderBytes + normalizedPathLen + payloadBytes,
radioBw: radioBw,
radioSf: radioSf,
radioCr: radioCr,
);
return Duration(
milliseconds: (airtimeMs * (1.0 + _defaultAirtimeBudgetFactor) * hops)
.round(),
);
}
int _normalizedPathLen(int pathLen) {
if (pathLen < 0 || pathLen >= 255) return 0;
return pathLen.clamp(0, 64).toInt();
}
double _estimateLoRaAirtimeMs(
int payloadLenBytes, {
int? radioBw,
int? radioSf,
int? radioCr,
}) {
final sf = _normalizeSf(radioSf);
final bw = _resolveBandwidthHz(radioBw).toDouble();
final cr = (_normalizeCr(radioCr) - 4).clamp(1, 4);
final ih = _defaultLoRaExplicitHeader == 1 ? 0 : 1;
final de = (sf >= 11 && _defaultLoRaBwHz <= 125000) ? 1 : 0;
final symbolMs = ((1 << sf) / bw) * 1000.0;
final preambleMs = (_defaultLoRaPreambleSymbols + 4.25) * symbolMs;
final num =
(8 * payloadLenBytes) -
(4 * sf) +
28 +
(16 * _defaultLoRaCrcEnabled) -
(20 * ih);
final den = 4 * (sf - (2 * de));
final payloadSymCoeff = den <= 0 ? 0 : (num / den).ceil();
final payloadSymbols =
8 + (payloadSymCoeff < 0 ? 0 : payloadSymCoeff) * (cr + 4);
final payloadMs = payloadSymbols * symbolMs;
return preambleMs + payloadMs;
}
int _normalizeSf(int? value) {
if (value == null) return _defaultLoRaSf;
if (value >= 5 && value <= 12) return value;
return _defaultLoRaSf;
}
int _normalizeCr(int? value) {
if (value == null) return _defaultLoRaCr;
if (value >= 5 && value <= 8) return value;
return _defaultLoRaCr;
}
int _resolveBandwidthHz(int? rawBw) {
if (rawBw == null) return _defaultLoRaBwHz;
if (rawBw > 1000) return rawBw;
switch (rawBw) {
case 0:
return 7800;
case 1:
return 10400;
case 2:
return 15600;
case 3:
return 20800;
case 4:
return 31250;
case 5:
return 41700;
case 6:
return 62500;
case 7:
return 125000;
case 8:
return 250000;
case 9:
return 500000;
default:
return _defaultLoRaBwHz;
}
}

View File

@@ -0,0 +1,87 @@
import 'dart:typed_data';
class RawRouteProbeRequest {
static const int _binaryMagic = 0x70; // 'p'
final int nonce;
final String requesterKey6;
const RawRouteProbeRequest({
required this.nonce,
required this.requesterKey6,
});
static RawRouteProbeRequest? tryParseBinary(Uint8List payload) {
if (payload.length != 11 || payload[0] != _binaryMagic) return null;
try {
final nonce =
(payload[1] << 24) |
(payload[2] << 16) |
(payload[3] << 8) |
payload[4];
final requesterKey6 = payload
.sublist(5, 11)
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join()
.toLowerCase();
return RawRouteProbeRequest(nonce: nonce, requesterKey6: requesterKey6);
} catch (_) {
return null;
}
}
Uint8List encodeBinary() {
if (!RegExp(r'^[0-9a-fA-F]{12}$').hasMatch(requesterKey6)) {
throw ArgumentError.value(
requesterKey6,
'requesterKey6',
'Expected 12 hex chars',
);
}
final out = Uint8List(11);
out[0] = _binaryMagic;
out[1] = (nonce >> 24) & 0xFF;
out[2] = (nonce >> 16) & 0xFF;
out[3] = (nonce >> 8) & 0xFF;
out[4] = nonce & 0xFF;
for (var i = 0; i < 6; i++) {
out[5 + i] = int.parse(
requesterKey6.substring(i * 2, i * 2 + 2),
radix: 16,
);
}
return out;
}
}
class RawRouteProbeAck {
static const int _binaryMagic = 0x71; // 'q'
final int nonce;
const RawRouteProbeAck({required this.nonce});
static RawRouteProbeAck? tryParseBinary(Uint8List payload) {
if (payload.length != 5 || payload[0] != _binaryMagic) return null;
try {
final nonce =
(payload[1] << 24) |
(payload[2] << 16) |
(payload[3] << 8) |
payload[4];
return RawRouteProbeAck(nonce: nonce);
} catch (_) {
return null;
}
}
Uint8List encodeBinary() {
final out = Uint8List(5);
out[0] = _binaryMagic;
out[1] = (nonce >> 24) & 0xFF;
out[2] = (nonce >> 16) & 0xFF;
out[3] = (nonce >> 8) & 0xFF;
out[4] = nonce & 0xFF;
return out;
}
}

View File

@@ -3,7 +3,7 @@ import 'dart:typed_data';
import '../models/contact.dart';
import '../providers/contacts_provider.dart';
enum TransmissionTargetFailure { unknownContact, unknownRoute, tooFar }
enum TransmissionTargetFailure { unknownContact, unknownRoute, tooFar, unreachable }
class TransmissionTargetResolution {
final Contact? target;

View File

@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:latlong2/latlong.dart';
import '../../l10n/app_localizations.dart';
import '../../utils/location_formats.dart';
/// Reusable location display widget with tap-to-show modal
/// Shows coordinates in a compact format with ability to view all formats
@@ -35,9 +36,9 @@ class LocationDisplay extends StatelessWidget {
Text(
'${location.latitude.toStringAsFixed(5)}, ${location.longitude.toStringAsFixed(5)}',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontFamily: 'monospace',
fontWeight: FontWeight.w600,
),
fontFamily: 'monospace',
fontWeight: FontWeight.w600,
),
),
const SizedBox(width: 6),
Icon(
@@ -54,9 +55,9 @@ class LocationDisplay extends StatelessWidget {
// Non-compact version (just text)
return Text(
'${location.latitude.toStringAsFixed(5)}, ${location.longitude.toStringAsFixed(5)}',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontFamily: 'monospace',
),
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(fontFamily: 'monospace'),
);
}
@@ -79,10 +80,7 @@ class LocationDisplay extends StatelessWidget {
children: [
const Text(
'Location Formats',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
),
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
IconButton(
icon: const Icon(Icons.close),
@@ -121,7 +119,7 @@ class LocationDisplay extends StatelessWidget {
_buildFormatRow(
context,
'Plus Code',
_convertToPlusCode(location.latitude, location.longitude),
formatPlusCode(location.latitude, location.longitude),
),
const SizedBox(height: 8),
],
@@ -140,9 +138,9 @@ class LocationDisplay extends StatelessWidget {
Text(
label,
style: Theme.of(context).textTheme.labelMedium?.copyWith(
color: Colors.grey,
fontWeight: FontWeight.w500,
),
color: Colors.grey,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 4),
InkWell(
@@ -150,7 +148,9 @@ class LocationDisplay extends StatelessWidget {
Clipboard.setData(ClipboardData(text: value));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(AppLocalizations.of(context)!.copiedToClipboard(label)),
content: Text(
AppLocalizations.of(context)!.copiedToClipboard(label),
),
duration: const Duration(seconds: 2),
),
);
@@ -168,9 +168,9 @@ class LocationDisplay extends StatelessWidget {
child: Text(
value,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontFamily: 'monospace',
fontWeight: FontWeight.w500,
),
fontFamily: 'monospace',
fontWeight: FontWeight.w500,
),
),
),
Icon(
@@ -242,31 +242,4 @@ class LocationDisplay extends StatelessWidget {
// Full MGRS would require UTM conversion library
return '$zone$letter (approximate)';
}
/// Convert to Google Plus Code format
/// Simplified implementation - returns approximate code
String _convertToPlusCode(double lat, double lon) {
// This is a simplified version - full Plus Code requires the open_location_code package
const base = '23456789CFGHJMPQRVWX';
// Normalize coordinates
lat = (lat + 90) / 180; // 0 to 1
lon = (lon + 180) / 360; // 0 to 1
String code = '';
for (int i = 0; i < 8; i++) {
if (i == 4) code += '+';
int latDigit = (lat * 20).floor() % 20;
int lonDigit = (lon * 20).floor() % 20;
code += base[latDigit];
code += base[lonDigit];
lat = (lat * 20) % 1;
lon = (lon * 20) % 1;
}
return code;
}
}

View File

@@ -21,10 +21,19 @@ class _ConnectionDialogState extends State<ConnectionDialog>
final List<DiscoveredServer> _discoveredServers = [];
int _scannedCount = 0;
int _totalToScan = 0;
String? _connectingToServerKey; // Track which server is being connected to (ip:port)
int _lastTabIndex = 0;
String?
_connectingToServerKey; // Track which server is being connected to (ip:port)
// Named listener method for proper cleanup
void _onTabChanged() {
if (_tabController.index == _lastTabIndex) return;
_lastTabIndex = _tabController.index;
if (_tabController.index == 0) {
_refreshBleDevices();
}
if (_tabController.index == 1) {
// Switched to network tab
if (_networkScanner.hasCachedResults && _discoveredServers.isEmpty) {
@@ -47,13 +56,16 @@ class _ConnectionDialogState extends State<ConnectionDialog>
void initState() {
super.initState();
_tabController = TabController(length: 2, vsync: this);
_connectionProvider = Provider.of<ConnectionProvider>(context, listen: false);
_connectionProvider = Provider.of<ConnectionProvider>(
context,
listen: false,
);
// Defer scan startup until after the first frame so Provider listeners
// are not notified while this dialog is still being built.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
_connectionProvider.startScan();
_refreshBleDevices();
});
// Set up network scanner callbacks
@@ -101,6 +113,12 @@ class _ConnectionDialogState extends State<ConnectionDialog>
_networkScanner.scan();
}
Future<void> _refreshBleDevices() async {
await _connectionProvider.stopScan();
if (!mounted) return;
await _connectionProvider.startScan();
}
Color _getSignalColor(int rssi) {
if (rssi >= -60) return Colors.green;
if (rssi >= -75) return Colors.orange;
@@ -218,10 +236,7 @@ class _ConnectionDialogState extends State<ConnectionDialog>
Icons.refresh,
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
onPressed: () {
connectionProvider.stopScan();
connectionProvider.startScan();
},
onPressed: _refreshBleDevices,
),
],
),
@@ -255,10 +270,7 @@ class _ConnectionDialogState extends State<ConnectionDialog>
),
const SizedBox(height: 8),
TextButton.icon(
onPressed: () {
connectionProvider.stopScan();
connectionProvider.startScan();
},
onPressed: _refreshBleDevices,
icon: const Icon(Icons.refresh),
label: Text(AppLocalizations.of(context)!.scanAgain),
),

View File

@@ -11,6 +11,7 @@ import '../../providers/map_provider.dart';
import '../../providers/app_provider.dart';
import 'direct_message_sheet.dart';
import 'room_login_sheet.dart';
import '../../utils/location_formats.dart';
import '../../utils/toast_logger.dart';
import '../../utils/battery_display_helper.dart';
import '../../l10n/app_localizations.dart';
@@ -69,6 +70,9 @@ class ContactTile extends StatelessWidget {
// Get room login state if this is a room
final connectionProvider = context.watch<ConnectionProvider>();
final isPingInProgress = connectionProvider.isPingInProgress(
contact.publicKey,
);
final roomLoginState = contact.type == ContactType.room
? connectionProvider.getRoomLoginState(contact.publicKeyPrefix)
: null;
@@ -187,6 +191,17 @@ class ContactTile extends StatelessWidget {
),
),
],
if (isPingInProgress) ...[
const SizedBox(width: 6),
SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Theme.of(context).colorScheme.primary,
),
),
],
],
),
subtitle: isSimpleMode
@@ -457,39 +472,43 @@ class ContactTile extends StatelessWidget {
_showContactDetails(context, contact);
}
},
onLongPress: () async {
final connectionProvider = context.read<ConnectionProvider>();
onLongPress: isPingInProgress
? null
: () async {
final connectionProvider = context.read<ConnectionProvider>();
// Determine if we should use flooding (no path) or direct (has path)
final hasPath = contact.hasPath;
// Determine if we should use flooding (no path) or direct (has path)
final hasPath = contact.hasPath;
// Use smart ping with automatic fallback
final result = await connectionProvider.smartPing(
contactPublicKey: contact.publicKey,
hasPath: hasPath,
onRetryWithFlooding: () {
// Called when retrying with flooding after direct timeout
if (context.mounted) {
ToastLogger.warning(
context,
AppLocalizations.of(
context,
)!.directPingTimeout(contact.displayName),
// Use smart ping with automatic fallback
final result = await connectionProvider.smartPing(
contactPublicKey: contact.publicKey,
hasPath: hasPath,
onRetryWithFlooding: () {
// Called when retrying with flooding after direct timeout
if (context.mounted) {
ToastLogger.warning(
context,
AppLocalizations.of(
context,
)!.directPingTimeout(contact.displayName),
);
}
},
);
}
},
);
// Show final result
if (context.mounted) {
if (!result.success) {
ToastLogger.error(
context,
AppLocalizations.of(context)!.pingFailed(contact.displayName),
);
}
}
},
// Show final result
if (context.mounted) {
if (!result.success) {
ToastLogger.error(
context,
AppLocalizations.of(
context,
)!.pingFailed(contact.displayName),
);
}
}
},
),
);
}
@@ -602,8 +621,12 @@ class ContactTile extends StatelessWidget {
minChildSize: 0.4,
maxChildSize: 0.9,
expand: false,
builder: (context, scrollController) => Column(
children: [
builder: (context, scrollController) {
final isPingInProgress = context
.watch<ConnectionProvider>()
.isPingInProgress(contact.publicKey);
return Column(
children: [
// Handle bar
Container(
margin: const EdgeInsets.only(top: 8, bottom: 16),
@@ -827,7 +850,7 @@ class ContactTile extends StatelessWidget {
_detailRowWithCopy(
context,
'Plus Code',
_convertToPlusCode(
formatPlusCode(
contact.displayLocation!.latitude,
contact.displayLocation!.longitude,
),
@@ -846,15 +869,25 @@ class ContactTile extends StatelessWidget {
),
),
TextButton.icon(
onPressed: () {
final connectionProvider = context
.read<ConnectionProvider>();
connectionProvider.requestTelemetry(
contact.publicKey,
zeroHop: true,
);
},
icon: const Icon(Icons.refresh, size: 18),
onPressed: isPingInProgress
? null
: () {
final connectionProvider = context
.read<ConnectionProvider>();
connectionProvider.requestTelemetry(
contact.publicKey,
zeroHop: true,
);
},
icon: isPingInProgress
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: const Icon(Icons.refresh, size: 18),
label: Text(AppLocalizations.of(context)!.refresh),
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(
@@ -990,8 +1023,9 @@ class ContactTile extends StatelessWidget {
],
),
),
],
),
],
);
},
),
);
}
@@ -1113,34 +1147,6 @@ class ContactTile extends StatelessWidget {
return '$zone$letter (approximate)';
}
/// Convert to Google Plus Code format
/// Simplified implementation - returns approximate code
String _convertToPlusCode(double lat, double lon) {
// This is a simplified version - full Plus Code requires the open_location_code package
// For now, return a placeholder that shows it's not fully implemented
const base = '23456789CFGHJMPQRVWX';
// Normalize coordinates
lat = (lat + 90) / 180; // 0 to 1
lon = (lon + 180) / 360; // 0 to 1
String code = '';
for (int i = 0; i < 8; i++) {
if (i == 4) code += '+';
int latDigit = (lat * 20).floor() % 20;
int lonDigit = (lon * 20).floor() % 20;
code += base[latDigit];
code += base[lonDigit];
lat = (lat * 20) % 1;
lon = (lon * 20) % 1;
}
return code;
}
IconData _getTypeIcon(ContactType type) {
switch (type) {
case ContactType.chat:

View File

@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_avif/flutter_avif.dart';
import 'package:provider/provider.dart';
import '../../models/message.dart';
import '../../providers/app_provider.dart';
import '../../providers/connection_provider.dart';
import '../../providers/contacts_provider.dart';
import '../../providers/image_provider.dart' as ip;
@@ -254,11 +255,17 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
int pathLen = 0,
}) async {
if (_isRequesting) return;
setState(() {
_isRequesting = true;
_errorText = null;
});
final conn = context.read<ConnectionProvider>();
final imageProvider = context.read<ip.ImageProvider>();
imageProvider.resumeIncomingSession(envelope.sessionId);
final contactsProvider = context.read<ContactsProvider>();
final resolution = await TransmissionTargetResolver.resolveFetchTarget(
final appProvider = context.read<AppProvider>();
var resolution = await TransmissionTargetResolver.resolveFetchTarget(
contactsProvider: contactsProvider,
refreshContacts: conn.getContacts,
isSentByMe: widget.isSentByMe,
@@ -271,6 +278,7 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
if (!mounted) return;
if (resolution.failure == TransmissionTargetFailure.unknownContact) {
_clearRequestState();
await _showBlockingAlert(
'Cannot fetch image',
'Sender contact is unknown. Sync contacts first.',
@@ -278,6 +286,7 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
return;
}
if (resolution.failure == TransmissionTargetFailure.unknownRoute) {
_clearRequestState();
await _showBlockingAlert(
'Cannot fetch image',
'Sender route is unknown. Sync contacts/path first.',
@@ -285,14 +294,76 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
return;
}
if (resolution.failure == TransmissionTargetFailure.tooFar) {
_clearRequestState();
await _showBlockingAlert(
'Cannot fetch image',
'Message is too far (${resolution.hops} hops, max ${resolution.maxHops}).',
);
return;
}
if (resolution.failure == TransmissionTargetFailure.unreachable) {
_clearRequestState();
await _showBlockingAlert(
'Cannot fetch image',
'Sender route did not respond to a path check. Sync contacts/path and try again.',
);
return;
}
var sender = resolution.target!;
var routeVerified = await appProvider.verifyRawTransportRoute(sender);
if (!mounted) return;
if (!routeVerified) {
await conn.getContacts();
if (!mounted) return;
resolution = await TransmissionTargetResolver.resolveFetchTarget(
contactsProvider: contactsProvider,
refreshContacts: conn.getContacts,
isSentByMe: widget.isSentByMe,
recipientPublicKey: widget.message.recipientPublicKey,
senderPublicKeyPrefix: widget.message.senderPublicKeyPrefix,
senderKey6FromEnvelope: envelope.senderKey6,
senderName: widget.message.senderName,
maxFetchHops: _maxFetchHops,
);
if (!mounted) return;
if (resolution.failure == TransmissionTargetFailure.unknownContact) {
_clearRequestState();
await _showBlockingAlert(
'Cannot fetch image',
'Sender contact is unknown. Sync contacts first.',
);
return;
}
if (resolution.failure == TransmissionTargetFailure.unknownRoute) {
_clearRequestState();
await _showBlockingAlert(
'Cannot fetch image',
'Sender route is unknown. Sync contacts/path first.',
);
return;
}
if (resolution.failure == TransmissionTargetFailure.tooFar) {
_clearRequestState();
await _showBlockingAlert(
'Cannot fetch image',
'Message is too far (${resolution.hops} hops, max ${resolution.maxHops}).',
);
return;
}
sender = resolution.target!;
routeVerified = await appProvider.verifyRawTransportRoute(sender);
if (!mounted) return;
if (!routeVerified) {
_clearRequestState();
await _showBlockingAlert(
'Cannot fetch image',
'Sender route did not respond on the raw transport path.',
);
return;
}
}
final sender = resolution.target!;
if (sender.outPathLen >= 2) {
_showToast(
'Image fetch over ${sender.outPathLen} hops may take a while.',
@@ -302,6 +373,7 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
setState(() => _errorText = null);
final deviceKey = conn.deviceInfo.publicKey;
if (deviceKey == null || deviceKey.length < 6) {
_clearRequestState();
await _showBlockingAlert(
'Cannot fetch image',
'Device key is unavailable.',
@@ -332,13 +404,11 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
timestampSec: DateTime.now().millisecondsSinceEpoch ~/ 1000,
);
setState(() {
_isRequesting = true;
_errorText = null;
});
final payload = request.encodeBinary();
try {
debugPrint(
'📷 [ImageMessageBubble] Outgoing image fetch request: session=${envelope.sessionId} want=${isPartialResume ? 'missing' : 'all'} target=${sender.advName} hops=${sender.outPathLen}',
);
await conn.sendRawVoicePacket(
contactPath: sender.outPath,
contactPathLen: sender.outPathLen,
@@ -405,6 +475,13 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
});
}
void _clearRequestState() {
if (!mounted) return;
setState(() {
_isRequesting = false;
});
}
Future<void> _showBlockingAlert(String title, String message) async {
if (!mounted) return;
_showToast('$title: $message');

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,160 @@
import 'package:flutter/material.dart';
import '../../models/contact.dart';
import '../../models/message.dart';
import '../../utils/avatar_label_helper.dart';
import '../../utils/message_extensions.dart';
import '../common/contact_avatar.dart';
Widget buildMessageHeaderAvatar(
BuildContext context, {
required bool isOwnMessage,
required bool isChannelMessage,
required dynamic senderContact,
required String displayName,
}) {
if (isOwnMessage) {
return CircleAvatar(
radius: 10.5,
backgroundColor: Theme.of(context).colorScheme.primaryContainer,
child: Icon(
Icons.account_circle,
size: 16,
color: Theme.of(context).colorScheme.primary,
),
);
}
if (senderContact is Contact) {
return ContactAvatar(
contact: senderContact,
radius: 10.5,
displayName: displayName,
);
}
final background = isChannelMessage
? Colors.teal.withValues(alpha: 0.16)
: Theme.of(context).colorScheme.surfaceContainerHighest;
final foreground = isChannelMessage
? Colors.teal.shade800
: Theme.of(context).colorScheme.onSurfaceVariant;
return CircleAvatar(
radius: 10.5,
backgroundColor: background,
child: Text(
AvatarLabelHelper.buildLabel(displayName),
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w700,
color: foreground,
letterSpacing: -0.2,
),
),
);
}
Widget buildBubbleMetaFooter(
BuildContext context, {
required Message message,
required bool isSarMarker,
}) {
final metaColor = Theme.of(
context,
).textTheme.labelSmall?.color?.withValues(alpha: 0.68);
final items = <Widget>[];
if (!isSarMarker && message.pathLen < 255) {
items.addAll([
Icon(Icons.alt_route, size: 11, color: metaColor),
const SizedBox(width: 3),
Text(
message.pathLen == 0 ? 'direct' : '${message.pathLen}hop',
style: Theme.of(
context,
).textTheme.labelSmall?.copyWith(color: metaColor),
),
Text(
'',
style: Theme.of(
context,
).textTheme.labelSmall?.copyWith(color: metaColor),
),
]);
}
items.add(
Text(
message.getLocalizedTimeAgo(context),
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: metaColor,
fontWeight: FontWeight.w500,
),
),
);
return Padding(
padding: const EdgeInsets.only(left: 6, right: 6, top: 1, bottom: 18),
child: Align(
alignment: Alignment.centerRight,
child: Row(mainAxisSize: MainAxisSize.min, children: items),
),
);
}
Widget buildChannelHeaderPill(
BuildContext context, {
required String label,
IconData icon = Icons.campaign_outlined,
}) {
final labelColor = Theme.of(
context,
).textTheme.labelSmall?.color?.withValues(alpha: 0.82);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: Theme.of(
context,
).colorScheme.surfaceContainerHighest.withValues(alpha: 0.65),
borderRadius: BorderRadius.circular(999),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
icon,
size: 11,
color: Theme.of(
context,
).textTheme.labelSmall?.color?.withValues(alpha: 0.7),
),
const SizedBox(width: 5),
Flexible(
child: Text(
label,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: labelColor,
fontWeight: FontWeight.w600,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
);
}
Widget buildDirectHeaderCounterpart(
BuildContext context, {
required String label,
}) {
return buildChannelHeaderPill(
context,
label: label,
icon: Icons.alternate_email,
);
}

View File

@@ -0,0 +1,382 @@
import 'package:flutter/material.dart';
import '../../models/message.dart';
import '../../models/message_reception_details.dart';
IconData getDeliveryStatusIcon(MessageDeliveryStatus status) {
switch (status) {
case MessageDeliveryStatus.sending:
return Icons.schedule;
case MessageDeliveryStatus.sent:
return Icons.done;
case MessageDeliveryStatus.delivered:
return Icons.done_all;
case MessageDeliveryStatus.failed:
return Icons.error_outline;
case MessageDeliveryStatus.received:
return Icons.inbox;
}
}
Color getDeliveryStatusColor(MessageDeliveryStatus status) {
switch (status) {
case MessageDeliveryStatus.sending:
return Colors.orange;
case MessageDeliveryStatus.sent:
return Colors.blue;
case MessageDeliveryStatus.delivered:
return Colors.green;
case MessageDeliveryStatus.failed:
return Colors.red;
case MessageDeliveryStatus.received:
return Colors.grey;
}
}
Widget buildChannelEchoStatus(BuildContext context, Message message) {
final hasEcho = message.echoCount > 0;
if (!hasEcho) {
return const SizedBox.shrink();
}
final statusColor = getDeliveryStatusColor(message.deliveryStatus);
final rssi = message.lastEchoRssiDbm;
final snr = message.lastEchoSnrRaw != null
? message.lastEchoSnrRaw!.toSigned(8) / 4.0
: null;
final quality = linkQualityLabel(rssi, snr);
final qualityColor = linkQualityColor(quality);
return Wrap(
spacing: 4,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
_techChip(
context,
icon: Icons.hub_outlined,
label: 'x${message.echoCount}',
color: statusColor,
),
if (message.expectedAckTag != null)
_techChip(
context,
icon: Icons.tag,
label:
'ACK ${message.expectedAckTag!.toRadixString(16).toUpperCase()}',
color: Colors.indigo,
),
_techChip(context, icon: Icons.bolt, label: quality, color: qualityColor),
if (message.lastEchoRssiDbm != null)
_signalCapsule(
context,
icon: Icons.network_cell,
label: message.lastEchoRssiDbm!.toString(),
filled: rssiScore(message.lastEchoRssiDbm!),
color: Colors.blueGrey,
),
if (message.lastEchoSnrRaw != null)
_signalCapsule(
context,
icon: Icons.graphic_eq,
label: (message.lastEchoSnrRaw!.toSigned(8) / 4.0).toStringAsFixed(1),
filled: snrScore(message.lastEchoSnrRaw!.toSigned(8) / 4.0),
color: Colors.teal,
),
],
);
}
bool shouldShowSentChannelStats(
Message message, {
required bool showReceivedStats,
}) {
if (!message.isSentMessage || !message.isChannelMessage) {
return false;
}
final hasSignalData =
message.echoCount > 0 ||
message.lastEchoRssiDbm != null ||
message.lastEchoSnrRaw != null ||
message.expectedAckTag != null;
return showReceivedStats && hasSignalData;
}
Widget buildReceivedSignalStatus(
BuildContext context,
Message message, {
MessageReceptionDetails? receptionDetails,
required int? rssiDbm,
required double? snrDb,
}) {
final hopLabel = hopDisplayLabel(message);
return Wrap(
spacing: 4,
runSpacing: 4,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
_techChip(
context,
icon: Icons.alt_route,
label: hopLabel,
color: Colors.indigo,
),
if (receptionDetails?.senderToReceiptMs != null)
_techChip(
context,
icon: Icons.schedule,
label: _formatMs(receptionDetails!.senderToReceiptMs!),
color: Colors.deepPurple,
),
if (receptionDetails?.estimatedTransmitMs != null)
_techChip(
context,
icon: Icons.timelapse,
label: '~${_formatMs(receptionDetails!.estimatedTransmitMs!)} tx',
color: Colors.blue,
),
if (receptionDetails?.postTransmitDelayMs != null)
_techChip(
context,
icon: Icons.hourglass_bottom,
label: '+${_formatMs(receptionDetails!.postTransmitDelayMs!)} lag',
color: Colors.orange,
),
if (receptionDetails?.pathBytesHex != null)
_techChip(
context,
icon: Icons.route,
label: receptionDetails!.pathBytesHex!,
color: Colors.brown,
),
if (rssiDbm != null || snrDb != null) ...[
_techChip(
context,
icon: Icons.bolt,
label: linkQualityLabel(rssiDbm, snrDb),
color: linkQualityColor(linkQualityLabel(rssiDbm, snrDb)),
),
if (rssiDbm != null)
_signalCapsule(
context,
icon: Icons.network_cell,
label: '$rssiDbm',
filled: rssiScore(rssiDbm),
color: Colors.blueGrey,
),
if (snrDb != null)
_signalCapsule(
context,
icon: Icons.graphic_eq,
label: snrDb.toStringAsFixed(1),
filled: snrScore(snrDb),
color: Colors.teal,
),
],
],
);
}
Widget buildSentDirectSignalStatus(
BuildContext context,
Message message, {
required int roundTripTimeMs,
required Duration txEstimate,
}) {
final estimatedTransmitMs = sanitizeEstimatedTransmitMs(
estimatedTransmitMs: txEstimate > Duration.zero
? txEstimate.inMilliseconds
: null,
senderToReceiptMs: roundTripTimeMs,
);
final postTransmitDelayMs = estimatedTransmitMs != null
? (roundTripTimeMs - estimatedTransmitMs).clamp(0, 86400000).toInt()
: null;
return Wrap(
spacing: 4,
runSpacing: 4,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
_techChip(
context,
icon: Icons.alt_route,
label: hopDisplayLabel(message),
color: Colors.indigo,
),
_techChip(
context,
icon: Icons.schedule,
label: _formatMs(roundTripTimeMs),
color: Colors.deepPurple,
),
if (estimatedTransmitMs != null)
_techChip(
context,
icon: Icons.timelapse,
label: '~${_formatMs(estimatedTransmitMs)} tx',
color: Colors.blue,
),
if (postTransmitDelayMs != null)
_techChip(
context,
icon: Icons.hourglass_bottom,
label: '+${_formatMs(postTransmitDelayMs)} lag',
color: Colors.orange,
),
if (message.retryAttempt > 0)
_techChip(
context,
icon: Icons.refresh,
label: 'retry ${message.retryAttempt}/3',
color: Colors.redAccent,
),
if (message.suggestedTimeoutMs != null)
_techChip(
context,
icon: Icons.timer_outlined,
label: 'timeout ${_formatMs(message.suggestedTimeoutMs!)}',
color: Colors.blueGrey,
),
if (message.usedFloodFallback)
_techChip(
context,
icon: Icons.waves,
label: 'flood fallback',
color: Colors.teal,
)
else if (message.expectedAckTag != null)
_techChip(
context,
icon: Icons.route,
label: 'direct ACK',
color: Colors.indigo,
),
],
);
}
String _formatMs(int value) {
if (value >= 60000) {
final minutes = value ~/ 60000;
final seconds = (value % 60000) ~/ 1000;
return '${minutes}m ${seconds}s';
}
if (value >= 1000) {
return '${(value / 1000).toStringAsFixed(value >= 10000 ? 0 : 1)}s';
}
return '${value}ms';
}
String hopDisplayLabel(Message message) {
if (message.pathLen == 0) return 'Direct';
if (message.pathLen >= 255 && message.isContactMessage) return 'Direct';
if (message.pathLen >= 255) return 'Unknown';
return '${message.pathLen} hop${message.pathLen == 1 ? '' : 's'}';
}
Widget _techChip(
BuildContext context, {
required IconData icon,
required String label,
required Color color,
}) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1.5),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 10, color: color),
const SizedBox(width: 2),
Text(
label,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: color,
fontWeight: FontWeight.w600,
fontSize: 10,
),
),
],
),
);
}
Widget _signalCapsule(
BuildContext context, {
required IconData icon,
required String label,
required int filled,
required Color color,
}) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1.5),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 10, color: color),
const SizedBox(width: 2),
Row(
mainAxisSize: MainAxisSize.min,
children: List.generate(5, (i) {
final active = i < filled;
return Container(
width: 3,
height: (4 + i).toDouble(),
margin: const EdgeInsets.symmetric(horizontal: 0.5),
decoration: BoxDecoration(
color: active ? color : color.withValues(alpha: 0.18),
borderRadius: BorderRadius.circular(1),
),
);
}),
),
const SizedBox(width: 2),
Text(
label,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: color,
fontWeight: FontWeight.w600,
fontSize: 10,
),
),
],
),
);
}
int rssiScore(int rssiDbm) => ((rssiDbm + 120) / 10).round().clamp(0, 5);
int snrScore(double snrDb) => ((snrDb + 5.0) / 5.0).round().clamp(0, 5);
String linkQualityLabel(int? rssiDbm, double? snrDb) {
var score = 0;
if (rssiDbm != null) score += rssiScore(rssiDbm);
if (snrDb != null) score += snrScore(snrDb);
if (score >= 8) return 'Excellent';
if (score >= 6) return 'Good';
if (score >= 4) return 'Fair';
return 'Weak';
}
Color linkQualityColor(String quality) {
switch (quality) {
case 'Excellent':
return Colors.green;
case 'Good':
return Colors.lightGreen;
case 'Fair':
return Colors.orange;
default:
return Colors.redAccent;
}
}

View File

@@ -0,0 +1,432 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../l10n/app_localizations.dart';
class MessagesComposer extends StatelessWidget {
final TextEditingController textController;
final FocusNode focusNode;
final TextInputFormatter messageByteLimiter;
final int messageByteCount;
final int maxMessageBytes;
final bool isRecording;
final bool isSendingVoice;
final bool voiceSupported;
final double bottomPadding;
final String destinationLabel;
final Widget destinationAvatar;
final VoidCallback onShowComposerActions;
final VoidCallback onShowRecipientSelector;
final Future<void> Function() onStartVoiceRecording;
final Future<void> Function() onStopAndSendVoice;
final Future<void> Function() onSendMessage;
const MessagesComposer({
super.key,
required this.textController,
required this.focusNode,
required this.messageByteLimiter,
required this.messageByteCount,
required this.maxMessageBytes,
required this.isRecording,
required this.isSendingVoice,
required this.voiceSupported,
required this.bottomPadding,
required this.destinationLabel,
required this.destinationAvatar,
required this.onShowComposerActions,
required this.onShowRecipientSelector,
required this.onStartVoiceRecording,
required this.onStopAndSendVoice,
required this.onSendMessage,
});
@override
Widget build(BuildContext context) {
return Container(
decoration: const BoxDecoration(color: Colors.transparent),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
SafeArea(
top: false,
child: Padding(
padding: EdgeInsets.fromLTRB(10, 4, 10, bottomPadding),
child: Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(28),
border: Border.all(
color: Theme.of(
context,
).dividerColor.withValues(alpha: 0.35),
),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.05),
blurRadius: 18,
offset: const Offset(0, 6),
),
],
),
child: Padding(
padding: const EdgeInsets.fromLTRB(10, 10, 10, 8),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
_ComposerActionButton(
isRecording: isRecording,
onPressed: isRecording
? onStopAndSendVoice
: onShowComposerActions,
),
const SizedBox(width: 8),
Expanded(
child: _DestinationSelector(
destinationLabel: destinationLabel,
destinationAvatar: destinationAvatar,
onTap: onShowRecipientSelector,
),
),
],
),
const SizedBox(height: 8),
ListenableBuilder(
listenable: Listenable.merge([
textController,
focusNode,
]),
builder: (context, _) {
final canSendText =
!isRecording &&
!isSendingVoice &&
textController.text.trim().isNotEmpty;
final semanticsLabel = isRecording
? 'Recording... release to send voice'
: (isSendingVoice
? 'Sending voice...'
: voiceSupported
? 'Send (long press to record voice)'
: 'Send');
return Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: _MessageInput(
textController: textController,
focusNode: focusNode,
messageByteLimiter: messageByteLimiter,
),
),
const SizedBox(width: 8),
_SendButton(
canSendText: canSendText,
isRecording: isRecording,
isSendingVoice: isSendingVoice,
voiceSupported: voiceSupported,
semanticsLabel: semanticsLabel,
messageByteCount: messageByteCount,
maxMessageBytes: maxMessageBytes,
onSendMessage: onSendMessage,
onStartVoiceRecording: onStartVoiceRecording,
onStopAndSendVoice: onStopAndSendVoice,
),
],
);
},
),
],
),
),
),
),
),
],
),
);
}
}
class _ComposerActionButton extends StatelessWidget {
final bool isRecording;
final VoidCallback onPressed;
const _ComposerActionButton({
required this.isRecording,
required this.onPressed,
});
@override
Widget build(BuildContext context) {
return Container(
width: 42,
height: 42,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
shape: BoxShape.circle,
border: Border.all(
color: Theme.of(context).dividerColor.withValues(alpha: 0.35),
),
),
child: IconButton(
icon: Icon(isRecording ? Icons.stop : Icons.add, size: 22),
tooltip: isRecording ? 'Stop recording' : 'More actions',
onPressed: onPressed,
color: isRecording ? Colors.red : Theme.of(context).colorScheme.primary,
),
);
}
}
class _DestinationSelector extends StatelessWidget {
final String destinationLabel;
final Widget destinationAvatar;
final VoidCallback onTap;
const _DestinationSelector({
required this.destinationLabel,
required this.destinationAvatar,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(20),
onTap: onTap,
child: Ink(
height: 42,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: Theme.of(context).dividerColor.withValues(alpha: 0.35),
),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 14),
child: Row(
children: [
destinationAvatar,
const SizedBox(width: 10),
Expanded(
child: Text(
destinationLabel,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: Theme.of(context).colorScheme.onSurface,
),
),
),
Icon(
Icons.expand_more_rounded,
size: 20,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
],
),
),
),
),
);
}
}
class _MessageInput extends StatelessWidget {
final TextEditingController textController;
final FocusNode focusNode;
final TextInputFormatter messageByteLimiter;
const _MessageInput({
required this.textController,
required this.focusNode,
required this.messageByteLimiter,
});
@override
Widget build(BuildContext context) {
return AnimatedContainer(
duration: const Duration(milliseconds: 180),
constraints: const BoxConstraints(minHeight: 46, maxHeight: 132),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(24),
border: Border.all(
color: focusNode.hasFocus
? Theme.of(context).colorScheme.primary
: Theme.of(context).dividerColor.withValues(alpha: 0.35),
width: focusNode.hasFocus ? 1.4 : 1,
),
boxShadow: focusNode.hasFocus
? [
BoxShadow(
color: Theme.of(
context,
).colorScheme.primary.withValues(alpha: 0.10),
blurRadius: 12,
offset: const Offset(0, 4),
),
]
: null,
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: TextField(
controller: textController,
focusNode: focusNode,
minLines: 1,
maxLines: 4,
keyboardType: TextInputType.multiline,
inputFormatters: [messageByteLimiter],
style: const TextStyle(fontSize: 15),
textAlignVertical: TextAlignVertical.center,
decoration: InputDecoration(
hintText: AppLocalizations.of(context)!.typeYourMessage,
hintStyle: TextStyle(
fontSize: 15,
color: Theme.of(
context,
).colorScheme.onSurfaceVariant.withValues(alpha: 0.9),
),
filled: false,
fillColor: Colors.transparent,
border: InputBorder.none,
isCollapsed: true,
),
textInputAction: TextInputAction.newline,
),
),
);
}
}
class _SendButton extends StatelessWidget {
final bool canSendText;
final bool isRecording;
final bool isSendingVoice;
final bool voiceSupported;
final String semanticsLabel;
final int messageByteCount;
final int maxMessageBytes;
final Future<void> Function() onSendMessage;
final Future<void> Function() onStartVoiceRecording;
final Future<void> Function() onStopAndSendVoice;
const _SendButton({
required this.canSendText,
required this.isRecording,
required this.isSendingVoice,
required this.voiceSupported,
required this.semanticsLabel,
required this.messageByteCount,
required this.maxMessageBytes,
required this.onSendMessage,
required this.onStartVoiceRecording,
required this.onStopAndSendVoice,
});
@override
Widget build(BuildContext context) {
return Semantics(
button: true,
enabled: canSendText || (voiceSupported && !isSendingVoice),
label: semanticsLabel,
onTap: canSendText ? onSendMessage : null,
onLongPress: (voiceSupported && !isSendingVoice)
? () {
if (isRecording) {
onStopAndSendVoice();
return;
}
onStartVoiceRecording();
}
: null,
child: Tooltip(
message: semanticsLabel,
excludeFromSemantics: true,
child: GestureDetector(
excludeFromSemantics: true,
onTap: canSendText ? onSendMessage : null,
onLongPressStart: (voiceSupported && !isSendingVoice)
? (_) => onStartVoiceRecording()
: null,
onLongPressEnd: (voiceSupported && isRecording)
? (_) => onStopAndSendVoice()
: null,
onLongPressCancel: (voiceSupported && isRecording)
? onStopAndSendVoice
: null,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
AnimatedContainer(
duration: const Duration(milliseconds: 180),
width: 46,
height: 46,
decoration: BoxDecoration(
color: canSendText || isRecording
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.surface,
shape: BoxShape.circle,
border: Border.all(
color: canSendText || isRecording
? Colors.transparent
: Theme.of(
context,
).dividerColor.withValues(alpha: 0.35),
),
boxShadow: canSendText || isRecording
? [
BoxShadow(
color: Theme.of(
context,
).colorScheme.primary.withValues(alpha: 0.22),
blurRadius: 14,
offset: const Offset(0, 6),
),
]
: null,
),
child: isSendingVoice
? Center(
child: CircularProgressIndicator(
strokeWidth: 2,
color: Theme.of(context).colorScheme.onPrimary,
),
)
: Icon(
isRecording ? Icons.mic_rounded : Icons.send_rounded,
size: 22,
color: canSendText || isRecording
? Theme.of(context).colorScheme.onPrimary
: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 4),
Text(
'$messageByteCount/$maxMessageBytes',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w500,
color: messageByteCount > maxMessageBytes * 0.9
? Colors.orange.shade800
: Theme.of(
context,
).colorScheme.onSurfaceVariant.withValues(alpha: 0.9),
),
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,94 @@
import 'package:flutter/material.dart';
import '../../l10n/app_localizations.dart';
import '../../models/message.dart';
import '../../widgets/messages/message_bubble.dart';
class MessagesContent extends StatelessWidget {
static const double defaultPadding = 8;
final List<Message> messages;
final ScrollController scrollController;
final String? highlightedMessageId;
final double bottomContentPadding;
final Future<void> Function() onRefresh;
final VoidCallback? onNavigateToMap;
final ValueChanged<Message>? onMessageTap;
const MessagesContent({
super.key,
required this.messages,
required this.scrollController,
required this.highlightedMessageId,
this.bottomContentPadding = 0,
required this.onRefresh,
this.onNavigateToMap,
this.onMessageTap,
});
@override
Widget build(BuildContext context) {
return RefreshIndicator(
onRefresh: onRefresh,
child: messages.isEmpty
? LayoutBuilder(
builder: (context, constraints) => SingleChildScrollView(
keyboardDismissBehavior:
ScrollViewKeyboardDismissBehavior.onDrag,
physics: const AlwaysScrollableScrollPhysics(),
child: ConstrainedBox(
constraints: BoxConstraints(minHeight: constraints.maxHeight),
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.message_outlined,
size: 64,
color: Theme.of(context).disabledColor,
),
const SizedBox(height: 16),
Text(
AppLocalizations.of(context)!.noMessagesYet,
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 8),
Text(
AppLocalizations.of(context)!.pullDownToSync,
style: Theme.of(context).textTheme.bodyMedium,
textAlign: TextAlign.center,
),
],
),
),
),
),
)
: ListView.builder(
controller: scrollController,
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
reverse: true,
padding: EdgeInsets.fromLTRB(
defaultPadding,
defaultPadding,
defaultPadding,
defaultPadding + bottomContentPadding,
),
itemCount: messages.length,
itemBuilder: (context, index) {
final message = messages[index];
return MessageBubble(
key: ValueKey(message.id),
message: message,
isHighlighted: message.id == highlightedMessageId,
onNavigateToMap: onNavigateToMap,
onTap: onMessageTap == null
? null
: () => onMessageTap!(message),
);
},
),
);
}
}

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import '../../models/contact.dart';
import '../../l10n/app_localizations.dart';
import '../common/contact_avatar.dart';
/// Bottom sheet for selecting message recipient (channel, contact, or room)
class RecipientSelectorSheet extends StatefulWidget {
@@ -168,7 +169,7 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
...filteredChannels.map((channel) {
return _buildRecipientTile(
context: context,
icon: Icons.public,
contact: channel,
title: channel.getLocalizedDisplayName(context),
subtitle: channel.isPublicChannel
? l10n.broadcastToAllNearby
@@ -215,10 +216,9 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
...filteredContacts.map((contact) {
return _buildRecipientTile(
context: context,
icon: Icons.person,
contact: contact,
title: contact.displayName,
subtitle: contact.publicKeyShort,
emoji: contact.roleEmoji,
isSelected: _isSelected('contact', contact),
onTap: () {
widget.onSelect('contact', contact);
@@ -261,10 +261,9 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
...filteredRooms.map((room) {
return _buildRecipientTile(
context: context,
icon: Icons.meeting_room,
contact: room,
title: room.displayName,
subtitle: room.publicKeyShort,
emoji: room.roleEmoji,
isSelected: _isSelected('room', room),
onTap: () {
widget.onSelect('room', room);
@@ -275,7 +274,9 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
],
// Empty state
if (widget.contacts.isEmpty && widget.rooms.isEmpty && widget.channels.isEmpty) ...[
if (widget.contacts.isEmpty &&
widget.rooms.isEmpty &&
widget.channels.isEmpty) ...[
Padding(
padding: const EdgeInsets.all(32),
child: Column(
@@ -310,36 +311,16 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
Widget _buildRecipientTile({
required BuildContext context,
required IconData icon,
required Contact contact,
required String title,
required String subtitle,
String? emoji,
required bool isSelected,
required VoidCallback onTap,
}) {
return ListTile(
leading: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: isSelected
? Theme.of(context).colorScheme.primaryContainer
: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(20),
),
child: Icon(
icon,
color: isSelected
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
leading: ContactAvatar(contact: contact, radius: 20, displayName: title),
title: Row(
children: [
if (emoji != null && emoji.isNotEmpty) ...[
Text(emoji, style: const TextStyle(fontSize: 16)),
const SizedBox(width: 8),
],
Expanded(
child: Text(
title,

View File

@@ -0,0 +1,107 @@
import 'package:flutter/material.dart';
import '../../models/message.dart';
import '../../utils/message_extensions.dart';
class SystemMessageBubble extends StatelessWidget {
final Message message;
const SystemMessageBubble({super.key, required this.message});
Color _getLevelColor(String? level) {
switch (level?.toLowerCase()) {
case 'success':
return Colors.green;
case 'warning':
return Colors.orange;
case 'error':
return Colors.red;
case 'info':
default:
return Colors.blue.shade300;
}
}
IconData _getLevelIcon(String? level) {
switch (level?.toLowerCase()) {
case 'success':
return Icons.check_circle_outline;
case 'warning':
return Icons.warning_amber_outlined;
case 'error':
return Icons.error_outline;
case 'info':
default:
return Icons.info_outline;
}
}
@override
Widget build(BuildContext context) {
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
final level = message.senderName ?? 'info';
final levelColor = _getLevelColor(level);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: levelColor.withValues(alpha: isDarkMode ? 0.18 : 0.08),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: levelColor.withValues(alpha: isDarkMode ? 0.3 : 0.16),
),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(_getLevelIcon(level), size: 16, color: levelColor),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
level.toUpperCase(),
style: Theme.of(context).textTheme.labelSmall
?.copyWith(
color: levelColor,
fontWeight: FontWeight.bold,
letterSpacing: 0.4,
),
),
),
Text(
message.getLocalizedTimeAgo(context),
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: Theme.of(
context,
).textTheme.bodySmall?.color?.withValues(alpha: 0.6),
),
),
],
),
const SizedBox(height: 4),
Text(
message.text,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
height: 1.3,
color: Theme.of(
context,
).textTheme.bodySmall?.color?.withValues(alpha: 0.8),
),
),
],
),
),
],
),
),
);
}
}

View File

@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../l10n/app_localizations.dart';
import '../../models/message.dart';
import '../../providers/app_provider.dart';
import '../../providers/connection_provider.dart';
import '../../providers/contacts_provider.dart';
import '../../providers/voice_provider.dart';
@@ -218,11 +219,18 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
int pathLen = 0,
}) async {
if (_isRequesting) return;
setState(() {
_isRequesting = true;
_autoPlayWhenReady = true;
_errorText = null;
});
final connectionProvider = context.read<ConnectionProvider>();
final voiceProvider = context.read<VoiceProvider>();
voiceProvider.resumeIncomingSession(sessionId);
final contactsProvider = context.read<ContactsProvider>();
final resolution = await TransmissionTargetResolver.resolveFetchTarget(
final appProvider = context.read<AppProvider>();
var resolution = await TransmissionTargetResolver.resolveFetchTarget(
contactsProvider: contactsProvider,
refreshContacts: connectionProvider.getContacts,
isSentByMe: widget.isSentByMe,
@@ -235,6 +243,7 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
if (!mounted) return;
if (resolution.failure == TransmissionTargetFailure.unknownContact) {
_clearRequestState();
await _showBlockingAlert(
'Cannot fetch voice',
'Sender contact is unknown. Sync contacts first.',
@@ -242,6 +251,7 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
return;
}
if (resolution.failure == TransmissionTargetFailure.unknownRoute) {
_clearRequestState();
await _showBlockingAlert(
'Cannot fetch voice',
'Sender route is unknown. Sync contacts/path first.',
@@ -249,27 +259,85 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
return;
}
if (resolution.failure == TransmissionTargetFailure.tooFar) {
_clearRequestState();
await _showBlockingAlert(
'Cannot fetch voice',
'Message is too far (${resolution.hops} hops, max ${resolution.maxHops}).',
);
return;
}
if (resolution.failure == TransmissionTargetFailure.unreachable) {
_clearRequestState();
await _showBlockingAlert(
'Cannot fetch voice',
'Sender route did not respond to a path check. Sync contacts/path and try again.',
);
return;
}
var sender = resolution.target!;
var routeVerified = await appProvider.verifyRawTransportRoute(sender);
if (!mounted) return;
if (!routeVerified) {
await connectionProvider.getContacts();
if (!mounted) return;
resolution = await TransmissionTargetResolver.resolveFetchTarget(
contactsProvider: contactsProvider,
refreshContacts: connectionProvider.getContacts,
isSentByMe: widget.isSentByMe,
recipientPublicKey: widget.message.recipientPublicKey,
senderPublicKeyPrefix: widget.message.senderPublicKeyPrefix,
senderKey6FromEnvelope: envelope?.senderKey6,
senderName: widget.message.senderName,
maxFetchHops: _maxFetchHops,
);
if (!mounted) return;
if (resolution.failure == TransmissionTargetFailure.unknownContact) {
_clearRequestState();
await _showBlockingAlert(
'Cannot fetch voice',
'Sender contact is unknown. Sync contacts first.',
);
return;
}
if (resolution.failure == TransmissionTargetFailure.unknownRoute) {
_clearRequestState();
await _showBlockingAlert(
'Cannot fetch voice',
'Sender route is unknown. Sync contacts/path first.',
);
return;
}
if (resolution.failure == TransmissionTargetFailure.tooFar) {
_clearRequestState();
await _showBlockingAlert(
'Cannot fetch voice',
'Message is too far (${resolution.hops} hops, max ${resolution.maxHops}).',
);
return;
}
sender = resolution.target!;
routeVerified = await appProvider.verifyRawTransportRoute(sender);
if (!mounted) return;
if (!routeVerified) {
_clearRequestState();
await _showBlockingAlert(
'Cannot fetch voice',
'Sender route did not respond on the raw transport path.',
);
return;
}
}
final sender = resolution.target!;
if (sender.outPathLen >= 2) {
_showToast(
'Voice fetch over ${sender.outPathLen} hops may take a while.',
);
}
if (!mounted) return;
setState(() {
_errorText = null;
});
final deviceKey = connectionProvider.deviceInfo.publicKey;
if (deviceKey == null || deviceKey.length < 6) {
_clearRequestState();
await _showBlockingAlert(
'Cannot fetch voice',
'Device key is unavailable.',
@@ -281,20 +349,34 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
.sublist(0, 6)
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join('');
final request = VoiceFetchRequest(
final missing = voiceProvider.missingPacketIndices(sessionId);
final totalPackets = sessionPacketCount(
voiceProvider: voiceProvider,
sessionId: sessionId,
requesterKey6: requesterKey6,
timestampSec: DateTime.now().millisecondsSinceEpoch ~/ 1000,
version: 2,
envelope: envelope,
);
setState(() {
_isRequesting = true;
_autoPlayWhenReady = true;
_errorText = null;
});
final isPartialResume =
missing.isNotEmpty && totalPackets > 0 && missing.length < totalPackets;
final request = isPartialResume
? VoiceFetchRequest(
sessionId: sessionId,
want: 'missing',
missingIndices: missing,
requesterKey6: requesterKey6,
timestampSec: DateTime.now().millisecondsSinceEpoch ~/ 1000,
version: 2,
)
: VoiceFetchRequest(
sessionId: sessionId,
requesterKey6: requesterKey6,
timestampSec: DateTime.now().millisecondsSinceEpoch ~/ 1000,
version: 2,
);
try {
debugPrint(
'🎙️ [VoiceMessageBubble] Outgoing voice fetch request: session=$sessionId want=${isPartialResume ? 'missing' : 'all'} target=${sender.advName} hops=${sender.outPathLen}',
);
await connectionProvider.sendRawVoicePacket(
contactPath: sender.outPath,
contactPathLen: sender.outPathLen,
@@ -309,11 +391,18 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
final effectivePathLen = sender.outPathLen >= 0
? sender.outPathLen
: pathLen;
final estimatedDurationMs =
envelope != null &&
totalPackets > 0 &&
missing.isNotEmpty &&
missing.length < totalPackets
? ((envelope.durationMs * missing.length) / totalPackets).round()
: envelope?.durationMs;
final txEstimate = envelope != null
? estimateVoiceTransmitDuration(
packetCount: envelope.total,
packetCount: isPartialResume ? missing.length : envelope.total,
mode: envelope.mode,
durationMs: envelope.durationMs,
durationMs: estimatedDurationMs ?? envelope.durationMs,
pathLen: effectivePathLen,
radioBw: radioBw,
radioSf: radioSf,
@@ -331,6 +420,14 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
);
}
int sessionPacketCount({
required VoiceProvider voiceProvider,
required String sessionId,
required VoiceEnvelope? envelope,
}) {
return voiceProvider.session(sessionId)?.total ?? envelope?.total ?? 0;
}
void _setUnavailable() {
if (!mounted) return;
_showToast(AppLocalizations.of(context)!.voiceUnavailable);
@@ -341,6 +438,14 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
});
}
void _clearRequestState() {
if (!mounted) return;
setState(() {
_isRequesting = false;
_autoPlayWhenReady = false;
});
}
void _cancelReceive(String sessionId) {
if (!mounted) return;
_requestTimeoutTimer?.cancel();

View File

@@ -851,7 +851,7 @@ packages:
description:
path: "."
ref: main
resolved-ref: "3f870e98ee9527a3137bfcbdd1454036912fb609"
resolved-ref: fb0a92a53b8e1ab23ffca50c2bffe819829843e6
url: "https://github.com/dz0ny/meshcore_client.git"
source: git
version: "0.1.0"

View File

@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 2026.0306.3+13
version: 2026.0307.1+14
environment:
sdk: ^3.9.2

View File

@@ -0,0 +1,50 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_sar_app/models/message_reception_details.dart';
void main() {
test('drops impossible transmit estimate for received messages', () {
expect(
sanitizeEstimatedTransmitMs(
estimatedTransmitMs: 16 * 60 * 1000 + 54 * 1000,
senderToReceiptMs: 4200,
),
isNull,
);
});
test(
'keeps close transmit estimate despite second-level timestamp rounding',
() {
expect(
sanitizeEstimatedTransmitMs(
estimatedTransmitMs: 1800,
senderToReceiptMs: 900,
),
1800,
);
},
);
test('round trips reception details json', () {
final details = MessageReceptionDetails(
capturedAt: DateTime.fromMillisecondsSinceEpoch(1700000000000),
packetLoggedAt: DateTime.fromMillisecondsSinceEpoch(1700000000500),
rssiDbm: -92,
snrDb: 7.5,
pathBytes: const [0xAA, 0xBB, 0xCC],
senderToReceiptMs: 4200,
estimatedTransmitMs: 1800,
postTransmitDelayMs: 2400,
);
final decoded = MessageReceptionDetails.fromJson(details.toJson());
expect(decoded, isNotNull);
expect(decoded!.rssiDbm, -92);
expect(decoded.snrDb, 7.5);
expect(decoded.pathBytesHex, 'aa:bb:cc');
expect(decoded.senderToReceiptMs, 4200);
expect(decoded.estimatedTransmitMs, 1800);
expect(decoded.postTransmitDelayMs, 2400);
});
}

View File

@@ -47,9 +47,8 @@ void main() {
expect(ok, isFalse);
});
test('sends only requested indices and waits for ack', () async {
test('sends only requested indices', () async {
final sent = <Uint8List>[];
final waited = <int>[];
final ok = await serveCachedSessionFragments<_Fragment>(
providerLabel: 'TestProvider',
sessionId: 'deadbeef',
@@ -70,15 +69,6 @@ void main() {
}) async {
sent.add(payload);
},
waitForFragmentAck:
({
required sessionId,
required index,
timeout = const Duration(seconds: 8),
}) async {
waited.add(index);
return true;
},
requestedIndices: {1, 2},
);
@@ -86,37 +76,6 @@ void main() {
expect(sent.length, equals(2));
expect(sent[0], equals(Uint8List.fromList([20])));
expect(sent[1], equals(Uint8List.fromList([30])));
expect(waited, equals([1, 2]));
});
test('fails when ack does not arrive', () async {
final ok = await serveCachedSessionFragments<_Fragment>(
providerLabel: 'TestProvider',
sessionId: 'deadbeef',
requester: _buildContact(outPathLen: 1),
fragments: [
_Fragment(0, Uint8List.fromList([1])),
],
maxDirectPayloadHops: 3,
indexOf: (f) => f.index,
encodeBinary: (f) => f.payload,
sendRawPacket:
({
required contactPath,
required contactPathLen,
required payload,
}) async {},
waitForFragmentAck:
({
required sessionId,
required index,
timeout = const Duration(seconds: 8),
}) async {
return false;
},
);
expect(ok, isFalse);
});
test('fails when no requested index matches cached fragments', () async {

View File

@@ -40,7 +40,7 @@ void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('MessagesProvider retransmission', () {
test('direct messages stay pending until delivery ACK arrives', () {
test('direct messages become sent before delivery ACK arrives', () {
final provider = MessagesProvider();
provider.addSentMessage(
_buildDirectMessage('m1'),
@@ -51,7 +51,7 @@ void main() {
expect(
provider.messages.single.deliveryStatus,
MessageDeliveryStatus.sending,
MessageDeliveryStatus.sent,
);
expect(provider.messages.single.expectedAckTag, 77);
@@ -64,6 +64,24 @@ void main() {
expect(provider.messages.single.roundTripTimeMs, 180);
});
test('direct messages stay sent after device accept until confirm arrives', () {
final provider = MessagesProvider();
provider.addSentMessage(
_buildDirectMessage('m1b'),
contact: _buildContact(),
);
provider.markMessageSent('m1b', 78, 250);
expect(
provider.messages.single.deliveryStatus,
MessageDeliveryStatus.sent,
);
expect(provider.messages.single.expectedAckTag, 78);
expect(provider.messages.single.roundTripTimeMs, isNull);
expect(provider.messages.single.deliveredAt, isNull);
});
test('channel messages are marked sent immediately', () {
final provider = MessagesProvider();
provider.addSentMessage(

View File

@@ -0,0 +1,92 @@
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_sar_app/models/contact.dart';
import 'package:meshcore_sar_app/utils/log_rx_route_decoder.dart';
void main() {
group('LogRxRouteDecoder.decode', () {
test('parses route and sender from LOG_RX_DATA packet', () {
final packet = Uint8List.fromList([
0x88,
0x37,
0xae,
0x05,
0x04,
0xc2,
0xba,
0x5f,
0xde,
0x5c,
]);
final decoded = LogRxRouteDecoder.decode(packet);
expect(decoded, isNotNull);
expect(decoded!.payloadType, 0x01);
expect(decoded.pathHashes, [0xc2, 0xba, 0x5f, 0xde]);
expect(decoded.originalSenderHash, 0xc2);
});
});
group('LogRxRouteDecoder.resolveHash', () {
test('prefers own node when hash matches device key', () {
final resolved = LogRxRouteDecoder.resolveHash(
0xc2,
contacts: const [],
ownPublicKey: Uint8List.fromList([0xc2, 0x01, 0x02]),
ownName: 'Base',
);
expect(resolved.isOwnNode, isTrue);
expect(resolved.label, 'Base (you)');
});
test('resolves unique contact by first public key byte', () {
final resolved = LogRxRouteDecoder.resolveHash(
0xc2,
contacts: [_contact(name: 'Alpha', keyPrefix: 0xc2)],
);
expect(resolved.isUniqueMatch, isTrue);
expect(resolved.label, 'Alpha');
});
test('marks ambiguous matches without pretending certainty', () {
final resolved = LogRxRouteDecoder.resolveHash(
0xc2,
contacts: [
_contact(name: 'Alpha', keyPrefix: 0xc2),
_contact(name: 'Bravo', keyPrefix: 0xc2),
],
);
expect(resolved.isUniqueMatch, isFalse);
expect(resolved.matchCount, 2);
});
});
}
Contact _contact({required String name, required int keyPrefix}) {
return Contact(
publicKey: Uint8List.fromList([
keyPrefix,
0x11,
0x22,
0x33,
0x44,
0x55,
0x66,
0x77,
]),
type: ContactType.chat,
flags: 0,
outPathLen: 0,
outPath: Uint8List(0),
advName: name,
lastAdvert: 0,
advLat: 0,
advLon: 0,
lastMod: 0,
);
}