Compare commits

...

20 Commits

Author SHA1 Message Date
Janez T
70a6156e8a Update iOS project version 2026-03-06 21:22:51 +01:00
Janez T
1856dce27a Update iOS project version 2026-03-06 21:05:02 +01:00
Janez T
485ae995c3 Commit client and rerun pub get 2026-03-06 20:51:49 +01:00
Janez T
0e1a56d765 Log contact data without revealing 2026-03-06 20:47:02 +01:00
Janez T
ca8e8e6ecb Increase cache for public repeaters 2026-03-06 20:29:30 +01:00
Janez T
43f5702666 Add conservative ACK timeout 2026-03-06 20:27:05 +01:00
Janez T
85d0b26c67 Add retransmission handling 2026-03-06 20:12:12 +01:00
Janez T
7cd5351921 Improve message input layout 2026-03-06 20:01:56 +01:00
Janez T
96feb75712 feat: save all pending work
ref:
2026-03-06 20:01:56 +01:00
Janez T
f6779e8776 fix: clean up messages composer bar
ref:
2026-03-06 20:01:56 +01:00
Janez Troha
a9d062dc34 Merge pull request #11 from MGJ520/main
Fix the Chinese translation switching issue
2026-03-06 16:23:51 +01:00
MGJ
3680b38302 Fix the Chinese translation switching issue 2026-03-06 20:10:26 +08:00
Janez T
e3902cea1e fix: bump client lock
ref:
2026-03-05 20:58:58 +01:00
Janez T
2482f94cd0 Push meshcore_client and refresh pub 2026-03-05 20:58:29 +01:00
Janez T
2b34bc4162 fix: stop lpp zero tail
ref:
2026-03-05 20:12:09 +01:00
Janez T
03c0c7ad38 fix: safe scan notifications
ref:
2026-03-05 20:10:08 +01:00
Janez T
42fc1f9cd2 fix: defer dialog scan start
ref:
2026-03-05 20:08:36 +01:00
Janez T
7807ad7471 Compare meshcore repos for bugs 2026-03-05 19:54:49 +01:00
Janez T
310eee1ff5 Compare meshcore sar against open 2026-03-05 19:48:16 +01:00
Janez T
b8f03079e7 Fix channel data sending bug 2026-03-05 19:39:03 +01:00
54 changed files with 6427 additions and 2354 deletions

View File

@@ -39,5 +39,13 @@ end
post_install do |installer| post_install do |installer|
installer.pods_project.targets.each do |target| installer.pods_project.targets.each do |target|
flutter_additional_ios_build_settings(target) flutter_additional_ios_build_settings(target)
# codec2_flutter uses ARM-specific register asm("sp") in debug_alloc.h that
# fails to compile for iOS simulators. Voice codec support is only required
# on real iOS devices, so skip the native Codec2 sources for simulator SDKs.
if target.name == 'codec2_flutter'
target.build_configurations.each do |config|
config.build_settings['EXCLUDED_SOURCE_FILE_NAMES[sdk=iphonesimulator*]'] = '*.c *.cpp'
end
end
end end
end end

View File

@@ -65,6 +65,9 @@ PODS:
- ObjectBox (= 4.4.1) - ObjectBox (= 4.4.1)
- package_info_plus (0.4.5): - package_info_plus (0.4.5):
- Flutter - Flutter
- path_provider_foundation (0.0.1):
- Flutter
- FlutterMacOS
- permission_handler_apple (9.3.0): - permission_handler_apple (9.3.0):
- Flutter - Flutter
- record_ios (1.2.0): - record_ios (1.2.0):
@@ -102,6 +105,7 @@ DEPENDENCIES:
- nsd_ios (from `.symlinks/plugins/nsd_ios/ios`) - nsd_ios (from `.symlinks/plugins/nsd_ios/ios`)
- objectbox_flutter_libs (from `.symlinks/plugins/objectbox_flutter_libs/ios`) - objectbox_flutter_libs (from `.symlinks/plugins/objectbox_flutter_libs/ios`)
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`) - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
- record_ios (from `.symlinks/plugins/record_ios/ios`) - record_ios (from `.symlinks/plugins/record_ios/ios`)
- share_plus (from `.symlinks/plugins/share_plus/ios`) - share_plus (from `.symlinks/plugins/share_plus/ios`)
@@ -149,6 +153,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/objectbox_flutter_libs/ios" :path: ".symlinks/plugins/objectbox_flutter_libs/ios"
package_info_plus: package_info_plus:
:path: ".symlinks/plugins/package_info_plus/ios" :path: ".symlinks/plugins/package_info_plus/ios"
path_provider_foundation:
:path: ".symlinks/plugins/path_provider_foundation/darwin"
permission_handler_apple: permission_handler_apple:
:path: ".symlinks/plugins/permission_handler_apple/ios" :path: ".symlinks/plugins/permission_handler_apple/ios"
record_ios: record_ios:
@@ -183,6 +189,7 @@ SPEC CHECKSUMS:
ObjectBox: 7da4aceb5013d041bfafdbc6d744a26918b09757 ObjectBox: 7da4aceb5013d041bfafdbc6d744a26918b09757
objectbox_flutter_libs: 09b1dec1b4cd27bf1a5f9bae7ccaa7e43588bf31 objectbox_flutter_libs: 09b1dec1b4cd27bf1a5f9bae7ccaa7e43588bf31
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880
permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
record_ios: 412daca2350b228e698fffcd08f1f94ceb1e3844 record_ios: 412daca2350b228e698fffcd08f1f94ceb1e3844
SDWebImage: e9fc87c1aab89a8ab1bbd74eba378c6f53be8abf SDWebImage: e9fc87c1aab89a8ab1bbd74eba378c6f53be8abf
@@ -193,6 +200,6 @@ SPEC CHECKSUMS:
url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b
vibration: ca8104a8875b9c493e15b21b04e456befd0ff6eb vibration: ca8104a8875b9c493e15b21b04e456befd0ff6eb
PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e PODFILE CHECKSUM: 2d56c9747241a29800bf539d978b210bda037878
COCOAPODS: 1.16.2 COCOAPODS: 1.16.2

View File

@@ -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 = 81; CURRENT_PROJECT_VERSION = 94;
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 = 81; CURRENT_PROJECT_VERSION = 94;
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 = 81; CURRENT_PROJECT_VERSION = 94;
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 = 81; CURRENT_PROJECT_VERSION = 94;
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 = 81; CURRENT_PROJECT_VERSION = 94;
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 = 81; CURRENT_PROJECT_VERSION = 94;
DEVELOPMENT_TEAM = JND55328G8; DEVELOPMENT_TEAM = JND55328G8;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;

View File

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

View File

@@ -5,22 +5,22 @@
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000253"> <testcase classname="fastlane.lanes" name="0: default_platform" time="0.000555">
</testcase> </testcase>
<testcase classname="fastlane.lanes" name="1: increment_build_number" time="0.428509"> <testcase classname="fastlane.lanes" name="1: increment_build_number" time="0.42343">
</testcase> </testcase>
<testcase classname="fastlane.lanes" name="2: build_app" time="102.185635"> <testcase classname="fastlane.lanes" name="2: build_app" time="91.987634">
</testcase> </testcase>
<testcase classname="fastlane.lanes" name="3: upload_to_app_store" time="3.215591"> <testcase classname="fastlane.lanes" name="3: upload_to_app_store" time="184.536223">
</testcase> </testcase>

View File

@@ -75,10 +75,12 @@ class DeviceInfo {
// Additional device capabilities (from RESP_CODE_DEVICE_INFO) // Additional device capabilities (from RESP_CODE_DEVICE_INFO)
final int? maxContacts; // Max contacts device supports final int? maxContacts; // Max contacts device supports
final int? maxChannels; // Max channels device supports final int? maxChannels; // Max channels device supports
final int? telemetryModes; // Telemetry permission modes (bits 0-1: Base, bits 2-3: Location) final int?
telemetryModes; // Telemetry permission modes (bits 0-1: Base, bits 2-3: Location)
final int? blePin; // BLE PIN code final int? blePin; // BLE PIN code
final int? multiAcks; // Extra ACK mode (0=no, 1=yes) final int? multiAcks; // Extra ACK mode (0=no, 1=yes)
final int? advertLocPolicy; // Location sharing policy (0=don't share, 1=share) final int?
advertLocPolicy; // Location sharing policy (0=don't share, 1=share)
// Firmware info // Firmware info
final int? firmwareVersion; final int? firmwareVersion;
@@ -88,6 +90,9 @@ class DeviceInfo {
// Repeat mode (firmware v9+) // Repeat mode (firmware v9+)
final bool? clientRepeat; final bool? clientRepeat;
final bool? supportsSpectrumScan;
final int? spectrumScanMinKhz;
final int? spectrumScanMaxKhz;
final List<({int lower, int upper})>? allowedRepeatFreqRanges; final List<({int lower, int upper})>? allowedRepeatFreqRanges;
DeviceInfo({ DeviceInfo({
@@ -124,6 +129,9 @@ class DeviceInfo {
this.manufacturerModel, this.manufacturerModel,
this.semanticVersion, this.semanticVersion,
this.clientRepeat, this.clientRepeat,
this.supportsSpectrumScan,
this.spectrumScanMinKhz,
this.spectrumScanMaxKhz,
this.allowedRepeatFreqRanges, this.allowedRepeatFreqRanges,
}); });
@@ -160,7 +168,9 @@ class DeviceInfo {
/// Get storage usage percentage (0-100) /// Get storage usage percentage (0-100)
double? get storageUsedPercent { double? get storageUsedPercent {
if (storageUsedKb == null || storageTotalKb == null || storageTotalKb == 0) { if (storageUsedKb == null ||
storageTotalKb == null ||
storageTotalKb == 0) {
return null; return null;
} }
return (storageUsedKb! / storageTotalKb!) * 100.0; return (storageUsedKb! / storageTotalKb!) * 100.0;
@@ -245,6 +255,9 @@ class DeviceInfo {
String? manufacturerModel, String? manufacturerModel,
String? semanticVersion, String? semanticVersion,
bool? clientRepeat, bool? clientRepeat,
bool? supportsSpectrumScan,
int? spectrumScanMinKhz,
int? spectrumScanMaxKhz,
List<({int lower, int upper})>? allowedRepeatFreqRanges, List<({int lower, int upper})>? allowedRepeatFreqRanges,
}) { }) {
return DeviceInfo( return DeviceInfo(
@@ -281,7 +294,11 @@ class DeviceInfo {
manufacturerModel: manufacturerModel ?? this.manufacturerModel, manufacturerModel: manufacturerModel ?? this.manufacturerModel,
semanticVersion: semanticVersion ?? this.semanticVersion, semanticVersion: semanticVersion ?? this.semanticVersion,
clientRepeat: clientRepeat ?? this.clientRepeat, clientRepeat: clientRepeat ?? this.clientRepeat,
allowedRepeatFreqRanges: allowedRepeatFreqRanges ?? this.allowedRepeatFreqRanges, supportsSpectrumScan: supportsSpectrumScan ?? this.supportsSpectrumScan,
spectrumScanMinKhz: spectrumScanMinKhz ?? this.spectrumScanMinKhz,
spectrumScanMaxKhz: spectrumScanMaxKhz ?? this.spectrumScanMaxKhz,
allowedRepeatFreqRanges:
allowedRepeatFreqRanges ?? this.allowedRepeatFreqRanges,
); );
} }

View File

@@ -0,0 +1,63 @@
import 'package:latlong2/latlong.dart';
class MessageContactLocation {
final LatLng location;
final String source;
final DateTime capturedAt;
final DateTime? sourceTimestamp;
const MessageContactLocation({
required this.location,
required this.source,
required this.capturedAt,
this.sourceTimestamp,
});
String get technicalSourceLabel {
switch (source) {
case 'telemetry':
return 'telemetry';
case 'advert':
return 'advert';
default:
return source;
}
}
String get formattedCoordinates =>
'${location.latitude.toStringAsFixed(6)}, ${location.longitude.toStringAsFixed(6)}';
Map<String, dynamic> toJson() {
return {
'latitude': location.latitude,
'longitude': location.longitude,
'source': source,
'capturedAtMillis': capturedAt.millisecondsSinceEpoch,
'sourceTimestampMillis': sourceTimestamp?.millisecondsSinceEpoch,
};
}
static MessageContactLocation? fromJson(Map<String, dynamic> json) {
final latitude = json['latitude'];
final longitude = json['longitude'];
final source = json['source'];
final capturedAtMillis = json['capturedAtMillis'];
if (latitude is! num ||
longitude is! num ||
source is! String ||
capturedAtMillis is! int) {
return null;
}
return MessageContactLocation(
location: LatLng(latitude.toDouble(), longitude.toDouble()),
source: source,
capturedAt: DateTime.fromMillisecondsSinceEpoch(capturedAtMillis),
sourceTimestamp: json['sourceTimestampMillis'] is int
? DateTime.fromMillisecondsSinceEpoch(
json['sourceTimestampMillis'] as int,
)
: null,
);
}
}

View File

@@ -8,6 +8,8 @@ import 'drawing_provider.dart';
import 'channels_provider.dart'; import 'channels_provider.dart';
import 'voice_provider.dart'; import 'voice_provider.dart';
import 'image_provider.dart' as ip; import 'image_provider.dart' as ip;
import 'helpers/fragment_ack_wait_registry.dart';
import 'helpers/session_metadata_restore.dart';
import '../services/tile_cache_service.dart'; import '../services/tile_cache_service.dart';
import '../services/location_tracking_service.dart'; import '../services/location_tracking_service.dart';
import '../services/packet_capture_storage_service.dart'; import '../services/packet_capture_storage_service.dart';
@@ -62,8 +64,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 Map<String, Completer<void>> _voiceFragmentAckWaiters = {}; final FragmentAckWaitRegistry _voiceFragmentAckWaiters =
final Map<String, Completer<void>> _imageFragmentAckWaiters = {}; FragmentAckWaitRegistry();
final FragmentAckWaitRegistry _imageFragmentAckWaiters =
FragmentAckWaitRegistry();
Timer? _packetCaptureFlushTimer; Timer? _packetCaptureFlushTimer;
String? _lastPersistedPacketSignature; String? _lastPersistedPacketSignature;
bool _isPersistingPacketCapture = false; bool _isPersistingPacketCapture = false;
@@ -165,12 +169,35 @@ class AppProvider with ChangeNotifier {
// Give DrawingProvider a moment to finish loading too // Give DrawingProvider a moment to finish loading too
await Future.delayed(const Duration(milliseconds: 100)); await Future.delayed(const Duration(milliseconds: 100));
_restoreSessionMetadataFromMessages();
debugPrint( debugPrint(
'🔄 [AppProvider] Early sync: syncing drawings from messages...', '🔄 [AppProvider] Early sync: syncing drawings from messages...',
); );
messagesProvider.syncDrawingsWithProvider(drawingProvider); messagesProvider.syncDrawingsWithProvider(drawingProvider);
} }
void _restoreSessionMetadataFromMessages() {
final restored = restoreSessionMetadataFromMessages(
messagesProvider.messages.map((message) => message.text),
);
_voiceSessionSenderKey6.addAll(restored.voiceSenderKeyBySession);
for (final entry in restored.imageEnvelopeBySession.entries) {
_imageSessionSenderKey6[entry.key] = entry.value.senderKey6.toLowerCase();
imageProvider.registerEnvelope(entry.value);
}
final restoredVoice = restored.voiceSenderKeyBySession.length;
final restoredImage = restored.imageEnvelopeBySession.length;
if (restoredVoice > 0 || restoredImage > 0) {
debugPrint(
'🔄 [AppProvider] Restored session metadata from messages: '
'$restoredVoice voice, $restoredImage image',
);
}
}
/// Load simple mode setting from shared preferences /// Load simple mode setting from shared preferences
Future<void> _loadSimpleMode() async { Future<void> _loadSimpleMode() async {
try { try {
@@ -580,14 +607,30 @@ class AppProvider with ChangeNotifier {
connectionProvider.onMessageReceived = (message) { connectionProvider.onMessageReceived = (message) {
// Enrich message with sender name from contacts first // Enrich message with sender name from contacts first
Message enrichedMessage = message; Message enrichedMessage = message;
Contact? senderContact;
if (message.senderPublicKeyPrefix != null && message.senderName == null) { if (message.senderPublicKeyPrefix != null && message.senderName == null) {
final contact = contactsProvider.findContactByKey( final contact = contactsProvider.findContactByKey(
message.senderPublicKeyPrefix!, message.senderPublicKeyPrefix!,
); );
if (contact != null) { if (contact != null) {
senderContact = contact;
enrichedMessage = message.copyWith(senderName: contact.advName); enrichedMessage = message.copyWith(senderName: contact.advName);
} }
} }
senderContact ??= message.senderPublicKeyPrefix != null
? contactsProvider.findContactByKey(message.senderPublicKeyPrefix!)
: null;
senderContact ??= enrichedMessage.senderName != null
? contactsProvider.contacts
.where((c) => c.advName == enrichedMessage.senderName)
.firstOrNull
: null;
final contactLocationSnapshot = senderContact != null
? contactsProvider.buildMessageContactLocationSnapshot(
senderContact,
capturedAt: enrichedMessage.receivedAt,
)
: null;
// Check if message is a drawing broadcast // Check if message is a drawing broadcast
if (DrawingMessageParser.isDrawingMessage(enrichedMessage.text)) { if (DrawingMessageParser.isDrawingMessage(enrichedMessage.text)) {
@@ -618,6 +661,7 @@ class AppProvider with ChangeNotifier {
messagesProvider.addMessage( messagesProvider.addMessage(
updatedMessage, updatedMessage,
contactLookup: (name) => '', contactLookup: (name) => '',
contactLocationSnapshot: contactLocationSnapshot,
); );
// Broadcast drawing message to SSE clients if server is running // Broadcast drawing message to SSE clients if server is running
@@ -653,6 +697,7 @@ class AppProvider with ChangeNotifier {
return ''; return '';
} }
}, },
contactLocationSnapshot: contactLocationSnapshot,
); );
connectionProvider.broadcastMessageToSseClients(enrichedMessage); connectionProvider.broadcastMessageToSseClients(enrichedMessage);
return; return;
@@ -680,6 +725,7 @@ class AppProvider with ChangeNotifier {
return ''; return '';
} }
}, },
contactLocationSnapshot: contactLocationSnapshot,
); );
connectionProvider.broadcastMessageToSseClients(enrichedMessage); connectionProvider.broadcastMessageToSseClients(enrichedMessage);
return; return;
@@ -716,6 +762,7 @@ class AppProvider with ChangeNotifier {
return ''; return '';
} }
}, },
contactLocationSnapshot: contactLocationSnapshot,
); );
// Broadcast message to SSE clients if server is running // Broadcast message to SSE clients if server is running
@@ -788,12 +835,12 @@ class AppProvider with ChangeNotifier {
final imageFetchRequest = ImageFetchRequest.tryParseBinary(payload); final imageFetchRequest = ImageFetchRequest.tryParseBinary(payload);
if (imageFetchRequest != null) { if (imageFetchRequest != null) {
final requester = contactsProvider.findContactByPrefixHex( final requester = _resolveImageFetchRequester(imageFetchRequest);
imageFetchRequest.requesterKey6,
);
if (requester == null) { if (requester == null) {
debugPrint( debugPrint(
'⚠️ [AppProvider] Image fetch requester contact not found (binary)', '⚠️ [AppProvider] Image fetch requester contact not found (binary) '
'for session ${imageFetchRequest.sessionId} / '
'${imageFetchRequest.requesterKey6}',
); );
messagesProvider.logSystemMessage( messagesProvider.logSystemMessage(
text: text:
@@ -804,7 +851,9 @@ class AppProvider with ChangeNotifier {
} }
if (requester.outPathLen > _maxDirectPayloadHops) { if (requester.outPathLen > _maxDirectPayloadHops) {
debugPrint( debugPrint(
'⚠️ [AppProvider] Image fetch requester too far: ${requester.outPathLen} hops', '⚠️ [AppProvider] Image fetch requester too far: '
'${requester.outPathLen} hops for session '
'${imageFetchRequest.sessionId}',
); );
messagesProvider.logSystemMessage( messagesProvider.logSystemMessage(
text: text:
@@ -813,6 +862,10 @@ class AppProvider with ChangeNotifier {
); );
return; return;
} }
debugPrint(
'📷 [AppProvider] Serving image session ${imageFetchRequest.sessionId} '
'to ${requester.advName} via ${requester.outPathLen} hop(s)',
);
unawaited( unawaited(
imageProvider.serveSessionTo( imageProvider.serveSessionTo(
sessionId: imageFetchRequest.sessionId, sessionId: imageFetchRequest.sessionId,
@@ -988,6 +1041,32 @@ class AppProvider with ChangeNotifier {
retryAttempt: retryAttempt, retryAttempt: retryAttempt,
); );
}; };
messagesProvider.onDirectPathFailedCallback =
({required contact, required failureStreak}) async {
debugPrint(
'🧭 [AppProvider] Clearing unhealthy path for ${contact.advName} after $failureStreak failed send chain(s)',
);
contactsProvider.markPathUnhealthy(contact.publicKey);
if (!connectionProvider.deviceInfo.isConnected) {
return;
}
try {
await connectionProvider.resetPath(contact.publicKey);
Future.delayed(const Duration(milliseconds: 150), () {
if (connectionProvider.deviceInfo.isConnected) {
connectionProvider.getContact(contact.publicKey);
}
});
} catch (e) {
debugPrint(
'⚠️ [AppProvider] Failed to reset path for ${contact.advName}: $e',
);
}
};
} }
/// Initialize the app (load contacts, sync time, etc.) /// Initialize the app (load contacts, sync time, etc.)
@@ -1189,6 +1268,42 @@ class AppProvider with ChangeNotifier {
return contactsProvider.findContactByPrefixHex(prefixHex.toLowerCase()); return contactsProvider.findContactByPrefixHex(prefixHex.toLowerCase());
} }
Contact? _resolveImageFetchRequester(ImageFetchRequest request) {
final liveContact = _resolveContactByPrefixHex(request.requesterKey6);
if (liveContact != null) {
return liveContact;
}
for (final message in messagesProvider.messages.reversed) {
final envelope = ImageEnvelope.tryParse(message.text);
if (envelope == null || envelope.sessionId != request.sessionId) {
continue;
}
final recipientKey = message.recipientPublicKey;
if (recipientKey == null || recipientKey.isEmpty) {
continue;
}
final recipient = contactsProvider.findContactByKey(recipientKey);
if (recipient == null) {
continue;
}
final recipientKey6 = recipient.publicKey
.sublist(0, 6)
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join();
if (recipientKey6 != request.requesterKey6) {
continue;
}
debugPrint(
'📷 [AppProvider] Resolved image requester from sent message metadata '
'for session ${request.sessionId}: ${recipient.advName}',
);
return recipient;
}
return null;
}
void _scheduleVoiceMissingRetry( void _scheduleVoiceMissingRetry(
String sessionId, { String sessionId, {
required bool justComplete, required bool justComplete,
@@ -1312,54 +1427,48 @@ class AppProvider with ChangeNotifier {
required String sessionId, required String sessionId,
required int index, required int index,
Duration timeout = const Duration(seconds: 8), Duration timeout = const Duration(seconds: 8),
}) async { }) => _voiceFragmentAckWaiters.waitFor(
final key = _fragmentAckKey(sessionId, index); _fragmentAckKey(sessionId, index),
final completer = Completer<void>(); timeout: timeout,
_voiceFragmentAckWaiters[key] = completer; );
try {
await completer.future.timeout(timeout);
return true;
} catch (_) {
if (_voiceFragmentAckWaiters[key] == completer) {
_voiceFragmentAckWaiters.remove(key);
}
return false;
}
}
void _completeVoiceFragmentAck(String sessionId, int index) { void _completeVoiceFragmentAck(String sessionId, int index) {
final key = _fragmentAckKey(sessionId, index); final completed = _voiceFragmentAckWaiters.complete(
final completer = _voiceFragmentAckWaiters.remove(key); _fragmentAckKey(sessionId, index),
if (completer != null && !completer.isCompleted) { );
completer.complete(); if (completed == 0) {
debugPrint(
' [AppProvider] Voice fragment ACK had no waiter: $sessionId#$index',
);
return;
} }
debugPrint(
'✅ [AppProvider] Voice fragment ACK received for $sessionId#$index ($completed waiter(s))',
);
} }
Future<bool> _waitForImageFragmentAck({ Future<bool> _waitForImageFragmentAck({
required String sessionId, required String sessionId,
required int index, required int index,
Duration timeout = const Duration(seconds: 8), Duration timeout = const Duration(seconds: 8),
}) async { }) => _imageFragmentAckWaiters.waitFor(
final key = _fragmentAckKey(sessionId, index); _fragmentAckKey(sessionId, index),
final completer = Completer<void>(); timeout: timeout,
_imageFragmentAckWaiters[key] = completer; );
try {
await completer.future.timeout(timeout);
return true;
} catch (_) {
if (_imageFragmentAckWaiters[key] == completer) {
_imageFragmentAckWaiters.remove(key);
}
return false;
}
}
void _completeImageFragmentAck(String sessionId, int index) { void _completeImageFragmentAck(String sessionId, int index) {
final key = _fragmentAckKey(sessionId, index); final completed = _imageFragmentAckWaiters.complete(
final completer = _imageFragmentAckWaiters.remove(key); _fragmentAckKey(sessionId, index),
if (completer != null && !completer.isCompleted) { );
completer.complete(); if (completed == 0) {
debugPrint(
' [AppProvider] Image fragment ACK had no waiter: $sessionId#$index',
);
return;
} }
debugPrint(
'✅ [AppProvider] Image fragment ACK received for $sessionId#$index ($completed waiter(s))',
);
} }
void _sendVoiceFragmentAck(VoicePacket packet) { void _sendVoiceFragmentAck(VoicePacket packet) {

View File

@@ -1,6 +1,7 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter_blue_plus/flutter_blue_plus.dart'; import 'package:flutter_blue_plus/flutter_blue_plus.dart';
import 'package:crypto/crypto.dart'; import 'package:crypto/crypto.dart';
import '../models/device_info.dart'; import '../models/device_info.dart';
@@ -88,6 +89,8 @@ class ConnectionProvider with ChangeNotifier {
bool _isScanning = false; bool _isScanning = false;
bool get isScanning => _isScanning; bool get isScanning => _isScanning;
bool _isSpectrumScanActive = false;
bool get isSpectrumScanActive => _isSpectrumScanActive;
String? _error; String? _error;
String? get error => _error; String? get error => _error;
@@ -246,7 +249,10 @@ class ConnectionProvider with ChangeNotifier {
await Future.delayed(const Duration(milliseconds: 300)); await Future.delayed(const Duration(milliseconds: 300));
if (pendingOp.messageId != null) { if (pendingOp.messageId != null) {
_messageDeliveryTracker.trackPendingMessage(pendingOp.messageId!); _messageDeliveryTracker.trackPendingDirectMessage(
pendingOp.messageId!,
pendingOp.contactPublicKey,
);
} }
await _activeService.sendTextMessage( await _activeService.sendTextMessage(
@@ -311,6 +317,10 @@ class ConnectionProvider with ChangeNotifier {
}; };
service.onMessageWaiting = () { service.onMessageWaiting = () {
if (_isSpectrumScanActive) {
debugPrint('📥 [Provider] MSG_WAITING ignored during spectrum scan');
return;
}
debugPrint('📥 [Provider] MSG_WAITING - auto-syncing'); debugPrint('📥 [Provider] MSG_WAITING - auto-syncing');
if (_isSyncingMessages) { if (_isSyncingMessages) {
_syncRequestedWhileBusy = true; _syncRequestedWhileBusy = true;
@@ -345,7 +355,11 @@ class ConnectionProvider with ChangeNotifier {
service.onMessageSent = service.onMessageSent =
(expectedAckTag, suggestedTimeoutMs, isFloodMode, contactPublicKey) { (expectedAckTag, suggestedTimeoutMs, isFloodMode, contactPublicKey) {
final messageId = _messageDeliveryTracker.popPendingMessageId(); final messageId = contactPublicKey != null
? _messageDeliveryTracker.popPendingDirectMessageId(
contactPublicKey,
)
: _messageDeliveryTracker.popPendingMessageId();
if (messageId != null) { if (messageId != null) {
_messageDeliveryTracker.mapAckTagToMessageId( _messageDeliveryTracker.mapAckTagToMessageId(
expectedAckTag, expectedAckTag,
@@ -378,6 +392,9 @@ class ConnectionProvider with ChangeNotifier {
manufacturerModel: deviceInfo['manufacturerModel'] as String?, manufacturerModel: deviceInfo['manufacturerModel'] as String?,
semanticVersion: deviceInfo['semanticVersion'] as String?, semanticVersion: deviceInfo['semanticVersion'] as String?,
clientRepeat: deviceInfo['clientRepeat'] as bool?, clientRepeat: deviceInfo['clientRepeat'] as bool?,
supportsSpectrumScan: deviceInfo['supportsSpectrumScan'] as bool?,
spectrumScanMinKhz: deviceInfo['spectrumScanMinKhz'] as int?,
spectrumScanMaxKhz: deviceInfo['spectrumScanMaxKhz'] as int?,
); );
notifyListeners(); notifyListeners();
if (_sseServer.isRunning) { if (_sseServer.isRunning) {
@@ -461,7 +478,7 @@ class ConnectionProvider with ChangeNotifier {
_isScanning = true; _isScanning = true;
_scannedDevices.clear(); _scannedDevices.clear();
_error = null; _error = null;
notifyListeners(); _notifyListenersSafely();
debugPrint('✅ [Provider] Scan state initialized, notifying listeners'); debugPrint('✅ [Provider] Scan state initialized, notifying listeners');
try { try {
@@ -477,7 +494,7 @@ class ConnectionProvider with ChangeNotifier {
debugPrint( debugPrint(
'✅ [Provider] Added device to list: ${device.platformName} (RSSI: $rssi dBm), total: ${_scannedDevices.length}', '✅ [Provider] Added device to list: ${device.platformName} (RSSI: $rssi dBm), total: ${_scannedDevices.length}',
); );
notifyListeners(); _notifyListenersSafely();
} else { } else {
// Update RSSI if device already exists // Update RSSI if device already exists
final index = _scannedDevices.indexWhere( final index = _scannedDevices.indexWhere(
@@ -488,7 +505,7 @@ class ConnectionProvider with ChangeNotifier {
debugPrint( debugPrint(
' 🔄 [Provider] Updated RSSI for ${device.platformName}: $rssi dBm', ' 🔄 [Provider] Updated RSSI for ${device.platformName}: $rssi dBm',
); );
notifyListeners(); _notifyListenersSafely();
} else { } else {
debugPrint( debugPrint(
' ⏭️ [Provider] Device already in list with same RSSI, skipping', ' ⏭️ [Provider] Device already in list with same RSSI, skipping',
@@ -502,7 +519,7 @@ class ConnectionProvider with ChangeNotifier {
} finally { } finally {
debugPrint('🏁 [Provider] Scan completed'); debugPrint('🏁 [Provider] Scan completed');
_isScanning = false; _isScanning = false;
notifyListeners(); _notifyListenersSafely();
} }
} }
@@ -510,6 +527,18 @@ class ConnectionProvider with ChangeNotifier {
Future<void> stopScan() async { Future<void> stopScan() async {
await FlutterBluePlus.stopScan(); await FlutterBluePlus.stopScan();
_isScanning = false; _isScanning = false;
_notifyListenersSafely();
}
void _notifyListenersSafely() {
final phase = SchedulerBinding.instance.schedulerPhase;
if (phase == SchedulerPhase.transientCallbacks ||
phase == SchedulerPhase.persistentCallbacks) {
SchedulerBinding.instance.addPostFrameCallback((_) {
notifyListeners();
});
return;
}
notifyListeners(); notifyListeners();
} }
@@ -1126,7 +1155,10 @@ class ConnectionProvider with ChangeNotifier {
// The MessagesProvider now uses simple ACK tag → recipientPublicKey mapping. // The MessagesProvider now uses simple ACK tag → recipientPublicKey mapping.
// We still track here for the SENT response callback to work. // We still track here for the SENT response callback to work.
if (messageId != null) { if (messageId != null) {
_messageDeliveryTracker.trackPendingMessage(messageId); _messageDeliveryTracker.trackPendingDirectMessage(
messageId,
contactPublicKey,
);
debugPrint(' 📝 Tracked pending message: $messageId'); debugPrint(' 📝 Tracked pending message: $messageId');
} }
@@ -1542,6 +1574,43 @@ class ConnectionProvider with ChangeNotifier {
} }
} }
Future<SpectrumScanResult?> scanSpectrum({
required int startFrequencyKhz,
required int stopFrequencyKhz,
required int bandwidthKhz,
required int stepKhz,
required int dwellMs,
required int thresholdDb,
}) async {
if (!_activeService.isConnected) {
_error = 'Not connected to device';
notifyListeners();
return null;
}
try {
_isSpectrumScanActive = true;
_activeService.setSpectrumScanActive(true);
notifyListeners();
return await _activeService.scanSpectrum(
startFrequencyKhz: startFrequencyKhz,
stopFrequencyKhz: stopFrequencyKhz,
bandwidthKhz: bandwidthKhz,
stepKhz: stepKhz,
dwellMs: dwellMs,
thresholdDb: thresholdDb,
);
} catch (e) {
_error = 'Failed to scan spectrum: $e';
notifyListeners();
return null;
} finally {
_isSpectrumScanActive = false;
_activeService.setSpectrumScanActive(false);
notifyListeners();
}
}
/// Set transmit power /// Set transmit power
Future<void> setTxPower(int powerDbm) async { Future<void> setTxPower(int powerDbm) async {
if (!_activeService.isConnected) { if (!_activeService.isConnected) {
@@ -1586,6 +1655,7 @@ class ConnectionProvider with ChangeNotifier {
/// Request fresh device info (triggers SelfInfo response) /// Request fresh device info (triggers SelfInfo response)
Future<void> refreshDeviceInfo() async { Future<void> refreshDeviceInfo() async {
if (_isSpectrumScanActive) return;
if (!_activeService.isConnected) { if (!_activeService.isConnected) {
_error = 'Not connected to device'; _error = 'Not connected to device';
notifyListeners(); notifyListeners();
@@ -1629,6 +1699,7 @@ class ConnectionProvider with ChangeNotifier {
/// Sync messages from device queue /// Sync messages from device queue
/// Call this repeatedly until no more messages are available /// Call this repeatedly until no more messages are available
Future<bool> syncNextMessage() async { Future<bool> syncNextMessage() async {
if (_isSpectrumScanActive) return false;
// Prevent re-entrancy and too-fast triggers // Prevent re-entrancy and too-fast triggers
if (_isSyncingMessages) { if (_isSyncingMessages) {
// Another sync (single or loop) is in progress // Another sync (single or loop) is in progress
@@ -1667,6 +1738,10 @@ class ConnectionProvider with ChangeNotifier {
/// Sync all waiting messages from device /// Sync all waiting messages from device
Future<int> syncAllMessages() async { Future<int> syncAllMessages() async {
if (_isSpectrumScanActive) {
debugPrint('⏸️ [Provider] Message sync skipped during spectrum scan');
return 0;
}
if (_isSyncingMessages) { if (_isSyncingMessages) {
// Already syncing; avoid overlapping loops // Already syncing; avoid overlapping loops
_syncRequestedWhileBusy = true; _syncRequestedWhileBusy = true;

View File

@@ -1,6 +1,7 @@
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:latlong2/latlong.dart'; import 'package:latlong2/latlong.dart';
import '../models/contact.dart'; import '../models/contact.dart';
import '../models/message_contact_location.dart';
import '../services/cayenne_lpp_parser.dart'; import '../services/cayenne_lpp_parser.dart';
import '../services/contact_storage_service.dart'; import '../services/contact_storage_service.dart';
import '../utils/key_comparison.dart'; import '../utils/key_comparison.dart';
@@ -214,6 +215,52 @@ class ContactsProvider with ChangeNotifier {
List<Contact> get chatContactsWithLocation => List<Contact> get chatContactsWithLocation =>
chatContacts.where((c) => c.displayLocation != null).toList(); chatContacts.where((c) => c.displayLocation != null).toList();
MessageContactLocation? buildMessageContactLocationSnapshot(
Contact contact, {
DateTime? capturedAt,
}) {
final snapshotTime = capturedAt ?? DateTime.now();
final telemetryGps = _getValidGpsOrNull(contact.telemetry?.gpsLocation);
final telemetryTimestamp = contact.telemetry?.timestamp;
AdvertLocation? advertLocation;
for (final point in contact.advertHistory) {
if (!point.timestamp.isAfter(snapshotTime)) {
advertLocation = point;
break;
}
}
advertLocation ??= contact.advertHistory.isNotEmpty
? contact.advertHistory.first
: null;
if (telemetryGps != null) {
final shouldUseTelemetry =
telemetryTimestamp == null ||
advertLocation == null ||
!telemetryTimestamp.isBefore(advertLocation.timestamp);
if (shouldUseTelemetry) {
return MessageContactLocation(
location: telemetryGps,
source: 'telemetry',
capturedAt: snapshotTime,
sourceTimestamp: telemetryTimestamp,
);
}
}
if (advertLocation != null) {
return MessageContactLocation(
location: advertLocation.location,
source: 'advert',
capturedAt: snapshotTime,
sourceTimestamp: advertLocation.timestamp,
);
}
return null;
}
/// Sort contacts by last seen (most recent first) /// Sort contacts by last seen (most recent first)
int _sortByLastSeen(Contact a, Contact b) { int _sortByLastSeen(Contact a, Contact b) {
return b.lastSeenTime.compareTo(a.lastSeenTime); return b.lastSeenTime.compareTo(a.lastSeenTime);
@@ -470,6 +517,22 @@ class ContactsProvider with ChangeNotifier {
return _contacts[keyHex]; return _contacts[keyHex];
} }
/// Clear a contact's learned path locally so the UI and next send both
/// prefer flood routing until the radio reports a fresh route.
void markPathUnhealthy(Uint8List publicKey) {
final contact = findContactByKey(publicKey);
if (contact == null || !contact.hasPath) {
return;
}
_contacts[contact.publicKeyHex] = contact.copyWith(
outPathLen: -1,
outPath: Uint8List(0),
);
_persistContacts();
notifyListeners();
}
/// Add or refresh a pending advert entry from PUSH_CODE_ADVERT (0x80). /// Add or refresh a pending advert entry from PUSH_CODE_ADVERT (0x80).
/// Excludes self key and existing contacts. /// Excludes self key and existing contacts.
void addPendingAdvert(Uint8List publicKey, {Uint8List? devicePublicKey}) { void addPendingAdvert(Uint8List publicKey, {Uint8List? devicePublicKey}) {

View File

@@ -0,0 +1,43 @@
import 'dart:async';
/// Tracks one or more in-flight waiters for the same fragment ACK key.
///
/// Duplicate fetch requests can race and wait on the same fragment ACK at once.
/// Completing all registered waiters avoids losing the earlier completer when a
/// later request registers for the same key.
class FragmentAckWaitRegistry {
final Map<String, List<Completer<void>>> _waiters = {};
Future<bool> waitFor(
String key, {
Duration timeout = const Duration(seconds: 8),
}) async {
final completer = Completer<void>();
final waiters = _waiters.putIfAbsent(key, () => <Completer<void>>[]);
waiters.add(completer);
try {
await completer.future.timeout(timeout);
return true;
} catch (_) {
final pending = _waiters[key];
pending?.remove(completer);
if (pending != null && pending.isEmpty) {
_waiters.remove(key);
}
return false;
}
}
int complete(String key) {
final waiters = _waiters.remove(key);
if (waiters == null || waiters.isEmpty) {
return 0;
}
for (final completer in waiters) {
if (!completer.isCompleted) {
completer.complete();
}
}
return waiters.length;
}
}

View File

@@ -1,3 +1,5 @@
import 'dart:typed_data';
/// Message delivery tracking helper /// Message delivery tracking helper
/// ///
/// Manages message delivery tracking for sent messages, including: /// Manages message delivery tracking for sent messages, including:
@@ -16,6 +18,9 @@ class MessageDeliveryTracker {
/// Messages tracked here before sending, popped when RESP_CODE_SENT arrives /// Messages tracked here before sending, popped when RESP_CODE_SENT arrives
final List<String> _pendingMessageIds = []; final List<String> _pendingMessageIds = [];
/// Contact-scoped FIFOs for matching direct-message SENT responses.
final Map<String, List<String>> _pendingMessageIdsByContact = {};
/// Map of ACK tag to message ID for delivery confirmation /// Map of ACK tag to message ID for delivery confirmation
final Map<int, String> _ackTagToMessageId = {}; final Map<int, String> _ackTagToMessageId = {};
@@ -33,6 +38,15 @@ class MessageDeliveryTracker {
_pendingMessageIds.add(messageId); _pendingMessageIds.add(messageId);
} }
/// Track a pending direct message ID for a specific contact.
void trackPendingDirectMessage(String messageId, Uint8List contactPublicKey) {
trackPendingMessage(messageId);
final contactKey = _contactKey(contactPublicKey);
_pendingMessageIdsByContact
.putIfAbsent(contactKey, () => [])
.add(messageId);
}
/// Pop the next pending message ID from FIFO queue /// Pop the next pending message ID from FIFO queue
/// ///
/// Called when RESP_CODE_SENT arrives. Returns null if queue empty. /// Called when RESP_CODE_SENT arrives. Returns null if queue empty.
@@ -43,6 +57,24 @@ class MessageDeliveryTracker {
return _pendingMessageIds.removeAt(0); return _pendingMessageIds.removeAt(0);
} }
/// Pop the next pending direct message ID for a specific contact.
///
/// Falls back to the legacy global FIFO if the contact queue is empty.
String? popPendingDirectMessageId(Uint8List contactPublicKey) {
final contactKey = _contactKey(contactPublicKey);
final queue = _pendingMessageIdsByContact[contactKey];
if (queue == null || queue.isEmpty) {
return popPendingMessageId();
}
final messageId = queue.removeAt(0);
if (queue.isEmpty) {
_pendingMessageIdsByContact.remove(contactKey);
}
_pendingMessageIds.remove(messageId);
return messageId;
}
/// Map ACK tag to message ID after RESP_CODE_SENT received /// Map ACK tag to message ID after RESP_CODE_SENT received
/// ///
/// Creates bidirectional mapping for efficient cleanup and tracking. /// Creates bidirectional mapping for efficient cleanup and tracking.
@@ -86,6 +118,17 @@ class MessageDeliveryTracker {
_ackTagToMessageId.remove(ackTag); _ackTagToMessageId.remove(ackTag);
_ackTagTimestamps.remove(ackTag); _ackTagTimestamps.remove(ackTag);
} }
_pendingMessageIds.remove(messageId);
final emptyKeys = <String>[];
for (final entry in _pendingMessageIdsByContact.entries) {
entry.value.remove(messageId);
if (entry.value.isEmpty) {
emptyKeys.add(entry.key);
}
}
for (final key in emptyKeys) {
_pendingMessageIdsByContact.remove(key);
}
} }
/// Clean up stale ACK mappings /// Clean up stale ACK mappings
@@ -114,6 +157,7 @@ class MessageDeliveryTracker {
/// Clear all tracking state /// Clear all tracking state
void clearTracking() { void clearTracking() {
_pendingMessageIds.clear(); _pendingMessageIds.clear();
_pendingMessageIdsByContact.clear();
_ackTagToMessageId.clear(); _ackTagToMessageId.clear();
_messageIdToAckTag.clear(); _messageIdToAckTag.clear();
_ackTagTimestamps.clear(); _ackTagTimestamps.clear();
@@ -133,9 +177,7 @@ class MessageDeliveryTracker {
/// Get oldest pending ACK timestamp (for debugging) /// Get oldest pending ACK timestamp (for debugging)
DateTime? get oldestPendingTimestamp { DateTime? get oldestPendingTimestamp {
if (_ackTagTimestamps.isEmpty) return null; if (_ackTagTimestamps.isEmpty) return null;
return _ackTagTimestamps.values.reduce( return _ackTagTimestamps.values.reduce((a, b) => a.isBefore(b) ? a : b);
(a, b) => a.isBefore(b) ? a : b,
);
} }
/// Get diagnostic info for debugging /// Get diagnostic info for debugging
@@ -145,6 +187,15 @@ class MessageDeliveryTracker {
'shouldRateLimit': shouldRateLimit, 'shouldRateLimit': shouldRateLimit,
'oldestPending': oldestPendingTimestamp?.toIso8601String(), 'oldestPending': oldestPendingTimestamp?.toIso8601String(),
'ackTags': _ackTagToMessageId.keys.toList(), 'ackTags': _ackTagToMessageId.keys.toList(),
'pendingByContact': _pendingMessageIdsByContact.map(
(key, value) => MapEntry(key, value.length),
),
}; };
} }
String _contactKey(Uint8List contactPublicKey) {
return contactPublicKey
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join();
}
} }

View File

@@ -1,3 +1,6 @@
import 'dart:convert';
import 'dart:math' as math;
import '../../models/message.dart'; import '../../models/message.dart';
import '../../models/contact.dart'; import '../../models/contact.dart';
@@ -17,11 +20,18 @@ class MessageRetryManager {
// Track retry state for each message ID // Track retry state for each message ID
final Map<String, int> _retryAttempts = {}; final Map<String, int> _retryAttempts = {};
final Map<String, DateTime> _lastRetryTimes = {}; final Map<String, DateTime> _lastRetryTimes = {};
final Map<String, int> _pathFailureStreaks = {};
// Progressive timeout values in milliseconds // Progressive timeout values in milliseconds
// These are app-level timeouts, separate from firmware's suggested timeout // These are app-level timeouts, separate from firmware's suggested timeout
// Firmware timeout is for ACK arrival, these are for retry attempts // Firmware timeout is for ACK arrival, these are for retry attempts
static const List<int> _timeouts = [4000, 8000, 12000]; static const List<int> _timeouts = [4000, 8000, 12000];
static const int _defaultLoRaSf = 10;
static const int _defaultLoRaCr = 5;
static const int _defaultLoRaBwHz = 250000;
static const int _defaultLoRaPreambleSymbols = 8;
static const int _defaultLoRaCrcEnabled = 1;
static const int _defaultLoRaExplicitHeader = 1;
/// Get timeout for a specific retry attempt (0-2) /// Get timeout for a specific retry attempt (0-2)
/// Returns: 4000ms for attempt 0, 8000ms for attempt 1, 12000ms for attempt 2 /// Returns: 4000ms for attempt 0, 8000ms for attempt 1, 12000ms for attempt 2
@@ -32,6 +42,30 @@ class MessageRetryManager {
return _timeouts[attempt]; return _timeouts[attempt];
} }
/// Calculate a conservative delivery-ACK timeout when firmware doesn't
/// provide one or returns an invalid value.
int calculateAckTimeoutMs({
required String text,
required Contact? contact,
int? suggestedTimeoutMs,
}) {
if (suggestedTimeoutMs != null && suggestedTimeoutMs > 0) {
return suggestedTimeoutMs;
}
final payloadBytes = utf8.encode(text).length;
final airtimeMs = _estimateLoRaAirtimeMs(payloadBytes);
final hopCount = contact?.hasPath == true
? math.max(contact!.outPathLen, 0)
: -1;
if (hopCount < 0) {
return ((airtimeMs * 10) + 4000).clamp(10000, 30000);
}
return ((airtimeMs * (hopCount + 1) * 2) + 1500).clamp(4000, 20000);
}
/// Check if a message is eligible for retry /// Check if a message is eligible for retry
/// ///
/// Returns true if: /// Returns true if:
@@ -87,6 +121,7 @@ class MessageRetryManager {
void clearAll() { void clearAll() {
_retryAttempts.clear(); _retryAttempts.clear();
_lastRetryTimes.clear(); _lastRetryTimes.clear();
_pathFailureStreaks.clear();
} }
/// Get current retry attempt for a message (for debugging) /// Get current retry attempt for a message (for debugging)
@@ -98,4 +133,50 @@ class MessageRetryManager {
DateTime? getLastRetryTime(String messageId) { DateTime? getLastRetryTime(String messageId) {
return _lastRetryTimes[messageId]; return _lastRetryTimes[messageId];
} }
/// Record a successful delivery for a contact and clear any accumulated
/// route failure streak for future sends.
void recordDeliverySuccess(Contact contact) {
_pathFailureStreaks.remove(contact.publicKeyHex);
}
/// Record a permanent route failure for a contact.
///
/// Returns the updated failure streak so callers can decide when to reset
/// the learned path on the radio and in local state.
int recordPathFailure(Contact contact) {
final contactKey = contact.publicKeyHex;
final next = (_pathFailureStreaks[contactKey] ?? 0) + 1;
_pathFailureStreaks[contactKey] = next;
return next;
}
int? getPathFailureStreak(Contact contact) {
return _pathFailureStreaks[contact.publicKeyHex];
}
int _estimateLoRaAirtimeMs(int payloadLenBytes) {
final sf = _defaultLoRaSf;
final bw = _defaultLoRaBwHz.toDouble();
final cr = (_defaultLoRaCr - 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).ceil();
}
} }

View File

@@ -0,0 +1,38 @@
import '../../utils/image_message_parser.dart';
import '../../utils/voice_message_parser.dart';
class RestoredSessionMetadata {
final Map<String, String> voiceSenderKeyBySession;
final Map<String, ImageEnvelope> imageEnvelopeBySession;
const RestoredSessionMetadata({
required this.voiceSenderKeyBySession,
required this.imageEnvelopeBySession,
});
}
RestoredSessionMetadata restoreSessionMetadataFromMessages(
Iterable<String> messageTexts,
) {
final voiceSenderKeyBySession = <String, String>{};
final imageEnvelopeBySession = <String, ImageEnvelope>{};
for (final text in messageTexts) {
final voiceEnvelope = VoiceEnvelope.tryParseText(text);
if (voiceEnvelope != null) {
voiceSenderKeyBySession[voiceEnvelope.sessionId] = voiceEnvelope
.senderKey6
.toLowerCase();
}
final imageEnvelope = ImageEnvelope.tryParse(text);
if (imageEnvelope != null) {
imageEnvelopeBySession[imageEnvelope.sessionId] = imageEnvelope;
}
}
return RestoredSessionMetadata(
voiceSenderKeyBySession: voiceSenderKeyBySession,
imageEnvelopeBySession: imageEnvelopeBySession,
);
}

View File

@@ -58,6 +58,7 @@ class ImageProvider with ChangeNotifier {
/// Incoming sessions keyed by sessionId. /// Incoming sessions keyed by sessionId.
final Map<String, ImageSession> _sessions = {}; final Map<String, ImageSession> _sessions = {};
final Set<String> _ignoredIncomingSessions = {};
/// Outgoing sessions cached for deferred serving. /// Outgoing sessions cached for deferred serving.
final Map<String, _OutgoingSession> _outgoing = {}; final Map<String, _OutgoingSession> _outgoing = {};
@@ -88,6 +89,8 @@ class ImageProvider with ChangeNotifier {
bool hasOutgoing(String sessionId) => _outgoing.containsKey(sessionId); bool hasOutgoing(String sessionId) => _outgoing.containsKey(sessionId);
Duration? estimateRemainingTransferTime(String sessionId) => Duration? estimateRemainingTransferTime(String sessionId) =>
_sessions[sessionId]?.estimateRemaining(); _sessions[sessionId]?.estimateRemaining();
bool isReceiveCanceled(String sessionId) =>
_ignoredIncomingSessions.contains(sessionId);
List<int> missingFragmentIndices(String sessionId) { List<int> missingFragmentIndices(String sessionId) {
final session = _sessions[sessionId]; final session = _sessions[sessionId];
@@ -107,6 +110,12 @@ class ImageProvider with ChangeNotifier {
/// ///
/// Returns true when the session just became complete. /// Returns true when the session just became complete.
bool addFragment(ImagePacket fragment, {int width = 0, int height = 0}) { bool addFragment(ImagePacket fragment, {int width = 0, int height = 0}) {
if (_ignoredIncomingSessions.contains(fragment.sessionId)) {
debugPrint(
'⏹️ [ImageProvider] Ignoring canceled incoming session ${fragment.sessionId}',
);
return false;
}
_sessions.putIfAbsent( _sessions.putIfAbsent(
fragment.sessionId, fragment.sessionId,
() => ImageSession( () => ImageSession(
@@ -135,9 +144,25 @@ class ImageProvider with ChangeNotifier {
return justComplete; return justComplete;
} }
void cancelIncomingSession(String sessionId) {
_ignoredIncomingSessions.add(sessionId);
_sessions.remove(sessionId);
unawaited(_persist());
notifyListeners();
}
void resumeIncomingSession(String sessionId) {
if (_ignoredIncomingSessions.remove(sessionId)) {
notifyListeners();
}
}
/// Register envelope metadata for a session (called when IE1 is received /// Register envelope metadata for a session (called when IE1 is received
/// before any binary fragments arrive). /// before any binary fragments arrive).
void registerEnvelope(ImageEnvelope envelope) { void registerEnvelope(ImageEnvelope envelope) {
if (_ignoredIncomingSessions.contains(envelope.sessionId)) {
return;
}
final existing = _sessions[envelope.sessionId]; final existing = _sessions[envelope.sessionId];
if (existing == null) { if (existing == null) {
_sessions[envelope.sessionId] = ImageSession( _sessions[envelope.sessionId] = ImageSession(
@@ -249,6 +274,7 @@ class ImageProvider with ChangeNotifier {
Future<void> clearAll() async { Future<void> clearAll() async {
_sessions.clear(); _sessions.clear();
_outgoing.clear(); _outgoing.clear();
_ignoredIncomingSessions.clear();
notifyListeners(); notifyListeners();
try { try {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();

View File

@@ -2,6 +2,7 @@ import 'dart:async';
import 'package:flutter/foundation.dart'; 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/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';
@@ -20,6 +21,7 @@ class MessagesProvider with ChangeNotifier {
final NotificationService _notificationService = NotificationService(); final NotificationService _notificationService = NotificationService();
bool _isInitialized = false; bool _isInitialized = false;
AppLocalizations? _localizations; AppLocalizations? _localizations;
final Map<String, MessageContactLocation> _messageContactLocations = {};
// 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 = {};
@@ -28,6 +30,13 @@ class MessagesProvider with ChangeNotifier {
// Key: message ID (not ACK tag, since multiple messages can share same ACK) // Key: message ID (not ACK tag, since multiple messages can share same ACK)
final Map<String, Timer> _timeoutTimers = {}; final Map<String, Timer> _timeoutTimers = {};
// Recently completed ACKs are kept briefly to ignore duplicate confirms.
final Map<int, DateTime> _completedAckHistory = {};
// Preserve ACK tags assigned to a message across retries.
final Map<String, Set<int>> _messageAckHistory = {};
final Map<int, (String, DateTime)> _ackHistoryLookup = {};
// Retry management // Retry management
final MessageRetryManager _retryManager = MessageRetryManager(); final MessageRetryManager _retryManager = MessageRetryManager();
@@ -65,6 +74,12 @@ class MessagesProvider with ChangeNotifier {
})? })?
sendMessageCallback; sendMessageCallback;
Future<void> Function({
required Contact contact,
required int failureStreak,
})?
onDirectPathFailedCallback;
List<Message> get messages => List.unmodifiable(_messages); List<Message> get messages => List.unmodifiable(_messages);
List<Message> get contactMessages => List<Message> get contactMessages =>
@@ -97,6 +112,9 @@ class MessagesProvider with ChangeNotifier {
String? get targetMessageId => _targetMessageId; String? get targetMessageId => _targetMessageId;
MessageContactLocation? getMessageContactLocation(String messageId) =>
_messageContactLocations[messageId];
/// Set localizations for notifications /// Set localizations for notifications
void setLocalizations(AppLocalizations localizations) { void setLocalizations(AppLocalizations localizations) {
_localizations = localizations; _localizations = localizations;
@@ -125,6 +143,11 @@ class MessagesProvider with ChangeNotifier {
try { try {
debugPrint('📦 [MessagesProvider] Loading persisted messages...'); debugPrint('📦 [MessagesProvider] Loading persisted messages...');
final storedMessages = await _storageService.loadMessages(); final storedMessages = await _storageService.loadMessages();
final storedContactLocations = await _storageService
.loadMessageContactLocations();
_messageContactLocations
..clear()
..addAll(storedContactLocations);
// 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) {
@@ -287,6 +310,7 @@ class MessagesProvider with ChangeNotifier {
void addMessage( void addMessage(
Message message, { Message message, {
String Function(String name)? contactLookup, String Function(String name)? contactLookup,
MessageContactLocation? contactLocationSnapshot,
}) { }) {
// 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);
@@ -377,6 +401,9 @@ class MessagesProvider with ChangeNotifier {
} }
_messages.add(finalMessage); _messages.add(finalMessage);
if (contactLocationSnapshot != null) {
_messageContactLocations[finalMessage.id] = contactLocationSnapshot;
}
// 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) {
@@ -563,7 +590,10 @@ class MessagesProvider with ChangeNotifier {
/// Persist messages to storage (async, non-blocking) /// Persist messages to storage (async, non-blocking)
Future<void> _persistMessages() async { Future<void> _persistMessages() async {
try { try {
await _storageService.saveMessages(_messages); await _storageService.saveMessages(
_messages,
messageContactLocations: _messageContactLocations,
);
} catch (e) { } catch (e) {
debugPrint('❌ [MessagesProvider] Error persisting messages: $e'); debugPrint('❌ [MessagesProvider] Error persisting messages: $e');
} }
@@ -678,6 +708,7 @@ class MessagesProvider with ChangeNotifier {
} }
_messageContactMap.remove(messageId); _messageContactMap.remove(messageId);
_groupedMessageMapping.remove(messageId); _groupedMessageMapping.remove(messageId);
_messageContactLocations.remove(messageId);
debugPrint('🗑️ [MessagesProvider] Message $messageId deleted'); debugPrint('🗑️ [MessagesProvider] Message $messageId deleted');
@@ -706,6 +737,7 @@ class MessagesProvider with ChangeNotifier {
void clearMessages() { void clearMessages() {
_messages.clear(); _messages.clear();
_sarMarkers.clear(); _sarMarkers.clear();
_messageContactLocations.clear();
_persistMessages(); _persistMessages();
notifyListeners(); notifyListeners();
} }
@@ -720,6 +752,7 @@ class MessagesProvider with ChangeNotifier {
void clearAll() { void clearAll() {
_messages.clear(); _messages.clear();
_sarMarkers.clear(); _sarMarkers.clear();
_messageContactLocations.clear();
_persistMessages(); _persistMessages();
notifyListeners(); notifyListeners();
} }
@@ -906,11 +939,13 @@ class MessagesProvider with ChangeNotifier {
final (groupId, recipientPublicKey) = groupMapping; final (groupId, recipientPublicKey) = groupMapping;
debugPrint(' ✅ This is part of a grouped message: $groupId'); debugPrint(' ✅ This is part of a grouped message: $groupId');
// Update the recipient status to "sent" in the grouped message // ACK-tracked recipients stay pending until the delivery confirm arrives.
updateGroupedMessageRecipientStatus( updateGroupedMessageRecipientStatus(
groupId, groupId,
recipientPublicKey, recipientPublicKey,
MessageDeliveryStatus.sent, expectedAckTag > 0
? MessageDeliveryStatus.sending
: MessageDeliveryStatus.sent,
); );
// Track the ACK for this specific recipient // Track the ACK for this specific recipient
@@ -1015,6 +1050,12 @@ class MessagesProvider with ChangeNotifier {
if (index != -1) { if (index != -1) {
final message = _messages[index]; final message = _messages[index];
final contact = _messageContactMap[messageId];
final effectiveTimeout = _retryManager.calculateAckTimeoutMs(
text: message.text,
contact: contact,
suggestedTimeoutMs: suggestedTimeoutMs > 0 ? suggestedTimeoutMs : null,
);
debugPrint(' Current status: ${message.deliveryStatus}'); debugPrint(' Current status: ${message.deliveryStatus}');
debugPrint(' Message type: ${message.messageType}'); debugPrint(' Message type: ${message.messageType}');
debugPrint( debugPrint(
@@ -1022,16 +1063,22 @@ class MessagesProvider with ChangeNotifier {
); );
final updatedMessage = message.copyWith( final updatedMessage = message.copyWith(
deliveryStatus: MessageDeliveryStatus.sent, deliveryStatus: expectedAckTag > 0
? MessageDeliveryStatus.sending
: MessageDeliveryStatus.sent,
expectedAckTag: expectedAckTag > 0 ? expectedAckTag : null, expectedAckTag: expectedAckTag > 0 ? expectedAckTag : null,
suggestedTimeoutMs: suggestedTimeoutMs > 0 ? suggestedTimeoutMs : null, suggestedTimeoutMs: expectedAckTag > 0 ? effectiveTimeout : null,
); );
_messages[index] = updatedMessage; _messages[index] = updatedMessage;
// Only track and set timeout for direct messages (channel messages have expectedAckTag=0) // Only track and set timeout for direct messages (channel messages have expectedAckTag=0)
if (expectedAckTag > 0 && suggestedTimeoutMs > 0) { if (expectedAckTag > 0) {
// Track by ACK tag for matching with delivery confirmation // Track by ACK tag for matching with delivery confirmation
_pendingSentMessages[expectedAckTag] = updatedMessage; _pendingSentMessages[expectedAckTag] = updatedMessage;
_messageAckHistory
.putIfAbsent(messageId, () => <int>{})
.add(expectedAckTag);
_ackHistoryLookup[expectedAckTag] = (messageId, DateTime.now());
debugPrint( debugPrint(
' ✅ Added to pending messages map with ACK: $expectedAckTag', ' ✅ Added to pending messages map with ACK: $expectedAckTag',
); );
@@ -1042,7 +1089,7 @@ class MessagesProvider with ChangeNotifier {
// Start timeout timer using message ID as key // Start timeout timer using message ID as key
_timeoutTimers[messageId] = Timer( _timeoutTimers[messageId] = Timer(
Duration(milliseconds: suggestedTimeoutMs), Duration(milliseconds: effectiveTimeout),
() { () {
debugPrint( debugPrint(
'⏱️ [MessagesProvider] Timeout for message $messageId (ACK $expectedAckTag)', '⏱️ [MessagesProvider] Timeout for message $messageId (ACK $expectedAckTag)',
@@ -1054,7 +1101,7 @@ class MessagesProvider with ChangeNotifier {
); );
debugPrint( debugPrint(
'⏱️ [MessagesProvider] Started ${suggestedTimeoutMs}ms timeout timer for message $messageId (ACK $expectedAckTag)', '⏱️ [MessagesProvider] Started ${effectiveTimeout}ms timeout timer for message $messageId (ACK $expectedAckTag)',
); );
} else { } else {
debugPrint( debugPrint(
@@ -1245,6 +1292,8 @@ class MessagesProvider with ChangeNotifier {
/// Update message status to delivered with RTT /// Update message status to delivered with RTT
void markMessageDelivered(int ackCode, int roundTripTimeMs) { void markMessageDelivered(int ackCode, int roundTripTimeMs) {
_cleanupCompletedAckHistory();
_cleanupAckHistoryLookup();
debugPrint( debugPrint(
'🔍 [MessagesProvider] markMessageDelivered called with ACK: $ackCode, RTT: ${roundTripTimeMs}ms', '🔍 [MessagesProvider] markMessageDelivered called with ACK: $ackCode, RTT: ${roundTripTimeMs}ms',
); );
@@ -1296,6 +1345,7 @@ class MessagesProvider with ChangeNotifier {
); );
_ackTagToRecipients.remove(ackCode); _ackTagToRecipients.remove(ackCode);
_pendingSentMessages.remove(ackCode); _pendingSentMessages.remove(ackCode);
_rememberCompletedAck(ackCode);
} }
debugPrint( debugPrint(
@@ -1339,9 +1389,15 @@ class MessagesProvider with ChangeNotifier {
// Remove from pending // Remove from pending
_pendingSentMessages.remove(ackCode); _pendingSentMessages.remove(ackCode);
_rememberCompletedAck(ackCode);
_clearAckHistoryForMessage(message.id);
// Clear retry tracking on successful delivery // Clear retry tracking on successful delivery
_retryManager.clearRetry(message.id); _retryManager.clearRetry(message.id);
final deliveredContact = _messageContactMap[message.id];
if (deliveredContact != null) {
_retryManager.recordDeliverySuccess(deliveredContact);
}
debugPrint( debugPrint(
'✅ [MessagesProvider] Message ${message.id} delivered in ${roundTripTimeMs}ms (ACK $ackCode)', '✅ [MessagesProvider] Message ${message.id} delivered in ${roundTripTimeMs}ms (ACK $ackCode)',
@@ -1362,6 +1418,43 @@ class MessagesProvider with ChangeNotifier {
); );
} }
} else { } else {
final historicalMatch = _ackHistoryLookup[ackCode];
if (historicalMatch != null) {
final historicalMessageId = historicalMatch.$1;
final historicalIndex = _messages.indexWhere(
(m) => m.id == historicalMessageId,
);
if (historicalIndex != -1 &&
_messages[historicalIndex].deliveryStatus !=
MessageDeliveryStatus.delivered) {
_messages[historicalIndex] = _messages[historicalIndex].copyWith(
deliveryStatus: MessageDeliveryStatus.delivered,
roundTripTimeMs: roundTripTimeMs,
deliveredAt: DateTime.now(),
);
_timeoutTimers[historicalMessageId]?.cancel();
_timeoutTimers.remove(historicalMessageId);
_rememberCompletedAck(ackCode);
_clearAckHistoryForMessage(historicalMessageId);
_retryManager.clearRetry(historicalMessageId);
final deliveredContact = _messageContactMap[historicalMessageId];
if (deliveredContact != null) {
_retryManager.recordDeliverySuccess(deliveredContact);
}
_persistMessages();
notifyListeners();
debugPrint(
'✅ [MessagesProvider] Historical ACK $ackCode matched message $historicalMessageId',
);
return;
}
}
if (_completedAckHistory.containsKey(ackCode)) {
debugPrint(
' [MessagesProvider] Duplicate/late ACK $ackCode ignored (already completed)',
);
return;
}
debugPrint( debugPrint(
'⚠️ [MessagesProvider] No pending message found for ACK code: $ackCode', '⚠️ [MessagesProvider] No pending message found for ACK code: $ackCode',
); );
@@ -1482,18 +1575,31 @@ class MessagesProvider with ChangeNotifier {
debugPrint( debugPrint(
'⏰ [MessagesProvider] Executing retry $nextAttempt for message $messageId', '⏰ [MessagesProvider] Executing retry $nextAttempt for message $messageId',
); );
final currentIndex = _messages.indexWhere((m) => m.id == messageId);
if (currentIndex == -1) {
return;
}
final currentMessage = _messages[currentIndex];
if (currentMessage.deliveryStatus == MessageDeliveryStatus.delivered) {
return;
}
if (sendMessageCallback != null) { if (sendMessageCallback != null) {
await sendMessageCallback!( final queued = await sendMessageCallback!(
contactPublicKey: contact.publicKey, contactPublicKey: contact.publicKey,
text: message.text, text: message.text,
messageId: messageId, messageId: messageId,
contact: contact, contact: contact,
retryAttempt: nextAttempt, retryAttempt: nextAttempt,
); );
if (!queued) {
_markAsPermanentlyFailed(messageId, currentMessage);
}
} else { } else {
debugPrint( debugPrint(
'⚠️ [MessagesProvider] sendMessageCallback not set, cannot retry', '⚠️ [MessagesProvider] sendMessageCallback not set, cannot retry',
); );
_markAsPermanentlyFailed(messageId, currentMessage);
} }
}); });
@@ -1529,17 +1635,21 @@ class MessagesProvider with ChangeNotifier {
// Send with flood mode (no retry after this) // Send with flood mode (no retry after this)
if (sendMessageCallback != null) { if (sendMessageCallback != null) {
await sendMessageCallback!( final queued = await sendMessageCallback!(
contactPublicKey: contact.publicKey, contactPublicKey: contact.publicKey,
text: message.text, text: message.text,
messageId: messageId, messageId: messageId,
contact: contact, contact: contact,
retryAttempt: 0, // Reset attempt for flood retryAttempt: 0, // Reset attempt for flood
); );
if (!queued) {
_markAsPermanentlyFailed(messageId, _messages[index]);
}
} else { } else {
debugPrint( debugPrint(
'⚠️ [MessagesProvider] sendMessageCallback not set, cannot send flood', '⚠️ [MessagesProvider] sendMessageCallback not set, cannot send flood',
); );
_markAsPermanentlyFailed(messageId, _messages[index]);
} }
_persistMessages(); _persistMessages();
@@ -1562,10 +1672,27 @@ class MessagesProvider with ChangeNotifier {
if (message.expectedAckTag != null) { if (message.expectedAckTag != null) {
_pendingSentMessages.remove(message.expectedAckTag); _pendingSentMessages.remove(message.expectedAckTag);
} }
_clearAckHistoryForMessage(messageId);
// Clear retry tracking // Clear retry tracking
_retryManager.clearRetry(messageId); _retryManager.clearRetry(messageId);
final failedContact = _messageContactMap[messageId];
if (failedContact != null && failedContact.hasPath) {
final failureStreak = _retryManager.recordPathFailure(failedContact);
debugPrint(
' Path failure streak for ${failedContact.advName}: $failureStreak',
);
if (failureStreak >= 2 && onDirectPathFailedCallback != null) {
unawaited(
onDirectPathFailedCallback!(
contact: failedContact,
failureStreak: failureStreak,
),
);
}
}
_persistMessages(); _persistMessages();
notifyListeners(); notifyListeners();
} }
@@ -1608,17 +1735,21 @@ class MessagesProvider with ChangeNotifier {
// Send again // Send again
if (sendMessageCallback != null) { if (sendMessageCallback != null) {
await sendMessageCallback!( final queued = await sendMessageCallback!(
contactPublicKey: contact.publicKey, contactPublicKey: contact.publicKey,
text: message.text, text: message.text,
messageId: messageId, messageId: messageId,
contact: contact, contact: contact,
retryAttempt: 0, retryAttempt: 0,
); );
if (!queued) {
_markAsPermanentlyFailed(messageId, _messages[index]);
}
} else { } else {
debugPrint( debugPrint(
'⚠️ [MessagesProvider] sendMessageCallback not set, cannot resend', '⚠️ [MessagesProvider] sendMessageCallback not set, cannot resend',
); );
_markAsPermanentlyFailed(messageId, _messages[index]);
} }
_persistMessages(); _persistMessages();
@@ -1631,10 +1762,62 @@ class MessagesProvider with ChangeNotifier {
timer.cancel(); timer.cancel();
} }
_timeoutTimers.clear(); _timeoutTimers.clear();
_completedAckHistory.clear();
_messageAckHistory.clear();
_ackHistoryLookup.clear();
// Clear retry manager // Clear retry manager
_retryManager.clearAll(); _retryManager.clearAll();
super.dispose(); super.dispose();
} }
void _rememberCompletedAck(int ackCode) {
_completedAckHistory[ackCode] = DateTime.now();
_cleanupCompletedAckHistory();
}
void _cleanupCompletedAckHistory({
Duration maxAge = const Duration(minutes: 15),
}) {
final cutoff = DateTime.now().subtract(maxAge);
final staleAcks = _completedAckHistory.entries
.where((entry) => entry.value.isBefore(cutoff))
.map((entry) => entry.key)
.toList();
for (final ack in staleAcks) {
_completedAckHistory.remove(ack);
}
}
void _clearAckHistoryForMessage(String messageId) {
final ackTags = _messageAckHistory.remove(messageId);
if (ackTags == null) {
return;
}
for (final ack in ackTags) {
_ackHistoryLookup.remove(ack);
}
}
void _cleanupAckHistoryLookup({
Duration maxAge = const Duration(minutes: 15),
}) {
final cutoff = DateTime.now().subtract(maxAge);
final staleAcks = _ackHistoryLookup.entries
.where((entry) => entry.value.$2.isBefore(cutoff))
.map((entry) => entry.key)
.toList();
for (final ack in staleAcks) {
final messageId = _ackHistoryLookup.remove(ack)?.$1;
if (messageId == null) {
continue;
}
final history = _messageAckHistory[messageId];
history?.remove(ack);
if (history != null && history.isEmpty) {
_messageAckHistory.remove(messageId);
}
}
}
} }

View File

@@ -59,6 +59,7 @@ class VoiceProvider with ChangeNotifier {
/// Active sessions keyed by sessionId. /// Active sessions keyed by sessionId.
final Map<String, VoiceSession> _sessions = {}; final Map<String, VoiceSession> _sessions = {};
final Set<String> _ignoredIncomingSessions = {};
/// Currently playing session ID, or null. /// Currently playing session ID, or null.
String? _playingSessionId; String? _playingSessionId;
@@ -117,6 +118,8 @@ class VoiceProvider with ChangeNotifier {
_outgoingSessions.containsKey(sessionId); _outgoingSessions.containsKey(sessionId);
Duration? estimateRemainingTransferTime(String sessionId) => Duration? estimateRemainingTransferTime(String sessionId) =>
_sessions[sessionId]?.estimateRemaining(); _sessions[sessionId]?.estimateRemaining();
bool isReceiveCanceled(String sessionId) =>
_ignoredIncomingSessions.contains(sessionId);
List<int> missingPacketIndices(String sessionId) { List<int> missingPacketIndices(String sessionId) {
final session = _sessions[sessionId]; final session = _sessions[sessionId];
@@ -133,6 +136,12 @@ class VoiceProvider with ChangeNotifier {
/// Add an incoming [packet] to its session. Creates the session on first packet. /// Add an incoming [packet] to its session. Creates the session on first packet.
/// Returns true if the session just became complete. /// Returns true if the session just became complete.
bool addPacket(VoicePacket packet) { bool addPacket(VoicePacket packet) {
if (_ignoredIncomingSessions.contains(packet.sessionId)) {
debugPrint(
'⏹️ [VoiceProvider] Ignoring canceled incoming session ${packet.sessionId}',
);
return false;
}
_sessions.putIfAbsent( _sessions.putIfAbsent(
packet.sessionId, packet.sessionId,
() => VoiceSession( () => VoiceSession(
@@ -159,6 +168,23 @@ class VoiceProvider with ChangeNotifier {
return justComplete; return justComplete;
} }
void cancelIncomingSession(String sessionId) {
_ignoredIncomingSessions.add(sessionId);
_sessions.remove(sessionId);
if (_playingSessionId == sessionId) {
unawaited(_player.stop());
_playingSessionId = null;
}
_persistVoiceData();
notifyListeners();
}
void resumeIncomingSession(String sessionId) {
if (_ignoredIncomingSessions.remove(sessionId)) {
notifyListeners();
}
}
/// Cache encoded packets for deferred voice serving. /// Cache encoded packets for deferred voice serving.
void cacheOutgoingSession(String sessionId, List<VoicePacket> packets) { void cacheOutgoingSession(String sessionId, List<VoicePacket> packets) {
if (packets.isEmpty) return; if (packets.isEmpty) return;
@@ -237,6 +263,7 @@ class VoiceProvider with ChangeNotifier {
Future<void> clearStoredVoiceData() async { Future<void> clearStoredVoiceData() async {
_sessions.clear(); _sessions.clear();
_outgoingSessions.clear(); _outgoingSessions.clear();
_ignoredIncomingSessions.clear();
_playingSessionId = null; _playingSessionId = null;
notifyListeners(); notifyListeners();
try { try {

View File

@@ -1,7 +1,7 @@
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:geolocator/geolocator.dart'; import 'package:geolocator/geolocator.dart';
import 'package:provider/provider.dart';
import '../providers/connection_provider.dart'; import '../providers/connection_provider.dart';
import '../services/validation_service.dart'; import '../services/validation_service.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
@@ -722,7 +722,8 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
SwitchListTile( SwitchListTile(
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
title: const Text('Client Repeat Mode'), title: const Text('Client Repeat Mode'),
subtitle: deviceInfo.allowedRepeatFreqRanges != null && subtitle:
deviceInfo.allowedRepeatFreqRanges != null &&
deviceInfo.allowedRepeatFreqRanges!.isNotEmpty deviceInfo.allowedRepeatFreqRanges!.isNotEmpty
? Text( ? Text(
'Allowed: ${deviceInfo.allowedRepeatFreqRanges!.map((r) => r.lower == r.upper ? '${(r.lower / 1000).toStringAsFixed(3)} MHz' : '${(r.lower / 1000).toStringAsFixed(3)}${(r.upper / 1000).toStringAsFixed(3)} MHz').join(', ')}', 'Allowed: ${deviceInfo.allowedRepeatFreqRanges!.map((r) => r.lower == r.upper ? '${(r.lower / 1000).toStringAsFixed(3)} MHz' : '${(r.lower / 1000).toStringAsFixed(3)}${(r.upper / 1000).toStringAsFixed(3)} MHz').join(', ')}',

View File

@@ -17,6 +17,7 @@ import 'map_management_screen.dart';
import 'settings_screen.dart'; import 'settings_screen.dart';
import 'device_config_screen.dart'; import 'device_config_screen.dart';
import 'packet_log_screen.dart'; import 'packet_log_screen.dart';
import 'spectrum_scan_screen.dart';
import '../utils/toast_logger.dart'; import '../utils/toast_logger.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
import '../widgets/permission_request_dialog.dart'; import '../widgets/permission_request_dialog.dart';
@@ -45,9 +46,9 @@ class HomeScreen extends StatefulWidget {
State<HomeScreen> createState() => _HomeScreenState(); State<HomeScreen> createState() => _HomeScreenState();
} }
class _HomeScreenState extends State<HomeScreen> class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
with SingleTickerProviderStateMixin {
late TabController _tabController; late TabController _tabController;
late final AppProvider _appProvider;
int _currentIndex = 0; int _currentIndex = 0;
bool _isMapFullscreen = false; bool _isMapFullscreen = false;
bool _showRxTxIndicators = true; bool _showRxTxIndicators = true;
@@ -73,9 +74,13 @@ class _HomeScreenState extends State<HomeScreen>
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_appProvider = context.read<AppProvider>();
_isMapEnabled = _appProvider.isMapEnabled;
_isContactsEnabled = _appProvider.isContactsEnabled;
_appProvider.addListener(_handleAppProviderChanged);
// Initialize synchronously so first build always has a valid controller. // Initialize synchronously so first build always has a valid controller.
_initTabController(); _initTabController();
_loadTabVisibilityAndInitTabs();
_loadRxTxPreference(); _loadRxTxPreference();
// Show permission dialog after the first frame if needed // Show permission dialog after the first frame if needed
@@ -86,31 +91,39 @@ class _HomeScreenState extends State<HomeScreen>
} }
} }
Future<void> _loadTabVisibilityAndInitTabs() async {
final prefs = await SharedPreferences.getInstance();
final mapEnabled = prefs.getBool('map_enabled') ?? true;
final contactsEnabled = prefs.getBool('contacts_enabled') ?? true;
if (!mounted) return;
if (_isMapEnabled != mapEnabled || _isContactsEnabled != contactsEnabled) {
_updateTabController(
mapEnabled: mapEnabled,
contactsEnabled: contactsEnabled,
);
}
}
void _initTabController() { void _initTabController() {
_tabController = TabController(length: _enabledTabs.length, vsync: this); _tabController = TabController(length: _enabledTabs.length, vsync: this);
_tabController.addListener(_onTabChanged); _tabController.addListener(_onTabChanged);
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
_handleTabActivated(_currentTab);
});
}
void _handleAppProviderChanged() {
if (!mounted) return;
_updateTabController(
mapEnabled: _appProvider.isMapEnabled,
contactsEnabled: _appProvider.isContactsEnabled,
);
} }
void _onTabChanged() { void _onTabChanged() {
final previousTab = _currentTab;
final nextIndex = _tabController.index;
setState(() { setState(() {
_currentIndex = _tabController.index; _currentIndex = nextIndex;
if (_currentTab != _HomeTab.map) { if (_currentTab != _HomeTab.map) {
_isMapFullscreen = false; _isMapFullscreen = false;
} }
}); });
final nextTab = _currentTab;
if (previousTab != nextTab) {
_handleTabActivated(nextTab);
}
} }
void _updateTabController({ void _updateTabController({
@@ -122,15 +135,21 @@ class _HomeScreenState extends State<HomeScreen>
} }
final oldTabs = _enabledTabs; final oldTabs = _enabledTabs;
final oldIndex = _tabController.index; final oldIndex = oldTabs.isEmpty
? 0
: _tabController.index.clamp(0, oldTabs.length - 1);
final oldTab = oldTabs[oldIndex]; final oldTab = oldTabs[oldIndex];
final oldController = _tabController; final oldController = _tabController;
oldController.removeListener(_onTabChanged); oldController.removeListener(_onTabChanged);
oldController.dispose();
// Update state // Update state
_isMapEnabled = mapEnabled; _isMapEnabled = mapEnabled;
_isContactsEnabled = contactsEnabled; _isContactsEnabled = contactsEnabled;
if (!_isMapEnabled) {
_isMapFullscreen = false;
}
final newTabs = _enabledTabs; final newTabs = _enabledTabs;
final newIndex = newTabs.indexOf(oldTab); final newIndex = newTabs.indexOf(oldTab);
@@ -143,11 +162,7 @@ class _HomeScreenState extends State<HomeScreen>
_tabController.index = _currentIndex; _tabController.index = _currentIndex;
setState(() {}); setState(() {});
_handleTabActivated(_currentTab);
// Dispose old controller after widgets have rebound to the new controller.
WidgetsBinding.instance.addPostFrameCallback((_) {
oldController.dispose();
});
} }
void _navigateToTab(_HomeTab tab) { void _navigateToTab(_HomeTab tab) {
@@ -157,6 +172,23 @@ class _HomeScreenState extends State<HomeScreen>
} }
} }
void _handleTabActivated(_HomeTab tab) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
switch (tab) {
case _HomeTab.messages:
context.read<MessagesProvider>().markAllAsRead();
break;
case _HomeTab.contacts:
context.read<ContactsProvider>().markAllAsViewed();
break;
case _HomeTab.map:
break;
}
});
}
Future<void> _loadRxTxPreference() async { Future<void> _loadRxTxPreference() async {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
if (mounted) { if (mounted) {
@@ -168,6 +200,7 @@ class _HomeScreenState extends State<HomeScreen>
@override @override
void dispose() { void dispose() {
_appProvider.removeListener(_handleAppProviderChanged);
_tabController.removeListener(_onTabChanged); _tabController.removeListener(_onTabChanged);
_tabController.dispose(); _tabController.dispose();
super.dispose(); super.dispose();
@@ -312,18 +345,7 @@ class _HomeScreenState extends State<HomeScreen>
messagesProvider.setLocalizations(localizations); messagesProvider.setLocalizations(localizations);
} }
// Check if tab visibility settings changed and update tab controller context.watch<AppProvider>();
final appProvider = context.watch<AppProvider>();
if (_isMapEnabled != appProvider.isMapEnabled ||
_isContactsEnabled != appProvider.isContactsEnabled) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
_updateTabController(
mapEnabled: appProvider.isMapEnabled,
contactsEnabled: appProvider.isContactsEnabled,
);
});
}
final enabledTabs = _enabledTabs; final enabledTabs = _enabledTabs;
final isMapTabActive = _currentTab == _HomeTab.map; final isMapTabActive = _currentTab == _HomeTab.map;
@@ -381,6 +403,26 @@ class _HomeScreenState extends State<HomeScreen>
}); });
}, },
), ),
PopupMenuItem(
child: Row(
children: [
const Icon(Icons.radar),
const SizedBox(width: 8),
const Text('Spectrum Scan'),
],
),
onTap: () {
final navigator = Navigator.of(context);
Future.delayed(Duration.zero, () {
if (!mounted) return;
navigator.push(
MaterialPageRoute(
builder: (context) => const SpectrumScanScreen(),
),
);
});
},
),
PopupMenuItem( PopupMenuItem(
child: Row( child: Row(
children: [ children: [

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,5 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert';
import 'dart:io' show Platform; import 'dart:io' show Platform;
import 'package:flutter/foundation.dart' show kIsWeb; import 'package:flutter/foundation.dart' show kIsWeb;
import 'dart:math' as math; import 'dart:math' as math;
@@ -44,11 +45,13 @@ class MessagesTab extends StatefulWidget {
} }
class _MessagesTabState extends State<MessagesTab> { class _MessagesTabState extends State<MessagesTab> {
static const int _maxContactMessageBytes = 156;
static const int _maxChannelMessageBytes = 127;
final TextEditingController _textController = TextEditingController(); final TextEditingController _textController = TextEditingController();
final FocusNode _focusNode = FocusNode(); final FocusNode _focusNode = FocusNode();
final ScrollController _scrollController = ScrollController(); final ScrollController _scrollController = ScrollController();
int _characterCount = 0; int _messageByteCount = 0;
static const int _maxCharacters = 160;
String? _highlightedMessageId; String? _highlightedMessageId;
Timer? _highlightTimer; // Timer for clearing message highlight Timer? _highlightTimer; // Timer for clearing message highlight
@@ -166,10 +169,44 @@ class _MessagesTabState extends State<MessagesTab> {
void _updateCharacterCount() { void _updateCharacterCount() {
setState(() { setState(() {
_characterCount = _textController.text.length; _messageByteCount = utf8.encode(_textController.text).length;
}); });
} }
int get _maxMessageBytes =>
_destinationType == MessageDestinationPreferences.destinationTypeChannel
? _maxChannelMessageBytes
: _maxContactMessageBytes;
TextInputFormatter get _messageByteLimiter =>
TextInputFormatter.withFunction((oldValue, newValue) {
if (utf8.encode(newValue.text).length <= _maxMessageBytes) {
return newValue;
}
return oldValue;
});
void _enforceMessageByteLimit() {
final currentText = _textController.text;
if (utf8.encode(currentText).length <= _maxMessageBytes) {
_updateCharacterCount();
return;
}
var truncated = currentText;
while (truncated.isNotEmpty &&
utf8.encode(truncated).length > _maxMessageBytes) {
truncated = truncated.substring(0, truncated.length - 1);
}
_textController.value = _textController.value.copyWith(
text: truncated,
selection: TextSelection.collapsed(offset: truncated.length),
composing: TextRange.empty,
);
_updateCharacterCount();
}
/// Load saved message destination from preferences /// Load saved message destination from preferences
Future<void> _loadSavedDestination() async { Future<void> _loadSavedDestination() async {
final savedDestination = final savedDestination =
@@ -211,6 +248,8 @@ class _MessagesTabState extends State<MessagesTab> {
await MessageDestinationPreferences.clearDestination(); await MessageDestinationPreferences.clearDestination();
} }
} }
_enforceMessageByteLimit();
} }
/// Show recipient selector bottom sheet /// Show recipient selector bottom sheet
@@ -250,6 +289,8 @@ class _MessagesTabState extends State<MessagesTab> {
_selectedRecipient = recipient; _selectedRecipient = recipient;
}); });
_enforceMessageByteLimit();
// Save to preferences // Save to preferences
await MessageDestinationPreferences.setDestination( await MessageDestinationPreferences.setDestination(
type, type,
@@ -287,6 +328,18 @@ class _MessagesTabState extends State<MessagesTab> {
return 'Select recipient'; return 'Select recipient';
} }
String _getDestinationLabel() {
if (_destinationType ==
MessageDestinationPreferences.destinationTypeChannel &&
_selectedRecipient != null) {
return _selectedRecipient!.getLocalizedDisplayName(context);
}
if (_selectedRecipient != null) {
return _selectedRecipient!.displayName;
}
return 'Public Channel';
}
Future<void> _sendMessage() async { Future<void> _sendMessage() async {
final text = _textController.text.trim(); final text = _textController.text.trim();
if (text.isEmpty) return; if (text.isEmpty) return;
@@ -371,7 +424,7 @@ class _MessagesTabState extends State<MessagesTab> {
); );
// Add to messages list with "sending" status // Add to messages list with "sending" status
messagesProvider.addSentMessage(sentMessage); messagesProvider.addSentMessage(sentMessage, contact: _selectedRecipient);
// Send to selected channel // Send to selected channel
await connectionProvider.sendChannelMessage( await connectionProvider.sendChannelMessage(
@@ -413,7 +466,10 @@ class _MessagesTabState extends State<MessagesTab> {
); );
// Add to messages list with "sending" status // Add to messages list with "sending" status
messagesProvider.addSentMessage(sentMessage); messagesProvider.addSentMessage(
sentMessage,
contact: _selectedRecipient,
);
// Send message to selected recipient // Send message to selected recipient
final sentSuccessfully = await connectionProvider.sendTextMessage( final sentSuccessfully = await connectionProvider.sendTextMessage(
@@ -604,7 +660,7 @@ class _MessagesTabState extends State<MessagesTab> {
deliveryStatus: MessageDeliveryStatus.sending, deliveryStatus: MessageDeliveryStatus.sending,
recipientPublicKey: isChannel ? null : recipient?.publicKey, recipientPublicKey: isChannel ? null : recipient?.publicKey,
); );
messagesProvider.addSentMessage(placeholder); messagesProvider.addSentMessage(placeholder, contact: recipient);
// Send IE1 envelope via normal message path. // Send IE1 envelope via normal message path.
final envelopeText = envelope.encode(); final envelopeText = envelope.encode();
@@ -641,21 +697,8 @@ class _MessagesTabState extends State<MessagesTab> {
'chunk=${imageDataBytesPerFragment}B', 'chunk=${imageDataBytesPerFragment}B',
); );
// Push all fragments immediately for direct contacts. // Image fragments are always served on demand after an explicit IR2
// For channels, fragments are served on demand via IR1 fetch requests. // fetch request, including direct contacts.
if (!isChannel && recipient != null) {
// Small delay so the IE1 envelope can propagate before fragments arrive.
await Future.delayed(const Duration(milliseconds: 500));
if (!mounted) return;
final served = await imageProvider.serveSessionTo(
sessionId: sessionId,
requester: recipient,
);
debugPrint(
'📷 [Image] Pushed ${served ? fragments.length : 0} '
'fragments to ${recipient.advName}',
);
}
} catch (e, st) { } catch (e, st) {
debugPrint('❌ [Image] _pickAndSendImage: $e\n$st'); debugPrint('❌ [Image] _pickAndSendImage: $e\n$st');
if (!mounted) return; if (!mounted) return;
@@ -922,7 +965,7 @@ class _MessagesTabState extends State<MessagesTab> {
channelIdx: channelIdx, channelIdx: channelIdx,
recipientPublicKey: isChannel ? null : recipient?.publicKey, recipientPublicKey: isChannel ? null : recipient?.publicKey,
); );
messagesProvider.addSentMessage(sentMsg); messagesProvider.addSentMessage(sentMsg, contact: recipient);
try { try {
if (isChannel) { if (isChannel) {
@@ -1328,9 +1371,6 @@ class _MessagesTabState extends State<MessagesTab> {
// SAR marker data is automatically added by SarMessageParser.enhanceMessage in MessagesProvider // SAR marker data is automatically added by SarMessageParser.enhanceMessage in MessagesProvider
); );
// Add to messages list with "sending" status
messagesProvider.addSentMessage(sentMessage);
// Look up the room contact for path logging // Look up the room contact for path logging
final contactsProvider = context.read<ContactsProvider>(); final contactsProvider = context.read<ContactsProvider>();
final roomContact = contactsProvider.contacts.where((c) { final roomContact = contactsProvider.contacts.where((c) {
@@ -1338,6 +1378,9 @@ class _MessagesTabState extends State<MessagesTab> {
c.publicKey.matches(roomPublicKey); c.publicKey.matches(roomPublicKey);
}).firstOrNull; }).firstOrNull;
// Add to messages list with "sending" status
messagesProvider.addSentMessage(sentMessage, contact: roomContact);
// Send SAR message to selected room (persisted and immutable) // Send SAR message to selected room (persisted and immutable)
final sentSuccessfully = await connectionProvider.sendTextMessage( final sentSuccessfully = await connectionProvider.sendTextMessage(
contactPublicKey: roomPublicKey!, contactPublicKey: roomPublicKey!,
@@ -1458,8 +1501,13 @@ class _MessagesTabState extends State<MessagesTab> {
return Consumer<MessagesProvider>( return Consumer<MessagesProvider>(
builder: (context, messagesProvider, child) { builder: (context, messagesProvider, child) {
final messages = _getFilteredMessages(messagesProvider); final messages = _getFilteredMessages(messagesProvider);
final bottomInset = MediaQuery.of(context).viewPadding.bottom;
final composerBottomPadding = bottomInset > 0 ? 2.0 : 10.0;
return Column( return GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: () => FocusScope.of(context).unfocus(),
child: Column(
children: [ children: [
// Messages list with pull-to-refresh // Messages list with pull-to-refresh
Expanded( Expanded(
@@ -1469,19 +1517,25 @@ class _MessagesTabState extends State<MessagesTab> {
? LayoutBuilder( ? LayoutBuilder(
builder: (context, constraints) => builder: (context, constraints) =>
SingleChildScrollView( SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(), keyboardDismissBehavior:
ScrollViewKeyboardDismissBehavior.onDrag,
physics:
const AlwaysScrollableScrollPhysics(),
child: ConstrainedBox( child: ConstrainedBox(
constraints: BoxConstraints( constraints: BoxConstraints(
minHeight: constraints.maxHeight, minHeight: constraints.maxHeight,
), ),
child: Center( child: Center(
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment:
MainAxisAlignment.center,
children: [ children: [
Icon( Icon(
Icons.message_outlined, Icons.message_outlined,
size: 64, size: 64,
color: Theme.of(context).disabledColor, color: Theme.of(
context,
).disabledColor,
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Text(
@@ -1510,6 +1564,8 @@ class _MessagesTabState extends State<MessagesTab> {
) )
: ListView.builder( : ListView.builder(
controller: _scrollController, controller: _scrollController,
keyboardDismissBehavior:
ScrollViewKeyboardDismissBehavior.onDrag,
reverse: true, reverse: true,
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
itemCount: messages.length, itemCount: messages.length,
@@ -1564,143 +1620,443 @@ class _MessagesTabState extends State<MessagesTab> {
Container( Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface, color: Theme.of(context).colorScheme.surface,
border: Border(
top: BorderSide(
color: Theme.of(context).dividerColor,
width: 1,
), ),
), child: Column(
), mainAxisSize: MainAxisSize.min,
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [ children: [
// Quick actions (+) button SafeArea(
IconButton( top: false,
icon: Icon(_isRecording ? Icons.stop : Icons.add), child: Padding(
tooltip: _isRecording ? 'Stop recording' : 'More actions', padding: EdgeInsets.fromLTRB(
10,
10,
10,
composerBottomPadding,
),
child: Container(
decoration: BoxDecoration(
color: Theme.of(
context,
).colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(28),
border: Border.all(
color: Theme.of(
context,
).dividerColor.withValues(alpha: 0.35),
),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.05),
blurRadius: 18,
offset: const Offset(0, 6),
),
],
),
child: Padding(
padding: const EdgeInsets.fromLTRB(10, 10, 10, 8),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Container(
width: 42,
height: 42,
decoration: BoxDecoration(
color: Theme.of(
context,
).colorScheme.surface,
shape: BoxShape.circle,
border: Border.all(
color: Theme.of(
context,
).dividerColor.withValues(alpha: 0.35),
),
),
child: IconButton(
icon: Icon(
_isRecording ? Icons.stop : Icons.add,
size: 22,
),
tooltip: _isRecording
? 'Stop recording'
: 'More actions',
onPressed: _isRecording onPressed: _isRecording
? _stopAndSendVoice ? _stopAndSendVoice
: _showComposerActions, : _showComposerActions,
style: IconButton.styleFrom( color: _isRecording
backgroundColor: Theme.of(
context,
).colorScheme.primaryContainer,
foregroundColor: _isRecording
? Colors.red ? Colors.red
: Theme.of(context).colorScheme.onPrimaryContainer, : Theme.of(
context,
).colorScheme.primary,
), ),
), ),
const SizedBox(width: 4), const SizedBox(width: 8),
// Destination switcher button Expanded(
IconButton( child: Material(
icon: Icon(_getDestinationIcon()), color: Colors.transparent,
tooltip: _getDestinationTooltip(), child: InkWell(
onPressed: _showRecipientSelector, borderRadius: BorderRadius.circular(20),
style: IconButton.styleFrom( onTap: _showRecipientSelector,
backgroundColor: child: Ink(
_destinationType == height: 42,
MessageDestinationPreferences decoration: BoxDecoration(
.destinationTypeChannel 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( ? Theme.of(
context, context,
).colorScheme.surfaceContainerHighest ).colorScheme.primary
: Theme.of(context).colorScheme.secondaryContainer, : Theme.of(context).dividerColor
foregroundColor: .withValues(alpha: 0.35),
_destinationType == width: _focusNode.hasFocus ? 1.4 : 1,
MessageDestinationPreferences
.destinationTypeChannel
? Theme.of(context).colorScheme.onSurface
: Theme.of(context).colorScheme.onSecondaryContainer,
), ),
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,
), ),
const SizedBox(width: 4),
// Text field with embedded send button
Expanded(
child: TextField( child: TextField(
controller: _textController, controller: _textController,
focusNode: _focusNode, focusNode: _focusNode,
maxLength: _maxCharacters, minLines: 1,
maxLines: null, maxLines: 4,
maxLengthEnforcement: MaxLengthEnforcement.enforced, keyboardType: TextInputType.multiline,
style: const TextStyle(fontSize: 14), inputFormatters: [
_messageByteLimiter,
],
style: const TextStyle(fontSize: 15),
textAlignVertical:
TextAlignVertical.center,
decoration: InputDecoration( decoration: InputDecoration(
hintText: AppLocalizations.of(context)!.typeYourMessage, hintText: AppLocalizations.of(
hintStyle: const TextStyle(fontSize: 14),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(24),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 10,
),
isDense: true,
counterText: _characterCount >= 150
? '$_characterCount/$_maxCharacters'
: '',
counterStyle: TextStyle(
fontSize: 10,
color: _characterCount > _maxCharacters * 0.9
? Colors.orange
: Theme.of(context).textTheme.bodySmall?.color,
),
suffixIcon: GestureDetector(
onLongPressStart:
(_voiceSupported && !_isSendingVoice)
? (_) => _startVoiceRecording()
: null,
onLongPressEnd: (_voiceSupported && _isRecording)
? (_) => _stopAndSendVoice()
: null,
onLongPressCancel: (_voiceSupported && _isRecording)
? () => _stopAndSendVoice()
: null,
child: IconButton(
icon: _isSendingVoice
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: Icon(
_isRecording
? Icons.mic
: Icons.send_rounded,
size: 22,
color: _isRecording
? Colors.red
: (_textController.text.trim().isEmpty
? Theme.of(context).disabledColor
: Theme.of(
context, context,
).colorScheme.primary), )!.typeYourMessage,
hintStyle: TextStyle(
fontSize: 15,
color: Theme.of(context)
.colorScheme
.onSurfaceVariant
.withValues(alpha: 0.9),
), ),
onPressed: filled: false,
_isRecording || fillColor: Colors.transparent,
_isSendingVoice || border: InputBorder.none,
_textController.text.trim().isEmpty isCollapsed: true,
? null ),
: _sendMessage, textInputAction:
tooltip: _isRecording 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' ? 'Recording... release to send voice'
: (_isSendingVoice : (_isSendingVoice
? 'Sending voice...' ? 'Sending voice...'
: _voiceSupported : _voiceSupported
? 'Send (long press to record voice)' ? 'Send (long press to record voice)'
: 'Send'), : '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,
),
),
),
],
),
),
),
);
},
),
],
),
],
), ),
), ),
), ),
textInputAction: TextInputAction.send,
onSubmitted: (_) => _sendMessage(),
), ),
), ),
], ],
), ),
), ),
], ],
),
); );
}, },
); );

View File

@@ -403,25 +403,17 @@ class _SettingsScreenState extends State<SettingsScreen> {
channelCount: 2, channelCount: 2,
); );
final sarMessages = SampleDataGenerator.generateSarMarkerMessages( final sampleMessages = SampleDataGenerator.generateAllMessages(
centerLocation: centerLocation, centerLocation: centerLocation,
l10n: l10n, l10n: l10n,
foundPersonCount: 2, foundPersonCount: 2,
fireCount: 1, fireCount: 1,
stagingCount: 1, stagingCount: 1,
objectCount: 1, objectCount: 1,
);
final channelMessages = SampleDataGenerator.generateChannelMessages(
centerLocation: centerLocation,
l10n: l10n,
generalChannelMessages: 8, generalChannelMessages: 8,
emergencyChannelMessages: 5, emergencyChannelMessages: 5,
); );
// Combine all messages
final allMessages = [...sarMessages, ...channelMessages];
// Add to providers // Add to providers
final contactsProvider = Provider.of<ContactsProvider>( final contactsProvider = Provider.of<ContactsProvider>(
context, context,
@@ -433,7 +425,12 @@ class _SettingsScreenState extends State<SettingsScreen> {
); );
contactsProvider.addContacts(contacts); contactsProvider.addContacts(contacts);
messagesProvider.addMessages(allMessages); for (final message in sampleMessages.messages) {
messagesProvider.addMessage(
message,
contactLocationSnapshot: sampleMessages.contactLocations[message.id],
);
}
if (!mounted) return; if (!mounted) return;
@@ -446,8 +443,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
AppLocalizations.of(context)!.loadedSampleData( AppLocalizations.of(context)!.loadedSampleData(
teamCount, teamCount,
channelCount, channelCount,
sarMessages.length, sampleMessages.messages.where((m) => m.isSarMarker).length,
channelMessages.length, sampleMessages.messages.length,
), ),
), ),
backgroundColor: Colors.green, backgroundColor: Colors.green,

View File

@@ -0,0 +1,601 @@
import 'package:flutter/material.dart';
import 'package:meshcore_client/meshcore_client.dart';
import 'package:provider/provider.dart';
import '../models/device_info.dart';
import '../providers/connection_provider.dart';
import '../widgets/device/spectrum_scan_panel.dart';
class SpectrumScanScreen extends StatefulWidget {
const SpectrumScanScreen({super.key});
@override
State<SpectrumScanScreen> createState() => _SpectrumScanScreenState();
}
class _SpectrumScanScreenState extends State<SpectrumScanScreen> {
static const List<String> _bandwidthOptions = [
'7.8 kHz',
'10.4 kHz',
'15.6 kHz',
'20.8 kHz',
'31.25 kHz',
'41.7 kHz',
'62.5 kHz',
'125 kHz',
'250 kHz',
'500 kHz',
];
String _selectedBandwidth = '62.5 kHz';
bool _isSpectrumScanRunning = false;
bool _rangeInitialized = false;
String? _lastRangeSourceKey;
late double _scanRangeMinMhz;
late double _scanRangeMaxMhz;
late RangeValues _scanRangeValues;
List<SpectrumScanCandidate> _scanCandidates = const [];
int? _selectedScanFrequencyKhz;
int _completedScanSectors = 0;
int _totalScanSectors = 0;
List<SpectrumScanCandidate> get _recommendedScanCandidates =>
_scanCandidates.take(8).toList();
@override
void initState() {
super.initState();
final deviceInfo = _deviceInfo;
if (deviceInfo.radioBw != null &&
deviceInfo.radioBw! >= 0 &&
deviceInfo.radioBw! < _bandwidthOptions.length) {
_selectedBandwidth = _bandwidthOptions[deviceInfo.radioBw!];
}
_syncRangeFromDevice(deviceInfo);
_selectedScanFrequencyKhz = deviceInfo.radioFreq;
}
DeviceInfo get _deviceInfo => context.read<ConnectionProvider>().deviceInfo;
@override
void didChangeDependencies() {
super.didChangeDependencies();
_syncRangeFromDevice(context.watch<ConnectionProvider>().deviceInfo);
}
void _syncRangeFromDevice(DeviceInfo deviceInfo) {
final minKhz = deviceInfo.spectrumScanMinKhz;
final maxKhz = deviceInfo.spectrumScanMaxKhz;
late double normalizedMinMhz;
late double normalizedMaxMhz;
late String sourceKey;
if (minKhz != null && maxKhz != null && maxKhz > minKhz) {
final normalized = _normalizeScanRange(
minKhz / 1000.0,
maxKhz / 1000.0,
deviceInfo.radioFreq != null ? deviceInfo.radioFreq! / 1000.0 : null,
);
normalizedMinMhz = normalized.$1;
normalizedMaxMhz = normalized.$2;
sourceKey = 'fw:$minKhz:$maxKhz';
} else {
final normalized = _normalizeScanRange(
null,
null,
deviceInfo.radioFreq != null ? deviceInfo.radioFreq! / 1000.0 : null,
);
normalizedMinMhz = normalized.$1;
normalizedMaxMhz = normalized.$2;
sourceKey =
'fallback:${deviceInfo.radioFreq != null ? deviceInfo.radioFreq! ~/ 1000 : 869525}';
}
if (_rangeInitialized && _lastRangeSourceKey == sourceKey) {
return;
}
_scanRangeMinMhz = normalizedMinMhz;
_scanRangeMaxMhz = normalizedMaxMhz;
if (_scanRangeMaxMhz <= _scanRangeMinMhz) {
_scanRangeMaxMhz = _scanRangeMinMhz + 0.5;
}
_scanRangeValues = RangeValues(_scanRangeMinMhz, _scanRangeMaxMhz);
_rangeInitialized = true;
_lastRangeSourceKey = sourceKey;
}
(double, double) _normalizeScanRange(
double? minMhz,
double? maxMhz,
double? centerMhz,
) {
const hardMinMhz = 800.0;
const hardMaxMhz = 950.0;
final center = centerMhz ?? 869.525;
if (minMhz != null && maxMhz != null) {
final clampedMin = minMhz.clamp(hardMinMhz, hardMaxMhz);
final clampedMax = maxMhz.clamp(hardMinMhz, hardMaxMhz);
if (clampedMax > clampedMin) {
return (clampedMin, clampedMax);
}
}
if (center >= 900.0 && center <= 930.0) {
return (902.0, 928.0);
}
return (863.0, 870.0);
}
double _bandwidthToKhz(String bw) {
switch (bw) {
case '7.8 kHz':
return 7.8;
case '10.4 kHz':
return 10.4;
case '15.6 kHz':
return 15.6;
case '20.8 kHz':
return 20.8;
case '31.25 kHz':
return 31.25;
case '41.7 kHz':
return 41.7;
case '62.5 kHz':
return 62.5;
case '125 kHz':
return 125.0;
case '250 kHz':
return 250.0;
case '500 kHz':
return 500.0;
default:
return 62.5;
}
}
String _currentParamProfile(DeviceInfo deviceInfo) {
final sf = deviceInfo.radioSf ?? 8;
final cr = deviceInfo.radioCr ?? 8;
return 'BW $_selectedBandwidth | SF$sf | CR 4/$cr';
}
String _recommendationTitle(int index) {
switch (index) {
case 0:
return 'Best candidate';
case 1:
return 'Alternate';
case 2:
return 'Fallback';
default:
return 'Candidate ${index + 1}';
}
}
List<int> _possibleBracketFrequenciesKhz() {
final bandwidthKhz = _bandwidthToKhz(_selectedBandwidth);
final halfBandwidthKhz = bandwidthKhz / 2.0;
final startKhz = (_scanRangeValues.start * 1000).round();
final stopKhz = (_scanRangeValues.end * 1000).round();
final firstCenterKhz = (startKhz + halfBandwidthKhz).round();
final lastCenterKhz = (stopKhz - halfBandwidthKhz).round();
if (lastCenterKhz < firstCenterKhz) {
return const [];
}
final stepKhz = bandwidthKhz >= 125.0 ? bandwidthKhz.round() : 25;
final centers = <int>[];
for (
var centerKhz = firstCenterKhz;
centerKhz <= lastCenterKhz && centers.length < 8;
centerKhz += stepKhz
) {
centers.add(centerKhz);
}
if (centers.isEmpty || centers.last != lastCenterKhz) {
centers.add(lastCenterKhz);
}
return centers.toSet().toList()..sort();
}
void _resetDerivedScanResults() {
_scanCandidates = const [];
_selectedScanFrequencyKhz = null;
}
List<(int startKhz, int stopKhz)> _buildScanSectors(double bandwidthKhz) {
final startKhz = (_scanRangeValues.start * 1000).round();
final stopKhz = (_scanRangeValues.end * 1000).round();
final sectorWidthKhz = (bandwidthKhz * 24).round().clamp(250, 1200);
final overlapKhz = bandwidthKhz.round().clamp(8, 500);
final sectors = <(int startKhz, int stopKhz)>[];
var sectorStartKhz = startKhz;
while (sectorStartKhz < stopKhz) {
final sectorStopKhz = (sectorStartKhz + sectorWidthKhz).clamp(
sectorStartKhz + overlapKhz,
stopKhz,
);
sectors.add((sectorStartKhz, sectorStopKhz));
if (sectorStopKhz >= stopKhz) {
break;
}
sectorStartKhz = sectorStopKhz - overlapKhz;
}
return sectors;
}
List<SpectrumScanCandidate> _mergeSectorCandidates(
Iterable<SpectrumScanCandidate> candidates,
) {
final byFrequency = <int, SpectrumScanCandidate>{};
for (final candidate in candidates) {
final existing = byFrequency[candidate.centerFrequencyKhz];
if (existing == null ||
candidate.occupancyPercent < existing.occupancyPercent ||
(candidate.occupancyPercent == existing.occupancyPercent &&
candidate.peakRssiDbm < existing.peakRssiDbm) ||
(candidate.occupancyPercent == existing.occupancyPercent &&
candidate.peakRssiDbm == existing.peakRssiDbm &&
candidate.avgRssiDbm < existing.avgRssiDbm)) {
byFrequency[candidate.centerFrequencyKhz] = candidate;
}
}
final merged = byFrequency.values.toList()
..sort((a, b) {
final occupancyCompare = a.occupancyPercent.compareTo(
b.occupancyPercent,
);
if (occupancyCompare != 0) return occupancyCompare;
final peakCompare = a.peakRssiDbm.compareTo(b.peakRssiDbm);
if (peakCompare != 0) return peakCompare;
return a.avgRssiDbm.compareTo(b.avgRssiDbm);
});
return merged;
}
Future<void> _runSpectrumScan() async {
final connectionProvider = context.read<ConnectionProvider>();
final bandwidthKhz = _bandwidthToKhz(_selectedBandwidth);
final sectors = _buildScanSectors(bandwidthKhz);
final sectorCandidates = <SpectrumScanCandidate>[];
setState(() {
_isSpectrumScanRunning = true;
_resetDerivedScanResults();
_completedScanSectors = 0;
_totalScanSectors = sectors.length;
});
try {
for (var i = 0; i < sectors.length; i++) {
final sector = sectors[i];
final result = await connectionProvider.scanSpectrum(
startFrequencyKhz: sector.$1,
stopFrequencyKhz: sector.$2,
bandwidthKhz: bandwidthKhz.round(),
stepKhz: (bandwidthKhz / 2).round().clamp(1, 1000),
dwellMs: 160,
thresholdDb: 8,
);
if (result != null) {
sectorCandidates.addAll(result.candidates);
final mergedCandidates = _mergeSectorCandidates(sectorCandidates);
if (mounted) {
setState(() {
_scanCandidates = mergedCandidates;
if (_selectedScanFrequencyKhz == null &&
mergedCandidates.isNotEmpty) {
_selectedScanFrequencyKhz =
mergedCandidates.first.centerFrequencyKhz;
}
_completedScanSectors = i + 1;
});
}
} else if (mounted) {
setState(() {
_completedScanSectors = i + 1;
});
}
}
} finally {
if (mounted) {
setState(() {
_isSpectrumScanRunning = false;
if (_completedScanSectors == 0) {
_totalScanSectors = sectors.length;
}
});
}
}
if (!mounted) return;
final mergedCandidates = _mergeSectorCandidates(sectorCandidates);
if (mergedCandidates.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Spectrum scan returned no candidate frequencies'),
backgroundColor: Colors.red,
),
);
return;
}
setState(() {
_scanCandidates = mergedCandidates;
_selectedScanFrequencyKhz = mergedCandidates.first.centerFrequencyKhz;
});
}
Future<void> _applySelectedScanFrequency() async {
if (_selectedScanFrequencyKhz == null) return;
final connectionProvider = context.read<ConnectionProvider>();
final deviceInfo = connectionProvider.deviceInfo;
await connectionProvider.setRadioParams(
frequency: _selectedScanFrequencyKhz!,
bandwidth: _bandwidthOptions.indexOf(_selectedBandwidth),
spreadingFactor: deviceInfo.radioSf ?? 8,
codingRate: deviceInfo.radioCr ?? 8,
repeat: deviceInfo.clientRepeat,
);
await connectionProvider.refreshDeviceInfo();
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Applied ${(_selectedScanFrequencyKhz! / 1000.0).toStringAsFixed(3)} MHz',
),
backgroundColor: Colors.green,
),
);
}
int? _currentPreviewFrequencyKhz(DeviceInfo deviceInfo) {
if (_selectedScanFrequencyKhz != null) {
return _selectedScanFrequencyKhz;
}
return deviceInfo.radioFreq;
}
@override
Widget build(BuildContext context) {
final deviceInfo = context.watch<ConnectionProvider>().deviceInfo;
final theme = Theme.of(context);
final possibleBracketFrequencies = _possibleBracketFrequenciesKhz();
final recommendedScanCandidates = _recommendedScanCandidates;
final recommendationFrequencies = recommendedScanCandidates.isNotEmpty
? recommendedScanCandidates
.map((candidate) => candidate.centerFrequencyKhz)
.toList()
: possibleBracketFrequencies;
return Scaffold(
appBar: AppBar(title: const Text('Spectrum Scan')),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
DropdownButtonFormField<String>(
initialValue: _selectedBandwidth,
decoration: const InputDecoration(
labelText: 'Bandwidth',
border: OutlineInputBorder(),
helperText:
'Scan and apply frequencies for this bandwidth',
),
items: _bandwidthOptions.map((value) {
return DropdownMenuItem<String>(
value: value,
child: Text(value),
);
}).toList(),
onChanged: (value) {
if (value == null) return;
setState(() {
_selectedBandwidth = value;
_resetDerivedScanResults();
});
},
),
const SizedBox(height: 16),
Text(
deviceInfo.spectrumScanMinKhz != null &&
deviceInfo.spectrumScanMaxKhz != null
? 'Firmware scan range: ${_scanRangeMinMhz.toStringAsFixed(3)}-${_scanRangeMaxMhz.toStringAsFixed(3)} MHz'
: 'Fallback scan range selected from current band. MeshCore commonly uses EU868 or US915.',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
if (_isSpectrumScanRunning) ...[
const SizedBox(height: 8),
Text(
'Scanning sector ${_completedScanSectors + 1} of $_totalScanSectors. Results update as each sector completes.',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.primary,
fontWeight: FontWeight.w600,
),
),
],
const SizedBox(height: 12),
SpectrumScanPanel(
theme: theme,
scanSupported: deviceInfo.supportsSpectrumScan == true,
isRunning: _isSpectrumScanRunning,
rangeMinMhz: _scanRangeMinMhz,
rangeMaxMhz: _scanRangeMaxMhz,
rangeValues: _scanRangeValues,
bandwidthKhz: _bandwidthToKhz(_selectedBandwidth),
selectedFrequencyKhz: _currentPreviewFrequencyKhz(
deviceInfo,
),
graphCandidates: _scanCandidates,
selectableCandidates: recommendedScanCandidates,
onRangeChanged: (values) {
setState(() {
_scanRangeValues = values;
_resetDerivedScanResults();
});
},
onCandidateChanged: (value) {
setState(() {
_selectedScanFrequencyKhz = value;
});
},
onRunScan: _runSpectrumScan,
onApplySelected: _applySelectedScanFrequency,
),
if (recommendationFrequencies.isNotEmpty) ...[
const SizedBox(height: 18),
Text(
_scanCandidates.isNotEmpty
? 'Recommended profiles'
: 'Possible brackets',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 8),
Text(
_scanCandidates.isNotEmpty
? 'Suggested frequencies from the latest scan with the radio parameters to keep alongside them.'
: 'Usable frequency brackets derived from the selected span and bandwidth, even without live scan data.',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 12),
for (
var i = 0;
i < recommendationFrequencies.length;
i++
) ...[
_RecommendationTile(
title: _recommendationTitle(i),
frequencyKhz: recommendationFrequencies[i],
candidate: recommendedScanCandidates.isNotEmpty
? recommendedScanCandidates[i]
: null,
bandwidthKhz: _bandwidthToKhz(_selectedBandwidth),
paramsLabel: _currentParamProfile(deviceInfo),
isSelected:
_selectedScanFrequencyKhz ==
recommendationFrequencies[i],
onSelect: () {
setState(() {
_selectedScanFrequencyKhz =
recommendationFrequencies[i];
});
},
),
if (i != recommendationFrequencies.length - 1)
const SizedBox(height: 10),
],
],
],
),
),
),
],
),
);
}
}
class _RecommendationTile extends StatelessWidget {
final String title;
final int frequencyKhz;
final SpectrumScanCandidate? candidate;
final double bandwidthKhz;
final String paramsLabel;
final bool isSelected;
final VoidCallback onSelect;
const _RecommendationTile({
required this.title,
required this.frequencyKhz,
required this.candidate,
required this.bandwidthKhz,
required this.paramsLabel,
required this.isSelected,
required this.onSelect,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final scheme = theme.colorScheme;
return InkWell(
onTap: onSelect,
borderRadius: BorderRadius.circular(14),
child: Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: isSelected
? scheme.primaryContainer.withValues(alpha: 0.55)
: scheme.surfaceContainerHighest.withValues(alpha: 0.35),
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: isSelected ? scheme.primary : scheme.outlineVariant,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
title,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
),
),
),
if (isSelected)
Icon(Icons.check_circle, color: scheme.primary, size: 18),
],
),
const SizedBox(height: 8),
Text(
'${(frequencyKhz / 1000.0).toStringAsFixed(3)} MHz',
style: theme.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 6),
Text(paramsLabel, style: theme.textTheme.bodyMedium),
const SizedBox(height: 6),
Text(
candidate != null
? 'Occupancy ${candidate!.occupancyPercent}% | Avg ${candidate!.avgRssiDbm} dBm | Peak ${candidate!.peakRssiDbm} dBm'
: 'Bracket ${(frequencyKhz / 1000.0 - bandwidthKhz / 2000.0).toStringAsFixed(3)}-${(frequencyKhz / 1000.0 + bandwidthKhz / 2000.0).toStringAsFixed(3)} MHz',
style: theme.textTheme.bodySmall?.copyWith(
color: scheme.onSurfaceVariant,
),
),
],
),
),
);
}
}

View File

@@ -25,6 +25,14 @@ class CayenneLppParser {
int fieldCount = 0; int fieldCount = 0;
while (reader.hasRemaining) { while (reader.hasRemaining) {
if (fieldCount > 0 && _isZeroPaddedTail(data, reader.remainingBytesCount)) {
debugPrint(
' Detected zero-padded telemetry tail, stopping parse at position '
'${data.length - reader.remainingBytesCount}',
);
break;
}
try { try {
fieldCount++; fieldCount++;
debugPrint( debugPrint(
@@ -250,6 +258,14 @@ class CayenneLppParser {
return ((voltage - 3.0) / 1.2) * 100.0; return ((voltage - 3.0) / 1.2) * 100.0;
} }
static bool _isZeroPaddedTail(Uint8List data, int remainingBytes) {
final start = data.length - remainingBytes;
for (int i = start; i < data.length; i++) {
if (data[i] != 0) return false;
}
return remainingBytes > 0;
}
/// Create Cayenne LPP data for GPS location /// Create Cayenne LPP data for GPS location
/// Standard Cayenne LPP GPS format (type 0x88): /// Standard Cayenne LPP GPS format (type 0x88):
/// - Latitude: 3 bytes, signed 24-bit, big-endian, × 10000 /// - Latitude: 3 bytes, signed 24-bit, big-endian, × 10000

View File

@@ -64,6 +64,8 @@ class LocalePreferences {
return 'Italiano'; return 'Italiano';
case 'el': case 'el':
return 'Greek'; return 'Greek';
case 'cn':
return '简体中文';
default: default:
return locale.languageCode; return locale.languageCode;
} }
@@ -88,6 +90,8 @@ class LocalePreferences {
return 'Italiano'; return 'Italiano';
case 'el': case 'el':
return 'Ελληνικά'; return 'Ελληνικά';
case 'cn':
return '简体中文';
default: default:
return locale.languageCode; return locale.languageCode;
} }

View File

@@ -31,17 +31,22 @@ class MeshMapNode {
} }
class MeshMapNodesService { class MeshMapNodesService {
static const String _nodesEndpoint = 'https://api.meshcore.nz/api/v1/map/nodes'; static const String _nodesEndpoint =
'https://api.meshcore.nz/api/v1/map/nodes';
static const Duration _cacheTtl = Duration(minutes: 2); static const Duration _cacheTtl = Duration(minutes: 2);
static const Duration traceCacheTtl = Duration(minutes: 10);
static List<MeshMapNode>? _cachedNodes; static List<MeshMapNode>? _cachedNodes;
static DateTime? _cachedAt; static DateTime? _cachedAt;
static Future<List<MeshMapNode>> fetchNodes({bool forceRefresh = false}) async { static Future<List<MeshMapNode>> fetchNodes({
bool forceRefresh = false,
Duration cacheTtl = _cacheTtl,
}) async {
final now = DateTime.now(); final now = DateTime.now();
if (!forceRefresh && if (!forceRefresh &&
_cachedNodes != null && _cachedNodes != null &&
_cachedAt != null && _cachedAt != null &&
now.difference(_cachedAt!) < _cacheTtl) { now.difference(_cachedAt!) < cacheTtl) {
return _cachedNodes!; return _cachedNodes!;
} }
@@ -58,7 +63,9 @@ class MeshMapNodesService {
final nodes = nodesRaw final nodes = nodesRaw
.whereType<Map<String, dynamic>>() .whereType<Map<String, dynamic>>()
.map(MeshMapNode.fromJson) .map(MeshMapNode.fromJson)
.where((n) => n.publicKey.isNotEmpty && n.latitude != 0 && n.longitude != 0) .where(
(n) => n.publicKey.isNotEmpty && n.latitude != 0 && n.longitude != 0,
)
.toList(); .toList();
_cachedNodes = nodes; _cachedNodes = nodes;

View File

@@ -2,15 +2,21 @@ import 'dart:convert';
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 '../models/message.dart'; import '../models/message.dart';
import '../models/message_contact_location.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
class MessageStorageService { class MessageStorageService {
static const String _messagesKey = 'stored_messages'; static const String _messagesKey = 'stored_messages';
static const String _messageContactLocationsKey =
'stored_message_contact_locations';
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(List<Message> messages) async { Future<void> saveMessages(
List<Message> messages, {
Map<String, MessageContactLocation> messageContactLocations = const {},
}) async {
try { try {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
@@ -24,6 +30,19 @@ class MessageStorageService {
final jsonString = jsonEncode(limitedList); final jsonString = jsonEncode(limitedList);
await prefs.setString(_messagesKey, jsonString); await prefs.setString(_messagesKey, jsonString);
final retainedMessageIds = limitedList
.map((entry) => entry['id'] as String)
.toSet();
final locationJson = <String, dynamic>{};
for (final entry in messageContactLocations.entries) {
if (retainedMessageIds.contains(entry.key)) {
locationJson[entry.key] = entry.value.toJson();
}
}
await prefs.setString(
_messageContactLocationsKey,
jsonEncode(locationJson),
);
debugPrint( debugPrint(
'✅ [MessageStorage] Saved ${limitedList.length} messages to storage', '✅ [MessageStorage] Saved ${limitedList.length} messages to storage',
@@ -33,6 +52,36 @@ class MessageStorageService {
} }
} }
Future<Map<String, MessageContactLocation>> loadMessageContactLocations()
async {
try {
final prefs = await SharedPreferences.getInstance();
final jsonString = prefs.getString(_messageContactLocationsKey);
if (jsonString == null || jsonString.isEmpty) {
return const {};
}
final decoded = jsonDecode(jsonString);
if (decoded is! Map<String, dynamic>) {
return const {};
}
final result = <String, MessageContactLocation>{};
for (final entry in decoded.entries) {
final value = entry.value;
if (value is! Map<String, dynamic>) continue;
final snapshot = MessageContactLocation.fromJson(value);
if (snapshot != null) {
result[entry.key] = snapshot;
}
}
return result;
} catch (e) {
debugPrint('❌ [MessageStorage] Error loading contact locations: $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 {
@@ -66,6 +115,7 @@ class MessageStorageService {
try { try {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
await prefs.remove(_messagesKey); await prefs.remove(_messagesKey);
await prefs.remove(_messageContactLocationsKey);
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');

View File

@@ -0,0 +1,32 @@
class AvatarLabelHelper {
static String buildLabel(String name) {
final trimmed = name.trim();
if (trimmed.isEmpty) return '?';
if (trimmed.startsWith('#')) {
final hashBody = trimmed.substring(1).replaceAll(RegExp(r'[\s_-]+'), '');
if (hashBody.isEmpty) {
return '#';
}
return '#${_take(hashBody, 2)}'.toUpperCase();
}
final parts = trimmed
.split(RegExp(r'[\s_-]+'))
.where((part) => part.isNotEmpty)
.toList();
if (parts.length >= 2) {
final first = _take(parts[0], 1);
final second = _take(parts[1], 1);
return '$first$second'.toUpperCase();
}
return _take(trimmed, 2).toUpperCase();
}
static String _take(String value, int count) {
if (value.length <= count) return value;
return value.substring(0, count);
}
}

View File

@@ -125,7 +125,10 @@ int safeImageDataBytesForPath(int pathLen) {
? maxRawPayloadFromCommandFrame ? maxRawPayloadFromCommandFrame
: maxRawPayloadFromMesh; : maxRawPayloadFromMesh;
final maxData = maxRawPayload - ImagePacket._headerLen; final maxData = maxRawPayload - ImagePacket._headerLen;
return maxData.clamp(1, 255).toInt(); // Keep a safety margin below the theoretical direct-route ceiling.
// Fragments at the absolute 172-byte command-frame limit have proven flaky
// in practice, so cap to the conservative protocol default.
return maxData.clamp(1, ImagePacket.maxDataBytes).toInt();
} }
/// Approximate end-to-end transmit time for image fragments on MeshCore LoRa. /// Approximate end-to-end transmit time for image fragments on MeshCore LoRa.
@@ -361,9 +364,7 @@ class ImageFetchRequest {
final ts = _parseInt(parts[3], base36: true); final ts = _parseInt(parts[3], base36: true);
final normalizedWant = wantToken == 'a' final normalizedWant = wantToken == 'a'
? 'all' ? 'all'
: ((wantToken.startsWith('m')) : ((wantToken.startsWith('m')) ? 'missing' : wantToken);
? 'missing'
: wantToken);
if (sid == null) return null; if (sid == null) return null;
final missingIndices = <int>[]; final missingIndices = <int>[];
@@ -532,7 +533,11 @@ String _toBase36(int value) => value.toRadixString(36);
String _encodeSessionId(String sessionIdHex) { String _encodeSessionId(String sessionIdHex) {
if (!RegExp(r'^[0-9a-fA-F]{8}$').hasMatch(sessionIdHex)) { if (!RegExp(r'^[0-9a-fA-F]{8}$').hasMatch(sessionIdHex)) {
throw ArgumentError.value(sessionIdHex, 'sessionIdHex', 'Expected 8 hex chars'); throw ArgumentError.value(
sessionIdHex,
'sessionIdHex',
'Expected 8 hex chars',
);
} }
final value = int.parse(sessionIdHex, radix: 16); final value = int.parse(sessionIdHex, radix: 16);
return value.toRadixString(36); return value.toRadixString(36);
@@ -546,10 +551,7 @@ String? _decodeSessionId(String token) {
} }
String _encodeMissingIndicesCompact(List<int> indices) { String _encodeMissingIndicesCompact(List<int> indices) {
final sorted = indices final sorted = indices.where((v) => v >= 0 && v <= 254).toSet().toList()
.where((v) => v >= 0 && v <= 254)
.toSet()
.toList()
..sort(); ..sort();
if (sorted.isEmpty) return ''; if (sorted.isEmpty) return '';
final chunks = <String>[]; final chunks = <String>[];
@@ -562,7 +564,9 @@ String _encodeMissingIndicesCompact(List<int> indices) {
continue; continue;
} }
chunks.add( chunks.add(
start == prev ? _toBase36(start) : '${_toBase36(start)}-${_toBase36(prev)}', start == prev
? _toBase36(start)
: '${_toBase36(start)}-${_toBase36(prev)}',
); );
start = curr; start = curr;
prev = curr; prev = curr;

View File

@@ -24,15 +24,30 @@ extension MessageLocalization on Message {
switch (deliveryStatus) { switch (deliveryStatus) {
case MessageDeliveryStatus.sending: case MessageDeliveryStatus.sending:
if (isContactMessage) {
if (retryAttempt > 0) {
return '${l10n.pending}${l10n.retryAttempt} $retryAttempt/3';
}
return l10n.pending;
}
return l10n.sending; return l10n.sending;
case MessageDeliveryStatus.sent: case MessageDeliveryStatus.sent:
return l10n.sent; return l10n.sent;
case MessageDeliveryStatus.delivered: case MessageDeliveryStatus.delivered:
if (retryAttempt > 0 && roundTripTimeMs != null) {
return '${l10n.deliveredWithTime(roundTripTimeMs!)}${l10n.retryAttempt} $retryAttempt/3';
}
if (retryAttempt > 0) {
return '${l10n.delivered}${l10n.retryAttempt} $retryAttempt/3';
}
if (roundTripTimeMs != null) { if (roundTripTimeMs != null) {
return l10n.deliveredWithTime(roundTripTimeMs!); return l10n.deliveredWithTime(roundTripTimeMs!);
} }
return l10n.delivered; return l10n.delivered;
case MessageDeliveryStatus.failed: case MessageDeliveryStatus.failed:
if (retryAttempt > 0) {
return '${l10n.failed}${l10n.retryAttempt} $retryAttempt/3';
}
return l10n.failed; return l10n.failed;
case MessageDeliveryStatus.received: case MessageDeliveryStatus.received:
return ''; return '';

View File

@@ -4,12 +4,66 @@ import 'package:flutter/material.dart';
import 'package:latlong2/latlong.dart'; import 'package:latlong2/latlong.dart';
import '../models/contact.dart'; import '../models/contact.dart';
import '../models/message.dart'; import '../models/message.dart';
import '../models/message_contact_location.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
/// Generates sample data for testing/demo purposes /// Generates sample data for testing/demo purposes
class SampleDataGenerator { class SampleDataGenerator {
static final Random _random = Random(); static final Random _random = Random();
static MessageContactLocation _sampleSnapshot({
required LatLng location,
required DateTime receivedAt,
required String source,
}) {
return MessageContactLocation(
location: location,
source: source,
capturedAt: receivedAt,
sourceTimestamp: receivedAt.subtract(const Duration(minutes: 2)),
);
}
static LatLng _randomNearbyLocation(LatLng center, double spread) {
final latOffset = (_random.nextDouble() - 0.5) * spread;
final lonOffset = (_random.nextDouble() - 0.5) * spread;
return LatLng(center.latitude + latOffset, center.longitude + lonOffset);
}
static SampleMessageBatch generateAllMessages({
required LatLng centerLocation,
required AppLocalizations l10n,
int foundPersonCount = 2,
int fireCount = 1,
int stagingCount = 1,
int objectCount = 1,
int generalChannelMessages = 8,
int emergencyChannelMessages = 5,
}) {
final sarBatch = generateSarMarkerMessages(
centerLocation: centerLocation,
l10n: l10n,
foundPersonCount: foundPersonCount,
fireCount: fireCount,
stagingCount: stagingCount,
objectCount: objectCount,
);
final channelBatch = generateChannelMessages(
centerLocation: centerLocation,
l10n: l10n,
generalChannelMessages: generalChannelMessages,
emergencyChannelMessages: emergencyChannelMessages,
);
return SampleMessageBatch(
messages: [...sarBatch.messages, ...channelBatch.messages],
contactLocations: {
...sarBatch.contactLocations,
...channelBatch.contactLocations,
},
);
}
/// Generate sample contacts around a center location /// Generate sample contacts around a center location
static List<Contact> generateContacts({ static List<Contact> generateContacts({
required LatLng centerLocation, required LatLng centerLocation,
@@ -113,7 +167,7 @@ class SampleDataGenerator {
} }
/// Generate sample SAR markers around a center location /// Generate sample SAR markers around a center location
static List<Message> generateSarMarkerMessages({ static SampleMessageBatch generateSarMarkerMessages({
required LatLng centerLocation, required LatLng centerLocation,
required AppLocalizations l10n, required AppLocalizations l10n,
int foundPersonCount = 2, int foundPersonCount = 2,
@@ -122,6 +176,7 @@ class SampleDataGenerator {
int objectCount = 1, int objectCount = 1,
}) { }) {
final messages = <Message>[]; final messages = <Message>[];
final contactLocations = <String, MessageContactLocation>{};
final now = DateTime.now(); final now = DateTime.now();
int messageId = 1; int messageId = 1;
@@ -137,7 +192,7 @@ class SampleDataGenerator {
); );
final timestamp = now.subtract(Duration(minutes: 10 + i * 5)); final timestamp = now.subtract(Duration(minutes: 10 + i * 5));
messages.add(Message( final message = Message(
id: 'sample_fp_$messageId', id: 'sample_fp_$messageId',
messageType: MessageType.contact, messageType: MessageType.contact,
senderPublicKeyPrefix: senderKey.sublist(0, 6), senderPublicKeyPrefix: senderKey.sublist(0, 6),
@@ -150,7 +205,13 @@ class SampleDataGenerator {
sarGpsCoordinates: LatLng(lat, lon), sarGpsCoordinates: LatLng(lat, lon),
sarCustomEmoji: '🧑', sarCustomEmoji: '🧑',
senderName: l10n.sampleTeamMember, senderName: l10n.sampleTeamMember,
)); );
messages.add(message);
contactLocations[message.id] = _sampleSnapshot(
location: _randomNearbyLocation(message.sarGpsCoordinates!, 0.006),
receivedAt: timestamp,
source: 'telemetry',
);
messageId++; messageId++;
} }
@@ -166,7 +227,7 @@ class SampleDataGenerator {
); );
final timestamp = now.subtract(Duration(minutes: 20 + i * 5)); final timestamp = now.subtract(Duration(minutes: 20 + i * 5));
messages.add(Message( final message = Message(
id: 'sample_fire_$messageId', id: 'sample_fire_$messageId',
messageType: MessageType.contact, messageType: MessageType.contact,
senderPublicKeyPrefix: senderKey.sublist(0, 6), senderPublicKeyPrefix: senderKey.sublist(0, 6),
@@ -179,7 +240,13 @@ class SampleDataGenerator {
sarGpsCoordinates: LatLng(lat, lon), sarGpsCoordinates: LatLng(lat, lon),
sarCustomEmoji: '🔥', sarCustomEmoji: '🔥',
senderName: l10n.sampleScout, senderName: l10n.sampleScout,
)); );
messages.add(message);
contactLocations[message.id] = _sampleSnapshot(
location: _randomNearbyLocation(message.sarGpsCoordinates!, 0.008),
receivedAt: timestamp,
source: 'advert',
);
messageId++; messageId++;
} }
@@ -195,7 +262,7 @@ class SampleDataGenerator {
); );
final timestamp = now.subtract(Duration(minutes: 30 + i * 5)); final timestamp = now.subtract(Duration(minutes: 30 + i * 5));
messages.add(Message( final message = Message(
id: 'sample_staging_$messageId', id: 'sample_staging_$messageId',
messageType: MessageType.contact, messageType: MessageType.contact,
senderPublicKeyPrefix: senderKey.sublist(0, 6), senderPublicKeyPrefix: senderKey.sublist(0, 6),
@@ -208,7 +275,13 @@ class SampleDataGenerator {
sarGpsCoordinates: LatLng(lat, lon), sarGpsCoordinates: LatLng(lat, lon),
sarCustomEmoji: '🏕️', sarCustomEmoji: '🏕️',
senderName: l10n.sampleBase, senderName: l10n.sampleBase,
)); );
messages.add(message);
contactLocations[message.id] = _sampleSnapshot(
location: _randomNearbyLocation(message.sarGpsCoordinates!, 0.01),
receivedAt: timestamp,
source: 'advert',
);
messageId++; messageId++;
} }
@@ -231,24 +304,34 @@ class SampleDataGenerator {
l10n.sampleObjectTrailMarker, l10n.sampleObjectTrailMarker,
]; ];
messages.add(Message( final message = Message(
id: 'sample_object_$messageId', id: 'sample_object_$messageId',
messageType: MessageType.contact, messageType: MessageType.contact,
senderPublicKeyPrefix: senderKey.sublist(0, 6), senderPublicKeyPrefix: senderKey.sublist(0, 6),
pathLen: 1, pathLen: 1,
textType: MessageTextType.plain, textType: MessageTextType.plain,
senderTimestamp: timestamp.millisecondsSinceEpoch ~/ 1000, senderTimestamp: timestamp.millisecondsSinceEpoch ~/ 1000,
text: 'S:📦:${lat.toStringAsFixed(5)},${lon.toStringAsFixed(5)}${notes[i % notes.length]}', text:
'S:📦:${lat.toStringAsFixed(5)},${lon.toStringAsFixed(5)}${notes[i % notes.length]}',
receivedAt: timestamp, receivedAt: timestamp,
isSarMarker: true, isSarMarker: true,
sarGpsCoordinates: LatLng(lat, lon), sarGpsCoordinates: LatLng(lat, lon),
sarCustomEmoji: '📦', sarCustomEmoji: '📦',
senderName: l10n.sampleSearcher, senderName: l10n.sampleSearcher,
)); );
messages.add(message);
contactLocations[message.id] = _sampleSnapshot(
location: _randomNearbyLocation(message.sarGpsCoordinates!, 0.007),
receivedAt: timestamp,
source: 'telemetry',
);
messageId++; messageId++;
} }
return messages; return SampleMessageBatch(
messages: messages,
contactLocations: contactLocations,
);
} }
/// Generate sample map drawings /// Generate sample map drawings
@@ -272,7 +355,9 @@ class SampleDataGenerator {
'id': 'sample_line_${now.millisecondsSinceEpoch}', 'id': 'sample_line_${now.millisecondsSinceEpoch}',
'color': Colors.blue.toARGB32(), 'color': Colors.blue.toARGB32(),
'createdAt': now.subtract(const Duration(minutes: 15)).toIso8601String(), 'createdAt': now.subtract(const Duration(minutes: 15)).toIso8601String(),
'points': linePoints.map((p) => {'lat': p.latitude, 'lon': p.longitude}).toList(), 'points': linePoints
.map((p) => {'lat': p.latitude, 'lon': p.longitude})
.toList(),
'sender': l10n.sampleTeamMember, 'sender': l10n.sampleTeamMember,
}); });
@@ -297,7 +382,7 @@ class SampleDataGenerator {
} }
/// Generate sample channel messages for public channels /// Generate sample channel messages for public channels
static List<Message> generateChannelMessages({ static SampleMessageBatch generateChannelMessages({
LatLng? centerLocation, LatLng? centerLocation,
required AppLocalizations l10n, required AppLocalizations l10n,
int generalChannelMessages = 8, int generalChannelMessages = 8,
@@ -306,6 +391,7 @@ class SampleDataGenerator {
// Use provided location or default to Ljubljana, Slovenia // Use provided location or default to Ljubljana, Slovenia
final center = centerLocation ?? const LatLng(46.0569, 14.5058); final center = centerLocation ?? const LatLng(46.0569, 14.5058);
final messages = <Message>[]; final messages = <Message>[];
final contactLocations = <String, MessageContactLocation>{};
final now = DateTime.now(); final now = DateTime.now();
int messageId = 1000; // Start with high ID to avoid conflicts int messageId = 1000; // Start with high ID to avoid conflicts
@@ -352,7 +438,11 @@ class SampleDataGenerator {
]; ];
// Generate General channel messages // Generate General channel messages
for (int i = 0; i < generalChannelMessages && i < generalMessages.length; i++) { for (
int i = 0;
i < generalChannelMessages && i < generalMessages.length;
i++
) {
final senderKey = Uint8List.fromList( final senderKey = Uint8List.fromList(
List.generate(32, (_) => _random.nextInt(256)), List.generate(32, (_) => _random.nextInt(256)),
); );
@@ -361,7 +451,7 @@ class SampleDataGenerator {
final minutesAgo = 120 - (i * 15) - _random.nextInt(10); final minutesAgo = 120 - (i * 15) - _random.nextInt(10);
final timestamp = now.subtract(Duration(minutes: minutesAgo)); final timestamp = now.subtract(Duration(minutes: minutesAgo));
messages.add(Message( final message = Message(
id: 'sample_general_$messageId', id: 'sample_general_$messageId',
messageType: MessageType.channel, messageType: MessageType.channel,
channelIdx: 0, // General channel channelIdx: 0, // General channel
@@ -372,12 +462,22 @@ class SampleDataGenerator {
text: generalMessages[i], text: generalMessages[i],
receivedAt: timestamp, receivedAt: timestamp,
senderName: teamNames[_random.nextInt(teamNames.length)], senderName: teamNames[_random.nextInt(teamNames.length)],
)); );
messages.add(message);
contactLocations[message.id] = _sampleSnapshot(
location: _randomNearbyLocation(center, 0.018),
receivedAt: timestamp,
source: i.isEven ? 'telemetry' : 'advert',
);
messageId++; messageId++;
} }
// Generate Emergency channel messages // Generate Emergency channel messages
for (int i = 0; i < emergencyChannelMessages && i < emergencyMessages.length; i++) { for (
int i = 0;
i < emergencyChannelMessages && i < emergencyMessages.length;
i++
) {
final senderKey = Uint8List.fromList( final senderKey = Uint8List.fromList(
List.generate(32, (_) => _random.nextInt(256)), List.generate(32, (_) => _random.nextInt(256)),
); );
@@ -386,7 +486,7 @@ class SampleDataGenerator {
final minutesAgo = 60 - (i * 10) - _random.nextInt(5); final minutesAgo = 60 - (i * 10) - _random.nextInt(5);
final timestamp = now.subtract(Duration(minutes: minutesAgo)); final timestamp = now.subtract(Duration(minutes: minutesAgo));
messages.add(Message( final message = Message(
id: 'sample_emergency_$messageId', id: 'sample_emergency_$messageId',
messageType: MessageType.channel, messageType: MessageType.channel,
channelIdx: 1, // Emergency channel channelIdx: 1, // Emergency channel
@@ -397,10 +497,29 @@ class SampleDataGenerator {
text: emergencyMessages[i], text: emergencyMessages[i],
receivedAt: timestamp, receivedAt: timestamp,
senderName: teamNames[_random.nextInt(teamNames.length)], senderName: teamNames[_random.nextInt(teamNames.length)],
)); );
messages.add(message);
contactLocations[message.id] = _sampleSnapshot(
location: _randomNearbyLocation(center, 0.012),
receivedAt: timestamp,
source: i.isEven ? 'advert' : 'telemetry',
);
messageId++; messageId++;
} }
return messages; return SampleMessageBatch(
messages: messages,
contactLocations: contactLocations,
);
} }
} }
class SampleMessageBatch {
final List<Message> messages;
final Map<String, MessageContactLocation> contactLocations;
const SampleMessageBatch({
required this.messages,
required this.contactLocations,
});
}

View File

@@ -0,0 +1,129 @@
import 'package:flutter/material.dart';
import '../../models/contact.dart';
import '../../services/trail_color_service.dart';
import '../../utils/avatar_label_helper.dart';
class ContactAvatar extends StatelessWidget {
final Contact contact;
final double radius;
final String? displayName;
const ContactAvatar({
super.key,
required this.contact,
this.radius = 20,
this.displayName,
});
@override
Widget build(BuildContext context) {
final backgroundColor = _getBackgroundColor(context);
final foregroundColor = _getForegroundColor(backgroundColor);
final emoji = contact.roleEmoji;
if (emoji != null && emoji.isNotEmpty) {
return _buildAvatarFrame(
backgroundColor: backgroundColor,
child: Text(emoji, style: TextStyle(fontSize: radius * 1.05)),
);
}
if (_shouldUseLabelFallback) {
return _buildAvatarFrame(
backgroundColor: backgroundColor,
child: Text(
AvatarLabelHelper.buildLabel(displayName ?? contact.displayName),
style: TextStyle(
color: foregroundColor,
fontSize: radius * 0.68,
fontWeight: FontWeight.w700,
letterSpacing: -0.4,
),
),
);
}
return _buildAvatarFrame(
backgroundColor: backgroundColor,
child: Icon(
_getTypeIcon(contact.type),
color: foregroundColor,
size: radius,
),
);
}
Widget _buildAvatarFrame({
required Color backgroundColor,
required Widget child,
}) {
if (_usesSquareShape) {
return Container(
width: radius * 2,
height: radius * 2,
decoration: BoxDecoration(
color: backgroundColor,
borderRadius: BorderRadius.circular(radius * 0.6),
),
alignment: Alignment.center,
child: child,
);
}
return CircleAvatar(
radius: radius,
backgroundColor: backgroundColor,
child: child,
);
}
bool get _shouldUseLabelFallback =>
contact.type == ContactType.chat ||
contact.type == ContactType.channel ||
contact.type == ContactType.room;
bool get _usesSquareShape =>
contact.type == ContactType.channel || contact.type == ContactType.room;
Color _getBackgroundColor(BuildContext context) {
if (_shouldUseLabelFallback || (contact.roleEmoji?.isNotEmpty ?? false)) {
return TrailColorService.getTrailColor(contact);
}
switch (contact.type) {
case ContactType.none:
return Theme.of(context).colorScheme.surfaceContainerHighest;
case ContactType.chat:
return Colors.blue;
case ContactType.repeater:
return Colors.orange;
case ContactType.room:
return Colors.purple;
case ContactType.channel:
return Colors.teal;
}
}
Color _getForegroundColor(Color backgroundColor) {
return ThemeData.estimateBrightnessForColor(backgroundColor) ==
Brightness.dark
? Colors.white
: Colors.black87;
}
IconData _getTypeIcon(ContactType type) {
switch (type) {
case ContactType.none:
return Icons.help_outline;
case ContactType.chat:
return Icons.person;
case ContactType.repeater:
return Icons.router;
case ContactType.room:
return Icons.meeting_room;
case ContactType.channel:
return Icons.public;
}
}
}

View File

@@ -16,6 +16,7 @@ class ConnectionDialog extends StatefulWidget {
class _ConnectionDialogState extends State<ConnectionDialog> class _ConnectionDialogState extends State<ConnectionDialog>
with SingleTickerProviderStateMixin { with SingleTickerProviderStateMixin {
late TabController _tabController; late TabController _tabController;
late final ConnectionProvider _connectionProvider;
final NetworkScannerService _networkScanner = NetworkScannerService(); final NetworkScannerService _networkScanner = NetworkScannerService();
final List<DiscoveredServer> _discoveredServers = []; final List<DiscoveredServer> _discoveredServers = [];
int _scannedCount = 0; int _scannedCount = 0;
@@ -46,13 +47,14 @@ 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);
// Start BLE scan by default // Defer scan startup until after the first frame so Provider listeners
final connectionProvider = Provider.of<ConnectionProvider>( // are not notified while this dialog is still being built.
context, WidgetsBinding.instance.addPostFrameCallback((_) {
listen: false, if (!mounted) return;
); _connectionProvider.startScan();
connectionProvider.startScan(); });
// Set up network scanner callbacks // Set up network scanner callbacks
_networkScanner.onServerDiscovered = (server) { _networkScanner.onServerDiscovered = (server) {
@@ -81,11 +83,7 @@ class _ConnectionDialogState extends State<ConnectionDialog>
@override @override
void dispose() { void dispose() {
final connectionProvider = Provider.of<ConnectionProvider>( _connectionProvider.stopScan();
context,
listen: false,
);
connectionProvider.stopScan();
_networkScanner.stopScan(); _networkScanner.stopScan();
// Remove listener before disposing to prevent memory leaks // Remove listener before disposing to prevent memory leaks
_tabController.removeListener(_onTabChanged); _tabController.removeListener(_onTabChanged);

View File

@@ -0,0 +1,394 @@
import 'package:flutter/material.dart';
import 'package:meshcore_client/meshcore_client.dart';
class SpectrumScanPanel extends StatelessWidget {
final ThemeData theme;
final bool scanSupported;
final bool isRunning;
final double rangeMinMhz;
final double rangeMaxMhz;
final RangeValues rangeValues;
final double bandwidthKhz;
final int? selectedFrequencyKhz;
final List<SpectrumScanCandidate> graphCandidates;
final List<SpectrumScanCandidate> selectableCandidates;
final ValueChanged<RangeValues> onRangeChanged;
final ValueChanged<int?> onCandidateChanged;
final VoidCallback onRunScan;
final VoidCallback onApplySelected;
const SpectrumScanPanel({
super.key,
required this.theme,
required this.scanSupported,
required this.isRunning,
required this.rangeMinMhz,
required this.rangeMaxMhz,
required this.rangeValues,
required this.bandwidthKhz,
required this.selectedFrequencyKhz,
required this.graphCandidates,
required this.selectableCandidates,
required this.onRangeChanged,
required this.onCandidateChanged,
required this.onRunScan,
required this.onApplySelected,
});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest.withValues(
alpha: 0.45,
),
borderRadius: BorderRadius.circular(18),
border: Border.all(color: theme.colorScheme.outlineVariant),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.tune, color: theme.colorScheme.primary),
const SizedBox(width: 10),
Expanded(
child: Text(
'Power Scan',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
),
FilledButton.icon(
onPressed: scanSupported && !isRunning ? onRunScan : null,
icon: isRunning
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.radar),
label: Text(
scanSupported
? (isRunning ? 'Scanning' : 'Scan')
: 'Unavailable',
),
),
],
),
const SizedBox(height: 8),
Text(
scanSupported
? 'Full range with bandwidth footprint. Firmware enforces hardware band limits and pauses the mesh while scanning.'
: 'Full range with bandwidth footprint. This companion does not support spectrum scan mode, so scanning is disabled.',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 14),
_FrequencyRangePreview(
minMhz: rangeMinMhz,
maxMhz: rangeMaxMhz,
selectedRange: rangeValues,
selectedBandwidthKhz: bandwidthKhz,
selectedFrequencyKhz: selectedFrequencyKhz,
candidates: graphCandidates,
),
const SizedBox(height: 10),
Wrap(
spacing: 14,
runSpacing: 6,
children: [
_LegendChip(
color: theme.colorScheme.primary,
label: 'Quiet',
),
const _LegendChip(color: Colors.orange, label: 'Moderate'),
_LegendChip(
color: theme.colorScheme.error,
label: 'Busy',
),
],
),
const SizedBox(height: 10),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'${rangeValues.start.toStringAsFixed(3)} MHz',
style: theme.textTheme.labelMedium,
),
Text(
'${rangeValues.end.toStringAsFixed(3)} MHz',
style: theme.textTheme.labelMedium,
),
],
),
RangeSlider(
values: rangeValues,
min: rangeMinMhz,
max: rangeMaxMhz,
divisions: (((rangeMaxMhz - rangeMinMhz) * 20).round()).clamp(
1,
400,
),
labels: RangeLabels(
rangeValues.start.toStringAsFixed(3),
rangeValues.end.toStringAsFixed(3),
),
onChanged: onRangeChanged,
),
if (selectableCandidates.isEmpty) ...[
const SizedBox(height: 6),
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
decoration: BoxDecoration(
color: theme.colorScheme.surface.withValues(alpha: 0.7),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: theme.colorScheme.outlineVariant),
),
child: Text(
scanSupported
? 'No scan results yet. Adjust the range and run a scan to populate candidate frequencies.'
: 'Spectrum preview only. This companion can display the configured span, but cannot scan for open channels.',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
] else ...[
const SizedBox(height: 10),
DropdownButtonFormField<int>(
initialValue: selectedFrequencyKhz,
isExpanded: true,
decoration: const InputDecoration(
labelText: 'Candidate frequency',
border: OutlineInputBorder(),
helperText: 'Best frequencies for the current bandwidth',
),
items: selectableCandidates.map((candidate) {
return DropdownMenuItem<int>(
value: candidate.centerFrequencyKhz,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
'${candidate.centerFrequencyMhz.toStringAsFixed(3)} MHz',
overflow: TextOverflow.ellipsis,
),
Text(
'${candidate.occupancyPercent}% occupied | peak ${candidate.peakRssiDbm} dBm',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
overflow: TextOverflow.ellipsis,
),
],
),
);
}).toList(),
selectedItemBuilder: (context) {
return selectableCandidates.map((candidate) {
return Align(
alignment: Alignment.centerLeft,
child: Text(
'${candidate.centerFrequencyMhz.toStringAsFixed(3)} MHz',
overflow: TextOverflow.ellipsis,
),
);
}).toList();
},
onChanged: onCandidateChanged,
),
const SizedBox(height: 10),
Align(
alignment: Alignment.centerRight,
child: OutlinedButton.icon(
onPressed: selectedFrequencyKhz == null
? null
: onApplySelected,
icon: const Icon(Icons.north_east),
label: const Text('Use selected frequency'),
),
),
],
],
),
);
}
}
class _LegendChip extends StatelessWidget {
final Color color;
final String label;
const _LegendChip({required this.color, required this.label});
@override
Widget build(BuildContext context) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 10,
height: 10,
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
),
const SizedBox(width: 6),
Text(label, style: Theme.of(context).textTheme.labelMedium),
],
);
}
}
class _FrequencyRangePreview extends StatelessWidget {
final double minMhz;
final double maxMhz;
final RangeValues selectedRange;
final double selectedBandwidthKhz;
final int? selectedFrequencyKhz;
final List<SpectrumScanCandidate> candidates;
const _FrequencyRangePreview({
required this.minMhz,
required this.maxMhz,
required this.selectedRange,
required this.selectedBandwidthKhz,
required this.selectedFrequencyKhz,
required this.candidates,
});
double _positionFor(double mhz) {
final span = maxMhz - minMhz;
if (span <= 0) return 0;
return ((mhz - minMhz) / span).clamp(0.0, 1.0);
}
Color _candidateColor(BuildContext context, int occupancyPercent) {
final scheme = Theme.of(context).colorScheme;
if (occupancyPercent <= 10) return scheme.primary;
if (occupancyPercent <= 35) return Colors.orange;
return scheme.error;
}
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final spanMhz = maxMhz - minMhz;
final selectedFreqMhz = selectedFrequencyKhz != null
? selectedFrequencyKhz! / 1000.0
: null;
final bwMhz = selectedBandwidthKhz / 1000.0;
return LayoutBuilder(
builder: (context, constraints) {
final width = constraints.maxWidth;
final rangeLeft = _positionFor(selectedRange.start) * width;
final rangeRight = _positionFor(selectedRange.end) * width;
double? bwLeft;
double? bwWidth;
if (selectedFreqMhz != null && spanMhz > 0) {
bwLeft = _positionFor(selectedFreqMhz - (bwMhz / 2)) * width;
final bwRight = _positionFor(selectedFreqMhz + (bwMhz / 2)) * width;
bwWidth = (bwRight - bwLeft).clamp(4.0, width);
}
return Container(
height: 108,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
gradient: LinearGradient(
colors: [
scheme.surface,
scheme.surfaceContainerHighest.withValues(alpha: 0.9),
],
),
border: Border.all(color: scheme.outlineVariant),
),
child: Stack(
children: [
Positioned.fill(
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 12,
),
child: DecoratedBox(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
gradient: LinearGradient(
colors: [
scheme.primary.withValues(alpha: 0.12),
scheme.tertiary.withValues(alpha: 0.08),
scheme.primary.withValues(alpha: 0.12),
],
),
),
),
),
),
Positioned(
left: rangeLeft,
top: 12,
width: (rangeRight - rangeLeft).clamp(8.0, width),
height: 56,
child: DecoratedBox(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
color: scheme.primary.withValues(alpha: 0.20),
border: Border.all(color: scheme.primary),
),
),
),
if (bwLeft != null && bwWidth != null)
Positioned(
left: bwLeft,
top: 28,
width: bwWidth,
height: 24,
child: DecoratedBox(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(999),
color: scheme.tertiary.withValues(alpha: 0.32),
border: Border.all(color: scheme.tertiary),
),
),
),
for (final candidate in candidates)
Positioned(
left: (_positionFor(candidate.centerFrequencyMhz) * width)
.clamp(10.0, width - 18.0),
top: 72,
child: Column(
children: [
Container(
width: 10,
height: 10,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: _candidateColor(
context,
candidate.occupancyPercent,
),
),
),
const SizedBox(height: 4),
Text(
candidate.centerFrequencyMhz.toStringAsFixed(3),
style: Theme.of(context).textTheme.labelSmall,
),
],
),
),
],
),
);
},
);
}
}

View File

@@ -849,6 +849,7 @@ class DrawingToolbar extends StatelessWidget {
contactPublicKey: room.publicKey, contactPublicKey: room.publicKey,
text: message, text: message,
messageId: messageId, messageId: messageId,
contact: room,
); );
debugPrint(' ✅ Sent successfully'); debugPrint(' ✅ Sent successfully');

View File

@@ -68,8 +68,7 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
senderKey6FromEnvelope: envelope.senderKey6, senderKey6FromEnvelope: envelope.senderKey6,
senderName: widget.message.senderName, senderName: widget.message.senderName,
); );
final effectivePathLen = final effectivePathLen = sender != null && sender.outPathLen >= 0
sender != null && sender.outPathLen >= 0
? sender.outPathLen ? sender.outPathLen
: widget.message.pathLen; : widget.message.pathLen;
final isComplete = imageProvider.isComplete(envelope.sessionId); final isComplete = imageProvider.isComplete(envelope.sessionId);
@@ -79,7 +78,12 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
if (_isRequesting && isComplete) { if (_isRequesting && isComplete) {
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) setState(() => _isRequesting = false); if (mounted) {
setState(() {
_isRequesting = false;
_errorText = null;
});
}
}); });
} }
@@ -189,8 +193,38 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
'$received/$total', '$received/$total',
style: const TextStyle(color: Colors.white, fontSize: 11), style: const TextStyle(color: Colors.white, fontSize: 11),
), ),
Positioned(
top: 8,
right: 8,
child: IconButton(
onPressed: () => _cancelReceive(envelope.sessionId),
icon: const Icon(Icons.close, size: 20),
color: Colors.white70,
tooltip: 'Cancel image receive',
),
),
] else if (_errorText != null) ...[ ] else if (_errorText != null) ...[
Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.broken_image, color: Colors.red, size: 36), const Icon(Icons.broken_image, color: Colors.red, size: 36),
const SizedBox(height: 8),
TextButton.icon(
onPressed: () => _requestAndFetch(
envelope,
radioBw: radioBw,
radioSf: radioSf,
radioCr: radioCr,
pathLen: pathLen,
),
icon: const Icon(Icons.refresh, size: 18),
label: const Text('Retry'),
style: TextButton.styleFrom(
foregroundColor: Colors.white70,
),
),
],
),
] else ...[ ] else ...[
// Tap-to-load icon. // Tap-to-load icon.
IconButton( IconButton(
@@ -221,6 +255,8 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
}) async { }) async {
if (_isRequesting) return; if (_isRequesting) return;
final conn = context.read<ConnectionProvider>(); final conn = context.read<ConnectionProvider>();
final imageProvider = context.read<ip.ImageProvider>();
imageProvider.resumeIncomingSession(envelope.sessionId);
final contactsProvider = context.read<ContactsProvider>(); final contactsProvider = context.read<ContactsProvider>();
final resolution = await TransmissionTargetResolver.resolveFetchTarget( final resolution = await TransmissionTargetResolver.resolveFetchTarget(
contactsProvider: contactsProvider, contactsProvider: contactsProvider,
@@ -264,7 +300,6 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
} }
setState(() => _errorText = null); setState(() => _errorText = null);
final imageProvider = context.read<ip.ImageProvider>();
final deviceKey = conn.deviceInfo.publicKey; final deviceKey = conn.deviceInfo.publicKey;
if (deviceKey == null || deviceKey.length < 6) { if (deviceKey == null || deviceKey.length < 6) {
await _showBlockingAlert( await _showBlockingAlert(
@@ -322,7 +357,9 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
if (!mounted) return; if (!mounted) return;
// Timeout = 2× estimated LoRa airtime (min 30s). // Timeout = 2× estimated LoRa airtime (min 30s).
final effectivePathLen = sender.outPathLen >= 0 ? sender.outPathLen : pathLen; final effectivePathLen = sender.outPathLen >= 0
? sender.outPathLen
: pathLen;
final txEstimate = estimateImageTransmitDuration( final txEstimate = estimateImageTransmitDuration(
fragmentCount: missing.isEmpty ? envelope.total : missing.length, fragmentCount: missing.isEmpty ? envelope.total : missing.length,
sizeBytes: missing.isEmpty sizeBytes: missing.isEmpty
@@ -357,6 +394,17 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
); );
} }
void _cancelReceive(String sessionId) {
if (!mounted) return;
_requestTimeoutTimer?.cancel();
context.read<ip.ImageProvider>().cancelIncomingSession(sessionId);
_showToast('Image receive canceled');
setState(() {
_isRequesting = false;
_errorText = 'Image receive canceled';
});
}
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');

View File

@@ -23,8 +23,10 @@ import '../../utils/key_comparison.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/tictactoe_message_parser.dart'; import '../../utils/tictactoe_message_parser.dart';
import '../../utils/avatar_label_helper.dart';
import '../../l10n/app_localizations.dart'; import '../../l10n/app_localizations.dart';
import '../../utils/message_extensions.dart'; import '../../utils/message_extensions.dart';
import '../common/contact_avatar.dart';
import 'voice_message_bubble.dart'; import 'voice_message_bubble.dart';
import 'image_message_bubble.dart'; import 'image_message_bubble.dart';
import 'tictactoe_message_bubble.dart'; import 'tictactoe_message_bubble.dart';
@@ -62,6 +64,103 @@ class _MessageBubbleState extends State<MessageBubble> {
bool _isExpanded = false; bool _isExpanded = false;
bool _showReceivedStats = false; bool _showReceivedStats = false;
Widget _buildHeaderAvatar(
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 isOwnMessage,
required bool isSarMarker,
}) {
final metaColor = Theme.of(
context,
).textTheme.labelSmall?.color?.withValues(alpha: 0.68);
final items = <Widget>[
Text(
message.getLocalizedTimeAgo(context),
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: metaColor,
fontWeight: FontWeight.w500,
),
),
];
if (!isOwnMessage && !isSarMarker && message.pathLen < 255) {
items.addAll([
Text(
'',
style: Theme.of(
context,
).textTheme.labelSmall?.copyWith(color: metaColor),
),
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),
),
]);
}
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),
),
);
}
@override @override
void didUpdateWidget(MessageBubble oldWidget) { void didUpdateWidget(MessageBubble oldWidget) {
super.didUpdateWidget(oldWidget); super.didUpdateWidget(oldWidget);
@@ -118,7 +217,26 @@ class _MessageBubbleState extends State<MessageBubble> {
); );
// Add retry message to provider // Add retry message to provider
messagesProvider.addSentMessage(retryMessage); Contact? roomContact;
if (failedMessage.messageType == MessageType.contact) {
if (failedMessage.recipientPublicKey == null) {
messagesProvider.markMessageFailed(retryMessageId);
ToastLogger.error(
context,
AppLocalizations.of(context)!.cannotRetryMissingRecipient,
);
return;
}
final contactsProvider = context.read<ContactsProvider>();
roomContact = contactsProvider.contacts.where((c) {
return c.publicKey.length >=
failedMessage.recipientPublicKey!.length &&
c.publicKey.matches(failedMessage.recipientPublicKey!);
}).firstOrNull;
}
messagesProvider.addSentMessage(retryMessage, contact: roomContact);
// Resend the message // Resend the message
if (failedMessage.messageType == MessageType.contact) { if (failedMessage.messageType == MessageType.contact) {
@@ -132,14 +250,6 @@ class _MessageBubbleState extends State<MessageBubble> {
return; return;
} }
// Look up the room contact for path logging
final contactsProvider = context.read<ContactsProvider>();
final roomContact = contactsProvider.contacts.where((c) {
return c.publicKey.length >=
failedMessage.recipientPublicKey!.length &&
c.publicKey.matches(failedMessage.recipientPublicKey!);
}).firstOrNull;
// Resend to the same room // Resend to the same room
final sentSuccessfully = await connectionProvider.sendTextMessage( final sentSuccessfully = await connectionProvider.sendTextMessage(
contactPublicKey: failedMessage.recipientPublicKey!, contactPublicKey: failedMessage.recipientPublicKey!,
@@ -345,6 +455,7 @@ class _MessageBubbleState extends State<MessageBubble> {
final radioSf = connectionProvider.deviceInfo.radioSf; final radioSf = connectionProvider.deviceInfo.radioSf;
final radioCr = connectionProvider.deviceInfo.radioCr; final radioCr = connectionProvider.deviceInfo.radioCr;
final contactsProvider = context.read<ContactsProvider>(); final contactsProvider = context.read<ContactsProvider>();
final messagesProvider = context.read<MessagesProvider>();
final voiceProvider = context.read<VoiceProvider>(); final voiceProvider = context.read<VoiceProvider>();
final imageProvider = context.read<ip.ImageProvider>(); final imageProvider = context.read<ip.ImageProvider>();
final selfPublicKey = connectionProvider.deviceInfo.publicKey; final selfPublicKey = connectionProvider.deviceInfo.publicKey;
@@ -386,6 +497,10 @@ class _MessageBubbleState extends State<MessageBubble> {
recipientName = recipientContact?.advName; recipientName = recipientContact?.advName;
} }
final senderLocationSnapshot = messagesProvider.getMessageContactLocation(
widget.message.id,
);
final envelope = VoiceEnvelope.tryParseText(widget.message.text); final envelope = VoiceEnvelope.tryParseText(widget.message.text);
final legacyVoicePacket = VoicePacket.tryParseText(widget.message.text); final legacyVoicePacket = VoicePacket.tryParseText(widget.message.text);
final voiceSession = widget.message.voiceId != null final voiceSession = widget.message.voiceId != null
@@ -459,6 +574,9 @@ class _MessageBubbleState extends State<MessageBubble> {
: null); : null);
final rssiDbm = final rssiDbm =
matchedRxLog?.logRxDataInfo?.rssiDbm ?? widget.message.lastEchoRssiDbm; matchedRxLog?.logRxDataInfo?.rssiDbm ?? widget.message.lastEchoRssiDbm;
final retryCause = _retryCauseLabel(widget.message);
final retryResult = _retryResultLabel(widget.message);
final retryMode = _retryModeLabel(widget.message);
final rawLines = <String>[ final rawLines = <String>[
'Message ID: ${widget.message.id}', 'Message ID: ${widget.message.id}',
@@ -468,7 +586,7 @@ class _MessageBubbleState extends State<MessageBubble> {
'Sent message: ${widget.message.isSentMessage}', 'Sent message: ${widget.message.isSentMessage}',
'Read: ${widget.message.isRead}', 'Read: ${widget.message.isRead}',
'Status: ${widget.message.deliveryStatus.name}', 'Status: ${widget.message.deliveryStatus.name}',
'Path length (nodes/hops): ${widget.message.pathLen}', 'Path length (nodes/hops): ${_hopDebugLabel(widget.message)}',
'Sender timestamp: ${widget.message.senderTimestamp} (${widget.message.sentAt.toIso8601String()})', 'Sender timestamp: ${widget.message.senderTimestamp} (${widget.message.sentAt.toIso8601String()})',
'Received at (RFC3339): ${_formatRfc3339(widget.message.receivedAt)}', 'Received at (RFC3339): ${_formatRfc3339(widget.message.receivedAt)}',
'Channel index: ${widget.message.channelIdx ?? '-'}', 'Channel index: ${widget.message.channelIdx ?? '-'}',
@@ -483,8 +601,14 @@ class _MessageBubbleState extends State<MessageBubble> {
'Round-trip ms: ${widget.message.roundTripTimeMs ?? '-'}', 'Round-trip ms: ${widget.message.roundTripTimeMs ?? '-'}',
'Retry attempt: ${widget.message.retryAttempt}', 'Retry attempt: ${widget.message.retryAttempt}',
'Used flood fallback: ${widget.message.usedFloodFallback}', 'Used flood fallback: ${widget.message.usedFloodFallback}',
'Retry cause: ${retryCause ?? '-'}',
'Retry mode: ${retryMode ?? '-'}',
'Retry result: ${retryResult ?? '-'}',
'Sender key prefix: ${senderPrefixHex ?? '-'}', 'Sender key prefix: ${senderPrefixHex ?? '-'}',
'Sender name: ${senderName ?? widget.message.senderName ?? '-'}', 'Sender name: ${senderName ?? widget.message.senderName ?? '-'}',
'Sender location at receipt: ${senderLocationSnapshot?.formattedCoordinates ?? '-'}',
'Sender location source: ${senderLocationSnapshot?.technicalSourceLabel ?? '-'}',
'Sender location timestamp: ${senderLocationSnapshot?.sourceTimestamp?.toIso8601String() ?? '-'}',
'Recipient key prefix: ${recipientPrefixHex ?? '-'}', 'Recipient key prefix: ${recipientPrefixHex ?? '-'}',
'Recipient name: ${recipientName ?? '-'}', 'Recipient name: ${recipientName ?? '-'}',
'Drawing flag: ${widget.message.isDrawing}', 'Drawing flag: ${widget.message.isDrawing}',
@@ -635,8 +759,7 @@ class _MessageBubbleState extends State<MessageBubble> {
_techBadge( _techBadge(
context, context,
icon: Icons.route, icon: Icons.route,
label: label: _hopDisplayLabel(widget.message),
'${widget.message.pathLen} hop${widget.message.pathLen == 1 ? '' : 's'}',
), ),
_techBadge( _techBadge(
context, context,
@@ -724,6 +847,25 @@ class _MessageBubbleState extends State<MessageBubble> {
value: widget.message.expectedAckTag! value: widget.message.expectedAckTag!
.toString(), .toString(),
), ),
if (widget.message.suggestedTimeoutMs != null)
_detailRow(
context,
label: 'ACK timeout',
value:
'${widget.message.suggestedTimeoutMs} ms',
),
if (retryCause != null)
_detailRow(
context,
label: 'Retry cause',
value: retryCause,
),
if (retryMode != null)
_detailRow(
context,
label: 'Retry mode',
value: retryMode,
),
if (widget.message.roundTripTimeMs != null) if (widget.message.roundTripTimeMs != null)
_detailRow( _detailRow(
context, context,
@@ -734,7 +876,18 @@ class _MessageBubbleState extends State<MessageBubble> {
_detailRow( _detailRow(
context, context,
label: l10n.retryAttempt, label: l10n.retryAttempt,
value: widget.message.retryAttempt.toString(), value: '${widget.message.retryAttempt}/3',
),
if (widget.message.lastRetryAt != null)
_detailRow(
context,
label: 'Last retry',
value: _formatRfc3339(
widget.message.lastRetryAt!,
),
onCopy: () => copyField(
_formatRfc3339(widget.message.lastRetryAt!),
),
), ),
if (widget.message.usedFloodFallback) if (widget.message.usedFloodFallback)
_detailRow( _detailRow(
@@ -742,6 +895,12 @@ class _MessageBubbleState extends State<MessageBubble> {
label: l10n.floodFallback, label: l10n.floodFallback,
value: l10n.yes, value: l10n.yes,
), ),
if (retryResult != null)
_detailRow(
context,
label: 'Retry result',
value: retryResult,
),
if (packetPathHex != null) if (packetPathHex != null)
_detailRow( _detailRow(
context, context,
@@ -1527,9 +1686,7 @@ class _MessageBubbleState extends State<MessageBubble> {
required int? rssiDbm, required int? rssiDbm,
required double? snrDb, required double? snrDb,
}) { }) {
final hopLabel = message.pathLen == 0 final hopLabel = _hopDisplayLabel(message);
? 'Direct'
: '${message.pathLen} hop${message.pathLen == 1 ? '' : 's'}';
return Wrap( return Wrap(
spacing: 4, spacing: 4,
@@ -1570,6 +1727,100 @@ class _MessageBubbleState extends State<MessageBubble> {
); );
} }
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'}';
}
String _hopDebugLabel(Message message) {
if (message.pathLen >= 255 && message.isContactMessage) {
return 'Direct (raw: ${message.pathLen})';
}
if (message.pathLen >= 255) return 'Unknown (raw: ${message.pathLen})';
return _hopDisplayLabel(message);
}
String? _retryCauseLabel(Message message) {
if (!message.isContactMessage || message.expectedAckTag == null) {
return null;
}
if (message.deliveryStatus == MessageDeliveryStatus.sending &&
message.retryAttempt == 0) {
return 'Waiting for delivery ACK';
}
if (message.retryAttempt > 0 || message.usedFloodFallback) {
return 'Delivery ACK timeout';
}
if (message.deliveryStatus == MessageDeliveryStatus.failed) {
return 'Delivery confirmation not received';
}
return null;
}
String? _retryModeLabel(Message message) {
if (!message.isContactMessage) {
return null;
}
if (message.usedFloodFallback) {
return 'Flood fallback';
}
if (message.retryAttempt > 0 || message.expectedAckTag != null) {
return 'Learned direct path';
}
return null;
}
String? _retryResultLabel(Message message) {
if (!message.isContactMessage) {
return null;
}
if (message.deliveryStatus == MessageDeliveryStatus.delivered) {
if (message.usedFloodFallback) {
return 'Delivered after flood fallback';
}
if (message.retryAttempt > 0) {
return 'Delivered after retry';
}
if (message.expectedAckTag != null) {
return 'Delivery confirmed';
}
}
if (message.deliveryStatus == MessageDeliveryStatus.sending) {
if (message.usedFloodFallback) {
return 'Flood fallback in progress';
}
if (message.retryAttempt > 0) {
return 'Retry in progress';
}
if (message.expectedAckTag != null) {
return 'Awaiting confirmation';
}
}
if (message.deliveryStatus == MessageDeliveryStatus.failed) {
if (message.usedFloodFallback) {
return 'Failed after flood fallback';
}
if (message.retryAttempt > 0) {
return 'Failed after retry attempts';
}
return 'Delivery failed';
}
return null;
}
Widget _techChip( Widget _techChip(
BuildContext context, { BuildContext context, {
required IconData icon, required IconData icon,
@@ -1736,9 +1987,10 @@ class _MessageBubbleState extends State<MessageBubble> {
: message.getRichDisplayName(senderContact); : message.getRichDisplayName(senderContact);
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context)!;
// For sent direct/channel messages, look up destination display label // Look up destination/source display labels for direct/channel messages
dynamic recipientContact; dynamic recipientContact;
String? recipientDisplayName; String? recipientDisplayName;
String? channelDisplayName;
if (isOwnMessage && if (isOwnMessage &&
message.isContactMessage && message.isContactMessage &&
message.recipientPublicKey != null) { message.recipientPublicKey != null) {
@@ -1766,32 +2018,49 @@ class _MessageBubbleState extends State<MessageBubble> {
recipientContact.displayName ?? recipientContact.advName; recipientContact.displayName ?? recipientContact.advName;
} }
} }
} else if (isOwnMessage && message.isChannelMessage) { } else if (message.isChannelMessage) {
if (message.channelIdx == 0) { if (message.channelIdx == 0) {
recipientDisplayName = l10n.publicChannel; channelDisplayName = l10n.publicChannel;
} else { } else {
final channelContact = contactsProvider.channels.where((c) { final channelContact = contactsProvider.channels.where((c) {
return c.publicKey.length > 1 && c.publicKey[1] == message.channelIdx; return c.publicKey.length > 1 && c.publicKey[1] == message.channelIdx;
}).firstOrNull; }).firstOrNull;
recipientDisplayName = channelDisplayName =
channelContact?.getLocalizedDisplayName(context) ?? channelContact?.getLocalizedDisplayName(context) ??
'${l10n.channel} ${message.channelIdx}'; '${l10n.channel} ${message.channelIdx}';
} }
if (isOwnMessage) {
recipientDisplayName = channelDisplayName;
}
} }
final recipientSubtitle = final recipientSubtitle =
isOwnMessage && message.isChannelMessage && recipientDisplayName != null isOwnMessage && message.isChannelMessage && recipientDisplayName != null
? '${l10n.channel}: $recipientDisplayName' ? '${l10n.channel}: $recipientDisplayName'
: recipientDisplayName; : recipientDisplayName;
final receivedChannelSubtitle =
!isOwnMessage && message.isChannelMessage && channelDisplayName != null
? '${l10n.channel}: $channelDisplayName'
: null;
return GestureDetector( final shouldFloatBubble = widget.isCompact;
final bubble = ConstrainedBox(
constraints: BoxConstraints(
maxWidth: shouldFloatBubble
? MediaQuery.of(context).size.width * 0.78
: double.infinity,
),
child: GestureDetector(
onTap: () => _handleBubbleTap( onTap: () => _handleBubbleTap(
isSarMarker: isSarMarker, isSarMarker: isSarMarker,
isDrawing: message.isDrawing, isDrawing: message.isDrawing,
), ),
onLongPress: widget.isCompact ? null : () => _showMessageOptions(context), onLongPress: widget.isCompact
? null
: () => _showMessageOptions(context),
child: Container( child: Container(
margin: const EdgeInsets.only(bottom: 8), margin: EdgeInsets.zero,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration( decoration: BoxDecoration(
color: widget.isHighlighted color: widget.isHighlighted
@@ -1853,7 +2122,10 @@ class _MessageBubbleState extends State<MessageBubble> {
BoxShadow( BoxShadow(
color: color:
(isSarMarker (isSarMarker
? _getSarMarkerBorderColor(context, isDarkMode) ? _getSarMarkerBorderColor(
context,
isDarkMode,
)
: Theme.of(context).colorScheme.primary) : Theme.of(context).colorScheme.primary)
.withValues(alpha: 0.3), .withValues(alpha: 0.3),
blurRadius: 8, blurRadius: 8,
@@ -1913,7 +2185,11 @@ class _MessageBubbleState extends State<MessageBubble> {
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
const Icon(Icons.draw, size: 16, color: Colors.white), const Icon(
Icons.draw,
size: 16,
color: Colors.white,
),
const SizedBox(width: 4), const SizedBox(width: 4),
Text( Text(
AppLocalizations.of(context)!.mapDrawing, AppLocalizations.of(context)!.mapDrawing,
@@ -1927,15 +2203,6 @@ class _MessageBubbleState extends State<MessageBubble> {
], ],
), ),
), ),
const Spacer(),
Text(
message.getLocalizedTimeAgo(context),
style: Theme.of(context).textTheme.labelSmall?.copyWith(
fontWeight: isSarMarker
? FontWeight.w600
: FontWeight.normal,
),
),
], ],
), ),
@@ -1957,22 +2224,19 @@ class _MessageBubbleState extends State<MessageBubble> {
shape: BoxShape.circle, shape: BoxShape.circle,
), ),
), ),
if (isOwnMessage) _buildHeaderAvatar(
Icon( context,
Icons.account_circle, isOwnMessage: isOwnMessage,
size: 16, isChannelMessage: message.isChannelMessage,
color: Theme.of(context).colorScheme.primary, senderContact: senderContact,
) displayName: displayName,
else if (message.isChannelMessage) ),
const Icon(Icons.tag, size: 16)
else
const Icon(Icons.person, size: 16),
const SizedBox(width: 4), const SizedBox(width: 4),
Expanded( Expanded(
child: Column( child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Flexible(
child: Text(
displayName, displayName,
style: Theme.of(context).textTheme.labelMedium style: Theme.of(context).textTheme.labelMedium
?.copyWith( ?.copyWith(
@@ -1984,15 +2248,72 @@ class _MessageBubbleState extends State<MessageBubble> {
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
// Show destination for sent direct/channel messages on a separate line ),
if (isOwnMessage && if (!widget.isCompact &&
recipientSubtitle != null && (recipientSubtitle != null ||
!widget.isCompact) ...[ receivedChannelSubtitle != null)) ...[
const SizedBox(height: 2), const SizedBox(width: 8),
Row( if (message.isChannelMessage)
Flexible(
child: 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: [ children: [
Icon( Icon(
Icons.arrow_forward, isOwnMessage
? Icons.campaign_outlined
: Icons.tag,
size: 11,
color: Theme.of(context)
.textTheme
.labelSmall
?.color
?.withValues(alpha: 0.7),
),
const SizedBox(width: 5),
Flexible(
child: Text(
isOwnMessage
? recipientDisplayName!
: channelDisplayName!,
style: Theme.of(context)
.textTheme
.labelSmall
?.copyWith(
color: Theme.of(context)
.textTheme
.labelSmall
?.color
?.withValues(alpha: 0.82),
fontWeight: FontWeight.w600,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
)
else
Expanded(
child: Row(
children: [
Icon(
isOwnMessage
? Icons.arrow_forward
: Icons.arrow_back,
size: 12, size: 12,
color: Theme.of(context) color: Theme.of(context)
.textTheme .textTheme
@@ -2003,8 +2324,12 @@ class _MessageBubbleState extends State<MessageBubble> {
const SizedBox(width: 4), const SizedBox(width: 4),
Expanded( Expanded(
child: Text( child: Text(
recipientSubtitle, isOwnMessage
style: Theme.of(context).textTheme.labelSmall ? recipientSubtitle!
: receivedChannelSubtitle!,
style: Theme.of(context)
.textTheme
.labelSmall
?.copyWith( ?.copyWith(
color: Theme.of(context) color: Theme.of(context)
.textTheme .textTheme
@@ -2019,86 +2344,146 @@ class _MessageBubbleState extends State<MessageBubble> {
), ),
], ],
), ),
),
], ],
], ],
), ),
), ),
// Time for regular messages (not shown for SAR/drawing as it's already above)
if (!isSarMarker && !message.isDrawing) ...[
const SizedBox(width: 8),
// Hop count indicator for received messages
if (!isOwnMessage && message.pathLen < 255) ...[
const SizedBox(width: 4),
Icon(
Icons.alt_route,
size: 11,
color: Theme.of(
context,
).textTheme.labelSmall?.color?.withValues(alpha: 0.6),
),
const SizedBox(width: 2),
Text(
message.pathLen == 0 ? 'direct' : '${message.pathLen}hop',
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: Theme.of(
context,
).textTheme.labelSmall?.color?.withValues(alpha: 0.6),
),
),
const SizedBox(width: 4),
],
Text(
message.getLocalizedTimeAgo(context),
style: Theme.of(context).textTheme.labelSmall,
),
],
], ],
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
// SAR marker content // SAR marker content
if (isSarMarker && message.sarMarkerType != null) ...[ if (isSarMarker && message.sarMarkerType != null) ...[
Column( Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white.withValues(
alpha: isDarkMode ? 0.06 : 0.42,
),
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: _getSarMarkerBorderColor(
context,
isDarkMode,
).withValues(alpha: 0.22),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Row( Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 52,
height: 52,
decoration: BoxDecoration(
color: _getSarMarkerBorderColor(
context,
isDarkMode,
).withValues(alpha: isDarkMode ? 0.2 : 0.12),
borderRadius: BorderRadius.circular(16),
),
alignment: Alignment.center,
child: Text(
message.sarCustomEmoji ??
message.sarMarkerType!.emoji,
style: const TextStyle(fontSize: 30, height: 1),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
message.sarCustomEmoji ?? message.sarMarkerType!.emoji, message.sarMarkerType!.getLocalizedName(
style: const TextStyle(fontSize: 32),
),
const SizedBox(width: 10),
Expanded(
child: Text(
message.sarNotes != null &&
message.sarNotes!.isNotEmpty
? message.sarNotes!
: message.sarMarkerType!.getLocalizedName(
context, context,
), ),
style: Theme.of(context).textTheme.titleSmall style: Theme.of(context).textTheme.titleMedium
?.copyWith(fontWeight: FontWeight.bold), ?.copyWith(
fontWeight: FontWeight.w800,
letterSpacing: -0.2,
),
),
if (message.sarNotes != null &&
message.sarNotes!.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
message.sarNotes!,
style: Theme.of(context)
.textTheme
.bodyMedium
?.copyWith(
height: 1.25,
color: Theme.of(context)
.colorScheme
.onSurface
.withValues(alpha: 0.86),
),
),
],
],
), ),
), ),
if (!widget.isCompact) if (!widget.isCompact)
Icon( Padding(
Icons.chevron_right, padding: const EdgeInsets.only(left: 8, top: 2),
size: 18, child: Icon(
color: Theme.of(context).colorScheme.primary, Icons.chevron_right_rounded,
size: 20,
color: _getSarMarkerBorderColor(
context,
isDarkMode,
),
),
), ),
], ],
), ),
if (message.sarGpsCoordinates != null) ...[ if (message.sarGpsCoordinates != null) ...[
const SizedBox(height: 6), const SizedBox(height: 12),
Text( Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 8,
),
decoration: BoxDecoration(
color: Colors.black.withValues(
alpha: isDarkMode ? 0.18 : 0.05,
),
borderRadius: BorderRadius.circular(10),
),
child: Row(
children: [
Icon(
Icons.place_outlined,
size: 15,
color: _getSarMarkerBorderColor(
context,
isDarkMode,
),
),
const SizedBox(width: 6),
Expanded(
child: Text(
'${message.sarGpsCoordinates!.latitude.toStringAsFixed(5)}, ${message.sarGpsCoordinates!.longitude.toStringAsFixed(5)}', '${message.sarGpsCoordinates!.latitude.toStringAsFixed(5)}, ${message.sarGpsCoordinates!.longitude.toStringAsFixed(5)}',
style: Theme.of(context).textTheme.labelMedium?.copyWith( style: Theme.of(context).textTheme.labelMedium
?.copyWith(
fontFamily: 'monospace', fontFamily: 'monospace',
fontWeight: FontWeight.w700,
letterSpacing: 0.15,
),
),
),
],
), ),
), ),
], ],
], ],
), ),
),
] ]
// Drawing message content (skip in compact mode - drawings hidden) // Drawing message content (skip in compact mode - drawings hidden)
else if (message.isDrawing && else if (message.isDrawing &&
@@ -2164,7 +2549,9 @@ class _MessageBubbleState extends State<MessageBubble> {
const SizedBox(width: 6), const SizedBox(width: 6),
Text( Text(
colorName, colorName,
style: Theme.of(context).textTheme.labelSmall, style: Theme.of(
context,
).textTheme.labelSmall,
), ),
], ],
), ),
@@ -2192,10 +2579,16 @@ class _MessageBubbleState extends State<MessageBubble> {
// Tic-Tac-Toe control message content // Tic-Tac-Toe control message content
else if (ticTacToeEvent?.type == TicTacToeEventType.start && else if (ticTacToeEvent?.type == TicTacToeEventType.start &&
!widget.isCompact) !widget.isCompact)
TicTacToeMessageBubble(message: message, isSentByMe: isOwnMessage) TicTacToeMessageBubble(
message: message,
isSentByMe: isOwnMessage,
)
// Regular message content // Regular message content
else if (!message.isDrawing || widget.isCompact) else if (!message.isDrawing || widget.isCompact)
Text(message.text, style: Theme.of(context).textTheme.bodyMedium), Text(
message.text,
style: Theme.of(context).textTheme.bodyMedium,
),
if (!widget.isCompact && if (!widget.isCompact &&
!isSarMarker && !isSarMarker &&
@@ -2282,7 +2675,9 @@ class _MessageBubbleState extends State<MessageBubble> {
), ),
const SizedBox(width: 4), const SizedBox(width: 4),
Icon( Icon(
_isExpanded ? Icons.expand_less : Icons.expand_more, _isExpanded
? Icons.expand_less
: Icons.expand_more,
size: 14, size: 14,
color: Theme.of( color: Theme.of(
context, context,
@@ -2306,7 +2701,9 @@ class _MessageBubbleState extends State<MessageBubble> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
AppLocalizations.of(context)!.recipientDetails, AppLocalizations.of(
context,
)!.recipientDetails,
style: Theme.of(context).textTheme.labelSmall style: Theme.of(context).textTheme.labelSmall
?.copyWith(fontWeight: FontWeight.bold), ?.copyWith(fontWeight: FontWeight.bold),
), ),
@@ -2432,7 +2829,10 @@ class _MessageBubbleState extends State<MessageBubble> {
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.orange.withValues(alpha: 0.2), color: Colors.orange.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(4),
border: Border.all(color: Colors.orange, width: 1), border: Border.all(
color: Colors.orange,
width: 1,
),
), ),
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@@ -2462,6 +2862,33 @@ class _MessageBubbleState extends State<MessageBubble> {
], ],
), ),
), ),
),
);
final bubbleWithMeta = Column(
crossAxisAlignment: isOwnMessage
? CrossAxisAlignment.end
: CrossAxisAlignment.start,
children: [
bubble,
_buildBubbleMetaFooter(
context,
message: message,
isOwnMessage: isOwnMessage,
isSarMarker: isSarMarker,
),
],
);
if (!shouldFloatBubble) {
return bubbleWithMeta;
}
return Row(
mainAxisAlignment: isOwnMessage
? MainAxisAlignment.end
: MainAxisAlignment.start,
children: [Flexible(child: bubbleWithMeta)],
); );
} }
} }

View File

@@ -30,7 +30,9 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
Future<_TraceResult> _loadTrace() async { Future<_TraceResult> _loadTrace() async {
final connectionProvider = context.read<ConnectionProvider>(); final connectionProvider = context.read<ConnectionProvider>();
final nodes = await MeshMapNodesService.fetchNodes(); final nodes = await MeshMapNodesService.fetchNodes(
cacheTtl: MeshMapNodesService.traceCacheTtl,
);
final packetPath = _extractPathFromPacketLogs( final packetPath = _extractPathFromPacketLogs(
logs: connectionProvider.bleService.packetLogs, logs: connectionProvider.bleService.packetLogs,
message: widget.message, message: widget.message,
@@ -166,8 +168,13 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
child: hasMapPath child: hasMapPath
? flutter_map.FlutterMap( ? flutter_map.FlutterMap(
options: flutter_map.MapOptions( options: flutter_map.MapOptions(
initialCameraFit: flutter_map.CameraFit.bounds( initialCameraFit:
bounds: flutter_map.LatLngBounds.fromPoints(mapPoints), flutter_map.CameraFit.bounds(
bounds:
flutter_map
.LatLngBounds.fromPoints(
mapPoints,
),
padding: const EdgeInsets.all(28), padding: const EdgeInsets.all(28),
), ),
), ),
@@ -175,14 +182,17 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
flutter_map.TileLayer( flutter_map.TileLayer(
urlTemplate: urlTemplate:
'https://tile.openstreetmap.org/{z}/{x}/{y}.png', 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
userAgentPackageName: 'com.meshcore.sar', userAgentPackageName:
'com.meshcore.sar',
), ),
flutter_map.PolylineLayer( flutter_map.PolylineLayer(
polylines: [ polylines: [
flutter_map.Polyline( flutter_map.Polyline(
points: mapPoints, points: mapPoints,
strokeWidth: 4, strokeWidth: 4,
color: Theme.of(context).colorScheme.primary, color: Theme.of(
context,
).colorScheme.primary,
), ),
], ],
), ),
@@ -202,12 +212,14 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
height: 34, height: 34,
child: CircleAvatar( child: CircleAvatar(
radius: 16, radius: 16,
backgroundColor: entry.key == 0 backgroundColor:
entry.key == 0
? Colors.green ? Colors.green
: (entry.key == : (entry.key ==
trace trace.matchedPathNodes
.matchedPathNodes .whereType<
.whereType<MeshMapNode>() MeshMapNode
>()
.length - .length -
1 1
? Colors.red ? Colors.red
@@ -216,7 +228,8 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
'${entry.key + 1}', '${entry.key + 1}',
style: const TextStyle( style: const TextStyle(
color: Colors.white, color: Colors.white,
fontWeight: FontWeight.bold, fontWeight:
FontWeight.bold,
fontSize: 11, fontSize: 11,
), ),
), ),
@@ -228,7 +241,9 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
], ],
) )
: const Center( : const Center(
child: Text('Not enough geolocated nodes to draw path'), child: Text(
'Not enough geolocated nodes to draw path',
),
), ),
), ),
), ),
@@ -244,8 +259,13 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
), ),
if (relayNodes.isEmpty) if (relayNodes.isEmpty)
const Padding( const Padding(
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8), padding: EdgeInsets.symmetric(
child: Text('No relay nodes could be matched for this message.'), horizontal: 16,
vertical: 8,
),
child: Text(
'No relay nodes could be matched for this message.',
),
), ),
...relayNodes.map( ...relayNodes.map(
(node) => ListTile( (node) => ListTile(
@@ -286,9 +306,8 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
MeshMapNode? _bestNodeForPrefix(List<MeshMapNode> nodes, String? prefixHex) { MeshMapNode? _bestNodeForPrefix(List<MeshMapNode> nodes, String? prefixHex) {
if (prefixHex == null || prefixHex.isEmpty) return null; if (prefixHex == null || prefixHex.isEmpty) return null;
final matches = nodes final matches =
.where((n) => n.publicKey.startsWith(prefixHex)) nodes.where((n) => n.publicKey.startsWith(prefixHex)).toList()
.toList()
..sort((a, b) => b.updatedAtMs.compareTo(a.updatedAtMs)); ..sort((a, b) => b.updatedAtMs.compareTo(a.updatedAtMs));
return matches.isEmpty ? null : matches.first; return matches.isEmpty ? null : matches.first;
} }
@@ -298,7 +317,9 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
required Message message, required Message message,
}) { }) {
if (message.pathLen <= 0 || message.pathLen >= 255) return null; if (message.pathLen <= 0 || message.pathLen >= 255) return null;
final expectedPayloadType = message.messageType == MessageType.channel ? 0x05 : 0x02; final expectedPayloadType = message.messageType == MessageType.channel
? 0x05
: 0x02;
BlePacketLog? bestLog; BlePacketLog? bestLog;
var bestDeltaMs = 999999999; var bestDeltaMs = 999999999;
@@ -313,7 +334,8 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
if (pathLen != message.pathLen) continue; if (pathLen != message.pathLen) continue;
if (raw.length < 5 + pathLen) continue; if (raw.length < 5 + pathLen) continue;
final deltaMs = (log.timestamp.difference(message.receivedAt).inMilliseconds).abs(); final deltaMs =
(log.timestamp.difference(message.receivedAt).inMilliseconds).abs();
if (deltaMs < bestDeltaMs) { if (deltaMs < bestDeltaMs) {
bestDeltaMs = deltaMs; bestDeltaMs = deltaMs;
bestLog = log; bestLog = log;
@@ -335,7 +357,9 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
final result = <MeshMapNode?>[]; final result = <MeshMapNode?>[];
for (var i = 0; i < pathHashes.length; i++) { for (var i = 0; i < pathHashes.length; i++) {
final hashHex = pathHashes[i].toRadixString(16).padLeft(2, '0'); final hashHex = pathHashes[i].toRadixString(16).padLeft(2, '0');
final candidates = nodes.where((n) => n.publicKey.startsWith(hashHex)).toList(); final candidates = nodes
.where((n) => n.publicKey.startsWith(hashHex))
.toList();
if (candidates.isEmpty) { if (candidates.isEmpty) {
result.add(null); result.add(null);
continue; continue;
@@ -343,7 +367,9 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
List<MeshMapNode> filtered = candidates; List<MeshMapNode> filtered = candidates;
if (i == 0 && senderPrefix != null) { if (i == 0 && senderPrefix != null) {
final senderMatches = filtered.where((n) => n.publicKey.startsWith(senderPrefix)).toList(); final senderMatches = filtered
.where((n) => n.publicKey.startsWith(senderPrefix))
.toList();
if (senderMatches.isNotEmpty) filtered = senderMatches; if (senderMatches.isNotEmpty) filtered = senderMatches;
} else if (i == pathHashes.length - 1 && recipientPrefix != null) { } else if (i == pathHashes.length - 1 && recipientPrefix != null) {
final recipientMatches = filtered final recipientMatches = filtered
@@ -366,7 +392,8 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
}) { }) {
if (relayCount <= 0 || sender == null || recipient == null) return const []; if (relayCount <= 0 || sender == null || recipient == null) return const [];
final candidates = nodes.where((n) { final candidates = nodes.where((n) {
if (sender.publicKey == n.publicKey || recipient.publicKey == n.publicKey) { if (sender.publicKey == n.publicKey ||
recipient.publicKey == n.publicKey) {
return false; return false;
} }
return true; return true;

View File

@@ -97,9 +97,7 @@ class TicTacToeMessageBubble extends StatelessWidget {
children: [ children: [
Text( Text(
'Tic-Tac-Toe · Game ${state.gameId}', 'Tic-Tac-Toe · Game ${state.gameId}',
style: Theme.of( style: Theme.of(context).textTheme.labelMedium?.copyWith(
context,
).textTheme.labelMedium?.copyWith(
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: titleColor, color: titleColor,
), ),
@@ -167,7 +165,7 @@ class TicTacToeMessageBubble extends StatelessWidget {
deliveryStatus: MessageDeliveryStatus.sending, deliveryStatus: MessageDeliveryStatus.sending,
recipientPublicKey: opponent.publicKey, recipientPublicKey: opponent.publicKey,
); );
messagesProvider.addSentMessage(sentMessage); messagesProvider.addSentMessage(sentMessage, contact: opponent);
final sent = await connectionProvider.sendTextMessage( final sent = await connectionProvider.sendTextMessage(
contactPublicKey: opponent.publicKey, contactPublicKey: opponent.publicKey,

View File

@@ -65,8 +65,7 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
senderKey6FromEnvelope: envelope?.senderKey6, senderKey6FromEnvelope: envelope?.senderKey6,
senderName: widget.message.senderName, senderName: widget.message.senderName,
); );
final effectivePathLen = final effectivePathLen = sender != null && sender.outPathLen >= 0
sender != null && sender.outPathLen >= 0
? sender.outPathLen ? sender.outPathLen
: widget.message.pathLen; : widget.message.pathLen;
final isPlaying = voiceProvider.isPlaying(voiceId); final isPlaying = voiceProvider.isPlaying(voiceId);
@@ -77,6 +76,7 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
_isRequesting = false; _isRequesting = false;
_errorText = null;
}); });
}); });
} }
@@ -125,6 +125,10 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
await voiceProvider.stop(); await voiceProvider.stop();
return; return;
} }
if (_isRequesting) {
_cancelReceive(voiceId);
return;
}
if (isComplete) { if (isComplete) {
await voiceProvider.play(voiceId); await voiceProvider.play(voiceId);
return; return;
@@ -151,7 +155,7 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
child: Icon( child: Icon(
isPlaying isPlaying
? Icons.stop ? Icons.stop
: (_isRequesting ? Icons.downloading : Icons.play_arrow), : (_isRequesting ? Icons.close : Icons.play_arrow),
size: 28, size: 28,
color: widget.isSentByMe color: widget.isSentByMe
? Theme.of(context).colorScheme.onPrimaryContainer ? Theme.of(context).colorScheme.onPrimaryContainer
@@ -215,6 +219,8 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
}) async { }) async {
if (_isRequesting) return; if (_isRequesting) return;
final connectionProvider = context.read<ConnectionProvider>(); final connectionProvider = context.read<ConnectionProvider>();
final voiceProvider = context.read<VoiceProvider>();
voiceProvider.resumeIncomingSession(sessionId);
final contactsProvider = context.read<ContactsProvider>(); final contactsProvider = context.read<ContactsProvider>();
final resolution = await TransmissionTargetResolver.resolveFetchTarget( final resolution = await TransmissionTargetResolver.resolveFetchTarget(
contactsProvider: contactsProvider, contactsProvider: contactsProvider,
@@ -300,7 +306,9 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
} }
// Timeout = 2× estimated LoRa airtime (min 30s). // Timeout = 2× estimated LoRa airtime (min 30s).
final effectivePathLen = sender.outPathLen >= 0 ? sender.outPathLen : pathLen; final effectivePathLen = sender.outPathLen >= 0
? sender.outPathLen
: pathLen;
final txEstimate = envelope != null final txEstimate = envelope != null
? estimateVoiceTransmitDuration( ? estimateVoiceTransmitDuration(
packetCount: envelope.total, packetCount: envelope.total,
@@ -333,6 +341,18 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
}); });
} }
void _cancelReceive(String sessionId) {
if (!mounted) return;
_requestTimeoutTimer?.cancel();
context.read<VoiceProvider>().cancelIncomingSession(sessionId);
_showToast('Voice receive canceled');
setState(() {
_isRequesting = false;
_autoPlayWhenReady = false;
_errorText = 'Voice receive canceled';
});
}
void _showToast(String message) { void _showToast(String message) {
if (!mounted) return; if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(

View File

@@ -16,6 +16,7 @@ import geolocator_apple
import nsd_macos import nsd_macos
import objectbox_flutter_libs import objectbox_flutter_libs
import package_info_plus import package_info_plus
import path_provider_foundation
import record_macos import record_macos
import share_plus import share_plus
import shared_preferences_foundation import shared_preferences_foundation
@@ -34,6 +35,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
NsdMacosPlugin.register(with: registry.registrar(forPlugin: "NsdMacosPlugin")) NsdMacosPlugin.register(with: registry.registrar(forPlugin: "NsdMacosPlugin"))
ObjectboxFlutterLibsPlugin.register(with: registry.registrar(forPlugin: "ObjectboxFlutterLibsPlugin")) ObjectboxFlutterLibsPlugin.register(with: registry.registrar(forPlugin: "ObjectboxFlutterLibsPlugin"))
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
RecordMacOsPlugin.register(with: registry.registrar(forPlugin: "RecordMacOsPlugin")) RecordMacOsPlugin.register(with: registry.registrar(forPlugin: "RecordMacOsPlugin"))
SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))

View File

@@ -129,14 +129,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.2" version: "1.1.2"
code_assets:
dependency: transitive
description:
name: code_assets
sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687"
url: "https://pub.dev"
source: hosted
version: "1.0.0"
codec2_flutter: codec2_flutter:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -243,7 +235,7 @@ packages:
source: hosted source: hosted
version: "3.3.0" version: "3.3.0"
fake_async: fake_async:
dependency: transitive dependency: "direct dev"
description: description:
name: fake_async name: fake_async
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
@@ -646,14 +638,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.2.5" version: "0.2.5"
glob:
dependency: transitive
description:
name: glob
sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de
url: "https://pub.dev"
source: hosted
version: "2.1.3"
gsettings: gsettings:
dependency: transitive dependency: transitive
description: description:
@@ -662,14 +646,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.2.8" version: "0.2.8"
hooks:
dependency: transitive
description:
name: hooks
sha256: "7a08a0d684cb3b8fb604b78455d5d352f502b68079f7b80b831c62220ab0a4f6"
url: "https://pub.dev"
source: hosted
version: "1.0.1"
http: http:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -846,14 +822,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.6.2" version: "2.6.2"
logging:
dependency: transitive
description:
name: logging
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
url: "https://pub.dev"
source: hosted
version: "1.3.0"
matcher: matcher:
dependency: transitive dependency: transitive
description: description:
@@ -883,7 +851,7 @@ packages:
description: description:
path: "." path: "."
ref: main ref: main
resolved-ref: d6f91774f19136ff71b0087feaf95fa5490524d9 resolved-ref: "3f870e98ee9527a3137bfcbdd1454036912fb609"
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"
@@ -911,14 +879,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.0.0" version: "2.0.0"
native_toolchain_c:
dependency: transitive
description:
name: native_toolchain_c
sha256: "89e83885ba09da5fdf2cdacc8002a712ca238c28b7f717910b34bcd27b0d03ac"
url: "https://pub.dev"
source: hosted
version: "0.17.4"
nested: nested:
dependency: transitive dependency: transitive
description: description:
@@ -991,14 +951,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.3.1" version: "4.3.1"
objective_c:
dependency: transitive
description:
name: objective_c
sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52"
url: "https://pub.dev"
source: hosted
version: "9.3.0"
package_info_plus: package_info_plus:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -1040,13 +992,13 @@ packages:
source: hosted source: hosted
version: "2.2.22" version: "2.2.22"
path_provider_foundation: path_provider_foundation:
dependency: transitive dependency: "direct overridden"
description: description:
name: path_provider_foundation name: path_provider_foundation
sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" sha256: "6d13aece7b3f5c5a9731eaf553ff9dcbc2eff41087fd2df587fd0fed9a3eb0c4"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.6.0" version: "2.5.1"
path_provider_linux: path_provider_linux:
dependency: transitive dependency: transitive
description: description:
@@ -1183,14 +1135,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.1.5+1" version: "6.1.5+1"
pub_semver:
dependency: transitive
description:
name: pub_semver
sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585"
url: "https://pub.dev"
source: hosted
version: "2.2.0"
record: record:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -1702,5 +1646,5 @@ packages:
source: hosted source: hosted
version: "3.1.3" version: "3.1.3"
sdks: sdks:
dart: ">=3.10.3 <4.0.0" dart: ">=3.10.0 <4.0.0"
flutter: ">=3.38.4" flutter: ">=3.38.0"

View File

@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # 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.0305.2+8 version: 2026.0306.3+13
environment: environment:
sdk: ^3.9.2 sdk: ^3.9.2
@@ -144,6 +144,10 @@ dev_dependencies:
# rules and activating additional ones. # rules and activating additional ones.
flutter_lints: ^6.0.0 flutter_lints: ^6.0.0
flutter_launcher_icons: "^0.14.4" flutter_launcher_icons: "^0.14.4"
fake_async: ^1.3.3
dependency_overrides:
path_provider_foundation: 2.5.1
flutter_launcher_icons: flutter_launcher_icons:
android: "launcher_icon" android: "launcher_icon"

View File

@@ -194,5 +194,43 @@ void main() {
} }
}, },
); );
test('builds message snapshot from latest valid telemetry', () {
final telemetryData = CayenneLppParser.createGpsData(
latitude: 45.0001,
longitude: 13.9999,
);
provider.updateTelemetry(publicKey.sublist(0, 6), telemetryData);
final contact = provider.findContactByKey(publicKey)!;
final snapshot = provider.buildMessageContactLocationSnapshot(
contact,
capturedAt: DateTime.now(),
);
expect(snapshot, isNotNull);
expect(snapshot!.source, equals('telemetry'));
expect(snapshot.location.latitude, closeTo(45.0001, 0.0001));
expect(snapshot.location.longitude, closeTo(13.9999, 0.0001));
});
test('builds message snapshot from advert when telemetry is invalid', () {
final invalidTelemetry = CayenneLppParser.createGpsData(
latitude: 0.0,
longitude: 0.0,
);
provider.updateTelemetry(publicKey.sublist(0, 6), invalidTelemetry);
final contact = provider.findContactByKey(publicKey)!;
final snapshot = provider.buildMessageContactLocationSnapshot(
contact,
capturedAt: DateTime.now(),
);
expect(snapshot, isNotNull);
expect(snapshot!.source, equals('advert'));
expect(snapshot.location.latitude, closeTo(46.0569, 0.000001));
expect(snapshot.location.longitude, closeTo(14.5058, 0.000001));
});
}); });
} }

View File

@@ -0,0 +1,37 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_sar_app/providers/helpers/fragment_ack_wait_registry.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('FragmentAckWaitRegistry', () {
test('completes multiple waiters registered for the same key', () async {
final registry = FragmentAckWaitRegistry();
final first = registry.waitFor(
'voice:1',
timeout: const Duration(milliseconds: 200),
);
final second = registry.waitFor(
'voice:1',
timeout: const Duration(milliseconds: 200),
);
expect(registry.complete('voice:1'), equals(2));
expect(await first, isTrue);
expect(await second, isTrue);
});
test('times out and cleans up a waiter when no ack arrives', () async {
final registry = FragmentAckWaitRegistry();
final completed = await registry.waitFor(
'voice:2',
timeout: const Duration(milliseconds: 20),
);
expect(completed, isFalse);
expect(registry.complete('voice:2'), equals(0));
});
});
}

View File

@@ -0,0 +1,37 @@
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_sar_app/providers/helpers/message_delivery_tracker.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('MessageDeliveryTracker', () {
test('matches pending direct messages by contact', () {
final tracker = MessageDeliveryTracker();
final alice = Uint8List.fromList(List<int>.filled(32, 0xAA));
final bob = Uint8List.fromList(List<int>.filled(32, 0xBB));
tracker.trackPendingDirectMessage('alice-1', alice);
tracker.trackPendingDirectMessage('bob-1', bob);
tracker.trackPendingDirectMessage('alice-2', alice);
expect(tracker.popPendingDirectMessageId(alice), 'alice-1');
expect(tracker.popPendingDirectMessageId(bob), 'bob-1');
expect(tracker.popPendingDirectMessageId(alice), 'alice-2');
});
test('removeByMessageId clears pending queue state', () {
final tracker = MessageDeliveryTracker();
final alice = Uint8List.fromList(List<int>.filled(32, 0xAA));
tracker.trackPendingDirectMessage('alice-1', alice);
tracker.mapAckTagToMessageId(42, 'alice-1');
tracker.removeByMessageId('alice-1');
expect(tracker.getMessageIdForAck(42), isNull);
expect(tracker.popPendingDirectMessageId(alice), isNull);
});
});
}

View File

@@ -0,0 +1,86 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_sar_app/providers/helpers/session_metadata_restore.dart';
import 'package:meshcore_sar_app/utils/image_message_parser.dart';
import 'package:meshcore_sar_app/utils/voice_message_parser.dart';
void main() {
group('restoreSessionMetadataFromMessages', () {
test(
'restores voice and image session senders from persisted envelopes',
() {
final voiceEnvelope = VoiceEnvelope(
sessionId: '00112233',
mode: VoicePacketMode.mode1200,
total: 4,
durationMs: 4000,
senderKey6: 'AABBCCDDEEFF',
timestampSec: 123456,
);
final imageEnvelope = ImageEnvelope(
sessionId: '195cb2fb',
format: ImageFormat.avif,
total: 7,
width: 118,
height: 256,
sizeBytes: 1069,
senderKey6: 'FE8B30EE05FC',
timestampSec: 123457,
);
final restored = restoreSessionMetadataFromMessages([
'plain text',
voiceEnvelope.encodeText(),
imageEnvelope.encode(),
]);
expect(
restored.voiceSenderKeyBySession,
equals({'00112233': 'aabbccddeeff'}),
);
expect(restored.imageEnvelopeBySession.keys, equals({'195cb2fb'}));
expect(
restored.imageEnvelopeBySession['195cb2fb']?.senderKey6,
equals('fe8b30ee05fc'),
);
},
);
test('keeps latest envelope when a session appears multiple times', () {
final first = ImageEnvelope(
sessionId: '195cb2fb',
format: ImageFormat.avif,
total: 7,
width: 100,
height: 100,
sizeBytes: 900,
senderKey6: '001122334455',
timestampSec: 100,
);
final second = ImageEnvelope(
sessionId: '195cb2fb',
format: ImageFormat.jpeg,
total: 8,
width: 118,
height: 256,
sizeBytes: 1069,
senderKey6: 'AABBCCDDEEFF',
timestampSec: 101,
);
final restored = restoreSessionMetadataFromMessages([
first.encode(),
second.encode(),
]);
expect(restored.imageEnvelopeBySession.length, equals(1));
expect(
restored.imageEnvelopeBySession['195cb2fb']?.senderKey6,
equals('aabbccddeeff'),
);
expect(
restored.imageEnvelopeBySession['195cb2fb']?.format,
equals(ImageFormat.jpeg),
);
});
});
}

View File

@@ -0,0 +1,52 @@
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_sar_app/providers/image_provider.dart';
import 'package:meshcore_sar_app/utils/image_message_parser.dart';
import 'package:shared_preferences/shared_preferences.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
SharedPreferences.setMockInitialValues({});
group('ImageProvider cancel receive', () {
test('ignores incoming fragments after cancel until resumed', () {
final provider = ImageProvider();
const sessionId = '01020304';
const envelope = ImageEnvelope(
sessionId: sessionId,
format: ImageFormat.avif,
total: 2,
width: 32,
height: 32,
sizeBytes: 4,
senderKey6: 'aabbccddeeff',
timestampSec: 1700000000,
);
final fragment = ImagePacket(
sessionId: sessionId,
format: ImageFormat.avif,
index: 0,
total: 2,
data: Uint8List.fromList([1, 2]),
);
provider.registerEnvelope(envelope);
provider.cancelIncomingSession(sessionId);
expect(provider.isReceiveCanceled(sessionId), isTrue);
expect(provider.session(sessionId), isNull);
provider.addFragment(fragment, width: 32, height: 32);
expect(provider.session(sessionId), isNull);
provider.resumeIncomingSession(sessionId);
provider.registerEnvelope(envelope);
provider.addFragment(fragment, width: 32, height: 32);
expect(provider.isReceiveCanceled(sessionId), isFalse);
expect(provider.session(sessionId)?.receivedCount, equals(1));
});
});
}

View File

@@ -0,0 +1,236 @@
import 'dart:typed_data';
import 'package:fake_async/fake_async.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_sar_app/models/contact.dart';
import 'package:meshcore_sar_app/models/message.dart';
import 'package:meshcore_sar_app/providers/messages_provider.dart';
Contact _buildContact() {
return Contact(
publicKey: Uint8List.fromList(List<int>.generate(32, (i) => i)),
type: ContactType.chat,
flags: 0,
outPathLen: 1,
outPath: Uint8List.fromList([1, 2, 3, 4]),
advName: 'Teammate',
lastAdvert: 1700000000,
advLat: 0,
advLon: 0,
lastMod: 1700000000,
);
}
Message _buildDirectMessage(String id) {
return Message(
id: id,
messageType: MessageType.contact,
senderPublicKeyPrefix: Uint8List.fromList([0, 1, 2, 3, 4, 5]),
pathLen: 0,
textType: MessageTextType.plain,
senderTimestamp: 1700000000,
text: 'hello',
receivedAt: DateTime.now(),
deliveryStatus: MessageDeliveryStatus.sending,
recipientPublicKey: Uint8List.fromList(List<int>.generate(32, (i) => i)),
);
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('MessagesProvider retransmission', () {
test('direct messages stay pending until delivery ACK arrives', () {
final provider = MessagesProvider();
provider.addSentMessage(
_buildDirectMessage('m1'),
contact: _buildContact(),
);
provider.markMessageSent('m1', 77, 250);
expect(
provider.messages.single.deliveryStatus,
MessageDeliveryStatus.sending,
);
expect(provider.messages.single.expectedAckTag, 77);
provider.markMessageDelivered(77, 180);
expect(
provider.messages.single.deliveryStatus,
MessageDeliveryStatus.delivered,
);
expect(provider.messages.single.roundTripTimeMs, 180);
});
test('channel messages are marked sent immediately', () {
final provider = MessagesProvider();
provider.addSentMessage(
Message(
id: 'c1',
messageType: MessageType.channel,
senderPublicKeyPrefix: Uint8List.fromList([0, 1, 2, 3, 4, 5]),
channelIdx: 0,
pathLen: 0,
textType: MessageTextType.plain,
senderTimestamp: 1700000000,
text: 'broadcast',
receivedAt: DateTime.now(),
deliveryStatus: MessageDeliveryStatus.sending,
),
);
provider.markMessageSent('c1', 0, 0);
expect(
provider.messages.single.deliveryStatus,
MessageDeliveryStatus.sent,
);
});
test('missing ACK schedules a delayed retransmission', () {
fakeAsync((async) {
final provider = MessagesProvider();
var retryCalls = 0;
provider.sendMessageCallback =
({
required contactPublicKey,
required text,
required messageId,
required contact,
retryAttempt = 0,
}) async {
retryCalls += 1;
return true;
};
provider.addSentMessage(
_buildDirectMessage('m2'),
contact: _buildContact(),
);
provider.markMessageSent('m2', 88, 10);
async.elapse(const Duration(milliseconds: 11));
async.flushMicrotasks();
expect(provider.messages.single.retryAttempt, 1);
expect(
provider.messages.single.deliveryStatus,
MessageDeliveryStatus.sending,
);
expect(retryCalls, 0);
async.elapse(const Duration(seconds: 4));
async.flushMicrotasks();
expect(retryCalls, 1);
});
});
test('uses calculated timeout when radio timeout is missing', () {
final provider = MessagesProvider();
provider.addSentMessage(
_buildDirectMessage('m3'),
contact: _buildContact(),
);
provider.markMessageSent('m3', 99, 0);
expect(provider.messages.single.suggestedTimeoutMs, isNotNull);
expect(
provider.messages.single.suggestedTimeoutMs!,
greaterThanOrEqualTo(4000),
);
});
test('older retry ack still marks message delivered', () {
final provider = MessagesProvider();
provider.addSentMessage(
_buildDirectMessage('m4'),
contact: _buildContact(),
);
provider.markMessageSent('m4', 111, 10);
provider.markMessageSent('m4', 112, 10);
provider.markMessageDelivered(111, 220);
expect(
provider.messages.single.deliveryStatus,
MessageDeliveryStatus.delivered,
);
expect(provider.messages.single.roundTripTimeMs, 220);
});
test('repeated max-retry failures request path reset', () async {
final provider = MessagesProvider();
final contact = _buildContact();
final resetRequests = <(String, int)>[];
provider.onDirectPathFailedCallback =
({required contact, required failureStreak}) async {
resetRequests.add((contact.advName, failureStreak));
};
provider.addSentMessage(
_buildDirectMessage('m5').copyWith(
retryAttempt: 3,
usedFloodFallback: true,
),
contact: contact,
);
provider.markMessageFailed('m5');
provider.addSentMessage(
_buildDirectMessage('m6').copyWith(
retryAttempt: 3,
usedFloodFallback: true,
),
contact: contact,
);
provider.markMessageFailed('m6');
await Future<void>.delayed(Duration.zero);
expect(resetRequests, [('Teammate', 2)]);
});
test('successful delivery clears path failure streak', () async {
final provider = MessagesProvider();
final contact = _buildContact();
final resetRequests = <int>[];
provider.onDirectPathFailedCallback =
({required contact, required failureStreak}) async {
resetRequests.add(failureStreak);
};
provider.addSentMessage(
_buildDirectMessage('m7').copyWith(
retryAttempt: 3,
usedFloodFallback: true,
),
contact: contact,
);
provider.markMessageFailed('m7');
provider.addSentMessage(
_buildDirectMessage('m8'),
contact: contact,
);
provider.markMessageSent('m8', 123, 10);
provider.markMessageDelivered(123, 150);
provider.addSentMessage(
_buildDirectMessage('m9').copyWith(
retryAttempt: 3,
usedFloodFallback: true,
),
contact: contact,
);
provider.markMessageFailed('m9');
await Future<void>.delayed(Duration.zero);
expect(resetRequests, isEmpty);
});
});
}

View File

@@ -1,13 +1,20 @@
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_sar_app/models/message.dart'; import 'package:meshcore_sar_app/models/message.dart';
import 'package:meshcore_sar_app/models/message_contact_location.dart';
import 'package:meshcore_sar_app/providers/messages_provider.dart'; import 'package:meshcore_sar_app/providers/messages_provider.dart';
import 'package:meshcore_sar_app/utils/voice_message_parser.dart'; import 'package:meshcore_sar_app/utils/voice_message_parser.dart';
import 'package:latlong2/latlong.dart';
import 'package:shared_preferences/shared_preferences.dart';
void main() { void main() {
TestWidgetsFlutterBinding.ensureInitialized(); TestWidgetsFlutterBinding.ensureInitialized();
group('MessagesProvider voice detection', () { group('MessagesProvider voice detection', () {
setUp(() {
SharedPreferences.setMockInitialValues({});
});
test('marks VE2 envelope messages as voice', () { test('marks VE2 envelope messages as voice', () {
final provider = MessagesProvider(); final provider = MessagesProvider();
final envelope = VoiceEnvelope( final envelope = VoiceEnvelope(
@@ -65,5 +72,39 @@ void main() {
expect(stored.isVoice, isTrue); expect(stored.isVoice, isTrue);
expect(stored.voiceId, equals('00112233')); expect(stored.voiceId, equals('00112233'));
}); });
test('persists received contact location snapshots', () async {
final provider = MessagesProvider();
final message = Message(
id: 'm3',
messageType: MessageType.contact,
pathLen: 1,
textType: MessageTextType.plain,
senderTimestamp: 1700000002,
text: 'status update',
receivedAt: DateTime.now(),
senderPublicKeyPrefix: Uint8List.fromList([0, 1, 2, 3, 4, 5]),
);
provider.addMessage(
message,
contactLocationSnapshot: MessageContactLocation(
location: const LatLng(46.0569, 14.5058),
source: 'advert',
capturedAt: DateTime.now(),
sourceTimestamp: DateTime.now(),
),
);
await Future<void>.delayed(const Duration(milliseconds: 50));
final restoredProvider = MessagesProvider();
await restoredProvider.initialize();
final snapshot = restoredProvider.getMessageContactLocation('m3');
expect(snapshot, isNotNull);
expect(snapshot!.source, equals('advert'));
expect(snapshot.location.latitude, closeTo(46.0569, 0.000001));
expect(snapshot.location.longitude, closeTo(14.5058, 0.000001));
});
}); });
} }

View File

@@ -410,6 +410,24 @@ void main() {
expect(decoded.batteryMilliVolts, closeTo(3850, 1)); expect(decoded.batteryMilliVolts, closeTo(3850, 1));
}); });
test('stops parsing at zero-padded telemetry tail', () {
final payload = Uint8List.fromList([
0x01, 0x74, 0x01, 0x5F, // voltage 3.51V
0x01, 0x88, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00,
]);
final decoded = CayenneLppParser.parse(payload);
expect(decoded.batteryMilliVolts, closeTo(3510, 1));
expect(decoded.batteryPercentage, closeTo(42.5, 0.1));
expect(decoded.gpsLocation, isNotNull);
expect(decoded.gpsLocation!.latitude, 0.0);
expect(decoded.gpsLocation!.longitude, 0.0);
expect(decoded.extraSensorData?['digital_input_0'], isNull);
});
test('empty data returns empty telemetry', () { test('empty data returns empty telemetry', () {
final empty = Uint8List(0); final empty = Uint8List(0);
final decoded = CayenneLppParser.parse(empty); final decoded = CayenneLppParser.parse(empty);

View File

@@ -0,0 +1,26 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_sar_app/utils/avatar_label_helper.dart';
void main() {
group('AvatarLabelHelper.buildLabel', () {
test('returns two initials for multi-word names', () {
expect(AvatarLabelHelper.buildLabel('John Smith'), 'JS');
});
test('returns first two characters for single-word names', () {
expect(AvatarLabelHelper.buildLabel('Alpha'), 'AL');
});
test('allows three compact characters for hash-prefixed names', () {
expect(AvatarLabelHelper.buildLabel('#ops'), '#OP');
});
test('removes spacing after hash-prefixed names', () {
expect(AvatarLabelHelper.buildLabel('# foo alpha'), '#FO');
});
test('keeps non-hash labels at two characters', () {
expect(AvatarLabelHelper.buildLabel('abc'), 'AB');
});
});
}

View File

@@ -113,4 +113,17 @@ void main() {
expect(parsed.index, equals(9)); expect(parsed.index, equals(9));
}); });
}); });
group('safeImageDataBytesForPath', () {
test('caps direct-route fragments to conservative default size', () {
expect(safeImageDataBytesForPath(0), equals(ImagePacket.maxDataBytes));
});
test('shrinks for longer paths but never exceeds conservative default', () {
expect(
safeImageDataBytesForPath(2),
lessThanOrEqualTo(ImagePacket.maxDataBytes),
);
});
});
} }

View File

@@ -0,0 +1,106 @@
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_sar_app/models/contact.dart';
import 'package:meshcore_sar_app/widgets/common/contact_avatar.dart';
void main() {
Contact buildContact({
required String name,
required ContactType type,
int secondByte = 0,
}) {
final publicKey = Uint8List(32);
publicKey[1] = secondByte;
return Contact(
publicKey: publicKey,
type: type,
flags: 0,
outPathLen: 0,
outPath: Uint8List(0),
advName: name,
lastAdvert: 0,
advLat: 0,
advLon: 0,
lastMod: 0,
);
}
Future<void> pumpAvatar(WidgetTester tester, Contact contact) async {
await tester.pumpWidget(
MaterialApp(
home: Scaffold(body: Center(child: ContactAvatar(contact: contact))),
),
);
}
testWidgets('renders label avatar for rooms', (tester) async {
await pumpAvatar(
tester,
buildContact(name: 'Operations Room', type: ContactType.room),
);
expect(find.text('OR'), findsOneWidget);
expect(find.byType(CircleAvatar), findsNothing);
expect(find.byIcon(Icons.meeting_room), findsNothing);
});
testWidgets('renders hash label avatar for rooms', (tester) async {
await pumpAvatar(
tester,
buildContact(name: '#ops-room', type: ContactType.room),
);
expect(find.text('#OP'), findsOneWidget);
expect(find.byType(CircleAvatar), findsNothing);
expect(find.byIcon(Icons.meeting_room), findsNothing);
});
testWidgets('renders compact hash label avatar when name includes spacing', (
tester,
) async {
await pumpAvatar(
tester,
buildContact(name: '# foo alpha', type: ContactType.room),
);
expect(find.text('#FO'), findsOneWidget);
expect(find.byType(CircleAvatar), findsNothing);
expect(find.byIcon(Icons.meeting_room), findsNothing);
});
testWidgets('renders label avatar for channels', (tester) async {
await pumpAvatar(
tester,
buildContact(name: '#ops', type: ContactType.channel, secondByte: 3),
);
expect(find.text('#OP'), findsOneWidget);
expect(find.byType(CircleAvatar), findsNothing);
expect(find.byIcon(Icons.public), findsNothing);
});
testWidgets('renders non-hash label avatar for channels', (tester) async {
await pumpAvatar(
tester,
buildContact(name: 'Command Net', type: ContactType.channel, secondByte: 3),
);
expect(find.text('CN'), findsOneWidget);
expect(find.byType(CircleAvatar), findsNothing);
expect(find.byIcon(Icons.public), findsNothing);
});
testWidgets('renders round avatar for chat contacts', (tester) async {
await pumpAvatar(
tester,
buildContact(name: 'John Smith', type: ContactType.chat),
);
expect(find.text('JS'), findsOneWidget);
expect(find.byType(CircleAvatar), findsOneWidget);
expect(find.byIcon(Icons.person), findsNothing);
});
}