Compare commits

...

8 Commits

Author SHA1 Message Date
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
23 changed files with 957 additions and 318 deletions

View File

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

View File

@@ -43,7 +43,7 @@
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>81</string>
<string>84</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<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.000211">
</testcase>
<testcase classname="fastlane.lanes" name="1: increment_build_number" time="0.428509">
<testcase classname="fastlane.lanes" name="1: increment_build_number" time="0.408783">
</testcase>
<testcase classname="fastlane.lanes" name="2: build_app" time="102.185635">
<testcase classname="fastlane.lanes" name="2: build_app" time="120.492323">
</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="12.544865">
</testcase>

View File

@@ -8,6 +8,8 @@ import 'drawing_provider.dart';
import 'channels_provider.dart';
import 'voice_provider.dart';
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/location_tracking_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, Timer> _voiceMissingRetryTimers = {};
final Map<String, int> _voiceMissingRetryAttempts = {};
final Map<String, Completer<void>> _voiceFragmentAckWaiters = {};
final Map<String, Completer<void>> _imageFragmentAckWaiters = {};
final FragmentAckWaitRegistry _voiceFragmentAckWaiters =
FragmentAckWaitRegistry();
final FragmentAckWaitRegistry _imageFragmentAckWaiters =
FragmentAckWaitRegistry();
Timer? _packetCaptureFlushTimer;
String? _lastPersistedPacketSignature;
bool _isPersistingPacketCapture = false;
@@ -165,12 +169,35 @@ class AppProvider with ChangeNotifier {
// Give DrawingProvider a moment to finish loading too
await Future.delayed(const Duration(milliseconds: 100));
_restoreSessionMetadataFromMessages();
debugPrint(
'🔄 [AppProvider] Early sync: syncing drawings from messages...',
);
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
Future<void> _loadSimpleMode() async {
try {
@@ -788,12 +815,12 @@ class AppProvider with ChangeNotifier {
final imageFetchRequest = ImageFetchRequest.tryParseBinary(payload);
if (imageFetchRequest != null) {
final requester = contactsProvider.findContactByPrefixHex(
imageFetchRequest.requesterKey6,
);
final requester = _resolveImageFetchRequester(imageFetchRequest);
if (requester == null) {
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(
text:
@@ -804,7 +831,9 @@ class AppProvider with ChangeNotifier {
}
if (requester.outPathLen > _maxDirectPayloadHops) {
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(
text:
@@ -813,6 +842,10 @@ class AppProvider with ChangeNotifier {
);
return;
}
debugPrint(
'📷 [AppProvider] Serving image session ${imageFetchRequest.sessionId} '
'to ${requester.advName} via ${requester.outPathLen} hop(s)',
);
unawaited(
imageProvider.serveSessionTo(
sessionId: imageFetchRequest.sessionId,
@@ -1189,6 +1222,42 @@ class AppProvider with ChangeNotifier {
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(
String sessionId, {
required bool justComplete,
@@ -1312,54 +1381,48 @@ class AppProvider with ChangeNotifier {
required String sessionId,
required int index,
Duration timeout = const Duration(seconds: 8),
}) async {
final key = _fragmentAckKey(sessionId, index);
final completer = Completer<void>();
_voiceFragmentAckWaiters[key] = completer;
try {
await completer.future.timeout(timeout);
return true;
} catch (_) {
if (_voiceFragmentAckWaiters[key] == completer) {
_voiceFragmentAckWaiters.remove(key);
}
return false;
}
}
}) => _voiceFragmentAckWaiters.waitFor(
_fragmentAckKey(sessionId, index),
timeout: timeout,
);
void _completeVoiceFragmentAck(String sessionId, int index) {
final key = _fragmentAckKey(sessionId, index);
final completer = _voiceFragmentAckWaiters.remove(key);
if (completer != null && !completer.isCompleted) {
completer.complete();
final completed = _voiceFragmentAckWaiters.complete(
_fragmentAckKey(sessionId, index),
);
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({
required String sessionId,
required int index,
Duration timeout = const Duration(seconds: 8),
}) async {
final key = _fragmentAckKey(sessionId, index);
final completer = Completer<void>();
_imageFragmentAckWaiters[key] = completer;
try {
await completer.future.timeout(timeout);
return true;
} catch (_) {
if (_imageFragmentAckWaiters[key] == completer) {
_imageFragmentAckWaiters.remove(key);
}
return false;
}
}
}) => _imageFragmentAckWaiters.waitFor(
_fragmentAckKey(sessionId, index),
timeout: timeout,
);
void _completeImageFragmentAck(String sessionId, int index) {
final key = _fragmentAckKey(sessionId, index);
final completer = _imageFragmentAckWaiters.remove(key);
if (completer != null && !completer.isCompleted) {
completer.complete();
final completed = _imageFragmentAckWaiters.complete(
_fragmentAckKey(sessionId, index),
);
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) {

View File

@@ -1,6 +1,7 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
import 'package:crypto/crypto.dart';
import '../models/device_info.dart';
@@ -461,7 +462,7 @@ class ConnectionProvider with ChangeNotifier {
_isScanning = true;
_scannedDevices.clear();
_error = null;
notifyListeners();
_notifyListenersSafely();
debugPrint('✅ [Provider] Scan state initialized, notifying listeners');
try {
@@ -477,7 +478,7 @@ class ConnectionProvider with ChangeNotifier {
debugPrint(
'✅ [Provider] Added device to list: ${device.platformName} (RSSI: $rssi dBm), total: ${_scannedDevices.length}',
);
notifyListeners();
_notifyListenersSafely();
} else {
// Update RSSI if device already exists
final index = _scannedDevices.indexWhere(
@@ -488,7 +489,7 @@ class ConnectionProvider with ChangeNotifier {
debugPrint(
' 🔄 [Provider] Updated RSSI for ${device.platformName}: $rssi dBm',
);
notifyListeners();
_notifyListenersSafely();
} else {
debugPrint(
' ⏭️ [Provider] Device already in list with same RSSI, skipping',
@@ -502,7 +503,7 @@ class ConnectionProvider with ChangeNotifier {
} finally {
debugPrint('🏁 [Provider] Scan completed');
_isScanning = false;
notifyListeners();
_notifyListenersSafely();
}
}
@@ -510,6 +511,18 @@ class ConnectionProvider with ChangeNotifier {
Future<void> stopScan() async {
await FlutterBluePlus.stopScan();
_isScanning = false;
_notifyListenersSafely();
}
void _notifyListenersSafely() {
final phase = SchedulerBinding.instance.schedulerPhase;
if (phase == SchedulerPhase.transientCallbacks ||
phase == SchedulerPhase.persistentCallbacks) {
SchedulerBinding.instance.addPostFrameCallback((_) {
notifyListeners();
});
return;
}
notifyListeners();
}

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

@@ -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.
final Map<String, ImageSession> _sessions = {};
final Set<String> _ignoredIncomingSessions = {};
/// Outgoing sessions cached for deferred serving.
final Map<String, _OutgoingSession> _outgoing = {};
@@ -88,6 +89,8 @@ class ImageProvider with ChangeNotifier {
bool hasOutgoing(String sessionId) => _outgoing.containsKey(sessionId);
Duration? estimateRemainingTransferTime(String sessionId) =>
_sessions[sessionId]?.estimateRemaining();
bool isReceiveCanceled(String sessionId) =>
_ignoredIncomingSessions.contains(sessionId);
List<int> missingFragmentIndices(String sessionId) {
final session = _sessions[sessionId];
@@ -107,6 +110,12 @@ class ImageProvider with ChangeNotifier {
///
/// Returns true when the session just became complete.
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(
fragment.sessionId,
() => ImageSession(
@@ -135,9 +144,25 @@ class ImageProvider with ChangeNotifier {
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
/// before any binary fragments arrive).
void registerEnvelope(ImageEnvelope envelope) {
if (_ignoredIncomingSessions.contains(envelope.sessionId)) {
return;
}
final existing = _sessions[envelope.sessionId];
if (existing == null) {
_sessions[envelope.sessionId] = ImageSession(
@@ -249,6 +274,7 @@ class ImageProvider with ChangeNotifier {
Future<void> clearAll() async {
_sessions.clear();
_outgoing.clear();
_ignoredIncomingSessions.clear();
notifyListeners();
try {
final prefs = await SharedPreferences.getInstance();

View File

@@ -59,6 +59,7 @@ class VoiceProvider with ChangeNotifier {
/// Active sessions keyed by sessionId.
final Map<String, VoiceSession> _sessions = {};
final Set<String> _ignoredIncomingSessions = {};
/// Currently playing session ID, or null.
String? _playingSessionId;
@@ -117,6 +118,8 @@ class VoiceProvider with ChangeNotifier {
_outgoingSessions.containsKey(sessionId);
Duration? estimateRemainingTransferTime(String sessionId) =>
_sessions[sessionId]?.estimateRemaining();
bool isReceiveCanceled(String sessionId) =>
_ignoredIncomingSessions.contains(sessionId);
List<int> missingPacketIndices(String 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.
/// Returns true if the session just became complete.
bool addPacket(VoicePacket packet) {
if (_ignoredIncomingSessions.contains(packet.sessionId)) {
debugPrint(
'⏹️ [VoiceProvider] Ignoring canceled incoming session ${packet.sessionId}',
);
return false;
}
_sessions.putIfAbsent(
packet.sessionId,
() => VoiceSession(
@@ -159,6 +168,23 @@ class VoiceProvider with ChangeNotifier {
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.
void cacheOutgoingSession(String sessionId, List<VoicePacket> packets) {
if (packets.isEmpty) return;
@@ -237,6 +263,7 @@ class VoiceProvider with ChangeNotifier {
Future<void> clearStoredVoiceData() async {
_sessions.clear();
_outgoingSessions.clear();
_ignoredIncomingSessions.clear();
_playingSessionId = null;
notifyListeners();
try {

View File

@@ -287,6 +287,61 @@ class _MessagesTabState extends State<MessagesTab> {
return 'Select recipient';
}
String _getCurrentScopeLabel() {
if (_destinationType ==
MessageDestinationPreferences.destinationTypeChannel) {
final channelName =
_selectedRecipient?.getLocalizedDisplayName(context) ??
AppLocalizations.of(context)!.publicChannel;
return 'Channel > $channelName';
}
if (_destinationType == MessageDestinationPreferences.destinationTypeRoom) {
final roomName =
_selectedRecipient?.displayName ??
AppLocalizations.of(context)!.messages;
return 'Room > $roomName';
}
final contactName =
_selectedRecipient?.displayName ??
AppLocalizations.of(context)!.messages;
return 'Direct > $contactName';
}
Widget _buildScopeIndicator() {
final theme = Theme.of(context);
return SizedBox(
width: double.infinity,
child: Align(
alignment: Alignment.centerLeft,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.visibility_outlined,
size: 14,
color: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(width: 6),
Flexible(
child: Text(
'View: ${_getCurrentScopeLabel()}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.left,
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
],
),
),
);
}
Future<void> _sendMessage() async {
final text = _textController.text.trim();
if (text.isEmpty) return;
@@ -641,21 +696,8 @@ class _MessagesTabState extends State<MessagesTab> {
'chunk=${imageDataBytesPerFragment}B',
);
// Push all fragments immediately for direct contacts.
// For channels, fragments are served on demand via IR1 fetch requests.
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}',
);
}
// Image fragments are always served on demand after an explicit IR2
// fetch request, including direct contacts.
} catch (e, st) {
debugPrint('❌ [Image] _pickAndSendImage: $e\n$st');
if (!mounted) return;
@@ -1571,130 +1613,176 @@ class _MessagesTabState extends State<MessagesTab> {
),
),
),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Quick actions (+) button
IconButton(
icon: Icon(_isRecording ? Icons.stop : Icons.add),
tooltip: _isRecording ? 'Stop recording' : 'More actions',
onPressed: _isRecording
? _stopAndSendVoice
: _showComposerActions,
style: IconButton.styleFrom(
backgroundColor: Theme.of(
context,
).colorScheme.primaryContainer,
foregroundColor: _isRecording
? Colors.red
: Theme.of(context).colorScheme.onPrimaryContainer,
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 8,
),
),
const SizedBox(width: 4),
// Destination switcher button
IconButton(
icon: Icon(_getDestinationIcon()),
tooltip: _getDestinationTooltip(),
onPressed: _showRecipientSelector,
style: IconButton.styleFrom(
backgroundColor:
_destinationType ==
MessageDestinationPreferences
.destinationTypeChannel
? Theme.of(
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
// Quick actions (+) button
IconButton(
icon: Icon(_isRecording ? Icons.stop : Icons.add),
tooltip: _isRecording
? 'Stop recording'
: 'More actions',
onPressed: _isRecording
? _stopAndSendVoice
: _showComposerActions,
style: IconButton.styleFrom(
backgroundColor: Theme.of(
context,
).colorScheme.surfaceContainerHighest
: Theme.of(context).colorScheme.secondaryContainer,
foregroundColor:
_destinationType ==
MessageDestinationPreferences
.destinationTypeChannel
? Theme.of(context).colorScheme.onSurface
: Theme.of(context).colorScheme.onSecondaryContainer,
),
),
const SizedBox(width: 4),
// Text field with embedded send button
Expanded(
child: TextField(
controller: _textController,
focusNode: _focusNode,
maxLength: _maxCharacters,
maxLines: null,
maxLengthEnforcement: MaxLengthEnforcement.enforced,
style: const TextStyle(fontSize: 14),
decoration: InputDecoration(
hintText: AppLocalizations.of(context)!.typeYourMessage,
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,
).colorScheme.primary),
),
onPressed:
_isRecording ||
_isSendingVoice ||
_textController.text.trim().isEmpty
? null
: _sendMessage,
tooltip: _isRecording
? 'Recording... release to send voice'
: (_isSendingVoice
? 'Sending voice...'
: _voiceSupported
? 'Send (long press to record voice)'
: 'Send'),
).colorScheme.primaryContainer,
foregroundColor: _isRecording
? Colors.red
: Theme.of(
context,
).colorScheme.onPrimaryContainer,
),
),
),
textInputAction: TextInputAction.send,
onSubmitted: (_) => _sendMessage(),
const SizedBox(width: 4),
// Destination switcher button
IconButton(
icon: Icon(_getDestinationIcon()),
tooltip: _getDestinationTooltip(),
onPressed: _showRecipientSelector,
style: IconButton.styleFrom(
backgroundColor:
_destinationType ==
MessageDestinationPreferences
.destinationTypeChannel
? Theme.of(
context,
).colorScheme.surfaceContainerHighest
: Theme.of(
context,
).colorScheme.secondaryContainer,
foregroundColor:
_destinationType ==
MessageDestinationPreferences
.destinationTypeChannel
? Theme.of(context).colorScheme.onSurface
: Theme.of(
context,
).colorScheme.onSecondaryContainer,
),
),
const SizedBox(width: 4),
// Scope label + text field share the same alignment
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(
left: 4,
right: 4,
bottom: 6,
),
child: _buildScopeIndicator(),
),
TextField(
controller: _textController,
focusNode: _focusNode,
maxLength: _maxCharacters,
maxLines: null,
maxLengthEnforcement:
MaxLengthEnforcement.enforced,
style: const TextStyle(fontSize: 14),
decoration: InputDecoration(
hintText: AppLocalizations.of(
context,
)!.typeYourMessage,
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)
.colorScheme
.primary),
),
onPressed:
_isRecording ||
_isSendingVoice ||
_textController.text
.trim()
.isEmpty
? null
: _sendMessage,
tooltip: _isRecording
? 'Recording... release to send voice'
: (_isSendingVoice
? 'Sending voice...'
: _voiceSupported
? 'Send (long press to record voice)'
: 'Send'),
),
),
),
textInputAction: TextInputAction.send,
onSubmitted: (_) => _sendMessage(),
),
],
),
),
],
),
),
],

View File

@@ -25,6 +25,14 @@ class CayenneLppParser {
int fieldCount = 0;
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 {
fieldCount++;
debugPrint(
@@ -250,6 +258,14 @@ class CayenneLppParser {
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
/// Standard Cayenne LPP GPS format (type 0x88):
/// - Latitude: 3 bytes, signed 24-bit, big-endian, × 10000

View File

@@ -125,7 +125,10 @@ int safeImageDataBytesForPath(int pathLen) {
? maxRawPayloadFromCommandFrame
: maxRawPayloadFromMesh;
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.
@@ -361,9 +364,7 @@ class ImageFetchRequest {
final ts = _parseInt(parts[3], base36: true);
final normalizedWant = wantToken == 'a'
? 'all'
: ((wantToken.startsWith('m'))
? 'missing'
: wantToken);
: ((wantToken.startsWith('m')) ? 'missing' : wantToken);
if (sid == null) return null;
final missingIndices = <int>[];
@@ -532,7 +533,11 @@ String _toBase36(int value) => value.toRadixString(36);
String _encodeSessionId(String 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);
return value.toRadixString(36);
@@ -546,10 +551,7 @@ String? _decodeSessionId(String token) {
}
String _encodeMissingIndicesCompact(List<int> indices) {
final sorted = indices
.where((v) => v >= 0 && v <= 254)
.toSet()
.toList()
final sorted = indices.where((v) => v >= 0 && v <= 254).toSet().toList()
..sort();
if (sorted.isEmpty) return '';
final chunks = <String>[];
@@ -562,7 +564,9 @@ String _encodeMissingIndicesCompact(List<int> indices) {
continue;
}
chunks.add(
start == prev ? _toBase36(start) : '${_toBase36(start)}-${_toBase36(prev)}',
start == prev
? _toBase36(start)
: '${_toBase36(start)}-${_toBase36(prev)}',
);
start = curr;
prev = curr;

View File

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

View File

@@ -68,8 +68,7 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
senderKey6FromEnvelope: envelope.senderKey6,
senderName: widget.message.senderName,
);
final effectivePathLen =
sender != null && sender.outPathLen >= 0
final effectivePathLen = sender != null && sender.outPathLen >= 0
? sender.outPathLen
: widget.message.pathLen;
final isComplete = imageProvider.isComplete(envelope.sessionId);
@@ -79,7 +78,12 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
if (_isRequesting && isComplete) {
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',
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) ...[
const Icon(Icons.broken_image, color: Colors.red, size: 36),
Column(
mainAxisSize: MainAxisSize.min,
children: [
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 ...[
// Tap-to-load icon.
IconButton(
@@ -221,6 +255,8 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
}) async {
if (_isRequesting) return;
final conn = context.read<ConnectionProvider>();
final imageProvider = context.read<ip.ImageProvider>();
imageProvider.resumeIncomingSession(envelope.sessionId);
final contactsProvider = context.read<ContactsProvider>();
final resolution = await TransmissionTargetResolver.resolveFetchTarget(
contactsProvider: contactsProvider,
@@ -264,7 +300,6 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
}
setState(() => _errorText = null);
final imageProvider = context.read<ip.ImageProvider>();
final deviceKey = conn.deviceInfo.publicKey;
if (deviceKey == null || deviceKey.length < 6) {
await _showBlockingAlert(
@@ -322,7 +357,9 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
if (!mounted) return;
// 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(
fragmentCount: missing.isEmpty ? envelope.total : missing.length,
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 {
if (!mounted) return;
_showToast('$title: $message');

View File

@@ -468,7 +468,7 @@ class _MessageBubbleState extends State<MessageBubble> {
'Sent message: ${widget.message.isSentMessage}',
'Read: ${widget.message.isRead}',
'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()})',
'Received at (RFC3339): ${_formatRfc3339(widget.message.receivedAt)}',
'Channel index: ${widget.message.channelIdx ?? '-'}',
@@ -635,8 +635,7 @@ class _MessageBubbleState extends State<MessageBubble> {
_techBadge(
context,
icon: Icons.route,
label:
'${widget.message.pathLen} hop${widget.message.pathLen == 1 ? '' : 's'}',
label: _hopDisplayLabel(widget.message),
),
_techBadge(
context,
@@ -1527,9 +1526,7 @@ class _MessageBubbleState extends State<MessageBubble> {
required int? rssiDbm,
required double? snrDb,
}) {
final hopLabel = message.pathLen == 0
? 'Direct'
: '${message.pathLen} hop${message.pathLen == 1 ? '' : 's'}';
final hopLabel = _hopDisplayLabel(message);
return Wrap(
spacing: 4,
@@ -1570,6 +1567,21 @@ 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);
}
Widget _techChip(
BuildContext context, {
required IconData icon,
@@ -1736,9 +1748,10 @@ class _MessageBubbleState extends State<MessageBubble> {
: message.getRichDisplayName(senderContact);
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;
String? recipientDisplayName;
String? channelDisplayName;
if (isOwnMessage &&
message.isContactMessage &&
message.recipientPublicKey != null) {
@@ -1766,103 +1779,123 @@ class _MessageBubbleState extends State<MessageBubble> {
recipientContact.displayName ?? recipientContact.advName;
}
}
} else if (isOwnMessage && message.isChannelMessage) {
} else if (message.isChannelMessage) {
if (message.channelIdx == 0) {
recipientDisplayName = l10n.publicChannel;
channelDisplayName = l10n.publicChannel;
} else {
final channelContact = contactsProvider.channels.where((c) {
return c.publicKey.length > 1 && c.publicKey[1] == message.channelIdx;
}).firstOrNull;
recipientDisplayName =
channelDisplayName =
channelContact?.getLocalizedDisplayName(context) ??
'${l10n.channel} ${message.channelIdx}';
}
if (isOwnMessage) {
recipientDisplayName = channelDisplayName;
}
}
final recipientSubtitle =
isOwnMessage && message.isChannelMessage && recipientDisplayName != null
? '${l10n.channel}: $recipientDisplayName'
: recipientDisplayName;
final receivedChannelSubtitle =
!isOwnMessage && message.isChannelMessage && channelDisplayName != null
? '${l10n.channel}: $channelDisplayName'
: null;
return GestureDetector(
onTap: () => _handleBubbleTap(
isSarMarker: isSarMarker,
isDrawing: message.isDrawing,
final shouldFloatBubble = message.isChannelMessage || widget.isCompact;
final bubble = ConstrainedBox(
constraints: BoxConstraints(
maxWidth: shouldFloatBubble
? MediaQuery.of(context).size.width * 0.78
: double.infinity,
),
onLongPress: widget.isCompact ? null : () => _showMessageOptions(context),
child: Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: widget.isHighlighted
? Theme.of(context).colorScheme.primaryContainer
: isSarMarker
? _getSarMarkerColor(context, isDarkMode)
: message.isDrawing
? (isDarkMode
? Theme.of(
child: GestureDetector(
onTap: () => _handleBubbleTap(
isSarMarker: isSarMarker,
isDrawing: message.isDrawing,
),
onLongPress: widget.isCompact
? null
: () => _showMessageOptions(context),
child: Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: widget.isHighlighted
? Theme.of(context).colorScheme.primaryContainer
: isSarMarker
? _getSarMarkerColor(context, isDarkMode)
: message.isDrawing
? (isDarkMode
? Theme.of(
context,
).colorScheme.primary.withValues(alpha: 0.15)
: Theme.of(
context,
).colorScheme.primary.withValues(alpha: 0.08))
: _getMessageBubbleColor(context, isOwnMessage, isDarkMode),
borderRadius: BorderRadius.circular(12),
border: widget.isHighlighted
? Border.all(
color: Theme.of(context).colorScheme.primary,
width: 3,
)
: isSarMarker
? Border.all(
color: _getSarMarkerBorderColor(context, isDarkMode),
width: 2,
)
: message.isDrawing
? Border.all(
color: Theme.of(
context,
).colorScheme.primary.withValues(alpha: 0.15)
: Theme.of(
).colorScheme.primary.withValues(alpha: 0.4),
width: 2,
)
: isOwnMessage
? Border.all(
color: Theme.of(
context,
).colorScheme.primary.withValues(alpha: 0.08))
: _getMessageBubbleColor(context, isOwnMessage, isDarkMode),
borderRadius: BorderRadius.circular(12),
border: widget.isHighlighted
? Border.all(
color: Theme.of(context).colorScheme.primary,
width: 3,
)
: isSarMarker
? Border.all(
color: _getSarMarkerBorderColor(context, isDarkMode),
width: 2,
)
: message.isDrawing
? Border.all(
color: Theme.of(
context,
).colorScheme.primary.withValues(alpha: 0.4),
width: 2,
)
: isOwnMessage
? Border.all(
color: Theme.of(
context,
).colorScheme.primary.withValues(alpha: 0.3),
width: 1.5,
)
: !message.isRead &&
!message.isSentMessage &&
!message.isSystemMessage
? Border.all(color: Colors.blue, width: 1.5)
: null,
boxShadow: widget.isHighlighted
? [
BoxShadow(
color: Theme.of(
context,
).colorScheme.primary.withValues(alpha: 0.5),
blurRadius: 12,
spreadRadius: 2,
offset: const Offset(0, 2),
),
]
: isSarMarker || message.isDrawing
? [
BoxShadow(
color:
(isSarMarker
? _getSarMarkerBorderColor(context, isDarkMode)
: Theme.of(context).colorScheme.primary)
.withValues(alpha: 0.3),
blurRadius: 8,
offset: const Offset(0, 2),
),
]
: null,
),
child: Column(
).colorScheme.primary.withValues(alpha: 0.3),
width: 1.5,
)
: !message.isRead &&
!message.isSentMessage &&
!message.isSystemMessage
? Border.all(color: Colors.blue, width: 1.5)
: null,
boxShadow: widget.isHighlighted
? [
BoxShadow(
color: Theme.of(
context,
).colorScheme.primary.withValues(alpha: 0.5),
blurRadius: 12,
spreadRadius: 2,
offset: const Offset(0, 2),
),
]
: isSarMarker || message.isDrawing
? [
BoxShadow(
color:
(isSarMarker
? _getSarMarkerBorderColor(
context,
isDarkMode,
)
: Theme.of(context).colorScheme.primary)
.withValues(alpha: 0.3),
blurRadius: 8,
offset: const Offset(0, 2),
),
]
: null,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header: Badge (if SAR or drawing) and time
@@ -1984,15 +2017,17 @@ class _MessageBubbleState extends State<MessageBubble> {
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
// Show destination for sent direct/channel messages on a separate line
if (isOwnMessage &&
recipientSubtitle != null &&
!widget.isCompact) ...[
// Show destination/source context on a separate line.
if (!widget.isCompact &&
(recipientSubtitle != null ||
receivedChannelSubtitle != null)) ...[
const SizedBox(height: 2),
Row(
children: [
Icon(
Icons.arrow_forward,
isOwnMessage
? Icons.arrow_forward
: Icons.arrow_back,
size: 12,
color: Theme.of(context)
.textTheme
@@ -2003,7 +2038,9 @@ class _MessageBubbleState extends State<MessageBubble> {
const SizedBox(width: 4),
Expanded(
child: Text(
recipientSubtitle,
isOwnMessage
? recipientSubtitle!
: receivedChannelSubtitle!,
style: Theme.of(context).textTheme.labelSmall
?.copyWith(
color: Theme.of(context)
@@ -2460,9 +2497,21 @@ class _MessageBubbleState extends State<MessageBubble> {
),
],
],
),
),
),
),
);
if (!shouldFloatBubble) {
return bubble;
}
return Row(
mainAxisAlignment: isOwnMessage
? MainAxisAlignment.end
: MainAxisAlignment.start,
children: [Flexible(child: bubble)],
);
}
}

View File

@@ -65,8 +65,7 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
senderKey6FromEnvelope: envelope?.senderKey6,
senderName: widget.message.senderName,
);
final effectivePathLen =
sender != null && sender.outPathLen >= 0
final effectivePathLen = sender != null && sender.outPathLen >= 0
? sender.outPathLen
: widget.message.pathLen;
final isPlaying = voiceProvider.isPlaying(voiceId);
@@ -77,6 +76,7 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
if (!mounted) return;
setState(() {
_isRequesting = false;
_errorText = null;
});
});
}
@@ -125,6 +125,10 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
await voiceProvider.stop();
return;
}
if (_isRequesting) {
_cancelReceive(voiceId);
return;
}
if (isComplete) {
await voiceProvider.play(voiceId);
return;
@@ -151,7 +155,7 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
child: Icon(
isPlaying
? Icons.stop
: (_isRequesting ? Icons.downloading : Icons.play_arrow),
: (_isRequesting ? Icons.close : Icons.play_arrow),
size: 28,
color: widget.isSentByMe
? Theme.of(context).colorScheme.onPrimaryContainer
@@ -215,6 +219,8 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
}) async {
if (_isRequesting) return;
final connectionProvider = context.read<ConnectionProvider>();
final voiceProvider = context.read<VoiceProvider>();
voiceProvider.resumeIncomingSession(sessionId);
final contactsProvider = context.read<ContactsProvider>();
final resolution = await TransmissionTargetResolver.resolveFetchTarget(
contactsProvider: contactsProvider,
@@ -300,7 +306,9 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
}
// 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
? estimateVoiceTransmitDuration(
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) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(

View File

@@ -883,7 +883,7 @@ packages:
description:
path: "."
ref: main
resolved-ref: d6f91774f19136ff71b0087feaf95fa5490524d9
resolved-ref: "11f51ccaba850531496179bf63e023cb4c9ad797"
url: "https://github.com/dz0ny/meshcore_client.git"
source: git
version: "0.1.0"

View File

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

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,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

@@ -410,6 +410,24 @@ void main() {
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', () {
final empty = Uint8List(0);
final decoded = CayenneLppParser.parse(empty);

View File

@@ -113,4 +113,17 @@ void main() {
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),
);
});
});
}