mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-12 08:50:30 +00:00
Compare commits
8 Commits
v2026.0306
...
v2026.0307
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
30cac1bcbf | ||
|
|
95e6aa35e2 | ||
|
|
2bc0e21cf0 | ||
|
|
f6c5a3a4ca | ||
|
|
810897d348 | ||
|
|
bb1c1f455d | ||
|
|
b0eded3780 | ||
|
|
f1d56e2000 |
@@ -23,6 +23,7 @@
|
|||||||
|
|
||||||
<!-- Microphone for voice messages -->
|
<!-- Microphone for voice messages -->
|
||||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
|
|
||||||
<!-- Notifications -->
|
<!-- Notifications -->
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
|||||||
@@ -489,7 +489,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CURRENT_PROJECT_VERSION = 94;
|
CURRENT_PROJECT_VERSION = 95;
|
||||||
DEVELOPMENT_TEAM = JND55328G8;
|
DEVELOPMENT_TEAM = JND55328G8;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
@@ -511,7 +511,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 94;
|
CURRENT_PROJECT_VERSION = 95;
|
||||||
DEVELOPMENT_TEAM = JND55328G8;
|
DEVELOPMENT_TEAM = JND55328G8;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
@@ -530,7 +530,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 94;
|
CURRENT_PROJECT_VERSION = 95;
|
||||||
DEVELOPMENT_TEAM = JND55328G8;
|
DEVELOPMENT_TEAM = JND55328G8;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
@@ -547,7 +547,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 94;
|
CURRENT_PROJECT_VERSION = 95;
|
||||||
DEVELOPMENT_TEAM = JND55328G8;
|
DEVELOPMENT_TEAM = JND55328G8;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
@@ -679,7 +679,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CURRENT_PROJECT_VERSION = 94;
|
CURRENT_PROJECT_VERSION = 95;
|
||||||
DEVELOPMENT_TEAM = JND55328G8;
|
DEVELOPMENT_TEAM = JND55328G8;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
@@ -702,7 +702,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CURRENT_PROJECT_VERSION = 94;
|
CURRENT_PROJECT_VERSION = 95;
|
||||||
DEVELOPMENT_TEAM = JND55328G8;
|
DEVELOPMENT_TEAM = JND55328G8;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
|
|||||||
@@ -43,7 +43,7 @@
|
|||||||
<key>CFBundleSignature</key>
|
<key>CFBundleSignature</key>
|
||||||
<string>????</string>
|
<string>????</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>94</string>
|
<string>95</string>
|
||||||
<key>LSRequiresIPhoneOS</key>
|
<key>LSRequiresIPhoneOS</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>NSBluetoothAlwaysUsageDescription</key>
|
<key>NSBluetoothAlwaysUsageDescription</key>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|
||||||
<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>
|
||||||
|
|
||||||
|
|
||||||
<testcase classname="fastlane.lanes" name="2: build_app" time="91.987634">
|
<testcase classname="fastlane.lanes" name="2: build_app" time="110.117264">
|
||||||
|
|
||||||
</testcase>
|
</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>
|
</testcase>
|
||||||
|
|
||||||
|
|||||||
86
lib/models/message_reception_details.dart
Normal file
86
lib/models/message_reception_details.dart
Normal 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?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:math' as math;
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import 'connection_provider.dart';
|
import 'connection_provider.dart';
|
||||||
@@ -16,9 +17,12 @@ import '../services/packet_capture_storage_service.dart';
|
|||||||
import '../models/contact.dart';
|
import '../models/contact.dart';
|
||||||
import '../models/message.dart';
|
import '../models/message.dart';
|
||||||
import '../models/ble_packet_log.dart';
|
import '../models/ble_packet_log.dart';
|
||||||
|
import '../models/message_reception_details.dart';
|
||||||
import '../utils/drawing_message_parser.dart';
|
import '../utils/drawing_message_parser.dart';
|
||||||
|
import '../utils/raw_route_probe.dart';
|
||||||
import '../utils/voice_message_parser.dart';
|
import '../utils/voice_message_parser.dart';
|
||||||
import '../utils/image_message_parser.dart';
|
import '../utils/image_message_parser.dart';
|
||||||
|
import '../utils/message_airtime_estimator.dart';
|
||||||
|
|
||||||
/// Main App Provider - coordinates all other providers
|
/// Main App Provider - coordinates all other providers
|
||||||
class AppProvider with ChangeNotifier {
|
class AppProvider with ChangeNotifier {
|
||||||
@@ -64,10 +68,10 @@ class AppProvider with ChangeNotifier {
|
|||||||
final Map<String, String> _imageSessionSenderKey6 = {};
|
final Map<String, String> _imageSessionSenderKey6 = {};
|
||||||
final Map<String, Timer> _voiceMissingRetryTimers = {};
|
final Map<String, Timer> _voiceMissingRetryTimers = {};
|
||||||
final Map<String, int> _voiceMissingRetryAttempts = {};
|
final Map<String, int> _voiceMissingRetryAttempts = {};
|
||||||
final FragmentAckWaitRegistry _voiceFragmentAckWaiters =
|
final Map<String, Timer> _imageMissingRetryTimers = {};
|
||||||
FragmentAckWaitRegistry();
|
final Map<String, int> _imageMissingRetryAttempts = {};
|
||||||
final FragmentAckWaitRegistry _imageFragmentAckWaiters =
|
final FragmentAckWaitRegistry _rawProbeWaiters = FragmentAckWaitRegistry();
|
||||||
FragmentAckWaitRegistry();
|
final Map<String, Future<bool>> _pendingRawRouteProbes = {};
|
||||||
Timer? _packetCaptureFlushTimer;
|
Timer? _packetCaptureFlushTimer;
|
||||||
String? _lastPersistedPacketSignature;
|
String? _lastPersistedPacketSignature;
|
||||||
bool _isPersistingPacketCapture = false;
|
bool _isPersistingPacketCapture = false;
|
||||||
@@ -460,27 +464,6 @@ class AppProvider with ChangeNotifier {
|
|||||||
payload: payload,
|
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
|
// When a contact is received from BLE
|
||||||
connectionProvider.onContactReceived = (contact) {
|
connectionProvider.onContactReceived = (contact) {
|
||||||
// Pass device public key to filter out our own contact
|
// Pass device public key to filter out our own contact
|
||||||
@@ -631,6 +614,9 @@ class AppProvider with ChangeNotifier {
|
|||||||
capturedAt: enrichedMessage.receivedAt,
|
capturedAt: enrichedMessage.receivedAt,
|
||||||
)
|
)
|
||||||
: null;
|
: null;
|
||||||
|
final receptionDetailsSnapshot = _buildReceptionDetailsSnapshot(
|
||||||
|
enrichedMessage,
|
||||||
|
);
|
||||||
|
|
||||||
// Check if message is a drawing broadcast
|
// Check if message is a drawing broadcast
|
||||||
if (DrawingMessageParser.isDrawingMessage(enrichedMessage.text)) {
|
if (DrawingMessageParser.isDrawingMessage(enrichedMessage.text)) {
|
||||||
@@ -662,6 +648,7 @@ class AppProvider with ChangeNotifier {
|
|||||||
updatedMessage,
|
updatedMessage,
|
||||||
contactLookup: (name) => '',
|
contactLookup: (name) => '',
|
||||||
contactLocationSnapshot: contactLocationSnapshot,
|
contactLocationSnapshot: contactLocationSnapshot,
|
||||||
|
receptionDetailsSnapshot: receptionDetailsSnapshot,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Broadcast drawing message to SSE clients if server is running
|
// Broadcast drawing message to SSE clients if server is running
|
||||||
@@ -698,6 +685,7 @@ class AppProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
contactLocationSnapshot: contactLocationSnapshot,
|
contactLocationSnapshot: contactLocationSnapshot,
|
||||||
|
receptionDetailsSnapshot: receptionDetailsSnapshot,
|
||||||
);
|
);
|
||||||
connectionProvider.broadcastMessageToSseClients(enrichedMessage);
|
connectionProvider.broadcastMessageToSseClients(enrichedMessage);
|
||||||
return;
|
return;
|
||||||
@@ -726,6 +714,7 @@ class AppProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
contactLocationSnapshot: contactLocationSnapshot,
|
contactLocationSnapshot: contactLocationSnapshot,
|
||||||
|
receptionDetailsSnapshot: receptionDetailsSnapshot,
|
||||||
);
|
);
|
||||||
connectionProvider.broadcastMessageToSseClients(enrichedMessage);
|
connectionProvider.broadcastMessageToSseClients(enrichedMessage);
|
||||||
return;
|
return;
|
||||||
@@ -763,12 +752,16 @@ class AppProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
contactLocationSnapshot: contactLocationSnapshot,
|
contactLocationSnapshot: contactLocationSnapshot,
|
||||||
|
receptionDetailsSnapshot: receptionDetailsSnapshot,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Broadcast message to SSE clients if server is running
|
// Broadcast message to SSE clients if server is running
|
||||||
connectionProvider.broadcastMessageToSseClients(enrichedMessage);
|
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)
|
// When telemetry is received via PUSH_CODE_TELEMETRY_RESPONSE (0x8B)
|
||||||
// Used by older firmware versions for telemetry responses
|
// Used by older firmware versions for telemetry responses
|
||||||
connectionProvider.onTelemetryReceived = (publicKey, lppData) {
|
connectionProvider.onTelemetryReceived = (publicKey, lppData) {
|
||||||
@@ -794,11 +787,30 @@ class AppProvider with ChangeNotifier {
|
|||||||
// Magic 0x72 'r' = voice fetch request; 0x69 'i' = image fetch request.
|
// Magic 0x72 'r' = voice fetch request; 0x69 'i' = image fetch request.
|
||||||
// Magic 0x56 'V' = voice packet; magic 0x49 'I' = image packet.
|
// Magic 0x56 'V' = voice packet; magic 0x49 'I' = image packet.
|
||||||
connectionProvider.onRawDataReceived = (payload, snrRaw, rssiDbm) {
|
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);
|
final voiceFetchRequest = VoiceFetchRequest.tryParseBinary(payload);
|
||||||
if (voiceFetchRequest != null) {
|
if (voiceFetchRequest != null) {
|
||||||
final requester = contactsProvider.findContactByPrefixHex(
|
debugPrint(
|
||||||
voiceFetchRequest.requesterKey6,
|
'🎙️ [AppProvider] Incoming voice fetch request: session=${voiceFetchRequest.sessionId} want=${voiceFetchRequest.want} requester=${voiceFetchRequest.requesterKey6}',
|
||||||
);
|
);
|
||||||
|
final requester = _resolveVoiceFetchRequester(voiceFetchRequest);
|
||||||
if (requester == null) {
|
if (requester == null) {
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'⚠️ [AppProvider] Voice fetch requester contact not found (binary)',
|
'⚠️ [AppProvider] Voice fetch requester contact not found (binary)',
|
||||||
@@ -878,18 +890,6 @@ class AppProvider with ChangeNotifier {
|
|||||||
return;
|
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)) {
|
if (ImagePacket.isImageBinary(payload)) {
|
||||||
final frag = ImagePacket.tryParseBinary(payload);
|
final frag = ImagePacket.tryParseBinary(payload);
|
||||||
if (frag == null) return;
|
if (frag == null) return;
|
||||||
@@ -900,7 +900,10 @@ class AppProvider with ChangeNotifier {
|
|||||||
width: session?.width ?? 0,
|
width: session?.width ?? 0,
|
||||||
height: session?.height ?? 0,
|
height: session?.height ?? 0,
|
||||||
);
|
);
|
||||||
_sendImageFragmentAck(frag);
|
_scheduleImageMissingRetry(
|
||||||
|
frag.sessionId,
|
||||||
|
justComplete: imageProvider.isComplete(frag.sessionId),
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -909,7 +912,6 @@ class AppProvider with ChangeNotifier {
|
|||||||
if (pkt == null) return;
|
if (pkt == null) return;
|
||||||
debugPrint('🎙️ [AppProvider] Binary voice packet received: $pkt');
|
debugPrint('🎙️ [AppProvider] Binary voice packet received: $pkt');
|
||||||
final justComplete = voiceProvider.addPacket(pkt);
|
final justComplete = voiceProvider.addPacket(pkt);
|
||||||
_sendVoiceFragmentAck(pkt);
|
|
||||||
_scheduleVoiceMissingRetry(pkt.sessionId, justComplete: justComplete);
|
_scheduleVoiceMissingRetry(pkt.sessionId, justComplete: justComplete);
|
||||||
// Insert or update the placeholder message in the chat list
|
// Insert or update the placeholder message in the chat list
|
||||||
_handleIncomingVoicePacket(pkt, justComplete: justComplete);
|
_handleIncomingVoicePacket(pkt, justComplete: justComplete);
|
||||||
@@ -1268,15 +1270,51 @@ class AppProvider with ChangeNotifier {
|
|||||||
return contactsProvider.findContactByPrefixHex(prefixHex.toLowerCase());
|
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) {
|
Contact? _resolveImageFetchRequester(ImageFetchRequest request) {
|
||||||
final liveContact = _resolveContactByPrefixHex(request.requesterKey6);
|
final liveContact = _resolveContactByPrefixHex(request.requesterKey6);
|
||||||
if (liveContact != null) {
|
if (liveContact != null) {
|
||||||
return liveContact;
|
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) {
|
for (final message in messagesProvider.messages.reversed) {
|
||||||
final envelope = ImageEnvelope.tryParse(message.text);
|
final envelope = tryParseEnvelope(message.text);
|
||||||
if (envelope == null || envelope.sessionId != request.sessionId) {
|
if (envelope == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final envelopeSessionId = switch (envelope) {
|
||||||
|
VoiceEnvelope voiceEnvelope => voiceEnvelope.sessionId,
|
||||||
|
ImageEnvelope imageEnvelope => imageEnvelope.sessionId,
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
if (envelopeSessionId != sessionId) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
final recipientKey = message.recipientPublicKey;
|
final recipientKey = message.recipientPublicKey;
|
||||||
@@ -1291,12 +1329,13 @@ class AppProvider with ChangeNotifier {
|
|||||||
.sublist(0, 6)
|
.sublist(0, 6)
|
||||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||||
.join();
|
.join();
|
||||||
if (recipientKey6 != request.requesterKey6) {
|
if (recipientKey6 != requesterKey6) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'📷 [AppProvider] Resolved image requester from sent message metadata '
|
'${mediaLabel == 'voice' ? '🎙️' : '📷'} [AppProvider] Resolved '
|
||||||
'for session ${request.sessionId}: ${recipient.advName}',
|
'$mediaLabel requester from sent message metadata for session '
|
||||||
|
'$sessionId: ${recipient.advName}',
|
||||||
);
|
);
|
||||||
return recipient;
|
return recipient;
|
||||||
}
|
}
|
||||||
@@ -1308,9 +1347,12 @@ class AppProvider with ChangeNotifier {
|
|||||||
String sessionId, {
|
String sessionId, {
|
||||||
required bool justComplete,
|
required bool justComplete,
|
||||||
}) {
|
}) {
|
||||||
|
if (voiceProvider.isReceiveCanceled(sessionId)) {
|
||||||
|
_clearVoiceMissingRetry(sessionId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (justComplete || voiceProvider.isComplete(sessionId)) {
|
if (justComplete || voiceProvider.isComplete(sessionId)) {
|
||||||
_voiceMissingRetryTimers.remove(sessionId)?.cancel();
|
_clearVoiceMissingRetry(sessionId);
|
||||||
_voiceMissingRetryAttempts.remove(sessionId);
|
|
||||||
return;
|
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 {
|
Future<void> _requestMissingVoicePackets(String sessionId) async {
|
||||||
|
if (voiceProvider.isReceiveCanceled(sessionId)) {
|
||||||
|
_clearVoiceMissingRetry(sessionId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (voiceProvider.isComplete(sessionId)) {
|
if (voiceProvider.isComplete(sessionId)) {
|
||||||
_voiceMissingRetryTimers.remove(sessionId)?.cancel();
|
_clearVoiceMissingRetry(sessionId);
|
||||||
_voiceMissingRetryAttempts.remove(sessionId);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1333,7 +1408,7 @@ class AppProvider with ChangeNotifier {
|
|||||||
debugPrint(
|
debugPrint(
|
||||||
'⚠️ [AppProvider] Voice re-request limit reached for $sessionId',
|
'⚠️ [AppProvider] Voice re-request limit reached for $sessionId',
|
||||||
);
|
);
|
||||||
_voiceMissingRetryTimers.remove(sessionId)?.cancel();
|
_clearVoiceMissingRetry(sessionId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1345,8 +1420,7 @@ class AppProvider with ChangeNotifier {
|
|||||||
|
|
||||||
final missing = voiceProvider.missingPacketIndices(sessionId);
|
final missing = voiceProvider.missingPacketIndices(sessionId);
|
||||||
if (missing.isEmpty) {
|
if (missing.isEmpty) {
|
||||||
_voiceMissingRetryTimers.remove(sessionId)?.cancel();
|
_clearVoiceMissingRetry(sessionId);
|
||||||
_voiceMissingRetryAttempts.remove(sessionId);
|
|
||||||
return;
|
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.
|
/// Insert or update a voice placeholder message for binary raw-data packets.
|
||||||
///
|
///
|
||||||
/// Binary voice packets arrive without a chat message, so we synthesise one
|
/// Binary voice packets arrive without a chat message, so we synthesise one
|
||||||
@@ -1421,94 +1556,206 @@ class AppProvider with ChangeNotifier {
|
|||||||
messagesProvider.addMessage(placeholder, contactLookup: (_) => '');
|
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({
|
Future<bool> verifyRawTransportRoute(
|
||||||
required String sessionId,
|
Contact target, {
|
||||||
required int index,
|
|
||||||
Duration timeout = const Duration(seconds: 8),
|
Duration timeout = const Duration(seconds: 8),
|
||||||
}) => _voiceFragmentAckWaiters.waitFor(
|
}) async {
|
||||||
_fragmentAckKey(sessionId, index),
|
if (!connectionProvider.deviceInfo.isConnected) {
|
||||||
timeout: timeout,
|
return false;
|
||||||
);
|
}
|
||||||
|
if (target.outPathLen < 0 || target.outPathLen > _maxDirectPayloadHops) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (target.outPath.isEmpty) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
void _completeVoiceFragmentAck(String sessionId, int index) {
|
final probeKey = _routeProbeTargetKey(target);
|
||||||
final completed = _voiceFragmentAckWaiters.complete(
|
final pendingProbe = _pendingRawRouteProbes[probeKey];
|
||||||
_fragmentAckKey(sessionId, index),
|
if (pendingProbe != null) {
|
||||||
);
|
return pendingProbe;
|
||||||
if (completed == 0) {
|
}
|
||||||
|
|
||||||
|
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(
|
debugPrint(
|
||||||
'ℹ️ [AppProvider] Voice fragment ACK had no waiter: $sessionId#$index',
|
'⚠️ [AppProvider] Raw route probe requester not found: ${request.requesterKey6}',
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
debugPrint(
|
if (requester.outPathLen < 0 ||
|
||||||
'✅ [AppProvider] Voice fragment ACK received for $sessionId#$index ($completed waiter(s))',
|
requester.outPathLen > _maxDirectPayloadHops) {
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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) {
|
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'ℹ️ [AppProvider] Image fragment ACK had no waiter: $sessionId#$index',
|
'⚠️ [AppProvider] Raw route probe requester out of range: ${requester.outPathLen}',
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
debugPrint(
|
if (requester.outPath.isEmpty) {
|
||||||
'✅ [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) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
debugPrint(
|
||||||
|
'📡 [AppProvider] Outgoing raw route probe ACK: requester=${requester.advName} hops=${requester.outPathLen} nonce=${request.nonce.toRadixString(16)}',
|
||||||
|
);
|
||||||
unawaited(
|
unawaited(
|
||||||
connectionProvider.sendRawVoicePacket(
|
connectionProvider.sendRawVoicePacket(
|
||||||
contactPath: sender.outPath,
|
contactPath: requester.outPath,
|
||||||
contactPathLen: sender.outPathLen,
|
contactPathLen: requester.outPathLen,
|
||||||
payload: VoiceFragmentAck(
|
payload: RawRouteProbeAck(nonce: request.nonce).encodeBinary(),
|
||||||
sessionId: packet.sessionId,
|
|
||||||
index: packet.index,
|
|
||||||
).encodeBinary(),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _sendImageFragmentAck(ImagePacket fragment) {
|
void _completeRawRouteProbeAck(int nonce) {
|
||||||
final senderKey6 = _imageSessionSenderKey6[fragment.sessionId];
|
_rawProbeWaiters.complete(_rawProbeKey(nonce));
|
||||||
if (senderKey6 == null) return;
|
}
|
||||||
final sender = _resolveContactByPrefixHex(senderKey6);
|
|
||||||
if (sender == null) return;
|
MessageReceptionDetails? _buildReceptionDetailsSnapshot(Message message) {
|
||||||
if (sender.outPathLen < 0 || sender.outPathLen > _maxDirectPayloadHops) {
|
final matchedRxLog = _findBestMatchingRxLog(message);
|
||||||
return;
|
final estimatedTx = estimateMessageTransmitDuration(
|
||||||
}
|
message,
|
||||||
unawaited(
|
radioBw: connectionProvider.deviceInfo.radioBw,
|
||||||
connectionProvider.sendRawVoicePacket(
|
radioSf: connectionProvider.deviceInfo.radioSf,
|
||||||
contactPath: sender.outPath,
|
radioCr: connectionProvider.deviceInfo.radioCr,
|
||||||
contactPathLen: sender.outPathLen,
|
|
||||||
payload: ImageFragmentAck(
|
|
||||||
sessionId: fragment.sessionId,
|
|
||||||
index: fragment.index,
|
|
||||||
).encodeBinary(),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
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
|
// 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) {
|
for (final timer in _voiceMissingRetryTimers.values) {
|
||||||
timer.cancel();
|
timer.cancel();
|
||||||
}
|
}
|
||||||
|
for (final timer in _imageMissingRetryTimers.values) {
|
||||||
|
timer.cancel();
|
||||||
|
}
|
||||||
_voiceMissingRetryTimers.clear();
|
_voiceMissingRetryTimers.clear();
|
||||||
_voiceMissingRetryAttempts.clear();
|
_voiceMissingRetryAttempts.clear();
|
||||||
|
_imageMissingRetryTimers.clear();
|
||||||
|
_imageMissingRetryAttempts.clear();
|
||||||
_voiceSessionSenderKey6.clear();
|
_voiceSessionSenderKey6.clear();
|
||||||
|
_imageSessionSenderKey6.clear();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1640,6 +1893,9 @@ class AppProvider with ChangeNotifier {
|
|||||||
for (final timer in _voiceMissingRetryTimers.values) {
|
for (final timer in _voiceMissingRetryTimers.values) {
|
||||||
timer.cancel();
|
timer.cancel();
|
||||||
}
|
}
|
||||||
|
for (final timer in _imageMissingRetryTimers.values) {
|
||||||
|
timer.cancel();
|
||||||
|
}
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -146,11 +146,15 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
final MessageDeliveryTracker _messageDeliveryTracker =
|
final MessageDeliveryTracker _messageDeliveryTracker =
|
||||||
MessageDeliveryTracker();
|
MessageDeliveryTracker();
|
||||||
final PingTracker _pingTracker = PingTracker();
|
final PingTracker _pingTracker = PingTracker();
|
||||||
|
final Map<String, Future<PingResult>> _pendingSmartPings = {};
|
||||||
|
|
||||||
// Expose room login states
|
// Expose room login states
|
||||||
Map<String, RoomLoginState> get roomLoginStates =>
|
Map<String, RoomLoginState> get roomLoginStates =>
|
||||||
_roomLoginManager.roomLoginStates;
|
_roomLoginManager.roomLoginStates;
|
||||||
|
|
||||||
|
bool isPingInProgress(Uint8List publicKey) =>
|
||||||
|
_pendingSmartPings.containsKey(_publicKeyToHex(publicKey));
|
||||||
|
|
||||||
// Callbacks for other providers
|
// Callbacks for other providers
|
||||||
Function(Contact)? onContactReceived;
|
Function(Contact)? onContactReceived;
|
||||||
Function(List<Contact>)? onContactsComplete;
|
Function(List<Contact>)? onContactsComplete;
|
||||||
@@ -1316,6 +1320,34 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
required Uint8List contactPublicKey,
|
required Uint8List contactPublicKey,
|
||||||
required bool hasPath,
|
required bool hasPath,
|
||||||
Function()? onRetryWithFlooding,
|
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 {
|
}) async {
|
||||||
if (!_activeService.isConnected) {
|
if (!_activeService.isConnected) {
|
||||||
_error = 'Not connected to device';
|
_error = 'Not connected to device';
|
||||||
@@ -1334,7 +1366,10 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Send the ping
|
// Send the ping
|
||||||
await _activeService.requestTelemetry(contactPublicKey, zeroHop: true);
|
await _activeService.requestTelemetry(
|
||||||
|
contactPublicKey,
|
||||||
|
zeroHop: firstAttemptDirect,
|
||||||
|
);
|
||||||
|
|
||||||
// Wait for response or timeout
|
// Wait for response or timeout
|
||||||
final bool gotResponse = await pingFuture;
|
final bool gotResponse = await pingFuture;
|
||||||
@@ -1361,8 +1396,8 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
wasDirectAttempt: false,
|
wasDirectAttempt: false,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Retry with flooding (zeroHop=true acts as broadcast to neighbors)
|
// Retry with flooding.
|
||||||
await _activeService.requestTelemetry(contactPublicKey, zeroHop: true);
|
await _activeService.requestTelemetry(contactPublicKey, zeroHop: false);
|
||||||
|
|
||||||
// Wait for response or timeout
|
// Wait for response or timeout
|
||||||
final bool gotRetryResponse = await retryFuture;
|
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)
|
/// Send binary request to contact (modern replacement for requestTelemetry)
|
||||||
///
|
///
|
||||||
/// Supports multiple request types:
|
/// Supports multiple request types:
|
||||||
|
|||||||
@@ -9,13 +9,6 @@ typedef RawPacketSender =
|
|||||||
required Uint8List payload,
|
required Uint8List payload,
|
||||||
});
|
});
|
||||||
|
|
||||||
typedef FragmentAckWaiter =
|
|
||||||
Future<bool> Function({
|
|
||||||
required String sessionId,
|
|
||||||
required int index,
|
|
||||||
Duration timeout,
|
|
||||||
});
|
|
||||||
|
|
||||||
Future<bool> serveCachedSessionFragments<T>({
|
Future<bool> serveCachedSessionFragments<T>({
|
||||||
required String providerLabel,
|
required String providerLabel,
|
||||||
required String sessionId,
|
required String sessionId,
|
||||||
@@ -25,9 +18,7 @@ Future<bool> serveCachedSessionFragments<T>({
|
|||||||
required int Function(T fragment) indexOf,
|
required int Function(T fragment) indexOf,
|
||||||
required Uint8List Function(T fragment) encodeBinary,
|
required Uint8List Function(T fragment) encodeBinary,
|
||||||
required RawPacketSender? sendRawPacket,
|
required RawPacketSender? sendRawPacket,
|
||||||
FragmentAckWaiter? waitForFragmentAck,
|
|
||||||
Set<int>? requestedIndices,
|
Set<int>? requestedIndices,
|
||||||
Duration ackTimeout = const Duration(seconds: 8),
|
|
||||||
}) async {
|
}) async {
|
||||||
if (fragments.isEmpty) {
|
if (fragments.isEmpty) {
|
||||||
debugPrint('⚠️ [$providerLabel] No cached fragments for $sessionId');
|
debugPrint('⚠️ [$providerLabel] No cached fragments for $sessionId');
|
||||||
@@ -65,24 +56,12 @@ Future<bool> serveCachedSessionFragments<T>({
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
final ackFuture = waitForFragmentAck?.call(
|
|
||||||
sessionId: sessionId,
|
|
||||||
index: index,
|
|
||||||
timeout: ackTimeout,
|
|
||||||
);
|
|
||||||
await sendRawPacket(
|
await sendRawPacket(
|
||||||
contactPath: requester.outPath,
|
contactPath: requester.outPath,
|
||||||
contactPathLen: requester.outPathLen,
|
contactPathLen: requester.outPathLen,
|
||||||
payload: encodeBinary(fragment),
|
payload: encodeBinary(fragment),
|
||||||
);
|
);
|
||||||
servedCount++;
|
servedCount++;
|
||||||
if (ackFuture != null) {
|
|
||||||
final acked = await ackFuture;
|
|
||||||
if (!acked) {
|
|
||||||
debugPrint('⚠️ [$providerLabel] ACK timeout for $sessionId#$index');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'❌ [$providerLabel] Serve error for $sessionId#$index: $e\n$st',
|
'❌ [$providerLabel] Serve error for $sessionId#$index: $e\n$st',
|
||||||
|
|||||||
@@ -70,12 +70,6 @@ class ImageProvider with ChangeNotifier {
|
|||||||
required Uint8List payload,
|
required Uint8List payload,
|
||||||
})?
|
})?
|
||||||
sendRawPacketCallback;
|
sendRawPacketCallback;
|
||||||
Future<bool> Function({
|
|
||||||
required String sessionId,
|
|
||||||
required int index,
|
|
||||||
Duration timeout,
|
|
||||||
})?
|
|
||||||
waitForFragmentAckCallback;
|
|
||||||
|
|
||||||
ImageProvider() {
|
ImageProvider() {
|
||||||
_restore();
|
_restore();
|
||||||
@@ -264,7 +258,6 @@ class ImageProvider with ChangeNotifier {
|
|||||||
indexOf: (fragment) => fragment.index,
|
indexOf: (fragment) => fragment.index,
|
||||||
encodeBinary: (fragment) => fragment.encodeBinary(),
|
encodeBinary: (fragment) => fragment.encodeBinary(),
|
||||||
sendRawPacket: sendRawPacketCallback,
|
sendRawPacket: sendRawPacketCallback,
|
||||||
waitForFragmentAck: waitForFragmentAckCallback,
|
|
||||||
requestedIndices: requestedIndices,
|
requestedIndices: requestedIndices,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'package:flutter/foundation.dart';
|
|||||||
import '../models/message.dart';
|
import '../models/message.dart';
|
||||||
import '../models/contact.dart';
|
import '../models/contact.dart';
|
||||||
import '../models/message_contact_location.dart';
|
import '../models/message_contact_location.dart';
|
||||||
|
import '../models/message_reception_details.dart';
|
||||||
import '../models/sar_marker.dart';
|
import '../models/sar_marker.dart';
|
||||||
import '../models/map_drawing.dart';
|
import '../models/map_drawing.dart';
|
||||||
import '../services/message_storage_service.dart';
|
import '../services/message_storage_service.dart';
|
||||||
@@ -22,6 +23,7 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
bool _isInitialized = false;
|
bool _isInitialized = false;
|
||||||
AppLocalizations? _localizations;
|
AppLocalizations? _localizations;
|
||||||
final Map<String, MessageContactLocation> _messageContactLocations = {};
|
final Map<String, MessageContactLocation> _messageContactLocations = {};
|
||||||
|
final Map<String, MessageReceptionDetails> _messageReceptionDetails = {};
|
||||||
|
|
||||||
// Track pending sent messages by expected ACK/TAG
|
// Track pending sent messages by expected ACK/TAG
|
||||||
final Map<int, Message> _pendingSentMessages = {};
|
final Map<int, Message> _pendingSentMessages = {};
|
||||||
@@ -74,10 +76,7 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
})?
|
})?
|
||||||
sendMessageCallback;
|
sendMessageCallback;
|
||||||
|
|
||||||
Future<void> Function({
|
Future<void> Function({required Contact contact, required int failureStreak})?
|
||||||
required Contact contact,
|
|
||||||
required int failureStreak,
|
|
||||||
})?
|
|
||||||
onDirectPathFailedCallback;
|
onDirectPathFailedCallback;
|
||||||
|
|
||||||
List<Message> get messages => List.unmodifiable(_messages);
|
List<Message> get messages => List.unmodifiable(_messages);
|
||||||
@@ -115,6 +114,9 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
MessageContactLocation? getMessageContactLocation(String messageId) =>
|
MessageContactLocation? getMessageContactLocation(String messageId) =>
|
||||||
_messageContactLocations[messageId];
|
_messageContactLocations[messageId];
|
||||||
|
|
||||||
|
MessageReceptionDetails? getMessageReceptionDetails(String messageId) =>
|
||||||
|
_messageReceptionDetails[messageId];
|
||||||
|
|
||||||
/// Set localizations for notifications
|
/// Set localizations for notifications
|
||||||
void setLocalizations(AppLocalizations localizations) {
|
void setLocalizations(AppLocalizations localizations) {
|
||||||
_localizations = localizations;
|
_localizations = localizations;
|
||||||
@@ -145,9 +147,14 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
final storedMessages = await _storageService.loadMessages();
|
final storedMessages = await _storageService.loadMessages();
|
||||||
final storedContactLocations = await _storageService
|
final storedContactLocations = await _storageService
|
||||||
.loadMessageContactLocations();
|
.loadMessageContactLocations();
|
||||||
|
final storedReceptionDetails = await _storageService
|
||||||
|
.loadMessageReceptionDetails();
|
||||||
_messageContactLocations
|
_messageContactLocations
|
||||||
..clear()
|
..clear()
|
||||||
..addAll(storedContactLocations);
|
..addAll(storedContactLocations);
|
||||||
|
_messageReceptionDetails
|
||||||
|
..clear()
|
||||||
|
..addAll(storedReceptionDetails);
|
||||||
|
|
||||||
// Add stored messages with enhancement to ensure SAR detection
|
// Add stored messages with enhancement to ensure SAR detection
|
||||||
for (final message in storedMessages) {
|
for (final message in storedMessages) {
|
||||||
@@ -311,6 +318,7 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
Message message, {
|
Message message, {
|
||||||
String Function(String name)? contactLookup,
|
String Function(String name)? contactLookup,
|
||||||
MessageContactLocation? contactLocationSnapshot,
|
MessageContactLocation? contactLocationSnapshot,
|
||||||
|
MessageReceptionDetails? receptionDetailsSnapshot,
|
||||||
}) {
|
}) {
|
||||||
// Always enhance message with SAR parser to detect SAR markers
|
// Always enhance message with SAR parser to detect SAR markers
|
||||||
var enhancedMessage = SarMessageParser.enhanceMessage(message);
|
var enhancedMessage = SarMessageParser.enhanceMessage(message);
|
||||||
@@ -397,6 +405,22 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
debugPrint(
|
debugPrint(
|
||||||
' Text: ${finalMessage.text.substring(0, finalMessage.text.length > 50 ? 50 : finalMessage.text.length)}...',
|
' 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
|
return; // Skip duplicate
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -404,6 +428,9 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
if (contactLocationSnapshot != null) {
|
if (contactLocationSnapshot != null) {
|
||||||
_messageContactLocations[finalMessage.id] = contactLocationSnapshot;
|
_messageContactLocations[finalMessage.id] = contactLocationSnapshot;
|
||||||
}
|
}
|
||||||
|
if (receptionDetailsSnapshot != null) {
|
||||||
|
_messageReceptionDetails[finalMessage.id] = receptionDetailsSnapshot;
|
||||||
|
}
|
||||||
|
|
||||||
// If it's a SAR marker message, extract and store the marker
|
// If it's a SAR marker message, extract and store the marker
|
||||||
if (finalMessage.isSarMarker) {
|
if (finalMessage.isSarMarker) {
|
||||||
@@ -593,6 +620,7 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
await _storageService.saveMessages(
|
await _storageService.saveMessages(
|
||||||
_messages,
|
_messages,
|
||||||
messageContactLocations: _messageContactLocations,
|
messageContactLocations: _messageContactLocations,
|
||||||
|
messageReceptionDetails: _messageReceptionDetails,
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('❌ [MessagesProvider] Error persisting messages: $e');
|
debugPrint('❌ [MessagesProvider] Error persisting messages: $e');
|
||||||
@@ -709,6 +737,7 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
_messageContactMap.remove(messageId);
|
_messageContactMap.remove(messageId);
|
||||||
_groupedMessageMapping.remove(messageId);
|
_groupedMessageMapping.remove(messageId);
|
||||||
_messageContactLocations.remove(messageId);
|
_messageContactLocations.remove(messageId);
|
||||||
|
_messageReceptionDetails.remove(messageId);
|
||||||
|
|
||||||
debugPrint('🗑️ [MessagesProvider] Message $messageId deleted');
|
debugPrint('🗑️ [MessagesProvider] Message $messageId deleted');
|
||||||
|
|
||||||
@@ -738,6 +767,7 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
_messages.clear();
|
_messages.clear();
|
||||||
_sarMarkers.clear();
|
_sarMarkers.clear();
|
||||||
_messageContactLocations.clear();
|
_messageContactLocations.clear();
|
||||||
|
_messageReceptionDetails.clear();
|
||||||
_persistMessages();
|
_persistMessages();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
@@ -753,6 +783,7 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
_messages.clear();
|
_messages.clear();
|
||||||
_sarMarkers.clear();
|
_sarMarkers.clear();
|
||||||
_messageContactLocations.clear();
|
_messageContactLocations.clear();
|
||||||
|
_messageReceptionDetails.clear();
|
||||||
_persistMessages();
|
_persistMessages();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
@@ -1062,10 +1093,11 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
' Message text preview: ${message.text.substring(0, message.text.length > 30 ? 30 : message.text.length)}...',
|
' 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(
|
final updatedMessage = message.copyWith(
|
||||||
deliveryStatus: expectedAckTag > 0
|
deliveryStatus: MessageDeliveryStatus.sent,
|
||||||
? MessageDeliveryStatus.sending
|
|
||||||
: MessageDeliveryStatus.sent,
|
|
||||||
expectedAckTag: expectedAckTag > 0 ? expectedAckTag : null,
|
expectedAckTag: expectedAckTag > 0 ? expectedAckTag : null,
|
||||||
suggestedTimeoutMs: expectedAckTag > 0 ? effectiveTimeout : null,
|
suggestedTimeoutMs: expectedAckTag > 0 ? effectiveTimeout : null,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -71,12 +71,6 @@ class VoiceProvider with ChangeNotifier {
|
|||||||
required Uint8List payload,
|
required Uint8List payload,
|
||||||
})?
|
})?
|
||||||
sendRawPacketCallback;
|
sendRawPacketCallback;
|
||||||
Future<bool> Function({
|
|
||||||
required String sessionId,
|
|
||||||
required int index,
|
|
||||||
Duration timeout,
|
|
||||||
})?
|
|
||||||
waitForFragmentAckCallback;
|
|
||||||
|
|
||||||
final Map<String, _OutgoingVoiceSession> _outgoingSessions = {};
|
final Map<String, _OutgoingVoiceSession> _outgoingSessions = {};
|
||||||
|
|
||||||
@@ -217,7 +211,6 @@ class VoiceProvider with ChangeNotifier {
|
|||||||
indexOf: (packet) => packet.index,
|
indexOf: (packet) => packet.index,
|
||||||
encodeBinary: (packet) => packet.encodeBinary(),
|
encodeBinary: (packet) => packet.encodeBinary(),
|
||||||
sendRawPacket: sendRawPacketCallback,
|
sendRawPacket: sendRawPacketCallback,
|
||||||
waitForFragmentAck: waitForFragmentAckCallback,
|
|
||||||
requestedIndices: requestedIndices,
|
requestedIndices: requestedIndices,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,9 @@ import '../models/message.dart';
|
|||||||
import '../models/contact.dart';
|
import '../models/contact.dart';
|
||||||
import '../widgets/messages/sar_update_sheet.dart';
|
import '../widgets/messages/sar_update_sheet.dart';
|
||||||
import '../widgets/messages/recipient_selector_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/message_destination_preferences.dart';
|
||||||
import '../services/voice_bitrate_preferences.dart';
|
import '../services/voice_bitrate_preferences.dart';
|
||||||
import '../services/voice_recorder_service.dart';
|
import '../services/voice_recorder_service.dart';
|
||||||
@@ -47,6 +49,7 @@ class MessagesTab extends StatefulWidget {
|
|||||||
class _MessagesTabState extends State<MessagesTab> {
|
class _MessagesTabState extends State<MessagesTab> {
|
||||||
static const int _maxContactMessageBytes = 156;
|
static const int _maxContactMessageBytes = 156;
|
||||||
static const int _maxChannelMessageBytes = 127;
|
static const int _maxChannelMessageBytes = 127;
|
||||||
|
static const double _composerOverlayHeight = 148;
|
||||||
|
|
||||||
final TextEditingController _textController = TextEditingController();
|
final TextEditingController _textController = TextEditingController();
|
||||||
final FocusNode _focusNode = FocusNode();
|
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() {
|
String _getDestinationLabel() {
|
||||||
if (_destinationType ==
|
if (_destinationType ==
|
||||||
MessageDestinationPreferences.destinationTypeChannel &&
|
MessageDestinationPreferences.destinationTypeChannel &&
|
||||||
@@ -466,10 +455,7 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Add to messages list with "sending" status
|
// Add to messages list with "sending" status
|
||||||
messagesProvider.addSentMessage(
|
messagesProvider.addSentMessage(sentMessage, contact: _selectedRecipient);
|
||||||
sentMessage,
|
|
||||||
contact: _selectedRecipient,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Send message to selected recipient
|
// Send message to selected recipient
|
||||||
final sentSuccessfully = await connectionProvider.sendTextMessage(
|
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() {
|
void _showComposerActions() {
|
||||||
showModalBottomSheet(
|
showModalBottomSheet(
|
||||||
context: context,
|
context: context,
|
||||||
@@ -1126,9 +1122,10 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.add_location_alt),
|
leading: const Icon(Icons.add_location_alt),
|
||||||
title: Text(AppLocalizations.of(context)!.sendSarMarker),
|
title: Text(AppLocalizations.of(context)!.sendSarMarker),
|
||||||
onTap: () {
|
onTap: () async {
|
||||||
Navigator.pop(sheetContext);
|
await _runAfterSheetDismissal(sheetContext, () async {
|
||||||
_showSarDialog();
|
_showSarDialog();
|
||||||
|
});
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
if (_voiceSupported)
|
if (_voiceSupported)
|
||||||
@@ -1138,13 +1135,14 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
title: Text(_isRecording ? 'Stop recording' : 'Record voice'),
|
title: Text(_isRecording ? 'Stop recording' : 'Record voice'),
|
||||||
onTap: _isSendingVoice
|
onTap: _isSendingVoice
|
||||||
? null
|
? null
|
||||||
: () {
|
: () async {
|
||||||
Navigator.pop(sheetContext);
|
await _runAfterSheetDismissal(sheetContext, () async {
|
||||||
if (_isRecording) {
|
if (_isRecording) {
|
||||||
_stopAndSendVoice();
|
await _stopAndSendVoice();
|
||||||
} else {
|
} else {
|
||||||
_startVoiceRecording();
|
await _startVoiceRecording();
|
||||||
}
|
}
|
||||||
|
});
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
@@ -1153,9 +1151,10 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
title: const Text('Send image from gallery'),
|
title: const Text('Send image from gallery'),
|
||||||
onTap: _isSendingImage
|
onTap: _isSendingImage
|
||||||
? null
|
? null
|
||||||
: () {
|
: () async {
|
||||||
Navigator.pop(sheetContext);
|
await _runAfterSheetDismissal(sheetContext, () async {
|
||||||
_pickAndSendImage(source: ImageSource.gallery);
|
await _pickAndSendImage(source: ImageSource.gallery);
|
||||||
|
});
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
@@ -1164,18 +1163,20 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
title: const Text('Take photo'),
|
title: const Text('Take photo'),
|
||||||
onTap: _isSendingImage
|
onTap: _isSendingImage
|
||||||
? null
|
? null
|
||||||
: () {
|
: () async {
|
||||||
Navigator.pop(sheetContext);
|
await _runAfterSheetDismissal(sheetContext, () async {
|
||||||
_pickAndSendImage(source: ImageSource.camera);
|
await _pickAndSendImage(source: ImageSource.camera);
|
||||||
|
});
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.grid_3x3),
|
leading: const Icon(Icons.grid_3x3),
|
||||||
title: const Text('Start Tic-Tac-Toe'),
|
title: const Text('Start Tic-Tac-Toe'),
|
||||||
subtitle: const Text('DM only'),
|
subtitle: const Text('DM only'),
|
||||||
onTap: () {
|
onTap: () async {
|
||||||
Navigator.pop(sheetContext);
|
await _runAfterSheetDismissal(sheetContext, () async {
|
||||||
_startTicTacToeGame();
|
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(
|
Future<void> _sendSarMessage(
|
||||||
String emoji,
|
String emoji,
|
||||||
String name,
|
String name,
|
||||||
@@ -1496,6 +1514,28 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
return filteredMessages;
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Consumer<MessagesProvider>(
|
return Consumer<MessagesProvider>(
|
||||||
@@ -1507,552 +1547,41 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
behavior: HitTestBehavior.translucent,
|
behavior: HitTestBehavior.translucent,
|
||||||
onTap: () => FocusScope.of(context).unfocus(),
|
onTap: () => FocusScope.of(context).unfocus(),
|
||||||
child: Column(
|
child: Stack(
|
||||||
children: [
|
children: [
|
||||||
// Messages list with pull-to-refresh
|
Positioned.fill(
|
||||||
Expanded(
|
child: MessagesContent(
|
||||||
child: RefreshIndicator(
|
messages: messages,
|
||||||
|
scrollController: _scrollController,
|
||||||
|
highlightedMessageId: _highlightedMessageId,
|
||||||
|
bottomContentPadding:
|
||||||
|
_composerOverlayHeight + composerBottomPadding,
|
||||||
onRefresh: _handleRefresh,
|
onRefresh: _handleRefresh,
|
||||||
child: messages.isEmpty
|
onNavigateToMap: widget.onNavigateToMap,
|
||||||
? LayoutBuilder(
|
onMessageTap: _handleMessageTap,
|
||||||
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,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
Positioned(
|
||||||
// Message input area
|
left: 0,
|
||||||
Container(
|
right: 0,
|
||||||
decoration: BoxDecoration(
|
bottom: 0,
|
||||||
color: Theme.of(context).colorScheme.surface,
|
child: MessagesComposer(
|
||||||
),
|
textController: _textController,
|
||||||
child: Column(
|
focusNode: _focusNode,
|
||||||
mainAxisSize: MainAxisSize.min,
|
messageByteLimiter: _messageByteLimiter,
|
||||||
children: [
|
messageByteCount: _messageByteCount,
|
||||||
SafeArea(
|
maxMessageBytes: _maxMessageBytes,
|
||||||
top: false,
|
isRecording: _isRecording,
|
||||||
child: Padding(
|
isSendingVoice: _isSendingVoice,
|
||||||
padding: EdgeInsets.fromLTRB(
|
voiceSupported: _voiceSupported,
|
||||||
10,
|
bottomPadding: composerBottomPadding,
|
||||||
10,
|
destinationLabel: _getDestinationLabel(),
|
||||||
10,
|
destinationAvatar: _buildDestinationAvatar(context),
|
||||||
composerBottomPadding,
|
onShowComposerActions: _showComposerActions,
|
||||||
),
|
onShowRecipientSelector: _showRecipientSelector,
|
||||||
child: Container(
|
onStartVoiceRecording: _startVoiceRecording,
|
||||||
decoration: BoxDecoration(
|
onStopAndSendVoice: _stopAndSendVoice,
|
||||||
color: Theme.of(
|
onSendMessage: _sendMessage,
|
||||||
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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
import 'package:share_plus/share_plus.dart';
|
import 'package:share_plus/share_plus.dart';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
import 'package:meshcore_client/meshcore_client.dart';
|
import 'package:meshcore_client/meshcore_client.dart';
|
||||||
import '../l10n/app_localizations.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 {
|
class PacketLogScreen extends StatefulWidget {
|
||||||
final MeshCoreBleService bleService;
|
final MeshCoreBleService bleService;
|
||||||
@@ -456,6 +460,26 @@ class _PacketLogCard extends StatelessWidget {
|
|||||||
final isRx = log.direction == PacketDirection.rx;
|
final isRx = log.direction == PacketDirection.rx;
|
||||||
final directionColor = isRx ? Colors.green : Colors.blue;
|
final directionColor = isRx ? Colors.green : Colors.blue;
|
||||||
final rxInfo = log.logRxDataInfo;
|
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(
|
return Card(
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
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),
|
const SizedBox(height: 12),
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.all(12),
|
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 {
|
class _FactCard extends StatelessWidget {
|
||||||
final IconData icon;
|
final IconData icon;
|
||||||
final String label;
|
final String label;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'package:flutter/foundation.dart';
|
|||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import '../models/message.dart';
|
import '../models/message.dart';
|
||||||
import '../models/message_contact_location.dart';
|
import '../models/message_contact_location.dart';
|
||||||
|
import '../models/message_reception_details.dart';
|
||||||
import 'package:latlong2/latlong.dart';
|
import 'package:latlong2/latlong.dart';
|
||||||
|
|
||||||
/// Service for persisting messages to local storage
|
/// Service for persisting messages to local storage
|
||||||
@@ -10,12 +11,15 @@ class MessageStorageService {
|
|||||||
static const String _messagesKey = 'stored_messages';
|
static const String _messagesKey = 'stored_messages';
|
||||||
static const String _messageContactLocationsKey =
|
static const String _messageContactLocationsKey =
|
||||||
'stored_message_contact_locations';
|
'stored_message_contact_locations';
|
||||||
|
static const String _messageReceptionDetailsKey =
|
||||||
|
'stored_message_reception_details';
|
||||||
static const int _maxStoredMessages = 1000; // Store up to 1000 messages
|
static const int _maxStoredMessages = 1000; // Store up to 1000 messages
|
||||||
|
|
||||||
/// Save messages to persistent storage
|
/// Save messages to persistent storage
|
||||||
Future<void> saveMessages(
|
Future<void> saveMessages(
|
||||||
List<Message> messages, {
|
List<Message> messages, {
|
||||||
Map<String, MessageContactLocation> messageContactLocations = const {},
|
Map<String, MessageContactLocation> messageContactLocations = const {},
|
||||||
|
Map<String, MessageReceptionDetails> messageReceptionDetails = const {},
|
||||||
}) async {
|
}) async {
|
||||||
try {
|
try {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
@@ -34,15 +38,25 @@ class MessageStorageService {
|
|||||||
.map((entry) => entry['id'] as String)
|
.map((entry) => entry['id'] as String)
|
||||||
.toSet();
|
.toSet();
|
||||||
final locationJson = <String, dynamic>{};
|
final locationJson = <String, dynamic>{};
|
||||||
|
final receptionJson = <String, dynamic>{};
|
||||||
for (final entry in messageContactLocations.entries) {
|
for (final entry in messageContactLocations.entries) {
|
||||||
if (retainedMessageIds.contains(entry.key)) {
|
if (retainedMessageIds.contains(entry.key)) {
|
||||||
locationJson[entry.key] = entry.value.toJson();
|
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(
|
await prefs.setString(
|
||||||
_messageContactLocationsKey,
|
_messageContactLocationsKey,
|
||||||
jsonEncode(locationJson),
|
jsonEncode(locationJson),
|
||||||
);
|
);
|
||||||
|
await prefs.setString(
|
||||||
|
_messageReceptionDetailsKey,
|
||||||
|
jsonEncode(receptionJson),
|
||||||
|
);
|
||||||
|
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'✅ [MessageStorage] Saved ${limitedList.length} messages to storage',
|
'✅ [MessageStorage] Saved ${limitedList.length} messages to storage',
|
||||||
@@ -52,8 +66,8 @@ class MessageStorageService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<Map<String, MessageContactLocation>> loadMessageContactLocations()
|
Future<Map<String, MessageContactLocation>>
|
||||||
async {
|
loadMessageContactLocations() async {
|
||||||
try {
|
try {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
final jsonString = prefs.getString(_messageContactLocationsKey);
|
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
|
/// Load messages from persistent storage
|
||||||
Future<List<Message>> loadMessages() async {
|
Future<List<Message>> loadMessages() async {
|
||||||
try {
|
try {
|
||||||
@@ -116,6 +160,7 @@ class MessageStorageService {
|
|||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
await prefs.remove(_messagesKey);
|
await prefs.remove(_messagesKey);
|
||||||
await prefs.remove(_messageContactLocationsKey);
|
await prefs.remove(_messageContactLocationsKey);
|
||||||
|
await prefs.remove(_messageReceptionDetailsKey);
|
||||||
debugPrint('✅ [MessageStorage] Cleared all stored messages');
|
debugPrint('✅ [MessageStorage] Cleared all stored messages');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('❌ [MessageStorage] Error clearing messages: $e');
|
debugPrint('❌ [MessageStorage] Error clearing messages: $e');
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ class VoiceRecorderService {
|
|||||||
|
|
||||||
/// Request microphone permission. Returns true if granted.
|
/// Request microphone permission. Returns true if granted.
|
||||||
Future<bool> requestPermission() async {
|
Future<bool> requestPermission() async {
|
||||||
return _recorder.hasPermission();
|
return _recorder.hasPermission(request: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Start capturing PCM audio.
|
/// Start capturing PCM audio.
|
||||||
@@ -38,9 +38,7 @@ class VoiceRecorderService {
|
|||||||
throw StateError('VoiceRecorderService: already recording');
|
throw StateError('VoiceRecorderService: already recording');
|
||||||
}
|
}
|
||||||
|
|
||||||
_controller = StreamController<Int16List>(
|
_controller = StreamController<Int16List>(onCancel: () => _stopInternal());
|
||||||
onCancel: () => _stopInternal(),
|
|
||||||
);
|
|
||||||
_isRecording = true;
|
_isRecording = true;
|
||||||
|
|
||||||
_startRecording(
|
_startRecording(
|
||||||
@@ -167,17 +165,17 @@ class _VoiceDynamicsProcessor {
|
|||||||
required int sampleRate,
|
required int sampleRate,
|
||||||
required bool enableCompressor,
|
required bool enableCompressor,
|
||||||
required bool enableLimiter,
|
required bool enableLimiter,
|
||||||
}) : _enableCompressor = enableCompressor,
|
}) : _enableCompressor = enableCompressor,
|
||||||
_enableLimiter = enableLimiter,
|
_enableLimiter = enableLimiter,
|
||||||
_compressor = _SimpleCompressor(
|
_compressor = _SimpleCompressor(
|
||||||
sampleRate: sampleRate.toDouble(),
|
sampleRate: sampleRate.toDouble(),
|
||||||
thresholdDb: -18.0,
|
thresholdDb: -18.0,
|
||||||
ratio: 2.5,
|
ratio: 2.5,
|
||||||
attackMs: 8.0,
|
attackMs: 8.0,
|
||||||
releaseMs: 120.0,
|
releaseMs: 120.0,
|
||||||
makeupGainDb: 4.0,
|
makeupGainDb: 4.0,
|
||||||
),
|
),
|
||||||
_limiter = _PeakLimiter(ceilingDb: -1.0);
|
_limiter = _PeakLimiter(ceilingDb: -1.0);
|
||||||
|
|
||||||
Int16List process(Int16List input) {
|
Int16List process(Int16List input) {
|
||||||
final output = Int16List(input.length);
|
final output = Int16List(input.length);
|
||||||
@@ -214,11 +212,11 @@ class _SimpleCompressor {
|
|||||||
required double attackMs,
|
required double attackMs,
|
||||||
required double releaseMs,
|
required double releaseMs,
|
||||||
required double makeupGainDb,
|
required double makeupGainDb,
|
||||||
}) : _thresholdDb = thresholdDb,
|
}) : _thresholdDb = thresholdDb,
|
||||||
_ratio = ratio,
|
_ratio = ratio,
|
||||||
_makeupGain = math.pow(10.0, makeupGainDb / 20.0).toDouble(),
|
_makeupGain = math.pow(10.0, makeupGainDb / 20.0).toDouble(),
|
||||||
_attackCoeff = math.exp(-1.0 / (sampleRate * (attackMs / 1000.0))),
|
_attackCoeff = math.exp(-1.0 / (sampleRate * (attackMs / 1000.0))),
|
||||||
_releaseCoeff = math.exp(-1.0 / (sampleRate * (releaseMs / 1000.0)));
|
_releaseCoeff = math.exp(-1.0 / (sampleRate * (releaseMs / 1000.0)));
|
||||||
|
|
||||||
double process(double x) {
|
double process(double x) {
|
||||||
final absX = x.abs();
|
final absX = x.abs();
|
||||||
@@ -266,14 +264,14 @@ class _VoiceBandPassFilter {
|
|||||||
required int sampleRate,
|
required int sampleRate,
|
||||||
required double lowCutHz,
|
required double lowCutHz,
|
||||||
required double highCutHz,
|
required double highCutHz,
|
||||||
}) : _highPass = _BiquadFilter.highPass(
|
}) : _highPass = _BiquadFilter.highPass(
|
||||||
sampleRate: sampleRate.toDouble(),
|
sampleRate: sampleRate.toDouble(),
|
||||||
cutoffHz: lowCutHz,
|
cutoffHz: lowCutHz,
|
||||||
),
|
),
|
||||||
_lowPass = _BiquadFilter.lowPass(
|
_lowPass = _BiquadFilter.lowPass(
|
||||||
sampleRate: sampleRate.toDouble(),
|
sampleRate: sampleRate.toDouble(),
|
||||||
cutoffHz: highCutHz,
|
cutoffHz: highCutHz,
|
||||||
);
|
);
|
||||||
|
|
||||||
Int16List process(Int16List input) {
|
Int16List process(Int16List input) {
|
||||||
final output = Int16List(input.length);
|
final output = Int16List(input.length);
|
||||||
@@ -306,11 +304,11 @@ class _BiquadFilter {
|
|||||||
required double b2,
|
required double b2,
|
||||||
required double a1,
|
required double a1,
|
||||||
required double a2,
|
required double a2,
|
||||||
}) : _b0 = b0,
|
}) : _b0 = b0,
|
||||||
_b1 = b1,
|
_b1 = b1,
|
||||||
_b2 = b2,
|
_b2 = b2,
|
||||||
_a1 = a1,
|
_a1 = a1,
|
||||||
_a2 = a2;
|
_a2 = a2;
|
||||||
|
|
||||||
factory _BiquadFilter.lowPass({
|
factory _BiquadFilter.lowPass({
|
||||||
required double sampleRate,
|
required double sampleRate,
|
||||||
|
|||||||
25
lib/utils/location_formats.dart
Normal file
25
lib/utils/location_formats.dart
Normal 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();
|
||||||
|
}
|
||||||
129
lib/utils/log_rx_route_decoder.dart
Normal file
129
lib/utils/log_rx_route_decoder.dart
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
149
lib/utils/message_airtime_estimator.dart
Normal file
149
lib/utils/message_airtime_estimator.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
87
lib/utils/raw_route_probe.dart
Normal file
87
lib/utils/raw_route_probe.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ import 'dart:typed_data';
|
|||||||
import '../models/contact.dart';
|
import '../models/contact.dart';
|
||||||
import '../providers/contacts_provider.dart';
|
import '../providers/contacts_provider.dart';
|
||||||
|
|
||||||
enum TransmissionTargetFailure { unknownContact, unknownRoute, tooFar }
|
enum TransmissionTargetFailure { unknownContact, unknownRoute, tooFar, unreachable }
|
||||||
|
|
||||||
class TransmissionTargetResolution {
|
class TransmissionTargetResolution {
|
||||||
final Contact? target;
|
final Contact? target;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:latlong2/latlong.dart';
|
import 'package:latlong2/latlong.dart';
|
||||||
import '../../l10n/app_localizations.dart';
|
import '../../l10n/app_localizations.dart';
|
||||||
|
import '../../utils/location_formats.dart';
|
||||||
|
|
||||||
/// Reusable location display widget with tap-to-show modal
|
/// Reusable location display widget with tap-to-show modal
|
||||||
/// Shows coordinates in a compact format with ability to view all formats
|
/// Shows coordinates in a compact format with ability to view all formats
|
||||||
@@ -35,9 +36,9 @@ class LocationDisplay extends StatelessWidget {
|
|||||||
Text(
|
Text(
|
||||||
'${location.latitude.toStringAsFixed(5)}, ${location.longitude.toStringAsFixed(5)}',
|
'${location.latitude.toStringAsFixed(5)}, ${location.longitude.toStringAsFixed(5)}',
|
||||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
fontFamily: 'monospace',
|
fontFamily: 'monospace',
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 6),
|
const SizedBox(width: 6),
|
||||||
Icon(
|
Icon(
|
||||||
@@ -54,9 +55,9 @@ class LocationDisplay extends StatelessWidget {
|
|||||||
// Non-compact version (just text)
|
// Non-compact version (just text)
|
||||||
return Text(
|
return Text(
|
||||||
'${location.latitude.toStringAsFixed(5)}, ${location.longitude.toStringAsFixed(5)}',
|
'${location.latitude.toStringAsFixed(5)}, ${location.longitude.toStringAsFixed(5)}',
|
||||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
style: Theme.of(
|
||||||
fontFamily: 'monospace',
|
context,
|
||||||
),
|
).textTheme.bodyMedium?.copyWith(fontFamily: 'monospace'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,10 +80,7 @@ class LocationDisplay extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
const Text(
|
const Text(
|
||||||
'Location Formats',
|
'Location Formats',
|
||||||
style: TextStyle(
|
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
|
||||||
fontSize: 20,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.close),
|
icon: const Icon(Icons.close),
|
||||||
@@ -121,7 +119,7 @@ class LocationDisplay extends StatelessWidget {
|
|||||||
_buildFormatRow(
|
_buildFormatRow(
|
||||||
context,
|
context,
|
||||||
'Plus Code',
|
'Plus Code',
|
||||||
_convertToPlusCode(location.latitude, location.longitude),
|
formatPlusCode(location.latitude, location.longitude),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
],
|
],
|
||||||
@@ -140,9 +138,9 @@ class LocationDisplay extends StatelessWidget {
|
|||||||
Text(
|
Text(
|
||||||
label,
|
label,
|
||||||
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
||||||
color: Colors.grey,
|
color: Colors.grey,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
InkWell(
|
InkWell(
|
||||||
@@ -150,7 +148,9 @@ class LocationDisplay extends StatelessWidget {
|
|||||||
Clipboard.setData(ClipboardData(text: value));
|
Clipboard.setData(ClipboardData(text: value));
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text(AppLocalizations.of(context)!.copiedToClipboard(label)),
|
content: Text(
|
||||||
|
AppLocalizations.of(context)!.copiedToClipboard(label),
|
||||||
|
),
|
||||||
duration: const Duration(seconds: 2),
|
duration: const Duration(seconds: 2),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -168,9 +168,9 @@ class LocationDisplay extends StatelessWidget {
|
|||||||
child: Text(
|
child: Text(
|
||||||
value,
|
value,
|
||||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
fontFamily: 'monospace',
|
fontFamily: 'monospace',
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Icon(
|
Icon(
|
||||||
@@ -242,31 +242,4 @@ class LocationDisplay extends StatelessWidget {
|
|||||||
// Full MGRS would require UTM conversion library
|
// Full MGRS would require UTM conversion library
|
||||||
return '$zone$letter (approximate)';
|
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,10 +21,19 @@ class _ConnectionDialogState extends State<ConnectionDialog>
|
|||||||
final List<DiscoveredServer> _discoveredServers = [];
|
final List<DiscoveredServer> _discoveredServers = [];
|
||||||
int _scannedCount = 0;
|
int _scannedCount = 0;
|
||||||
int _totalToScan = 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
|
// Named listener method for proper cleanup
|
||||||
void _onTabChanged() {
|
void _onTabChanged() {
|
||||||
|
if (_tabController.index == _lastTabIndex) return;
|
||||||
|
_lastTabIndex = _tabController.index;
|
||||||
|
|
||||||
|
if (_tabController.index == 0) {
|
||||||
|
_refreshBleDevices();
|
||||||
|
}
|
||||||
|
|
||||||
if (_tabController.index == 1) {
|
if (_tabController.index == 1) {
|
||||||
// Switched to network tab
|
// Switched to network tab
|
||||||
if (_networkScanner.hasCachedResults && _discoveredServers.isEmpty) {
|
if (_networkScanner.hasCachedResults && _discoveredServers.isEmpty) {
|
||||||
@@ -47,13 +56,16 @@ class _ConnectionDialogState extends State<ConnectionDialog>
|
|||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_tabController = TabController(length: 2, vsync: this);
|
_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
|
// Defer scan startup until after the first frame so Provider listeners
|
||||||
// are not notified while this dialog is still being built.
|
// are not notified while this dialog is still being built.
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
_connectionProvider.startScan();
|
_refreshBleDevices();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Set up network scanner callbacks
|
// Set up network scanner callbacks
|
||||||
@@ -101,6 +113,12 @@ class _ConnectionDialogState extends State<ConnectionDialog>
|
|||||||
_networkScanner.scan();
|
_networkScanner.scan();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _refreshBleDevices() async {
|
||||||
|
await _connectionProvider.stopScan();
|
||||||
|
if (!mounted) return;
|
||||||
|
await _connectionProvider.startScan();
|
||||||
|
}
|
||||||
|
|
||||||
Color _getSignalColor(int rssi) {
|
Color _getSignalColor(int rssi) {
|
||||||
if (rssi >= -60) return Colors.green;
|
if (rssi >= -60) return Colors.green;
|
||||||
if (rssi >= -75) return Colors.orange;
|
if (rssi >= -75) return Colors.orange;
|
||||||
@@ -218,10 +236,7 @@ class _ConnectionDialogState extends State<ConnectionDialog>
|
|||||||
Icons.refresh,
|
Icons.refresh,
|
||||||
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
||||||
),
|
),
|
||||||
onPressed: () {
|
onPressed: _refreshBleDevices,
|
||||||
connectionProvider.stopScan();
|
|
||||||
connectionProvider.startScan();
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -255,10 +270,7 @@ class _ConnectionDialogState extends State<ConnectionDialog>
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
TextButton.icon(
|
TextButton.icon(
|
||||||
onPressed: () {
|
onPressed: _refreshBleDevices,
|
||||||
connectionProvider.stopScan();
|
|
||||||
connectionProvider.startScan();
|
|
||||||
},
|
|
||||||
icon: const Icon(Icons.refresh),
|
icon: const Icon(Icons.refresh),
|
||||||
label: Text(AppLocalizations.of(context)!.scanAgain),
|
label: Text(AppLocalizations.of(context)!.scanAgain),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import '../../providers/map_provider.dart';
|
|||||||
import '../../providers/app_provider.dart';
|
import '../../providers/app_provider.dart';
|
||||||
import 'direct_message_sheet.dart';
|
import 'direct_message_sheet.dart';
|
||||||
import 'room_login_sheet.dart';
|
import 'room_login_sheet.dart';
|
||||||
|
import '../../utils/location_formats.dart';
|
||||||
import '../../utils/toast_logger.dart';
|
import '../../utils/toast_logger.dart';
|
||||||
import '../../utils/battery_display_helper.dart';
|
import '../../utils/battery_display_helper.dart';
|
||||||
import '../../l10n/app_localizations.dart';
|
import '../../l10n/app_localizations.dart';
|
||||||
@@ -69,6 +70,9 @@ class ContactTile extends StatelessWidget {
|
|||||||
|
|
||||||
// Get room login state if this is a room
|
// Get room login state if this is a room
|
||||||
final connectionProvider = context.watch<ConnectionProvider>();
|
final connectionProvider = context.watch<ConnectionProvider>();
|
||||||
|
final isPingInProgress = connectionProvider.isPingInProgress(
|
||||||
|
contact.publicKey,
|
||||||
|
);
|
||||||
final roomLoginState = contact.type == ContactType.room
|
final roomLoginState = contact.type == ContactType.room
|
||||||
? connectionProvider.getRoomLoginState(contact.publicKeyPrefix)
|
? connectionProvider.getRoomLoginState(contact.publicKeyPrefix)
|
||||||
: null;
|
: 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
|
subtitle: isSimpleMode
|
||||||
@@ -457,39 +472,43 @@ class ContactTile extends StatelessWidget {
|
|||||||
_showContactDetails(context, contact);
|
_showContactDetails(context, contact);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onLongPress: () async {
|
onLongPress: isPingInProgress
|
||||||
final connectionProvider = context.read<ConnectionProvider>();
|
? null
|
||||||
|
: () async {
|
||||||
|
final connectionProvider = context.read<ConnectionProvider>();
|
||||||
|
|
||||||
// Determine if we should use flooding (no path) or direct (has path)
|
// Determine if we should use flooding (no path) or direct (has path)
|
||||||
final hasPath = contact.hasPath;
|
final hasPath = contact.hasPath;
|
||||||
|
|
||||||
// Use smart ping with automatic fallback
|
// Use smart ping with automatic fallback
|
||||||
final result = await connectionProvider.smartPing(
|
final result = await connectionProvider.smartPing(
|
||||||
contactPublicKey: contact.publicKey,
|
contactPublicKey: contact.publicKey,
|
||||||
hasPath: hasPath,
|
hasPath: hasPath,
|
||||||
onRetryWithFlooding: () {
|
onRetryWithFlooding: () {
|
||||||
// Called when retrying with flooding after direct timeout
|
// Called when retrying with flooding after direct timeout
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
ToastLogger.warning(
|
ToastLogger.warning(
|
||||||
context,
|
context,
|
||||||
AppLocalizations.of(
|
AppLocalizations.of(
|
||||||
context,
|
context,
|
||||||
)!.directPingTimeout(contact.displayName),
|
)!.directPingTimeout(contact.displayName),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// Show final result
|
// Show final result
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
ToastLogger.error(
|
ToastLogger.error(
|
||||||
context,
|
context,
|
||||||
AppLocalizations.of(context)!.pingFailed(contact.displayName),
|
AppLocalizations.of(
|
||||||
);
|
context,
|
||||||
}
|
)!.pingFailed(contact.displayName),
|
||||||
}
|
);
|
||||||
},
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -602,8 +621,12 @@ class ContactTile extends StatelessWidget {
|
|||||||
minChildSize: 0.4,
|
minChildSize: 0.4,
|
||||||
maxChildSize: 0.9,
|
maxChildSize: 0.9,
|
||||||
expand: false,
|
expand: false,
|
||||||
builder: (context, scrollController) => Column(
|
builder: (context, scrollController) {
|
||||||
children: [
|
final isPingInProgress = context
|
||||||
|
.watch<ConnectionProvider>()
|
||||||
|
.isPingInProgress(contact.publicKey);
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
// Handle bar
|
// Handle bar
|
||||||
Container(
|
Container(
|
||||||
margin: const EdgeInsets.only(top: 8, bottom: 16),
|
margin: const EdgeInsets.only(top: 8, bottom: 16),
|
||||||
@@ -827,7 +850,7 @@ class ContactTile extends StatelessWidget {
|
|||||||
_detailRowWithCopy(
|
_detailRowWithCopy(
|
||||||
context,
|
context,
|
||||||
'Plus Code',
|
'Plus Code',
|
||||||
_convertToPlusCode(
|
formatPlusCode(
|
||||||
contact.displayLocation!.latitude,
|
contact.displayLocation!.latitude,
|
||||||
contact.displayLocation!.longitude,
|
contact.displayLocation!.longitude,
|
||||||
),
|
),
|
||||||
@@ -846,15 +869,25 @@ class ContactTile extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
TextButton.icon(
|
TextButton.icon(
|
||||||
onPressed: () {
|
onPressed: isPingInProgress
|
||||||
final connectionProvider = context
|
? null
|
||||||
.read<ConnectionProvider>();
|
: () {
|
||||||
connectionProvider.requestTelemetry(
|
final connectionProvider = context
|
||||||
contact.publicKey,
|
.read<ConnectionProvider>();
|
||||||
zeroHop: true,
|
connectionProvider.requestTelemetry(
|
||||||
);
|
contact.publicKey,
|
||||||
},
|
zeroHop: true,
|
||||||
icon: const Icon(Icons.refresh, size: 18),
|
);
|
||||||
|
},
|
||||||
|
icon: isPingInProgress
|
||||||
|
? const SizedBox(
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.refresh, size: 18),
|
||||||
label: Text(AppLocalizations.of(context)!.refresh),
|
label: Text(AppLocalizations.of(context)!.refresh),
|
||||||
style: TextButton.styleFrom(
|
style: TextButton.styleFrom(
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
@@ -990,8 +1023,9 @@ class ContactTile extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1113,34 +1147,6 @@ class ContactTile extends StatelessWidget {
|
|||||||
return '$zone$letter (approximate)';
|
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) {
|
IconData _getTypeIcon(ContactType type) {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case ContactType.chat:
|
case ContactType.chat:
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_avif/flutter_avif.dart';
|
import 'package:flutter_avif/flutter_avif.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import '../../models/message.dart';
|
import '../../models/message.dart';
|
||||||
|
import '../../providers/app_provider.dart';
|
||||||
import '../../providers/connection_provider.dart';
|
import '../../providers/connection_provider.dart';
|
||||||
import '../../providers/contacts_provider.dart';
|
import '../../providers/contacts_provider.dart';
|
||||||
import '../../providers/image_provider.dart' as ip;
|
import '../../providers/image_provider.dart' as ip;
|
||||||
@@ -254,11 +255,17 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
|
|||||||
int pathLen = 0,
|
int pathLen = 0,
|
||||||
}) async {
|
}) async {
|
||||||
if (_isRequesting) return;
|
if (_isRequesting) return;
|
||||||
|
setState(() {
|
||||||
|
_isRequesting = true;
|
||||||
|
_errorText = null;
|
||||||
|
});
|
||||||
|
|
||||||
final conn = context.read<ConnectionProvider>();
|
final conn = context.read<ConnectionProvider>();
|
||||||
final imageProvider = context.read<ip.ImageProvider>();
|
final imageProvider = context.read<ip.ImageProvider>();
|
||||||
imageProvider.resumeIncomingSession(envelope.sessionId);
|
imageProvider.resumeIncomingSession(envelope.sessionId);
|
||||||
final contactsProvider = context.read<ContactsProvider>();
|
final contactsProvider = context.read<ContactsProvider>();
|
||||||
final resolution = await TransmissionTargetResolver.resolveFetchTarget(
|
final appProvider = context.read<AppProvider>();
|
||||||
|
var resolution = await TransmissionTargetResolver.resolveFetchTarget(
|
||||||
contactsProvider: contactsProvider,
|
contactsProvider: contactsProvider,
|
||||||
refreshContacts: conn.getContacts,
|
refreshContacts: conn.getContacts,
|
||||||
isSentByMe: widget.isSentByMe,
|
isSentByMe: widget.isSentByMe,
|
||||||
@@ -271,6 +278,7 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
|
|||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
||||||
if (resolution.failure == TransmissionTargetFailure.unknownContact) {
|
if (resolution.failure == TransmissionTargetFailure.unknownContact) {
|
||||||
|
_clearRequestState();
|
||||||
await _showBlockingAlert(
|
await _showBlockingAlert(
|
||||||
'Cannot fetch image',
|
'Cannot fetch image',
|
||||||
'Sender contact is unknown. Sync contacts first.',
|
'Sender contact is unknown. Sync contacts first.',
|
||||||
@@ -278,6 +286,7 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (resolution.failure == TransmissionTargetFailure.unknownRoute) {
|
if (resolution.failure == TransmissionTargetFailure.unknownRoute) {
|
||||||
|
_clearRequestState();
|
||||||
await _showBlockingAlert(
|
await _showBlockingAlert(
|
||||||
'Cannot fetch image',
|
'Cannot fetch image',
|
||||||
'Sender route is unknown. Sync contacts/path first.',
|
'Sender route is unknown. Sync contacts/path first.',
|
||||||
@@ -285,14 +294,76 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (resolution.failure == TransmissionTargetFailure.tooFar) {
|
if (resolution.failure == TransmissionTargetFailure.tooFar) {
|
||||||
|
_clearRequestState();
|
||||||
await _showBlockingAlert(
|
await _showBlockingAlert(
|
||||||
'Cannot fetch image',
|
'Cannot fetch image',
|
||||||
'Message is too far (${resolution.hops} hops, max ${resolution.maxHops}).',
|
'Message is too far (${resolution.hops} hops, max ${resolution.maxHops}).',
|
||||||
);
|
);
|
||||||
return;
|
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) {
|
if (sender.outPathLen >= 2) {
|
||||||
_showToast(
|
_showToast(
|
||||||
'Image fetch over ${sender.outPathLen} hops may take a while.',
|
'Image fetch over ${sender.outPathLen} hops may take a while.',
|
||||||
@@ -302,6 +373,7 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
|
|||||||
setState(() => _errorText = null);
|
setState(() => _errorText = null);
|
||||||
final deviceKey = conn.deviceInfo.publicKey;
|
final deviceKey = conn.deviceInfo.publicKey;
|
||||||
if (deviceKey == null || deviceKey.length < 6) {
|
if (deviceKey == null || deviceKey.length < 6) {
|
||||||
|
_clearRequestState();
|
||||||
await _showBlockingAlert(
|
await _showBlockingAlert(
|
||||||
'Cannot fetch image',
|
'Cannot fetch image',
|
||||||
'Device key is unavailable.',
|
'Device key is unavailable.',
|
||||||
@@ -332,13 +404,11 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
|
|||||||
timestampSec: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
timestampSec: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||||
);
|
);
|
||||||
|
|
||||||
setState(() {
|
|
||||||
_isRequesting = true;
|
|
||||||
_errorText = null;
|
|
||||||
});
|
|
||||||
|
|
||||||
final payload = request.encodeBinary();
|
final payload = request.encodeBinary();
|
||||||
try {
|
try {
|
||||||
|
debugPrint(
|
||||||
|
'📷 [ImageMessageBubble] Outgoing image fetch request: session=${envelope.sessionId} want=${isPartialResume ? 'missing' : 'all'} target=${sender.advName} hops=${sender.outPathLen}',
|
||||||
|
);
|
||||||
await conn.sendRawVoicePacket(
|
await conn.sendRawVoicePacket(
|
||||||
contactPath: sender.outPath,
|
contactPath: sender.outPath,
|
||||||
contactPathLen: sender.outPathLen,
|
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 {
|
Future<void> _showBlockingAlert(String title, String message) async {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
_showToast('$title: $message');
|
_showToast('$title: $message');
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
160
lib/widgets/messages/message_bubble_header.dart
Normal file
160
lib/widgets/messages/message_bubble_header.dart
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
382
lib/widgets/messages/message_bubble_signal.dart
Normal file
382
lib/widgets/messages/message_bubble_signal.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
432
lib/widgets/messages/messages_composer.dart
Normal file
432
lib/widgets/messages/messages_composer.dart
Normal 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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
94
lib/widgets/messages/messages_content.dart
Normal file
94
lib/widgets/messages/messages_content.dart
Normal 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),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../../models/contact.dart';
|
import '../../models/contact.dart';
|
||||||
import '../../l10n/app_localizations.dart';
|
import '../../l10n/app_localizations.dart';
|
||||||
|
import '../common/contact_avatar.dart';
|
||||||
|
|
||||||
/// Bottom sheet for selecting message recipient (channel, contact, or room)
|
/// Bottom sheet for selecting message recipient (channel, contact, or room)
|
||||||
class RecipientSelectorSheet extends StatefulWidget {
|
class RecipientSelectorSheet extends StatefulWidget {
|
||||||
@@ -168,7 +169,7 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
|
|||||||
...filteredChannels.map((channel) {
|
...filteredChannels.map((channel) {
|
||||||
return _buildRecipientTile(
|
return _buildRecipientTile(
|
||||||
context: context,
|
context: context,
|
||||||
icon: Icons.public,
|
contact: channel,
|
||||||
title: channel.getLocalizedDisplayName(context),
|
title: channel.getLocalizedDisplayName(context),
|
||||||
subtitle: channel.isPublicChannel
|
subtitle: channel.isPublicChannel
|
||||||
? l10n.broadcastToAllNearby
|
? l10n.broadcastToAllNearby
|
||||||
@@ -215,10 +216,9 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
|
|||||||
...filteredContacts.map((contact) {
|
...filteredContacts.map((contact) {
|
||||||
return _buildRecipientTile(
|
return _buildRecipientTile(
|
||||||
context: context,
|
context: context,
|
||||||
icon: Icons.person,
|
contact: contact,
|
||||||
title: contact.displayName,
|
title: contact.displayName,
|
||||||
subtitle: contact.publicKeyShort,
|
subtitle: contact.publicKeyShort,
|
||||||
emoji: contact.roleEmoji,
|
|
||||||
isSelected: _isSelected('contact', contact),
|
isSelected: _isSelected('contact', contact),
|
||||||
onTap: () {
|
onTap: () {
|
||||||
widget.onSelect('contact', contact);
|
widget.onSelect('contact', contact);
|
||||||
@@ -261,10 +261,9 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
|
|||||||
...filteredRooms.map((room) {
|
...filteredRooms.map((room) {
|
||||||
return _buildRecipientTile(
|
return _buildRecipientTile(
|
||||||
context: context,
|
context: context,
|
||||||
icon: Icons.meeting_room,
|
contact: room,
|
||||||
title: room.displayName,
|
title: room.displayName,
|
||||||
subtitle: room.publicKeyShort,
|
subtitle: room.publicKeyShort,
|
||||||
emoji: room.roleEmoji,
|
|
||||||
isSelected: _isSelected('room', room),
|
isSelected: _isSelected('room', room),
|
||||||
onTap: () {
|
onTap: () {
|
||||||
widget.onSelect('room', room);
|
widget.onSelect('room', room);
|
||||||
@@ -275,7 +274,9 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
|
|||||||
],
|
],
|
||||||
|
|
||||||
// Empty state
|
// Empty state
|
||||||
if (widget.contacts.isEmpty && widget.rooms.isEmpty && widget.channels.isEmpty) ...[
|
if (widget.contacts.isEmpty &&
|
||||||
|
widget.rooms.isEmpty &&
|
||||||
|
widget.channels.isEmpty) ...[
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.all(32),
|
padding: const EdgeInsets.all(32),
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -310,36 +311,16 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
|
|||||||
|
|
||||||
Widget _buildRecipientTile({
|
Widget _buildRecipientTile({
|
||||||
required BuildContext context,
|
required BuildContext context,
|
||||||
required IconData icon,
|
required Contact contact,
|
||||||
required String title,
|
required String title,
|
||||||
required String subtitle,
|
required String subtitle,
|
||||||
String? emoji,
|
|
||||||
required bool isSelected,
|
required bool isSelected,
|
||||||
required VoidCallback onTap,
|
required VoidCallback onTap,
|
||||||
}) {
|
}) {
|
||||||
return ListTile(
|
return ListTile(
|
||||||
leading: Container(
|
leading: ContactAvatar(contact: contact, radius: 20, displayName: title),
|
||||||
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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
title: Row(
|
title: Row(
|
||||||
children: [
|
children: [
|
||||||
if (emoji != null && emoji.isNotEmpty) ...[
|
|
||||||
Text(emoji, style: const TextStyle(fontSize: 16)),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
],
|
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
title,
|
title,
|
||||||
|
|||||||
107
lib/widgets/messages/system_message_bubble.dart
Normal file
107
lib/widgets/messages/system_message_bubble.dart
Normal 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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import '../../l10n/app_localizations.dart';
|
import '../../l10n/app_localizations.dart';
|
||||||
import '../../models/message.dart';
|
import '../../models/message.dart';
|
||||||
|
import '../../providers/app_provider.dart';
|
||||||
import '../../providers/connection_provider.dart';
|
import '../../providers/connection_provider.dart';
|
||||||
import '../../providers/contacts_provider.dart';
|
import '../../providers/contacts_provider.dart';
|
||||||
import '../../providers/voice_provider.dart';
|
import '../../providers/voice_provider.dart';
|
||||||
@@ -218,11 +219,18 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
|
|||||||
int pathLen = 0,
|
int pathLen = 0,
|
||||||
}) async {
|
}) async {
|
||||||
if (_isRequesting) return;
|
if (_isRequesting) return;
|
||||||
|
setState(() {
|
||||||
|
_isRequesting = true;
|
||||||
|
_autoPlayWhenReady = true;
|
||||||
|
_errorText = null;
|
||||||
|
});
|
||||||
|
|
||||||
final connectionProvider = context.read<ConnectionProvider>();
|
final connectionProvider = context.read<ConnectionProvider>();
|
||||||
final voiceProvider = context.read<VoiceProvider>();
|
final voiceProvider = context.read<VoiceProvider>();
|
||||||
voiceProvider.resumeIncomingSession(sessionId);
|
voiceProvider.resumeIncomingSession(sessionId);
|
||||||
final contactsProvider = context.read<ContactsProvider>();
|
final contactsProvider = context.read<ContactsProvider>();
|
||||||
final resolution = await TransmissionTargetResolver.resolveFetchTarget(
|
final appProvider = context.read<AppProvider>();
|
||||||
|
var resolution = await TransmissionTargetResolver.resolveFetchTarget(
|
||||||
contactsProvider: contactsProvider,
|
contactsProvider: contactsProvider,
|
||||||
refreshContacts: connectionProvider.getContacts,
|
refreshContacts: connectionProvider.getContacts,
|
||||||
isSentByMe: widget.isSentByMe,
|
isSentByMe: widget.isSentByMe,
|
||||||
@@ -235,6 +243,7 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
|
|||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
||||||
if (resolution.failure == TransmissionTargetFailure.unknownContact) {
|
if (resolution.failure == TransmissionTargetFailure.unknownContact) {
|
||||||
|
_clearRequestState();
|
||||||
await _showBlockingAlert(
|
await _showBlockingAlert(
|
||||||
'Cannot fetch voice',
|
'Cannot fetch voice',
|
||||||
'Sender contact is unknown. Sync contacts first.',
|
'Sender contact is unknown. Sync contacts first.',
|
||||||
@@ -242,6 +251,7 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (resolution.failure == TransmissionTargetFailure.unknownRoute) {
|
if (resolution.failure == TransmissionTargetFailure.unknownRoute) {
|
||||||
|
_clearRequestState();
|
||||||
await _showBlockingAlert(
|
await _showBlockingAlert(
|
||||||
'Cannot fetch voice',
|
'Cannot fetch voice',
|
||||||
'Sender route is unknown. Sync contacts/path first.',
|
'Sender route is unknown. Sync contacts/path first.',
|
||||||
@@ -249,27 +259,85 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (resolution.failure == TransmissionTargetFailure.tooFar) {
|
if (resolution.failure == TransmissionTargetFailure.tooFar) {
|
||||||
|
_clearRequestState();
|
||||||
await _showBlockingAlert(
|
await _showBlockingAlert(
|
||||||
'Cannot fetch voice',
|
'Cannot fetch voice',
|
||||||
'Message is too far (${resolution.hops} hops, max ${resolution.maxHops}).',
|
'Message is too far (${resolution.hops} hops, max ${resolution.maxHops}).',
|
||||||
);
|
);
|
||||||
return;
|
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) {
|
if (sender.outPathLen >= 2) {
|
||||||
_showToast(
|
_showToast(
|
||||||
'Voice fetch over ${sender.outPathLen} hops may take a while.',
|
'Voice fetch over ${sender.outPathLen} hops may take a while.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!mounted) return;
|
|
||||||
setState(() {
|
|
||||||
_errorText = null;
|
|
||||||
});
|
|
||||||
|
|
||||||
final deviceKey = connectionProvider.deviceInfo.publicKey;
|
final deviceKey = connectionProvider.deviceInfo.publicKey;
|
||||||
if (deviceKey == null || deviceKey.length < 6) {
|
if (deviceKey == null || deviceKey.length < 6) {
|
||||||
|
_clearRequestState();
|
||||||
await _showBlockingAlert(
|
await _showBlockingAlert(
|
||||||
'Cannot fetch voice',
|
'Cannot fetch voice',
|
||||||
'Device key is unavailable.',
|
'Device key is unavailable.',
|
||||||
@@ -281,20 +349,34 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
|
|||||||
.sublist(0, 6)
|
.sublist(0, 6)
|
||||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||||
.join('');
|
.join('');
|
||||||
final request = VoiceFetchRequest(
|
final missing = voiceProvider.missingPacketIndices(sessionId);
|
||||||
|
final totalPackets = sessionPacketCount(
|
||||||
|
voiceProvider: voiceProvider,
|
||||||
sessionId: sessionId,
|
sessionId: sessionId,
|
||||||
requesterKey6: requesterKey6,
|
envelope: envelope,
|
||||||
timestampSec: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
|
||||||
version: 2,
|
|
||||||
);
|
);
|
||||||
|
final isPartialResume =
|
||||||
setState(() {
|
missing.isNotEmpty && totalPackets > 0 && missing.length < totalPackets;
|
||||||
_isRequesting = true;
|
final request = isPartialResume
|
||||||
_autoPlayWhenReady = true;
|
? VoiceFetchRequest(
|
||||||
_errorText = null;
|
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 {
|
try {
|
||||||
|
debugPrint(
|
||||||
|
'🎙️ [VoiceMessageBubble] Outgoing voice fetch request: session=$sessionId want=${isPartialResume ? 'missing' : 'all'} target=${sender.advName} hops=${sender.outPathLen}',
|
||||||
|
);
|
||||||
await connectionProvider.sendRawVoicePacket(
|
await connectionProvider.sendRawVoicePacket(
|
||||||
contactPath: sender.outPath,
|
contactPath: sender.outPath,
|
||||||
contactPathLen: sender.outPathLen,
|
contactPathLen: sender.outPathLen,
|
||||||
@@ -309,11 +391,18 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
|
|||||||
final effectivePathLen = sender.outPathLen >= 0
|
final effectivePathLen = sender.outPathLen >= 0
|
||||||
? sender.outPathLen
|
? sender.outPathLen
|
||||||
: pathLen;
|
: pathLen;
|
||||||
|
final estimatedDurationMs =
|
||||||
|
envelope != null &&
|
||||||
|
totalPackets > 0 &&
|
||||||
|
missing.isNotEmpty &&
|
||||||
|
missing.length < totalPackets
|
||||||
|
? ((envelope.durationMs * missing.length) / totalPackets).round()
|
||||||
|
: envelope?.durationMs;
|
||||||
final txEstimate = envelope != null
|
final txEstimate = envelope != null
|
||||||
? estimateVoiceTransmitDuration(
|
? estimateVoiceTransmitDuration(
|
||||||
packetCount: envelope.total,
|
packetCount: isPartialResume ? missing.length : envelope.total,
|
||||||
mode: envelope.mode,
|
mode: envelope.mode,
|
||||||
durationMs: envelope.durationMs,
|
durationMs: estimatedDurationMs ?? envelope.durationMs,
|
||||||
pathLen: effectivePathLen,
|
pathLen: effectivePathLen,
|
||||||
radioBw: radioBw,
|
radioBw: radioBw,
|
||||||
radioSf: radioSf,
|
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() {
|
void _setUnavailable() {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
_showToast(AppLocalizations.of(context)!.voiceUnavailable);
|
_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) {
|
void _cancelReceive(String sessionId) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
_requestTimeoutTimer?.cancel();
|
_requestTimeoutTimer?.cancel();
|
||||||
|
|||||||
@@ -851,7 +851,7 @@ packages:
|
|||||||
description:
|
description:
|
||||||
path: "."
|
path: "."
|
||||||
ref: main
|
ref: main
|
||||||
resolved-ref: "3f870e98ee9527a3137bfcbdd1454036912fb609"
|
resolved-ref: fb0a92a53b8e1ab23ffca50c2bffe819829843e6
|
||||||
url: "https://github.com/dz0ny/meshcore_client.git"
|
url: "https://github.com/dz0ny/meshcore_client.git"
|
||||||
source: git
|
source: git
|
||||||
version: "0.1.0"
|
version: "0.1.0"
|
||||||
|
|||||||
@@ -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
|
# 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
|
# 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.
|
# 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:
|
environment:
|
||||||
sdk: ^3.9.2
|
sdk: ^3.9.2
|
||||||
|
|||||||
50
test/models/message_reception_details_test.dart
Normal file
50
test/models/message_reception_details_test.dart
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -47,9 +47,8 @@ void main() {
|
|||||||
expect(ok, isFalse);
|
expect(ok, isFalse);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('sends only requested indices and waits for ack', () async {
|
test('sends only requested indices', () async {
|
||||||
final sent = <Uint8List>[];
|
final sent = <Uint8List>[];
|
||||||
final waited = <int>[];
|
|
||||||
final ok = await serveCachedSessionFragments<_Fragment>(
|
final ok = await serveCachedSessionFragments<_Fragment>(
|
||||||
providerLabel: 'TestProvider',
|
providerLabel: 'TestProvider',
|
||||||
sessionId: 'deadbeef',
|
sessionId: 'deadbeef',
|
||||||
@@ -70,15 +69,6 @@ void main() {
|
|||||||
}) async {
|
}) async {
|
||||||
sent.add(payload);
|
sent.add(payload);
|
||||||
},
|
},
|
||||||
waitForFragmentAck:
|
|
||||||
({
|
|
||||||
required sessionId,
|
|
||||||
required index,
|
|
||||||
timeout = const Duration(seconds: 8),
|
|
||||||
}) async {
|
|
||||||
waited.add(index);
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
requestedIndices: {1, 2},
|
requestedIndices: {1, 2},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -86,37 +76,6 @@ void main() {
|
|||||||
expect(sent.length, equals(2));
|
expect(sent.length, equals(2));
|
||||||
expect(sent[0], equals(Uint8List.fromList([20])));
|
expect(sent[0], equals(Uint8List.fromList([20])));
|
||||||
expect(sent[1], equals(Uint8List.fromList([30])));
|
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 {
|
test('fails when no requested index matches cached fragments', () async {
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ void main() {
|
|||||||
TestWidgetsFlutterBinding.ensureInitialized();
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
group('MessagesProvider retransmission', () {
|
group('MessagesProvider retransmission', () {
|
||||||
test('direct messages stay pending until delivery ACK arrives', () {
|
test('direct messages become sent before delivery ACK arrives', () {
|
||||||
final provider = MessagesProvider();
|
final provider = MessagesProvider();
|
||||||
provider.addSentMessage(
|
provider.addSentMessage(
|
||||||
_buildDirectMessage('m1'),
|
_buildDirectMessage('m1'),
|
||||||
@@ -51,7 +51,7 @@ void main() {
|
|||||||
|
|
||||||
expect(
|
expect(
|
||||||
provider.messages.single.deliveryStatus,
|
provider.messages.single.deliveryStatus,
|
||||||
MessageDeliveryStatus.sending,
|
MessageDeliveryStatus.sent,
|
||||||
);
|
);
|
||||||
expect(provider.messages.single.expectedAckTag, 77);
|
expect(provider.messages.single.expectedAckTag, 77);
|
||||||
|
|
||||||
@@ -64,6 +64,24 @@ void main() {
|
|||||||
expect(provider.messages.single.roundTripTimeMs, 180);
|
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', () {
|
test('channel messages are marked sent immediately', () {
|
||||||
final provider = MessagesProvider();
|
final provider = MessagesProvider();
|
||||||
provider.addSentMessage(
|
provider.addSentMessage(
|
||||||
|
|||||||
92
test/utils/log_rx_route_decoder_test.dart
Normal file
92
test/utils/log_rx_route_decoder_test.dart
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user