mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 16:30:28 +00:00
Compare commits
8 Commits
2026.0305.
...
v2026.0305
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e3902cea1e | ||
|
|
2482f94cd0 | ||
|
|
2b34bc4162 | ||
|
|
03c0c7ad38 | ||
|
|
42fc1f9cd2 | ||
|
|
7807ad7471 | ||
|
|
310eee1ff5 | ||
|
|
b8f03079e7 |
@@ -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 = 84;
|
||||||
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 = 84;
|
||||||
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 = 84;
|
||||||
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 = 84;
|
||||||
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 = 84;
|
||||||
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 = 84;
|
||||||
DEVELOPMENT_TEAM = JND55328G8;
|
DEVELOPMENT_TEAM = JND55328G8;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
|
|||||||
@@ -43,7 +43,7 @@
|
|||||||
<key>CFBundleSignature</key>
|
<key>CFBundleSignature</key>
|
||||||
<string>????</string>
|
<string>????</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>81</string>
|
<string>84</string>
|
||||||
<key>LSRequiresIPhoneOS</key>
|
<key>LSRequiresIPhoneOS</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>NSBluetoothAlwaysUsageDescription</key>
|
<key>NSBluetoothAlwaysUsageDescription</key>
|
||||||
|
|||||||
@@ -5,22 +5,22 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000253">
|
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000211">
|
||||||
|
|
||||||
</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.408783">
|
||||||
|
|
||||||
</testcase>
|
</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>
|
||||||
|
|
||||||
|
|
||||||
<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>
|
</testcase>
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -788,12 +815,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 +831,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 +842,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,
|
||||||
@@ -1189,6 +1222,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 +1381,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) {
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -461,7 +462,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 +478,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 +489,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 +503,7 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
} finally {
|
} finally {
|
||||||
debugPrint('🏁 [Provider] Scan completed');
|
debugPrint('🏁 [Provider] Scan completed');
|
||||||
_isScanning = false;
|
_isScanning = false;
|
||||||
notifyListeners();
|
_notifyListenersSafely();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -510,6 +511,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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
43
lib/providers/helpers/fragment_ack_wait_registry.dart
Normal file
43
lib/providers/helpers/fragment_ack_wait_registry.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
38
lib/providers/helpers/session_metadata_restore.dart
Normal file
38
lib/providers/helpers/session_metadata_restore.dart
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -287,6 +287,61 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
return 'Select recipient';
|
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 {
|
Future<void> _sendMessage() async {
|
||||||
final text = _textController.text.trim();
|
final text = _textController.text.trim();
|
||||||
if (text.isEmpty) return;
|
if (text.isEmpty) return;
|
||||||
@@ -641,21 +696,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;
|
||||||
@@ -1571,130 +1613,176 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
|
child: Column(
|
||||||
child: Row(
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.end,
|
|
||||||
children: [
|
children: [
|
||||||
// Quick actions (+) button
|
Padding(
|
||||||
IconButton(
|
padding: const EdgeInsets.symmetric(
|
||||||
icon: Icon(_isRecording ? Icons.stop : Icons.add),
|
horizontal: 8,
|
||||||
tooltip: _isRecording ? 'Stop recording' : 'More actions',
|
vertical: 8,
|
||||||
onPressed: _isRecording
|
|
||||||
? _stopAndSendVoice
|
|
||||||
: _showComposerActions,
|
|
||||||
style: IconButton.styleFrom(
|
|
||||||
backgroundColor: Theme.of(
|
|
||||||
context,
|
|
||||||
).colorScheme.primaryContainer,
|
|
||||||
foregroundColor: _isRecording
|
|
||||||
? Colors.red
|
|
||||||
: Theme.of(context).colorScheme.onPrimaryContainer,
|
|
||||||
),
|
),
|
||||||
),
|
child: Row(
|
||||||
const SizedBox(width: 4),
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
// Destination switcher button
|
children: [
|
||||||
IconButton(
|
// Quick actions (+) button
|
||||||
icon: Icon(_getDestinationIcon()),
|
IconButton(
|
||||||
tooltip: _getDestinationTooltip(),
|
icon: Icon(_isRecording ? Icons.stop : Icons.add),
|
||||||
onPressed: _showRecipientSelector,
|
tooltip: _isRecording
|
||||||
style: IconButton.styleFrom(
|
? 'Stop recording'
|
||||||
backgroundColor:
|
: 'More actions',
|
||||||
_destinationType ==
|
onPressed: _isRecording
|
||||||
MessageDestinationPreferences
|
? _stopAndSendVoice
|
||||||
.destinationTypeChannel
|
: _showComposerActions,
|
||||||
? Theme.of(
|
style: IconButton.styleFrom(
|
||||||
|
backgroundColor: Theme.of(
|
||||||
context,
|
context,
|
||||||
).colorScheme.surfaceContainerHighest
|
).colorScheme.primaryContainer,
|
||||||
: Theme.of(context).colorScheme.secondaryContainer,
|
foregroundColor: _isRecording
|
||||||
foregroundColor:
|
? Colors.red
|
||||||
_destinationType ==
|
: Theme.of(
|
||||||
MessageDestinationPreferences
|
context,
|
||||||
.destinationTypeChannel
|
).colorScheme.onPrimaryContainer,
|
||||||
? 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'),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(width: 4),
|
||||||
textInputAction: TextInputAction.send,
|
// Destination switcher button
|
||||||
onSubmitted: (_) => _sendMessage(),
|
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(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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) ...[
|
||||||
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 ...[
|
] 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');
|
||||||
|
|||||||
@@ -468,7 +468,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 ?? '-'}',
|
||||||
@@ -635,8 +635,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,
|
||||||
@@ -1527,9 +1526,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 +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(
|
Widget _techChip(
|
||||||
BuildContext context, {
|
BuildContext context, {
|
||||||
required IconData icon,
|
required IconData icon,
|
||||||
@@ -1736,9 +1748,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,103 +1779,123 @@ 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 = message.isChannelMessage || widget.isCompact;
|
||||||
onTap: () => _handleBubbleTap(
|
final bubble = ConstrainedBox(
|
||||||
isSarMarker: isSarMarker,
|
constraints: BoxConstraints(
|
||||||
isDrawing: message.isDrawing,
|
maxWidth: shouldFloatBubble
|
||||||
|
? MediaQuery.of(context).size.width * 0.78
|
||||||
|
: double.infinity,
|
||||||
),
|
),
|
||||||
onLongPress: widget.isCompact ? null : () => _showMessageOptions(context),
|
child: GestureDetector(
|
||||||
child: Container(
|
onTap: () => _handleBubbleTap(
|
||||||
margin: const EdgeInsets.only(bottom: 8),
|
isSarMarker: isSarMarker,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
isDrawing: message.isDrawing,
|
||||||
decoration: BoxDecoration(
|
),
|
||||||
color: widget.isHighlighted
|
onLongPress: widget.isCompact
|
||||||
? Theme.of(context).colorScheme.primaryContainer
|
? null
|
||||||
: isSarMarker
|
: () => _showMessageOptions(context),
|
||||||
? _getSarMarkerColor(context, isDarkMode)
|
child: Container(
|
||||||
: message.isDrawing
|
margin: const EdgeInsets.only(bottom: 8),
|
||||||
? (isDarkMode
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||||
? Theme.of(
|
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,
|
context,
|
||||||
).colorScheme.primary.withValues(alpha: 0.15)
|
).colorScheme.primary.withValues(alpha: 0.4),
|
||||||
: Theme.of(
|
width: 2,
|
||||||
|
)
|
||||||
|
: isOwnMessage
|
||||||
|
? Border.all(
|
||||||
|
color: Theme.of(
|
||||||
context,
|
context,
|
||||||
).colorScheme.primary.withValues(alpha: 0.08))
|
).colorScheme.primary.withValues(alpha: 0.3),
|
||||||
: _getMessageBubbleColor(context, isOwnMessage, isDarkMode),
|
width: 1.5,
|
||||||
borderRadius: BorderRadius.circular(12),
|
)
|
||||||
border: widget.isHighlighted
|
: !message.isRead &&
|
||||||
? Border.all(
|
!message.isSentMessage &&
|
||||||
color: Theme.of(context).colorScheme.primary,
|
!message.isSystemMessage
|
||||||
width: 3,
|
? Border.all(color: Colors.blue, width: 1.5)
|
||||||
)
|
: null,
|
||||||
: isSarMarker
|
boxShadow: widget.isHighlighted
|
||||||
? Border.all(
|
? [
|
||||||
color: _getSarMarkerBorderColor(context, isDarkMode),
|
BoxShadow(
|
||||||
width: 2,
|
color: Theme.of(
|
||||||
)
|
context,
|
||||||
: message.isDrawing
|
).colorScheme.primary.withValues(alpha: 0.5),
|
||||||
? Border.all(
|
blurRadius: 12,
|
||||||
color: Theme.of(
|
spreadRadius: 2,
|
||||||
context,
|
offset: const Offset(0, 2),
|
||||||
).colorScheme.primary.withValues(alpha: 0.4),
|
),
|
||||||
width: 2,
|
]
|
||||||
)
|
: isSarMarker || message.isDrawing
|
||||||
: isOwnMessage
|
? [
|
||||||
? Border.all(
|
BoxShadow(
|
||||||
color: Theme.of(
|
color:
|
||||||
context,
|
(isSarMarker
|
||||||
).colorScheme.primary.withValues(alpha: 0.3),
|
? _getSarMarkerBorderColor(
|
||||||
width: 1.5,
|
context,
|
||||||
)
|
isDarkMode,
|
||||||
: !message.isRead &&
|
)
|
||||||
!message.isSentMessage &&
|
: Theme.of(context).colorScheme.primary)
|
||||||
!message.isSystemMessage
|
.withValues(alpha: 0.3),
|
||||||
? Border.all(color: Colors.blue, width: 1.5)
|
blurRadius: 8,
|
||||||
: null,
|
offset: const Offset(0, 2),
|
||||||
boxShadow: widget.isHighlighted
|
),
|
||||||
? [
|
]
|
||||||
BoxShadow(
|
: null,
|
||||||
color: Theme.of(
|
),
|
||||||
context,
|
child: Column(
|
||||||
).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,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
// Header: Badge (if SAR or drawing) and time
|
// Header: Badge (if SAR or drawing) and time
|
||||||
@@ -1984,15 +2017,17 @@ 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
|
// Show destination/source context on a separate line.
|
||||||
if (isOwnMessage &&
|
if (!widget.isCompact &&
|
||||||
recipientSubtitle != null &&
|
(recipientSubtitle != null ||
|
||||||
!widget.isCompact) ...[
|
receivedChannelSubtitle != null)) ...[
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
Icon(
|
||||||
Icons.arrow_forward,
|
isOwnMessage
|
||||||
|
? Icons.arrow_forward
|
||||||
|
: Icons.arrow_back,
|
||||||
size: 12,
|
size: 12,
|
||||||
color: Theme.of(context)
|
color: Theme.of(context)
|
||||||
.textTheme
|
.textTheme
|
||||||
@@ -2003,7 +2038,9 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
recipientSubtitle,
|
isOwnMessage
|
||||||
|
? recipientSubtitle!
|
||||||
|
: receivedChannelSubtitle!,
|
||||||
style: Theme.of(context).textTheme.labelSmall
|
style: Theme.of(context).textTheme.labelSmall
|
||||||
?.copyWith(
|
?.copyWith(
|
||||||
color: Theme.of(context)
|
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)],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -883,7 +883,7 @@ packages:
|
|||||||
description:
|
description:
|
||||||
path: "."
|
path: "."
|
||||||
ref: main
|
ref: main
|
||||||
resolved-ref: d6f91774f19136ff71b0087feaf95fa5490524d9
|
resolved-ref: "11f51ccaba850531496179bf63e023cb4c9ad797"
|
||||||
url: "https://github.com/dz0ny/meshcore_client.git"
|
url: "https://github.com/dz0ny/meshcore_client.git"
|
||||||
source: git
|
source: git
|
||||||
version: "0.1.0"
|
version: "0.1.0"
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
|||||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||||
# In Windows, build-name is used as the major, minor, and patch parts
|
# In Windows, build-name is used as the major, minor, and patch parts
|
||||||
# of the product and file versions while build-number is used as the build suffix.
|
# of the product and file versions while build-number is used as the build suffix.
|
||||||
version: 2026.0305.2+8
|
version: 2026.0305.3+9
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.9.2
|
sdk: ^3.9.2
|
||||||
|
|||||||
37
test/providers/helpers/fragment_ack_wait_registry_test.dart
Normal file
37
test/providers/helpers/fragment_ack_wait_registry_test.dart
Normal 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));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
86
test/providers/helpers/session_metadata_restore_test.dart
Normal file
86
test/providers/helpers/session_metadata_restore_test.dart
Normal 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),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
52
test/providers/image_provider_cancel_test.dart
Normal file
52
test/providers/image_provider_cancel_test.dart
Normal 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));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user