mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 16:30:28 +00:00
Compare commits
36 Commits
v2026.0311
...
v2026.0313
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e7a481f97b | ||
|
|
9f31f50e61 | ||
|
|
d4237b7569 | ||
|
|
b35c11c804 | ||
|
|
be2cf53374 | ||
|
|
13a729492f | ||
|
|
9d8594fe05 | ||
|
|
249268dd00 | ||
|
|
63aa9619e6 | ||
|
|
a7ad4bb8ec | ||
|
|
636e114428 | ||
|
|
d5684c51c7 | ||
|
|
ced39b6992 | ||
|
|
083b715039 | ||
|
|
36584be0ab | ||
|
|
dc19296804 | ||
|
|
40072175e3 | ||
|
|
11f3bf9f97 | ||
|
|
24e715d01e | ||
|
|
6e17de3866 | ||
|
|
90877a1891 | ||
|
|
77e43843bf | ||
|
|
1e70f52069 | ||
|
|
5e404944e9 | ||
|
|
53daf2c9a7 | ||
|
|
c36d63a0ad | ||
|
|
69ec2dfdb5 | ||
|
|
d2e6b692f3 | ||
|
|
baa1aa6edd | ||
|
|
38cb3dc031 | ||
|
|
abb73a4c65 | ||
|
|
2d1e6c0ca4 | ||
|
|
b38a8c55d6 | ||
|
|
ca872f2049 | ||
|
|
1b6ff59af3 | ||
|
|
e31bfac2ac |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -80,10 +80,11 @@ create_feature_graphic.py
|
|||||||
# Tool caches and local state
|
# Tool caches and local state
|
||||||
.cachebro/
|
.cachebro/
|
||||||
.osgrep/
|
.osgrep/
|
||||||
|
third_party/lpcnet_flutter/
|
||||||
|
|
||||||
# Claude Code local settings (permissions, personal config)
|
# Claude Code local settings (permissions, personal config)
|
||||||
.claude/settings.local.json
|
.claude/settings.local.json
|
||||||
|
|
||||||
# Dart code coverage
|
# Dart code coverage
|
||||||
coverage/
|
coverage/
|
||||||
lcov.info
|
lcov.info
|
||||||
|
|||||||
@@ -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 = 111;
|
CURRENT_PROJECT_VERSION = 115;
|
||||||
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 = 111;
|
CURRENT_PROJECT_VERSION = 115;
|
||||||
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 = 111;
|
CURRENT_PROJECT_VERSION = 115;
|
||||||
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 = 111;
|
CURRENT_PROJECT_VERSION = 115;
|
||||||
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 = 111;
|
CURRENT_PROJECT_VERSION = 115;
|
||||||
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 = 111;
|
CURRENT_PROJECT_VERSION = 115;
|
||||||
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>111</string>
|
<string>115</string>
|
||||||
<key>LSRequiresIPhoneOS</key>
|
<key>LSRequiresIPhoneOS</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>ITSAppUsesNonExemptEncryption</key>
|
<key>ITSAppUsesNonExemptEncryption</key>
|
||||||
|
|||||||
@@ -5,22 +5,24 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000256">
|
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.001473">
|
||||||
|
|
||||||
</testcase>
|
</testcase>
|
||||||
|
|
||||||
|
|
||||||
<testcase classname="fastlane.lanes" name="1: increment_build_number" time="0.924905">
|
<testcase classname="fastlane.lanes" name="1: increment_build_number" time="0.569758">
|
||||||
|
|
||||||
</testcase>
|
</testcase>
|
||||||
|
|
||||||
|
|
||||||
<testcase classname="fastlane.lanes" name="2: build_app" time="113.366384">
|
<testcase classname="fastlane.lanes" name="2: build_app" time="95.271262">
|
||||||
|
|
||||||
</testcase>
|
</testcase>
|
||||||
|
|
||||||
|
|
||||||
<testcase classname="fastlane.lanes" name="3: upload_to_app_store" time="755.542625">
|
<testcase classname="fastlane.lanes" name="3: upload_to_app_store" time="91.795184">
|
||||||
|
|
||||||
|
<failure message="/opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane/lib/fastlane/actions/actions_helper.rb:67:in 'Fastlane::Actions.execute_action' /opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane/lib/fastlane/runner.rb:255:in 'block in Fastlane::Runner#execute_action' /opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane/lib/fastlane/runner.rb:229:in 'Dir.chdir' /opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane/lib/fastlane/runner.rb:229:in 'Fastlane::Runner#execute_action' /opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane/lib/fastlane/runner.rb:157:in 'Fastlane::Runner#trigger_action_by_name' /opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane/lib/fastlane/fast_file.rb:159:in 'Fastlane::FastFile#method_missing' Fastfile:23:in 'block (2 levels) in Fastlane::FastFile#parsing_binding' /opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane/lib/fastlane/lane.rb:41:in 'Fastlane::Lane#call' /opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane/lib/fastlane/runner.rb:49:in 'block in Fastlane::Runner#execute' /opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane/lib/fastlane/runner.rb:45:in 'Dir.chdir' /opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane/lib/fastlane/runner.rb:45:in 'Fastlane::Runner#execute' /opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane/lib/fastlane/lane_manager.rb:46:in 'Fastlane::LaneManager.cruise_lane' /opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane/lib/fastlane/command_line_handler.rb:34:in 'Fastlane::CommandLineHandler.handle' /opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane/lib/fastlane/commands_generator.rb:110:in 'block (2 levels) in Fastlane::CommandsGenerator#run' /opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/commander-4.6.0/lib/commander/command.rb:187:in 'Commander::Command#call' /opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/commander-4.6.0/lib/commander/command.rb:157:in 'Commander::Command#run' /opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/commander-4.6.0/lib/commander/runner.rb:444:in 'Commander::Runner#run_active_command' /opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane_core/lib/fastlane_core/ui/fastlane_runner.rb:124:in 'Commander::Runner#run!' /opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/commander-4.6.0/lib/commander/delegates.rb:18:in 'Commander::Delegates#run!' /opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane/lib/fastlane/commands_generator.rb:363:in 'Fastlane::CommandsGenerator#run' /opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane/lib/fastlane/commands_generator.rb:43:in 'Fastlane::CommandsGenerator.start' /opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane/lib/fastlane/cli_tools_distributor.rb:132:in 'Fastlane::CLIToolsDistributor.take_off' /opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/bin/fastlane:23:in '<top (required)>' /opt/homebrew/Cellar/ruby/4.0.1/lib/ruby/4.0.0/rubygems.rb:303:in 'Kernel#load' /opt/homebrew/Cellar/ruby/4.0.1/lib/ruby/4.0.0/rubygems.rb:303:in 'Gem.activate_and_load_bin_path' /opt/homebrew/Cellar/fastlane/2.231.1/libexec/bin/fastlane:25:in '<main>' Invalid username and password combination. Used 'hey@dz0n.dev' as the username." />
|
||||||
|
|
||||||
</testcase>
|
</testcase>
|
||||||
|
|
||||||
|
|||||||
39
lib/models/contact_group.dart
Normal file
39
lib/models/contact_group.dart
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
class SavedContactGroup {
|
||||||
|
final String id;
|
||||||
|
final String sectionKey;
|
||||||
|
final String label;
|
||||||
|
final String query;
|
||||||
|
final DateTime createdAt;
|
||||||
|
final List<String>? matchPrefixes;
|
||||||
|
final bool isAutoGroup;
|
||||||
|
|
||||||
|
const SavedContactGroup({
|
||||||
|
required this.id,
|
||||||
|
required this.sectionKey,
|
||||||
|
required this.label,
|
||||||
|
required this.query,
|
||||||
|
required this.createdAt,
|
||||||
|
this.matchPrefixes,
|
||||||
|
this.isAutoGroup = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
SavedContactGroup copyWith({
|
||||||
|
String? id,
|
||||||
|
String? sectionKey,
|
||||||
|
String? label,
|
||||||
|
String? query,
|
||||||
|
DateTime? createdAt,
|
||||||
|
List<String>? matchPrefixes,
|
||||||
|
bool? isAutoGroup,
|
||||||
|
}) {
|
||||||
|
return SavedContactGroup(
|
||||||
|
id: id ?? this.id,
|
||||||
|
sectionKey: sectionKey ?? this.sectionKey,
|
||||||
|
label: label ?? this.label,
|
||||||
|
query: query ?? this.query,
|
||||||
|
createdAt: createdAt ?? this.createdAt,
|
||||||
|
matchPrefixes: matchPrefixes ?? this.matchPrefixes,
|
||||||
|
isAutoGroup: isAutoGroup ?? this.isAutoGroup,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
|
enum PathRecordSource { learned, observed }
|
||||||
|
|
||||||
class PathRecord {
|
class PathRecord {
|
||||||
final List<int> pathBytes;
|
final List<int> pathBytes;
|
||||||
final int hopCount;
|
final int hopCount;
|
||||||
final int hashSize;
|
final int hashSize;
|
||||||
|
final PathRecordSource source;
|
||||||
final int successCount;
|
final int successCount;
|
||||||
final int failureCount;
|
final int failureCount;
|
||||||
final int lastRoundTripTimeMs;
|
final int lastRoundTripTimeMs;
|
||||||
@@ -11,6 +14,7 @@ class PathRecord {
|
|||||||
required this.pathBytes,
|
required this.pathBytes,
|
||||||
required this.hopCount,
|
required this.hopCount,
|
||||||
required this.hashSize,
|
required this.hashSize,
|
||||||
|
required this.source,
|
||||||
required this.successCount,
|
required this.successCount,
|
||||||
required this.failureCount,
|
required this.failureCount,
|
||||||
required this.lastRoundTripTimeMs,
|
required this.lastRoundTripTimeMs,
|
||||||
@@ -27,6 +31,7 @@ class PathRecord {
|
|||||||
List<int>? pathBytes,
|
List<int>? pathBytes,
|
||||||
int? hopCount,
|
int? hopCount,
|
||||||
int? hashSize,
|
int? hashSize,
|
||||||
|
PathRecordSource? source,
|
||||||
int? successCount,
|
int? successCount,
|
||||||
int? failureCount,
|
int? failureCount,
|
||||||
int? lastRoundTripTimeMs,
|
int? lastRoundTripTimeMs,
|
||||||
@@ -36,6 +41,7 @@ class PathRecord {
|
|||||||
pathBytes: pathBytes ?? this.pathBytes,
|
pathBytes: pathBytes ?? this.pathBytes,
|
||||||
hopCount: hopCount ?? this.hopCount,
|
hopCount: hopCount ?? this.hopCount,
|
||||||
hashSize: hashSize ?? this.hashSize,
|
hashSize: hashSize ?? this.hashSize,
|
||||||
|
source: source ?? this.source,
|
||||||
successCount: successCount ?? this.successCount,
|
successCount: successCount ?? this.successCount,
|
||||||
failureCount: failureCount ?? this.failureCount,
|
failureCount: failureCount ?? this.failureCount,
|
||||||
lastRoundTripTimeMs: lastRoundTripTimeMs ?? this.lastRoundTripTimeMs,
|
lastRoundTripTimeMs: lastRoundTripTimeMs ?? this.lastRoundTripTimeMs,
|
||||||
@@ -48,6 +54,7 @@ class PathRecord {
|
|||||||
'path_bytes': pathBytes,
|
'path_bytes': pathBytes,
|
||||||
'hop_count': hopCount,
|
'hop_count': hopCount,
|
||||||
'hash_size': hashSize,
|
'hash_size': hashSize,
|
||||||
|
'source': source.name,
|
||||||
'success_count': successCount,
|
'success_count': successCount,
|
||||||
'failure_count': failureCount,
|
'failure_count': failureCount,
|
||||||
'last_round_trip_time_ms': lastRoundTripTimeMs,
|
'last_round_trip_time_ms': lastRoundTripTimeMs,
|
||||||
@@ -62,6 +69,10 @@ class PathRecord {
|
|||||||
.toList(),
|
.toList(),
|
||||||
hopCount: json['hop_count'] as int? ?? 0,
|
hopCount: json['hop_count'] as int? ?? 0,
|
||||||
hashSize: json['hash_size'] as int? ?? 1,
|
hashSize: json['hash_size'] as int? ?? 1,
|
||||||
|
source: PathRecordSource.values.firstWhere(
|
||||||
|
(value) => value.name == (json['source'] as String? ?? 'learned'),
|
||||||
|
orElse: () => PathRecordSource.learned,
|
||||||
|
),
|
||||||
successCount: json['success_count'] as int? ?? 0,
|
successCount: json['success_count'] as int? ?? 0,
|
||||||
failureCount: json['failure_count'] as int? ?? 0,
|
failureCount: json['failure_count'] as int? ?? 0,
|
||||||
lastRoundTripTimeMs: json['last_round_trip_time_ms'] as int? ?? 0,
|
lastRoundTripTimeMs: json['last_round_trip_time_ms'] as int? ?? 0,
|
||||||
@@ -163,6 +174,10 @@ class ContactPathHistory {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
List<PathRecord> get observedPaths => directPaths
|
||||||
|
.where((record) => record.source == PathRecordSource.observed)
|
||||||
|
.toList();
|
||||||
|
|
||||||
factory ContactPathHistory.fromJson(
|
factory ContactPathHistory.fromJson(
|
||||||
String contactPublicKeyHex,
|
String contactPublicKeyHex,
|
||||||
Map<String, dynamic> json,
|
Map<String, dynamic> json,
|
||||||
|
|||||||
@@ -59,6 +59,24 @@ class _DirectMessageRouteSession {
|
|||||||
/// Main App Provider - coordinates all other providers
|
/// Main App Provider - coordinates all other providers
|
||||||
class AppProvider with ChangeNotifier {
|
class AppProvider with ChangeNotifier {
|
||||||
static const int _maxDirectPayloadHops = 3;
|
static const int _maxDirectPayloadHops = 3;
|
||||||
|
@visibleForTesting
|
||||||
|
static bool isDeletedChannelInfo(
|
||||||
|
int channelIdx,
|
||||||
|
String channelName,
|
||||||
|
Uint8List secret,
|
||||||
|
) {
|
||||||
|
return channelIdx != 0 &&
|
||||||
|
channelName.isEmpty &&
|
||||||
|
secret.every((byte) => byte == 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@visibleForTesting
|
||||||
|
static String channelContactName(int channelIdx, String channelName) {
|
||||||
|
return channelName.isEmpty && channelIdx != 0
|
||||||
|
? 'Channel $channelIdx'
|
||||||
|
: channelName;
|
||||||
|
}
|
||||||
|
|
||||||
final ConnectionProvider connectionProvider;
|
final ConnectionProvider connectionProvider;
|
||||||
final ContactsProvider contactsProvider;
|
final ContactsProvider contactsProvider;
|
||||||
final MessagesProvider messagesProvider;
|
final MessagesProvider messagesProvider;
|
||||||
@@ -91,6 +109,12 @@ class AppProvider with ChangeNotifier {
|
|||||||
bool get isVoiceCompressorEnabled => _isVoiceCompressorEnabled;
|
bool get isVoiceCompressorEnabled => _isVoiceCompressorEnabled;
|
||||||
bool _isVoiceLimiterEnabled = true;
|
bool _isVoiceLimiterEnabled = true;
|
||||||
bool get isVoiceLimiterEnabled => _isVoiceLimiterEnabled;
|
bool get isVoiceLimiterEnabled => _isVoiceLimiterEnabled;
|
||||||
|
bool _isVoiceAutoGainEnabled = false;
|
||||||
|
bool get isVoiceAutoGainEnabled => _isVoiceAutoGainEnabled;
|
||||||
|
bool _isVoiceEchoCancellationEnabled = false;
|
||||||
|
bool get isVoiceEchoCancellationEnabled => _isVoiceEchoCancellationEnabled;
|
||||||
|
bool _isVoiceNoiseSuppressionEnabled = false;
|
||||||
|
bool get isVoiceNoiseSuppressionEnabled => _isVoiceNoiseSuppressionEnabled;
|
||||||
double _messageFontScale = 1.0;
|
double _messageFontScale = 1.0;
|
||||||
double get messageFontScale => _messageFontScale;
|
double get messageFontScale => _messageFontScale;
|
||||||
bool _autoAddDiscoveredContacts = false;
|
bool _autoAddDiscoveredContacts = false;
|
||||||
@@ -142,6 +166,8 @@ class AppProvider with ChangeNotifier {
|
|||||||
required this.imageProvider,
|
required this.imageProvider,
|
||||||
}) {
|
}) {
|
||||||
_setupCallbacks();
|
_setupCallbacks();
|
||||||
|
connectionProvider.canStartAutomaticMessageSyncCallback =
|
||||||
|
_canStartAutomaticMessageSync;
|
||||||
_wasDeviceConnected = connectionProvider.deviceInfo.isConnected;
|
_wasDeviceConnected = connectionProvider.deviceInfo.isConnected;
|
||||||
_initializeLocationTracking();
|
_initializeLocationTracking();
|
||||||
_loadMapEnabled();
|
_loadMapEnabled();
|
||||||
@@ -151,6 +177,9 @@ class AppProvider with ChangeNotifier {
|
|||||||
_loadVoiceBandPassFilterEnabled();
|
_loadVoiceBandPassFilterEnabled();
|
||||||
_loadVoiceCompressorEnabled();
|
_loadVoiceCompressorEnabled();
|
||||||
_loadVoiceLimiterEnabled();
|
_loadVoiceLimiterEnabled();
|
||||||
|
_loadVoiceAutoGainEnabled();
|
||||||
|
_loadVoiceEchoCancellationEnabled();
|
||||||
|
_loadVoiceNoiseSuppressionEnabled();
|
||||||
_loadMessageFontScale();
|
_loadMessageFontScale();
|
||||||
_loadAutoAddDiscoveredContacts();
|
_loadAutoAddDiscoveredContacts();
|
||||||
_loadMessagingRouteSettings();
|
_loadMessagingRouteSettings();
|
||||||
@@ -263,6 +292,17 @@ class AppProvider with ChangeNotifier {
|
|||||||
String? _resolveContactNameForNotification(Uint8List? publicKey) {
|
String? _resolveContactNameForNotification(Uint8List? publicKey) {
|
||||||
if (publicKey == null || publicKey.isEmpty) return null;
|
if (publicKey == null || publicKey.isEmpty) return null;
|
||||||
|
|
||||||
|
final ownPublicKey = connectionProvider.deviceInfo.publicKey;
|
||||||
|
final ownName = _preferredSelfDisplayName(
|
||||||
|
deviceName: connectionProvider.deviceInfo.deviceName,
|
||||||
|
selfName: connectionProvider.deviceInfo.selfName,
|
||||||
|
);
|
||||||
|
if (_matchesPublicKeyPrefix(publicKey, ownPublicKey) &&
|
||||||
|
ownName != null &&
|
||||||
|
ownName.trim().isNotEmpty) {
|
||||||
|
return ownName;
|
||||||
|
}
|
||||||
|
|
||||||
Contact? contact;
|
Contact? contact;
|
||||||
if (publicKey.length >= 32) {
|
if (publicKey.length >= 32) {
|
||||||
contact = contactsProvider.findContactByKey(publicKey);
|
contact = contactsProvider.findContactByKey(publicKey);
|
||||||
@@ -275,6 +315,89 @@ class AppProvider with ChangeNotifier {
|
|||||||
return contact?.advName;
|
return contact?.advName;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static bool shouldIgnoreSelfReplay({
|
||||||
|
required Message message,
|
||||||
|
required Uint8List? ownPublicKey,
|
||||||
|
required String? ownName,
|
||||||
|
}) {
|
||||||
|
if ((!message.isContactMessage && !message.isChannelMessage) ||
|
||||||
|
message.pathLen > 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_matchesPublicKeyPrefix(message.senderPublicKeyPrefix, ownPublicKey)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
final trimmedSenderName = message.senderName?.trim();
|
||||||
|
final trimmedOwnName = ownName?.trim();
|
||||||
|
if (trimmedSenderName == null ||
|
||||||
|
trimmedSenderName.isEmpty ||
|
||||||
|
trimmedOwnName == null ||
|
||||||
|
trimmedOwnName.isEmpty) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return trimmedSenderName == trimmedOwnName;
|
||||||
|
}
|
||||||
|
|
||||||
|
static String? preferredSelfDisplayName({
|
||||||
|
required String? deviceName,
|
||||||
|
required String? selfName,
|
||||||
|
}) => _preferredSelfDisplayName(deviceName: deviceName, selfName: selfName);
|
||||||
|
|
||||||
|
static String? _preferredSelfDisplayName({
|
||||||
|
required String? deviceName,
|
||||||
|
required String? selfName,
|
||||||
|
}) {
|
||||||
|
final trimmedSelfName = selfName?.trim();
|
||||||
|
if (trimmedSelfName != null && trimmedSelfName.isNotEmpty) {
|
||||||
|
return trimmedSelfName;
|
||||||
|
}
|
||||||
|
|
||||||
|
final trimmedDeviceName = deviceName?.trim();
|
||||||
|
if (trimmedDeviceName == null || trimmedDeviceName.isEmpty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmedDeviceName.toLowerCase().startsWith('meshcore-')) {
|
||||||
|
final stripped = trimmedDeviceName.substring('meshcore-'.length).trim();
|
||||||
|
if (stripped.isNotEmpty) {
|
||||||
|
return stripped;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return trimmedDeviceName;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool _matchesPublicKeyPrefix(
|
||||||
|
Uint8List? candidateKey,
|
||||||
|
Uint8List? ownPublicKey,
|
||||||
|
) {
|
||||||
|
if (candidateKey == null ||
|
||||||
|
candidateKey.isEmpty ||
|
||||||
|
ownPublicKey == null ||
|
||||||
|
ownPublicKey.isEmpty) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
final compareLength = candidateKey.length >= 6 && ownPublicKey.length >= 6
|
||||||
|
? 6
|
||||||
|
: (candidateKey.length < ownPublicKey.length
|
||||||
|
? candidateKey.length
|
||||||
|
: ownPublicKey.length);
|
||||||
|
if (compareLength <= 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var index = 0; index < compareLength; index++) {
|
||||||
|
if (candidateKey[index] != ownPublicKey[index]) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/// Load map enabled setting from shared preferences
|
/// Load map enabled setting from shared preferences
|
||||||
Future<void> _loadMapEnabled() async {
|
Future<void> _loadMapEnabled() async {
|
||||||
try {
|
try {
|
||||||
@@ -439,6 +562,72 @@ class AppProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _loadVoiceAutoGainEnabled() async {
|
||||||
|
try {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
_isVoiceAutoGainEnabled =
|
||||||
|
prefs.getBool('voice_auto_gain_enabled') ?? false;
|
||||||
|
notifyListeners();
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Error loading voice auto gain setting: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> toggleVoiceAutoGainEnabled(bool enabled) async {
|
||||||
|
try {
|
||||||
|
_isVoiceAutoGainEnabled = enabled;
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setBool('voice_auto_gain_enabled', enabled);
|
||||||
|
notifyListeners();
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Error saving voice auto gain setting: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadVoiceEchoCancellationEnabled() async {
|
||||||
|
try {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
_isVoiceEchoCancellationEnabled =
|
||||||
|
prefs.getBool('voice_echo_cancellation_enabled') ?? false;
|
||||||
|
notifyListeners();
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Error loading voice echo cancellation setting: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> toggleVoiceEchoCancellationEnabled(bool enabled) async {
|
||||||
|
try {
|
||||||
|
_isVoiceEchoCancellationEnabled = enabled;
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setBool('voice_echo_cancellation_enabled', enabled);
|
||||||
|
notifyListeners();
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Error saving voice echo cancellation setting: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadVoiceNoiseSuppressionEnabled() async {
|
||||||
|
try {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
_isVoiceNoiseSuppressionEnabled =
|
||||||
|
prefs.getBool('voice_noise_suppression_enabled') ?? false;
|
||||||
|
notifyListeners();
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Error loading voice noise suppression setting: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> toggleVoiceNoiseSuppressionEnabled(bool enabled) async {
|
||||||
|
try {
|
||||||
|
_isVoiceNoiseSuppressionEnabled = enabled;
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setBool('voice_noise_suppression_enabled', enabled);
|
||||||
|
notifyListeners();
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Error saving voice noise suppression setting: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _loadMessageFontScale() async {
|
Future<void> _loadMessageFontScale() async {
|
||||||
try {
|
try {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
@@ -659,12 +848,33 @@ class AppProvider with ChangeNotifier {
|
|||||||
'🔔 [AppProvider] onChannelInfoReceived called: idx=$channelIdx, name="$channelName"',
|
'🔔 [AppProvider] onChannelInfoReceived called: idx=$channelIdx, name="$channelName"',
|
||||||
);
|
);
|
||||||
|
|
||||||
// Check if this is a channel deletion (empty name)
|
final isDeletedChannel = connectionProvider
|
||||||
if (channelName.isEmpty && channelIdx != 0) {
|
.shouldTreatChannelInfoAsDeleted(
|
||||||
|
channelIdx,
|
||||||
|
channelName,
|
||||||
|
secret,
|
||||||
|
);
|
||||||
|
final isEmptyChannelInfo = AppProvider.isDeletedChannelInfo(
|
||||||
|
channelIdx,
|
||||||
|
channelName,
|
||||||
|
secret,
|
||||||
|
);
|
||||||
|
final contactChannelName = AppProvider.channelContactName(
|
||||||
|
channelIdx,
|
||||||
|
channelName,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Only treat the slot as deleted when firmware returns an empty
|
||||||
|
// name and a zeroed secret. Protected channels may still have an
|
||||||
|
// empty display name but remain fully configured.
|
||||||
|
if (isDeletedChannel) {
|
||||||
debugPrint(
|
debugPrint(
|
||||||
' 🗑️ Channel $channelIdx deleted - removing from providers',
|
' 🗑️ Channel $channelIdx deleted - removing from providers',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
messagesProvider.clearChannelMessages(channelIdx);
|
||||||
|
debugPrint(' ✅ Cleared channel messages');
|
||||||
|
|
||||||
// Remove from ChannelsProvider
|
// Remove from ChannelsProvider
|
||||||
channelsProvider.removeChannel(channelIdx);
|
channelsProvider.removeChannel(channelIdx);
|
||||||
debugPrint(' ✅ Removed from ChannelsProvider');
|
debugPrint(' ✅ Removed from ChannelsProvider');
|
||||||
@@ -683,6 +893,13 @@ class AppProvider with ChangeNotifier {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isEmptyChannelInfo) {
|
||||||
|
debugPrint(
|
||||||
|
' ⏭️ Ignoring empty channel slot $channelIdx during sync',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Add/update in ChannelsProvider
|
// Add/update in ChannelsProvider
|
||||||
channelsProvider.addOrUpdateChannel(
|
channelsProvider.addOrUpdateChannel(
|
||||||
index: channelIdx,
|
index: channelIdx,
|
||||||
@@ -695,10 +912,10 @@ class AppProvider with ChangeNotifier {
|
|||||||
// Also add as Contact to ContactsProvider (for UI display)
|
// Also add as Contact to ContactsProvider (for UI display)
|
||||||
// Skip if it's public channel (already exists)
|
// Skip if it's public channel (already exists)
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'📻 [AppProvider] Channel $channelIdx: "$channelName" (isEmpty: ${channelName.isEmpty}, isHashChannel: ${channelName.startsWith('#')})',
|
'📻 [AppProvider] Channel $channelIdx: "$contactChannelName" (rawNameEmpty: ${channelName.isEmpty}, isHashChannel: ${channelName.startsWith('#')})',
|
||||||
);
|
);
|
||||||
|
|
||||||
if (channelName.isNotEmpty && channelIdx != 0) {
|
if (channelIdx != 0) {
|
||||||
debugPrint(
|
debugPrint(
|
||||||
' ✅ Adding channel $channelIdx to ContactsProvider as Contact',
|
' ✅ Adding channel $channelIdx to ContactsProvider as Contact',
|
||||||
);
|
);
|
||||||
@@ -718,7 +935,7 @@ class AppProvider with ChangeNotifier {
|
|||||||
flags: flags ?? 0,
|
flags: flags ?? 0,
|
||||||
outPathLen: -1, // Flood mode for channels
|
outPathLen: -1, // Flood mode for channels
|
||||||
outPath: Uint8List(0), // Empty path for channels
|
outPath: Uint8List(0), // Empty path for channels
|
||||||
advName: channelName,
|
advName: contactChannelName,
|
||||||
lastAdvert: now,
|
lastAdvert: now,
|
||||||
advLat: 0, // Channels don't have location
|
advLat: 0, // Channels don't have location
|
||||||
advLon: 0,
|
advLon: 0,
|
||||||
@@ -732,7 +949,7 @@ class AppProvider with ChangeNotifier {
|
|||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
debugPrint(
|
debugPrint(
|
||||||
' ⏭️ Skipping channel $channelIdx (empty: ${channelName.isEmpty}, isPublic: ${channelIdx == 0})',
|
' ⏭️ Skipping channel $channelIdx (isPublic: ${channelIdx == 0})',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (e, stackTrace) {
|
} catch (e, stackTrace) {
|
||||||
@@ -743,6 +960,29 @@ class AppProvider with ChangeNotifier {
|
|||||||
|
|
||||||
// When a message is received
|
// When a message is received
|
||||||
connectionProvider.onMessageReceived = (message) {
|
connectionProvider.onMessageReceived = (message) {
|
||||||
|
if (message.isChannelMessage) {
|
||||||
|
debugPrint(
|
||||||
|
'📥 [AppProvider] Channel message received: '
|
||||||
|
'id=${message.id}, '
|
||||||
|
'senderName=${message.senderName ?? "-"}, '
|
||||||
|
'senderKey=${message.senderKeyShort ?? "-"}, '
|
||||||
|
'pathLen=${message.pathLen}, '
|
||||||
|
'ts=${message.senderTimestamp}, '
|
||||||
|
'text=${message.text}',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (AppProvider.shouldIgnoreSelfReplay(
|
||||||
|
message: message,
|
||||||
|
ownPublicKey: connectionProvider.deviceInfo.publicKey,
|
||||||
|
ownName:
|
||||||
|
connectionProvider.deviceInfo.deviceName ??
|
||||||
|
connectionProvider.deviceInfo.selfName,
|
||||||
|
)) {
|
||||||
|
debugPrint('⏭️ [AppProvider] Ignoring self replay: ${message.id}');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Enrich message with sender name from contacts first
|
// Enrich message with sender name from contacts first
|
||||||
Message enrichedMessage = message;
|
Message enrichedMessage = message;
|
||||||
Contact? senderContact;
|
Contact? senderContact;
|
||||||
@@ -775,7 +1015,6 @@ class AppProvider with ChangeNotifier {
|
|||||||
final receivedPathBytes = receptionDetailsSnapshot?.pathBytes;
|
final receivedPathBytes = receptionDetailsSnapshot?.pathBytes;
|
||||||
if (senderContact != null &&
|
if (senderContact != null &&
|
||||||
enrichedMessage.isChannelMessage &&
|
enrichedMessage.isChannelMessage &&
|
||||||
(enrichedMessage.channelIdx ?? 0) == 0 &&
|
|
||||||
receivedPathBytes != null &&
|
receivedPathBytes != null &&
|
||||||
receivedPathBytes.isNotEmpty) {
|
receivedPathBytes.isNotEmpty) {
|
||||||
unawaited(
|
unawaited(
|
||||||
@@ -1180,6 +1419,7 @@ class AppProvider with ChangeNotifier {
|
|||||||
debugPrint(
|
debugPrint(
|
||||||
'📡 [AppProvider] Advertisement received: ${publicKey.sublist(0, 6).map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}...',
|
'📡 [AppProvider] Advertisement received: ${publicKey.sublist(0, 6).map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}...',
|
||||||
);
|
);
|
||||||
|
unawaited(_retainAdvertRxPath(publicKey));
|
||||||
// Check if this is an existing contact that might have updated location
|
// Check if this is an existing contact that might have updated location
|
||||||
final contact = contactsProvider.findContactByKey(publicKey);
|
final contact = contactsProvider.findContactByKey(publicKey);
|
||||||
if (contact != null) {
|
if (contact != null) {
|
||||||
@@ -1293,6 +1533,9 @@ class AppProvider with ChangeNotifier {
|
|||||||
retryAttempt: retryAttempt,
|
retryAttempt: retryAttempt,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
connectionProvider.resolveContactForDmCallback = (contactPublicKey) {
|
||||||
|
return contactsProvider.findContactByKey(contactPublicKey);
|
||||||
|
};
|
||||||
messagesProvider.onFinalRouterFallbackCallback =
|
messagesProvider.onFinalRouterFallbackCallback =
|
||||||
({required messageId, required contact, required message}) async {
|
({required messageId, required contact, required message}) async {
|
||||||
return _sendWithFinalNearestRouterFallback(
|
return _sendWithFinalNearestRouterFallback(
|
||||||
@@ -1321,6 +1564,9 @@ class AppProvider with ChangeNotifier {
|
|||||||
roundTripTimeMs: roundTripTimeMs,
|
roundTripTimeMs: roundTripTimeMs,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
messagesProvider.onManualRetryPreparedCallback = (messageId) {
|
||||||
|
_directMessageRouteSessions.remove(messageId);
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<Contact> _prepareDirectMessageSend({
|
Future<Contact> _prepareDirectMessageSend({
|
||||||
@@ -1583,6 +1829,38 @@ class AppProvider with ChangeNotifier {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _retainAdvertRxPath(Uint8List publicKey) async {
|
||||||
|
final decoded = _findBestMatchingAdvertRxRoute(publicKey);
|
||||||
|
if (decoded == null || decoded.pathBytes.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final reversedPathBytes = LogRxRouteDecoder.reverseHopBytes(
|
||||||
|
decoded.pathBytes,
|
||||||
|
hashSize: decoded.hashSize,
|
||||||
|
);
|
||||||
|
final reversedHopHashes = LogRxRouteDecoder.splitHopHashes(
|
||||||
|
reversedPathBytes,
|
||||||
|
hashSize: decoded.hashSize,
|
||||||
|
);
|
||||||
|
|
||||||
|
final parsedRoute = ContactRouteCodec.parse(
|
||||||
|
reversedHopHashes.join(','),
|
||||||
|
expectedHashSize: decoded.hashSize,
|
||||||
|
);
|
||||||
|
contactsProvider.retainReceivedRoute(
|
||||||
|
publicKey,
|
||||||
|
signedEncodedPathLen: parsedRoute.signedEncodedPathLen,
|
||||||
|
paddedPathBytes: parsedRoute.paddedPathBytes,
|
||||||
|
devicePublicKey: connectionProvider.deviceInfo.publicKey,
|
||||||
|
);
|
||||||
|
await _pathHistoryService.recordReceivedBytePath(
|
||||||
|
publicKey.map((byte) => byte.toRadixString(16).padLeft(2, '0')).join(),
|
||||||
|
decoded.pathBytes,
|
||||||
|
decoded.hashSize,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
int _inferReceivedPathHashSize(
|
int _inferReceivedPathHashSize(
|
||||||
List<int> pathBytes, {
|
List<int> pathBytes, {
|
||||||
required int preferredHashSize,
|
required int preferredHashSize,
|
||||||
@@ -1604,6 +1882,8 @@ class AppProvider with ChangeNotifier {
|
|||||||
if (!connectionProvider.deviceInfo.isConnected) return;
|
if (!connectionProvider.deviceInfo.isConnected) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
_isReconnectSyncInProgress = true;
|
||||||
|
_hasCompletedConnectionBootstrap = false;
|
||||||
// Initialize contacts provider with device public key to exclude self
|
// Initialize contacts provider with device public key to exclude self
|
||||||
// If already initialized (from early load), this will just filter out self-contact
|
// If already initialized (from early load), this will just filter out self-contact
|
||||||
// This must happen before getContacts to ensure proper filtering
|
// This must happen before getContacts to ensure proper filtering
|
||||||
@@ -1623,30 +1903,14 @@ class AppProvider with ChangeNotifier {
|
|||||||
// Small delay to ensure contacts are fully loaded
|
// Small delay to ensure contacts are fully loaded
|
||||||
await Future.delayed(const Duration(milliseconds: 500));
|
await Future.delayed(const Duration(milliseconds: 500));
|
||||||
|
|
||||||
// Sync channels to get channel names
|
// Sync all channels so slot assignment and channel state mirror the device.
|
||||||
// In simple mode: only sync first 5 channels for faster startup
|
final channelsToSync = connectionProvider.deviceInfo.maxChannels;
|
||||||
// In normal mode: sync all channels (up to device max)
|
debugPrint(
|
||||||
const channelsToSync = 5;
|
'📻 [AppProvider] Syncing channels (max: ${channelsToSync ?? 40})...',
|
||||||
debugPrint('📻 [AppProvider] Syncing channels (simple mode: max 5)...');
|
);
|
||||||
await connectionProvider.syncChannels(maxChannels: channelsToSync);
|
await connectionProvider.syncChannels(maxChannels: channelsToSync);
|
||||||
debugPrint('✅ [AppProvider] Channel sync complete');
|
debugPrint('✅ [AppProvider] Channel sync complete');
|
||||||
|
|
||||||
// Configure the default public channel (channel 0)
|
|
||||||
// This must be done before sending any channel messages
|
|
||||||
// Note: Some firmware versions may have this pre-configured
|
|
||||||
debugPrint(
|
|
||||||
'📻 [AppProvider] Configuring default public channel (channel 0)...',
|
|
||||||
);
|
|
||||||
try {
|
|
||||||
await connectionProvider.configureDefaultPublicChannel();
|
|
||||||
debugPrint('✅ [AppProvider] Public channel configured successfully');
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint(
|
|
||||||
'⚠️ [AppProvider] Public channel configuration failed (may already be configured): $e',
|
|
||||||
);
|
|
||||||
// Continue anyway - channel might already be configured in firmware
|
|
||||||
}
|
|
||||||
|
|
||||||
// Automatically login to all saved rooms
|
// Automatically login to all saved rooms
|
||||||
await _autoLoginToRooms();
|
await _autoLoginToRooms();
|
||||||
|
|
||||||
@@ -1655,7 +1919,9 @@ class AppProvider with ChangeNotifier {
|
|||||||
debugPrint(
|
debugPrint(
|
||||||
'🔄 [AppProvider] Performing initial message sync (fallback for missed pushes)',
|
'🔄 [AppProvider] Performing initial message sync (fallback for missed pushes)',
|
||||||
);
|
);
|
||||||
final initialMessageCount = await connectionProvider.syncAllMessages();
|
final initialMessageCount = await connectionProvider.syncAllMessages(
|
||||||
|
force: true,
|
||||||
|
);
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'📥 [AppProvider] Initial sync retrieved $initialMessageCount message(s)',
|
'📥 [AppProvider] Initial sync retrieved $initialMessageCount message(s)',
|
||||||
);
|
);
|
||||||
@@ -1677,19 +1943,26 @@ class AppProvider with ChangeNotifier {
|
|||||||
|
|
||||||
_hasCompletedConnectionBootstrap = true;
|
_hasCompletedConnectionBootstrap = true;
|
||||||
_wasDeviceConnected = connectionProvider.deviceInfo.isConnected;
|
_wasDeviceConnected = connectionProvider.deviceInfo.isConnected;
|
||||||
|
await _flushDeferredAutomaticMessageSync();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('Initialization error: $e');
|
debugPrint('Initialization error: $e');
|
||||||
|
} finally {
|
||||||
|
_isReconnectSyncInProgress = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _syncAfterReconnect() async {
|
Future<void> _syncAfterReconnect({bool started = false}) async {
|
||||||
if (_isReconnectSyncInProgress ||
|
if (!connectionProvider.deviceInfo.isConnected) {
|
||||||
!connectionProvider.deviceInfo.isConnected) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
_isReconnectSyncInProgress = true;
|
if (!started) {
|
||||||
|
if (_isReconnectSyncInProgress) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_isReconnectSyncInProgress = true;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'🔄 [AppProvider] Device reconnected - syncing contacts and missed messages',
|
'🔄 [AppProvider] Device reconnected - syncing contacts and missed messages',
|
||||||
@@ -1700,14 +1973,19 @@ class AppProvider with ChangeNotifier {
|
|||||||
);
|
);
|
||||||
await connectionProvider.getContacts();
|
await connectionProvider.getContacts();
|
||||||
|
|
||||||
final messageCount = await connectionProvider.syncAllMessages();
|
final messageCount = await connectionProvider.syncAllMessages(
|
||||||
|
force: true,
|
||||||
|
);
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'📥 [AppProvider] Reconnect sync retrieved $messageCount message(s)',
|
'📥 [AppProvider] Reconnect sync retrieved $messageCount message(s)',
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('❌ [AppProvider] Reconnect sync error: $e');
|
debugPrint('❌ [AppProvider] Reconnect sync error: $e');
|
||||||
} finally {
|
} finally {
|
||||||
|
_hasCompletedConnectionBootstrap =
|
||||||
|
connectionProvider.deviceInfo.isConnected;
|
||||||
_isReconnectSyncInProgress = false;
|
_isReconnectSyncInProgress = false;
|
||||||
|
await _flushDeferredAutomaticMessageSync();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2629,6 +2907,37 @@ class AppProvider with ChangeNotifier {
|
|||||||
return bestLog;
|
return bestLog;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
DecodedLogRxRoute? _findBestMatchingAdvertRxRoute(Uint8List publicKey) {
|
||||||
|
final publicKeyHex = publicKey
|
||||||
|
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
|
||||||
|
.join()
|
||||||
|
.toLowerCase();
|
||||||
|
|
||||||
|
DecodedLogRxRoute? bestRoute;
|
||||||
|
var bestDeltaMs = 999999999;
|
||||||
|
final now = DateTime.now();
|
||||||
|
|
||||||
|
for (final log in connectionProvider.bleService.packetLogs) {
|
||||||
|
if (log.responseCode != 0x88) continue;
|
||||||
|
if (log.rawData.length < 6) continue;
|
||||||
|
|
||||||
|
final decoded = LogRxRouteDecoder.decode(log.rawData);
|
||||||
|
if (decoded == null) continue;
|
||||||
|
if (decoded.payloadType != 0x04) continue;
|
||||||
|
final senderHash = decoded.originalSenderHashHex;
|
||||||
|
if (senderHash == null || !publicKeyHex.startsWith(senderHash)) continue;
|
||||||
|
|
||||||
|
final deltaMs = (log.timestamp.difference(now).inMilliseconds).abs();
|
||||||
|
if (deltaMs < bestDeltaMs) {
|
||||||
|
bestDeltaMs = deltaMs;
|
||||||
|
bestRoute = decoded;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bestDeltaMs > 30000) return null;
|
||||||
|
return bestRoute;
|
||||||
|
}
|
||||||
|
|
||||||
List<int>? _extractPathBytesFromLog(BlePacketLog? log) {
|
List<int>? _extractPathBytesFromLog(BlePacketLog? log) {
|
||||||
if (log == null) return null;
|
if (log == null) return null;
|
||||||
final decoded = LogRxRouteDecoder.decode(log.rawData);
|
final decoded = LogRxRouteDecoder.decode(log.rawData);
|
||||||
@@ -2647,9 +2956,10 @@ class AppProvider with ChangeNotifier {
|
|||||||
// Sync contacts
|
// Sync contacts
|
||||||
await connectionProvider.getContacts();
|
await connectionProvider.getContacts();
|
||||||
|
|
||||||
// Sync channels (respect simple mode settings)
|
// Sync all channels so refresh reflects the full device state.
|
||||||
const channelsToSync = 5;
|
await connectionProvider.syncChannels(
|
||||||
await connectionProvider.syncChannels(maxChannels: channelsToSync);
|
maxChannels: connectionProvider.deviceInfo.maxChannels,
|
||||||
|
);
|
||||||
|
|
||||||
// Messages are automatically synced via PUSH_CODE_MSG_WAITING events
|
// Messages are automatically synced via PUSH_CODE_MSG_WAITING events
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
@@ -2668,7 +2978,9 @@ class AppProvider with ChangeNotifier {
|
|||||||
debugPrint(
|
debugPrint(
|
||||||
'🔄 [AppProvider] Manual message sync requested (user initiated)',
|
'🔄 [AppProvider] Manual message sync requested (user initiated)',
|
||||||
);
|
);
|
||||||
final messageCount = await connectionProvider.syncAllMessages();
|
final messageCount = await connectionProvider.syncAllMessages(
|
||||||
|
force: true,
|
||||||
|
);
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'✅ [AppProvider] Manual sync completed: $messageCount messages',
|
'✅ [AppProvider] Manual sync completed: $messageCount messages',
|
||||||
);
|
);
|
||||||
@@ -2697,9 +3009,32 @@ class AppProvider with ChangeNotifier {
|
|||||||
_stopLocationTracking();
|
_stopLocationTracking();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isConnected && !wasConnected && _hasCompletedConnectionBootstrap) {
|
if (!isConnected) {
|
||||||
unawaited(_syncAfterReconnect());
|
connectionProvider.clearPendingAutomaticMessageSync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isConnected && !wasConnected && _hasCompletedConnectionBootstrap) {
|
||||||
|
_isReconnectSyncInProgress = true;
|
||||||
|
unawaited(_syncAfterReconnect(started: true));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _canStartAutomaticMessageSync() {
|
||||||
|
return connectionProvider.deviceInfo.isConnected &&
|
||||||
|
_hasCompletedConnectionBootstrap &&
|
||||||
|
!_isReconnectSyncInProgress;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _flushDeferredAutomaticMessageSync() async {
|
||||||
|
if (!connectionProvider.hasPendingAutomaticMessageSync ||
|
||||||
|
!_canStartAutomaticMessageSync()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
debugPrint(
|
||||||
|
'🔄 [AppProvider] Running deferred automatic message sync after bootstrap',
|
||||||
|
);
|
||||||
|
await connectionProvider.syncAllMessages(force: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Start location tracking
|
/// Start location tracking
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
bool get isScanning => _isScanning;
|
bool get isScanning => _isScanning;
|
||||||
bool _isSpectrumScanActive = false;
|
bool _isSpectrumScanActive = false;
|
||||||
bool get isSpectrumScanActive => _isSpectrumScanActive;
|
bool get isSpectrumScanActive => _isSpectrumScanActive;
|
||||||
|
final Set<int> _pendingDeletedChannelIndices = <int>{};
|
||||||
|
|
||||||
String? _error;
|
String? _error;
|
||||||
String? get error => _error;
|
String? get error => _error;
|
||||||
@@ -185,9 +186,12 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
onMessageEchoDetected;
|
onMessageEchoDetected;
|
||||||
Function(Uint8List publicKeyPrefix, Uint8List statusData)? onStatusResponse;
|
Function(Uint8List publicKeyPrefix, Uint8List statusData)? onStatusResponse;
|
||||||
Function(Uint8List payload, int snrRaw, int rssiDbm)? onRawDataReceived;
|
Function(Uint8List payload, int snrRaw, int rssiDbm)? onRawDataReceived;
|
||||||
|
Contact? Function(Uint8List contactPublicKey)? resolveContactForDmCallback;
|
||||||
|
bool Function()? canStartAutomaticMessageSyncCallback;
|
||||||
|
|
||||||
// Track pending send operations for auto-recovery
|
// Track pending send operations for auto-recovery
|
||||||
final Map<String, _PendingSendOperation> _pendingSendOperations = {};
|
final Map<String, _PendingSendOperation> _pendingSendOperations = {};
|
||||||
|
bool _pendingAutomaticMessageSync = false;
|
||||||
|
|
||||||
ConnectionProvider() {
|
ConnectionProvider() {
|
||||||
_wireServiceCallbacks(_bleService);
|
_wireServiceCallbacks(_bleService);
|
||||||
@@ -332,6 +336,13 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
debugPrint('📥 [Provider] MSG_WAITING ignored during spectrum scan');
|
debugPrint('📥 [Provider] MSG_WAITING ignored during spectrum scan');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!(canStartAutomaticMessageSyncCallback?.call() ?? true)) {
|
||||||
|
_pendingAutomaticMessageSync = true;
|
||||||
|
debugPrint(
|
||||||
|
'📥 [Provider] MSG_WAITING deferred until connection bootstrap completes',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
debugPrint('📥 [Provider] MSG_WAITING - auto-syncing');
|
debugPrint('📥 [Provider] MSG_WAITING - auto-syncing');
|
||||||
if (_isSyncingMessages) {
|
if (_isSyncingMessages) {
|
||||||
_syncRequestedWhileBusy = true;
|
_syncRequestedWhileBusy = true;
|
||||||
@@ -761,7 +772,21 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
try {
|
try {
|
||||||
// Use maxChannels from device info if available, otherwise default to 40
|
// Use maxChannels from device info if available, otherwise default to 40
|
||||||
final channelCount = maxChannels ?? _deviceInfo.maxChannels ?? 40;
|
final channelCount = maxChannels ?? _deviceInfo.maxChannels ?? 40;
|
||||||
await _activeService.syncAllChannels(maxChannels: channelCount);
|
debugPrint(
|
||||||
|
'📻 [Provider] Syncing channels with tolerant per-slot reads (1-${channelCount - 1})...',
|
||||||
|
);
|
||||||
|
|
||||||
|
for (int i = 1; i < channelCount; i++) {
|
||||||
|
try {
|
||||||
|
await _activeService.getChannel(i);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint(
|
||||||
|
'⚠️ [Provider] Channel sync skipped slot $i after read failure: $e',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
debugPrint('✅ [Provider] Channel sync complete');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_error = 'Failed to sync channels: $e';
|
_error = 'Failed to sync channels: $e';
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
@@ -815,6 +840,41 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
/// This should be set by AppProvider to query ChannelsProvider
|
/// This should be set by AppProvider to query ChannelsProvider
|
||||||
Function(int channelIdx)? getChannelInfo;
|
Function(int channelIdx)? getChannelInfo;
|
||||||
|
|
||||||
|
@visibleForTesting
|
||||||
|
static bool channelHasConfiguredSecret(Uint8List secret) {
|
||||||
|
return secret.any((byte) => byte != 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@visibleForTesting
|
||||||
|
static bool isDuplicateChannelName({
|
||||||
|
required String requestedName,
|
||||||
|
required String existingName,
|
||||||
|
}) {
|
||||||
|
return requestedName.startsWith('#') && existingName == requestedName;
|
||||||
|
}
|
||||||
|
|
||||||
|
@visibleForTesting
|
||||||
|
static int? firstAvailableChannelSlot({
|
||||||
|
required Set<int> occupiedIndices,
|
||||||
|
required int maxChannels,
|
||||||
|
}) {
|
||||||
|
for (int i = 1; i < maxChannels; i++) {
|
||||||
|
if (!occupiedIndices.contains(i)) {
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _channelSlotIsOccupied(Object channel) {
|
||||||
|
final channelName = (channel as dynamic).name as String?;
|
||||||
|
final secret = (channel as dynamic).secret;
|
||||||
|
final hasConfiguredSecret =
|
||||||
|
secret is Uint8List && channelHasConfiguredSecret(secret);
|
||||||
|
return (channelName != null && channelName.isNotEmpty) ||
|
||||||
|
hasConfiguredSecret;
|
||||||
|
}
|
||||||
|
|
||||||
/// Check if a specific channel slot is empty
|
/// Check if a specific channel slot is empty
|
||||||
Future<bool> isChannelSlotEmpty(int channelIdx) async {
|
Future<bool> isChannelSlotEmpty(int channelIdx) async {
|
||||||
if (!_activeService.isConnected) {
|
if (!_activeService.isConnected) {
|
||||||
@@ -826,8 +886,7 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
if (getChannelInfo != null) {
|
if (getChannelInfo != null) {
|
||||||
final channel = getChannelInfo!(channelIdx);
|
final channel = getChannelInfo!(channelIdx);
|
||||||
if (channel != null) {
|
if (channel != null) {
|
||||||
final channelName = (channel as dynamic).name as String?;
|
return !_channelSlotIsOccupied(channel);
|
||||||
return channelName == null || channelName.isEmpty;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -839,13 +898,17 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
if (getChannelInfo != null) {
|
if (getChannelInfo != null) {
|
||||||
final channel = getChannelInfo!(channelIdx);
|
final channel = getChannelInfo!(channelIdx);
|
||||||
if (channel != null) {
|
if (channel != null) {
|
||||||
final channelName = (channel as dynamic).name as String?;
|
return !_channelSlotIsOccupied(channel);
|
||||||
return channelName == null || channelName.isEmpty;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If still no info, assume it's empty
|
// If we still have no authoritative answer after an on-device query,
|
||||||
return true;
|
// treat the slot as occupied/unknown so we never overwrite a channel
|
||||||
|
// because of stale or delayed state propagation.
|
||||||
|
debugPrint(
|
||||||
|
' ⚠️ Slot $channelIdx state is still unknown after query; refusing to treat it as empty',
|
||||||
|
);
|
||||||
|
return false;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('❌ [Provider] Failed to check slot $channelIdx: $e');
|
debugPrint('❌ [Provider] Failed to check slot $channelIdx: $e');
|
||||||
if (_isEmptyChannelQueryError(e)) {
|
if (_isEmptyChannelQueryError(e)) {
|
||||||
@@ -868,6 +931,37 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
return message.contains('0x1f') && message.contains('timed out');
|
return message.contains('0x1f') && message.contains('timed out');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool shouldTreatChannelInfoAsDeleted(
|
||||||
|
int channelIdx,
|
||||||
|
String channelName,
|
||||||
|
Uint8List secret,
|
||||||
|
) {
|
||||||
|
final isEmptyInfo =
|
||||||
|
channelIdx != 0 &&
|
||||||
|
channelName.isEmpty &&
|
||||||
|
secret.every((byte) => byte == 0);
|
||||||
|
if (!isEmptyInfo) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return _pendingDeletedChannelIndices.remove(channelIdx);
|
||||||
|
}
|
||||||
|
|
||||||
|
Set<int> _syncedOccupiedChannelIndices({required int maxChannels}) {
|
||||||
|
if (getChannelInfo == null) {
|
||||||
|
throw Exception('Channel state is unavailable');
|
||||||
|
}
|
||||||
|
|
||||||
|
final occupiedIndices = <int>{};
|
||||||
|
for (int i = 1; i < maxChannels; i++) {
|
||||||
|
final channel = getChannelInfo!(i);
|
||||||
|
if (channel != null && _channelSlotIsOccupied(channel)) {
|
||||||
|
occupiedIndices.add(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return occupiedIndices;
|
||||||
|
}
|
||||||
|
|
||||||
Future<int?> findNextEmptyChannelSlot() async {
|
Future<int?> findNextEmptyChannelSlot() async {
|
||||||
if (!_activeService.isConnected) {
|
if (!_activeService.isConnected) {
|
||||||
throw Exception('Not connected to device');
|
throw Exception('Not connected to device');
|
||||||
@@ -880,58 +974,21 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
final maxChannels = _deviceInfo.maxChannels ?? 40;
|
final maxChannels = _deviceInfo.maxChannels ?? 40;
|
||||||
final maxCustomChannels = maxChannels > 0 ? maxChannels - 1 : 0;
|
final maxCustomChannels = maxChannels > 0 ? maxChannels - 1 : 0;
|
||||||
|
|
||||||
// Match meshcore-open: trust the current synced channel list first and
|
final occupiedIndices = _syncedOccupiedChannelIndices(
|
||||||
// pick the first missing slot rather than probing every slot on-device.
|
maxChannels: maxChannels,
|
||||||
if (getChannelInfo != null) {
|
);
|
||||||
final usedIndices = <int>{};
|
final emptySlot = firstAvailableChannelSlot(
|
||||||
for (int i = 1; i < maxChannels; i++) {
|
occupiedIndices: occupiedIndices,
|
||||||
final channel = getChannelInfo!(i);
|
maxChannels: maxChannels,
|
||||||
if (channel == null) {
|
);
|
||||||
continue;
|
if (emptySlot != null) {
|
||||||
}
|
debugPrint(' ✅ Found empty slot from synced channels: $emptySlot');
|
||||||
|
return emptySlot;
|
||||||
final channelName = (channel as dynamic).name as String?;
|
|
||||||
if (channelName != null && channelName.isNotEmpty) {
|
|
||||||
usedIndices.add(i);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (int i = 1; i < maxChannels; i++) {
|
|
||||||
if (!usedIndices.contains(i)) {
|
|
||||||
debugPrint(' ✅ Found empty slot from synced channels: $i');
|
|
||||||
return i;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
debugPrint(
|
|
||||||
' ⚠️ Synced channels report all custom slots occupied ($maxCustomChannels total)',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check each slot starting from 1 (skip 0 = public channel)
|
debugPrint(
|
||||||
for (int i = 1; i < maxChannels; i++) {
|
' ❌ Synced channels report all custom slots occupied ($maxCustomChannels total)',
|
||||||
// First check cache
|
);
|
||||||
if (getChannelInfo != null) {
|
|
||||||
final channel = getChannelInfo!(i);
|
|
||||||
if (channel != null) {
|
|
||||||
final channelName = (channel as dynamic).name as String?;
|
|
||||||
if (channelName != null && channelName.isNotEmpty) {
|
|
||||||
debugPrint(' ⏭️ Slot $i occupied: "$channelName"');
|
|
||||||
continue; // Skip occupied slots
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Slot appears empty in cache, verify by querying device
|
|
||||||
debugPrint(' 🔍 Checking slot $i...');
|
|
||||||
final isEmpty = await isChannelSlotEmpty(i);
|
|
||||||
if (isEmpty) {
|
|
||||||
debugPrint(' ✅ Found empty slot: $i');
|
|
||||||
return i;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
debugPrint(' ❌ All slots (1-${maxChannels - 1}) are in use');
|
|
||||||
return null;
|
return null;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('❌ [Provider] Failed to find empty channel slot: $e');
|
debugPrint('❌ [Provider] Failed to find empty channel slot: $e');
|
||||||
@@ -961,100 +1018,15 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
debugPrint('📻 [Provider] Creating new channel...');
|
debugPrint('📻 [Provider] Creating new channel...');
|
||||||
debugPrint(' Name: $channelName');
|
debugPrint(' Name: $channelName');
|
||||||
|
|
||||||
// Determine channel type
|
|
||||||
final bool isHashChannel = channelName.startsWith('#');
|
final bool isHashChannel = channelName.startsWith('#');
|
||||||
|
final secretBytes = isHashChannel
|
||||||
|
? _generateHashChannelSecret(channelName)
|
||||||
|
: _convertSecretToBytes(channelSecret);
|
||||||
|
|
||||||
// Check for duplicate channels
|
await _createChannelWithSecretBytes(
|
||||||
int? existingSlot;
|
channelName: channelName,
|
||||||
if (getChannelInfo != null) {
|
secretBytes: secretBytes,
|
||||||
final maxChannels = _deviceInfo.maxChannels ?? 40;
|
isHashChannel: isHashChannel,
|
||||||
for (int i = 1; i < maxChannels; i++) {
|
|
||||||
final channel = getChannelInfo!(i);
|
|
||||||
if (channel != null) {
|
|
||||||
final existingName = (channel as dynamic).name as String?;
|
|
||||||
if (existingName != null && existingName.isNotEmpty) {
|
|
||||||
// For hash channels (#name), check exact match to prevent duplicates
|
|
||||||
if (isHashChannel && existingName == channelName) {
|
|
||||||
debugPrint(
|
|
||||||
' ⚠️ Hash channel "$channelName" already exists in slot $i',
|
|
||||||
);
|
|
||||||
throw Exception(
|
|
||||||
'Channel "$channelName" already exists. Hash channels cannot be duplicated.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// For private channels, check name match to allow overwrite
|
|
||||||
else if (!isHashChannel && existingName == channelName) {
|
|
||||||
debugPrint(
|
|
||||||
' ℹ️ Private channel "$channelName" found in slot $i - will overwrite',
|
|
||||||
);
|
|
||||||
existingSlot = i;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine slot to use
|
|
||||||
final int slotIdx;
|
|
||||||
if (existingSlot != null) {
|
|
||||||
// Overwrite existing private channel
|
|
||||||
slotIdx = existingSlot;
|
|
||||||
debugPrint(' Using existing slot: $slotIdx (overwrite mode)');
|
|
||||||
} else {
|
|
||||||
// Find next empty slot for new channel
|
|
||||||
final maxChannels = _deviceInfo.maxChannels ?? 40;
|
|
||||||
final maxCustomChannels = maxChannels > 0 ? maxChannels - 1 : 0;
|
|
||||||
final emptySlot = await findNextEmptyChannelSlot();
|
|
||||||
if (emptySlot == null) {
|
|
||||||
throw Exception(
|
|
||||||
'All channel slots are in use (maximum $maxCustomChannels custom channels)',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
slotIdx = emptySlot;
|
|
||||||
debugPrint(' Using empty slot: $slotIdx (new channel)');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate secret
|
|
||||||
final List<int> secretBytes;
|
|
||||||
if (isHashChannel) {
|
|
||||||
// Hash channel: auto-generate secret from name using SHA256
|
|
||||||
debugPrint(' Channel type: Hash channel (#)');
|
|
||||||
secretBytes = _generateHashChannelSecret(channelName);
|
|
||||||
debugPrint(' Secret auto-generated from channel name using SHA256');
|
|
||||||
} else {
|
|
||||||
// Private channel: use explicit secret with MD5
|
|
||||||
debugPrint(' Channel type: Private channel');
|
|
||||||
secretBytes = _convertSecretToBytes(channelSecret);
|
|
||||||
debugPrint(' Secret converted to 16-byte key using MD5');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send CMD_SET_CHANNEL to radio. Some devices accept the write but do not
|
|
||||||
// reliably answer the follow-up CMD_GET_CHANNEL verification (0x1F).
|
|
||||||
try {
|
|
||||||
await _activeService.setChannel(
|
|
||||||
channelIdx: slotIdx,
|
|
||||||
channelName: channelName,
|
|
||||||
secret: secretBytes,
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
if (_isChannelRefreshTimeoutError(e)) {
|
|
||||||
debugPrint(
|
|
||||||
'⚠️ [Provider] CMD_GET_CHANNEL verification timed out after SET_CHANNEL; assuming the channel write succeeded',
|
|
||||||
);
|
|
||||||
onChannelInfoReceived?.call(
|
|
||||||
slotIdx,
|
|
||||||
channelName,
|
|
||||||
Uint8List.fromList(secretBytes),
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
rethrow;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
debugPrint(
|
|
||||||
'✅ [Provider] Channel ${existingSlot != null ? 'updated' : 'created'} successfully in slot $slotIdx',
|
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_error = 'Failed to create channel: $e';
|
_error = 'Failed to create channel: $e';
|
||||||
@@ -1064,6 +1036,75 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _createChannelWithSecretBytes({
|
||||||
|
required String channelName,
|
||||||
|
required List<int> secretBytes,
|
||||||
|
required bool isHashChannel,
|
||||||
|
}) async {
|
||||||
|
final maxChannels = _deviceInfo.maxChannels ?? 40;
|
||||||
|
|
||||||
|
if (getChannelInfo != null) {
|
||||||
|
for (int i = 1; i < maxChannels; i++) {
|
||||||
|
final channel = getChannelInfo!(i);
|
||||||
|
if (channel == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
final existingName = (channel as dynamic).name as String?;
|
||||||
|
if (existingName != null &&
|
||||||
|
existingName.isNotEmpty &&
|
||||||
|
isDuplicateChannelName(
|
||||||
|
requestedName: channelName,
|
||||||
|
existingName: existingName,
|
||||||
|
)) {
|
||||||
|
debugPrint(
|
||||||
|
' ⚠️ Hash channel "$channelName" already exists in slot $i',
|
||||||
|
);
|
||||||
|
throw Exception(
|
||||||
|
'Channel "$channelName" already exists. Hash channels cannot be duplicated.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final maxCustomChannels = maxChannels > 0 ? maxChannels - 1 : 0;
|
||||||
|
final slotIdx = await findNextEmptyChannelSlot();
|
||||||
|
if (slotIdx == null) {
|
||||||
|
throw Exception(
|
||||||
|
'All channel slots are in use (maximum $maxCustomChannels custom channels)',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
debugPrint(
|
||||||
|
' Channel type: ${isHashChannel ? "Hash channel (#)" : "Private channel"}',
|
||||||
|
);
|
||||||
|
debugPrint(' Using empty slot: $slotIdx');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await _activeService.setChannel(
|
||||||
|
channelIdx: slotIdx,
|
||||||
|
channelName: channelName,
|
||||||
|
secret: secretBytes,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
if (_isChannelRefreshTimeoutError(e)) {
|
||||||
|
debugPrint(
|
||||||
|
'⚠️ [Provider] CMD_GET_CHANNEL verification timed out after SET_CHANNEL; assuming the channel write succeeded',
|
||||||
|
);
|
||||||
|
onChannelInfoReceived?.call(
|
||||||
|
slotIdx,
|
||||||
|
channelName,
|
||||||
|
Uint8List.fromList(secretBytes),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
debugPrint('✅ [Provider] Channel created successfully in slot $slotIdx');
|
||||||
|
}
|
||||||
|
|
||||||
/// Delete a channel and remove it from the UI
|
/// Delete a channel and remove it from the UI
|
||||||
///
|
///
|
||||||
/// Clears the channel slot on the device and removes it from both
|
/// Clears the channel slot on the device and removes it from both
|
||||||
@@ -1083,6 +1124,7 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
debugPrint('🗑️ [Provider] Deleting channel in slot $channelIdx...');
|
debugPrint('🗑️ [Provider] Deleting channel in slot $channelIdx...');
|
||||||
|
_pendingDeletedChannelIndices.add(channelIdx);
|
||||||
|
|
||||||
// Delete channel on device (sets empty name and zeroed secret)
|
// Delete channel on device (sets empty name and zeroed secret)
|
||||||
await _activeService.deleteChannel(channelIdx);
|
await _activeService.deleteChannel(channelIdx);
|
||||||
@@ -1091,6 +1133,10 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
'✅ [Provider] Channel deleted successfully from slot $channelIdx',
|
'✅ [Provider] Channel deleted successfully from slot $channelIdx',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Soft-reset local UI state immediately, matching meshcore-open's
|
||||||
|
// behavior, then reconcile with an on-device refresh below.
|
||||||
|
onChannelInfoReceived?.call(channelIdx, '', Uint8List(16), null);
|
||||||
|
|
||||||
// Small delay to allow the response to propagate
|
// Small delay to allow the response to propagate
|
||||||
await Future.delayed(const Duration(milliseconds: 100));
|
await Future.delayed(const Duration(milliseconds: 100));
|
||||||
|
|
||||||
@@ -1098,6 +1144,7 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
// The empty channel will trigger removal via onChannelInfoReceived callback
|
// The empty channel will trigger removal via onChannelInfoReceived callback
|
||||||
await _activeService.getChannel(channelIdx);
|
await _activeService.getChannel(channelIdx);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
_pendingDeletedChannelIndices.remove(channelIdx);
|
||||||
_error = 'Failed to delete channel: $e';
|
_error = 'Failed to delete channel: $e';
|
||||||
debugPrint('❌ [Provider] Channel deletion failed: $e');
|
debugPrint('❌ [Provider] Channel deletion failed: $e');
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
@@ -1164,6 +1211,7 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var effectiveContact = contact;
|
var effectiveContact = contact;
|
||||||
|
effectiveContact ??= resolveContactForDmCallback?.call(contactPublicKey);
|
||||||
if (messageId != null &&
|
if (messageId != null &&
|
||||||
effectiveContact != null &&
|
effectiveContact != null &&
|
||||||
prepareDirectMessageSendCallback != null) {
|
prepareDirectMessageSendCallback != null) {
|
||||||
@@ -1616,6 +1664,22 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> factoryResetDevice() async {
|
||||||
|
if (!_activeService.isConnected) {
|
||||||
|
_error = 'Not connected to device';
|
||||||
|
notifyListeners();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
_error = null;
|
||||||
|
await _activeService.factoryReset();
|
||||||
|
} catch (e) {
|
||||||
|
_error = 'Failed to wipe device data: $e';
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Set advertised name
|
/// Set advertised name
|
||||||
Future<void> setAdvertName(String name) async {
|
Future<void> setAdvertName(String name) async {
|
||||||
if (!_activeService.isConnected) {
|
if (!_activeService.isConnected) {
|
||||||
@@ -1892,11 +1956,18 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Sync all waiting messages from device
|
/// Sync all waiting messages from device
|
||||||
Future<int> syncAllMessages() async {
|
Future<int> syncAllMessages({bool force = false}) async {
|
||||||
if (_isSpectrumScanActive) {
|
if (_isSpectrumScanActive) {
|
||||||
debugPrint('⏸️ [Provider] Message sync skipped during spectrum scan');
|
debugPrint('⏸️ [Provider] Message sync skipped during spectrum scan');
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
if (!force && !(canStartAutomaticMessageSyncCallback?.call() ?? true)) {
|
||||||
|
_pendingAutomaticMessageSync = true;
|
||||||
|
debugPrint(
|
||||||
|
'⏸️ [Provider] Message sync deferred until connection bootstrap completes',
|
||||||
|
);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
if (_isSyncingMessages) {
|
if (_isSyncingMessages) {
|
||||||
// Already syncing; avoid overlapping loops
|
// Already syncing; avoid overlapping loops
|
||||||
_syncRequestedWhileBusy = true;
|
_syncRequestedWhileBusy = true;
|
||||||
@@ -1912,6 +1983,7 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
int totalCount = 0;
|
int totalCount = 0;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
_pendingAutomaticMessageSync = false;
|
||||||
_isSyncingMessages = true;
|
_isSyncingMessages = true;
|
||||||
do {
|
do {
|
||||||
_syncRequestedWhileBusy = false;
|
_syncRequestedWhileBusy = false;
|
||||||
@@ -2003,6 +2075,12 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool get hasPendingAutomaticMessageSync => _pendingAutomaticMessageSync;
|
||||||
|
|
||||||
|
void clearPendingAutomaticMessageSync() {
|
||||||
|
_pendingAutomaticMessageSync = false;
|
||||||
|
}
|
||||||
|
|
||||||
/// Login to a room or repeater
|
/// Login to a room or repeater
|
||||||
///
|
///
|
||||||
/// Sends login request with password. Results will be delivered via
|
/// Sends login request with password. Results will be delivered via
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
|
import 'dart:math';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:latlong2/latlong.dart';
|
import 'package:latlong2/latlong.dart';
|
||||||
import '../models/contact.dart';
|
import '../models/contact.dart';
|
||||||
|
import '../models/contact_group.dart';
|
||||||
import '../models/message_contact_location.dart';
|
import '../models/message_contact_location.dart';
|
||||||
import '../services/cayenne_lpp_parser.dart';
|
import '../services/cayenne_lpp_parser.dart';
|
||||||
import '../services/contact_storage_service.dart';
|
import '../services/contact_storage_service.dart';
|
||||||
@@ -10,8 +12,15 @@ import '../utils/key_comparison.dart';
|
|||||||
class PendingAdvert {
|
class PendingAdvert {
|
||||||
final Uint8List publicKey;
|
final Uint8List publicKey;
|
||||||
final DateTime receivedAt;
|
final DateTime receivedAt;
|
||||||
|
final int? signedEncodedPathLen;
|
||||||
|
final Uint8List? paddedPathBytes;
|
||||||
|
|
||||||
const PendingAdvert({required this.publicKey, required this.receivedAt});
|
const PendingAdvert({
|
||||||
|
required this.publicKey,
|
||||||
|
required this.receivedAt,
|
||||||
|
this.signedEncodedPathLen,
|
||||||
|
this.paddedPathBytes,
|
||||||
|
});
|
||||||
|
|
||||||
String get publicKeyHex =>
|
String get publicKeyHex =>
|
||||||
publicKey.map((b) => b.toRadixString(16).padLeft(2, '0')).join('');
|
publicKey.map((b) => b.toRadixString(16).padLeft(2, '0')).join('');
|
||||||
@@ -21,17 +30,37 @@ class PendingAdvert {
|
|||||||
return prefix.map((b) => b.toRadixString(16).padLeft(2, '0')).join(':');
|
return prefix.map((b) => b.toRadixString(16).padLeft(2, '0')).join(':');
|
||||||
}
|
}
|
||||||
|
|
||||||
PendingAdvert copyWith({Uint8List? publicKey, DateTime? receivedAt}) {
|
PendingAdvert copyWith({
|
||||||
|
Uint8List? publicKey,
|
||||||
|
DateTime? receivedAt,
|
||||||
|
int? signedEncodedPathLen,
|
||||||
|
Uint8List? paddedPathBytes,
|
||||||
|
}) {
|
||||||
return PendingAdvert(
|
return PendingAdvert(
|
||||||
publicKey: publicKey ?? this.publicKey,
|
publicKey: publicKey ?? this.publicKey,
|
||||||
receivedAt: receivedAt ?? this.receivedAt,
|
receivedAt: receivedAt ?? this.receivedAt,
|
||||||
|
signedEncodedPathLen: signedEncodedPathLen ?? this.signedEncodedPathLen,
|
||||||
|
paddedPathBytes: paddedPathBytes ?? this.paddedPathBytes,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _RetainedRoute {
|
||||||
|
final int signedEncodedPathLen;
|
||||||
|
final Uint8List paddedPathBytes;
|
||||||
|
|
||||||
|
const _RetainedRoute({
|
||||||
|
required this.signedEncodedPathLen,
|
||||||
|
required this.paddedPathBytes,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// Contacts Provider - manages contact list and telemetry
|
/// Contacts Provider - manages contact list and telemetry
|
||||||
class ContactsProvider with ChangeNotifier {
|
class ContactsProvider with ChangeNotifier {
|
||||||
|
static const double _firstHopFallbackOffsetMeters = 100.0;
|
||||||
|
static const String autoGroupIdPrefix = 'auto_group_';
|
||||||
final Map<String, Contact> _contacts = {};
|
final Map<String, Contact> _contacts = {};
|
||||||
|
final List<SavedContactGroup> _savedContactGroups = <SavedContactGroup>[];
|
||||||
final Map<String, PendingAdvert> _pendingAdverts = {};
|
final Map<String, PendingAdvert> _pendingAdverts = {};
|
||||||
final ContactStorageService _storageService = ContactStorageService();
|
final ContactStorageService _storageService = ContactStorageService();
|
||||||
bool _isInitialized = false;
|
bool _isInitialized = false;
|
||||||
@@ -53,6 +82,7 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
'📦 [ContactsProvider] Early loading persisted contacts (no filtering)...',
|
'📦 [ContactsProvider] Early loading persisted contacts (no filtering)...',
|
||||||
);
|
);
|
||||||
final storedContacts = await _storageService.loadContacts();
|
final storedContacts = await _storageService.loadContacts();
|
||||||
|
final storedGroups = await _storageService.loadContactGroups();
|
||||||
|
|
||||||
// Add stored contacts (excluding any with all-zeros public key)
|
// Add stored contacts (excluding any with all-zeros public key)
|
||||||
const publicChannelKey =
|
const publicChannelKey =
|
||||||
@@ -66,8 +96,11 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_isInitialized = true;
|
_isInitialized = true;
|
||||||
|
_savedContactGroups
|
||||||
|
..clear()
|
||||||
|
..addAll(storedGroups);
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'✅ [ContactsProvider] Early loaded ${storedContacts.length} persisted contacts',
|
'✅ [ContactsProvider] Early loaded ${storedContacts.length} persisted contacts and ${storedGroups.length} groups',
|
||||||
);
|
);
|
||||||
|
|
||||||
// Ensure public channel exists after loading
|
// Ensure public channel exists after loading
|
||||||
@@ -97,6 +130,7 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
final storedContacts = await _storageService.loadContacts(
|
final storedContacts = await _storageService.loadContacts(
|
||||||
excludePublicKey: devicePublicKey,
|
excludePublicKey: devicePublicKey,
|
||||||
);
|
);
|
||||||
|
final storedGroups = await _storageService.loadContactGroups();
|
||||||
|
|
||||||
// Add stored contacts (excluding any with all-zeros public key)
|
// Add stored contacts (excluding any with all-zeros public key)
|
||||||
const publicChannelKey =
|
const publicChannelKey =
|
||||||
@@ -110,8 +144,11 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_isInitialized = true;
|
_isInitialized = true;
|
||||||
|
_savedContactGroups
|
||||||
|
..clear()
|
||||||
|
..addAll(storedGroups);
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'✅ [ContactsProvider] Loaded ${storedContacts.length} persisted contacts',
|
'✅ [ContactsProvider] Loaded ${storedContacts.length} persisted contacts and ${storedGroups.length} groups',
|
||||||
);
|
);
|
||||||
|
|
||||||
// Ensure public channel exists after loading
|
// Ensure public channel exists after loading
|
||||||
@@ -182,10 +219,113 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
List<Contact> get contacts => _contacts.values.toList();
|
List<Contact> get contacts => _contacts.values.toList();
|
||||||
|
List<SavedContactGroup> get savedContactGroups =>
|
||||||
|
List<SavedContactGroup>.from(_savedContactGroups)
|
||||||
|
..sort((a, b) => b.createdAt.compareTo(a.createdAt));
|
||||||
List<PendingAdvert> get pendingAdverts =>
|
List<PendingAdvert> get pendingAdverts =>
|
||||||
_pendingAdverts.values.toList()
|
_pendingAdverts.values.toList()
|
||||||
..sort((a, b) => b.receivedAt.compareTo(a.receivedAt));
|
..sort((a, b) => b.receivedAt.compareTo(a.receivedAt));
|
||||||
|
|
||||||
|
List<SavedContactGroup> savedGroupsForSection(String sectionKey) {
|
||||||
|
return savedContactGroups
|
||||||
|
.where((group) => group.sectionKey == sectionKey)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool hasSavedGroupForFilter(String sectionKey, String query) {
|
||||||
|
final normalizedQuery = _normalizeGroupQuery(query);
|
||||||
|
if (normalizedQuery.isEmpty) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return _savedContactGroups.any(
|
||||||
|
(group) =>
|
||||||
|
group.sectionKey == sectionKey &&
|
||||||
|
_normalizeGroupQuery(group.query) == normalizedQuery,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> addSavedGroupForFilter(
|
||||||
|
String sectionKey,
|
||||||
|
String query, {
|
||||||
|
String? label,
|
||||||
|
List<String>? matchPrefixes,
|
||||||
|
bool isAutoGroup = false,
|
||||||
|
}) async {
|
||||||
|
final normalizedQuery = _normalizeGroupQuery(query);
|
||||||
|
if (normalizedQuery.isEmpty ||
|
||||||
|
hasSavedGroupForFilter(sectionKey, normalizedQuery)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_savedContactGroups.add(
|
||||||
|
SavedContactGroup(
|
||||||
|
id: '${sectionKey}_${DateTime.now().microsecondsSinceEpoch}',
|
||||||
|
sectionKey: sectionKey,
|
||||||
|
label: (label ?? query).trim(),
|
||||||
|
query: query.trim(),
|
||||||
|
createdAt: DateTime.now(),
|
||||||
|
matchPrefixes: matchPrefixes,
|
||||||
|
isAutoGroup: isAutoGroup,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await _persistSavedGroups();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> removeSavedGroupById(String id) async {
|
||||||
|
final beforeCount = _savedContactGroups.length;
|
||||||
|
_savedContactGroups.removeWhere((group) => group.id == id);
|
||||||
|
if (_savedContactGroups.length == beforeCount) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _persistSavedGroups();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> removeSavedGroupForFilter(
|
||||||
|
String sectionKey,
|
||||||
|
String query,
|
||||||
|
) async {
|
||||||
|
final normalizedQuery = _normalizeGroupQuery(query);
|
||||||
|
final beforeCount = _savedContactGroups.length;
|
||||||
|
_savedContactGroups.removeWhere(
|
||||||
|
(group) =>
|
||||||
|
group.sectionKey == sectionKey &&
|
||||||
|
_normalizeGroupQuery(group.query) == normalizedQuery,
|
||||||
|
);
|
||||||
|
if (_savedContactGroups.length == beforeCount) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _persistSavedGroups();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> replaceAutoGroupsForSection(
|
||||||
|
String sectionKey,
|
||||||
|
List<SavedContactGroup> groups,
|
||||||
|
) async {
|
||||||
|
_savedContactGroups.removeWhere(
|
||||||
|
(group) => group.sectionKey == sectionKey && group.isAutoGroup,
|
||||||
|
);
|
||||||
|
_savedContactGroups.addAll(groups);
|
||||||
|
await _persistSavedGroups();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _persistSavedGroups() async {
|
||||||
|
try {
|
||||||
|
await _storageService.saveContactGroups(_savedContactGroups);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('❌ [ContactsProvider] Error persisting contact groups: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _normalizeGroupQuery(String query) => query.trim().toLowerCase();
|
||||||
|
|
||||||
List<Contact> get chatContacts =>
|
List<Contact> get chatContacts =>
|
||||||
contacts.where((c) => c.isChat).toList()..sort(_sortByLastSeen);
|
contacts.where((c) => c.isChat).toList()..sort(_sortByLastSeen);
|
||||||
|
|
||||||
@@ -338,8 +478,43 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
required Contact incomingContact,
|
required Contact incomingContact,
|
||||||
Contact? existingContact,
|
Contact? existingContact,
|
||||||
}) {
|
}) {
|
||||||
|
final retainedRoute = _retainedRouteForContact(
|
||||||
|
keyHex: incomingContact.publicKeyHex,
|
||||||
|
incomingContact: incomingContact,
|
||||||
|
existingContact: existingContact,
|
||||||
|
);
|
||||||
|
final mergedTelemetry = _mergeTelemetryForContact(
|
||||||
|
existingTelemetry: existingContact?.telemetry,
|
||||||
|
incomingTelemetry: incomingContact.telemetry,
|
||||||
|
);
|
||||||
|
final inferredFallbackLocation = _inferFirstHopFallbackLocation(
|
||||||
|
incomingContact: incomingContact,
|
||||||
|
existingContact: existingContact,
|
||||||
|
retainedRoute: retainedRoute,
|
||||||
|
mergedTelemetry: mergedTelemetry,
|
||||||
|
);
|
||||||
|
final inferredFallbackAdvLat = inferredFallbackLocation != null
|
||||||
|
? _coordinateToAdvertMicrodegrees(inferredFallbackLocation.latitude)
|
||||||
|
: null;
|
||||||
|
final inferredFallbackAdvLon = inferredFallbackLocation != null
|
||||||
|
? _coordinateToAdvertMicrodegrees(inferredFallbackLocation.longitude)
|
||||||
|
: null;
|
||||||
|
|
||||||
if (existingContact == null) {
|
if (existingContact == null) {
|
||||||
var newContact = incomingContact.copyWith(isNew: true);
|
var newContact = incomingContact.copyWith(
|
||||||
|
isNew: true,
|
||||||
|
nameOverride: existingContact?.nameOverride,
|
||||||
|
telemetry: mergedTelemetry,
|
||||||
|
outPathLen:
|
||||||
|
retainedRoute?.signedEncodedPathLen ?? incomingContact.outPathLen,
|
||||||
|
outPath: retainedRoute?.paddedPathBytes ?? incomingContact.outPath,
|
||||||
|
advLat: incomingContact.advertLocation != null
|
||||||
|
? incomingContact.advLat
|
||||||
|
: inferredFallbackAdvLat ?? incomingContact.advLat,
|
||||||
|
advLon: incomingContact.advertLocation != null
|
||||||
|
? incomingContact.advLon
|
||||||
|
: inferredFallbackAdvLon ?? incomingContact.advLon,
|
||||||
|
);
|
||||||
if (incomingContact.advertLocation != null) {
|
if (incomingContact.advertLocation != null) {
|
||||||
final timestamp = DateTime.fromMillisecondsSinceEpoch(
|
final timestamp = DateTime.fromMillisecondsSinceEpoch(
|
||||||
incomingContact.lastAdvert * 1000,
|
incomingContact.lastAdvert * 1000,
|
||||||
@@ -352,27 +527,27 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
return newContact;
|
return newContact;
|
||||||
}
|
}
|
||||||
|
|
||||||
final mergedTelemetry = _mergeTelemetryForContact(
|
|
||||||
existingTelemetry: existingContact.telemetry,
|
|
||||||
incomingTelemetry: incomingContact.telemetry,
|
|
||||||
);
|
|
||||||
final incomingAdvertLocation = incomingContact.advertLocation;
|
final incomingAdvertLocation = incomingContact.advertLocation;
|
||||||
final existingAdvertLocation = existingContact.advertLocation;
|
final existingAdvertLocation = existingContact.advertLocation;
|
||||||
|
|
||||||
var updatedContact = incomingContact.copyWith(
|
var updatedContact = incomingContact.copyWith(
|
||||||
isNew: existingContact.isNew,
|
isNew: false,
|
||||||
|
nameOverride: existingContact.nameOverride,
|
||||||
advertHistory: existingContact.advertHistory,
|
advertHistory: existingContact.advertHistory,
|
||||||
telemetry: mergedTelemetry,
|
telemetry: mergedTelemetry,
|
||||||
|
outPathLen:
|
||||||
|
retainedRoute?.signedEncodedPathLen ?? incomingContact.outPathLen,
|
||||||
|
outPath: retainedRoute?.paddedPathBytes ?? incomingContact.outPath,
|
||||||
advLat: incomingAdvertLocation != null
|
advLat: incomingAdvertLocation != null
|
||||||
? incomingContact.advLat
|
? incomingContact.advLat
|
||||||
: existingAdvertLocation != null
|
: existingAdvertLocation != null
|
||||||
? existingContact.advLat
|
? existingContact.advLat
|
||||||
: incomingContact.advLat,
|
: inferredFallbackAdvLat ?? incomingContact.advLat,
|
||||||
advLon: incomingAdvertLocation != null
|
advLon: incomingAdvertLocation != null
|
||||||
? incomingContact.advLon
|
? incomingContact.advLon
|
||||||
: existingAdvertLocation != null
|
: existingAdvertLocation != null
|
||||||
? existingContact.advLon
|
? existingContact.advLon
|
||||||
: incomingContact.advLon,
|
: inferredFallbackAdvLon ?? incomingContact.advLon,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (incomingAdvertLocation != null) {
|
if (incomingAdvertLocation != null) {
|
||||||
@@ -388,6 +563,145 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
return updatedContact;
|
return updatedContact;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_RetainedRoute? _retainedRouteForContact({
|
||||||
|
required String keyHex,
|
||||||
|
required Contact incomingContact,
|
||||||
|
required Contact? existingContact,
|
||||||
|
}) {
|
||||||
|
if (incomingContact.routeHasPath) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final pendingAdvert = _pendingAdverts[keyHex];
|
||||||
|
final pendingPathBytes = pendingAdvert?.paddedPathBytes;
|
||||||
|
final pendingPathLen = pendingAdvert?.signedEncodedPathLen;
|
||||||
|
if (pendingPathLen != null &&
|
||||||
|
pendingPathBytes != null &&
|
||||||
|
pendingPathBytes.isNotEmpty) {
|
||||||
|
return _RetainedRoute(
|
||||||
|
signedEncodedPathLen: pendingPathLen,
|
||||||
|
paddedPathBytes: Uint8List.fromList(pendingPathBytes),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existingContact != null && existingContact.routeHasPath) {
|
||||||
|
return _RetainedRoute(
|
||||||
|
signedEncodedPathLen: existingContact.outPathLen,
|
||||||
|
paddedPathBytes: Uint8List.fromList(existingContact.outPath),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
LatLng? _inferFirstHopFallbackLocation({
|
||||||
|
required Contact incomingContact,
|
||||||
|
required Contact? existingContact,
|
||||||
|
required _RetainedRoute? retainedRoute,
|
||||||
|
required ContactTelemetry? mergedTelemetry,
|
||||||
|
}) {
|
||||||
|
if (_getValidGpsOrNull(mergedTelemetry?.gpsLocation) != null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (incomingContact.advertLocation != null ||
|
||||||
|
existingContact?.advertLocation != null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final routeBytes =
|
||||||
|
retainedRoute?.paddedPathBytes ??
|
||||||
|
(incomingContact.routeHasPath
|
||||||
|
? incomingContact.routePathBytes
|
||||||
|
: existingContact?.routeHasPath == true
|
||||||
|
? existingContact!.routePathBytes
|
||||||
|
: null);
|
||||||
|
final routeHashSize = retainedRoute != null
|
||||||
|
? ((ContactRouteCodec.toUnsignedDescriptor(
|
||||||
|
retainedRoute.signedEncodedPathLen,
|
||||||
|
) >>
|
||||||
|
6) +
|
||||||
|
1)
|
||||||
|
: incomingContact.routeHasPath
|
||||||
|
? incomingContact.routeHashSize
|
||||||
|
: existingContact?.routeHasPath == true
|
||||||
|
? existingContact!.routeHashSize
|
||||||
|
: 0;
|
||||||
|
if (routeBytes == null ||
|
||||||
|
routeHashSize <= 0 ||
|
||||||
|
routeBytes.length < routeHashSize) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final lastHopHex = routeBytes
|
||||||
|
.sublist(routeBytes.length - routeHashSize)
|
||||||
|
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
|
||||||
|
.join();
|
||||||
|
final repeaterCandidates = _contacts.values.where((candidate) {
|
||||||
|
if (!candidate.isRepeater ||
|
||||||
|
candidate.publicKeyHex == incomingContact.publicKeyHex) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
final location = candidate.displayLocation;
|
||||||
|
return location != null && candidate.publicKeyHex.startsWith(lastHopHex);
|
||||||
|
}).toList()..sort((a, b) => b.lastAdvert.compareTo(a.lastAdvert));
|
||||||
|
if (repeaterCandidates.isEmpty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final repeaterLocation = repeaterCandidates.first.displayLocation;
|
||||||
|
if (repeaterLocation == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final bearingDegrees = _stableFallbackBearingDegrees(incomingContact);
|
||||||
|
return _offsetFromLocation(
|
||||||
|
repeaterLocation,
|
||||||
|
distanceMeters: _firstHopFallbackOffsetMeters,
|
||||||
|
bearingDegrees: bearingDegrees,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
double _stableFallbackBearingDegrees(Contact contact) {
|
||||||
|
if (contact.publicKey.length < 2) {
|
||||||
|
return 90.0;
|
||||||
|
}
|
||||||
|
final seed = (contact.publicKey[0] << 8) | contact.publicKey[1];
|
||||||
|
return (seed % 360).toDouble();
|
||||||
|
}
|
||||||
|
|
||||||
|
LatLng _offsetFromLocation(
|
||||||
|
LatLng origin, {
|
||||||
|
required double distanceMeters,
|
||||||
|
required double bearingDegrees,
|
||||||
|
}) {
|
||||||
|
const earthRadiusMeters = 6371000.0;
|
||||||
|
final angularDistance = distanceMeters / earthRadiusMeters;
|
||||||
|
final bearingRadians = bearingDegrees * 3.1415926535897932 / 180.0;
|
||||||
|
final lat1 = origin.latitude * 3.1415926535897932 / 180.0;
|
||||||
|
final lon1 = origin.longitude * 3.1415926535897932 / 180.0;
|
||||||
|
|
||||||
|
final sinLat1 = sin(lat1);
|
||||||
|
final cosLat1 = cos(lat1);
|
||||||
|
final sinAngularDistance = sin(angularDistance);
|
||||||
|
final cosAngularDistance = cos(angularDistance);
|
||||||
|
|
||||||
|
final lat2 = asin(
|
||||||
|
sinLat1 * cosAngularDistance +
|
||||||
|
cosLat1 * sinAngularDistance * cos(bearingRadians),
|
||||||
|
);
|
||||||
|
final lon2 =
|
||||||
|
lon1 +
|
||||||
|
atan2(
|
||||||
|
sin(bearingRadians) * sinAngularDistance * cosLat1,
|
||||||
|
cosAngularDistance - sinLat1 * sin(lat2),
|
||||||
|
);
|
||||||
|
|
||||||
|
return LatLng(
|
||||||
|
lat2 * 180.0 / 3.1415926535897932,
|
||||||
|
((lon2 * 180.0 / 3.1415926535897932 + 540.0) % 360.0) - 180.0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Update contact telemetry
|
/// Update contact telemetry
|
||||||
void updateTelemetry(Uint8List publicKeyPrefix, Uint8List lppData) {
|
void updateTelemetry(Uint8List publicKeyPrefix, Uint8List lppData) {
|
||||||
debugPrint('📊 [ContactsProvider] updateTelemetry() called');
|
debugPrint('📊 [ContactsProvider] updateTelemetry() called');
|
||||||
@@ -646,20 +960,74 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
Uint8List publicKey, {
|
Uint8List publicKey, {
|
||||||
required int signedEncodedPathLen,
|
required int signedEncodedPathLen,
|
||||||
required Uint8List paddedPathBytes,
|
required Uint8List paddedPathBytes,
|
||||||
|
LatLng? inferredFallbackLocation,
|
||||||
}) {
|
}) {
|
||||||
final contact = findContactByKey(publicKey);
|
final contact = findContactByKey(publicKey);
|
||||||
if (contact == null) {
|
if (contact == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
_contacts[contact.publicKeyHex] = contact.copyWith(
|
var updatedContact = contact.copyWith(
|
||||||
outPathLen: signedEncodedPathLen,
|
outPathLen: signedEncodedPathLen,
|
||||||
outPath: Uint8List.fromList(paddedPathBytes),
|
outPath: Uint8List.fromList(paddedPathBytes),
|
||||||
);
|
);
|
||||||
|
if (inferredFallbackLocation != null) {
|
||||||
|
updatedContact = updatedContact
|
||||||
|
.copyWith(
|
||||||
|
advLat: _coordinateToAdvertMicrodegrees(
|
||||||
|
inferredFallbackLocation.latitude,
|
||||||
|
),
|
||||||
|
advLon: _coordinateToAdvertMicrodegrees(
|
||||||
|
inferredFallbackLocation.longitude,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.addAdvertLocation(inferredFallbackLocation, DateTime.now());
|
||||||
|
}
|
||||||
|
_contacts[contact.publicKeyHex] = updatedContact;
|
||||||
_persistContacts();
|
_persistContacts();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void retainReceivedRoute(
|
||||||
|
Uint8List publicKey, {
|
||||||
|
required int signedEncodedPathLen,
|
||||||
|
required Uint8List paddedPathBytes,
|
||||||
|
Uint8List? devicePublicKey,
|
||||||
|
}) {
|
||||||
|
if (devicePublicKey != null && publicKey.matches(devicePublicKey)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final keyHex = publicKey
|
||||||
|
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||||
|
.join('');
|
||||||
|
final contact = _contacts[keyHex];
|
||||||
|
if (contact != null) {
|
||||||
|
_contacts[keyHex] = contact.copyWith(
|
||||||
|
outPathLen: signedEncodedPathLen,
|
||||||
|
outPath: Uint8List.fromList(paddedPathBytes),
|
||||||
|
);
|
||||||
|
_persistContacts();
|
||||||
|
notifyListeners();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final now = DateTime.now();
|
||||||
|
final existing = _pendingAdverts[keyHex];
|
||||||
|
_pendingAdverts[keyHex] =
|
||||||
|
(existing ??
|
||||||
|
PendingAdvert(
|
||||||
|
publicKey: Uint8List.fromList(publicKey),
|
||||||
|
receivedAt: now,
|
||||||
|
))
|
||||||
|
.copyWith(
|
||||||
|
receivedAt: now,
|
||||||
|
signedEncodedPathLen: signedEncodedPathLen,
|
||||||
|
paddedPathBytes: Uint8List.fromList(paddedPathBytes),
|
||||||
|
);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
void resetContactRouteLocal(Uint8List publicKey) {
|
void resetContactRouteLocal(Uint8List publicKey) {
|
||||||
final contact = findContactByKey(publicKey);
|
final contact = findContactByKey(publicKey);
|
||||||
if (contact == null) {
|
if (contact == null) {
|
||||||
@@ -674,6 +1042,26 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void setContactNameOverride(String publicKeyHex, String? overrideName) {
|
||||||
|
final contact = _contacts[publicKeyHex];
|
||||||
|
if (contact == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final normalizedOverride = overrideName?.trim();
|
||||||
|
final nextOverride =
|
||||||
|
(normalizedOverride == null || normalizedOverride.isEmpty)
|
||||||
|
? null
|
||||||
|
: normalizedOverride;
|
||||||
|
if (contact.nameOverride == nextOverride) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_contacts[publicKeyHex] = contact.copyWith(nameOverride: nextOverride);
|
||||||
|
_persistContacts();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
/// Add or refresh a pending advert entry from PUSH_CODE_ADVERT (0x80).
|
/// Add or refresh a pending advert entry from PUSH_CODE_ADVERT (0x80).
|
||||||
/// Excludes self key and existing contacts.
|
/// Excludes self key and existing contacts.
|
||||||
void addPendingAdvert(Uint8List publicKey, {Uint8List? devicePublicKey}) {
|
void addPendingAdvert(Uint8List publicKey, {Uint8List? devicePublicKey}) {
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ enum DrawingMode { none, line, rectangle, measure }
|
|||||||
/// Provider for managing map drawings
|
/// Provider for managing map drawings
|
||||||
class DrawingProvider with ChangeNotifier {
|
class DrawingProvider with ChangeNotifier {
|
||||||
static const String _storageKey = 'map_drawings';
|
static const String _storageKey = 'map_drawings';
|
||||||
|
static const String _showReceivedDrawingsKey = 'map_show_received_drawings';
|
||||||
|
static const String _showSarMarkersKey = 'map_show_sar_markers';
|
||||||
|
|
||||||
// Drawing state
|
// Drawing state
|
||||||
DrawingMode _drawingMode = DrawingMode.none;
|
DrawingMode _drawingMode = DrawingMode.none;
|
||||||
@@ -57,6 +59,7 @@ class DrawingProvider with ChangeNotifier {
|
|||||||
|
|
||||||
/// Initialize and load saved drawings
|
/// Initialize and load saved drawings
|
||||||
Future<void> initialize() async {
|
Future<void> initialize() async {
|
||||||
|
await _loadPreferences();
|
||||||
await _loadDrawings();
|
await _loadDrawings();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,15 +80,30 @@ class DrawingProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Toggle visibility of received drawings
|
/// Toggle visibility of received drawings
|
||||||
void toggleReceivedDrawings() {
|
Future<void> toggleReceivedDrawings() async {
|
||||||
_showReceivedDrawings = !_showReceivedDrawings;
|
_showReceivedDrawings = !_showReceivedDrawings;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
await _savePreferences();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Toggle visibility of SAR markers
|
/// Toggle visibility of SAR markers
|
||||||
void toggleSarMarkers() {
|
Future<void> toggleSarMarkers() async {
|
||||||
_showSarMarkers = !_showSarMarkers;
|
_showSarMarkers = !_showSarMarkers;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
await _savePreferences();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadPreferences() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
_showReceivedDrawings = prefs.getBool(_showReceivedDrawingsKey) ?? true;
|
||||||
|
_showSarMarkers = prefs.getBool(_showSarMarkersKey) ?? true;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _savePreferences() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setBool(_showReceivedDrawingsKey, _showReceivedDrawings);
|
||||||
|
await prefs.setBool(_showSarMarkersKey, _showSarMarkers);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Start drawing a line
|
/// Start drawing a line
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter_map/flutter_map.dart';
|
import 'package:flutter_map/flutter_map.dart';
|
||||||
import 'package:latlong2/latlong.dart';
|
import 'package:latlong2/latlong.dart';
|
||||||
@@ -6,6 +8,10 @@ import '../models/location_trail.dart';
|
|||||||
import '../models/map_drawing.dart';
|
import '../models/map_drawing.dart';
|
||||||
|
|
||||||
class MapProvider with ChangeNotifier {
|
class MapProvider with ChangeNotifier {
|
||||||
|
MapProvider() {
|
||||||
|
unawaited(_loadInitialState());
|
||||||
|
}
|
||||||
|
|
||||||
LatLng? _targetLocation;
|
LatLng? _targetLocation;
|
||||||
double? _targetZoom;
|
double? _targetZoom;
|
||||||
bool _shouldAnimate = false;
|
bool _shouldAnimate = false;
|
||||||
@@ -33,6 +39,7 @@ class MapProvider with ChangeNotifier {
|
|||||||
|
|
||||||
// Contact trail toggles
|
// Contact trail toggles
|
||||||
bool _showAllContactTrails = true; // Default to showing all contact trails
|
bool _showAllContactTrails = true; // Default to showing all contact trails
|
||||||
|
bool _hideRepeatersOnMap = false;
|
||||||
|
|
||||||
// Imported trail (from GPX)
|
// Imported trail (from GPX)
|
||||||
LocationTrail? _importedTrail;
|
LocationTrail? _importedTrail;
|
||||||
@@ -67,6 +74,7 @@ class MapProvider with ChangeNotifier {
|
|||||||
|
|
||||||
// Contact trail getters
|
// Contact trail getters
|
||||||
bool get showAllContactTrails => _showAllContactTrails;
|
bool get showAllContactTrails => _showAllContactTrails;
|
||||||
|
bool get hideRepeatersOnMap => _hideRepeatersOnMap;
|
||||||
|
|
||||||
// Imported trail getters
|
// Imported trail getters
|
||||||
LocationTrail? get importedTrail => _importedTrail;
|
LocationTrail? get importedTrail => _importedTrail;
|
||||||
@@ -350,6 +358,11 @@ class MapProvider with ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _loadInitialState() async {
|
||||||
|
await Future.wait([loadOverlayState(), loadTrailSettings()]);
|
||||||
|
await loadRepeaterVisibilitySettings();
|
||||||
|
}
|
||||||
|
|
||||||
/// Save overlay state to SharedPreferences
|
/// Save overlay state to SharedPreferences
|
||||||
Future<void> _saveOverlayState() async {
|
Future<void> _saveOverlayState() async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
@@ -386,6 +399,20 @@ class MapProvider with ChangeNotifier {
|
|||||||
await prefs.setBool('map_show_all_contact_trails', _showAllContactTrails);
|
await prefs.setBool('map_show_all_contact_trails', _showAllContactTrails);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> setHideRepeatersOnMap(bool hide) async {
|
||||||
|
if (_hideRepeatersOnMap == hide) return;
|
||||||
|
_hideRepeatersOnMap = hide;
|
||||||
|
notifyListeners();
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setBool('map_hide_repeaters', _hideRepeatersOnMap);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> loadRepeaterVisibilitySettings() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
_hideRepeatersOnMap = prefs.getBool('map_hide_repeaters') ?? false;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
/// Set imported trail (from GPX import)
|
/// Set imported trail (from GPX import)
|
||||||
void setImportedTrail(LocationTrail trail) {
|
void setImportedTrail(LocationTrail trail) {
|
||||||
_importedTrail = trail;
|
_importedTrail = trail;
|
||||||
|
|||||||
@@ -20,8 +20,11 @@ import 'helpers/message_retry_manager.dart';
|
|||||||
|
|
||||||
/// Messages Provider - manages message history and SAR markers
|
/// Messages Provider - manages message history and SAR markers
|
||||||
class MessagesProvider with ChangeNotifier {
|
class MessagesProvider with ChangeNotifier {
|
||||||
|
static const Duration _channelEchoWarningDelay = Duration(seconds: 12);
|
||||||
|
|
||||||
final List<Message> _messages = [];
|
final List<Message> _messages = [];
|
||||||
final Map<String, SarMarker> _sarMarkers = {};
|
final Map<String, SarMarker> _sarMarkers = {};
|
||||||
|
final Set<String> _removedSarMarkerIds = <String>{};
|
||||||
final MessageStorageService _storageService = MessageStorageService();
|
final MessageStorageService _storageService = MessageStorageService();
|
||||||
final NotificationService _notificationService = NotificationService();
|
final NotificationService _notificationService = NotificationService();
|
||||||
bool _isInitialized = false;
|
bool _isInitialized = false;
|
||||||
@@ -37,6 +40,8 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
// Track timeout timers for pending messages
|
// Track timeout timers for pending messages
|
||||||
// Key: message ID (not ACK tag, since multiple messages can share same ACK)
|
// Key: message ID (not ACK tag, since multiple messages can share same ACK)
|
||||||
final Map<String, Timer> _timeoutTimers = {};
|
final Map<String, Timer> _timeoutTimers = {};
|
||||||
|
final Map<String, Timer> _channelEchoWarningTimers = {};
|
||||||
|
final Set<String> _channelEchoWarningMessageIds = <String>{};
|
||||||
|
|
||||||
// Recently completed ACKs are kept briefly to ignore duplicate confirms.
|
// Recently completed ACKs are kept briefly to ignore duplicate confirms.
|
||||||
final Map<int, DateTime> _completedAckHistory = {};
|
final Map<int, DateTime> _completedAckHistory = {};
|
||||||
@@ -86,6 +91,7 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
|
|
||||||
Future<void> Function({required Contact contact, required int failureStreak})?
|
Future<void> Function({required Contact contact, required int failureStreak})?
|
||||||
onDirectPathFailedCallback;
|
onDirectPathFailedCallback;
|
||||||
|
void Function(String messageId)? onManualRetryPreparedCallback;
|
||||||
Future<bool> Function({
|
Future<bool> Function({
|
||||||
required String messageId,
|
required String messageId,
|
||||||
required Contact contact,
|
required Contact contact,
|
||||||
@@ -124,6 +130,7 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
_messages.where((m) => m.isSystemMessage).toList();
|
_messages.where((m) => m.isSystemMessage).toList();
|
||||||
|
|
||||||
List<SarMarker> get sarMarkers => _sarMarkers.values.toList();
|
List<SarMarker> get sarMarkers => _sarMarkers.values.toList();
|
||||||
|
Set<String> get removedSarMarkerIds => Set.unmodifiable(_removedSarMarkerIds);
|
||||||
|
|
||||||
List<SarMarker> get foundPersonMarkers =>
|
List<SarMarker> get foundPersonMarkers =>
|
||||||
sarMarkers.where((m) => m.type == SarMarkerType.foundPerson).toList();
|
sarMarkers.where((m) => m.type == SarMarkerType.foundPerson).toList();
|
||||||
@@ -153,6 +160,9 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
MessageRouteMetadata? getMessageRouteMetadata(String messageId) =>
|
MessageRouteMetadata? getMessageRouteMetadata(String messageId) =>
|
||||||
_messageRouteMetadata[messageId];
|
_messageRouteMetadata[messageId];
|
||||||
|
|
||||||
|
bool hasChannelSendWarning(String messageId) =>
|
||||||
|
_channelEchoWarningMessageIds.contains(messageId);
|
||||||
|
|
||||||
void updateMessageRouteSelection(
|
void updateMessageRouteSelection(
|
||||||
String messageId,
|
String messageId,
|
||||||
PathSelection selection, {
|
PathSelection selection, {
|
||||||
@@ -165,8 +175,12 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
|
|
||||||
final index = _messages.indexWhere((message) => message.id == messageId);
|
final index = _messages.indexWhere((message) => message.id == messageId);
|
||||||
if (index != -1) {
|
if (index != -1) {
|
||||||
|
final nextPathLen = selection.hopCount > 0
|
||||||
|
? selection.hopCount
|
||||||
|
: _messages[index].pathLen;
|
||||||
_messages[index] = _messages[index].copyWith(
|
_messages[index] = _messages[index].copyWith(
|
||||||
usedFloodFallback: selection.usesFlood,
|
usedFloodFallback: selection.usesFlood,
|
||||||
|
pathLen: nextPathLen,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -239,6 +253,17 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
)
|
)
|
||||||
.length;
|
.length;
|
||||||
|
|
||||||
|
int getUnreadCountForChannel(int channelIdx) => _messages
|
||||||
|
.where(
|
||||||
|
(message) =>
|
||||||
|
message.isChannelMessage &&
|
||||||
|
(message.channelIdx ?? 0) == channelIdx &&
|
||||||
|
!message.isRead &&
|
||||||
|
!message.isSentMessage &&
|
||||||
|
!message.isSystemMessage,
|
||||||
|
)
|
||||||
|
.length;
|
||||||
|
|
||||||
bool _isMessageForDestination(Message message, Contact contact) {
|
bool _isMessageForDestination(Message message, Contact contact) {
|
||||||
if (message.isSystemMessage) return false;
|
if (message.isSystemMessage) return false;
|
||||||
|
|
||||||
@@ -293,6 +318,8 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
.loadMessageTransferDetails();
|
.loadMessageTransferDetails();
|
||||||
final storedRouteMetadata = await _storageService
|
final storedRouteMetadata = await _storageService
|
||||||
.loadMessageRouteMetadata();
|
.loadMessageRouteMetadata();
|
||||||
|
final storedRemovedSarMarkerIds = await _storageService
|
||||||
|
.loadRemovedSarMarkerIds();
|
||||||
_messageContactLocations
|
_messageContactLocations
|
||||||
..clear()
|
..clear()
|
||||||
..addAll(storedContactLocations);
|
..addAll(storedContactLocations);
|
||||||
@@ -305,6 +332,9 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
_messageRouteMetadata
|
_messageRouteMetadata
|
||||||
..clear()
|
..clear()
|
||||||
..addAll(storedRouteMetadata);
|
..addAll(storedRouteMetadata);
|
||||||
|
_removedSarMarkerIds
|
||||||
|
..clear()
|
||||||
|
..addAll(storedRemovedSarMarkerIds);
|
||||||
|
|
||||||
// Add stored messages with enhancement to ensure SAR detection
|
// Add stored messages with enhancement to ensure SAR detection
|
||||||
for (final message in storedMessages) {
|
for (final message in storedMessages) {
|
||||||
@@ -352,7 +382,7 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
// Extract SAR markers
|
// Extract SAR markers
|
||||||
if (enhancedMessage.isSarMarker) {
|
if (enhancedMessage.isSarMarker) {
|
||||||
final marker = enhancedMessage.toSarMarker();
|
final marker = enhancedMessage.toSarMarker();
|
||||||
if (marker != null) {
|
if (marker != null && !_removedSarMarkerIds.contains(marker.id)) {
|
||||||
_sarMarkers[marker.id] = marker;
|
_sarMarkers[marker.id] = marker;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -493,6 +523,7 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
enhancedMessage = _resolveSenderNameIfNeeded(enhancedMessage);
|
||||||
|
|
||||||
// For channel messages with sender name, try to link with contact
|
// For channel messages with sender name, try to link with contact
|
||||||
Message finalMessage = enhancedMessage;
|
Message finalMessage = enhancedMessage;
|
||||||
@@ -532,29 +563,27 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
// - Mesh network retransmissions
|
// - Mesh network retransmissions
|
||||||
// - Multiple paths in the network
|
// - Multiple paths in the network
|
||||||
// - Syncing messages from device queue
|
// - Syncing messages from device queue
|
||||||
if (_isDuplicate(finalMessage)) {
|
final duplicateIndex = _findDuplicateMessageIndex(finalMessage);
|
||||||
|
if (duplicateIndex != -1) {
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'⚠️ [MessagesProvider] Duplicate message detected, skipping: ${finalMessage.id}',
|
'⚠️ [MessagesProvider] Duplicate message detected, skipping: ${finalMessage.id}',
|
||||||
);
|
);
|
||||||
debugPrint(
|
debugPrint(
|
||||||
' Text: ${finalMessage.text.substring(0, finalMessage.text.length > 50 ? 50 : finalMessage.text.length)}...',
|
' Text: ${finalMessage.text.substring(0, finalMessage.text.length > 50 ? 50 : finalMessage.text.length)}...',
|
||||||
);
|
);
|
||||||
final existingIndex = _messages.indexWhere(
|
final existingId = _messages[duplicateIndex].id;
|
||||||
(existing) =>
|
if (finalMessage.isChannelMessage &&
|
||||||
existing.messageType == finalMessage.messageType &&
|
!finalMessage.isSentMessage &&
|
||||||
existing.senderTimestamp == finalMessage.senderTimestamp &&
|
_messages[duplicateIndex].isSentMessage) {
|
||||||
existing.text == finalMessage.text,
|
_clearChannelSendWarning(existingId);
|
||||||
);
|
|
||||||
if (existingIndex != -1) {
|
|
||||||
final existingId = _messages[existingIndex].id;
|
|
||||||
if (contactLocationSnapshot != null) {
|
|
||||||
_messageContactLocations[existingId] = contactLocationSnapshot;
|
|
||||||
}
|
|
||||||
if (receptionDetailsSnapshot != null) {
|
|
||||||
_messageReceptionDetails[existingId] = receptionDetailsSnapshot;
|
|
||||||
}
|
|
||||||
_persistMessages();
|
|
||||||
}
|
}
|
||||||
|
if (contactLocationSnapshot != null) {
|
||||||
|
_messageContactLocations[existingId] = contactLocationSnapshot;
|
||||||
|
}
|
||||||
|
if (receptionDetailsSnapshot != null) {
|
||||||
|
_messageReceptionDetails[existingId] = receptionDetailsSnapshot;
|
||||||
|
}
|
||||||
|
_persistMessages();
|
||||||
return; // Skip duplicate
|
return; // Skip duplicate
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -569,7 +598,7 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
// If it's a SAR marker message, extract and store the marker
|
// If it's a SAR marker message, extract and store the marker
|
||||||
if (finalMessage.isSarMarker) {
|
if (finalMessage.isSarMarker) {
|
||||||
final marker = finalMessage.toSarMarker();
|
final marker = finalMessage.toSarMarker();
|
||||||
if (marker != null) {
|
if (marker != null && !_removedSarMarkerIds.contains(marker.id)) {
|
||||||
_sarMarkers[marker.id] = marker;
|
_sarMarkers[marker.id] = marker;
|
||||||
|
|
||||||
// Trigger urgent notification for received SAR messages (not sent by user)
|
// Trigger urgent notification for received SAR messages (not sent by user)
|
||||||
@@ -593,50 +622,100 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
/// Messages are considered duplicates if they have:
|
/// Messages are considered duplicates if they have:
|
||||||
/// 1. Same sender public key prefix (for contact messages)
|
/// 1. Same sender public key prefix (for contact messages)
|
||||||
/// 2. Same channel index (for channel messages)
|
/// 2. Same channel index (for channel messages)
|
||||||
/// 3. Same sender timestamp
|
/// 3. Same text content
|
||||||
/// 4. Same text content
|
|
||||||
///
|
///
|
||||||
/// Note: Sent messages (isSentMessage=true) are NEVER duplicates
|
/// Note: Sent messages (isSentMessage=true) are NEVER duplicates
|
||||||
/// because they can be retried with different message IDs
|
/// because they can be retried with different message IDs
|
||||||
bool _isDuplicate(Message message) {
|
int _findDuplicateMessageIndex(Message message) {
|
||||||
// Sent messages (our own messages) should never be considered duplicates
|
// Sent messages (our own messages) should never be considered duplicates
|
||||||
// They can be retried multiple times with different IDs
|
// They can be retried multiple times with different IDs
|
||||||
if (message.isSentMessage) {
|
if (message.isSentMessage) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int index = 0; index < _messages.length; index++) {
|
||||||
|
final existing = _messages[index];
|
||||||
|
if (!_matchesDuplicateScope(existing, message) ||
|
||||||
|
existing.text != message.text) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (message.isChannelMessage) {
|
||||||
|
debugPrint(
|
||||||
|
'🔁 [MessagesProvider] Channel duplicate match: '
|
||||||
|
'incoming(id=${message.id}, senderName=${message.senderName ?? "-"}, senderKey=${message.senderKeyShort ?? "-"}, pathLen=${message.pathLen}, ts=${message.senderTimestamp}) '
|
||||||
|
'existing(id=${existing.id}, sent=${existing.isSentMessage}, senderName=${existing.senderName ?? "-"}, senderKey=${existing.senderKeyShort ?? "-"}, pathLen=${existing.pathLen}, ts=${existing.senderTimestamp})',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _matchesDuplicateScope(Message existing, Message message) {
|
||||||
|
if (existing.messageType != message.messageType) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return _messages.any((existing) {
|
if (message.isContactMessage) {
|
||||||
// Check message type matches
|
return existing.senderKeyShort == message.senderKeyShort;
|
||||||
if (existing.messageType != message.messageType) {
|
}
|
||||||
|
|
||||||
|
if (message.isChannelMessage) {
|
||||||
|
if (existing.channelIdx != message.channelIdx) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check sender matches
|
final withinChannelRepeatWindow =
|
||||||
if (message.isContactMessage) {
|
(existing.senderTimestamp - message.senderTimestamp).abs() <= 30;
|
||||||
// For contact messages, compare sender public key prefix
|
|
||||||
if (existing.senderKeyShort != message.senderKeyShort) {
|
final existingSenderKey = existing.senderKeyShort;
|
||||||
|
final incomingSenderKey = message.senderKeyShort;
|
||||||
|
if (existingSenderKey != null &&
|
||||||
|
incomingSenderKey != null &&
|
||||||
|
existingSenderKey == incomingSenderKey &&
|
||||||
|
withinChannelRepeatWindow) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
final existingSenderName = _normalizedResolvedSenderName(existing);
|
||||||
|
final incomingSenderName = _normalizedResolvedSenderName(message);
|
||||||
|
if (existing.isChannelMessage && message.isChannelMessage) {
|
||||||
|
debugPrint(
|
||||||
|
'🧪 [MessagesProvider] Channel dedupe check: '
|
||||||
|
'existing(id=${existing.id}, sent=${existing.isSentMessage}, senderName=${existing.senderName ?? "-"}, resolvedSender=${existingSenderName ?? "-"}, senderKey=${existing.senderKeyShort ?? "-"}, ts=${existing.senderTimestamp}, pathLen=${existing.pathLen}) '
|
||||||
|
'incoming(id=${message.id}, sent=${message.isSentMessage}, senderName=${message.senderName ?? "-"}, resolvedSender=${incomingSenderName ?? "-"}, senderKey=${message.senderKeyShort ?? "-"}, ts=${message.senderTimestamp}, pathLen=${message.pathLen}) '
|
||||||
|
'withinWindow=$withinChannelRepeatWindow',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (existingSenderName != null &&
|
||||||
|
incomingSenderName != null &&
|
||||||
|
existingSenderName == incomingSenderName) {
|
||||||
|
if (!existing.isSentMessage && !message.isSentMessage) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!withinChannelRepeatWindow) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
} else if (message.isChannelMessage) {
|
|
||||||
// For channel messages, compare channel index
|
// Mirror meshcore-open behavior for self-authored channel messages:
|
||||||
if (existing.channelIdx != message.channelIdx) {
|
// zero-hop self replays are dropped earlier in AppProvider, while
|
||||||
return false;
|
// routed self replays should fold into the local sent bubble when the
|
||||||
|
// public sender name matches within a short window.
|
||||||
|
if (existing.isSentMessage &&
|
||||||
|
!message.isSentMessage &&
|
||||||
|
existingSenderName == incomingSenderName) {
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check timestamp matches (sender timestamp is the unique identifier from the sender)
|
return false;
|
||||||
if (existing.senderTimestamp != message.senderTimestamp) {
|
}
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check text content matches
|
return true;
|
||||||
if (existing.text != message.text) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// All criteria match - this is a duplicate
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Add multiple messages
|
/// Add multiple messages
|
||||||
@@ -649,7 +728,7 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
final enhancedMessage = SarMessageParser.enhanceMessage(message);
|
final enhancedMessage = SarMessageParser.enhanceMessage(message);
|
||||||
|
|
||||||
// Check for duplicates
|
// Check for duplicates
|
||||||
if (_isDuplicate(enhancedMessage)) {
|
if (_findDuplicateMessageIndex(enhancedMessage) != -1) {
|
||||||
duplicateCount++;
|
duplicateCount++;
|
||||||
continue; // Skip duplicate
|
continue; // Skip duplicate
|
||||||
}
|
}
|
||||||
@@ -659,7 +738,7 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
|
|
||||||
if (enhancedMessage.isSarMarker) {
|
if (enhancedMessage.isSarMarker) {
|
||||||
final marker = enhancedMessage.toSarMarker();
|
final marker = enhancedMessage.toSarMarker();
|
||||||
if (marker != null) {
|
if (marker != null && !_removedSarMarkerIds.contains(marker.id)) {
|
||||||
_sarMarkers[marker.id] = marker;
|
_sarMarkers[marker.id] = marker;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -675,6 +754,80 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String? _normalizeSenderName(String? value) {
|
||||||
|
final trimmed = value?.trim();
|
||||||
|
if (trimmed == null || trimmed.isEmpty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var normalized = trimmed.toLowerCase();
|
||||||
|
if (normalized.startsWith('meshcore-')) {
|
||||||
|
normalized = normalized.substring('meshcore-'.length).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized.isEmpty ? null : normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
Message _resolveSenderNameIfNeeded(Message message) {
|
||||||
|
if (message.senderName != null || message.senderPublicKeyPrefix == null) {
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
final resolvedSenderName = resolveContactNameCallback?.call(
|
||||||
|
message.senderPublicKeyPrefix,
|
||||||
|
);
|
||||||
|
if (resolvedSenderName == null || resolvedSenderName.trim().isEmpty) {
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
return message.copyWith(senderName: resolvedSenderName.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _normalizedResolvedSenderName(Message message) {
|
||||||
|
final directName = _normalizeSenderName(message.senderName);
|
||||||
|
if (directName != null) {
|
||||||
|
return directName;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.senderPublicKeyPrefix == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final resolved = resolveContactNameCallback?.call(
|
||||||
|
message.senderPublicKeyPrefix,
|
||||||
|
);
|
||||||
|
return _normalizeSenderName(resolved);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _scheduleChannelEchoWarning(String messageId) {
|
||||||
|
_channelEchoWarningTimers[messageId]?.cancel();
|
||||||
|
_channelEchoWarningMessageIds.remove(messageId);
|
||||||
|
_channelEchoWarningTimers[messageId] = Timer(_channelEchoWarningDelay, () {
|
||||||
|
final index = _messages.indexWhere((m) => m.id == messageId);
|
||||||
|
_channelEchoWarningTimers.remove(messageId);
|
||||||
|
if (index == -1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final message = _messages[index];
|
||||||
|
if (!message.isChannelMessage ||
|
||||||
|
!message.isSentMessage ||
|
||||||
|
message.deliveryStatus != MessageDeliveryStatus.sent ||
|
||||||
|
message.echoCount > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_channelEchoWarningMessageIds.add(messageId);
|
||||||
|
notifyListeners();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _clearChannelSendWarning(String messageId) {
|
||||||
|
_channelEchoWarningTimers[messageId]?.cancel();
|
||||||
|
_channelEchoWarningTimers.remove(messageId);
|
||||||
|
_channelEchoWarningMessageIds.remove(messageId);
|
||||||
|
}
|
||||||
|
|
||||||
/// Trigger urgent notification for SAR marker
|
/// Trigger urgent notification for SAR marker
|
||||||
Future<void> _triggerSarNotification(
|
Future<void> _triggerSarNotification(
|
||||||
Message message,
|
Message message,
|
||||||
@@ -854,6 +1007,54 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Remove all messages associated with a specific channel.
|
||||||
|
void clearChannelMessages(int channelIdx) {
|
||||||
|
final messageIdsToRemove = _messages
|
||||||
|
.where(
|
||||||
|
(message) =>
|
||||||
|
message.isChannelMessage && message.channelIdx == channelIdx,
|
||||||
|
)
|
||||||
|
.map((message) => message.id)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
if (messageIdsToRemove.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_messages.removeWhere(
|
||||||
|
(message) => message.isChannelMessage && message.channelIdx == channelIdx,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (final messageId in messageIdsToRemove) {
|
||||||
|
_timeoutTimers[messageId]?.cancel();
|
||||||
|
_timeoutTimers.remove(messageId);
|
||||||
|
_messageContactMap.remove(messageId);
|
||||||
|
_groupedMessageMapping.remove(messageId);
|
||||||
|
_messageContactLocations.remove(messageId);
|
||||||
|
_messageReceptionDetails.remove(messageId);
|
||||||
|
_messageTransferDetails.remove(messageId);
|
||||||
|
_messageRouteMetadata.remove(messageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
_pendingSentMessages.removeWhere(
|
||||||
|
(_, message) =>
|
||||||
|
message.isChannelMessage && message.channelIdx == channelIdx,
|
||||||
|
);
|
||||||
|
_messageAckHistory.removeWhere(
|
||||||
|
(messageId, _) => messageIdsToRemove.contains(messageId),
|
||||||
|
);
|
||||||
|
_ackHistoryLookup.removeWhere(
|
||||||
|
(_, value) => messageIdsToRemove.contains(value.$1),
|
||||||
|
);
|
||||||
|
|
||||||
|
debugPrint(
|
||||||
|
'🗑️ [MessagesProvider] Cleared ${messageIdsToRemove.length} message(s) for channel $channelIdx',
|
||||||
|
);
|
||||||
|
|
||||||
|
_persistMessages();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
/// Get recent messages (last N messages)
|
/// Get recent messages (last N messages)
|
||||||
List<Message> getRecentMessages({int count = 50}) {
|
List<Message> getRecentMessages({int count = 50}) {
|
||||||
final sorted = List<Message>.from(_messages)
|
final sorted = List<Message>.from(_messages)
|
||||||
@@ -881,17 +1082,35 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
return _sarMarkers[id];
|
return _sarMarkers[id];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Message? getMessageById(String id) {
|
||||||
|
final index = _messages.indexWhere((message) => message.id == id);
|
||||||
|
if (index == -1) return null;
|
||||||
|
return _messages[index];
|
||||||
|
}
|
||||||
|
|
||||||
/// Get recent SAR markers (within last hour)
|
/// Get recent SAR markers (within last hour)
|
||||||
List<SarMarker> getRecentSarMarkers() {
|
List<SarMarker> getRecentSarMarkers() {
|
||||||
return sarMarkers.where((m) => m.isRecent).toList();
|
return sarMarkers.where((m) => m.isRecent).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Remove a SAR marker
|
/// Remove a SAR marker
|
||||||
void removeSarMarker(String id) {
|
Future<void> removeSarMarker(String id) async {
|
||||||
_sarMarkers.remove(id);
|
_sarMarkers.remove(id);
|
||||||
|
_removedSarMarkerIds.add(id);
|
||||||
|
await _storageService.saveRemovedSarMarkerIds(_removedSarMarkerIds);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> removeSarMarkerPermanently(String id) async {
|
||||||
|
final hasBackingMessage = _messages.any((message) => message.id == id);
|
||||||
|
if (hasBackingMessage) {
|
||||||
|
deleteMessage(id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await removeSarMarker(id);
|
||||||
|
}
|
||||||
|
|
||||||
/// Mark all messages as read
|
/// Mark all messages as read
|
||||||
void markAllAsRead() {
|
void markAllAsRead() {
|
||||||
bool hasChanges = false;
|
bool hasChanges = false;
|
||||||
@@ -979,6 +1198,7 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
final marker = message.toSarMarker();
|
final marker = message.toSarMarker();
|
||||||
if (marker != null) {
|
if (marker != null) {
|
||||||
_sarMarkers.remove(marker.id);
|
_sarMarkers.remove(marker.id);
|
||||||
|
_removedSarMarkerIds.add(marker.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -988,6 +1208,7 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
// Cancel timeout timer if it exists
|
// Cancel timeout timer if it exists
|
||||||
_timeoutTimers[message.id]?.cancel();
|
_timeoutTimers[message.id]?.cancel();
|
||||||
_timeoutTimers.remove(message.id);
|
_timeoutTimers.remove(message.id);
|
||||||
|
_clearChannelSendWarning(message.id);
|
||||||
if (message.expectedAckTag != null) {
|
if (message.expectedAckTag != null) {
|
||||||
_pendingSentMessages.remove(message.expectedAckTag);
|
_pendingSentMessages.remove(message.expectedAckTag);
|
||||||
}
|
}
|
||||||
@@ -1001,6 +1222,7 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
debugPrint('🗑️ [MessagesProvider] Message $messageId deleted');
|
debugPrint('🗑️ [MessagesProvider] Message $messageId deleted');
|
||||||
|
|
||||||
_persistMessages();
|
_persistMessages();
|
||||||
|
unawaited(_storageService.saveRemovedSarMarkerIds(_removedSarMarkerIds));
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1023,31 +1245,47 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
|
|
||||||
/// Clear all messages
|
/// Clear all messages
|
||||||
void clearMessages() {
|
void clearMessages() {
|
||||||
|
for (final timer in _channelEchoWarningTimers.values) {
|
||||||
|
timer.cancel();
|
||||||
|
}
|
||||||
|
_channelEchoWarningTimers.clear();
|
||||||
|
_channelEchoWarningMessageIds.clear();
|
||||||
_messages.clear();
|
_messages.clear();
|
||||||
_sarMarkers.clear();
|
_sarMarkers.clear();
|
||||||
|
_removedSarMarkerIds.clear();
|
||||||
_messageContactLocations.clear();
|
_messageContactLocations.clear();
|
||||||
_messageReceptionDetails.clear();
|
_messageReceptionDetails.clear();
|
||||||
_messageTransferDetails.clear();
|
_messageTransferDetails.clear();
|
||||||
_messageRouteMetadata.clear();
|
_messageRouteMetadata.clear();
|
||||||
_persistMessages();
|
_persistMessages();
|
||||||
|
unawaited(_storageService.saveRemovedSarMarkerIds(_removedSarMarkerIds));
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Clear all SAR markers
|
/// Clear all SAR markers
|
||||||
void clearSarMarkers() {
|
Future<void> clearSarMarkers() async {
|
||||||
|
_removedSarMarkerIds.addAll(_sarMarkers.keys);
|
||||||
_sarMarkers.clear();
|
_sarMarkers.clear();
|
||||||
|
await _storageService.saveRemovedSarMarkerIds(_removedSarMarkerIds);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Clear all data
|
/// Clear all data
|
||||||
void clearAll() {
|
void clearAll() {
|
||||||
|
for (final timer in _channelEchoWarningTimers.values) {
|
||||||
|
timer.cancel();
|
||||||
|
}
|
||||||
|
_channelEchoWarningTimers.clear();
|
||||||
|
_channelEchoWarningMessageIds.clear();
|
||||||
_messages.clear();
|
_messages.clear();
|
||||||
_sarMarkers.clear();
|
_sarMarkers.clear();
|
||||||
|
_removedSarMarkerIds.clear();
|
||||||
_messageContactLocations.clear();
|
_messageContactLocations.clear();
|
||||||
_messageReceptionDetails.clear();
|
_messageReceptionDetails.clear();
|
||||||
_messageTransferDetails.clear();
|
_messageTransferDetails.clear();
|
||||||
_messageRouteMetadata.clear();
|
_messageRouteMetadata.clear();
|
||||||
_persistMessages();
|
_persistMessages();
|
||||||
|
unawaited(_storageService.saveRemovedSarMarkerIds(_removedSarMarkerIds));
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1206,9 +1444,10 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
enhancedMessage = _resolveSenderNameIfNeeded(enhancedMessage);
|
||||||
|
|
||||||
// Check for duplicates (shouldn't happen for sent messages, but be safe)
|
// Check for duplicates (shouldn't happen for sent messages, but be safe)
|
||||||
if (_isDuplicate(enhancedMessage)) {
|
if (_findDuplicateMessageIndex(enhancedMessage) != -1) {
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'⚠️ [MessagesProvider] Duplicate sent message detected, skipping: ${enhancedMessage.id}',
|
'⚠️ [MessagesProvider] Duplicate sent message detected, skipping: ${enhancedMessage.id}',
|
||||||
);
|
);
|
||||||
@@ -1233,7 +1472,7 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
// If it's a SAR marker message, extract and store the marker
|
// If it's a SAR marker message, extract and store the marker
|
||||||
if (sendingMessage.isSarMarker) {
|
if (sendingMessage.isSarMarker) {
|
||||||
final marker = sendingMessage.toSarMarker();
|
final marker = sendingMessage.toSarMarker();
|
||||||
if (marker != null) {
|
if (marker != null && !_removedSarMarkerIds.contains(marker.id)) {
|
||||||
debugPrint(' ✅ SAR Marker created:');
|
debugPrint(' ✅ SAR Marker created:');
|
||||||
debugPrint(' marker.id: ${marker.id}');
|
debugPrint(' marker.id: ${marker.id}');
|
||||||
debugPrint(' marker.notes: "${marker.notes}"');
|
debugPrint(' marker.notes: "${marker.notes}"');
|
||||||
@@ -1452,6 +1691,9 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
debugPrint(
|
debugPrint(
|
||||||
' ℹ️ Channel message (no ACK tracking) - marked as sent immediately',
|
' ℹ️ Channel message (no ACK tracking) - marked as sent immediately',
|
||||||
);
|
);
|
||||||
|
if (message.isChannelMessage) {
|
||||||
|
_scheduleChannelEchoWarning(messageId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
debugPrint(' Calling notifyListeners() to update UI with "sent" status');
|
debugPrint(' Calling notifyListeners() to update UI with "sent" status');
|
||||||
@@ -1500,6 +1742,7 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
lastEchoAt: DateTime.now(),
|
lastEchoAt: DateTime.now(),
|
||||||
);
|
);
|
||||||
_messages[index] = updatedMessage;
|
_messages[index] = updatedMessage;
|
||||||
|
_clearChannelSendWarning(messageId);
|
||||||
|
|
||||||
debugPrint(' Updated echo count to: $echoCount');
|
debugPrint(' Updated echo count to: $echoCount');
|
||||||
_persistMessages();
|
_persistMessages();
|
||||||
@@ -1875,6 +2118,7 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final message = _messages[index];
|
final message = _messages[index];
|
||||||
|
_clearChannelSendWarning(messageId);
|
||||||
final contact = _messageContactMap[messageId];
|
final contact = _messageContactMap[messageId];
|
||||||
|
|
||||||
debugPrint('❌ [MessagesProvider] Message $messageId timeout/failed');
|
debugPrint('❌ [MessagesProvider] Message $messageId timeout/failed');
|
||||||
@@ -1914,6 +2158,7 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
deliveryStatus: MessageDeliveryStatus.sending,
|
deliveryStatus: MessageDeliveryStatus.sending,
|
||||||
lastRetryAt: DateTime.now(),
|
lastRetryAt: DateTime.now(),
|
||||||
);
|
);
|
||||||
|
_clearChannelSendWarning(messageId);
|
||||||
|
|
||||||
// Cancel old timeout timer
|
// Cancel old timeout timer
|
||||||
_timeoutTimers[message.id]?.cancel();
|
_timeoutTimers[message.id]?.cancel();
|
||||||
@@ -1979,6 +2224,7 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
deliveryStatus: MessageDeliveryStatus.sending,
|
deliveryStatus: MessageDeliveryStatus.sending,
|
||||||
lastRetryAt: DateTime.now(),
|
lastRetryAt: DateTime.now(),
|
||||||
);
|
);
|
||||||
|
_clearChannelSendWarning(messageId);
|
||||||
|
|
||||||
_timeoutTimers[message.id]?.cancel();
|
_timeoutTimers[message.id]?.cancel();
|
||||||
_timeoutTimers.remove(message.id);
|
_timeoutTimers.remove(message.id);
|
||||||
@@ -2069,6 +2315,7 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
_clearAckHistoryForMessage(messageId);
|
_clearAckHistoryForMessage(messageId);
|
||||||
_retryManager.clearRetry(messageId);
|
_retryManager.clearRetry(messageId);
|
||||||
_messageRouteMetadata.remove(messageId);
|
_messageRouteMetadata.remove(messageId);
|
||||||
|
onManualRetryPreparedCallback?.call(messageId);
|
||||||
|
|
||||||
_messages[index] = Message(
|
_messages[index] = Message(
|
||||||
id: message.id,
|
id: message.id,
|
||||||
@@ -2104,6 +2351,7 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
isVoice: message.isVoice,
|
isVoice: message.isVoice,
|
||||||
voiceId: message.voiceId,
|
voiceId: message.voiceId,
|
||||||
);
|
);
|
||||||
|
_clearChannelSendWarning(messageId);
|
||||||
|
|
||||||
_persistMessages();
|
_persistMessages();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
@@ -2167,6 +2415,11 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
timer.cancel();
|
timer.cancel();
|
||||||
}
|
}
|
||||||
_timeoutTimers.clear();
|
_timeoutTimers.clear();
|
||||||
|
for (final timer in _channelEchoWarningTimers.values) {
|
||||||
|
timer.cancel();
|
||||||
|
}
|
||||||
|
_channelEchoWarningTimers.clear();
|
||||||
|
_channelEchoWarningMessageIds.clear();
|
||||||
_completedAckHistory.clear();
|
_completedAckHistory.clear();
|
||||||
_messageAckHistory.clear();
|
_messageAckHistory.clear();
|
||||||
_ackHistoryLookup.clear();
|
_ackHistoryLookup.clear();
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
import 'dart:typed_data';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import '../models/contact.dart';
|
import '../models/contact.dart';
|
||||||
@@ -299,11 +300,15 @@ class VoiceProvider with ChangeNotifier {
|
|||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final pcm = await _codec.decodePackets(session.packets, session.mode);
|
final decodedPcm = await _codec.decodePackets(
|
||||||
|
session.packets,
|
||||||
|
session.mode,
|
||||||
|
);
|
||||||
|
final pcm = _preparePlaybackPcm(decodedPcm, session.mode);
|
||||||
debugPrint('🎙️ [VoiceProvider] decoded ${pcm.length} PCM samples');
|
debugPrint('🎙️ [VoiceProvider] decoded ${pcm.length} PCM samples');
|
||||||
_playingSessionId = sessionId;
|
_playingSessionId = sessionId;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
await _player.play(pcm);
|
await _player.play(pcm, sampleRateHz: session.mode.sampleRateHz);
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
debugPrint('❌ [VoiceProvider] Playback error: $e\n$st');
|
debugPrint('❌ [VoiceProvider] Playback error: $e\n$st');
|
||||||
if (_playingSessionId == sessionId) {
|
if (_playingSessionId == sessionId) {
|
||||||
@@ -319,6 +324,10 @@ class VoiceProvider with ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Int16List _preparePlaybackPcm(Int16List pcm, VoicePacketMode mode) {
|
||||||
|
return pcm;
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> clearStoredVoiceData() async {
|
Future<void> clearStoredVoiceData() async {
|
||||||
_sessions.clear();
|
_sessions.clear();
|
||||||
_outgoingSessions.clear();
|
_outgoingSessions.clear();
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -18,6 +18,7 @@ import 'repeaters_map_screen.dart';
|
|||||||
import 'settings_screen.dart';
|
import 'settings_screen.dart';
|
||||||
import 'device_config_screen.dart';
|
import 'device_config_screen.dart';
|
||||||
import 'packet_log_screen.dart';
|
import 'packet_log_screen.dart';
|
||||||
|
import 'live_traffic_screen.dart';
|
||||||
import 'spectrum_scan_screen.dart';
|
import 'spectrum_scan_screen.dart';
|
||||||
import '../utils/toast_logger.dart';
|
import '../utils/toast_logger.dart';
|
||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
@@ -218,6 +219,10 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _openLiveTraffic(ConnectionProvider provider) {
|
||||||
|
openLiveTrafficScreen(context, provider);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||||
_lifecycleState = state;
|
_lifecycleState = state;
|
||||||
@@ -647,6 +652,41 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
items.add(
|
||||||
|
PopupMenuItem(
|
||||||
|
child: const Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.radar_outlined),
|
||||||
|
SizedBox(width: 8),
|
||||||
|
Text('Live Traffic'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
onTap: () {
|
||||||
|
final navigator = Navigator.of(context);
|
||||||
|
final provider = context.read<ConnectionProvider>();
|
||||||
|
Future.delayed(Duration.zero, () {
|
||||||
|
if (!mounted) return;
|
||||||
|
navigator.push(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (_) => LiveTrafficScreen.fromProvider(
|
||||||
|
provider,
|
||||||
|
openPacketLogs: () {
|
||||||
|
navigator.push(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (_) => PacketLogScreen(
|
||||||
|
bleService: provider.bleService,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
items.add(
|
items.add(
|
||||||
PopupMenuItem(
|
PopupMenuItem(
|
||||||
child: const Row(
|
child: const Row(
|
||||||
@@ -713,6 +753,9 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
switch (tab) {
|
switch (tab) {
|
||||||
case _HomeTab.messages:
|
case _HomeTab.messages:
|
||||||
return MessagesTab(
|
return MessagesTab(
|
||||||
|
isActive:
|
||||||
|
_currentTab == _HomeTab.messages &&
|
||||||
|
_lifecycleState == AppLifecycleState.resumed,
|
||||||
onNavigateToMap: _isMapEnabled
|
onNavigateToMap: _isMapEnabled
|
||||||
? () => _navigateToTab(_HomeTab.map)
|
? () => _navigateToTab(_HomeTab.map)
|
||||||
: null,
|
: null,
|
||||||
@@ -1056,6 +1099,7 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
SizedBox(width: isTight ? 8 : 12),
|
SizedBox(width: isTight ? 8 : 12),
|
||||||
if (_showRxTxIndicators)
|
if (_showRxTxIndicators)
|
||||||
GestureDetector(
|
GestureDetector(
|
||||||
|
onTap: () => _openLiveTraffic(provider),
|
||||||
onLongPress: () {
|
onLongPress: () {
|
||||||
Navigator.push(
|
Navigator.push(
|
||||||
context,
|
context,
|
||||||
|
|||||||
1016
lib/screens/live_traffic_screen.dart
Normal file
1016
lib/screens/live_traffic_screen.dart
Normal file
File diff suppressed because it is too large
Load Diff
@@ -22,6 +22,7 @@ import '../models/message.dart';
|
|||||||
import '../services/background_location_service.dart';
|
import '../services/background_location_service.dart';
|
||||||
import '../services/location_tracking_service.dart';
|
import '../services/location_tracking_service.dart';
|
||||||
import '../services/map_marker_service.dart';
|
import '../services/map_marker_service.dart';
|
||||||
|
import '../services/message_destination_preferences.dart';
|
||||||
import '../services/trail_color_service.dart';
|
import '../services/trail_color_service.dart';
|
||||||
import '../widgets/map_debug_info.dart';
|
import '../widgets/map_debug_info.dart';
|
||||||
import '../widgets/map/compass_widget.dart';
|
import '../widgets/map/compass_widget.dart';
|
||||||
@@ -816,6 +817,174 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _showSarMarkerActions(
|
||||||
|
SarMarker marker,
|
||||||
|
MessagesProvider messagesProvider,
|
||||||
|
ContactsProvider contactsProvider,
|
||||||
|
) async {
|
||||||
|
final message = messagesProvider.getMessageById(marker.id);
|
||||||
|
|
||||||
|
await showModalBottomSheet<void>(
|
||||||
|
context: context,
|
||||||
|
showDragHandle: true,
|
||||||
|
builder: (sheetContext) {
|
||||||
|
final theme = Theme.of(sheetContext);
|
||||||
|
return SafeArea(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(20, 8, 20, 20),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Text(marker.emoji, style: const TextStyle(fontSize: 24)),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
marker.displayName,
|
||||||
|
style: theme.textTheme.titleMedium,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
'${marker.location.latitude.toStringAsFixed(6)}, ${marker.location.longitude.toStringAsFixed(6)}',
|
||||||
|
style: theme.textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
marker.senderName != null
|
||||||
|
? '${marker.timeAgo} • ${marker.senderName}'
|
||||||
|
: marker.timeAgo,
|
||||||
|
style: theme.textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
if (marker.notes != null && marker.notes!.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(marker.notes!),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
if (message != null)
|
||||||
|
ListTile(
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
leading: const Icon(Icons.chat_bubble_outline),
|
||||||
|
title: const Text('Open message'),
|
||||||
|
subtitle: const Text('Jump to the related SAR message'),
|
||||||
|
onTap: () async {
|
||||||
|
Navigator.pop(sheetContext);
|
||||||
|
await _openSarMarkerMessage(
|
||||||
|
message,
|
||||||
|
messagesProvider,
|
||||||
|
contactsProvider,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
leading: const Icon(Icons.delete_outline, color: Colors.red),
|
||||||
|
title: Text(
|
||||||
|
'Remove marker',
|
||||||
|
style: TextStyle(color: theme.colorScheme.error),
|
||||||
|
),
|
||||||
|
subtitle: Text(
|
||||||
|
message != null
|
||||||
|
? 'This also removes the linked SAR message.'
|
||||||
|
: 'Hide this marker from the map.',
|
||||||
|
),
|
||||||
|
onTap: () async {
|
||||||
|
final confirmed = await _confirmSarMarkerRemoval(
|
||||||
|
hasMessage: message != null,
|
||||||
|
);
|
||||||
|
if (!mounted ||
|
||||||
|
!sheetContext.mounted ||
|
||||||
|
confirmed != true) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Navigator.pop(sheetContext);
|
||||||
|
await messagesProvider.removeSarMarkerPermanently(
|
||||||
|
marker.id,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool?> _confirmSarMarkerRemoval({required bool hasMessage}) {
|
||||||
|
return showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (dialogContext) => AlertDialog(
|
||||||
|
title: const Text('Remove SAR marker'),
|
||||||
|
content: Text(
|
||||||
|
hasMessage
|
||||||
|
? 'This will remove the marker and its linked chat message.'
|
||||||
|
: 'This will hide the marker from the map, even if it is not visible in chat.',
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(dialogContext, false),
|
||||||
|
child: Text(AppLocalizations.of(dialogContext)!.cancel),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(dialogContext, true),
|
||||||
|
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
||||||
|
child: Text(AppLocalizations.of(dialogContext)!.delete),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _openSarMarkerMessage(
|
||||||
|
Message message,
|
||||||
|
MessagesProvider messagesProvider,
|
||||||
|
ContactsProvider contactsProvider,
|
||||||
|
) async {
|
||||||
|
if (message.isChannelMessage) {
|
||||||
|
final channelContact = contactsProvider.channels.where((contact) {
|
||||||
|
return contact.publicKey.length > 1 &&
|
||||||
|
contact.publicKey[1] == (message.channelIdx ?? 0);
|
||||||
|
}).firstOrNull;
|
||||||
|
|
||||||
|
messagesProvider.navigateToDestination(
|
||||||
|
MessageDestinationPreferences.destinationTypeChannel,
|
||||||
|
recipientPublicKeyHex: channelContact?.publicKeyHex,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
Contact? destinationContact;
|
||||||
|
|
||||||
|
if (message.recipientPublicKey != null) {
|
||||||
|
destinationContact = contactsProvider.contacts.where((contact) {
|
||||||
|
return contact.publicKey.length >=
|
||||||
|
message.recipientPublicKey!.length &&
|
||||||
|
contact.publicKey.matches(message.recipientPublicKey!);
|
||||||
|
}).firstOrNull;
|
||||||
|
} else if (message.senderPublicKeyPrefix != null &&
|
||||||
|
message.senderPublicKeyPrefix!.length >= 6) {
|
||||||
|
destinationContact = contactsProvider.findContactByPrefix(
|
||||||
|
message.senderPublicKeyPrefix!,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (destinationContact != null) {
|
||||||
|
messagesProvider.navigateToDestination(
|
||||||
|
destinationContact.isRoom
|
||||||
|
? MessageDestinationPreferences.destinationTypeRoom
|
||||||
|
: MessageDestinationPreferences.destinationTypeContact,
|
||||||
|
recipientPublicKeyHex: destinationContact.publicKeyHex,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
messagesProvider.navigateToMessage(message.id);
|
||||||
|
widget.onNavigateToMessages?.call();
|
||||||
|
}
|
||||||
|
|
||||||
/// Show SAR dialog with pre-populated location from map long press
|
/// Show SAR dialog with pre-populated location from map long press
|
||||||
void _showSarDialogWithLocation(LatLng location) {
|
void _showSarDialogWithLocation(LatLng location) {
|
||||||
// Create a Position object from the LatLng coordinates
|
// Create a Position object from the LatLng coordinates
|
||||||
@@ -1119,9 +1288,26 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
super.build(context); // Required for AutomaticKeepAliveClientMixin
|
super.build(context); // Required for AutomaticKeepAliveClientMixin
|
||||||
|
|
||||||
return Consumer3<ContactsProvider, MessagesProvider, DrawingProvider>(
|
return Consumer4<
|
||||||
builder: (context, contactsProvider, messagesProvider, drawingProvider, child) {
|
ContactsProvider,
|
||||||
final contactsWithLocation = contactsProvider.contactsWithLocation;
|
MessagesProvider,
|
||||||
|
DrawingProvider,
|
||||||
|
MapProvider
|
||||||
|
>(
|
||||||
|
builder: (
|
||||||
|
context,
|
||||||
|
contactsProvider,
|
||||||
|
messagesProvider,
|
||||||
|
drawingProvider,
|
||||||
|
mapProvider,
|
||||||
|
child,
|
||||||
|
) {
|
||||||
|
final allContactsWithLocation = contactsProvider.contactsWithLocation;
|
||||||
|
final contactsWithLocation = mapProvider.hideRepeatersOnMap
|
||||||
|
? allContactsWithLocation
|
||||||
|
.where((contact) => !contact.isRepeater)
|
||||||
|
.toList()
|
||||||
|
: allContactsWithLocation;
|
||||||
// Filter SAR markers based on visibility toggle
|
// Filter SAR markers based on visibility toggle
|
||||||
final allSarMarkers = messagesProvider.sarMarkers;
|
final allSarMarkers = messagesProvider.sarMarkers;
|
||||||
final sarMarkers = drawingProvider.showSarMarkers
|
final sarMarkers = drawingProvider.showSarMarkers
|
||||||
@@ -1719,7 +1905,7 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
|
|||||||
onTap: (contact) {
|
onTap: (contact) {
|
||||||
_showDetailedCompassWithContact(
|
_showDetailedCompassWithContact(
|
||||||
context,
|
context,
|
||||||
contactsProvider.contactsWithLocation,
|
contactsWithLocation,
|
||||||
messagesProvider.sarMarkers,
|
messagesProvider.sarMarkers,
|
||||||
contact,
|
contact,
|
||||||
);
|
);
|
||||||
@@ -1731,9 +1917,11 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
|
|||||||
context: context,
|
context: context,
|
||||||
mapRotation: _getMapRotation(),
|
mapRotation: _getMapRotation(),
|
||||||
onTap: (marker) {
|
onTap: (marker) {
|
||||||
// Navigate to the corresponding message in Messages tab
|
_showSarMarkerActions(
|
||||||
messagesProvider.navigateToMessage(marker.id);
|
marker,
|
||||||
widget.onNavigateToMessages?.call();
|
messagesProvider,
|
||||||
|
contactsProvider,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
// User location marker with directional pointer
|
// User location marker with directional pointer
|
||||||
@@ -2021,7 +2209,7 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
|
|||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
onTap: () => _showDetailedCompass(
|
onTap: () => _showDetailedCompass(
|
||||||
context,
|
context,
|
||||||
contactsProvider.contactsWithLocation,
|
contactsWithLocation,
|
||||||
messagesProvider.sarMarkers,
|
messagesProvider.sarMarkers,
|
||||||
),
|
),
|
||||||
child: CompassWidget(
|
child: CompassWidget(
|
||||||
|
|||||||
@@ -39,8 +39,9 @@ import '../l10n/app_localizations.dart';
|
|||||||
|
|
||||||
class MessagesTab extends StatefulWidget {
|
class MessagesTab extends StatefulWidget {
|
||||||
final VoidCallback? onNavigateToMap;
|
final VoidCallback? onNavigateToMap;
|
||||||
|
final bool isActive;
|
||||||
|
|
||||||
const MessagesTab({super.key, this.onNavigateToMap});
|
const MessagesTab({super.key, this.onNavigateToMap, this.isActive = true});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<MessagesTab> createState() => _MessagesTabState();
|
State<MessagesTab> createState() => _MessagesTabState();
|
||||||
@@ -50,6 +51,7 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
static const int _maxContactMessageBytes = 156;
|
static const int _maxContactMessageBytes = 156;
|
||||||
static const int _maxChannelMessageBytes = 127;
|
static const int _maxChannelMessageBytes = 127;
|
||||||
static const double _composerOverlayHeight = 148;
|
static const double _composerOverlayHeight = 148;
|
||||||
|
static const Duration _channelAutoReadDelay = Duration(seconds: 5);
|
||||||
|
|
||||||
final TextEditingController _textController = TextEditingController();
|
final TextEditingController _textController = TextEditingController();
|
||||||
final FocusNode _focusNode = FocusNode();
|
final FocusNode _focusNode = FocusNode();
|
||||||
@@ -57,6 +59,11 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
int _messageByteCount = 0;
|
int _messageByteCount = 0;
|
||||||
String? _highlightedMessageId;
|
String? _highlightedMessageId;
|
||||||
Timer? _highlightTimer; // Timer for clearing message highlight
|
Timer? _highlightTimer; // Timer for clearing message highlight
|
||||||
|
Timer? _channelReadTimer;
|
||||||
|
String? _pendingChannelReadKey;
|
||||||
|
TextEditingValue _lastComposerValue = const TextEditingValue();
|
||||||
|
bool _isMentionPickerOpen = false;
|
||||||
|
bool _suppressMentionTrigger = false;
|
||||||
|
|
||||||
// Message destination state
|
// Message destination state
|
||||||
String _destinationType =
|
String _destinationType =
|
||||||
@@ -71,7 +78,7 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
final VoiceRecorderService _voiceRecorder = VoiceRecorderService();
|
final VoiceRecorderService _voiceRecorder = VoiceRecorderService();
|
||||||
bool _isRecording = false;
|
bool _isRecording = false;
|
||||||
bool _isSendingVoice = false;
|
bool _isSendingVoice = false;
|
||||||
static const int _maxVoicePackets = 10;
|
static const Duration _maxVoiceRecordingDuration = Duration(seconds: 30);
|
||||||
static const double _silenceRmsThreshold = 500.0;
|
static const double _silenceRmsThreshold = 500.0;
|
||||||
static const double _silencePeakThreshold = 1400.0;
|
static const double _silencePeakThreshold = 1400.0;
|
||||||
static const int _maxInteriorSilentChunks = 2;
|
static const int _maxInteriorSilentChunks = 2;
|
||||||
@@ -85,16 +92,17 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_textController.addListener(_updateCharacterCount);
|
_lastComposerValue = _textController.value;
|
||||||
|
_textController.addListener(_handleComposerChanged);
|
||||||
// Load saved message destination
|
// Load saved message destination
|
||||||
_loadSavedDestination();
|
_loadSavedDestination();
|
||||||
_loadVoiceBitrate();
|
_loadVoiceSettings();
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
_checkForNavigationRequest();
|
_checkForNavigationRequest();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loadVoiceBitrate() async {
|
Future<void> _loadVoiceSettings() async {
|
||||||
final bitrate = await VoiceBitratePreferences.getBitrate();
|
final bitrate = await VoiceBitratePreferences.getBitrate();
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -115,6 +123,7 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_highlightTimer?.cancel();
|
_highlightTimer?.cancel();
|
||||||
|
_channelReadTimer?.cancel();
|
||||||
_voiceStreamSub?.cancel();
|
_voiceStreamSub?.cancel();
|
||||||
_voiceRecorder.dispose();
|
_voiceRecorder.dispose();
|
||||||
_textController.dispose();
|
_textController.dispose();
|
||||||
@@ -123,6 +132,14 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(covariant MessagesTab oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
if (oldWidget.isActive != widget.isActive) {
|
||||||
|
_syncChannelAutoReadTimer(context.read<MessagesProvider>());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void _checkForNavigationRequest() {
|
void _checkForNavigationRequest() {
|
||||||
final messagesProvider = context.read<MessagesProvider>();
|
final messagesProvider = context.read<MessagesProvider>();
|
||||||
final targetMessageId = messagesProvider.targetMessageId;
|
final targetMessageId = messagesProvider.targetMessageId;
|
||||||
@@ -177,12 +194,73 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _handleComposerChanged() {
|
||||||
|
final previousValue = _lastComposerValue;
|
||||||
|
final currentValue = _textController.value;
|
||||||
|
_lastComposerValue = currentValue;
|
||||||
|
|
||||||
|
_updateCharacterCount();
|
||||||
|
|
||||||
|
if (_suppressMentionTrigger || _isMentionPickerOpen) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final mentionTriggerRange = _getMentionTriggerRange(
|
||||||
|
previousValue: previousValue,
|
||||||
|
currentValue: currentValue,
|
||||||
|
);
|
||||||
|
if (mentionTriggerRange == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
unawaited(_showMentionSelectorForRange(mentionTriggerRange));
|
||||||
|
}
|
||||||
|
|
||||||
void _updateCharacterCount() {
|
void _updateCharacterCount() {
|
||||||
setState(() {
|
setState(() {
|
||||||
_messageByteCount = utf8.encode(_textController.text).length;
|
_messageByteCount = utf8.encode(_textController.text).length;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
TextRange? _getMentionTriggerRange({
|
||||||
|
required TextEditingValue previousValue,
|
||||||
|
required TextEditingValue currentValue,
|
||||||
|
}) {
|
||||||
|
if (!previousValue.selection.isValid ||
|
||||||
|
!currentValue.selection.isValid ||
|
||||||
|
!previousValue.selection.isCollapsed ||
|
||||||
|
!currentValue.selection.isCollapsed) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final previousOffset = previousValue.selection.baseOffset;
|
||||||
|
final currentOffset = currentValue.selection.baseOffset;
|
||||||
|
if (previousOffset < 0 || currentOffset < 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentValue.text.length != previousValue.text.length + 1 ||
|
||||||
|
currentOffset != previousOffset + 1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentValue.text.substring(0, previousOffset) !=
|
||||||
|
previousValue.text.substring(0, previousOffset)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentValue.text.substring(currentOffset) !=
|
||||||
|
previousValue.text.substring(previousOffset)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentValue.text[previousOffset] != '@') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return TextRange(start: previousOffset, end: currentOffset);
|
||||||
|
}
|
||||||
|
|
||||||
int get _maxMessageBytes =>
|
int get _maxMessageBytes =>
|
||||||
_destinationType == MessageDestinationPreferences.destinationTypeChannel
|
_destinationType == MessageDestinationPreferences.destinationTypeChannel
|
||||||
? _maxChannelMessageBytes
|
? _maxChannelMessageBytes
|
||||||
@@ -300,6 +378,56 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _showMentionSelectorForRange(TextRange triggerRange) async {
|
||||||
|
final contactsProvider = context.read<ContactsProvider>();
|
||||||
|
final contacts = contactsProvider.contacts
|
||||||
|
.where((contact) => contact.type == ContactType.chat)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
if (contacts.isEmpty || !mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_isMentionPickerOpen = true;
|
||||||
|
Contact? selectedContact;
|
||||||
|
|
||||||
|
await showModalBottomSheet<void>(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
builder: (context) => RecipientSelectorSheet(
|
||||||
|
contacts: contacts,
|
||||||
|
rooms: const [],
|
||||||
|
channels: const [],
|
||||||
|
unreadCount: 0,
|
||||||
|
unreadCountsByPublicKey: {
|
||||||
|
for (final contact in contacts) contact.publicKeyHex: 0,
|
||||||
|
},
|
||||||
|
currentDestinationType: null,
|
||||||
|
currentRecipientPublicKey: null,
|
||||||
|
showAllOption: false,
|
||||||
|
onSelect: (_, recipient) {
|
||||||
|
selectedContact = recipient;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
_isMentionPickerOpen = false;
|
||||||
|
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedContact != null) {
|
||||||
|
_insertReplyMention(
|
||||||
|
selectedContact!.displayName,
|
||||||
|
replacementRange: triggerRange,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
_focusNode.requestFocus();
|
||||||
|
}
|
||||||
|
|
||||||
/// Handle recipient selection
|
/// Handle recipient selection
|
||||||
Future<void> _onRecipientSelected(String type, Contact? recipient) async {
|
Future<void> _onRecipientSelected(String type, Contact? recipient) async {
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -338,13 +466,13 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
_focusNode.requestFocus();
|
_focusNode.requestFocus();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _insertReplyMention(String displayName) {
|
void _insertReplyMention(String displayName, {TextRange? replacementRange}) {
|
||||||
final trimmedName = displayName.trim();
|
final trimmedName = displayName.trim();
|
||||||
if (trimmedName.isEmpty) return;
|
if (trimmedName.isEmpty) return;
|
||||||
|
|
||||||
final mention = '@[$trimmedName] ';
|
final mention = '@[$trimmedName] ';
|
||||||
final value = _textController.value;
|
final value = _textController.value;
|
||||||
final selection = value.selection;
|
final selection = replacementRange ?? value.selection;
|
||||||
final hasSelection =
|
final hasSelection =
|
||||||
selection.isValid &&
|
selection.isValid &&
|
||||||
selection.start >= 0 &&
|
selection.start >= 0 &&
|
||||||
@@ -355,11 +483,14 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
final nextText = value.text.replaceRange(start, end, mention);
|
final nextText = value.text.replaceRange(start, end, mention);
|
||||||
final nextOffset = start + mention.length;
|
final nextOffset = start + mention.length;
|
||||||
|
|
||||||
|
_suppressMentionTrigger = true;
|
||||||
_textController.value = value.copyWith(
|
_textController.value = value.copyWith(
|
||||||
text: nextText,
|
text: nextText,
|
||||||
selection: TextSelection.collapsed(offset: nextOffset),
|
selection: TextSelection.collapsed(offset: nextOffset),
|
||||||
composing: TextRange.empty,
|
composing: TextRange.empty,
|
||||||
);
|
);
|
||||||
|
_lastComposerValue = _textController.value;
|
||||||
|
_suppressMentionTrigger = false;
|
||||||
_enforceMessageByteLimit();
|
_enforceMessageByteLimit();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -467,6 +598,9 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_textController.clear();
|
||||||
|
_focusNode.unfocus();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Check destination type and send accordingly
|
// Check destination type and send accordingly
|
||||||
if (_destinationType ==
|
if (_destinationType ==
|
||||||
@@ -497,13 +631,18 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
await _sendToChannel(text, connectionProvider, messagesProvider, 0);
|
await _sendToChannel(text, connectionProvider, messagesProvider, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
_textController.clear();
|
|
||||||
_focusNode.unfocus();
|
|
||||||
_markCurrentDestinationAsRead();
|
_markCurrentDestinationAsRead();
|
||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
if (_textController.text.isEmpty) {
|
||||||
|
_textController.text = text;
|
||||||
|
_textController.selection = TextSelection.collapsed(
|
||||||
|
offset: _textController.text.length,
|
||||||
|
);
|
||||||
|
_focusNode.requestFocus();
|
||||||
|
}
|
||||||
ToastLogger.error(context, 'Failed to send: $e');
|
ToastLogger.error(context, 'Failed to send: $e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -817,7 +956,7 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
Future<void> _startVoiceRecording() async {
|
Future<void> _startVoiceRecording() async {
|
||||||
if (_isSendingVoice || _isRecording) return;
|
if (_isSendingVoice || _isRecording) return;
|
||||||
debugPrint('🎙️ [Voice] _startVoiceRecording called');
|
debugPrint('🎙️ [Voice] _startVoiceRecording called');
|
||||||
// Read fresh bitrate preference so settings changes apply immediately.
|
// Read fresh voice preferences so settings changes apply immediately.
|
||||||
final selectedBitrate = await VoiceBitratePreferences.getBitrate();
|
final selectedBitrate = await VoiceBitratePreferences.getBitrate();
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -856,11 +995,12 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
_selectedVoiceBitrate,
|
_selectedVoiceBitrate,
|
||||||
);
|
);
|
||||||
final packetDuration = Duration(
|
final packetDuration = Duration(
|
||||||
milliseconds: codec2ModeFor(_activeVoiceMode!).packetDurationMs,
|
milliseconds: _activeVoiceMode!.packetDurationMs,
|
||||||
);
|
);
|
||||||
|
final maxVoicePackets = _maxVoicePacketsForMode(_activeVoiceMode!);
|
||||||
|
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'🎙️ [Voice] session=$_currentVoiceSessionId mode=$_activeVoiceMode chunkDuration=${packetDuration.inMilliseconds}ms',
|
'🎙️ [Voice] session=$_currentVoiceSessionId mode=$_activeVoiceMode chunkDuration=${packetDuration.inMilliseconds}ms maxPackets=$maxVoicePackets',
|
||||||
);
|
);
|
||||||
|
|
||||||
_recordedChunks.clear();
|
_recordedChunks.clear();
|
||||||
@@ -869,9 +1009,13 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
try {
|
try {
|
||||||
final stream = _voiceRecorder.startCapture(
|
final stream = _voiceRecorder.startCapture(
|
||||||
chunkDuration: packetDuration,
|
chunkDuration: packetDuration,
|
||||||
|
sampleRateHz: _activeVoiceMode!.sampleRateHz,
|
||||||
enableBandPassFilter: appProvider.isVoiceBandPassFilterEnabled,
|
enableBandPassFilter: appProvider.isVoiceBandPassFilterEnabled,
|
||||||
enableCompressor: appProvider.isVoiceCompressorEnabled,
|
enableCompressor: appProvider.isVoiceCompressorEnabled,
|
||||||
enableLimiter: appProvider.isVoiceLimiterEnabled,
|
enableLimiter: appProvider.isVoiceLimiterEnabled,
|
||||||
|
enableAutoGain: appProvider.isVoiceAutoGainEnabled,
|
||||||
|
enableEchoCancellation: appProvider.isVoiceEchoCancellationEnabled,
|
||||||
|
enableNoiseSuppression: appProvider.isVoiceNoiseSuppressionEnabled,
|
||||||
);
|
);
|
||||||
debugPrint('🎙️ [Voice] capture started, listening for chunks...');
|
debugPrint('🎙️ [Voice] capture started, listening for chunks...');
|
||||||
_voiceStreamSub = stream.listen(
|
_voiceStreamSub = stream.listen(
|
||||||
@@ -882,7 +1026,7 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
'🎙️ [Voice] chunk #${_recordedChunks.length} received: ${pcmChunk.length} samples',
|
'🎙️ [Voice] chunk #${_recordedChunks.length} received: ${pcmChunk.length} samples',
|
||||||
);
|
);
|
||||||
setState(() {});
|
setState(() {});
|
||||||
if (_recordedChunks.length >= _maxVoicePackets) {
|
if (_recordedChunks.length >= maxVoicePackets) {
|
||||||
debugPrint('🎙️ [Voice] max packets reached, stopping');
|
debugPrint('🎙️ [Voice] max packets reached, stopping');
|
||||||
_stopAndSendVoice();
|
_stopAndSendVoice();
|
||||||
}
|
}
|
||||||
@@ -899,6 +1043,12 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
int _maxVoicePacketsForMode(VoicePacketMode mode) {
|
||||||
|
final packets =
|
||||||
|
_maxVoiceRecordingDuration.inMilliseconds ~/ mode.packetDurationMs;
|
||||||
|
return packets < 1 ? 1 : packets;
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _stopAndSendVoice() async {
|
Future<void> _stopAndSendVoice() async {
|
||||||
if (!_isRecording) return;
|
if (!_isRecording) return;
|
||||||
final trimSilenceEnabled = context
|
final trimSilenceEnabled = context
|
||||||
@@ -913,9 +1063,14 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
await _voiceRecorder.stopCapture();
|
await _voiceRecorder.stopCapture();
|
||||||
|
|
||||||
final rawChunks = List<Int16List>.from(_recordedChunks);
|
final rawChunks = List<Int16List>.from(_recordedChunks);
|
||||||
final chunks = trimSilenceEnabled ? _trimSilence(rawChunks) : rawChunks;
|
final trimmedChunks = trimSilenceEnabled
|
||||||
|
? _trimSilence(rawChunks)
|
||||||
|
: rawChunks;
|
||||||
final sessionId = _currentVoiceSessionId;
|
final sessionId = _currentVoiceSessionId;
|
||||||
final mode = _activeVoiceMode;
|
final mode = _activeVoiceMode;
|
||||||
|
final chunks = mode == null
|
||||||
|
? trimmedChunks
|
||||||
|
: _prepareChunksForSending(trimmedChunks, mode);
|
||||||
_recordedChunks.clear();
|
_recordedChunks.clear();
|
||||||
|
|
||||||
debugPrint(
|
debugPrint(
|
||||||
@@ -1104,6 +1259,13 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
messagesProvider.markMessageSent(msgId, 0, 0);
|
messagesProvider.markMessageSent(msgId, 0, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
List<Int16List> _prepareChunksForSending(
|
||||||
|
List<Int16List> chunks,
|
||||||
|
VoicePacketMode mode,
|
||||||
|
) {
|
||||||
|
return chunks;
|
||||||
|
}
|
||||||
|
|
||||||
List<Int16List> _trimSilence(List<Int16List> chunks) {
|
List<Int16List> _trimSilence(List<Int16List> chunks) {
|
||||||
if (chunks.isEmpty) return chunks;
|
if (chunks.isEmpty) return chunks;
|
||||||
|
|
||||||
@@ -1133,16 +1295,28 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
bool _isSilentChunk(Int16List chunk) {
|
bool _isSilentChunk(Int16List chunk) {
|
||||||
if (chunk.isEmpty) return true;
|
if (chunk.isEmpty) return true;
|
||||||
|
|
||||||
|
final rms = _chunkRms(chunk);
|
||||||
|
final peak = _chunkPeak(chunk);
|
||||||
|
return rms < _silenceRmsThreshold && peak < _silencePeakThreshold;
|
||||||
|
}
|
||||||
|
|
||||||
|
double _chunkRms(Int16List chunk) {
|
||||||
var sumSquares = 0.0;
|
var sumSquares = 0.0;
|
||||||
|
for (final sample in chunk) {
|
||||||
|
sumSquares += sample * sample;
|
||||||
|
}
|
||||||
|
return math.sqrt(sumSquares / chunk.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
int _chunkPeak(Int16List chunk) {
|
||||||
var peak = 0;
|
var peak = 0;
|
||||||
for (final sample in chunk) {
|
for (final sample in chunk) {
|
||||||
final absSample = sample.abs();
|
final absSample = sample.abs();
|
||||||
if (absSample > peak) peak = absSample;
|
if (absSample > peak) {
|
||||||
sumSquares += sample * sample;
|
peak = absSample;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
return peak;
|
||||||
final rms = math.sqrt(sumSquares / chunk.length);
|
|
||||||
return rms < _silenceRmsThreshold && peak < _silencePeakThreshold;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
bool _isPublicChannelSelected() {
|
bool _isPublicChannelSelected() {
|
||||||
@@ -1677,12 +1851,64 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _markCurrentDestinationAsRead() {
|
void _markCurrentDestinationAsRead() {
|
||||||
|
_channelReadTimer?.cancel();
|
||||||
|
_pendingChannelReadKey = null;
|
||||||
context.read<MessagesProvider>().markDestinationAsRead(
|
context.read<MessagesProvider>().markDestinationAsRead(
|
||||||
destinationType: _destinationType,
|
destinationType: _destinationType,
|
||||||
contact: _selectedRecipient,
|
contact: _selectedRecipient,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _syncChannelAutoReadTimer(MessagesProvider messagesProvider) {
|
||||||
|
if (!widget.isActive ||
|
||||||
|
_destinationType !=
|
||||||
|
MessageDestinationPreferences.destinationTypeChannel) {
|
||||||
|
_channelReadTimer?.cancel();
|
||||||
|
_pendingChannelReadKey = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final channelIdx = _selectedRecipient?.publicKey[1] ?? 0;
|
||||||
|
final unreadCount = messagesProvider.getUnreadCountForChannel(channelIdx);
|
||||||
|
|
||||||
|
if (unreadCount <= 0) {
|
||||||
|
_channelReadTimer?.cancel();
|
||||||
|
_pendingChannelReadKey = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final nextKey = '$channelIdx:$unreadCount';
|
||||||
|
if (_pendingChannelReadKey == nextKey &&
|
||||||
|
_channelReadTimer?.isActive == true) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_channelReadTimer?.cancel();
|
||||||
|
_pendingChannelReadKey = nextKey;
|
||||||
|
_channelReadTimer = Timer(_channelAutoReadDelay, () {
|
||||||
|
if (!mounted || !widget.isActive) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_destinationType !=
|
||||||
|
MessageDestinationPreferences.destinationTypeChannel) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final currentChannelIdx = _selectedRecipient?.publicKey[1] ?? 0;
|
||||||
|
if (currentChannelIdx != channelIdx) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final latestProvider = context.read<MessagesProvider>();
|
||||||
|
if (latestProvider.getUnreadCountForChannel(channelIdx) <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_markCurrentDestinationAsRead();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
List<Message> _getFilteredMessages(MessagesProvider messagesProvider) {
|
List<Message> _getFilteredMessages(MessagesProvider messagesProvider) {
|
||||||
// Get all recent messages
|
// Get all recent messages
|
||||||
final allMessages = messagesProvider.getRecentMessages(count: 100);
|
final allMessages = messagesProvider.getRecentMessages(count: 100);
|
||||||
@@ -1775,6 +2001,7 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Consumer<MessagesProvider>(
|
return Consumer<MessagesProvider>(
|
||||||
builder: (context, messagesProvider, child) {
|
builder: (context, messagesProvider, child) {
|
||||||
|
_syncChannelAutoReadTimer(messagesProvider);
|
||||||
final messages = _getFilteredMessages(messagesProvider);
|
final messages = _getFilteredMessages(messagesProvider);
|
||||||
final bottomInset = MediaQuery.of(context).viewPadding.bottom;
|
final bottomInset = MediaQuery.of(context).viewPadding.bottom;
|
||||||
final composerBottomPadding = bottomInset > 0 ? 2.0 : 10.0;
|
final composerBottomPadding = bottomInset > 0 ? 2.0 : 10.0;
|
||||||
|
|||||||
@@ -14,11 +14,12 @@ import '../providers/contacts_provider.dart';
|
|||||||
import '../providers/messages_provider.dart';
|
import '../providers/messages_provider.dart';
|
||||||
import '../providers/app_provider.dart';
|
import '../providers/app_provider.dart';
|
||||||
import '../providers/connection_provider.dart';
|
import '../providers/connection_provider.dart';
|
||||||
|
import '../providers/drawing_provider.dart';
|
||||||
|
import '../providers/map_provider.dart';
|
||||||
import '../services/location_tracking_service.dart';
|
import '../services/location_tracking_service.dart';
|
||||||
import '../services/locale_preferences.dart';
|
import '../services/locale_preferences.dart';
|
||||||
import '../services/mesh_map_nodes_service.dart';
|
import '../services/mesh_map_nodes_service.dart';
|
||||||
import '../services/update_checker_service.dart';
|
import '../services/update_checker_service.dart';
|
||||||
import '../services/voice_codec_service.dart';
|
|
||||||
import '../services/voice_bitrate_preferences.dart';
|
import '../services/voice_bitrate_preferences.dart';
|
||||||
import '../services/image_preferences.dart';
|
import '../services/image_preferences.dart';
|
||||||
import '../services/route_hash_preferences.dart';
|
import '../services/route_hash_preferences.dart';
|
||||||
@@ -73,6 +74,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
bool _fastLocationUpdatesEnabled = false;
|
bool _fastLocationUpdatesEnabled = false;
|
||||||
double _fastLocationMovementThresholdMeters = 10.0;
|
double _fastLocationMovementThresholdMeters = 10.0;
|
||||||
int _fastLocationActiveCadenceSeconds = 10;
|
int _fastLocationActiveCadenceSeconds = 10;
|
||||||
|
bool _rotateMapWithHeading = false;
|
||||||
|
bool _showMapDebugInfo = false;
|
||||||
|
bool _openMapInFullscreen = false;
|
||||||
bool _isDeveloperModeEnabled = false;
|
bool _isDeveloperModeEnabled = false;
|
||||||
DateTime? _onlineTraceCacheUpdatedAt;
|
DateTime? _onlineTraceCacheUpdatedAt;
|
||||||
bool _isClearingOnlineTraceCache = false;
|
bool _isClearingOnlineTraceCache = false;
|
||||||
@@ -88,12 +92,13 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
_loadPackageInfo();
|
_loadPackageInfo();
|
||||||
_initializeLocationService();
|
_initializeLocationService();
|
||||||
_loadRxTxPreference();
|
_loadRxTxPreference();
|
||||||
_loadVoiceBitratePreference();
|
_loadVoicePreferences();
|
||||||
_loadRouteHashSizePreference();
|
_loadRouteHashSizePreference();
|
||||||
_loadImagePreferences();
|
_loadImagePreferences();
|
||||||
_loadFastLocationSettings();
|
_loadFastLocationSettings();
|
||||||
_loadDeveloperMode();
|
_loadDeveloperMode();
|
||||||
_loadOnlineTraceCacheStatus();
|
_loadOnlineTraceCacheStatus();
|
||||||
|
_loadMapPreferences();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -178,7 +183,23 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
await prefs.setBool('show_rx_tx_indicators', value);
|
await prefs.setBool('show_rx_tx_indicators', value);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loadVoiceBitratePreference() async {
|
Future<void> _loadMapPreferences() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_rotateMapWithHeading =
|
||||||
|
prefs.getBool('map_rotate_with_heading') ?? false;
|
||||||
|
_showMapDebugInfo = prefs.getBool('map_show_debug_info') ?? false;
|
||||||
|
_openMapInFullscreen = prefs.getBool('map_fullscreen') ?? false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _saveMapPreference(String key, bool value) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setBool(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadVoicePreferences() async {
|
||||||
final value = await VoiceBitratePreferences.getBitrate();
|
final value = await VoiceBitratePreferences.getBitrate();
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -1155,6 +1176,90 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
),
|
),
|
||||||
]),
|
]),
|
||||||
|
|
||||||
|
_buildSectionHeader('Map'),
|
||||||
|
_buildSettingsCard([
|
||||||
|
SwitchListTile(
|
||||||
|
secondary: const Icon(Icons.explore),
|
||||||
|
title: const Text('Rotate map with heading'),
|
||||||
|
subtitle: const Text(
|
||||||
|
'Rotate the map based on your compass or movement heading',
|
||||||
|
),
|
||||||
|
value: _rotateMapWithHeading,
|
||||||
|
onChanged: (value) async {
|
||||||
|
setState(() {
|
||||||
|
_rotateMapWithHeading = value;
|
||||||
|
});
|
||||||
|
await _saveMapPreference('map_rotate_with_heading', value);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
SwitchListTile(
|
||||||
|
secondary: const Icon(Icons.bug_report_outlined),
|
||||||
|
title: const Text('Show map debug info'),
|
||||||
|
subtitle: const Text(
|
||||||
|
'Display extra map diagnostics and internal state overlays',
|
||||||
|
),
|
||||||
|
value: _showMapDebugInfo,
|
||||||
|
onChanged: (value) async {
|
||||||
|
setState(() {
|
||||||
|
_showMapDebugInfo = value;
|
||||||
|
});
|
||||||
|
await _saveMapPreference('map_show_debug_info', value);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
SwitchListTile(
|
||||||
|
secondary: const Icon(Icons.fullscreen),
|
||||||
|
title: const Text('Open map in fullscreen'),
|
||||||
|
subtitle: const Text(
|
||||||
|
'Start the map tab in fullscreen mode by default',
|
||||||
|
),
|
||||||
|
value: _openMapInFullscreen,
|
||||||
|
onChanged: (value) async {
|
||||||
|
setState(() {
|
||||||
|
_openMapInFullscreen = value;
|
||||||
|
});
|
||||||
|
await _saveMapPreference('map_fullscreen', value);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
Consumer<DrawingProvider>(
|
||||||
|
builder: (context, drawingProvider, child) => SwitchListTile(
|
||||||
|
secondary: const Icon(Icons.fmd_good_outlined),
|
||||||
|
title: const Text('Show SAR markers'),
|
||||||
|
subtitle: const Text(
|
||||||
|
'Display SAR markers on the main map',
|
||||||
|
),
|
||||||
|
value: drawingProvider.showSarMarkers,
|
||||||
|
onChanged: (value) {
|
||||||
|
drawingProvider.toggleSarMarkers();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Consumer<MapProvider>(
|
||||||
|
builder: (context, mapProvider, child) => SwitchListTile(
|
||||||
|
secondary: const Icon(Icons.timeline),
|
||||||
|
title: const Text('Show all contact trails'),
|
||||||
|
subtitle: const Text(
|
||||||
|
'Display location trails for all contacts that have history',
|
||||||
|
),
|
||||||
|
value: mapProvider.showAllContactTrails,
|
||||||
|
onChanged: (value) async {
|
||||||
|
await mapProvider.toggleAllContactTrails();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Consumer<MapProvider>(
|
||||||
|
builder: (context, mapProvider, child) => SwitchListTile(
|
||||||
|
secondary: const Icon(Icons.router_outlined),
|
||||||
|
title: const Text('Hide repeaters on map'),
|
||||||
|
subtitle: const Text(
|
||||||
|
'Hide repeater contacts from the main map view',
|
||||||
|
),
|
||||||
|
value: mapProvider.hideRepeatersOnMap,
|
||||||
|
onChanged: (value) async {
|
||||||
|
await mapProvider.setHideRepeatersOnMap(value);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]),
|
||||||
_buildSectionHeader('Voice'),
|
_buildSectionHeader('Voice'),
|
||||||
Consumer2<AppProvider, ConnectionProvider>(
|
Consumer2<AppProvider, ConnectionProvider>(
|
||||||
builder: (context, appProvider, connectionProvider, child) =>
|
builder: (context, appProvider, connectionProvider, child) =>
|
||||||
@@ -1164,6 +1269,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
bandPassEnabled: appProvider.isVoiceBandPassFilterEnabled,
|
bandPassEnabled: appProvider.isVoiceBandPassFilterEnabled,
|
||||||
compressorEnabled: appProvider.isVoiceCompressorEnabled,
|
compressorEnabled: appProvider.isVoiceCompressorEnabled,
|
||||||
limiterEnabled: appProvider.isVoiceLimiterEnabled,
|
limiterEnabled: appProvider.isVoiceLimiterEnabled,
|
||||||
|
autoGainEnabled: appProvider.isVoiceAutoGainEnabled,
|
||||||
|
echoCancellationEnabled:
|
||||||
|
appProvider.isVoiceEchoCancellationEnabled,
|
||||||
|
noiseSuppressionEnabled:
|
||||||
|
appProvider.isVoiceNoiseSuppressionEnabled,
|
||||||
silenceTrimEnabled: appProvider.isVoiceSilenceTrimmingEnabled,
|
silenceTrimEnabled: appProvider.isVoiceSilenceTrimmingEnabled,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -1210,6 +1320,43 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
Consumer<AppProvider>(
|
||||||
|
builder: (context, appProvider, child) => SwitchListTile(
|
||||||
|
secondary: const Icon(Icons.auto_fix_high),
|
||||||
|
title: const Text('Mic auto gain'),
|
||||||
|
subtitle: const Text('Lets the recorder adjust input level'),
|
||||||
|
value: appProvider.isVoiceAutoGainEnabled,
|
||||||
|
onChanged: (value) async {
|
||||||
|
await appProvider.toggleVoiceAutoGainEnabled(value);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Consumer<AppProvider>(
|
||||||
|
builder: (context, appProvider, child) => SwitchListTile(
|
||||||
|
secondary: const Icon(Icons.hearing_disabled),
|
||||||
|
title: const Text('Echo cancellation'),
|
||||||
|
subtitle: const Text(
|
||||||
|
'Uses recorder echo cancellation if available',
|
||||||
|
),
|
||||||
|
value: appProvider.isVoiceEchoCancellationEnabled,
|
||||||
|
onChanged: (value) async {
|
||||||
|
await appProvider.toggleVoiceEchoCancellationEnabled(value);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Consumer<AppProvider>(
|
||||||
|
builder: (context, appProvider, child) => SwitchListTile(
|
||||||
|
secondary: const Icon(Icons.noise_control_off),
|
||||||
|
title: const Text('Noise suppression'),
|
||||||
|
subtitle: const Text(
|
||||||
|
'Uses recorder noise suppression if available',
|
||||||
|
),
|
||||||
|
value: appProvider.isVoiceNoiseSuppressionEnabled,
|
||||||
|
onChanged: (value) async {
|
||||||
|
await appProvider.toggleVoiceNoiseSuppressionEnabled(value);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
Consumer<AppProvider>(
|
Consumer<AppProvider>(
|
||||||
builder: (context, appProvider, child) => SwitchListTile(
|
builder: (context, appProvider, child) => SwitchListTile(
|
||||||
secondary: const Icon(Icons.content_cut),
|
secondary: const Icon(Icons.content_cut),
|
||||||
@@ -1729,6 +1876,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
required bool bandPassEnabled,
|
required bool bandPassEnabled,
|
||||||
required bool compressorEnabled,
|
required bool compressorEnabled,
|
||||||
required bool limiterEnabled,
|
required bool limiterEnabled,
|
||||||
|
required bool autoGainEnabled,
|
||||||
|
required bool echoCancellationEnabled,
|
||||||
|
required bool noiseSuppressionEnabled,
|
||||||
required bool silenceTrimEnabled,
|
required bool silenceTrimEnabled,
|
||||||
}) {
|
}) {
|
||||||
final supported = VoiceBitratePreferences.supportedBitrates;
|
final supported = VoiceBitratePreferences.supportedBitrates;
|
||||||
@@ -1741,6 +1891,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
(bandPassEnabled ? 1 : 0) +
|
(bandPassEnabled ? 1 : 0) +
|
||||||
(compressorEnabled ? 1 : 0) +
|
(compressorEnabled ? 1 : 0) +
|
||||||
(limiterEnabled ? 1 : 0) +
|
(limiterEnabled ? 1 : 0) +
|
||||||
|
(autoGainEnabled ? 1 : 0) +
|
||||||
|
(echoCancellationEnabled ? 1 : 0) +
|
||||||
|
(noiseSuppressionEnabled ? 1 : 0) +
|
||||||
(silenceTrimEnabled ? 1 : 0);
|
(silenceTrimEnabled ? 1 : 0);
|
||||||
final radioBw = connectionProvider.deviceInfo.radioBw;
|
final radioBw = connectionProvider.deviceInfo.radioBw;
|
||||||
final radioSf = connectionProvider.deviceInfo.radioSf;
|
final radioSf = connectionProvider.deviceInfo.radioSf;
|
||||||
@@ -1748,7 +1901,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
final bwHz = _resolveBandwidthHz(radioBw);
|
final bwHz = _resolveBandwidthHz(radioBw);
|
||||||
final voiceMode = VoiceBitratePreferences.toVoiceMode(bitrate);
|
final voiceMode = VoiceBitratePreferences.toVoiceMode(bitrate);
|
||||||
const voicePreviewMs = 10000; // 10-second reference clip
|
const voicePreviewMs = 10000; // 10-second reference clip
|
||||||
final packetDurationMs = codec2ModeFor(voiceMode).packetDurationMs;
|
final packetDurationMs = voiceMode.packetDurationMs;
|
||||||
final voicePacketCount =
|
final voicePacketCount =
|
||||||
(voicePreviewMs + packetDurationMs - 1) ~/ packetDurationMs;
|
(voicePreviewMs + packetDurationMs - 1) ~/ packetDurationMs;
|
||||||
final voiceDirect = estimateVoiceTransmitDuration(
|
final voiceDirect = estimateVoiceTransmitDuration(
|
||||||
@@ -1783,7 +1936,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
'Bitrate: $bitrate bps',
|
'Codec: ${voiceMode.label} · $bitrate bps',
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
@@ -1830,6 +1983,31 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: _voiceStatChip(
|
||||||
|
label: 'Auto gain',
|
||||||
|
enabled: autoGainEnabled,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: _voiceStatChip(
|
||||||
|
label: 'Echo cancel',
|
||||||
|
enabled: echoCancellationEnabled,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: _voiceStatChip(
|
||||||
|
label: 'Noise suppress',
|
||||||
|
enabled: noiseSuppressionEnabled,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
@@ -1842,7 +2020,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
'Processing enabled: $enabledCount/4',
|
'Processing enabled: $enabledCount/7',
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
336
lib/services/contact_route_resolver.dart
Normal file
336
lib/services/contact_route_resolver.dart
Normal file
@@ -0,0 +1,336 @@
|
|||||||
|
import 'dart:math' as math;
|
||||||
|
|
||||||
|
import 'package:latlong2/latlong.dart';
|
||||||
|
|
||||||
|
import '../models/contact.dart';
|
||||||
|
|
||||||
|
class ResolvedContactRoutePlan {
|
||||||
|
final List<String> tokens;
|
||||||
|
final List<Contact> selectedContacts;
|
||||||
|
final String summary;
|
||||||
|
|
||||||
|
const ResolvedContactRoutePlan({
|
||||||
|
required this.tokens,
|
||||||
|
required this.selectedContacts,
|
||||||
|
required this.summary,
|
||||||
|
});
|
||||||
|
|
||||||
|
String get canonicalText => tokens.join(',');
|
||||||
|
}
|
||||||
|
|
||||||
|
class ContactRouteResolver {
|
||||||
|
static const Distance _distance = Distance();
|
||||||
|
|
||||||
|
const ContactRouteResolver._();
|
||||||
|
|
||||||
|
static ResolvedContactRoutePlan? resolveAutomaticRoute({
|
||||||
|
required LatLng senderLocation,
|
||||||
|
required Contact recipient,
|
||||||
|
required List<Contact> availableContacts,
|
||||||
|
required int hashSize,
|
||||||
|
}) {
|
||||||
|
final recipientLocation = recipient.displayLocation;
|
||||||
|
if (recipientLocation == null) return null;
|
||||||
|
|
||||||
|
final repeaters = availableContacts
|
||||||
|
.where(
|
||||||
|
(contact) =>
|
||||||
|
contact.isRepeater &&
|
||||||
|
contact.displayLocation != null &&
|
||||||
|
contact.publicKeyHex != recipient.publicKeyHex,
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
|
if (repeaters.isEmpty) return null;
|
||||||
|
|
||||||
|
final recipientLatLng = LatLng(
|
||||||
|
recipientLocation.latitude,
|
||||||
|
recipientLocation.longitude,
|
||||||
|
);
|
||||||
|
|
||||||
|
final routedCandidates =
|
||||||
|
repeaters
|
||||||
|
.where(
|
||||||
|
(contact) =>
|
||||||
|
contact.routeHasPath && contact.routeHashSize == hashSize,
|
||||||
|
)
|
||||||
|
.toList()
|
||||||
|
..sort(
|
||||||
|
(a, b) =>
|
||||||
|
_scoreKnownRouteRepeater(
|
||||||
|
senderLocation: senderLocation,
|
||||||
|
recipientLocation: recipientLatLng,
|
||||||
|
repeater: a,
|
||||||
|
availableContacts: repeaters,
|
||||||
|
hashSize: hashSize,
|
||||||
|
).compareTo(
|
||||||
|
_scoreKnownRouteRepeater(
|
||||||
|
senderLocation: senderLocation,
|
||||||
|
recipientLocation: recipientLatLng,
|
||||||
|
repeater: b,
|
||||||
|
availableContacts: repeaters,
|
||||||
|
hashSize: hashSize,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (routedCandidates.isNotEmpty) {
|
||||||
|
final anchor = routedCandidates.first;
|
||||||
|
final tokens = <String>[
|
||||||
|
...anchor.routeCanonicalText
|
||||||
|
.split(',')
|
||||||
|
.where((token) => token.isNotEmpty)
|
||||||
|
.map((token) => token.toUpperCase()),
|
||||||
|
_tokenFor(anchor, hashSize),
|
||||||
|
];
|
||||||
|
final selectedContacts = _matchContactsForTokens(
|
||||||
|
tokens,
|
||||||
|
availableContacts: repeaters,
|
||||||
|
);
|
||||||
|
return ResolvedContactRoutePlan(
|
||||||
|
tokens: _dedupeTokens(tokens),
|
||||||
|
selectedContacts: selectedContacts,
|
||||||
|
summary: 'Resolved via known repeater route',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final corridorRepeaters = List<Contact>.from(repeaters)
|
||||||
|
..sort((a, b) {
|
||||||
|
final progressA = _progressAlongSegment(
|
||||||
|
point: LatLng(
|
||||||
|
a.displayLocation!.latitude,
|
||||||
|
a.displayLocation!.longitude,
|
||||||
|
),
|
||||||
|
start: senderLocation,
|
||||||
|
end: recipientLatLng,
|
||||||
|
);
|
||||||
|
final progressB = _progressAlongSegment(
|
||||||
|
point: LatLng(
|
||||||
|
b.displayLocation!.latitude,
|
||||||
|
b.displayLocation!.longitude,
|
||||||
|
),
|
||||||
|
start: senderLocation,
|
||||||
|
end: recipientLatLng,
|
||||||
|
);
|
||||||
|
final progressCompare = progressA.compareTo(progressB);
|
||||||
|
if (progressCompare != 0) return progressCompare;
|
||||||
|
return _scoreRepeater(
|
||||||
|
senderLocation: senderLocation,
|
||||||
|
recipientLocation: recipientLatLng,
|
||||||
|
repeater: a,
|
||||||
|
).compareTo(
|
||||||
|
_scoreRepeater(
|
||||||
|
senderLocation: senderLocation,
|
||||||
|
recipientLocation: recipientLatLng,
|
||||||
|
repeater: b,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
final selected = <Contact>[];
|
||||||
|
var currentPoint = senderLocation;
|
||||||
|
var currentDistanceToRecipient = _distance.as(
|
||||||
|
LengthUnit.Meter,
|
||||||
|
senderLocation,
|
||||||
|
recipientLatLng,
|
||||||
|
);
|
||||||
|
final maxCorridorDistance = math.max(
|
||||||
|
1500.0,
|
||||||
|
currentDistanceToRecipient * 0.22,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (final repeater in corridorRepeaters) {
|
||||||
|
if (selected.length >= 4) break;
|
||||||
|
final repeaterPoint = LatLng(
|
||||||
|
repeater.displayLocation!.latitude,
|
||||||
|
repeater.displayLocation!.longitude,
|
||||||
|
);
|
||||||
|
final distanceToSegment = _distanceToSegmentMeters(
|
||||||
|
repeaterPoint,
|
||||||
|
senderLocation,
|
||||||
|
recipientLatLng,
|
||||||
|
);
|
||||||
|
if (distanceToSegment > maxCorridorDistance) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
final nextDistanceToRecipient = _distance.as(
|
||||||
|
LengthUnit.Meter,
|
||||||
|
repeaterPoint,
|
||||||
|
recipientLatLng,
|
||||||
|
);
|
||||||
|
if (nextDistanceToRecipient >= currentDistanceToRecipient - 300) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
final distanceFromCurrent = _distance.as(
|
||||||
|
LengthUnit.Meter,
|
||||||
|
currentPoint,
|
||||||
|
repeaterPoint,
|
||||||
|
);
|
||||||
|
if (distanceFromCurrent < 100) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
selected.add(repeater);
|
||||||
|
currentPoint = repeaterPoint;
|
||||||
|
currentDistanceToRecipient = nextDistanceToRecipient;
|
||||||
|
|
||||||
|
if (currentDistanceToRecipient < 2500) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selected.isEmpty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ResolvedContactRoutePlan(
|
||||||
|
tokens: selected.map((contact) => _tokenFor(contact, hashSize)).toList(),
|
||||||
|
selectedContacts: selected,
|
||||||
|
summary: 'Resolved from repeater locations',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<Contact> _matchContactsForTokens(
|
||||||
|
List<String> tokens, {
|
||||||
|
required List<Contact> availableContacts,
|
||||||
|
}) {
|
||||||
|
final matches = <Contact>[];
|
||||||
|
final seen = <String>{};
|
||||||
|
for (final token in tokens) {
|
||||||
|
final match = availableContacts
|
||||||
|
.where(
|
||||||
|
(contact) => contact.publicKeyHex.toUpperCase().startsWith(token),
|
||||||
|
)
|
||||||
|
.firstOrNull;
|
||||||
|
if (match != null && seen.add(match.publicKeyHex)) {
|
||||||
|
matches.add(match);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<String> _dedupeTokens(List<String> tokens) {
|
||||||
|
final result = <String>[];
|
||||||
|
for (final token in tokens) {
|
||||||
|
if (result.isEmpty || result.last != token) {
|
||||||
|
result.add(token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _tokenFor(Contact contact, int hashSize) {
|
||||||
|
final hex = contact.publicKeyHex.toUpperCase();
|
||||||
|
final length = hashSize * 2;
|
||||||
|
return hex.length < length ? hex : hex.substring(0, length);
|
||||||
|
}
|
||||||
|
|
||||||
|
static double _scoreRepeater({
|
||||||
|
required LatLng senderLocation,
|
||||||
|
required LatLng recipientLocation,
|
||||||
|
required Contact repeater,
|
||||||
|
}) {
|
||||||
|
final point = LatLng(
|
||||||
|
repeater.displayLocation!.latitude,
|
||||||
|
repeater.displayLocation!.longitude,
|
||||||
|
);
|
||||||
|
final toRecipient = _distance.as(
|
||||||
|
LengthUnit.Meter,
|
||||||
|
point,
|
||||||
|
recipientLocation,
|
||||||
|
);
|
||||||
|
final corridor = _distanceToSegmentMeters(
|
||||||
|
point,
|
||||||
|
senderLocation,
|
||||||
|
recipientLocation,
|
||||||
|
);
|
||||||
|
return (toRecipient * 0.75) + (corridor * 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
static double _scoreKnownRouteRepeater({
|
||||||
|
required LatLng senderLocation,
|
||||||
|
required LatLng recipientLocation,
|
||||||
|
required Contact repeater,
|
||||||
|
required List<Contact> availableContacts,
|
||||||
|
required int hashSize,
|
||||||
|
}) {
|
||||||
|
final baseScore = _scoreRepeater(
|
||||||
|
senderLocation: senderLocation,
|
||||||
|
recipientLocation: recipientLocation,
|
||||||
|
repeater: repeater,
|
||||||
|
);
|
||||||
|
final repeaterPoint = LatLng(
|
||||||
|
repeater.displayLocation!.latitude,
|
||||||
|
repeater.displayLocation!.longitude,
|
||||||
|
);
|
||||||
|
|
||||||
|
final chainContacts = repeater.routeCanonicalText
|
||||||
|
.split(',')
|
||||||
|
.where((token) => token.isNotEmpty)
|
||||||
|
.map(
|
||||||
|
(token) => availableContacts
|
||||||
|
.where(
|
||||||
|
(contact) =>
|
||||||
|
contact.displayLocation != null &&
|
||||||
|
contact.publicKeyHex.toUpperCase().startsWith(
|
||||||
|
token.toUpperCase(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.firstOrNull,
|
||||||
|
)
|
||||||
|
.whereType<Contact>()
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
final chainEnd = chainContacts.isNotEmpty
|
||||||
|
? LatLng(
|
||||||
|
chainContacts.last.displayLocation!.latitude,
|
||||||
|
chainContacts.last.displayLocation!.longitude,
|
||||||
|
)
|
||||||
|
: senderLocation;
|
||||||
|
final chainGap = _distance.as(LengthUnit.Meter, chainEnd, repeaterPoint);
|
||||||
|
final senderGap = _distance.as(
|
||||||
|
LengthUnit.Meter,
|
||||||
|
senderLocation,
|
||||||
|
repeaterPoint,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (chainGap * 0.55) + (baseScore * 0.35) + (senderGap * 0.10);
|
||||||
|
}
|
||||||
|
|
||||||
|
static double _progressAlongSegment({
|
||||||
|
required LatLng point,
|
||||||
|
required LatLng start,
|
||||||
|
required LatLng end,
|
||||||
|
}) {
|
||||||
|
final dx = end.longitude - start.longitude;
|
||||||
|
final dy = end.latitude - start.latitude;
|
||||||
|
final lengthSquared = (dx * dx) + (dy * dy);
|
||||||
|
if (lengthSquared == 0) return 0;
|
||||||
|
return (((point.longitude - start.longitude) * dx) +
|
||||||
|
((point.latitude - start.latitude) * dy)) /
|
||||||
|
lengthSquared;
|
||||||
|
}
|
||||||
|
|
||||||
|
static double _distanceToSegmentMeters(LatLng p, LatLng a, LatLng b) {
|
||||||
|
final ax = a.longitude;
|
||||||
|
final ay = a.latitude;
|
||||||
|
final bx = b.longitude;
|
||||||
|
final by = b.latitude;
|
||||||
|
final px = p.longitude;
|
||||||
|
final py = p.latitude;
|
||||||
|
|
||||||
|
final abx = bx - ax;
|
||||||
|
final aby = by - ay;
|
||||||
|
final apx = px - ax;
|
||||||
|
final apy = py - ay;
|
||||||
|
final ab2 = abx * abx + aby * aby;
|
||||||
|
if (ab2 == 0) {
|
||||||
|
return _distance.as(LengthUnit.Meter, a, p);
|
||||||
|
}
|
||||||
|
var t = (apx * abx + apy * aby) / ab2;
|
||||||
|
t = t.clamp(0.0, 1.0);
|
||||||
|
final closest = LatLng(ay + aby * t, ax + abx * t);
|
||||||
|
return _distance.as(LengthUnit.Meter, closest, p);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,12 +2,14 @@ import 'dart:convert';
|
|||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import '../models/contact.dart';
|
import '../models/contact.dart';
|
||||||
|
import '../models/contact_group.dart';
|
||||||
import '../utils/key_comparison.dart';
|
import '../utils/key_comparison.dart';
|
||||||
import 'package:latlong2/latlong.dart';
|
import 'package:latlong2/latlong.dart';
|
||||||
|
|
||||||
/// Service for persisting contacts to local storage
|
/// Service for persisting contacts to local storage
|
||||||
class ContactStorageService {
|
class ContactStorageService {
|
||||||
static const String _contactsKey = 'stored_contacts';
|
static const String _contactsKey = 'stored_contacts';
|
||||||
|
static const String _contactGroupsKey = 'stored_contact_groups';
|
||||||
static const int _maxStoredContacts = 500; // Store up to 500 contacts
|
static const int _maxStoredContacts = 500; // Store up to 500 contacts
|
||||||
|
|
||||||
/// Save contacts to persistent storage
|
/// Save contacts to persistent storage
|
||||||
@@ -90,6 +92,40 @@ class ContactStorageService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> saveContactGroups(List<SavedContactGroup> groups) async {
|
||||||
|
try {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final jsonString = jsonEncode(
|
||||||
|
groups.map((group) => _contactGroupToJson(group)).toList(),
|
||||||
|
);
|
||||||
|
await prefs.setString(_contactGroupsKey, jsonString);
|
||||||
|
debugPrint(
|
||||||
|
'✅ [ContactStorage] Saved ${groups.length} contact groups to storage',
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('❌ [ContactStorage] Error saving contact groups: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<SavedContactGroup>> loadContactGroups() async {
|
||||||
|
try {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final jsonString = prefs.getString(_contactGroupsKey);
|
||||||
|
if (jsonString == null || jsonString.isEmpty) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
final jsonList = jsonDecode(jsonString) as List<dynamic>;
|
||||||
|
return jsonList
|
||||||
|
.map((json) => _contactGroupFromJson(json as Map<String, dynamic>))
|
||||||
|
.whereType<SavedContactGroup>()
|
||||||
|
.toList();
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('❌ [ContactStorage] Error loading contact groups: $e');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Get storage statistics
|
/// Get storage statistics
|
||||||
Future<Map<String, dynamic>> getStorageStats() async {
|
Future<Map<String, dynamic>> getStorageStats() async {
|
||||||
try {
|
try {
|
||||||
@@ -123,6 +159,7 @@ class ContactStorageService {
|
|||||||
'outPathLen': contact.outPathLen,
|
'outPathLen': contact.outPathLen,
|
||||||
'outPath': base64Encode(contact.outPath),
|
'outPath': base64Encode(contact.outPath),
|
||||||
'advName': contact.advName,
|
'advName': contact.advName,
|
||||||
|
'nameOverride': contact.nameOverride,
|
||||||
'lastAdvert': contact.lastAdvert,
|
'lastAdvert': contact.lastAdvert,
|
||||||
'advLat': contact.advLat,
|
'advLat': contact.advLat,
|
||||||
'advLon': contact.advLon,
|
'advLon': contact.advLon,
|
||||||
@@ -145,6 +182,7 @@ class ContactStorageService {
|
|||||||
outPathLen: json['outPathLen'] as int,
|
outPathLen: json['outPathLen'] as int,
|
||||||
outPath: Uint8List.fromList(base64Decode(json['outPath'] as String)),
|
outPath: Uint8List.fromList(base64Decode(json['outPath'] as String)),
|
||||||
advName: json['advName'] as String,
|
advName: json['advName'] as String,
|
||||||
|
nameOverride: json['nameOverride'] as String?,
|
||||||
lastAdvert: json['lastAdvert'] as int,
|
lastAdvert: json['lastAdvert'] as int,
|
||||||
advLat: json['advLat'] as int,
|
advLat: json['advLat'] as int,
|
||||||
advLon: json['advLon'] as int,
|
advLon: json['advLon'] as int,
|
||||||
@@ -203,4 +241,37 @@ class ContactStorageService {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> _contactGroupToJson(SavedContactGroup group) {
|
||||||
|
return {
|
||||||
|
'id': group.id,
|
||||||
|
'sectionKey': group.sectionKey,
|
||||||
|
'label': group.label,
|
||||||
|
'query': group.query,
|
||||||
|
'createdAtMillis': group.createdAt.millisecondsSinceEpoch,
|
||||||
|
'matchPrefixes': group.matchPrefixes,
|
||||||
|
'isAutoGroup': group.isAutoGroup,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
SavedContactGroup? _contactGroupFromJson(Map<String, dynamic> json) {
|
||||||
|
try {
|
||||||
|
return SavedContactGroup(
|
||||||
|
id: json['id'] as String,
|
||||||
|
sectionKey: json['sectionKey'] as String,
|
||||||
|
label: json['label'] as String,
|
||||||
|
query: json['query'] as String,
|
||||||
|
createdAt: DateTime.fromMillisecondsSinceEpoch(
|
||||||
|
json['createdAtMillis'] as int,
|
||||||
|
),
|
||||||
|
matchPrefixes: (json['matchPrefixes'] as List<dynamic>?)
|
||||||
|
?.map((value) => value as String)
|
||||||
|
.toList(),
|
||||||
|
isAutoGroup: json['isAutoGroup'] as bool? ?? false,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('❌ [ContactStorage] Error parsing contact group: $e');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
247
lib/services/live_traffic_summary.dart
Normal file
247
lib/services/live_traffic_summary.dart
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
import '../models/ble_packet_log.dart';
|
||||||
|
import '../utils/log_rx_route_decoder.dart';
|
||||||
|
|
||||||
|
enum LiveTrafficBusyness { quiet, active, busy }
|
||||||
|
|
||||||
|
class LiveTrafficEntry {
|
||||||
|
final BlePacketLog log;
|
||||||
|
final DecodedLogRxRoute? route;
|
||||||
|
|
||||||
|
const LiveTrafficEntry({required this.log, required this.route});
|
||||||
|
|
||||||
|
bool get isMultiHop => (route?.hopCount ?? 0) > 1;
|
||||||
|
|
||||||
|
int? get hopCount => route?.hopCount;
|
||||||
|
|
||||||
|
String get payloadLabel {
|
||||||
|
final decodedRoute = route;
|
||||||
|
if (decodedRoute == null) {
|
||||||
|
return log.responseCode != null ? log.opcodeName : 'Unknown';
|
||||||
|
}
|
||||||
|
return payloadTypeLabel(decodedRoute.payloadType);
|
||||||
|
}
|
||||||
|
|
||||||
|
String? get payloadMeaning {
|
||||||
|
final decodedRoute = route;
|
||||||
|
if (decodedRoute == null) return null;
|
||||||
|
return payloadTypeMeaning(decodedRoute.payloadType);
|
||||||
|
}
|
||||||
|
|
||||||
|
String get routePreview {
|
||||||
|
final decodedRoute = route;
|
||||||
|
if (decodedRoute == null || decodedRoute.hopHashes.isEmpty) {
|
||||||
|
return 'Direct packet';
|
||||||
|
}
|
||||||
|
return decodedRoute.hopHashes
|
||||||
|
.map((hashHex) => '0x${hashHex.toUpperCase()}')
|
||||||
|
.join(' -> ');
|
||||||
|
}
|
||||||
|
|
||||||
|
static String payloadTypeLabel(int payloadType) {
|
||||||
|
switch (payloadType) {
|
||||||
|
case 0x00:
|
||||||
|
return 'Request';
|
||||||
|
case 0x01:
|
||||||
|
return 'Response';
|
||||||
|
case 0x02:
|
||||||
|
return 'Text message';
|
||||||
|
case 0x03:
|
||||||
|
return 'Ack';
|
||||||
|
case 0x04:
|
||||||
|
return 'Advertisement';
|
||||||
|
case 0x05:
|
||||||
|
return 'Group text';
|
||||||
|
case 0x06:
|
||||||
|
return 'Group datagram';
|
||||||
|
case 0x07:
|
||||||
|
return 'Anonymous request';
|
||||||
|
case 0x08:
|
||||||
|
return 'Returned path';
|
||||||
|
case 0x09:
|
||||||
|
return 'Trace path';
|
||||||
|
case 0x0A:
|
||||||
|
return 'Multipart packet';
|
||||||
|
case 0x0B:
|
||||||
|
return 'Control packet';
|
||||||
|
default:
|
||||||
|
return '0x${payloadType.toRadixString(16).padLeft(2, '0')}';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static String payloadTypeMeaning(int payloadType) {
|
||||||
|
switch (payloadType) {
|
||||||
|
case 0x00:
|
||||||
|
return 'Request (destination/source hashes + MAC)';
|
||||||
|
case 0x01:
|
||||||
|
return 'Response to Request or Anonymous request';
|
||||||
|
case 0x02:
|
||||||
|
return 'Plain text message';
|
||||||
|
case 0x03:
|
||||||
|
return 'Simple acknowledgement';
|
||||||
|
case 0x04:
|
||||||
|
return 'Node advertisement';
|
||||||
|
case 0x05:
|
||||||
|
return 'Unverified group text message';
|
||||||
|
case 0x06:
|
||||||
|
return 'Unverified group datagram';
|
||||||
|
case 0x07:
|
||||||
|
return 'Generic anonymous request';
|
||||||
|
case 0x08:
|
||||||
|
return 'Returned path payload';
|
||||||
|
case 0x09:
|
||||||
|
return 'Trace path collecting hop SNR';
|
||||||
|
case 0x0A:
|
||||||
|
return 'One packet from a multipart set';
|
||||||
|
case 0x0B:
|
||||||
|
return 'Control or discovery packet';
|
||||||
|
default:
|
||||||
|
return 'protocol payload';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class LiveTrafficSnapshot {
|
||||||
|
final DateTime windowStart;
|
||||||
|
final Duration windowDuration;
|
||||||
|
final int packetsPerMinute;
|
||||||
|
final int rxCount;
|
||||||
|
final int txCount;
|
||||||
|
final int totalCount;
|
||||||
|
final double? avgSnrDb;
|
||||||
|
final double? latestSnrDb;
|
||||||
|
final double? avgRssiDbm;
|
||||||
|
final int? latestRssiDbm;
|
||||||
|
final int multiHopCount;
|
||||||
|
final double? avgHopCount;
|
||||||
|
final List<LiveTrafficEntry> visibleEntries;
|
||||||
|
final LiveTrafficBusyness busyness;
|
||||||
|
|
||||||
|
const LiveTrafficSnapshot({
|
||||||
|
required this.windowStart,
|
||||||
|
required this.windowDuration,
|
||||||
|
required this.packetsPerMinute,
|
||||||
|
required this.rxCount,
|
||||||
|
required this.txCount,
|
||||||
|
required this.totalCount,
|
||||||
|
required this.avgSnrDb,
|
||||||
|
required this.latestSnrDb,
|
||||||
|
required this.avgRssiDbm,
|
||||||
|
required this.latestRssiDbm,
|
||||||
|
required this.multiHopCount,
|
||||||
|
required this.avgHopCount,
|
||||||
|
required this.visibleEntries,
|
||||||
|
required this.busyness,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class LiveTrafficSummary {
|
||||||
|
static const Duration rollingWindow = Duration(seconds: 60);
|
||||||
|
static const int maxVisibleEntries = 120;
|
||||||
|
static const int logRxDataResponseCode = 0x88;
|
||||||
|
|
||||||
|
const LiveTrafficSummary._();
|
||||||
|
|
||||||
|
static LiveTrafficSnapshot fromLogs(
|
||||||
|
Iterable<BlePacketLog> logs, {
|
||||||
|
required DateTime now,
|
||||||
|
DateTime? clearedAt,
|
||||||
|
int? preferredHashSize,
|
||||||
|
Duration window = rollingWindow,
|
||||||
|
String? packetTypeFilter,
|
||||||
|
}) {
|
||||||
|
final windowStart = now.subtract(window);
|
||||||
|
final effectiveStart = clearedAt != null && clearedAt.isAfter(windowStart)
|
||||||
|
? clearedAt
|
||||||
|
: windowStart;
|
||||||
|
|
||||||
|
final recentLogs = logs
|
||||||
|
.where(
|
||||||
|
(log) =>
|
||||||
|
log.direction == PacketDirection.rx &&
|
||||||
|
log.responseCode == logRxDataResponseCode &&
|
||||||
|
!log.timestamp.isBefore(effectiveStart),
|
||||||
|
)
|
||||||
|
.toList()
|
||||||
|
..sort((a, b) => a.timestamp.compareTo(b.timestamp));
|
||||||
|
|
||||||
|
final entries = <LiveTrafficEntry>[];
|
||||||
|
for (final log in recentLogs) {
|
||||||
|
final route = LogRxRouteDecoder.decode(
|
||||||
|
log.rawData,
|
||||||
|
preferredHashSize: preferredHashSize,
|
||||||
|
);
|
||||||
|
entries.add(LiveTrafficEntry(log: log, route: route));
|
||||||
|
}
|
||||||
|
|
||||||
|
final filteredEntries = packetTypeFilter == null
|
||||||
|
? entries
|
||||||
|
: entries
|
||||||
|
.where((entry) => entry.payloadLabel == packetTypeFilter)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
var rxCount = 0;
|
||||||
|
var snrCount = 0;
|
||||||
|
var snrSum = 0.0;
|
||||||
|
var rssiCount = 0;
|
||||||
|
var rssiSum = 0.0;
|
||||||
|
double? latestSnrDb;
|
||||||
|
int? latestRssiDbm;
|
||||||
|
var multiHopCount = 0;
|
||||||
|
var hopCountTotal = 0;
|
||||||
|
var hopCountSamples = 0;
|
||||||
|
|
||||||
|
for (final entry in filteredEntries) {
|
||||||
|
rxCount += 1;
|
||||||
|
|
||||||
|
final rxInfo = entry.log.logRxDataInfo;
|
||||||
|
if (rxInfo?.snrDb != null) {
|
||||||
|
snrCount += 1;
|
||||||
|
snrSum += rxInfo!.snrDb!;
|
||||||
|
latestSnrDb = rxInfo.snrDb!;
|
||||||
|
}
|
||||||
|
if (rxInfo?.rssiDbm != null) {
|
||||||
|
rssiCount += 1;
|
||||||
|
rssiSum += rxInfo!.rssiDbm!.toDouble();
|
||||||
|
latestRssiDbm = rxInfo.rssiDbm!;
|
||||||
|
}
|
||||||
|
|
||||||
|
final route = entry.route;
|
||||||
|
if (route != null && route.hopCount > 0) {
|
||||||
|
hopCountSamples += 1;
|
||||||
|
hopCountTotal += route.hopCount;
|
||||||
|
if (route.hopCount > 1) {
|
||||||
|
multiHopCount += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final visibleEntries = filteredEntries.reversed.take(maxVisibleEntries).toList();
|
||||||
|
const txCount = 0;
|
||||||
|
final totalCount = rxCount;
|
||||||
|
final packetsPerMinute =
|
||||||
|
((totalCount * Duration.secondsPerMinute) / window.inSeconds).round();
|
||||||
|
|
||||||
|
return LiveTrafficSnapshot(
|
||||||
|
windowStart: effectiveStart,
|
||||||
|
windowDuration: window,
|
||||||
|
packetsPerMinute: packetsPerMinute,
|
||||||
|
rxCount: rxCount,
|
||||||
|
txCount: txCount,
|
||||||
|
totalCount: totalCount,
|
||||||
|
avgSnrDb: snrCount == 0 ? null : snrSum / snrCount,
|
||||||
|
latestSnrDb: latestSnrDb,
|
||||||
|
avgRssiDbm: rssiCount == 0 ? null : rssiSum / rssiCount,
|
||||||
|
latestRssiDbm: latestRssiDbm,
|
||||||
|
multiHopCount: multiHopCount,
|
||||||
|
avgHopCount: hopCountSamples == 0 ? null : hopCountTotal / hopCountSamples,
|
||||||
|
visibleEntries: visibleEntries,
|
||||||
|
busyness: _busynessForPacketsPerMinute(packetsPerMinute),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static LiveTrafficBusyness _busynessForPacketsPerMinute(int ppm) {
|
||||||
|
if (ppm <= 5) return LiveTrafficBusyness.quiet;
|
||||||
|
if (ppm <= 20) return LiveTrafficBusyness.active;
|
||||||
|
return LiveTrafficBusyness.busy;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import 'package:latlong2/latlong.dart';
|
|||||||
/// Service for persisting messages to local storage
|
/// Service for persisting messages to local storage
|
||||||
class MessageStorageService {
|
class MessageStorageService {
|
||||||
static const String _messagesKey = 'stored_messages';
|
static const String _messagesKey = 'stored_messages';
|
||||||
|
static const String _removedSarMarkerIdsKey = 'removed_sar_marker_ids';
|
||||||
static const String _messageContactLocationsKey =
|
static const String _messageContactLocationsKey =
|
||||||
'stored_message_contact_locations';
|
'stored_message_contact_locations';
|
||||||
static const String _messageReceptionDetailsKey =
|
static const String _messageReceptionDetailsKey =
|
||||||
@@ -19,6 +20,8 @@ class MessageStorageService {
|
|||||||
'stored_message_transfer_details';
|
'stored_message_transfer_details';
|
||||||
static const String _messageRouteMetadataKey =
|
static const String _messageRouteMetadataKey =
|
||||||
'stored_message_route_metadata';
|
'stored_message_route_metadata';
|
||||||
|
static const String _embeddedReceptionDetailsKey = 'storedReceptionDetails';
|
||||||
|
static const String _legacyPathBytesKey = 'storedPathBytes';
|
||||||
static const int _maxStoredMessages = 1000; // Store up to 1000 messages
|
static const int _maxStoredMessages = 1000; // Store up to 1000 messages
|
||||||
|
|
||||||
/// Save messages to persistent storage
|
/// Save messages to persistent storage
|
||||||
@@ -32,8 +35,16 @@ class MessageStorageService {
|
|||||||
try {
|
try {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
|
||||||
// Convert messages to JSON
|
// Convert messages to JSON and embed path bytes as a fallback so they
|
||||||
final jsonList = messages.map((msg) => _messageToJson(msg)).toList();
|
// survive restore even if the sidecar reception-details entry is absent.
|
||||||
|
final jsonList = messages
|
||||||
|
.map(
|
||||||
|
(msg) => _messageToJson(
|
||||||
|
msg,
|
||||||
|
receptionDetails: messageReceptionDetails[msg.id],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
|
|
||||||
// Limit to max stored messages (keep most recent)
|
// Limit to max stored messages (keep most recent)
|
||||||
final limitedList = jsonList.length > _maxStoredMessages
|
final limitedList = jsonList.length > _maxStoredMessages
|
||||||
@@ -129,24 +140,36 @@ class MessageStorageService {
|
|||||||
try {
|
try {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
final jsonString = prefs.getString(_messageReceptionDetailsKey);
|
final jsonString = prefs.getString(_messageReceptionDetailsKey);
|
||||||
if (jsonString == null || jsonString.isEmpty) {
|
|
||||||
return const {};
|
|
||||||
}
|
|
||||||
|
|
||||||
final decoded = jsonDecode(jsonString);
|
|
||||||
if (decoded is! Map<String, dynamic>) {
|
|
||||||
return const {};
|
|
||||||
}
|
|
||||||
|
|
||||||
final result = <String, MessageReceptionDetails>{};
|
final result = <String, MessageReceptionDetails>{};
|
||||||
for (final entry in decoded.entries) {
|
if (jsonString != null && jsonString.isNotEmpty) {
|
||||||
final value = entry.value;
|
final decoded = jsonDecode(jsonString);
|
||||||
if (value is! Map<String, dynamic>) continue;
|
if (decoded is Map<String, dynamic>) {
|
||||||
final snapshot = MessageReceptionDetails.fromJson(value);
|
for (final entry in decoded.entries) {
|
||||||
if (snapshot != null) {
|
final value = entry.value;
|
||||||
result[entry.key] = snapshot;
|
if (value is! Map<String, dynamic>) continue;
|
||||||
|
final snapshot = MessageReceptionDetails.fromJson(value);
|
||||||
|
if (snapshot != null) {
|
||||||
|
result[entry.key] = snapshot;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final embeddedReceptionDetails = await _loadEmbeddedReceptionDetails();
|
||||||
|
embeddedReceptionDetails.forEach((messageId, snapshot) {
|
||||||
|
result.putIfAbsent(messageId, () => snapshot);
|
||||||
|
});
|
||||||
|
|
||||||
|
final fallbackPathBytes = await _loadLegacyPathBytesFromMessages();
|
||||||
|
fallbackPathBytes.forEach((messageId, pathBytes) {
|
||||||
|
result.putIfAbsent(
|
||||||
|
messageId,
|
||||||
|
() => MessageReceptionDetails(
|
||||||
|
capturedAt: DateTime.fromMillisecondsSinceEpoch(0),
|
||||||
|
pathBytes: pathBytes,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
return result;
|
return result;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('❌ [MessageStorage] Error loading reception details: $e');
|
debugPrint('❌ [MessageStorage] Error loading reception details: $e');
|
||||||
@@ -247,12 +270,36 @@ class MessageStorageService {
|
|||||||
await prefs.remove(_messageReceptionDetailsKey);
|
await prefs.remove(_messageReceptionDetailsKey);
|
||||||
await prefs.remove(_messageTransferDetailsKey);
|
await prefs.remove(_messageTransferDetailsKey);
|
||||||
await prefs.remove(_messageRouteMetadataKey);
|
await prefs.remove(_messageRouteMetadataKey);
|
||||||
|
await prefs.remove(_removedSarMarkerIdsKey);
|
||||||
debugPrint('✅ [MessageStorage] Cleared all stored messages');
|
debugPrint('✅ [MessageStorage] Cleared all stored messages');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('❌ [MessageStorage] Error clearing messages: $e');
|
debugPrint('❌ [MessageStorage] Error clearing messages: $e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<Set<String>> loadRemovedSarMarkerIds() async {
|
||||||
|
try {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final ids = prefs.getStringList(_removedSarMarkerIdsKey) ?? const [];
|
||||||
|
return ids.toSet();
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('❌ [MessageStorage] Error loading removed SAR marker IDs: $e');
|
||||||
|
return const <String>{};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> saveRemovedSarMarkerIds(Set<String> ids) async {
|
||||||
|
try {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setStringList(_removedSarMarkerIdsKey, ids.toList()..sort());
|
||||||
|
debugPrint(
|
||||||
|
'✅ [MessageStorage] Saved ${ids.length} removed SAR marker IDs',
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('❌ [MessageStorage] Error saving removed SAR marker IDs: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Get storage statistics
|
/// Get storage statistics
|
||||||
Future<Map<String, dynamic>> getStorageStats() async {
|
Future<Map<String, dynamic>> getStorageStats() async {
|
||||||
try {
|
try {
|
||||||
@@ -278,7 +325,10 @@ class MessageStorageService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Convert Message to JSON
|
/// Convert Message to JSON
|
||||||
Map<String, dynamic> _messageToJson(Message message) {
|
Map<String, dynamic> _messageToJson(
|
||||||
|
Message message, {
|
||||||
|
MessageReceptionDetails? receptionDetails,
|
||||||
|
}) {
|
||||||
return {
|
return {
|
||||||
'id': message.id,
|
'id': message.id,
|
||||||
'messageType': message.messageType.name,
|
'messageType': message.messageType.name,
|
||||||
@@ -338,9 +388,67 @@ class MessageStorageService {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
.toList(),
|
.toList(),
|
||||||
|
if (receptionDetails != null)
|
||||||
|
_embeddedReceptionDetailsKey: receptionDetails.toJson(),
|
||||||
|
if (receptionDetails?.pathBytes case final pathBytes?)
|
||||||
|
_legacyPathBytesKey: List<int>.from(pathBytes),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<Map<String, MessageReceptionDetails>>
|
||||||
|
_loadEmbeddedReceptionDetails() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final jsonString = prefs.getString(_messagesKey);
|
||||||
|
if (jsonString == null || jsonString.isEmpty) {
|
||||||
|
return const {};
|
||||||
|
}
|
||||||
|
|
||||||
|
final decoded = jsonDecode(jsonString);
|
||||||
|
if (decoded is! List) {
|
||||||
|
return const {};
|
||||||
|
}
|
||||||
|
|
||||||
|
final result = <String, MessageReceptionDetails>{};
|
||||||
|
for (final entry in decoded) {
|
||||||
|
if (entry is! Map<String, dynamic>) continue;
|
||||||
|
final messageId = entry['id'];
|
||||||
|
final embedded = entry[_embeddedReceptionDetailsKey];
|
||||||
|
if (messageId is! String || embedded is! Map<String, dynamic>) continue;
|
||||||
|
final snapshot = MessageReceptionDetails.fromJson(embedded);
|
||||||
|
if (snapshot == null) continue;
|
||||||
|
result[messageId] = snapshot;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Map<String, List<int>>> _loadLegacyPathBytesFromMessages() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final jsonString = prefs.getString(_messagesKey);
|
||||||
|
if (jsonString == null || jsonString.isEmpty) {
|
||||||
|
return const {};
|
||||||
|
}
|
||||||
|
|
||||||
|
final decoded = jsonDecode(jsonString);
|
||||||
|
if (decoded is! List) {
|
||||||
|
return const {};
|
||||||
|
}
|
||||||
|
|
||||||
|
final result = <String, List<int>>{};
|
||||||
|
for (final entry in decoded) {
|
||||||
|
if (entry is! Map<String, dynamic>) continue;
|
||||||
|
final messageId = entry['id'];
|
||||||
|
final pathBytes = entry[_legacyPathBytesKey];
|
||||||
|
if (messageId is! String || pathBytes is! List) continue;
|
||||||
|
final normalized = pathBytes
|
||||||
|
.whereType<num>()
|
||||||
|
.map((b) => b.toInt())
|
||||||
|
.toList();
|
||||||
|
if (normalized.isEmpty) continue;
|
||||||
|
result[messageId] = normalized;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
/// Convert JSON to Message
|
/// Convert JSON to Message
|
||||||
Message? _messageFromJson(Map<String, dynamic> json) {
|
Message? _messageFromJson(Map<String, dynamic> json) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import 'package:shared_preferences/shared_preferences.dart';
|
|||||||
import '../models/contact.dart';
|
import '../models/contact.dart';
|
||||||
import '../models/path_history.dart';
|
import '../models/path_history.dart';
|
||||||
import '../models/path_selection.dart';
|
import '../models/path_selection.dart';
|
||||||
|
import '../utils/log_rx_route_decoder.dart';
|
||||||
|
|
||||||
class PathHistoryService {
|
class PathHistoryService {
|
||||||
static const String _storageKey = 'contact_path_history_v1';
|
static const String _storageKey = 'contact_path_history_v1';
|
||||||
@@ -53,6 +54,7 @@ class PathHistoryService {
|
|||||||
pathBytes: contact.routePathBytes.toList(),
|
pathBytes: contact.routePathBytes.toList(),
|
||||||
hopCount: contact.routeHopCount,
|
hopCount: contact.routeHopCount,
|
||||||
hashSize: contact.routeHashSize,
|
hashSize: contact.routeHashSize,
|
||||||
|
source: existing?.source ?? PathRecordSource.learned,
|
||||||
successCount: existing?.successCount ?? 0,
|
successCount: existing?.successCount ?? 0,
|
||||||
failureCount: existing?.failureCount ?? 0,
|
failureCount: existing?.failureCount ?? 0,
|
||||||
lastRoundTripTimeMs: existing?.lastRoundTripTimeMs ?? 0,
|
lastRoundTripTimeMs: existing?.lastRoundTripTimeMs ?? 0,
|
||||||
@@ -83,15 +85,21 @@ class PathHistoryService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final normalizedPathBytes = LogRxRouteDecoder.reverseHopBytes(
|
||||||
|
pathBytes,
|
||||||
|
hashSize: hashSize,
|
||||||
|
);
|
||||||
|
|
||||||
final history = _historyFor(contactPublicKeyHex);
|
final history = _historyFor(contactPublicKeyHex);
|
||||||
final signature = pathBytes
|
final signature = normalizedPathBytes
|
||||||
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
|
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
|
||||||
.join();
|
.join();
|
||||||
final existing = _findDirectPath(history.directPaths, signature);
|
final existing = _findDirectPath(history.directPaths, signature);
|
||||||
final updated = PathRecord(
|
final updated = PathRecord(
|
||||||
pathBytes: List<int>.from(pathBytes),
|
pathBytes: normalizedPathBytes,
|
||||||
hopCount: pathBytes.length ~/ hashSize,
|
hopCount: normalizedPathBytes.length ~/ hashSize,
|
||||||
hashSize: hashSize,
|
hashSize: hashSize,
|
||||||
|
source: PathRecordSource.observed,
|
||||||
successCount: existing?.successCount ?? 0,
|
successCount: existing?.successCount ?? 0,
|
||||||
failureCount: existing?.failureCount ?? 0,
|
failureCount: existing?.failureCount ?? 0,
|
||||||
lastRoundTripTimeMs: existing?.lastRoundTripTimeMs ?? 0,
|
lastRoundTripTimeMs: existing?.lastRoundTripTimeMs ?? 0,
|
||||||
@@ -191,6 +199,7 @@ class PathHistoryService {
|
|||||||
pathBytes: selection.pathBytes.toList(),
|
pathBytes: selection.pathBytes.toList(),
|
||||||
hopCount: selection.hopCount,
|
hopCount: selection.hopCount,
|
||||||
hashSize: selection.hashSize,
|
hashSize: selection.hashSize,
|
||||||
|
source: existing?.source ?? PathRecordSource.learned,
|
||||||
successCount: (existing?.successCount ?? 0) + (success ? 1 : 0),
|
successCount: (existing?.successCount ?? 0) + (success ? 1 : 0),
|
||||||
failureCount: (existing?.failureCount ?? 0) + (success ? 0 : 1),
|
failureCount: (existing?.failureCount ?? 0) + (success ? 0 : 1),
|
||||||
lastRoundTripTimeMs: success
|
lastRoundTripTimeMs: success
|
||||||
|
|||||||
21
lib/services/voice_codec_preferences.dart
Normal file
21
lib/services/voice_codec_preferences.dart
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
import '../utils/voice_message_parser.dart';
|
||||||
|
|
||||||
|
class VoiceCodecPreferences {
|
||||||
|
static const String _codecKey = 'voice_codec';
|
||||||
|
static const VoiceCodecKind defaultCodec = VoiceCodecKind.codec2;
|
||||||
|
|
||||||
|
static Future<VoiceCodecKind> getCodec() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final raw = prefs.getString(_codecKey);
|
||||||
|
return VoiceCodecKind.values.firstWhere(
|
||||||
|
(codec) => codec.name == raw,
|
||||||
|
orElse: () => defaultCodec,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> setCodec(VoiceCodecKind codec) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setString(_codecKey, codec.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,18 +3,22 @@ import 'package:codec2_flutter/codec2_flutter.dart';
|
|||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import '../utils/voice_message_parser.dart';
|
import '../utils/voice_message_parser.dart';
|
||||||
|
|
||||||
export 'package:codec2_flutter/codec2_flutter.dart' show Codec2Mode;
|
|
||||||
|
|
||||||
/// Maps [VoicePacketMode] to the [Codec2Mode] enum from the FFI plugin.
|
|
||||||
Codec2Mode codec2ModeFor(VoicePacketMode pktMode) {
|
Codec2Mode codec2ModeFor(VoicePacketMode pktMode) {
|
||||||
switch (pktMode) {
|
switch (pktMode) {
|
||||||
case VoicePacketMode.mode3200: return Codec2Mode.mode3200;
|
case VoicePacketMode.mode3200:
|
||||||
case VoicePacketMode.mode1600: return Codec2Mode.mode1600;
|
return Codec2Mode.mode3200;
|
||||||
case VoicePacketMode.mode1400: return Codec2Mode.mode1400;
|
case VoicePacketMode.mode1600:
|
||||||
case VoicePacketMode.mode700c: return Codec2Mode.mode700c;
|
return Codec2Mode.mode1600;
|
||||||
case VoicePacketMode.mode1200: return Codec2Mode.mode1200;
|
case VoicePacketMode.mode1400:
|
||||||
case VoicePacketMode.mode1300: return Codec2Mode.mode1300;
|
return Codec2Mode.mode1400;
|
||||||
case VoicePacketMode.mode2400: return Codec2Mode.mode2400;
|
case VoicePacketMode.mode700c:
|
||||||
|
return Codec2Mode.mode700c;
|
||||||
|
case VoicePacketMode.mode1200:
|
||||||
|
return Codec2Mode.mode1200;
|
||||||
|
case VoicePacketMode.mode1300:
|
||||||
|
return Codec2Mode.mode1300;
|
||||||
|
case VoicePacketMode.mode2400:
|
||||||
|
return Codec2Mode.mode2400;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,14 +43,11 @@ class VoiceCodecService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Encode [pcm] (Int16 samples, 8000 Hz mono) with [mode].
|
|
||||||
/// Returns the raw Codec2-encoded bytes.
|
|
||||||
Future<Uint8List> encode(Int16List pcm, VoicePacketMode mode) {
|
Future<Uint8List> encode(Int16List pcm, VoicePacketMode mode) {
|
||||||
_ensureCodec2Supported();
|
_ensureCodec2Supported();
|
||||||
return Codec2.encodeInIsolate(pcm, codec2ModeFor(mode));
|
return Codec2.encodeInIsolate(pcm, codec2ModeFor(mode));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Decode [codec2Bytes] back to Int16 PCM (8000 Hz mono) with [mode].
|
|
||||||
Future<Int16List> decode(Uint8List codec2Bytes, VoicePacketMode mode) {
|
Future<Int16List> decode(Uint8List codec2Bytes, VoicePacketMode mode) {
|
||||||
_ensureCodec2Supported();
|
_ensureCodec2Supported();
|
||||||
return Codec2.decodeInIsolate(codec2Bytes, codec2ModeFor(mode));
|
return Codec2.decodeInIsolate(codec2Bytes, codec2ModeFor(mode));
|
||||||
@@ -59,24 +60,14 @@ class VoiceCodecService {
|
|||||||
VoicePacketMode mode,
|
VoicePacketMode mode,
|
||||||
) async {
|
) async {
|
||||||
_ensureCodec2Supported();
|
_ensureCodec2Supported();
|
||||||
final c2Mode = codec2ModeFor(mode);
|
|
||||||
final c2 = Codec2.create(c2Mode);
|
|
||||||
final spf = c2.samplesPerFrame;
|
|
||||||
c2.destroy();
|
|
||||||
|
|
||||||
// Estimate total samples (use actual data or silence per missing packet)
|
|
||||||
final all = <Int16List>[];
|
final all = <Int16List>[];
|
||||||
for (final pkt in packets) {
|
for (final pkt in packets) {
|
||||||
if (pkt == null || pkt.codec2Data.isEmpty) {
|
if (pkt == null || pkt.codec2Data.isEmpty) {
|
||||||
// Silence for missing packet — duration approximated by mode
|
all.add(Int16List(mode.samplesPerPacket));
|
||||||
final silenceSamples = (codec2ModeFor(mode).framesPerSecond) * spf;
|
|
||||||
all.add(Int16List(silenceSamples));
|
|
||||||
} else {
|
} else {
|
||||||
final decoded = await Codec2.decodeInIsolate(pkt.codec2Data, c2Mode);
|
all.add(await decode(pkt.codec2Data, mode));
|
||||||
all.add(decoded);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final total = all.fold<int>(0, (sum, l) => sum + l.length);
|
final total = all.fold<int>(0, (sum, l) => sum + l.length);
|
||||||
final result = Int16List(total);
|
final result = Int16List(total);
|
||||||
var offset = 0;
|
var offset = 0;
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
import '../utils/voice_message_parser.dart';
|
import '../utils/voice_message_parser.dart';
|
||||||
|
|
||||||
/// Web/unsupported platform stub — Codec2 FFI is not available.
|
|
||||||
enum Codec2Mode {
|
enum Codec2Mode {
|
||||||
mode3200(0),
|
mode3200(0),
|
||||||
mode2400(1),
|
mode2400(1),
|
||||||
@@ -19,13 +18,20 @@ enum Codec2Mode {
|
|||||||
|
|
||||||
int get bytesPerSecond {
|
int get bytesPerSecond {
|
||||||
switch (this) {
|
switch (this) {
|
||||||
case mode3200: return 400;
|
case mode3200:
|
||||||
case mode700c: return 100;
|
return 400;
|
||||||
case mode1200: return 150;
|
case mode700c:
|
||||||
case mode1300: return 175;
|
return 100;
|
||||||
case mode1400: return 175;
|
case mode1200:
|
||||||
case mode1600: return 200;
|
return 150;
|
||||||
case mode2400: return 300;
|
case mode1300:
|
||||||
|
return 175;
|
||||||
|
case mode1400:
|
||||||
|
return 175;
|
||||||
|
case mode1600:
|
||||||
|
return 200;
|
||||||
|
case mode2400:
|
||||||
|
return 300;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,13 +46,20 @@ enum Codec2Mode {
|
|||||||
|
|
||||||
Codec2Mode codec2ModeFor(VoicePacketMode pktMode) {
|
Codec2Mode codec2ModeFor(VoicePacketMode pktMode) {
|
||||||
switch (pktMode) {
|
switch (pktMode) {
|
||||||
case VoicePacketMode.mode3200: return Codec2Mode.mode3200;
|
case VoicePacketMode.mode3200:
|
||||||
case VoicePacketMode.mode1600: return Codec2Mode.mode1600;
|
return Codec2Mode.mode3200;
|
||||||
case VoicePacketMode.mode1400: return Codec2Mode.mode1400;
|
case VoicePacketMode.mode1600:
|
||||||
case VoicePacketMode.mode700c: return Codec2Mode.mode700c;
|
return Codec2Mode.mode1600;
|
||||||
case VoicePacketMode.mode1200: return Codec2Mode.mode1200;
|
case VoicePacketMode.mode1400:
|
||||||
case VoicePacketMode.mode1300: return Codec2Mode.mode1300;
|
return Codec2Mode.mode1400;
|
||||||
case VoicePacketMode.mode2400: return Codec2Mode.mode2400;
|
case VoicePacketMode.mode700c:
|
||||||
|
return Codec2Mode.mode700c;
|
||||||
|
case VoicePacketMode.mode1200:
|
||||||
|
return Codec2Mode.mode1200;
|
||||||
|
case VoicePacketMode.mode1300:
|
||||||
|
return Codec2Mode.mode1300;
|
||||||
|
case VoicePacketMode.mode2400:
|
||||||
|
return Codec2Mode.mode2400;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,6 +79,5 @@ class VoiceCodecService {
|
|||||||
Future<Int16List> decodePackets(
|
Future<Int16List> decodePackets(
|
||||||
List<VoicePacket?> packets,
|
List<VoicePacket?> packets,
|
||||||
VoicePacketMode mode,
|
VoicePacketMode mode,
|
||||||
) =>
|
) => Future.error(UnsupportedError('Voice not supported on web'));
|
||||||
Future.error(UnsupportedError('Voice not supported on web'));
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import 'package:audioplayers/audioplayers.dart';
|
|||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
|
||||||
/// Plays decoded 8000 Hz / 16-bit mono PCM samples by writing a WAV file
|
/// Plays decoded mono PCM samples by writing a WAV file
|
||||||
/// to the system temp directory and using [AudioPlayer].
|
/// to the system temp directory and using [AudioPlayer].
|
||||||
class VoicePlayerService {
|
class VoicePlayerService {
|
||||||
final AudioPlayer _player = AudioPlayer();
|
final AudioPlayer _player = AudioPlayer();
|
||||||
@@ -49,16 +49,17 @@ class VoicePlayerService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Play [pcmSamples] (Int16, 8000 Hz, mono).
|
Future<void> play(Int16List pcmSamples, {required int sampleRateHz}) async {
|
||||||
Future<void> play(Int16List pcmSamples) async {
|
|
||||||
debugPrint('🔊 [VoicePlayer] play() called, ${pcmSamples.length} samples');
|
debugPrint('🔊 [VoicePlayer] play() called, ${pcmSamples.length} samples');
|
||||||
if (_isPlaying) await stop();
|
if (_isPlaying) await stop();
|
||||||
_position = Duration.zero;
|
_position = Duration.zero;
|
||||||
_duration = Duration(milliseconds: (pcmSamples.length * 1000) ~/ 8000);
|
_duration = Duration(
|
||||||
|
milliseconds: (pcmSamples.length * 1000) ~/ sampleRateHz,
|
||||||
|
);
|
||||||
_playbackStartedAt = DateTime.now();
|
_playbackStartedAt = DateTime.now();
|
||||||
_events.add(null);
|
_events.add(null);
|
||||||
|
|
||||||
final wavBytes = _buildWav(pcmSamples, sampleRate: 8000);
|
final wavBytes = _buildWav(pcmSamples, sampleRate: sampleRateHz);
|
||||||
final tmpDir = await getTemporaryDirectory();
|
final tmpDir = await getTemporaryDirectory();
|
||||||
final file = File('${tmpDir.path}/vc_voice.wav');
|
final file = File('${tmpDir.path}/vc_voice.wav');
|
||||||
await file.writeAsBytes(wavBytes);
|
await file.writeAsBytes(wavBytes);
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import 'dart:typed_data';
|
|||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:record/record.dart';
|
import 'package:record/record.dart';
|
||||||
|
|
||||||
/// Captures raw PCM audio at 8000 Hz, 16-bit mono.
|
/// Captures raw PCM audio at a codec-selected sample rate, 16-bit mono.
|
||||||
///
|
///
|
||||||
/// [startCapture] returns a [Stream<Int16List>] that emits chunks of PCM
|
/// [startCapture] returns a [Stream<Int16List>] that emits chunks of PCM
|
||||||
/// samples every [chunkDuration]. Call [stopCapture] to end recording.
|
/// samples every [chunkDuration]. Call [stopCapture] to end recording.
|
||||||
@@ -27,12 +27,16 @@ class VoiceRecorderService {
|
|||||||
/// [enableBandPassFilter] applies voice-tuned band-pass filtering when true.
|
/// [enableBandPassFilter] applies voice-tuned band-pass filtering when true.
|
||||||
/// [enableCompressor] normalizes speech dynamics before encoding.
|
/// [enableCompressor] normalizes speech dynamics before encoding.
|
||||||
/// [enableLimiter] protects against clipping peaks before encoding.
|
/// [enableLimiter] protects against clipping peaks before encoding.
|
||||||
/// The returned stream emits [Int16List] chunks that are ready for Codec2 encoding.
|
/// The returned stream emits [Int16List] chunks that are ready for voice encoding.
|
||||||
Stream<Int16List> startCapture({
|
Stream<Int16List> startCapture({
|
||||||
Duration chunkDuration = const Duration(seconds: 1),
|
Duration chunkDuration = const Duration(seconds: 1),
|
||||||
|
int sampleRateHz = 8000,
|
||||||
bool enableBandPassFilter = true,
|
bool enableBandPassFilter = true,
|
||||||
bool enableCompressor = true,
|
bool enableCompressor = true,
|
||||||
bool enableLimiter = true,
|
bool enableLimiter = true,
|
||||||
|
bool enableAutoGain = false,
|
||||||
|
bool enableEchoCancellation = false,
|
||||||
|
bool enableNoiseSuppression = false,
|
||||||
}) {
|
}) {
|
||||||
if (_isRecording) {
|
if (_isRecording) {
|
||||||
throw StateError('VoiceRecorderService: already recording');
|
throw StateError('VoiceRecorderService: already recording');
|
||||||
@@ -43,39 +47,59 @@ class VoiceRecorderService {
|
|||||||
|
|
||||||
_startRecording(
|
_startRecording(
|
||||||
chunkDuration,
|
chunkDuration,
|
||||||
|
sampleRateHz: sampleRateHz,
|
||||||
enableBandPassFilter: enableBandPassFilter,
|
enableBandPassFilter: enableBandPassFilter,
|
||||||
enableCompressor: enableCompressor,
|
enableCompressor: enableCompressor,
|
||||||
enableLimiter: enableLimiter,
|
enableLimiter: enableLimiter,
|
||||||
|
enableAutoGain: enableAutoGain,
|
||||||
|
enableEchoCancellation: enableEchoCancellation,
|
||||||
|
enableNoiseSuppression: enableNoiseSuppression,
|
||||||
);
|
);
|
||||||
return _controller!.stream;
|
return _controller!.stream;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _startRecording(
|
Future<void> _startRecording(
|
||||||
Duration chunkDuration, {
|
Duration chunkDuration, {
|
||||||
|
required int sampleRateHz,
|
||||||
required bool enableBandPassFilter,
|
required bool enableBandPassFilter,
|
||||||
required bool enableCompressor,
|
required bool enableCompressor,
|
||||||
required bool enableLimiter,
|
required bool enableLimiter,
|
||||||
|
required bool enableAutoGain,
|
||||||
|
required bool enableEchoCancellation,
|
||||||
|
required bool enableNoiseSuppression,
|
||||||
}) async {
|
}) async {
|
||||||
final config = const RecordConfig(
|
final useBandPassFilter = enableBandPassFilter;
|
||||||
|
final useCompressor = enableCompressor;
|
||||||
|
final useLimiter = enableLimiter;
|
||||||
|
final config = RecordConfig(
|
||||||
encoder: AudioEncoder.pcm16bits,
|
encoder: AudioEncoder.pcm16bits,
|
||||||
sampleRate: 8000,
|
sampleRate: sampleRateHz,
|
||||||
numChannels: 1,
|
numChannels: 1,
|
||||||
bitRate: 128000, // ignored for PCM, but required by API
|
bitRate: 128000, // ignored for PCM, but required by API
|
||||||
|
autoGain: enableAutoGain,
|
||||||
|
echoCancel: enableEchoCancellation,
|
||||||
|
noiseSuppress: enableNoiseSuppression,
|
||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final stream = await _recorder.startStream(config);
|
final stream = await _recorder.startStream(config);
|
||||||
final voiceFilter = _VoiceBandPassFilter(
|
final voiceFilter = _VoiceBandPassFilter(
|
||||||
sampleRate: 8000,
|
sampleRate: sampleRateHz,
|
||||||
lowCutHz: 250.0,
|
lowCutHz: 250.0,
|
||||||
highCutHz: 3400.0,
|
highCutHz: 3400.0,
|
||||||
);
|
);
|
||||||
final dynamics = _VoiceDynamicsProcessor(
|
final dynamics = _VoiceDynamicsProcessor(
|
||||||
sampleRate: 8000,
|
sampleRate: sampleRateHz,
|
||||||
enableCompressor: enableCompressor,
|
thresholdDb: -18.0,
|
||||||
enableLimiter: enableLimiter,
|
ratio: 2.5,
|
||||||
|
attackMs: 8.0,
|
||||||
|
releaseMs: 120.0,
|
||||||
|
makeupGainDb: 4.0,
|
||||||
|
enableCompressor: useCompressor,
|
||||||
|
enableLimiter: useLimiter,
|
||||||
);
|
);
|
||||||
final chunkBytes = 8000 * 2 * chunkDuration.inMilliseconds ~/ 1000;
|
final chunkBytes =
|
||||||
|
sampleRateHz * 2 * chunkDuration.inMilliseconds ~/ 1000;
|
||||||
final buffer = <int>[];
|
final buffer = <int>[];
|
||||||
|
|
||||||
_sub = stream.listen(
|
_sub = stream.listen(
|
||||||
@@ -85,9 +109,7 @@ class VoiceRecorderService {
|
|||||||
final chunk = buffer.sublist(0, chunkBytes);
|
final chunk = buffer.sublist(0, chunkBytes);
|
||||||
buffer.removeRange(0, chunkBytes);
|
buffer.removeRange(0, chunkBytes);
|
||||||
final pcm = _bytesToInt16(Uint8List.fromList(chunk));
|
final pcm = _bytesToInt16(Uint8List.fromList(chunk));
|
||||||
final filtered = enableBandPassFilter
|
final filtered = useBandPassFilter ? voiceFilter.process(pcm) : pcm;
|
||||||
? voiceFilter.process(pcm)
|
|
||||||
: pcm;
|
|
||||||
_controller?.add(dynamics.process(filtered));
|
_controller?.add(dynamics.process(filtered));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -95,9 +117,7 @@ class VoiceRecorderService {
|
|||||||
if (buffer.isNotEmpty) {
|
if (buffer.isNotEmpty) {
|
||||||
final padded = _padToEven(buffer);
|
final padded = _padToEven(buffer);
|
||||||
final pcm = _bytesToInt16(Uint8List.fromList(padded));
|
final pcm = _bytesToInt16(Uint8List.fromList(padded));
|
||||||
final filtered = enableBandPassFilter
|
final filtered = useBandPassFilter ? voiceFilter.process(pcm) : pcm;
|
||||||
? voiceFilter.process(pcm)
|
|
||||||
: pcm;
|
|
||||||
_controller?.add(dynamics.process(filtered));
|
_controller?.add(dynamics.process(filtered));
|
||||||
}
|
}
|
||||||
_controller?.close();
|
_controller?.close();
|
||||||
@@ -163,17 +183,22 @@ class _VoiceDynamicsProcessor {
|
|||||||
|
|
||||||
_VoiceDynamicsProcessor({
|
_VoiceDynamicsProcessor({
|
||||||
required int sampleRate,
|
required int sampleRate,
|
||||||
|
required double thresholdDb,
|
||||||
|
required double ratio,
|
||||||
|
required double attackMs,
|
||||||
|
required double releaseMs,
|
||||||
|
required double makeupGainDb,
|
||||||
required bool enableCompressor,
|
required bool enableCompressor,
|
||||||
required bool enableLimiter,
|
required bool enableLimiter,
|
||||||
}) : _enableCompressor = enableCompressor,
|
}) : _enableCompressor = enableCompressor,
|
||||||
_enableLimiter = enableLimiter,
|
_enableLimiter = enableLimiter,
|
||||||
_compressor = _SimpleCompressor(
|
_compressor = _SimpleCompressor(
|
||||||
sampleRate: sampleRate.toDouble(),
|
sampleRate: sampleRate.toDouble(),
|
||||||
thresholdDb: -18.0,
|
thresholdDb: thresholdDb,
|
||||||
ratio: 2.5,
|
ratio: ratio,
|
||||||
attackMs: 8.0,
|
attackMs: attackMs,
|
||||||
releaseMs: 120.0,
|
releaseMs: releaseMs,
|
||||||
makeupGainDb: 4.0,
|
makeupGainDb: makeupGainDb,
|
||||||
),
|
),
|
||||||
_limiter = _PeakLimiter(ceilingDb: -1.0);
|
_limiter = _PeakLimiter(ceilingDb: -1.0);
|
||||||
|
|
||||||
|
|||||||
@@ -4,54 +4,98 @@ class InferredContactGroup {
|
|||||||
final String key;
|
final String key;
|
||||||
final String label;
|
final String label;
|
||||||
final List<Contact> contacts;
|
final List<Contact> contacts;
|
||||||
|
final List<String> matchPrefixes;
|
||||||
|
|
||||||
const InferredContactGroup({
|
const InferredContactGroup({
|
||||||
required this.key,
|
required this.key,
|
||||||
required this.label,
|
required this.label,
|
||||||
required this.contacts,
|
required this.contacts,
|
||||||
});
|
List<String>? matchPrefixes,
|
||||||
|
}) : matchPrefixes = matchPrefixes ?? const <String>[];
|
||||||
|
|
||||||
DateTime get latestSeen => contacts.first.lastSeenTime;
|
DateTime get latestSeen => contacts.first.lastSeenTime;
|
||||||
}
|
}
|
||||||
|
|
||||||
class ContactListItem {
|
|
||||||
final Contact? contact;
|
|
||||||
final InferredContactGroup? group;
|
|
||||||
|
|
||||||
const ContactListItem._({this.contact, this.group});
|
|
||||||
|
|
||||||
const ContactListItem.contact(Contact contact) : this._(contact: contact);
|
|
||||||
|
|
||||||
const ContactListItem.group(InferredContactGroup group)
|
|
||||||
: this._(group: group);
|
|
||||||
|
|
||||||
bool get isGroup => group != null;
|
|
||||||
|
|
||||||
DateTime get latestSeen => group?.latestSeen ?? contact!.lastSeenTime;
|
|
||||||
}
|
|
||||||
|
|
||||||
class ContactGrouping {
|
class ContactGrouping {
|
||||||
static final RegExp _prefixedNamePattern = RegExp(
|
static final RegExp _prefixedNamePattern = RegExp(
|
||||||
r'^([A-Za-z0-9]{2,})([-_/:])',
|
r'^([A-Za-z0-9]{2,})([-_/:])',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
static String? inferredGroupLabelForContact(Contact contact) {
|
||||||
|
return _extractPrefix(contact.displayName)?.label;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool contactMatchesInferredGroupLabel(Contact contact, String query) {
|
||||||
|
final normalizedQuery = query.trim().toLowerCase();
|
||||||
|
if (normalizedQuery.isEmpty) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
final groupLabel = inferredGroupLabelForContact(contact)?.toLowerCase();
|
||||||
|
return groupLabel?.contains(normalizedQuery) ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
static String? sharedInferredGroupLabel(List<Contact> contacts) {
|
||||||
|
String? sharedLabel;
|
||||||
|
for (final contact in contacts) {
|
||||||
|
final label = inferredGroupLabelForContact(contact);
|
||||||
|
if (label == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (sharedLabel == null) {
|
||||||
|
sharedLabel = label;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (sharedLabel != label) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sharedLabel;
|
||||||
|
}
|
||||||
|
|
||||||
|
static String? sharedParentGroupLabel(List<String> labels) {
|
||||||
|
if (labels.isEmpty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var commonPrefix = labels.first;
|
||||||
|
for (final label in labels.skip(1)) {
|
||||||
|
final maxLength = commonPrefix.length < label.length
|
||||||
|
? commonPrefix.length
|
||||||
|
: label.length;
|
||||||
|
var matchLength = 0;
|
||||||
|
while (matchLength < maxLength &&
|
||||||
|
commonPrefix.codeUnitAt(matchLength) ==
|
||||||
|
label.codeUnitAt(matchLength)) {
|
||||||
|
matchLength++;
|
||||||
|
}
|
||||||
|
commonPrefix = commonPrefix.substring(0, matchLength);
|
||||||
|
if (commonPrefix.isEmpty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final separatorIndex = commonPrefix.lastIndexOf(RegExp(r'[-_/:]'));
|
||||||
|
if (separatorIndex < 1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final parentLabel = commonPrefix.substring(0, separatorIndex + 1);
|
||||||
|
return parentLabel.length >= 3 ? parentLabel : null;
|
||||||
|
}
|
||||||
|
|
||||||
static List<Contact> sortByLastSeen(List<Contact> contacts) {
|
static List<Contact> sortByLastSeen(List<Contact> contacts) {
|
||||||
return List<Contact>.from(contacts)
|
return List<Contact>.from(contacts)
|
||||||
..sort((a, b) => b.lastSeenTime.compareTo(a.lastSeenTime));
|
..sort((a, b) => b.lastSeenTime.compareTo(a.lastSeenTime));
|
||||||
}
|
}
|
||||||
|
|
||||||
static List<ContactListItem> buildItems(
|
static List<InferredContactGroup> inferGroups(
|
||||||
List<Contact> contacts, {
|
List<Contact> contacts, {
|
||||||
int minGroupSize = 4,
|
int minGroupSize = 4,
|
||||||
|
int? maxNamedGroups,
|
||||||
|
String? overflowGroupLabel,
|
||||||
}) {
|
}) {
|
||||||
final sortedContacts = sortByLastSeen(contacts);
|
final sortedContacts = sortByLastSeen(contacts);
|
||||||
return buildItemsFromSorted(sortedContacts, minGroupSize: minGroupSize);
|
|
||||||
}
|
|
||||||
|
|
||||||
static List<ContactListItem> buildItemsFromSorted(
|
|
||||||
List<Contact> sortedContacts, {
|
|
||||||
int minGroupSize = 4,
|
|
||||||
}) {
|
|
||||||
final groupedContacts = <String, List<Contact>>{};
|
final groupedContacts = <String, List<Contact>>{};
|
||||||
final groupLabels = <String, String>{};
|
final groupLabels = <String, String>{};
|
||||||
|
|
||||||
@@ -62,32 +106,46 @@ class ContactGrouping {
|
|||||||
groupLabels.putIfAbsent(prefix.key, () => prefix.label);
|
groupLabels.putIfAbsent(prefix.key, () => prefix.label);
|
||||||
}
|
}
|
||||||
|
|
||||||
final eligibleGroups = <String, InferredContactGroup>{};
|
final rankedGroups = <InferredContactGroup>[];
|
||||||
for (final entry in groupedContacts.entries) {
|
for (final entry in groupedContacts.entries) {
|
||||||
if (entry.value.length < minGroupSize) continue;
|
if (entry.value.length < minGroupSize) continue;
|
||||||
eligibleGroups[entry.key] = InferredContactGroup(
|
rankedGroups.add(
|
||||||
key: entry.key,
|
InferredContactGroup(
|
||||||
label: groupLabels[entry.key] ?? entry.key,
|
key: entry.key,
|
||||||
contacts: entry.value,
|
label: groupLabels[entry.key] ?? entry.key,
|
||||||
|
contacts: entry.value,
|
||||||
|
matchPrefixes: [groupLabels[entry.key] ?? entry.key],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
rankedGroups.sort((a, b) => b.latestSeen.compareTo(a.latestSeen));
|
||||||
|
|
||||||
final emittedGroups = <String>{};
|
if (maxNamedGroups != null &&
|
||||||
final items = <ContactListItem>[];
|
overflowGroupLabel != null &&
|
||||||
|
rankedGroups.length > maxNamedGroups) {
|
||||||
|
final retainedGroups = rankedGroups.take(maxNamedGroups).toList();
|
||||||
|
final overflowGroups = rankedGroups.skip(maxNamedGroups).toList();
|
||||||
|
final parentLabel = sharedParentGroupLabel([
|
||||||
|
for (final group in overflowGroups) ...group.matchPrefixes,
|
||||||
|
]);
|
||||||
|
if (parentLabel != null) {
|
||||||
|
final overflowContacts = overflowGroups
|
||||||
|
.expand((group) => group.contacts)
|
||||||
|
.toList();
|
||||||
|
return [
|
||||||
|
...retainedGroups,
|
||||||
|
InferredContactGroup(
|
||||||
|
key: parentLabel,
|
||||||
|
label: parentLabel,
|
||||||
|
contacts: sortByLastSeen(overflowContacts),
|
||||||
|
matchPrefixes: [parentLabel],
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
for (final contact in sortedContacts) {
|
return rankedGroups;
|
||||||
final prefix = _extractPrefix(contact.displayName);
|
|
||||||
final group = prefix == null ? null : eligibleGroups[prefix.key];
|
|
||||||
if (group == null) {
|
|
||||||
items.add(ContactListItem.contact(contact));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (emittedGroups.add(group.key)) {
|
|
||||||
items.add(ContactListItem.group(group));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
return rankedGroups;
|
||||||
return items;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static _GroupPrefix? _extractPrefix(String name) {
|
static _GroupPrefix? _extractPrefix(String name) {
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ class ResolvedNodeHash {
|
|||||||
final bool isOwnNode;
|
final bool isOwnNode;
|
||||||
final bool isUniqueMatch;
|
final bool isUniqueMatch;
|
||||||
final int matchCount;
|
final int matchCount;
|
||||||
|
final double? latitude;
|
||||||
|
final double? longitude;
|
||||||
|
|
||||||
const ResolvedNodeHash({
|
const ResolvedNodeHash({
|
||||||
required this.hashHex,
|
required this.hashHex,
|
||||||
@@ -37,6 +39,8 @@ class ResolvedNodeHash {
|
|||||||
required this.isOwnNode,
|
required this.isOwnNode,
|
||||||
required this.isUniqueMatch,
|
required this.isUniqueMatch,
|
||||||
required this.matchCount,
|
required this.matchCount,
|
||||||
|
this.latitude,
|
||||||
|
this.longitude,
|
||||||
});
|
});
|
||||||
|
|
||||||
String get hexLabel => '0x${hashHex.toUpperCase()}';
|
String get hexLabel => '0x${hashHex.toUpperCase()}';
|
||||||
@@ -154,11 +158,33 @@ class LogRxRouteDecoder {
|
|||||||
return hops;
|
return hops;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static List<int> reverseHopBytes(
|
||||||
|
List<int> pathBytes, {
|
||||||
|
required int hashSize,
|
||||||
|
}) {
|
||||||
|
if (pathBytes.isEmpty) return const [];
|
||||||
|
if (hashSize < 1 || hashSize > 3 || pathBytes.length % hashSize != 0) {
|
||||||
|
return List<int>.from(pathBytes.reversed);
|
||||||
|
}
|
||||||
|
|
||||||
|
final reversed = <int>[];
|
||||||
|
for (
|
||||||
|
var index = pathBytes.length - hashSize;
|
||||||
|
index >= 0;
|
||||||
|
index -= hashSize
|
||||||
|
) {
|
||||||
|
reversed.addAll(pathBytes.sublist(index, index + hashSize));
|
||||||
|
}
|
||||||
|
return reversed;
|
||||||
|
}
|
||||||
|
|
||||||
static ResolvedNodeHash resolveHash(
|
static ResolvedNodeHash resolveHash(
|
||||||
String hashHex, {
|
String hashHex, {
|
||||||
required Iterable<Contact> contacts,
|
required Iterable<Contact> contacts,
|
||||||
Uint8List? ownPublicKey,
|
Uint8List? ownPublicKey,
|
||||||
String? ownName,
|
String? ownName,
|
||||||
|
double? ownLatitude,
|
||||||
|
double? ownLongitude,
|
||||||
}) {
|
}) {
|
||||||
final normalizedHashHex = hashHex.toLowerCase();
|
final normalizedHashHex = hashHex.toLowerCase();
|
||||||
final ownKeyHex = _bytesToHex(ownPublicKey);
|
final ownKeyHex = _bytesToHex(ownPublicKey);
|
||||||
@@ -172,6 +198,8 @@ class LogRxRouteDecoder {
|
|||||||
isOwnNode: true,
|
isOwnNode: true,
|
||||||
isUniqueMatch: true,
|
isUniqueMatch: true,
|
||||||
matchCount: 1,
|
matchCount: 1,
|
||||||
|
latitude: ownLatitude,
|
||||||
|
longitude: ownLongitude,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,12 +218,15 @@ class LogRxRouteDecoder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (matches.length == 1) {
|
if (matches.length == 1) {
|
||||||
|
final location = matches.first.displayLocation;
|
||||||
return ResolvedNodeHash(
|
return ResolvedNodeHash(
|
||||||
hashHex: normalizedHashHex,
|
hashHex: normalizedHashHex,
|
||||||
label: matches.first.displayName,
|
label: matches.first.displayName,
|
||||||
isOwnNode: false,
|
isOwnNode: false,
|
||||||
isUniqueMatch: true,
|
isUniqueMatch: true,
|
||||||
matchCount: 1,
|
matchCount: 1,
|
||||||
|
latitude: location?.latitude,
|
||||||
|
longitude: location?.longitude,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,14 +9,16 @@ extension MessageLocalization on Message {
|
|||||||
/// Get localized delivery status text
|
/// Get localized delivery status text
|
||||||
String getLocalizedDeliveryStatus(BuildContext context) {
|
String getLocalizedDeliveryStatus(BuildContext context) {
|
||||||
final l10n = AppLocalizations.of(context)!;
|
final l10n = AppLocalizations.of(context)!;
|
||||||
final routeMetadata = context
|
final messagesProvider = context.read<MessagesProvider>();
|
||||||
.read<MessagesProvider>()
|
final routeMetadata = messagesProvider.getMessageRouteMetadata(id);
|
||||||
.getMessageRouteMetadata(id);
|
|
||||||
|
|
||||||
// For channel messages, show echo count instead of delivery status
|
// For channel messages, show echo count instead of delivery status
|
||||||
if (isChannelMessage && deliveryStatus == MessageDeliveryStatus.sent) {
|
if (isChannelMessage && deliveryStatus == MessageDeliveryStatus.sent) {
|
||||||
final latestMeta = _formatEchoMeta(context);
|
final latestMeta = _formatEchoMeta(context);
|
||||||
if (echoCount == 0) {
|
if (echoCount == 0) {
|
||||||
|
if (messagesProvider.hasChannelSendWarning(id)) {
|
||||||
|
return 'Broadcast may have failed';
|
||||||
|
}
|
||||||
return l10n.broadcast; // "Broadcast (no echoes yet)"
|
return l10n.broadcast; // "Broadcast (no echoes yet)"
|
||||||
} else if (echoCount == 1) {
|
} else if (echoCount == 1) {
|
||||||
return latestMeta == null ? '1 node' : '1 node • $latestMeta';
|
return latestMeta == null ? '1 node' : '1 node • $latestMeta';
|
||||||
|
|||||||
239
lib/utils/trace_node_resolver.dart
Normal file
239
lib/utils/trace_node_resolver.dart
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
import 'package:latlong2/latlong.dart';
|
||||||
|
|
||||||
|
import '../services/mesh_map_nodes_service.dart';
|
||||||
|
|
||||||
|
class ResolvedTraceNode {
|
||||||
|
final List<MeshMapNode> candidates;
|
||||||
|
final int matchCount;
|
||||||
|
final bool usedOnlineFallback;
|
||||||
|
final int selectedIndex;
|
||||||
|
|
||||||
|
const ResolvedTraceNode({
|
||||||
|
required this.candidates,
|
||||||
|
required this.matchCount,
|
||||||
|
required this.usedOnlineFallback,
|
||||||
|
this.selectedIndex = 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
MeshMapNode? get node =>
|
||||||
|
candidates.isEmpty ? null : candidates[selectedIndex];
|
||||||
|
bool get hasMatch => node != null;
|
||||||
|
bool get isAmbiguous => matchCount > 1;
|
||||||
|
bool get canCycle => candidates.length > 1;
|
||||||
|
|
||||||
|
String? get matchSummary {
|
||||||
|
if (matchCount <= 1) return null;
|
||||||
|
final source = usedOnlineFallback ? 'online' : 'local';
|
||||||
|
return '$matchCount $source matches';
|
||||||
|
}
|
||||||
|
|
||||||
|
String? get cycleSummary =>
|
||||||
|
canCycle ? 'tap to cycle ${selectedIndex + 1}/$matchCount' : null;
|
||||||
|
|
||||||
|
ResolvedTraceNode cycle() {
|
||||||
|
if (!canCycle) return this;
|
||||||
|
return ResolvedTraceNode(
|
||||||
|
candidates: candidates,
|
||||||
|
matchCount: matchCount,
|
||||||
|
usedOnlineFallback: usedOnlineFallback,
|
||||||
|
selectedIndex: (selectedIndex + 1) % candidates.length,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class TraceNodeResolver {
|
||||||
|
static const Distance _distance = Distance();
|
||||||
|
|
||||||
|
const TraceNodeResolver._();
|
||||||
|
|
||||||
|
static ResolvedTraceNode resolveBest({
|
||||||
|
required List<MeshMapNode> nodes,
|
||||||
|
required Set<String> localPublicKeys,
|
||||||
|
required String? prefixHex,
|
||||||
|
LatLng? referenceA,
|
||||||
|
LatLng? referenceB,
|
||||||
|
String? preferredPrefix,
|
||||||
|
}) {
|
||||||
|
if (prefixHex == null || prefixHex.isEmpty) {
|
||||||
|
return const ResolvedTraceNode(
|
||||||
|
candidates: <MeshMapNode>[],
|
||||||
|
matchCount: 0,
|
||||||
|
usedOnlineFallback: false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final allMatches = nodes
|
||||||
|
.where((n) => n.publicKey.startsWith(prefixHex))
|
||||||
|
.toList();
|
||||||
|
if (allMatches.isEmpty) {
|
||||||
|
return const ResolvedTraceNode(
|
||||||
|
candidates: <MeshMapNode>[],
|
||||||
|
matchCount: 0,
|
||||||
|
usedOnlineFallback: false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final localMatches = allMatches
|
||||||
|
.where((node) => localPublicKeys.contains(node.publicKey))
|
||||||
|
.toList();
|
||||||
|
var pool = localMatches.isNotEmpty ? localMatches : allMatches;
|
||||||
|
final usedOnlineFallback = localMatches.isEmpty;
|
||||||
|
|
||||||
|
if (preferredPrefix != null && preferredPrefix.isNotEmpty) {
|
||||||
|
final preferredMatches = pool
|
||||||
|
.where((node) => node.publicKey.startsWith(preferredPrefix))
|
||||||
|
.toList();
|
||||||
|
if (preferredMatches.isNotEmpty) {
|
||||||
|
pool = preferredMatches;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pool.sort((a, b) {
|
||||||
|
final distanceCompare =
|
||||||
|
_scoreNode(
|
||||||
|
a,
|
||||||
|
referenceA: referenceA,
|
||||||
|
referenceB: referenceB,
|
||||||
|
).compareTo(
|
||||||
|
_scoreNode(b, referenceA: referenceA, referenceB: referenceB),
|
||||||
|
);
|
||||||
|
if (distanceCompare != 0) return distanceCompare;
|
||||||
|
return b.updatedAtMs.compareTo(a.updatedAtMs);
|
||||||
|
});
|
||||||
|
|
||||||
|
return ResolvedTraceNode(
|
||||||
|
candidates: List<MeshMapNode>.unmodifiable(pool),
|
||||||
|
matchCount: pool.length,
|
||||||
|
usedOnlineFallback: usedOnlineFallback,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<ResolvedTraceNode> alignPathSelections({
|
||||||
|
required List<ResolvedTraceNode> nodes,
|
||||||
|
MeshMapNode? startNode,
|
||||||
|
MeshMapNode? endNode,
|
||||||
|
}) {
|
||||||
|
if (nodes.isEmpty || nodes.any((node) => node.candidates.isEmpty)) {
|
||||||
|
return nodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
final candidateCosts = List.generate(
|
||||||
|
nodes.length,
|
||||||
|
(_) => <double>[],
|
||||||
|
growable: false,
|
||||||
|
);
|
||||||
|
final previousChoice = List.generate(
|
||||||
|
nodes.length,
|
||||||
|
(_) => <int>[],
|
||||||
|
growable: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (var i = 0; i < nodes.length; i++) {
|
||||||
|
final currentCandidates = nodes[i].candidates;
|
||||||
|
candidateCosts[i] = List<double>.filled(
|
||||||
|
currentCandidates.length,
|
||||||
|
double.infinity,
|
||||||
|
);
|
||||||
|
previousChoice[i] = List<int>.filled(currentCandidates.length, -1);
|
||||||
|
|
||||||
|
for (var j = 0; j < currentCandidates.length; j++) {
|
||||||
|
final current = currentCandidates[j];
|
||||||
|
if (i == 0) {
|
||||||
|
candidateCosts[i][j] = startNode == null
|
||||||
|
? 0
|
||||||
|
: _distanceBetweenNodes(startNode, current);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
final previousCandidates = nodes[i - 1].candidates;
|
||||||
|
for (var k = 0; k < previousCandidates.length; k++) {
|
||||||
|
final candidateCost =
|
||||||
|
candidateCosts[i - 1][k] +
|
||||||
|
_distanceBetweenNodes(previousCandidates[k], current);
|
||||||
|
if (candidateCost < candidateCosts[i][j]) {
|
||||||
|
candidateCosts[i][j] = candidateCost;
|
||||||
|
previousChoice[i][j] = k;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var bestLastIndex = 0;
|
||||||
|
var bestLastCost = double.infinity;
|
||||||
|
final lastCandidates = nodes.last.candidates;
|
||||||
|
for (var i = 0; i < lastCandidates.length; i++) {
|
||||||
|
final endCost = endNode == null
|
||||||
|
? 0
|
||||||
|
: _distanceBetweenNodes(lastCandidates[i], endNode);
|
||||||
|
final totalCost = candidateCosts.last[i] + endCost;
|
||||||
|
if (totalCost < bestLastCost) {
|
||||||
|
bestLastCost = totalCost;
|
||||||
|
bestLastIndex = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final selectedIndices = List<int>.filled(nodes.length, 0);
|
||||||
|
selectedIndices[nodes.length - 1] = bestLastIndex;
|
||||||
|
for (var i = nodes.length - 1; i > 0; i--) {
|
||||||
|
selectedIndices[i - 1] = previousChoice[i][selectedIndices[i]];
|
||||||
|
}
|
||||||
|
|
||||||
|
return List<ResolvedTraceNode>.generate(nodes.length, (index) {
|
||||||
|
final resolved = nodes[index];
|
||||||
|
return ResolvedTraceNode(
|
||||||
|
candidates: resolved.candidates,
|
||||||
|
matchCount: resolved.matchCount,
|
||||||
|
usedOnlineFallback: resolved.usedOnlineFallback,
|
||||||
|
selectedIndex: selectedIndices[index],
|
||||||
|
);
|
||||||
|
}, growable: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
static double _scoreNode(
|
||||||
|
MeshMapNode node, {
|
||||||
|
LatLng? referenceA,
|
||||||
|
LatLng? referenceB,
|
||||||
|
}) {
|
||||||
|
final point = LatLng(node.latitude, node.longitude);
|
||||||
|
if (referenceA != null && referenceB != null) {
|
||||||
|
return _distanceToSegmentMeters(point, referenceA, referenceB);
|
||||||
|
}
|
||||||
|
if (referenceA != null) {
|
||||||
|
return _distance.as(LengthUnit.Meter, point, referenceA);
|
||||||
|
}
|
||||||
|
if (referenceB != null) {
|
||||||
|
return _distance.as(LengthUnit.Meter, point, referenceB);
|
||||||
|
}
|
||||||
|
return double.maxFinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
static double _distanceBetweenNodes(MeshMapNode a, MeshMapNode b) {
|
||||||
|
return _distance.as(
|
||||||
|
LengthUnit.Meter,
|
||||||
|
LatLng(a.latitude, a.longitude),
|
||||||
|
LatLng(b.latitude, b.longitude),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static double _distanceToSegmentMeters(LatLng p, LatLng a, LatLng b) {
|
||||||
|
final ax = a.longitude;
|
||||||
|
final ay = a.latitude;
|
||||||
|
final bx = b.longitude;
|
||||||
|
final by = b.latitude;
|
||||||
|
final px = p.longitude;
|
||||||
|
final py = p.latitude;
|
||||||
|
|
||||||
|
final abx = bx - ax;
|
||||||
|
final aby = by - ay;
|
||||||
|
final apx = px - ax;
|
||||||
|
final apy = py - ay;
|
||||||
|
final ab2 = abx * abx + aby * aby;
|
||||||
|
if (ab2 == 0) {
|
||||||
|
return _distance.as(LengthUnit.Meter, a, p);
|
||||||
|
}
|
||||||
|
var t = (apx * abx + apy * aby) / ab2;
|
||||||
|
t = t.clamp(0.0, 1.0);
|
||||||
|
final closest = LatLng(ay + aby * t, ax + abx * t);
|
||||||
|
return _distance.as(LengthUnit.Meter, closest, p);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,20 +11,41 @@ const int _defaultLoRaCrcEnabled = 1;
|
|||||||
const int _defaultLoRaExplicitHeader = 1;
|
const int _defaultLoRaExplicitHeader = 1;
|
||||||
const double _defaultAirtimeBudgetFactor = 1.0; // one half duty-cycle
|
const double _defaultAirtimeBudgetFactor = 1.0; // one half duty-cycle
|
||||||
|
|
||||||
/// Identifies which Codec2 mode was used for a voice packet.
|
enum VoiceCodecKind {
|
||||||
/// Matches the modeId byte in the text/binary packet header.
|
codec2(0, 'Codec2');
|
||||||
enum VoicePacketMode {
|
|
||||||
mode700c(0, '700C'),
|
|
||||||
mode1200(1, '1200'),
|
|
||||||
mode2400(2, '2400'),
|
|
||||||
mode1300(3, '1300'),
|
|
||||||
mode1400(4, '1400'),
|
|
||||||
mode1600(5, '1600'),
|
|
||||||
mode3200(6, '3200');
|
|
||||||
|
|
||||||
const VoicePacketMode(this.id, this.label);
|
const VoiceCodecKind(this.id, this.label);
|
||||||
final int id;
|
final int id;
|
||||||
final String label;
|
final String label;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Identifies which voice codec/mode was used for a packet.
|
||||||
|
/// Matches the modeId byte in the text/binary packet header.
|
||||||
|
enum VoicePacketMode {
|
||||||
|
mode700c(0, '700C', VoiceCodecKind.codec2, 8000, 100, 1600),
|
||||||
|
mode1200(1, '1200', VoiceCodecKind.codec2, 8000, 150, 1040),
|
||||||
|
mode2400(2, '2400', VoiceCodecKind.codec2, 8000, 300, 520),
|
||||||
|
mode1300(3, '1300', VoiceCodecKind.codec2, 8000, 175, 880),
|
||||||
|
mode1400(4, '1400', VoiceCodecKind.codec2, 8000, 175, 880),
|
||||||
|
mode1600(5, '1600', VoiceCodecKind.codec2, 8000, 200, 800),
|
||||||
|
mode3200(6, '3200', VoiceCodecKind.codec2, 8000, 400, 400);
|
||||||
|
|
||||||
|
const VoicePacketMode(
|
||||||
|
this.id,
|
||||||
|
this.label,
|
||||||
|
this.codec,
|
||||||
|
this.sampleRateHz,
|
||||||
|
this.bytesPerSecond,
|
||||||
|
this.packetDurationMs,
|
||||||
|
);
|
||||||
|
final int id;
|
||||||
|
final String label;
|
||||||
|
final VoiceCodecKind codec;
|
||||||
|
final int sampleRateHz;
|
||||||
|
final int bytesPerSecond;
|
||||||
|
final int packetDurationMs;
|
||||||
|
|
||||||
|
int get samplesPerPacket => sampleRateHz * packetDurationMs ~/ 1000;
|
||||||
|
|
||||||
static VoicePacketMode fromId(int id) => VoicePacketMode.values.firstWhere(
|
static VoicePacketMode fromId(int id) => VoicePacketMode.values.firstWhere(
|
||||||
(m) => m.id == id,
|
(m) => m.id == id,
|
||||||
@@ -153,23 +174,21 @@ class VoicePacket {
|
|||||||
|
|
||||||
/// Estimated audio duration of this packet in milliseconds.
|
/// Estimated audio duration of this packet in milliseconds.
|
||||||
int get durationMs {
|
int get durationMs {
|
||||||
// bytesPerSecond for each mode
|
try {
|
||||||
final bps = switch (mode) {
|
final bytesPerSecond = voiceModeBytesPerSecond(mode);
|
||||||
VoicePacketMode.mode700c => 100,
|
if (bytesPerSecond <= 0) return 0;
|
||||||
VoicePacketMode.mode1200 => 150,
|
return (codec2Data.length * 1000 ~/ bytesPerSecond).clamp(0, 1500);
|
||||||
VoicePacketMode.mode1300 => 175,
|
} catch (_) {
|
||||||
VoicePacketMode.mode1400 => 175,
|
// Be permissive with stale or malformed persisted voice metadata.
|
||||||
VoicePacketMode.mode1600 => 200,
|
return 0;
|
||||||
VoicePacketMode.mode2400 => 300,
|
}
|
||||||
VoicePacketMode.mode3200 => 400,
|
|
||||||
};
|
|
||||||
if (bps == 0) return 0;
|
|
||||||
return (codec2Data.length * 1000 ~/ bps).clamp(0, 1500);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() {
|
String toString() {
|
||||||
final suffix = total > 0 ? ' ${mode.label} [$index/${total - 1}]' : ' [$index]';
|
final suffix = total > 0
|
||||||
|
? ' ${mode.label} [$index/${total - 1}]'
|
||||||
|
: ' [$index]';
|
||||||
return 'VoicePacket($sessionId$suffix ${codec2Data.length}B)';
|
return 'VoicePacket($sessionId$suffix ${codec2Data.length}B)';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -244,10 +263,10 @@ class VoiceEnvelope {
|
|||||||
int voiceModeBytesPerSecond(VoicePacketMode mode) => switch (mode) {
|
int voiceModeBytesPerSecond(VoicePacketMode mode) => switch (mode) {
|
||||||
VoicePacketMode.mode700c => 100,
|
VoicePacketMode.mode700c => 100,
|
||||||
VoicePacketMode.mode1200 => 150,
|
VoicePacketMode.mode1200 => 150,
|
||||||
|
VoicePacketMode.mode2400 => 300,
|
||||||
VoicePacketMode.mode1300 => 175,
|
VoicePacketMode.mode1300 => 175,
|
||||||
VoicePacketMode.mode1400 => 175,
|
VoicePacketMode.mode1400 => 175,
|
||||||
VoicePacketMode.mode1600 => 200,
|
VoicePacketMode.mode1600 => 200,
|
||||||
VoicePacketMode.mode2400 => 300,
|
|
||||||
VoicePacketMode.mode3200 => 400,
|
VoicePacketMode.mode3200 => 400,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -419,8 +438,7 @@ class VoiceFetchRequest {
|
|||||||
this.version = 3,
|
this.version = 3,
|
||||||
});
|
});
|
||||||
|
|
||||||
static bool isVoiceFetchRequestText(String text) =>
|
static bool isVoiceFetchRequestText(String text) => text.startsWith(_prefix);
|
||||||
text.startsWith(_prefix);
|
|
||||||
static bool isVoiceFetchRequestBinary(Uint8List payload) =>
|
static bool isVoiceFetchRequestBinary(Uint8List payload) =>
|
||||||
payload.isNotEmpty && payload[0] == _binaryMagic;
|
payload.isNotEmpty && payload[0] == _binaryMagic;
|
||||||
|
|
||||||
@@ -473,9 +491,7 @@ class VoiceFetchRequest {
|
|||||||
final requesterKey6 = parts[2];
|
final requesterKey6 = parts[2];
|
||||||
final normalizedWant = wantToken == 'a'
|
final normalizedWant = wantToken == 'a'
|
||||||
? 'all'
|
? 'all'
|
||||||
: ((wantToken.startsWith('m'))
|
: ((wantToken.startsWith('m')) ? 'missing' : wantToken);
|
||||||
? 'missing'
|
|
||||||
: wantToken);
|
|
||||||
|
|
||||||
if (sid == null) {
|
if (sid == null) {
|
||||||
return null;
|
return null;
|
||||||
@@ -602,7 +618,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);
|
||||||
@@ -616,10 +636,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>[];
|
||||||
@@ -632,7 +649,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;
|
||||||
|
|||||||
175
lib/widgets/common/bidirectional_refresh.dart
Normal file
175
lib/widgets/common/bidirectional_refresh.dart
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
enum _RefreshEdge { top, bottom }
|
||||||
|
|
||||||
|
class BidirectionalRefresh extends StatefulWidget {
|
||||||
|
static const double defaultTriggerDistance = 90;
|
||||||
|
|
||||||
|
final Widget child;
|
||||||
|
final Future<void> Function() onRefresh;
|
||||||
|
final double triggerDistance;
|
||||||
|
|
||||||
|
const BidirectionalRefresh({
|
||||||
|
super.key,
|
||||||
|
required this.child,
|
||||||
|
required this.onRefresh,
|
||||||
|
this.triggerDistance = defaultTriggerDistance,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<BidirectionalRefresh> createState() => _BidirectionalRefreshState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _BidirectionalRefreshState extends State<BidirectionalRefresh> {
|
||||||
|
double _topPullDistance = 0;
|
||||||
|
double _bottomPullDistance = 0;
|
||||||
|
bool _isRefreshing = false;
|
||||||
|
|
||||||
|
bool _handleScrollNotification(ScrollNotification notification) {
|
||||||
|
if (_isRefreshing) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (notification is ScrollStartNotification) {
|
||||||
|
_resetPullDistance();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (notification is OverscrollNotification) {
|
||||||
|
final metrics = notification.metrics;
|
||||||
|
final isAtTop = metrics.extentBefore == 0;
|
||||||
|
final isAtBottom = metrics.extentAfter == 0;
|
||||||
|
|
||||||
|
if (!isAtTop && !isAtBottom) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var shouldRefresh = false;
|
||||||
|
setState(() {
|
||||||
|
if (isAtTop) {
|
||||||
|
_topPullDistance += notification.overscroll.abs();
|
||||||
|
shouldRefresh = _topPullDistance >= widget.triggerDistance;
|
||||||
|
}
|
||||||
|
if (isAtBottom) {
|
||||||
|
_bottomPullDistance += notification.overscroll.abs();
|
||||||
|
shouldRefresh =
|
||||||
|
shouldRefresh || _bottomPullDistance >= widget.triggerDistance;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (shouldRefresh) {
|
||||||
|
_runRefresh();
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (notification is ScrollEndNotification) {
|
||||||
|
_resetPullDistance();
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _runRefresh() async {
|
||||||
|
if (_isRefreshing) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_isRefreshing = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await widget.onRefresh();
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_isRefreshing = false;
|
||||||
|
_topPullDistance = 0;
|
||||||
|
_bottomPullDistance = 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _resetPullDistance() {
|
||||||
|
if (_topPullDistance == 0 && _bottomPullDistance == 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_topPullDistance = 0;
|
||||||
|
_bottomPullDistance = 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final topProgress = (_topPullDistance / widget.triggerDistance).clamp(
|
||||||
|
0.0,
|
||||||
|
1.0,
|
||||||
|
);
|
||||||
|
final bottomProgress = (_bottomPullDistance / widget.triggerDistance).clamp(
|
||||||
|
0.0,
|
||||||
|
1.0,
|
||||||
|
);
|
||||||
|
|
||||||
|
return NotificationListener<ScrollNotification>(
|
||||||
|
onNotification: _handleScrollNotification,
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
widget.child,
|
||||||
|
_RefreshIndicatorOverlay(
|
||||||
|
alignment: Alignment.topCenter,
|
||||||
|
progress: _isRefreshing ? 1 : topProgress,
|
||||||
|
visible: _isRefreshing || _topPullDistance > 0,
|
||||||
|
edge: _RefreshEdge.top,
|
||||||
|
),
|
||||||
|
_RefreshIndicatorOverlay(
|
||||||
|
alignment: Alignment.bottomCenter,
|
||||||
|
progress: _isRefreshing ? 1 : bottomProgress,
|
||||||
|
visible: _isRefreshing || _bottomPullDistance > 0,
|
||||||
|
edge: _RefreshEdge.bottom,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _RefreshIndicatorOverlay extends StatelessWidget {
|
||||||
|
final Alignment alignment;
|
||||||
|
final double progress;
|
||||||
|
final bool visible;
|
||||||
|
final _RefreshEdge edge;
|
||||||
|
|
||||||
|
const _RefreshIndicatorOverlay({
|
||||||
|
required this.alignment,
|
||||||
|
required this.progress,
|
||||||
|
required this.visible,
|
||||||
|
required this.edge,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (!visible) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
|
||||||
|
final indicator = RefreshProgressIndicator(value: progress);
|
||||||
|
final padding = edge == _RefreshEdge.top
|
||||||
|
? const EdgeInsets.only(top: 12)
|
||||||
|
: const EdgeInsets.only(bottom: 12);
|
||||||
|
|
||||||
|
return Positioned.fill(
|
||||||
|
child: IgnorePointer(
|
||||||
|
child: Align(
|
||||||
|
alignment: alignment,
|
||||||
|
child: Padding(
|
||||||
|
padding: padding,
|
||||||
|
child: SizedBox.square(dimension: 28, child: indicator),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,20 +1,17 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../../l10n/app_localizations.dart';
|
import '../../l10n/app_localizations.dart';
|
||||||
|
|
||||||
/// Dialog for adding a new channel
|
/// Bottom sheet for adding a new channel
|
||||||
class AddChannelDialog extends StatefulWidget {
|
class AddChannelSheet extends StatefulWidget {
|
||||||
final Future<void> Function(String name, String secret) onCreateChannel;
|
final Future<void> Function(String name, String secret) onCreateChannel;
|
||||||
|
|
||||||
const AddChannelDialog({
|
const AddChannelSheet({super.key, required this.onCreateChannel});
|
||||||
super.key,
|
|
||||||
required this.onCreateChannel,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<AddChannelDialog> createState() => _AddChannelDialogState();
|
State<AddChannelSheet> createState() => _AddChannelSheetState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _AddChannelDialogState extends State<AddChannelDialog> {
|
class _AddChannelSheetState extends State<AddChannelSheet> {
|
||||||
final _formKey = GlobalKey<FormState>();
|
final _formKey = GlobalKey<FormState>();
|
||||||
final _nameController = TextEditingController();
|
final _nameController = TextEditingController();
|
||||||
final _secretController = TextEditingController();
|
final _secretController = TextEditingController();
|
||||||
@@ -84,7 +81,7 @@ class _AddChannelDialogState extends State<AddChannelDialog> {
|
|||||||
try {
|
try {
|
||||||
final channelName = _nameController.text.trim();
|
final channelName = _nameController.text.trim();
|
||||||
final isHashChannel = channelName.startsWith('#');
|
final isHashChannel = channelName.startsWith('#');
|
||||||
|
|
||||||
// For hash channels, pass empty secret (will be auto-generated)
|
// For hash channels, pass empty secret (will be auto-generated)
|
||||||
// For private channels, use the provided secret
|
// For private channels, use the provided secret
|
||||||
final secret = isHashChannel ? '' : _secretController.text;
|
final secret = isHashChannel ? '' : _secretController.text;
|
||||||
@@ -109,152 +106,183 @@ class _AddChannelDialogState extends State<AddChannelDialog> {
|
|||||||
final normalizedName = _nameController.text.trimLeft();
|
final normalizedName = _nameController.text.trimLeft();
|
||||||
final isHashChannel = normalizedName.startsWith('#');
|
final isHashChannel = normalizedName.startsWith('#');
|
||||||
|
|
||||||
return AlertDialog(
|
return ConstrainedBox(
|
||||||
title: Text(l10n.addChannel),
|
constraints: BoxConstraints(
|
||||||
content: SingleChildScrollView(
|
maxHeight: MediaQuery.of(context).size.height * 0.85,
|
||||||
child: Form(
|
),
|
||||||
key: _formKey,
|
child: SingleChildScrollView(
|
||||||
child: Column(
|
child: Padding(
|
||||||
mainAxisSize: MainAxisSize.min,
|
padding: const EdgeInsets.fromLTRB(20, 8, 20, 20),
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
child: Form(
|
||||||
children: [
|
key: _formKey,
|
||||||
// Info banner explaining channel types
|
child: Column(
|
||||||
Container(
|
mainAxisSize: MainAxisSize.min,
|
||||||
padding: const EdgeInsets.all(12),
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
decoration: BoxDecoration(
|
children: [
|
||||||
color: theme.colorScheme.primaryContainer.withValues(alpha: 0.3),
|
Row(
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
border: Border.all(
|
|
||||||
color: theme.colorScheme.primary.withValues(alpha: 0.3),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
|
||||||
Icons.info_outline,
|
|
||||||
size: 20,
|
|
||||||
color: theme.colorScheme.primary,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
l10n.channelTypesInfo,
|
l10n.addChannel,
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
style: theme.textTheme.titleLarge?.copyWith(
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
fontWeight: FontWeight.w700,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
IconButton(
|
||||||
|
onPressed: _isCreating
|
||||||
|
? null
|
||||||
|
: () => Navigator.of(context).pop(),
|
||||||
|
icon: const Icon(Icons.close),
|
||||||
|
tooltip: l10n.close,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
|
|
||||||
// Channel Name Field
|
|
||||||
TextFormField(
|
|
||||||
controller: _nameController,
|
|
||||||
decoration: InputDecoration(
|
|
||||||
labelText: l10n.channelName,
|
|
||||||
hintText: l10n.channelNameHint,
|
|
||||||
border: const OutlineInputBorder(),
|
|
||||||
prefixIcon: Icon(
|
|
||||||
isHashChannel ? Icons.tag : Icons.lock_outline,
|
|
||||||
color: isHashChannel ? Colors.blue : Colors.orange,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
enabled: !_isCreating,
|
|
||||||
maxLength: 31,
|
|
||||||
validator: _validateName,
|
|
||||||
textInputAction: isHashChannel
|
|
||||||
? TextInputAction.done
|
|
||||||
: TextInputAction.next,
|
|
||||||
onChanged: (_) => setState(() {}), // Rebuild to update icon
|
|
||||||
onFieldSubmitted: (_) {
|
|
||||||
if (isHashChannel) {
|
|
||||||
_handleCreate();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
// Channel Secret Field (only show for private channels)
|
|
||||||
if (!isHashChannel) ...[
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
TextFormField(
|
|
||||||
controller: _secretController,
|
|
||||||
decoration: InputDecoration(
|
|
||||||
labelText: l10n.channelSecret,
|
|
||||||
hintText: l10n.channelSecretHint,
|
|
||||||
border: const OutlineInputBorder(),
|
|
||||||
),
|
|
||||||
obscureText: true,
|
|
||||||
enabled: !_isCreating,
|
|
||||||
maxLength: 32,
|
|
||||||
validator: _validateSecret,
|
|
||||||
textInputAction: TextInputAction.done,
|
|
||||||
onFieldSubmitted: (_) => _handleCreate(),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
// Help Text for private channels
|
|
||||||
Text(
|
|
||||||
l10n.channelSecretHelp,
|
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
|
|
||||||
// Help Text for hash channels
|
|
||||||
if (isHashChannel) ...[
|
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.all(12),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: theme.colorScheme.primaryContainer.withValues(alpha: 0.5),
|
color: theme.colorScheme.primaryContainer.withValues(
|
||||||
borderRadius: BorderRadius.circular(8),
|
alpha: 0.3,
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(
|
||||||
|
color: theme.colorScheme.primary.withValues(alpha: 0.3),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
const Icon(
|
Icon(
|
||||||
Icons.auto_awesome,
|
Icons.info_outline,
|
||||||
size: 20,
|
size: 20,
|
||||||
color: Colors.blue,
|
color: theme.colorScheme.primary,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
l10n.hashChannelInfo,
|
l10n.channelTypesInfo,
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
color: theme.colorScheme.primary,
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
TextFormField(
|
||||||
|
controller: _nameController,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: l10n.channelName,
|
||||||
|
hintText: l10n.channelNameHint,
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
prefixIcon: Icon(
|
||||||
|
isHashChannel ? Icons.tag : Icons.lock_outline,
|
||||||
|
color: isHashChannel ? Colors.blue : Colors.orange,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
enabled: !_isCreating,
|
||||||
|
maxLength: 31,
|
||||||
|
validator: _validateName,
|
||||||
|
textInputAction: isHashChannel
|
||||||
|
? TextInputAction.done
|
||||||
|
: TextInputAction.next,
|
||||||
|
onChanged: (_) => setState(() {}),
|
||||||
|
onFieldSubmitted: (_) {
|
||||||
|
if (isHashChannel) {
|
||||||
|
_handleCreate();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
if (!isHashChannel) ...[
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
TextFormField(
|
||||||
|
controller: _secretController,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: l10n.channelSecret,
|
||||||
|
hintText: l10n.channelSecretHint,
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
obscureText: true,
|
||||||
|
enabled: !_isCreating,
|
||||||
|
maxLength: 32,
|
||||||
|
validator: _validateSecret,
|
||||||
|
textInputAction: TextInputAction.done,
|
||||||
|
onFieldSubmitted: (_) => _handleCreate(),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
l10n.channelSecretHelp,
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
if (isHashChannel) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.colorScheme.primaryContainer.withValues(
|
||||||
|
alpha: 0.5,
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(
|
||||||
|
Icons.auto_awesome,
|
||||||
|
size: 20,
|
||||||
|
color: Colors.blue,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
l10n.hashChannelInfo,
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.primary,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: OutlinedButton(
|
||||||
|
onPressed: _isCreating
|
||||||
|
? null
|
||||||
|
: () => Navigator.of(context).pop(),
|
||||||
|
child: Text(l10n.cancel),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: FilledButton(
|
||||||
|
onPressed: _isCreating ? null : _handleCreate,
|
||||||
|
child: _isCreating
|
||||||
|
? const SizedBox(
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Text(l10n.createChannel),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
],
|
],
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
actions: [
|
|
||||||
// Cancel Button
|
|
||||||
TextButton(
|
|
||||||
onPressed: _isCreating ? null : () => Navigator.of(context).pop(),
|
|
||||||
child: Text(l10n.cancel),
|
|
||||||
),
|
|
||||||
|
|
||||||
// Create Button
|
|
||||||
FilledButton(
|
|
||||||
onPressed: _isCreating ? null : _handleCreate,
|
|
||||||
child: _isCreating
|
|
||||||
? const SizedBox(
|
|
||||||
width: 16,
|
|
||||||
height: 16,
|
|
||||||
child: CircularProgressIndicator(strokeWidth: 2),
|
|
||||||
)
|
|
||||||
: Text(l10n.createChannel),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,40 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_map/flutter_map.dart' as flutter_map;
|
||||||
|
import 'package:latlong2/latlong.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
import 'dart:math' as math;
|
||||||
|
|
||||||
import '../../models/contact.dart';
|
import '../../models/contact.dart';
|
||||||
|
import '../../providers/connection_provider.dart';
|
||||||
import '../../providers/app_provider.dart';
|
import '../../providers/app_provider.dart';
|
||||||
|
import '../../services/contact_route_resolver.dart';
|
||||||
|
import '../../services/path_history_service.dart';
|
||||||
import '../../services/route_hash_preferences.dart';
|
import '../../services/route_hash_preferences.dart';
|
||||||
|
import '../../models/path_history.dart';
|
||||||
|
|
||||||
class ContactRouteDialogResult {
|
class ContactRouteDialogResult {
|
||||||
final ParsedContactRoute? route;
|
final ParsedContactRoute? route;
|
||||||
final bool shouldClear;
|
final bool shouldClear;
|
||||||
|
final LatLng? inferredFallbackLocation;
|
||||||
|
|
||||||
const ContactRouteDialogResult._({this.route, required this.shouldClear});
|
const ContactRouteDialogResult._({
|
||||||
|
this.route,
|
||||||
|
required this.shouldClear,
|
||||||
|
this.inferredFallbackLocation,
|
||||||
|
});
|
||||||
|
|
||||||
const ContactRouteDialogResult.set(ParsedContactRoute route)
|
const ContactRouteDialogResult.set(ParsedContactRoute route)
|
||||||
: this._(route: route, shouldClear: false);
|
: this._(route: route, shouldClear: false);
|
||||||
|
|
||||||
|
const ContactRouteDialogResult.setWithFallback(
|
||||||
|
ParsedContactRoute route, {
|
||||||
|
LatLng? inferredFallbackLocation,
|
||||||
|
}) : this._(
|
||||||
|
route: route,
|
||||||
|
shouldClear: false,
|
||||||
|
inferredFallbackLocation: inferredFallbackLocation,
|
||||||
|
);
|
||||||
|
|
||||||
const ContactRouteDialogResult.clear() : this._(shouldClear: true);
|
const ContactRouteDialogResult.clear() : this._(shouldClear: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,10 +77,14 @@ class ContactRouteDialog extends StatefulWidget {
|
|||||||
|
|
||||||
class _ContactRouteDialogState extends State<ContactRouteDialog> {
|
class _ContactRouteDialogState extends State<ContactRouteDialog> {
|
||||||
late final TextEditingController _controller;
|
late final TextEditingController _controller;
|
||||||
|
final PathHistoryService _pathHistoryService = PathHistoryService();
|
||||||
int _selectedHashSize = RouteHashPreferences.defaultHashSize;
|
int _selectedHashSize = RouteHashPreferences.defaultHashSize;
|
||||||
ParsedContactRoute? _parsedRoute;
|
ParsedContactRoute? _parsedRoute;
|
||||||
String? _errorText;
|
String? _errorText;
|
||||||
bool _showRoutingInfo = false;
|
bool _showRoutingInfo = false;
|
||||||
|
List<Contact> _selectedMapHops = const [];
|
||||||
|
ContactPathHistory? _pathHistory;
|
||||||
|
_RouteEntryMode _entryMode = _RouteEntryMode.map;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -68,7 +93,11 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
|
|||||||
text: widget.contact.routeCanonicalText,
|
text: widget.contact.routeCanonicalText,
|
||||||
);
|
);
|
||||||
_controller.addListener(_reparse);
|
_controller.addListener(_reparse);
|
||||||
|
_entryMode = widget.contact.routeCanonicalText.isNotEmpty
|
||||||
|
? _RouteEntryMode.manual
|
||||||
|
: _RouteEntryMode.map;
|
||||||
_loadHashSizePreference();
|
_loadHashSizePreference();
|
||||||
|
_loadPathHistory();
|
||||||
_reparse();
|
_reparse();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,6 +115,7 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
|
|||||||
setState(() {
|
setState(() {
|
||||||
_parsedRoute = null;
|
_parsedRoute = null;
|
||||||
_errorText = null;
|
_errorText = null;
|
||||||
|
_selectedMapHops = const [];
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -95,9 +125,11 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
|
|||||||
input,
|
input,
|
||||||
expectedHashSize: _selectedHashSize,
|
expectedHashSize: _selectedHashSize,
|
||||||
);
|
);
|
||||||
|
final selectedMapHops = _mapSelectionForText(input);
|
||||||
setState(() {
|
setState(() {
|
||||||
_parsedRoute = parsed;
|
_parsedRoute = parsed;
|
||||||
_errorText = null;
|
_errorText = null;
|
||||||
|
_selectedMapHops = selectedMapHops;
|
||||||
});
|
});
|
||||||
} on ContactRouteFormatException catch (error) {
|
} on ContactRouteFormatException catch (error) {
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -107,6 +139,36 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
List<Contact> get _routeCandidates =>
|
||||||
|
widget.availableContacts
|
||||||
|
.where(
|
||||||
|
(contact) => contact.isRepeater && contact.displayLocation != null,
|
||||||
|
)
|
||||||
|
.toList()
|
||||||
|
..sort((a, b) => a.displayName.compareTo(b.displayName));
|
||||||
|
|
||||||
|
List<Contact> _mapSelectionForText(String text) {
|
||||||
|
final tokens = text
|
||||||
|
.trim()
|
||||||
|
.split(',')
|
||||||
|
.map((token) => token.trim().toUpperCase())
|
||||||
|
.where((token) => token.isNotEmpty)
|
||||||
|
.toList();
|
||||||
|
final selected = <Contact>[];
|
||||||
|
final seen = <String>{};
|
||||||
|
for (final token in tokens) {
|
||||||
|
final match = _routeCandidates
|
||||||
|
.where(
|
||||||
|
(contact) => contact.publicKeyHex.toUpperCase().startsWith(token),
|
||||||
|
)
|
||||||
|
.firstOrNull;
|
||||||
|
if (match != null && seen.add(match.publicKeyHex)) {
|
||||||
|
selected.add(match);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return selected;
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _loadHashSizePreference() async {
|
Future<void> _loadHashSizePreference() async {
|
||||||
final hashSize = await RouteHashPreferences.getHashSize();
|
final hashSize = await RouteHashPreferences.getHashSize();
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
@@ -116,6 +178,16 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
|
|||||||
_reparse();
|
_reparse();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _loadPathHistory() async {
|
||||||
|
await _pathHistoryService.initialize();
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_pathHistory = _pathHistoryService.historyFor(
|
||||||
|
widget.contact.publicKeyHex,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
String _tokenFor(Contact contact, int hashSize) {
|
String _tokenFor(Contact contact, int hashSize) {
|
||||||
final hex = contact.publicKeyHex.toUpperCase();
|
final hex = contact.publicKeyHex.toUpperCase();
|
||||||
final length = hashSize * 2;
|
final length = hashSize * 2;
|
||||||
@@ -125,137 +197,647 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
|
|||||||
return hex.substring(0, length);
|
return hex.substring(0, length);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _appendHop(Contact contact) {
|
void _syncControllerFromSelectedHops() {
|
||||||
final token = _tokenFor(contact, _selectedHashSize);
|
final tokens = _selectedMapHops
|
||||||
final current = _controller.text.trim();
|
.map((contact) => _tokenFor(contact, _selectedHashSize))
|
||||||
_controller.text = current.isEmpty ? token : '$current,$token';
|
.toList();
|
||||||
|
_controller.text = tokens.join(',');
|
||||||
_controller.selection = TextSelection.fromPosition(
|
_controller.selection = TextSelection.fromPosition(
|
||||||
TextPosition(offset: _controller.text.length),
|
TextPosition(offset: _controller.text.length),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _toggleHop(Contact contact) {
|
||||||
|
setState(() {
|
||||||
|
if (_selectedMapHops.any(
|
||||||
|
(item) => item.publicKeyHex == contact.publicKeyHex,
|
||||||
|
)) {
|
||||||
|
_selectedMapHops = _selectedMapHops
|
||||||
|
.where((item) => item.publicKeyHex != contact.publicKeyHex)
|
||||||
|
.toList();
|
||||||
|
} else {
|
||||||
|
_selectedMapHops = [..._selectedMapHops, contact];
|
||||||
|
}
|
||||||
|
_syncControllerFromSelectedHops();
|
||||||
|
_reparse();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _applyResolvedPlan(ResolvedContactRoutePlan plan) {
|
||||||
|
setState(() {
|
||||||
|
_selectedMapHops = plan.selectedContacts;
|
||||||
|
_controller.text = plan.canonicalText;
|
||||||
|
_controller.selection = TextSelection.fromPosition(
|
||||||
|
TextPosition(offset: _controller.text.length),
|
||||||
|
);
|
||||||
|
_errorText = null;
|
||||||
|
_entryMode = _RouteEntryMode.map;
|
||||||
|
});
|
||||||
|
_reparse();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _applyHistoryRecord(PathRecord record) {
|
||||||
|
final canonicalText = _canonicalRouteFromBytes(
|
||||||
|
record.pathBytes,
|
||||||
|
hashSize: record.hashSize,
|
||||||
|
);
|
||||||
|
setState(() {
|
||||||
|
_controller.text = canonicalText;
|
||||||
|
_controller.selection = TextSelection.fromPosition(
|
||||||
|
TextPosition(offset: _controller.text.length),
|
||||||
|
);
|
||||||
|
_errorText = null;
|
||||||
|
_entryMode = _RouteEntryMode.manual;
|
||||||
|
});
|
||||||
|
_reparse();
|
||||||
|
}
|
||||||
|
|
||||||
|
LatLng? _resolveLastHopLocation() {
|
||||||
|
if (_selectedMapHops.isNotEmpty) {
|
||||||
|
return _selectedMapHops.last.displayLocation == null
|
||||||
|
? null
|
||||||
|
: LatLng(
|
||||||
|
_selectedMapHops.last.displayLocation!.latitude,
|
||||||
|
_selectedMapHops.last.displayLocation!.longitude,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final tokens = _controller.text
|
||||||
|
.trim()
|
||||||
|
.split(',')
|
||||||
|
.map((token) => token.trim().toUpperCase())
|
||||||
|
.where((token) => token.isNotEmpty)
|
||||||
|
.toList();
|
||||||
|
if (tokens.isEmpty) return null;
|
||||||
|
final lastToken = tokens.last;
|
||||||
|
final match = _routeCandidates
|
||||||
|
.where(
|
||||||
|
(contact) => contact.publicKeyHex.toUpperCase().startsWith(lastToken),
|
||||||
|
)
|
||||||
|
.firstOrNull;
|
||||||
|
final location = match?.displayLocation;
|
||||||
|
if (location == null) return null;
|
||||||
|
return LatLng(location.latitude, location.longitude);
|
||||||
|
}
|
||||||
|
|
||||||
|
LatLng? _buildSyntheticFallbackLocation() {
|
||||||
|
final lastHopLocation = _resolveLastHopLocation();
|
||||||
|
if (lastHopLocation == null) return null;
|
||||||
|
|
||||||
|
final seed = widget.contact.publicKey.fold<int>(
|
||||||
|
_controller.text.codeUnits.fold<int>(0, (sum, unit) => sum + unit),
|
||||||
|
(sum, byte) => sum + byte,
|
||||||
|
);
|
||||||
|
final angle = (seed % 360) * (math.pi / 180.0);
|
||||||
|
const radiusMeters = 500.0;
|
||||||
|
final latOffset = (radiusMeters / 111320.0) * math.cos(angle);
|
||||||
|
final lonDenominator =
|
||||||
|
111320.0 * math.cos(lastHopLocation.latitude * (math.pi / 180.0));
|
||||||
|
final lonOffset = lonDenominator.abs() < 1e-6
|
||||||
|
? 0.0
|
||||||
|
: (radiusMeters / lonDenominator) * math.sin(angle);
|
||||||
|
return LatLng(
|
||||||
|
lastHopLocation.latitude + latOffset,
|
||||||
|
lastHopLocation.longitude + lonOffset,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _resolvePathAutomatically() {
|
||||||
|
final connectionProvider = context.read<ConnectionProvider>();
|
||||||
|
final advLat = connectionProvider.deviceInfo.advLat;
|
||||||
|
final advLon = connectionProvider.deviceInfo.advLon;
|
||||||
|
final recipientLocation = widget.contact.displayLocation;
|
||||||
|
if (advLat == null ||
|
||||||
|
advLon == null ||
|
||||||
|
(advLat == 0 && advLon == 0) ||
|
||||||
|
recipientLocation == null) {
|
||||||
|
setState(() {
|
||||||
|
_errorText =
|
||||||
|
'Automatic resolve needs both your advertised location and the contact location.';
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final plan = ContactRouteResolver.resolveAutomaticRoute(
|
||||||
|
senderLocation: LatLng(advLat / 1e6, advLon / 1e6),
|
||||||
|
recipient: widget.contact,
|
||||||
|
availableContacts: widget.availableContacts,
|
||||||
|
hashSize: _selectedHashSize,
|
||||||
|
);
|
||||||
|
if (plan == null) {
|
||||||
|
setState(() {
|
||||||
|
_errorText =
|
||||||
|
'Could not resolve a route from available repeater locations.';
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_applyResolvedPlan(plan);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _canonicalRouteFromBytes(
|
||||||
|
List<int> pathBytes, {
|
||||||
|
required int hashSize,
|
||||||
|
}) {
|
||||||
|
final hops = <String>[];
|
||||||
|
for (var i = 0; i < pathBytes.length; i += hashSize) {
|
||||||
|
final hop = pathBytes.sublist(i, i + hashSize);
|
||||||
|
hops.add(
|
||||||
|
hop
|
||||||
|
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
|
||||||
|
.join()
|
||||||
|
.toUpperCase(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return hops.join(',');
|
||||||
|
}
|
||||||
|
|
||||||
|
String _historySubtitle(PathRecord record) {
|
||||||
|
final attempts = record.successCount + record.failureCount;
|
||||||
|
final lastSeen = MaterialLocalizations.of(
|
||||||
|
context,
|
||||||
|
).formatShortDate(record.lastUsedAt);
|
||||||
|
final sourceLabel = switch (record.source) {
|
||||||
|
PathRecordSource.observed => 'Observed on mesh',
|
||||||
|
PathRecordSource.learned => 'Learned route',
|
||||||
|
};
|
||||||
|
final successRate = attempts == 0
|
||||||
|
? 'No send stats yet'
|
||||||
|
: '${(record.successRate * 100).round()}% success over $attempts send${attempts == 1 ? '' : 's'}';
|
||||||
|
final latency = record.lastRoundTripTimeMs > 0
|
||||||
|
? ' • ${record.lastRoundTripTimeMs} ms'
|
||||||
|
: '';
|
||||||
|
return '$sourceLabel • $successRate • Last used $lastSeen$latency';
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildHistoryRecordTile(PathRecord record, {String? title}) {
|
||||||
|
final canonicalText = _canonicalRouteFromBytes(
|
||||||
|
record.pathBytes,
|
||||||
|
hashSize: record.hashSize,
|
||||||
|
);
|
||||||
|
return Card(
|
||||||
|
margin: EdgeInsets.zero,
|
||||||
|
child: ListTile(
|
||||||
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 16,
|
||||||
|
vertical: 10,
|
||||||
|
),
|
||||||
|
leading: title == null ? null : const Icon(Icons.alt_route),
|
||||||
|
title: title == null
|
||||||
|
? Text(
|
||||||
|
canonicalText,
|
||||||
|
style: Theme.of(
|
||||||
|
context,
|
||||||
|
).textTheme.bodyMedium?.copyWith(fontFamily: 'monospace'),
|
||||||
|
)
|
||||||
|
: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(title, style: Theme.of(context).textTheme.titleSmall),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text(
|
||||||
|
canonicalText,
|
||||||
|
style: Theme.of(
|
||||||
|
context,
|
||||||
|
).textTheme.bodyMedium?.copyWith(fontFamily: 'monospace'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
subtitle: Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 6),
|
||||||
|
child: Text(_historySubtitle(record)),
|
||||||
|
),
|
||||||
|
trailing: FilledButton.tonal(
|
||||||
|
onPressed: () => _applyHistoryRecord(record),
|
||||||
|
child: const Text('Use'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildPreviewSection() {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
_parsedRoute == null
|
||||||
|
? 'Preview: enter or pick a route to validate it.'
|
||||||
|
: 'Preview: ${_parsedRoute!.summary} • ${_parsedRoute!.byteLength} bytes • descriptor 0x${_parsedRoute!.encodedPathLen.toRadixString(16).padLeft(2, '0').toUpperCase()}',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
if (_parsedRoute != null) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
SelectableText(
|
||||||
|
_parsedRoute!.canonicalText,
|
||||||
|
style: Theme.of(
|
||||||
|
context,
|
||||||
|
).textTheme.bodyMedium?.copyWith(fontFamily: 'monospace'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildBuilderTab(
|
||||||
|
BuildContext context, {
|
||||||
|
required List<Contact> routeCandidates,
|
||||||
|
required List<LatLng> mapPoints,
|
||||||
|
required List<LatLng> routePoints,
|
||||||
|
}) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
SegmentedButton<_RouteEntryMode>(
|
||||||
|
segments: const [
|
||||||
|
ButtonSegment<_RouteEntryMode>(
|
||||||
|
value: _RouteEntryMode.map,
|
||||||
|
icon: Icon(Icons.map_outlined),
|
||||||
|
label: Text('Map'),
|
||||||
|
),
|
||||||
|
ButtonSegment<_RouteEntryMode>(
|
||||||
|
value: _RouteEntryMode.manual,
|
||||||
|
icon: Icon(Icons.tune),
|
||||||
|
label: Text('Manual'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
selected: {_entryMode},
|
||||||
|
onSelectionChanged: (selection) {
|
||||||
|
setState(() {
|
||||||
|
_entryMode = selection.first;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
if (_entryMode == _RouteEntryMode.manual) ...[
|
||||||
|
TextField(
|
||||||
|
controller: _controller,
|
||||||
|
textCapitalization: TextCapitalization.characters,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: 'Route',
|
||||||
|
hintText: _selectedHashSize == 1
|
||||||
|
? 'AA,BB,CC'
|
||||||
|
: _selectedHashSize == 2
|
||||||
|
? 'AABB,CCDD'
|
||||||
|
: 'AABBCC,DDEEFF',
|
||||||
|
helperText:
|
||||||
|
'Enter comma-separated hops. Path byte size comes from global Settings. Colon form like AA:BB is also accepted.',
|
||||||
|
errorText: _errorText,
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_buildPreviewSection(),
|
||||||
|
] else ...[
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
crossAxisAlignment: WrapCrossAlignment.center,
|
||||||
|
children: [
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: _resolvePathAutomatically,
|
||||||
|
icon: const Icon(Icons.auto_fix_high),
|
||||||
|
label: const Text('Resolve Path'),
|
||||||
|
),
|
||||||
|
ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 280),
|
||||||
|
child: Text(
|
||||||
|
'Tap repeaters on the map to build the path, then review the generated route below.',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
SizedBox(
|
||||||
|
height: 260,
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
child: DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: Border.all(color: Theme.of(context).dividerColor),
|
||||||
|
),
|
||||||
|
child: mapPoints.length < 2
|
||||||
|
? const Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.all(16),
|
||||||
|
child: Text(
|
||||||
|
'Map path builder needs your advertised location, the contact location, and visible repeater locations.',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: flutter_map.FlutterMap(
|
||||||
|
options: flutter_map.MapOptions(
|
||||||
|
initialCameraFit: flutter_map.CameraFit.bounds(
|
||||||
|
bounds: flutter_map.LatLngBounds.fromPoints(
|
||||||
|
mapPoints,
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.all(32),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
children: [
|
||||||
|
flutter_map.TileLayer(
|
||||||
|
urlTemplate:
|
||||||
|
'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||||
|
userAgentPackageName: 'com.meshcore.sar',
|
||||||
|
),
|
||||||
|
if (routePoints.length >= 2)
|
||||||
|
flutter_map.PolylineLayer(
|
||||||
|
polylines: [
|
||||||
|
flutter_map.Polyline(
|
||||||
|
points: routePoints,
|
||||||
|
strokeWidth: 4,
|
||||||
|
color: Theme.of(context).colorScheme.primary,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
flutter_map.MarkerLayer(
|
||||||
|
markers: [
|
||||||
|
...routeCandidates.map((candidate) {
|
||||||
|
final isSelected = _selectedMapHops.any(
|
||||||
|
(item) =>
|
||||||
|
item.publicKeyHex ==
|
||||||
|
candidate.publicKeyHex,
|
||||||
|
);
|
||||||
|
return flutter_map.Marker(
|
||||||
|
point: LatLng(
|
||||||
|
candidate.displayLocation!.latitude,
|
||||||
|
candidate.displayLocation!.longitude,
|
||||||
|
),
|
||||||
|
width: 64,
|
||||||
|
height: 70,
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: () => _toggleHop(candidate),
|
||||||
|
child: _RouteMarkerDot(
|
||||||
|
label: _tokenFor(
|
||||||
|
candidate,
|
||||||
|
_selectedHashSize,
|
||||||
|
),
|
||||||
|
color: isSelected
|
||||||
|
? Theme.of(
|
||||||
|
context,
|
||||||
|
).colorScheme.primary
|
||||||
|
: Colors.blueGrey,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
if (_selectedMapHops.isNotEmpty)
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: _selectedMapHops.map((contact) {
|
||||||
|
return InputChip(
|
||||||
|
label: Text(contact.displayName),
|
||||||
|
onDeleted: () => _toggleHop(contact),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
TextField(
|
||||||
|
controller: _controller,
|
||||||
|
readOnly: true,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: 'Generated route',
|
||||||
|
helperText: 'Switch to Manual if you want to edit the hop list.',
|
||||||
|
errorText: _errorText,
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_buildPreviewSection(),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildHistoryTab() {
|
||||||
|
final records = List<PathRecord>.from(_pathHistory?.directPaths ?? const [])
|
||||||
|
..sort((a, b) => b.lastUsedAt.compareTo(a.lastUsedAt));
|
||||||
|
if (records.isEmpty) {
|
||||||
|
return Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Text(
|
||||||
|
'No historical paths for this contact yet.',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
PathRecord? observedRecord;
|
||||||
|
for (final record in records) {
|
||||||
|
if (record.source == PathRecordSource.observed) {
|
||||||
|
observedRecord = record;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
final remainingRecords = observedRecord == null
|
||||||
|
? records
|
||||||
|
: records
|
||||||
|
.where((record) => !identical(record, observedRecord))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
if (observedRecord != null) ...[
|
||||||
|
_buildHistoryRecordTile(observedRecord, title: 'Observed mesh route'),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
],
|
||||||
|
if (remainingRecords.isEmpty)
|
||||||
|
Text(
|
||||||
|
observedRecord == null
|
||||||
|
? 'No additional route history yet.'
|
||||||
|
: 'Observed routes you start using will continue to build history here.',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium,
|
||||||
|
)
|
||||||
|
else
|
||||||
|
ListView.separated(
|
||||||
|
itemCount: remainingRecords.length,
|
||||||
|
shrinkWrap: true,
|
||||||
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
|
separatorBuilder: (_, _) => const SizedBox(height: 8),
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
return _buildHistoryRecordTile(remainingRecords[index]);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final appProvider = context.watch<AppProvider>();
|
final appProvider = context.watch<AppProvider>();
|
||||||
final routeCandidates =
|
final routeCandidates = _routeCandidates;
|
||||||
widget.availableContacts
|
final connectionProvider = context.watch<ConnectionProvider>();
|
||||||
.where((contact) => contact.isRepeater || contact.isRoom)
|
final selfPoint =
|
||||||
.toList()
|
connectionProvider.deviceInfo.advLat != null &&
|
||||||
..sort((a, b) => a.displayName.compareTo(b.displayName));
|
connectionProvider.deviceInfo.advLon != null &&
|
||||||
|
!(connectionProvider.deviceInfo.advLat == 0 &&
|
||||||
|
connectionProvider.deviceInfo.advLon == 0)
|
||||||
|
? LatLng(
|
||||||
|
connectionProvider.deviceInfo.advLat! / 1e6,
|
||||||
|
connectionProvider.deviceInfo.advLon! / 1e6,
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
final recipientLocation = widget.contact.displayLocation;
|
||||||
|
final recipientPoint = recipientLocation == null
|
||||||
|
? null
|
||||||
|
: LatLng(recipientLocation.latitude, recipientLocation.longitude);
|
||||||
|
final routePoints = <LatLng>[
|
||||||
|
...?selfPoint == null ? null : [selfPoint],
|
||||||
|
..._selectedMapHops
|
||||||
|
.where((contact) => contact.displayLocation != null)
|
||||||
|
.map(
|
||||||
|
(contact) => LatLng(
|
||||||
|
contact.displayLocation!.latitude,
|
||||||
|
contact.displayLocation!.longitude,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
...?recipientPoint == null ? null : [recipientPoint],
|
||||||
|
];
|
||||||
|
final mapPoints = <LatLng>[
|
||||||
|
...?selfPoint == null ? null : [selfPoint],
|
||||||
|
...?recipientPoint == null ? null : [recipientPoint],
|
||||||
|
...routeCandidates.map(
|
||||||
|
(contact) => LatLng(
|
||||||
|
contact.displayLocation!.latitude,
|
||||||
|
contact.displayLocation!.longitude,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
return FractionallySizedBox(
|
return DefaultTabController(
|
||||||
heightFactor: 0.85,
|
length: 2,
|
||||||
child: Padding(
|
child: FractionallySizedBox(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
heightFactor: 0.85,
|
||||||
child: Column(
|
child: Padding(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
||||||
children: [
|
child: Column(
|
||||||
Text(
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
'Set Route for ${widget.contact.displayName}',
|
children: [
|
||||||
style: Theme.of(context).textTheme.headlineSmall,
|
Text(
|
||||||
),
|
'Set Route for ${widget.contact.displayName}',
|
||||||
const SizedBox(height: 16),
|
style: Theme.of(context).textTheme.headlineSmall,
|
||||||
TextField(
|
|
||||||
controller: _controller,
|
|
||||||
textCapitalization: TextCapitalization.characters,
|
|
||||||
decoration: InputDecoration(
|
|
||||||
labelText: 'Route',
|
|
||||||
hintText: _selectedHashSize == 1
|
|
||||||
? 'AA,BB,CC'
|
|
||||||
: _selectedHashSize == 2
|
|
||||||
? 'AABB,CCDD'
|
|
||||||
: 'AABBCC,DDEEFF',
|
|
||||||
helperText:
|
|
||||||
'Use comma-separated hops. Path byte size comes from global Settings. Colon form like AA:BB is also accepted.',
|
|
||||||
errorText: _errorText,
|
|
||||||
border: const OutlineInputBorder(),
|
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(height: 8),
|
||||||
const SizedBox(height: 12),
|
Text(
|
||||||
Text(
|
'Choose how to build the route, or reuse one from history.',
|
||||||
_parsedRoute == null
|
style: Theme.of(context).textTheme.bodyMedium,
|
||||||
? 'Preview: enter a route to validate it.'
|
),
|
||||||
: 'Preview: ${_parsedRoute!.summary} • ${_parsedRoute!.byteLength} bytes • descriptor 0x${_parsedRoute!.encodedPathLen.toRadixString(16).padLeft(2, '0').toUpperCase()}',
|
const SizedBox(height: 16),
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
const TabBar(
|
||||||
),
|
tabs: [
|
||||||
if (_parsedRoute != null) ...[
|
Tab(text: 'Build'),
|
||||||
const SizedBox(height: 4),
|
Tab(text: 'History'),
|
||||||
SelectableText(
|
],
|
||||||
_parsedRoute!.canonicalText,
|
),
|
||||||
style: Theme.of(
|
const SizedBox(height: 16),
|
||||||
context,
|
Expanded(
|
||||||
).textTheme.bodyMedium?.copyWith(fontFamily: 'monospace'),
|
child: TabBarView(
|
||||||
|
children: [
|
||||||
|
SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
_buildBuilderTab(
|
||||||
|
context,
|
||||||
|
routeCandidates: routeCandidates,
|
||||||
|
mapPoints: mapPoints,
|
||||||
|
routePoints: routePoints,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_AutomationRoutingInfo(
|
||||||
|
isExpanded: _showRoutingInfo,
|
||||||
|
onToggle: () {
|
||||||
|
setState(() {
|
||||||
|
_showRoutingInfo = !_showRoutingInfo;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
autoRouteRotationEnabled:
|
||||||
|
appProvider.autoRouteRotationEnabled,
|
||||||
|
nearestRelayFallbackEnabled:
|
||||||
|
appProvider.nearestRelayFallbackEnabled,
|
||||||
|
clearPathOnMaxRetry:
|
||||||
|
appProvider.clearPathOnMaxRetry,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SingleChildScrollView(child: _buildHistoryTab()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
OverflowBar(
|
||||||
|
alignment: MainAxisAlignment.spaceBetween,
|
||||||
|
spacing: 8,
|
||||||
|
overflowSpacing: 8,
|
||||||
|
children: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
if (widget.contact.routeHasPath)
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(
|
||||||
|
context,
|
||||||
|
).pop(const ContactRouteDialogResult.clear()),
|
||||||
|
child: const Text('Clear Route'),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: _parsedRoute == null
|
||||||
|
? null
|
||||||
|
: () => Navigator.of(context).pop(
|
||||||
|
ContactRouteDialogResult.setWithFallback(
|
||||||
|
_parsedRoute!,
|
||||||
|
inferredFallbackLocation:
|
||||||
|
_buildSyntheticFallbackLocation(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: const Text('Set Route'),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
const SizedBox(height: 16),
|
),
|
||||||
_AutomationRoutingInfo(
|
),
|
||||||
isExpanded: _showRoutingInfo,
|
),
|
||||||
onToggle: () {
|
);
|
||||||
setState(() {
|
}
|
||||||
_showRoutingInfo = !_showRoutingInfo;
|
}
|
||||||
});
|
|
||||||
},
|
enum _RouteEntryMode { map, manual }
|
||||||
autoRouteRotationEnabled: appProvider.autoRouteRotationEnabled,
|
|
||||||
nearestRelayFallbackEnabled:
|
class _RouteMarkerDot extends StatelessWidget {
|
||||||
appProvider.nearestRelayFallbackEnabled,
|
final String label;
|
||||||
clearPathOnMaxRetry: appProvider.clearPathOnMaxRetry,
|
final Color color;
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
const _RouteMarkerDot({required this.label, required this.color});
|
||||||
Text(
|
|
||||||
'Pick hops from contacts',
|
@override
|
||||||
style: Theme.of(context).textTheme.labelLarge,
|
Widget build(BuildContext context) {
|
||||||
),
|
return Tooltip(
|
||||||
const SizedBox(height: 8),
|
message: label,
|
||||||
if (routeCandidates.isEmpty)
|
child: Center(
|
||||||
const Text(
|
child: Container(
|
||||||
'No repeater or room contacts are available for route building.',
|
width: 26,
|
||||||
)
|
height: 26,
|
||||||
else
|
decoration: BoxDecoration(
|
||||||
Expanded(
|
color: color,
|
||||||
child: ListView.builder(
|
shape: BoxShape.circle,
|
||||||
itemCount: routeCandidates.length,
|
border: Border.all(color: Colors.white, width: 2),
|
||||||
itemBuilder: (context, index) {
|
),
|
||||||
final candidate = routeCandidates[index];
|
|
||||||
return ListTile(
|
|
||||||
dense: true,
|
|
||||||
contentPadding: EdgeInsets.zero,
|
|
||||||
title: Text(candidate.displayName),
|
|
||||||
trailing: TextButton(
|
|
||||||
onPressed: () => _appendHop(candidate),
|
|
||||||
child: Text(
|
|
||||||
'Use ${_tokenFor(candidate, _selectedHashSize)}',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => Navigator.of(context).pop(),
|
|
||||||
child: const Text('Cancel'),
|
|
||||||
),
|
|
||||||
if (widget.contact.routeHasPath)
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => Navigator.of(
|
|
||||||
context,
|
|
||||||
).pop(const ContactRouteDialogResult.clear()),
|
|
||||||
child: const Text('Clear Route'),
|
|
||||||
),
|
|
||||||
const Spacer(),
|
|
||||||
FilledButton(
|
|
||||||
onPressed: _parsedRoute == null
|
|
||||||
? null
|
|
||||||
: () => Navigator.of(
|
|
||||||
context,
|
|
||||||
).pop(ContactRouteDialogResult.set(_parsedRoute!)),
|
|
||||||
child: const Text('Set Route'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import '../../providers/messages_provider.dart';
|
|||||||
import '../../providers/sensors_provider.dart';
|
import '../../providers/sensors_provider.dart';
|
||||||
import '../../services/message_destination_preferences.dart';
|
import '../../services/message_destination_preferences.dart';
|
||||||
import 'contact_route_dialog.dart';
|
import 'contact_route_dialog.dart';
|
||||||
|
import 'contact_trace_sheet.dart';
|
||||||
import 'room_login_sheet.dart';
|
import 'room_login_sheet.dart';
|
||||||
import '../common/contact_avatar.dart';
|
import '../common/contact_avatar.dart';
|
||||||
import '../../utils/toast_logger.dart';
|
import '../../utils/toast_logger.dart';
|
||||||
@@ -20,6 +21,8 @@ import '../../l10n/app_localizations.dart';
|
|||||||
|
|
||||||
class ContactTile extends StatelessWidget {
|
class ContactTile extends StatelessWidget {
|
||||||
final Contact contact;
|
final Contact contact;
|
||||||
|
final String? groupLabel;
|
||||||
|
final bool compact;
|
||||||
final Position? currentPosition;
|
final Position? currentPosition;
|
||||||
final double Function(double, double, double, double)? calculateDistance;
|
final double Function(double, double, double, double)? calculateDistance;
|
||||||
final String Function(double)? formatDistance;
|
final String Function(double)? formatDistance;
|
||||||
@@ -29,6 +32,8 @@ class ContactTile extends StatelessWidget {
|
|||||||
const ContactTile({
|
const ContactTile({
|
||||||
super.key,
|
super.key,
|
||||||
required this.contact,
|
required this.contact,
|
||||||
|
this.groupLabel,
|
||||||
|
this.compact = false,
|
||||||
this.currentPosition,
|
this.currentPosition,
|
||||||
this.calculateDistance,
|
this.calculateDistance,
|
||||||
this.formatDistance,
|
this.formatDistance,
|
||||||
@@ -124,31 +129,46 @@ class ContactTile extends StatelessWidget {
|
|||||||
: colorScheme.onSurfaceVariant,
|
: colorScheme.onSurfaceVariant,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
);
|
);
|
||||||
final Widget subtitleWidget = Column(
|
final Widget subtitleWidget = compact
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
? _buildCompactSubtitle(context, distanceText)
|
||||||
children: [
|
: Column(
|
||||||
if (location != null) ...[
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
const SizedBox(height: 2),
|
children: [
|
||||||
_buildLocationLine(
|
const SizedBox(height: 4),
|
||||||
context,
|
Wrap(
|
||||||
latitude: location.latitude,
|
spacing: 6,
|
||||||
longitude: location.longitude,
|
runSpacing: 6,
|
||||||
distanceText: distanceText,
|
children: [
|
||||||
),
|
if (groupLabel case final label?)
|
||||||
const SizedBox(height: 6),
|
_buildMetaPill(
|
||||||
Row(children: [_buildRoutePill(context, contact)]),
|
context,
|
||||||
] else
|
icon: Icons.folder_copy_outlined,
|
||||||
Padding(
|
label: label,
|
||||||
padding: const EdgeInsets.only(top: 4),
|
),
|
||||||
child: Text(
|
],
|
||||||
AppLocalizations.of(context)!.noGpsData,
|
),
|
||||||
style: Theme.of(
|
if (location != null) ...[
|
||||||
context,
|
const SizedBox(height: 2),
|
||||||
).textTheme.labelSmall?.copyWith(color: Colors.grey),
|
_buildLocationLine(
|
||||||
),
|
context,
|
||||||
),
|
latitude: location.latitude,
|
||||||
],
|
longitude: location.longitude,
|
||||||
);
|
distanceText: distanceText,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Row(children: [_buildRoutePill(context, contact)]),
|
||||||
|
] else
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 4),
|
||||||
|
child: Text(
|
||||||
|
AppLocalizations.of(context)!.noGpsData,
|
||||||
|
style: Theme.of(
|
||||||
|
context,
|
||||||
|
).textTheme.labelSmall?.copyWith(color: Colors.grey),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
margin: const EdgeInsets.only(bottom: 8),
|
margin: const EdgeInsets.only(bottom: 8),
|
||||||
@@ -272,6 +292,28 @@ class ContactTile extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildCompactSubtitle(BuildContext context, String? distanceText) {
|
||||||
|
final location = contact.displayLocation;
|
||||||
|
final compactPills = <Widget>[
|
||||||
|
if (groupLabel case final label?)
|
||||||
|
_buildMetaPill(context, icon: Icons.folder_copy_outlined, label: label),
|
||||||
|
if (distanceText != null) _buildDistancePill(context, distanceText),
|
||||||
|
if (contact.routeHasPath && contact.routeHopCount > 0)
|
||||||
|
_buildRoutePill(context, contact),
|
||||||
|
if (location == null)
|
||||||
|
_buildMetaPill(
|
||||||
|
context,
|
||||||
|
icon: Icons.location_disabled_outlined,
|
||||||
|
label: AppLocalizations.of(context)!.noGpsData,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 4),
|
||||||
|
child: Wrap(spacing: 6, runSpacing: 6, children: compactPills),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
void _handlePrimaryTap(BuildContext context, Contact contact) {
|
void _handlePrimaryTap(BuildContext context, Contact contact) {
|
||||||
_showContactActionSheet(context, contact);
|
_showContactActionSheet(context, contact);
|
||||||
}
|
}
|
||||||
@@ -296,95 +338,115 @@ class ContactTile extends StatelessWidget {
|
|||||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||||
),
|
),
|
||||||
builder: (sheetContext) => SafeArea(
|
builder: (sheetContext) => SafeArea(
|
||||||
child: Column(
|
child: SingleChildScrollView(
|
||||||
mainAxisSize: MainAxisSize.min,
|
child: Column(
|
||||||
children: [
|
mainAxisSize: MainAxisSize.min,
|
||||||
ListTile(
|
children: [
|
||||||
leading: const Icon(Icons.message_outlined),
|
|
||||||
title: Text(l10n.messages),
|
|
||||||
enabled: canMessage,
|
|
||||||
onTap: !canMessage
|
|
||||||
? null
|
|
||||||
: () async {
|
|
||||||
Navigator.pop(sheetContext);
|
|
||||||
await _openMessagesForContact(context, contact);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
if (contact.displayLocation != null)
|
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.map_outlined),
|
leading: const Icon(Icons.message_outlined),
|
||||||
title: Text(l10n.viewOnMap),
|
title: Text(l10n.messages),
|
||||||
onTap: () {
|
enabled: canMessage,
|
||||||
Navigator.pop(sheetContext);
|
onTap: !canMessage
|
||||||
_showContactOnMap(context, contact);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
if (contact.type == ContactType.room && !contact.isPublicChannel)
|
|
||||||
ListTile(
|
|
||||||
leading: const Icon(Icons.login),
|
|
||||||
title: Text(
|
|
||||||
context
|
|
||||||
.read<ConnectionProvider>()
|
|
||||||
.getRoomLoginState(contact.publicKeyPrefix)
|
|
||||||
?.isLoggedIn ==
|
|
||||||
true
|
|
||||||
? AppLocalizations.of(context)!.reLoginToRoom
|
|
||||||
: AppLocalizations.of(context)!.loginToRoom,
|
|
||||||
),
|
|
||||||
onTap: () {
|
|
||||||
Navigator.pop(sheetContext);
|
|
||||||
_showRoomLoginDialog(context, contact);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
if (canAddToSensors)
|
|
||||||
ListTile(
|
|
||||||
leading: Icon(
|
|
||||||
isInSensors ? Icons.sensors : Icons.sensors_outlined,
|
|
||||||
),
|
|
||||||
title: Text(
|
|
||||||
isInSensors
|
|
||||||
? l10n.contactInSensors
|
|
||||||
: l10n.contactAddToSensors,
|
|
||||||
),
|
|
||||||
enabled: !isInSensors,
|
|
||||||
onTap: isInSensors
|
|
||||||
? null
|
? null
|
||||||
: () async {
|
: () async {
|
||||||
Navigator.pop(sheetContext);
|
Navigator.pop(sheetContext);
|
||||||
await _addContactToSensors(context, contact);
|
await _openMessagesForContact(context, contact);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
if (canSetPath)
|
if (contact.displayLocation != null)
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.alt_route),
|
leading: const Icon(Icons.map_outlined),
|
||||||
title: Text(l10n.contactSetPath),
|
title: Text(l10n.viewOnMap),
|
||||||
onTap: () {
|
onTap: () {
|
||||||
Navigator.pop(sheetContext);
|
Navigator.pop(sheetContext);
|
||||||
_showSetRouteDialog(context, contact);
|
_showContactOnMap(context, contact);
|
||||||
},
|
},
|
||||||
),
|
|
||||||
if (!contact.isPublicChannel)
|
|
||||||
ListTile(
|
|
||||||
leading: const Icon(Icons.delete, color: Colors.red),
|
|
||||||
title: Text(
|
|
||||||
contact.isChannel ? l10n.deleteChannel : l10n.deleteContact,
|
|
||||||
style: const TextStyle(color: Colors.red),
|
|
||||||
),
|
),
|
||||||
onTap: () async {
|
if (contact.type == ContactType.room && !contact.isPublicChannel)
|
||||||
Navigator.pop(sheetContext);
|
ListTile(
|
||||||
await Future<void>.delayed(Duration.zero);
|
leading: const Icon(Icons.login),
|
||||||
if (!context.mounted) return;
|
title: Text(
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
context
|
||||||
|
.read<ConnectionProvider>()
|
||||||
|
.getRoomLoginState(contact.publicKeyPrefix)
|
||||||
|
?.isLoggedIn ==
|
||||||
|
true
|
||||||
|
? AppLocalizations.of(context)!.reLoginToRoom
|
||||||
|
: AppLocalizations.of(context)!.loginToRoom,
|
||||||
|
),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(sheetContext);
|
||||||
|
_showRoomLoginDialog(context, contact);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
if (canAddToSensors)
|
||||||
|
ListTile(
|
||||||
|
leading: Icon(
|
||||||
|
isInSensors ? Icons.sensors : Icons.sensors_outlined,
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
isInSensors
|
||||||
|
? l10n.contactInSensors
|
||||||
|
: l10n.contactAddToSensors,
|
||||||
|
),
|
||||||
|
enabled: !isInSensors,
|
||||||
|
onTap: isInSensors
|
||||||
|
? null
|
||||||
|
: () async {
|
||||||
|
Navigator.pop(sheetContext);
|
||||||
|
await _addContactToSensors(context, contact);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
if (canSetPath)
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.alt_route),
|
||||||
|
title: Text(l10n.contactSetPath),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(sheetContext);
|
||||||
|
_showSetRouteDialog(context, contact);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
if (!contact.isChannel)
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.route),
|
||||||
|
title: const Text('Trace'),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(sheetContext);
|
||||||
|
_showTraceSheet(context, contact);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
if (!contact.isPublicChannel)
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.edit_outlined),
|
||||||
|
title: const Text('Edit name'),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(sheetContext);
|
||||||
|
_showNameOverrideDialog(context, contact);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
if (!contact.isPublicChannel)
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.delete, color: Colors.red),
|
||||||
|
title: Text(
|
||||||
|
contact.isChannel ? l10n.deleteChannel : l10n.deleteContact,
|
||||||
|
style: const TextStyle(color: Colors.red),
|
||||||
|
),
|
||||||
|
onTap: () async {
|
||||||
|
Navigator.pop(sheetContext);
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
if (contact.isChannel) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
_showDeleteChannelDialog(context, contact);
|
if (!context.mounted) return;
|
||||||
} else {
|
if (contact.isChannel) {
|
||||||
_showDeleteConfirmation(context, contact);
|
_showDeleteChannelDialog(context, contact);
|
||||||
}
|
} else {
|
||||||
});
|
_showDeleteConfirmation(context, contact);
|
||||||
},
|
}
|
||||||
),
|
});
|
||||||
],
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -453,6 +515,18 @@ class ContactTile extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _showTraceSheet(BuildContext context, Contact contact) {
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||||
|
shape: const RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
||||||
|
),
|
||||||
|
builder: (context) => ContactTraceSheet(contact: contact),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
void _showDeleteConfirmation(
|
void _showDeleteConfirmation(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
Contact contact, {
|
Contact contact, {
|
||||||
@@ -512,6 +586,40 @@ class ContactTile extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _showNameOverrideDialog(
|
||||||
|
BuildContext context,
|
||||||
|
Contact contact,
|
||||||
|
) async {
|
||||||
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
final result = await showModalBottomSheet<String?>(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
showDragHandle: true,
|
||||||
|
builder: (dialogContext) => SafeArea(
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.only(
|
||||||
|
bottom: MediaQuery.of(dialogContext).viewInsets.bottom,
|
||||||
|
),
|
||||||
|
child: _ContactNameOverrideSheet(
|
||||||
|
initialValue: contact.nameOverride ?? '',
|
||||||
|
advertisedName: contact.advName,
|
||||||
|
cancelLabel: l10n.cancel,
|
||||||
|
saveLabel: l10n.save,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result == null || !context.mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
context.read<ContactsProvider>().setContactNameOverride(
|
||||||
|
contact.publicKeyHex,
|
||||||
|
result,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _showSetRouteDialog(
|
Future<void> _showSetRouteDialog(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
Contact contact,
|
Contact contact,
|
||||||
@@ -569,6 +677,7 @@ class ContactTile extends StatelessWidget {
|
|||||||
contact.publicKey,
|
contact.publicKey,
|
||||||
signedEncodedPathLen: parsedRoute.signedEncodedPathLen,
|
signedEncodedPathLen: parsedRoute.signedEncodedPathLen,
|
||||||
paddedPathBytes: parsedRoute.paddedPathBytes,
|
paddedPathBytes: parsedRoute.paddedPathBytes,
|
||||||
|
inferredFallbackLocation: routeResult.inferredFallbackLocation,
|
||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -649,6 +758,38 @@ class ContactTile extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildMetaPill(
|
||||||
|
BuildContext context, {
|
||||||
|
required IconData icon,
|
||||||
|
required String label,
|
||||||
|
bool monospace = false,
|
||||||
|
}) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.75),
|
||||||
|
borderRadius: BorderRadius.circular(999),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(icon, size: 12, color: colorScheme.onSurfaceVariant),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
fontFamily: monospace ? 'monospace' : null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildLocationLine(
|
Widget _buildLocationLine(
|
||||||
BuildContext context, {
|
BuildContext context, {
|
||||||
required double latitude,
|
required double latitude,
|
||||||
@@ -838,3 +979,105 @@ class ContactTile extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _ContactNameOverrideSheet extends StatefulWidget {
|
||||||
|
final String initialValue;
|
||||||
|
final String advertisedName;
|
||||||
|
final String cancelLabel;
|
||||||
|
final String saveLabel;
|
||||||
|
|
||||||
|
const _ContactNameOverrideSheet({
|
||||||
|
required this.initialValue,
|
||||||
|
required this.advertisedName,
|
||||||
|
required this.cancelLabel,
|
||||||
|
required this.saveLabel,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_ContactNameOverrideSheet> createState() =>
|
||||||
|
_ContactNameOverrideSheetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ContactNameOverrideSheetState extends State<_ContactNameOverrideSheet> {
|
||||||
|
late final TextEditingController _controller;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_controller = TextEditingController(text: widget.initialValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_controller.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _submit() {
|
||||||
|
Navigator.of(context).pop(_controller.text);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
|
return SingleChildScrollView(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(20, 8, 20, 20),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'Edit name',
|
||||||
|
style: theme.textTheme.titleLarge?.copyWith(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
icon: const Icon(Icons.close),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
TextField(
|
||||||
|
controller: _controller,
|
||||||
|
autofocus: true,
|
||||||
|
textInputAction: TextInputAction.done,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: 'Custom name',
|
||||||
|
hintText: widget.advertisedName,
|
||||||
|
helperText: 'Leave blank to use the advertised name.',
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
onSubmitted: (_) => _submit(),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: OutlinedButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
child: Text(widget.cancelLabel),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: FilledButton(
|
||||||
|
onPressed: _submit,
|
||||||
|
child: Text(widget.saveLabel),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
604
lib/widgets/contacts/contact_trace_sheet.dart
Normal file
604
lib/widgets/contacts/contact_trace_sheet.dart
Normal file
@@ -0,0 +1,604 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:math' as math;
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_map/flutter_map.dart' as flutter_map;
|
||||||
|
import 'package:latlong2/latlong.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import '../../models/contact.dart';
|
||||||
|
import '../../providers/connection_provider.dart';
|
||||||
|
import '../../providers/contacts_provider.dart';
|
||||||
|
import '../../services/mesh_map_nodes_service.dart';
|
||||||
|
import '../../utils/trace_node_resolver.dart';
|
||||||
|
|
||||||
|
class ContactTraceSheet extends StatefulWidget {
|
||||||
|
final Contact contact;
|
||||||
|
|
||||||
|
const ContactTraceSheet({super.key, required this.contact});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ContactTraceSheet> createState() => _ContactTraceSheetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ContactTraceSheetState extends State<ContactTraceSheet> {
|
||||||
|
late final Future<_ContactTraceResult> _future;
|
||||||
|
_ContactTraceResult? _traceOverride;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_future = _loadTrace();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<_ContactTraceResult> _loadTrace() async {
|
||||||
|
final connectionProvider = context.read<ConnectionProvider>();
|
||||||
|
final contactsProvider = context.read<ContactsProvider>();
|
||||||
|
final localNodes = _localNodesFromContacts(
|
||||||
|
contactsProvider,
|
||||||
|
connectionProvider: connectionProvider,
|
||||||
|
);
|
||||||
|
final localPublicKeys = localNodes.map((node) => node.publicKey).toSet();
|
||||||
|
|
||||||
|
var trace = _buildTraceResult(
|
||||||
|
nodes: localNodes,
|
||||||
|
localPublicKeys: localPublicKeys,
|
||||||
|
selfPublicKey: connectionProvider.deviceInfo.publicKey,
|
||||||
|
);
|
||||||
|
if (_isCompleteTrace(trace)) {
|
||||||
|
return trace;
|
||||||
|
}
|
||||||
|
|
||||||
|
unawaited(
|
||||||
|
MeshMapNodesService.syncInBackgroundIfStale(
|
||||||
|
cacheTtl: MeshMapNodesService.traceCacheTtl,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final remoteNodes = await MeshMapNodesService.loadCachedNodes(
|
||||||
|
cacheTtl: MeshMapNodesService.traceCacheTtl,
|
||||||
|
);
|
||||||
|
trace = _buildTraceResult(
|
||||||
|
nodes: _mergeNodes(localNodes, remoteNodes),
|
||||||
|
localPublicKeys: localPublicKeys,
|
||||||
|
selfPublicKey: connectionProvider.deviceInfo.publicKey,
|
||||||
|
);
|
||||||
|
return trace;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return SafeArea(
|
||||||
|
child: FutureBuilder<_ContactTraceResult>(
|
||||||
|
future: _future,
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
if (snapshot.connectionState != ConnectionState.done) {
|
||||||
|
return const SizedBox(
|
||||||
|
height: 360,
|
||||||
|
child: Center(child: CircularProgressIndicator()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (snapshot.hasError) {
|
||||||
|
return SizedBox(
|
||||||
|
height: 360,
|
||||||
|
child: Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Text('Failed to load trace: ${snapshot.error}'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final trace = _traceOverride ?? snapshot.data!;
|
||||||
|
final routeEntries = _displayRouteEntries(trace);
|
||||||
|
final concreteNodes = routeEntries
|
||||||
|
.where((entry) => entry.resolved.node != null)
|
||||||
|
.map((entry) => entry.resolved.node!)
|
||||||
|
.toList();
|
||||||
|
final mapPoints = concreteNodes
|
||||||
|
.map((node) => LatLng(node.latitude, node.longitude))
|
||||||
|
.toList();
|
||||||
|
final hasMapPath = mapPoints.length >= 2;
|
||||||
|
final relayNodes = trace.matchedRelayNodes
|
||||||
|
.map((entry) => entry.node)
|
||||||
|
.whereType<MeshMapNode>();
|
||||||
|
|
||||||
|
return SizedBox(
|
||||||
|
height: MediaQuery.of(context).size.height * 0.75,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Center(
|
||||||
|
child: Container(
|
||||||
|
width: 40,
|
||||||
|
height: 4,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(context).dividerColor,
|
||||||
|
borderRadius: BorderRadius.circular(2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 14, 16, 8),
|
||||||
|
child: Text(
|
||||||
|
'Trace',
|
||||||
|
style: Theme.of(context).textTheme.titleLarge,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
child: Text(
|
||||||
|
trace.routeHashes.isEmpty
|
||||||
|
? 'Direct route to ${widget.contact.displayName}'
|
||||||
|
: 'Route from saved contact path (${trace.routeHashes.length} hop${trace.routeHashes.length == 1 ? '' : 's'})',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
Expanded(
|
||||||
|
child: ListView(
|
||||||
|
padding: const EdgeInsets.only(bottom: 16),
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
child: SizedBox(
|
||||||
|
height: 240,
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
child: DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: Border.all(
|
||||||
|
color: Theme.of(context).dividerColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: hasMapPath
|
||||||
|
? flutter_map.FlutterMap(
|
||||||
|
options: flutter_map.MapOptions(
|
||||||
|
initialCameraFit:
|
||||||
|
flutter_map.CameraFit.bounds(
|
||||||
|
bounds:
|
||||||
|
flutter_map
|
||||||
|
.LatLngBounds.fromPoints(
|
||||||
|
mapPoints,
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.all(28),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
children: [
|
||||||
|
flutter_map.TileLayer(
|
||||||
|
urlTemplate:
|
||||||
|
'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||||
|
userAgentPackageName:
|
||||||
|
'com.meshcore.sar',
|
||||||
|
),
|
||||||
|
flutter_map.PolylineLayer(
|
||||||
|
polylines: [
|
||||||
|
flutter_map.Polyline(
|
||||||
|
points: mapPoints,
|
||||||
|
strokeWidth: 4,
|
||||||
|
color: Theme.of(
|
||||||
|
context,
|
||||||
|
).colorScheme.primary,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
flutter_map.MarkerLayer(
|
||||||
|
markers: concreteNodes
|
||||||
|
.asMap()
|
||||||
|
.entries
|
||||||
|
.map(
|
||||||
|
(entry) => flutter_map.Marker(
|
||||||
|
point: LatLng(
|
||||||
|
entry.value.latitude,
|
||||||
|
entry.value.longitude,
|
||||||
|
),
|
||||||
|
width: 34,
|
||||||
|
height: 34,
|
||||||
|
child: CircleAvatar(
|
||||||
|
radius: 16,
|
||||||
|
backgroundColor:
|
||||||
|
entry.key == 0
|
||||||
|
? Colors.green
|
||||||
|
: (entry.key ==
|
||||||
|
concreteNodes
|
||||||
|
.length -
|
||||||
|
1
|
||||||
|
? Colors.red
|
||||||
|
: Colors.blue),
|
||||||
|
child: Text(
|
||||||
|
'${entry.key + 1}',
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight:
|
||||||
|
FontWeight.bold,
|
||||||
|
fontSize: 11,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
: const Center(
|
||||||
|
child: Text(
|
||||||
|
'Not enough geolocated nodes to draw path',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
child: Text(
|
||||||
|
'Route',
|
||||||
|
style: Theme.of(context).textTheme.titleMedium,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (routeEntries.isEmpty)
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.symmetric(
|
||||||
|
horizontal: 16,
|
||||||
|
vertical: 8,
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'No named nodes could be matched for this trace.',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
...routeEntries.asMap().entries.map(
|
||||||
|
(entry) => ListTile(
|
||||||
|
onTap: entry.value.resolved.canCycle
|
||||||
|
? () => setState(() {
|
||||||
|
final baseTrace =
|
||||||
|
_traceOverride ?? snapshot.data!;
|
||||||
|
_traceOverride = baseTrace.cycleEntry(
|
||||||
|
entry.value.target,
|
||||||
|
);
|
||||||
|
})
|
||||||
|
: null,
|
||||||
|
leading: CircleAvatar(
|
||||||
|
radius: 14,
|
||||||
|
backgroundColor: entry.key == 0
|
||||||
|
? Colors.green
|
||||||
|
: (entry.key == routeEntries.length - 1
|
||||||
|
? Colors.red
|
||||||
|
: Colors.blue),
|
||||||
|
child: Text(
|
||||||
|
'${entry.key + 1}',
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: 11,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
title: Text(entry.value.label),
|
||||||
|
subtitle: Text(
|
||||||
|
'${_routeRoleLabel(entry.key, routeEntries.length)}${entry.value.keyLabel == null ? '' : ' • ${entry.value.keyLabel}'}${entry.value.matchSummary == null ? '' : ' • ${entry.value.matchSummary}'}${entry.value.resolved.cycleSummary == null ? '' : ' • ${entry.value.resolved.cycleSummary}'}',
|
||||||
|
),
|
||||||
|
trailing: entry.value.resolved.canCycle
|
||||||
|
? const Icon(Icons.sync_alt)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
child: Text(
|
||||||
|
'Relays (${relayNodes.length})',
|
||||||
|
style: Theme.of(context).textTheme.titleMedium,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (relayNodes.isEmpty)
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.symmetric(
|
||||||
|
horizontal: 16,
|
||||||
|
vertical: 8,
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'No relay nodes could be matched for this contact.',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
...relayNodes.map(
|
||||||
|
(node) => ListTile(
|
||||||
|
leading: const Icon(Icons.router),
|
||||||
|
title: Text(node.name),
|
||||||
|
subtitle: Text(
|
||||||
|
'${node.publicKey.substring(0, math.min(12, node.publicKey.length))} • '
|
||||||
|
'${node.latitude.toStringAsFixed(5)}, ${node.longitude.toStringAsFixed(5)}',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<_RouteDisplayEntry> _displayRouteEntries(_ContactTraceResult trace) {
|
||||||
|
final entries = <_RouteDisplayEntry>[];
|
||||||
|
if (trace.sender.node != null) {
|
||||||
|
entries.add(
|
||||||
|
_RouteDisplayEntry.fromResolved(
|
||||||
|
trace.sender,
|
||||||
|
target: const _RouteEntryTarget.sender(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
entries.addAll(
|
||||||
|
trace.matchedRelayNodes.asMap().entries.map((entry) {
|
||||||
|
final resolved = entry.value;
|
||||||
|
final node = resolved.node;
|
||||||
|
final hashHex = trace.routeHashes[entry.key].toUpperCase();
|
||||||
|
return _RouteDisplayEntry(
|
||||||
|
resolved: resolved,
|
||||||
|
label: node?.name ?? 'Unknown',
|
||||||
|
keyLabel: node != null ? _prefixKeyLabel(node.publicKey) : hashHex,
|
||||||
|
matchSummary: resolved.matchSummary,
|
||||||
|
target: _RouteEntryTarget.relayNode(entry.key),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
if (trace.recipient.node != null) {
|
||||||
|
entries.add(
|
||||||
|
_RouteDisplayEntry.fromResolved(
|
||||||
|
trace.recipient,
|
||||||
|
target: const _RouteEntryTarget.recipient(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
String _routeRoleLabel(int index, int total) {
|
||||||
|
if (index == 0) return 'Sender';
|
||||||
|
if (index == total - 1) return 'Recipient';
|
||||||
|
return 'Relay';
|
||||||
|
}
|
||||||
|
|
||||||
|
String _prefixKeyLabel(String publicKey) =>
|
||||||
|
publicKey.substring(0, math.min(12, publicKey.length));
|
||||||
|
|
||||||
|
bool _isCompleteTrace(_ContactTraceResult trace) {
|
||||||
|
if (trace.sender.node == null || trace.recipient.node == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (trace.routeHashes.isEmpty) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return trace.matchedRelayNodes.every((node) => node.node != null);
|
||||||
|
}
|
||||||
|
|
||||||
|
_ContactTraceResult _buildTraceResult({
|
||||||
|
required List<MeshMapNode> nodes,
|
||||||
|
required Set<String> localPublicKeys,
|
||||||
|
required List<int>? selfPublicKey,
|
||||||
|
}) {
|
||||||
|
final senderNode = TraceNodeResolver.resolveBest(
|
||||||
|
nodes: nodes,
|
||||||
|
localPublicKeys: localPublicKeys,
|
||||||
|
prefixHex: _toPrefixHex(selfPublicKey),
|
||||||
|
);
|
||||||
|
final recipientNode = TraceNodeResolver.resolveBest(
|
||||||
|
nodes: nodes,
|
||||||
|
localPublicKeys: localPublicKeys,
|
||||||
|
prefixHex: _toPrefixHex(widget.contact.publicKey),
|
||||||
|
);
|
||||||
|
final senderLatLng = senderNode.node == null
|
||||||
|
? null
|
||||||
|
: LatLng(senderNode.node!.latitude, senderNode.node!.longitude);
|
||||||
|
final recipientLatLng = recipientNode.node == null
|
||||||
|
? null
|
||||||
|
: LatLng(recipientNode.node!.latitude, recipientNode.node!.longitude);
|
||||||
|
final routeHashes =
|
||||||
|
widget.contact.routeHasPath && widget.contact.routeHopCount > 0
|
||||||
|
? widget.contact.routeCanonicalText
|
||||||
|
.split(',')
|
||||||
|
.where((token) => token.isNotEmpty)
|
||||||
|
.map((token) => token.toLowerCase())
|
||||||
|
.toList()
|
||||||
|
: const <String>[];
|
||||||
|
|
||||||
|
final matchedRelayNodes = routeHashes
|
||||||
|
.map(
|
||||||
|
(hash) => TraceNodeResolver.resolveBest(
|
||||||
|
nodes: nodes,
|
||||||
|
localPublicKeys: localPublicKeys,
|
||||||
|
prefixHex: hash,
|
||||||
|
referenceA: senderLatLng,
|
||||||
|
referenceB: recipientLatLng,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
|
final alignedRelayNodes = TraceNodeResolver.alignPathSelections(
|
||||||
|
nodes: matchedRelayNodes,
|
||||||
|
startNode: senderNode.node,
|
||||||
|
endNode: recipientNode.node,
|
||||||
|
);
|
||||||
|
|
||||||
|
return _ContactTraceResult(
|
||||||
|
sender: senderNode,
|
||||||
|
recipient: recipientNode,
|
||||||
|
routeHashes: routeHashes,
|
||||||
|
matchedRelayNodes: alignedRelayNodes,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _toPrefixHex(List<int>? key) {
|
||||||
|
if (key == null || key.isEmpty) return null;
|
||||||
|
final take = key.length < 6 ? key.length : 6;
|
||||||
|
return key
|
||||||
|
.take(take)
|
||||||
|
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||||
|
.join()
|
||||||
|
.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
List<MeshMapNode> _localNodesFromContacts(
|
||||||
|
ContactsProvider contactsProvider, {
|
||||||
|
required ConnectionProvider connectionProvider,
|
||||||
|
}) {
|
||||||
|
final nodes = contactsProvider.contactsWithLocation
|
||||||
|
.map((contact) {
|
||||||
|
final location = contact.displayLocation;
|
||||||
|
if (location == null) return null;
|
||||||
|
return MeshMapNode(
|
||||||
|
type: contact.type.index,
|
||||||
|
name: contact.displayName,
|
||||||
|
publicKey: contact.publicKeyHex.toLowerCase(),
|
||||||
|
latitude: location.latitude,
|
||||||
|
longitude: location.longitude,
|
||||||
|
updatedAtMs: contact.lastAdvert * 1000,
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.whereType<MeshMapNode>()
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
final selfNode = _selfNode(connectionProvider);
|
||||||
|
if (selfNode != null) {
|
||||||
|
nodes.add(selfNode);
|
||||||
|
}
|
||||||
|
return nodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
MeshMapNode? _selfNode(ConnectionProvider connectionProvider) {
|
||||||
|
final publicKey = connectionProvider.deviceInfo.publicKey;
|
||||||
|
final advLat = connectionProvider.deviceInfo.advLat;
|
||||||
|
final advLon = connectionProvider.deviceInfo.advLon;
|
||||||
|
if (publicKey == null || advLat == null || advLon == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (advLat == 0 && advLon == 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return MeshMapNode(
|
||||||
|
type: -1,
|
||||||
|
name: connectionProvider.deviceInfo.selfName?.trim().isNotEmpty == true
|
||||||
|
? connectionProvider.deviceInfo.selfName!.trim()
|
||||||
|
: 'You',
|
||||||
|
publicKey: publicKey
|
||||||
|
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||||
|
.join()
|
||||||
|
.toLowerCase(),
|
||||||
|
latitude: advLat / 1e6,
|
||||||
|
longitude: advLon / 1e6,
|
||||||
|
updatedAtMs: DateTime.now().millisecondsSinceEpoch,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<MeshMapNode> _mergeNodes(
|
||||||
|
List<MeshMapNode> preferred,
|
||||||
|
List<MeshMapNode> fallback,
|
||||||
|
) {
|
||||||
|
final merged = <String, MeshMapNode>{};
|
||||||
|
for (final node in fallback) {
|
||||||
|
merged[node.publicKey] = node;
|
||||||
|
}
|
||||||
|
for (final node in preferred) {
|
||||||
|
merged[node.publicKey] = node;
|
||||||
|
}
|
||||||
|
return merged.values.toList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ContactTraceResult {
|
||||||
|
final ResolvedTraceNode sender;
|
||||||
|
final ResolvedTraceNode recipient;
|
||||||
|
final List<String> routeHashes;
|
||||||
|
final List<ResolvedTraceNode> matchedRelayNodes;
|
||||||
|
|
||||||
|
const _ContactTraceResult({
|
||||||
|
required this.sender,
|
||||||
|
required this.recipient,
|
||||||
|
required this.routeHashes,
|
||||||
|
required this.matchedRelayNodes,
|
||||||
|
});
|
||||||
|
|
||||||
|
_ContactTraceResult cycleEntry(_RouteEntryTarget target) {
|
||||||
|
switch (target.kind) {
|
||||||
|
case _RouteEntryKind.sender:
|
||||||
|
return _ContactTraceResult(
|
||||||
|
sender: sender.cycle(),
|
||||||
|
recipient: recipient,
|
||||||
|
routeHashes: routeHashes,
|
||||||
|
matchedRelayNodes: matchedRelayNodes,
|
||||||
|
);
|
||||||
|
case _RouteEntryKind.recipient:
|
||||||
|
return _ContactTraceResult(
|
||||||
|
sender: sender,
|
||||||
|
recipient: recipient.cycle(),
|
||||||
|
routeHashes: routeHashes,
|
||||||
|
matchedRelayNodes: matchedRelayNodes,
|
||||||
|
);
|
||||||
|
case _RouteEntryKind.relayNode:
|
||||||
|
final updated = matchedRelayNodes.toList();
|
||||||
|
updated[target.index] = updated[target.index].cycle();
|
||||||
|
return _ContactTraceResult(
|
||||||
|
sender: sender,
|
||||||
|
recipient: recipient,
|
||||||
|
routeHashes: routeHashes,
|
||||||
|
matchedRelayNodes: updated,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _RouteDisplayEntry {
|
||||||
|
final ResolvedTraceNode resolved;
|
||||||
|
final String label;
|
||||||
|
final String? keyLabel;
|
||||||
|
final String? matchSummary;
|
||||||
|
final _RouteEntryTarget target;
|
||||||
|
|
||||||
|
const _RouteDisplayEntry({
|
||||||
|
required this.resolved,
|
||||||
|
required this.label,
|
||||||
|
required this.keyLabel,
|
||||||
|
required this.matchSummary,
|
||||||
|
required this.target,
|
||||||
|
});
|
||||||
|
|
||||||
|
MeshMapNode? get node => resolved.node;
|
||||||
|
|
||||||
|
factory _RouteDisplayEntry.fromResolved(
|
||||||
|
ResolvedTraceNode resolved, {
|
||||||
|
required _RouteEntryTarget target,
|
||||||
|
}) {
|
||||||
|
final node = resolved.node!;
|
||||||
|
return _RouteDisplayEntry(
|
||||||
|
resolved: resolved,
|
||||||
|
label: node.name,
|
||||||
|
keyLabel: node.publicKey.substring(
|
||||||
|
0,
|
||||||
|
math.min(12, node.publicKey.length),
|
||||||
|
),
|
||||||
|
matchSummary: resolved.matchSummary,
|
||||||
|
target: target,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum _RouteEntryKind { sender, recipient, relayNode }
|
||||||
|
|
||||||
|
class _RouteEntryTarget {
|
||||||
|
final _RouteEntryKind kind;
|
||||||
|
final int index;
|
||||||
|
|
||||||
|
const _RouteEntryTarget._(this.kind, [this.index = 0]);
|
||||||
|
|
||||||
|
const _RouteEntryTarget.sender() : this._(_RouteEntryKind.sender);
|
||||||
|
const _RouteEntryTarget.recipient() : this._(_RouteEntryKind.recipient);
|
||||||
|
const _RouteEntryTarget.relayNode(int index)
|
||||||
|
: this._(_RouteEntryKind.relayNode, index);
|
||||||
|
}
|
||||||
@@ -104,6 +104,7 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
|
|
||||||
final textColor =
|
final textColor =
|
||||||
baseBodyStyle?.color ?? Theme.of(context).colorScheme.onSurface;
|
baseBodyStyle?.color ?? Theme.of(context).colorScheme.onSurface;
|
||||||
|
final mentionFontSize = (baseBodyStyle?.fontSize ?? 14) - 1;
|
||||||
final backgroundColor = Theme.of(
|
final backgroundColor = Theme.of(
|
||||||
context,
|
context,
|
||||||
).colorScheme.primary.withValues(alpha: 0.12);
|
).colorScheme.primary.withValues(alpha: 0.12);
|
||||||
@@ -130,8 +131,8 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
WidgetSpan(
|
WidgetSpan(
|
||||||
alignment: PlaceholderAlignment.middle,
|
alignment: PlaceholderAlignment.middle,
|
||||||
child: Container(
|
child: Container(
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 1, vertical: 1),
|
margin: const EdgeInsets.symmetric(horizontal: 1),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: backgroundColor,
|
color: backgroundColor,
|
||||||
borderRadius: BorderRadius.circular(999),
|
borderRadius: BorderRadius.circular(999),
|
||||||
@@ -141,8 +142,9 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
'@$mentionName',
|
'@$mentionName',
|
||||||
style: baseBodyStyle?.copyWith(
|
style: baseBodyStyle?.copyWith(
|
||||||
color: textColor,
|
color: textColor,
|
||||||
fontWeight: FontWeight.w700,
|
fontSize: mentionFontSize,
|
||||||
height: 1.1,
|
fontWeight: FontWeight.w600,
|
||||||
|
height: 1.0,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -1788,6 +1790,7 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
receptionDetails?.rssiDbm ??
|
receptionDetails?.rssiDbm ??
|
||||||
matchedRxLog?.logRxDataInfo?.rssiDbm ??
|
matchedRxLog?.logRxDataInfo?.rssiDbm ??
|
||||||
message.lastEchoRssiDbm;
|
message.lastEchoRssiDbm;
|
||||||
|
final routeMetadata = messagesProvider.getMessageRouteMetadata(message.id);
|
||||||
|
|
||||||
// Look up contact information for rich display name
|
// Look up contact information for rich display name
|
||||||
final contactsProvider = context.read<ContactsProvider>();
|
final contactsProvider = context.read<ContactsProvider>();
|
||||||
@@ -2071,9 +2074,11 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
displayName,
|
displayName,
|
||||||
style: Theme.of(context).textTheme.labelMedium
|
style: Theme.of(context).textTheme.bodySmall
|
||||||
?.copyWith(
|
?.copyWith(
|
||||||
|
fontSize: 12,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
|
height: 1.1,
|
||||||
color: isOwnMessage
|
color: isOwnMessage
|
||||||
? Theme.of(context).colorScheme.primary
|
? Theme.of(context).colorScheme.primary
|
||||||
: null,
|
: null,
|
||||||
@@ -2515,7 +2520,7 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
switch (recipient.deliveryStatus) {
|
switch (recipient.deliveryStatus) {
|
||||||
case MessageDeliveryStatus.delivered:
|
case MessageDeliveryStatus.delivered:
|
||||||
statusColor = Colors.green;
|
statusColor = Colors.green;
|
||||||
statusIcon = Icons.check_circle;
|
statusIcon = Icons.done_all;
|
||||||
statusText =
|
statusText =
|
||||||
recipient.roundTripTimeMs != null
|
recipient.roundTripTimeMs != null
|
||||||
? '${recipient.roundTripTimeMs}ms'
|
? '${recipient.roundTripTimeMs}ms'
|
||||||
@@ -2523,6 +2528,15 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
context,
|
context,
|
||||||
)!.delivered;
|
)!.delivered;
|
||||||
break;
|
break;
|
||||||
|
case MessageDeliveryStatus.sent:
|
||||||
|
statusColor = Theme.of(
|
||||||
|
context,
|
||||||
|
).colorScheme.onSurfaceVariant;
|
||||||
|
statusIcon = Icons.done;
|
||||||
|
statusText = AppLocalizations.of(
|
||||||
|
context,
|
||||||
|
)!.sent;
|
||||||
|
break;
|
||||||
case MessageDeliveryStatus.failed:
|
case MessageDeliveryStatus.failed:
|
||||||
statusColor = Colors.red;
|
statusColor = Colors.red;
|
||||||
statusIcon = Icons.cancel;
|
statusIcon = Icons.cancel;
|
||||||
@@ -2531,7 +2545,6 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
)!.failed;
|
)!.failed;
|
||||||
break;
|
break;
|
||||||
case MessageDeliveryStatus.sending:
|
case MessageDeliveryStatus.sending:
|
||||||
case MessageDeliveryStatus.sent:
|
|
||||||
default:
|
default:
|
||||||
statusColor = Colors.orange;
|
statusColor = Colors.orange;
|
||||||
statusIcon = Icons.schedule;
|
statusIcon = Icons.schedule;
|
||||||
@@ -2726,6 +2739,7 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
context,
|
context,
|
||||||
message: message,
|
message: message,
|
||||||
isSarMarker: isSarMarker,
|
isSarMarker: isSarMarker,
|
||||||
|
routeMetadata: routeMetadata,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
|||||||
|
|
||||||
import '../../models/contact.dart';
|
import '../../models/contact.dart';
|
||||||
import '../../models/message.dart';
|
import '../../models/message.dart';
|
||||||
|
import '../../models/message_route_metadata.dart';
|
||||||
import '../../utils/avatar_label_helper.dart';
|
import '../../utils/avatar_label_helper.dart';
|
||||||
import '../../utils/message_extensions.dart';
|
import '../../utils/message_extensions.dart';
|
||||||
import '../common/contact_avatar.dart';
|
import '../common/contact_avatar.dart';
|
||||||
@@ -59,6 +60,7 @@ Widget buildBubbleMetaFooter(
|
|||||||
BuildContext context, {
|
BuildContext context, {
|
||||||
required Message message,
|
required Message message,
|
||||||
required bool isSarMarker,
|
required bool isSarMarker,
|
||||||
|
MessageRouteMetadata? routeMetadata,
|
||||||
}) {
|
}) {
|
||||||
final metaColor = Theme.of(
|
final metaColor = Theme.of(
|
||||||
context,
|
context,
|
||||||
@@ -86,12 +88,13 @@ Widget buildBubbleMetaFooter(
|
|||||||
).textTheme.labelSmall?.copyWith(color: metaColor),
|
).textTheme.labelSmall?.copyWith(color: metaColor),
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
} else if (!isSarMarker && message.pathLen < 255) {
|
} else if (!isSarMarker && _effectivePathLen(message, routeMetadata) < 255) {
|
||||||
|
final effectivePathLen = _effectivePathLen(message, routeMetadata);
|
||||||
items.addAll([
|
items.addAll([
|
||||||
Icon(Icons.alt_route, size: 11, color: metaColor),
|
Icon(Icons.alt_route, size: 11, color: metaColor),
|
||||||
const SizedBox(width: 3),
|
const SizedBox(width: 3),
|
||||||
Text(
|
Text(
|
||||||
message.pathLen == 0 ? 'direct' : '${message.pathLen}hop',
|
effectivePathLen == 0 ? 'direct' : '${effectivePathLen}hop',
|
||||||
style: Theme.of(
|
style: Theme.of(
|
||||||
context,
|
context,
|
||||||
).textTheme.labelSmall?.copyWith(color: metaColor),
|
).textTheme.labelSmall?.copyWith(color: metaColor),
|
||||||
@@ -124,17 +127,27 @@ Widget buildBubbleMetaFooter(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
int _effectivePathLen(Message message, MessageRouteMetadata? routeMetadata) =>
|
||||||
|
routeMetadata?.hopCount ?? message.pathLen;
|
||||||
|
|
||||||
Widget buildChannelHeaderPill(
|
Widget buildChannelHeaderPill(
|
||||||
BuildContext context, {
|
BuildContext context, {
|
||||||
required String label,
|
required String label,
|
||||||
IconData icon = Icons.campaign_outlined,
|
IconData icon = Icons.campaign_outlined,
|
||||||
|
EdgeInsetsGeometry padding = const EdgeInsets.symmetric(
|
||||||
|
horizontal: 8,
|
||||||
|
vertical: 3,
|
||||||
|
),
|
||||||
|
double iconSize = 11,
|
||||||
|
double iconSpacing = 5,
|
||||||
|
TextStyle? textStyle,
|
||||||
}) {
|
}) {
|
||||||
final labelColor = Theme.of(
|
final labelColor = Theme.of(
|
||||||
context,
|
context,
|
||||||
).textTheme.labelSmall?.color?.withValues(alpha: 0.82);
|
).textTheme.labelSmall?.color?.withValues(alpha: 0.82);
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
padding: padding,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Theme.of(
|
color: Theme.of(
|
||||||
context,
|
context,
|
||||||
@@ -146,19 +159,21 @@ Widget buildChannelHeaderPill(
|
|||||||
children: [
|
children: [
|
||||||
Icon(
|
Icon(
|
||||||
icon,
|
icon,
|
||||||
size: 11,
|
size: iconSize,
|
||||||
color: Theme.of(
|
color: Theme.of(
|
||||||
context,
|
context,
|
||||||
).textTheme.labelSmall?.color?.withValues(alpha: 0.7),
|
).textTheme.labelSmall?.color?.withValues(alpha: 0.7),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 5),
|
SizedBox(width: iconSpacing),
|
||||||
Flexible(
|
Flexible(
|
||||||
child: Text(
|
child: Text(
|
||||||
label,
|
label,
|
||||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
style:
|
||||||
color: labelColor,
|
textStyle ??
|
||||||
fontWeight: FontWeight.w600,
|
Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||||
),
|
color: labelColor,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
@@ -176,5 +191,16 @@ Widget buildDirectHeaderCounterpart(
|
|||||||
context,
|
context,
|
||||||
label: label,
|
label: label,
|
||||||
icon: Icons.alternate_email,
|
icon: Icons.alternate_email,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||||
|
iconSize: 10,
|
||||||
|
iconSpacing: 4,
|
||||||
|
textStyle: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||||
|
fontSize: 10,
|
||||||
|
height: 1.0,
|
||||||
|
color: Theme.of(
|
||||||
|
context,
|
||||||
|
).textTheme.labelSmall?.color?.withValues(alpha: 0.82),
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
import '../../models/message.dart';
|
import '../../models/message.dart';
|
||||||
|
import '../../models/message_route_metadata.dart';
|
||||||
import '../../models/path_selection.dart';
|
import '../../models/path_selection.dart';
|
||||||
import '../../models/message_reception_details.dart';
|
import '../../models/message_reception_details.dart';
|
||||||
import '../../providers/messages_provider.dart';
|
import '../../providers/messages_provider.dart';
|
||||||
@@ -209,7 +210,7 @@ Widget buildSentDirectSignalStatus(
|
|||||||
_techChip(
|
_techChip(
|
||||||
context,
|
context,
|
||||||
icon: Icons.alt_route,
|
icon: Icons.alt_route,
|
||||||
label: hopDisplayLabel(message),
|
label: hopDisplayLabelForMessage(message, routeMetadata),
|
||||||
color: Colors.indigo,
|
color: Colors.indigo,
|
||||||
),
|
),
|
||||||
_techChip(
|
_techChip(
|
||||||
@@ -294,6 +295,17 @@ String hopDisplayLabel(Message message) {
|
|||||||
return '${message.pathLen} hop${message.pathLen == 1 ? '' : 's'}';
|
return '${message.pathLen} hop${message.pathLen == 1 ? '' : 's'}';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String hopDisplayLabelForMessage(
|
||||||
|
Message message,
|
||||||
|
MessageRouteMetadata? routeMetadata,
|
||||||
|
) {
|
||||||
|
final effectivePathLen = routeMetadata?.hopCount ?? message.pathLen;
|
||||||
|
if (effectivePathLen == 0) return 'Direct';
|
||||||
|
if (effectivePathLen >= 255 && message.isContactMessage) return 'Direct';
|
||||||
|
if (effectivePathLen >= 255) return 'Unknown';
|
||||||
|
return '$effectivePathLen hop${effectivePathLen == 1 ? '' : 's'}';
|
||||||
|
}
|
||||||
|
|
||||||
Widget _techChip(
|
Widget _techChip(
|
||||||
BuildContext context, {
|
BuildContext context, {
|
||||||
required IconData icon,
|
required IconData icon,
|
||||||
|
|||||||
@@ -10,9 +10,11 @@ import '../../models/ble_packet_log.dart';
|
|||||||
import '../../models/message.dart';
|
import '../../models/message.dart';
|
||||||
import '../../providers/connection_provider.dart';
|
import '../../providers/connection_provider.dart';
|
||||||
import '../../providers/contacts_provider.dart';
|
import '../../providers/contacts_provider.dart';
|
||||||
|
import '../../providers/messages_provider.dart';
|
||||||
import '../../services/mesh_map_nodes_service.dart';
|
import '../../services/mesh_map_nodes_service.dart';
|
||||||
import '../../services/route_hash_preferences.dart';
|
import '../../services/route_hash_preferences.dart';
|
||||||
import '../../utils/log_rx_route_decoder.dart';
|
import '../../utils/log_rx_route_decoder.dart';
|
||||||
|
import '../../utils/trace_node_resolver.dart';
|
||||||
|
|
||||||
class MessageTraceSheet extends StatefulWidget {
|
class MessageTraceSheet extends StatefulWidget {
|
||||||
final Message message;
|
final Message message;
|
||||||
@@ -25,6 +27,7 @@ class MessageTraceSheet extends StatefulWidget {
|
|||||||
|
|
||||||
class _MessageTraceSheetState extends State<MessageTraceSheet> {
|
class _MessageTraceSheetState extends State<MessageTraceSheet> {
|
||||||
late final Future<_TraceResult> _future;
|
late final Future<_TraceResult> _future;
|
||||||
|
_TraceResult? _traceOverride;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -35,20 +38,27 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
|
|||||||
Future<_TraceResult> _loadTrace() async {
|
Future<_TraceResult> _loadTrace() async {
|
||||||
final connectionProvider = context.read<ConnectionProvider>();
|
final connectionProvider = context.read<ConnectionProvider>();
|
||||||
final contactsProvider = context.read<ContactsProvider>();
|
final contactsProvider = context.read<ContactsProvider>();
|
||||||
|
final messagesProvider = context.read<MessagesProvider>();
|
||||||
final preferredHashSize = await RouteHashPreferences.getHashSize();
|
final preferredHashSize = await RouteHashPreferences.getHashSize();
|
||||||
final packetPath = _extractPathFromPacketLogs(
|
final storedPath = messagesProvider
|
||||||
logs: connectionProvider.bleService.packetLogs,
|
.getMessageReceptionDetails(widget.message.id)
|
||||||
message: widget.message,
|
?.pathBytes;
|
||||||
);
|
final packetPath = (storedPath != null && storedPath.isNotEmpty)
|
||||||
|
? storedPath
|
||||||
|
: _extractPathFromPacketLogs(
|
||||||
|
logs: connectionProvider.bleService.packetLogs,
|
||||||
|
message: widget.message,
|
||||||
|
);
|
||||||
|
|
||||||
final senderPrefix = _toPrefixHex(widget.message.senderPublicKeyPrefix);
|
final senderPrefix = _toPrefixHex(widget.message.senderPublicKeyPrefix);
|
||||||
final recipientPrefix = widget.message.recipientPublicKey != null
|
final recipientPrefix = widget.message.recipientPublicKey != null
|
||||||
? _toPrefixHex(widget.message.recipientPublicKey)
|
? _toPrefixHex(widget.message.recipientPublicKey)
|
||||||
: _toPrefixHex(connectionProvider.deviceInfo.publicKey);
|
: _toPrefixHex(connectionProvider.deviceInfo.publicKey);
|
||||||
|
|
||||||
final localNodes = _localNodesFromContacts(contactsProvider);
|
final localNodes = _localNodesFromContacts(contactsProvider);
|
||||||
|
final localPublicKeys = localNodes.map((node) => node.publicKey).toSet();
|
||||||
var trace = _buildTraceResult(
|
var trace = _buildTraceResult(
|
||||||
nodes: localNodes,
|
nodes: localNodes,
|
||||||
|
localPublicKeys: localPublicKeys,
|
||||||
packetPath: packetPath,
|
packetPath: packetPath,
|
||||||
preferredHashSize: preferredHashSize,
|
preferredHashSize: preferredHashSize,
|
||||||
senderPrefix: senderPrefix,
|
senderPrefix: senderPrefix,
|
||||||
@@ -72,6 +82,7 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
|
|||||||
);
|
);
|
||||||
trace = _buildTraceResult(
|
trace = _buildTraceResult(
|
||||||
nodes: _mergeNodes(localNodes, remoteNodes),
|
nodes: _mergeNodes(localNodes, remoteNodes),
|
||||||
|
localPublicKeys: localPublicKeys,
|
||||||
packetPath: packetPath,
|
packetPath: packetPath,
|
||||||
preferredHashSize: preferredHashSize,
|
preferredHashSize: preferredHashSize,
|
||||||
senderPrefix: senderPrefix,
|
senderPrefix: senderPrefix,
|
||||||
@@ -104,11 +115,11 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final trace = snapshot.data!;
|
final trace = _traceOverride ?? snapshot.data!;
|
||||||
final routeEntries = _displayRouteEntries(trace);
|
final routeEntries = _displayRouteEntries(trace);
|
||||||
final concretePathNodes = routeEntries
|
final concretePathNodes = routeEntries
|
||||||
.where((entry) => entry.node != null)
|
.where((entry) => entry.resolved.node != null)
|
||||||
.map((entry) => entry.node!)
|
.map((entry) => entry.resolved.node!)
|
||||||
.toList();
|
.toList();
|
||||||
final mapPoints = concretePathNodes
|
final mapPoints = concretePathNodes
|
||||||
.map((n) => LatLng(n.latitude, n.longitude))
|
.map((n) => LatLng(n.latitude, n.longitude))
|
||||||
@@ -264,6 +275,15 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
|
|||||||
),
|
),
|
||||||
...routeEntries.asMap().entries.map(
|
...routeEntries.asMap().entries.map(
|
||||||
(entry) => ListTile(
|
(entry) => ListTile(
|
||||||
|
onTap: entry.value.resolved.canCycle
|
||||||
|
? () => setState(() {
|
||||||
|
final baseTrace =
|
||||||
|
_traceOverride ?? snapshot.data!;
|
||||||
|
_traceOverride = baseTrace.cycleEntry(
|
||||||
|
entry.value.target,
|
||||||
|
);
|
||||||
|
})
|
||||||
|
: null,
|
||||||
leading: CircleAvatar(
|
leading: CircleAvatar(
|
||||||
radius: 14,
|
radius: 14,
|
||||||
backgroundColor: entry.key == 0
|
backgroundColor: entry.key == 0
|
||||||
@@ -282,8 +302,11 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
|
|||||||
),
|
),
|
||||||
title: Text(entry.value.label),
|
title: Text(entry.value.label),
|
||||||
subtitle: Text(
|
subtitle: Text(
|
||||||
'${_routeRoleLabel(entry.key, routeEntries.length)}${entry.value.keyLabel == null ? '' : ' • ${entry.value.keyLabel}'}',
|
'${_routeRoleLabel(entry.key, routeEntries.length)}${entry.value.keyLabel == null ? '' : ' • ${entry.value.keyLabel}'}${entry.value.matchSummary == null ? '' : ' • ${entry.value.matchSummary}'}${entry.value.resolved.cycleSummary == null ? '' : ' • ${entry.value.resolved.cycleSummary}'}',
|
||||||
),
|
),
|
||||||
|
trailing: entry.value.resolved.canCycle
|
||||||
|
? const Icon(Icons.sync_alt)
|
||||||
|
: null,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
@@ -326,7 +349,10 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
List<MeshMapNode> _relayNodes(_TraceResult trace) {
|
List<MeshMapNode> _relayNodes(_TraceResult trace) {
|
||||||
final concrete = trace.matchedPathNodes.whereType<MeshMapNode>().toList();
|
final concrete = trace.matchedPathNodes
|
||||||
|
.map((entry) => entry.node)
|
||||||
|
.whereType<MeshMapNode>()
|
||||||
|
.toList();
|
||||||
if (concrete.isEmpty) return const [];
|
if (concrete.isEmpty) return const [];
|
||||||
|
|
||||||
if (trace.mode == TraceMode.packetPath) {
|
if (trace.mode == TraceMode.packetPath) {
|
||||||
@@ -339,13 +365,23 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
List<_RouteDisplayEntry> _displayRouteEntries(_TraceResult trace) {
|
List<_RouteDisplayEntry> _displayRouteEntries(_TraceResult trace) {
|
||||||
final pathNodes = trace.matchedPathNodes.whereType<MeshMapNode>().toList();
|
final pathNodes = trace.matchedPathNodes
|
||||||
|
.map((entry) => entry.node)
|
||||||
|
.whereType<MeshMapNode>()
|
||||||
|
.toList();
|
||||||
if (pathNodes.isEmpty) {
|
if (pathNodes.isEmpty) {
|
||||||
return [
|
return [
|
||||||
if (trace.sender != null) _RouteDisplayEntry.fromNode(trace.sender!),
|
if (trace.sender.node != null)
|
||||||
if (trace.recipient != null &&
|
_RouteDisplayEntry.fromResolved(
|
||||||
trace.recipient!.publicKey != trace.sender?.publicKey)
|
trace.sender,
|
||||||
_RouteDisplayEntry.fromNode(trace.recipient!),
|
target: const _RouteEntryTarget.sender(),
|
||||||
|
),
|
||||||
|
if (trace.recipient.node != null &&
|
||||||
|
trace.recipient.node!.publicKey != trace.sender.node?.publicKey)
|
||||||
|
_RouteDisplayEntry.fromResolved(
|
||||||
|
trace.recipient,
|
||||||
|
target: const _RouteEntryTarget.recipient(),
|
||||||
|
),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -353,29 +389,51 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
|
|||||||
final entries = trace.matchedPathNodes.asMap().entries.map((entry) {
|
final entries = trace.matchedPathNodes.asMap().entries.map((entry) {
|
||||||
final hashHex = trace.pathHashes[entry.key].toUpperCase();
|
final hashHex = trace.pathHashes[entry.key].toUpperCase();
|
||||||
return _RouteDisplayEntry(
|
return _RouteDisplayEntry(
|
||||||
node: entry.value,
|
resolved: entry.value,
|
||||||
label: entry.value?.name ?? 'Unknown',
|
label: entry.value.node?.name ?? 'Unknown',
|
||||||
keyLabel: entry.value != null
|
keyLabel: entry.value.node != null
|
||||||
? _prefixKeyLabel(entry.value!.publicKey)
|
? _prefixKeyLabel(entry.value.node!.publicKey)
|
||||||
: hashHex,
|
: hashHex,
|
||||||
|
matchSummary: entry.value.matchSummary,
|
||||||
|
target: _RouteEntryTarget.pathNode(entry.key),
|
||||||
);
|
);
|
||||||
}).toList();
|
}).toList();
|
||||||
final lastKey = pathNodes.last.publicKey;
|
final lastKey = pathNodes.last.publicKey;
|
||||||
return [
|
return [
|
||||||
...entries,
|
...entries,
|
||||||
if (trace.recipient != null && trace.recipient!.publicKey != lastKey)
|
if (trace.recipient.node != null &&
|
||||||
_RouteDisplayEntry.fromNode(trace.recipient!),
|
trace.recipient.node!.publicKey != lastKey)
|
||||||
|
_RouteDisplayEntry.fromResolved(
|
||||||
|
trace.recipient,
|
||||||
|
target: const _RouteEntryTarget.recipient(),
|
||||||
|
),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
final firstKey = pathNodes.first.publicKey;
|
final firstKey = pathNodes.first.publicKey;
|
||||||
final lastKey = pathNodes.last.publicKey;
|
final lastKey = pathNodes.last.publicKey;
|
||||||
return [
|
return [
|
||||||
if (trace.sender != null && trace.sender!.publicKey != firstKey)
|
if (trace.sender.node != null && trace.sender.node!.publicKey != firstKey)
|
||||||
_RouteDisplayEntry.fromNode(trace.sender!),
|
_RouteDisplayEntry.fromResolved(
|
||||||
...pathNodes.map(_RouteDisplayEntry.fromNode),
|
trace.sender,
|
||||||
if (trace.recipient != null && trace.recipient!.publicKey != lastKey)
|
target: const _RouteEntryTarget.sender(),
|
||||||
_RouteDisplayEntry.fromNode(trace.recipient!),
|
),
|
||||||
|
...trace.matchedPathNodes
|
||||||
|
.asMap()
|
||||||
|
.entries
|
||||||
|
.where((entry) => entry.value.node != null)
|
||||||
|
.map(
|
||||||
|
(entry) => _RouteDisplayEntry.fromResolved(
|
||||||
|
entry.value,
|
||||||
|
target: _RouteEntryTarget.pathNode(entry.key),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (trace.recipient.node != null &&
|
||||||
|
trace.recipient.node!.publicKey != lastKey)
|
||||||
|
_RouteDisplayEntry.fromResolved(
|
||||||
|
trace.recipient,
|
||||||
|
target: const _RouteEntryTarget.recipient(),
|
||||||
|
),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -398,14 +456,6 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
|
|||||||
.toLowerCase();
|
.toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
MeshMapNode? _bestNodeForPrefix(List<MeshMapNode> nodes, String? prefixHex) {
|
|
||||||
if (prefixHex == null || prefixHex.isEmpty) return null;
|
|
||||||
final matches =
|
|
||||||
nodes.where((n) => n.publicKey.startsWith(prefixHex)).toList()
|
|
||||||
..sort((a, b) => b.updatedAtMs.compareTo(a.updatedAtMs));
|
|
||||||
return matches.isEmpty ? null : matches.first;
|
|
||||||
}
|
|
||||||
|
|
||||||
List<MeshMapNode> _localNodesFromContacts(ContactsProvider contactsProvider) {
|
List<MeshMapNode> _localNodesFromContacts(ContactsProvider contactsProvider) {
|
||||||
return contactsProvider.contactsWithLocation
|
return contactsProvider.contactsWithLocation
|
||||||
.map((contact) {
|
.map((contact) {
|
||||||
@@ -440,13 +490,28 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
|
|||||||
|
|
||||||
_TraceResult _buildTraceResult({
|
_TraceResult _buildTraceResult({
|
||||||
required List<MeshMapNode> nodes,
|
required List<MeshMapNode> nodes,
|
||||||
|
required Set<String> localPublicKeys,
|
||||||
required List<int>? packetPath,
|
required List<int>? packetPath,
|
||||||
required int preferredHashSize,
|
required int preferredHashSize,
|
||||||
required String? senderPrefix,
|
required String? senderPrefix,
|
||||||
required String? recipientPrefix,
|
required String? recipientPrefix,
|
||||||
}) {
|
}) {
|
||||||
final senderNode = _bestNodeForPrefix(nodes, senderPrefix);
|
final senderNode = TraceNodeResolver.resolveBest(
|
||||||
final recipientNode = _bestNodeForPrefix(nodes, recipientPrefix);
|
nodes: nodes,
|
||||||
|
localPublicKeys: localPublicKeys,
|
||||||
|
prefixHex: senderPrefix,
|
||||||
|
);
|
||||||
|
final recipientNode = TraceNodeResolver.resolveBest(
|
||||||
|
nodes: nodes,
|
||||||
|
localPublicKeys: localPublicKeys,
|
||||||
|
prefixHex: recipientPrefix,
|
||||||
|
);
|
||||||
|
final senderLatLng = senderNode.node == null
|
||||||
|
? null
|
||||||
|
: LatLng(senderNode.node!.latitude, senderNode.node!.longitude);
|
||||||
|
final recipientLatLng = recipientNode.node == null
|
||||||
|
? null
|
||||||
|
: LatLng(recipientNode.node!.latitude, recipientNode.node!.longitude);
|
||||||
|
|
||||||
if (packetPath != null && packetPath.isNotEmpty) {
|
if (packetPath != null && packetPath.isNotEmpty) {
|
||||||
final hashSize = LogRxRouteDecoder.inferHashSize(
|
final hashSize = LogRxRouteDecoder.inferHashSize(
|
||||||
@@ -456,32 +521,46 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
|
|||||||
final hopHashes = LogRxRouteDecoder.splitHopHashes(
|
final hopHashes = LogRxRouteDecoder.splitHopHashes(
|
||||||
packetPath,
|
packetPath,
|
||||||
hashSize: hashSize,
|
hashSize: hashSize,
|
||||||
);
|
).reversed.toList();
|
||||||
final matched = _matchNodesFromPathHashes(
|
final matched = _matchNodesFromPathHashes(
|
||||||
nodes: nodes,
|
nodes: nodes,
|
||||||
|
localPublicKeys: localPublicKeys,
|
||||||
pathHashes: hopHashes,
|
pathHashes: hopHashes,
|
||||||
senderPrefix: senderPrefix,
|
senderPrefix: senderPrefix,
|
||||||
recipientPrefix: recipientPrefix,
|
recipientPrefix: recipientPrefix,
|
||||||
|
senderLatLng: senderLatLng,
|
||||||
|
recipientLatLng: recipientLatLng,
|
||||||
|
);
|
||||||
|
final alignedMatched = TraceNodeResolver.alignPathSelections(
|
||||||
|
nodes: matched,
|
||||||
|
startNode: senderNode.node,
|
||||||
|
endNode: recipientNode.node,
|
||||||
);
|
);
|
||||||
return _TraceResult(
|
return _TraceResult(
|
||||||
mode: TraceMode.packetPath,
|
mode: TraceMode.packetPath,
|
||||||
sender: senderNode,
|
sender: senderNode,
|
||||||
recipient: recipientNode,
|
recipient: recipientNode,
|
||||||
pathHashes: hopHashes,
|
pathHashes: hopHashes,
|
||||||
matchedPathNodes: matched,
|
matchedPathNodes: alignedMatched,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final inferred = _inferRelaysFromHopCount(
|
final inferred = _inferRelaysFromHopCount(
|
||||||
nodes: nodes,
|
nodes: nodes,
|
||||||
sender: senderNode,
|
sender: senderNode.node,
|
||||||
recipient: recipientNode,
|
recipient: recipientNode.node,
|
||||||
relayCount: math.max(0, widget.message.pathLen),
|
relayCount: math.max(0, widget.message.pathLen),
|
||||||
);
|
);
|
||||||
final matchedPathNodes = <MeshMapNode?>[
|
final matchedPathNodes = <ResolvedTraceNode>[
|
||||||
if (senderNode != null) senderNode,
|
if (senderNode.node != null) senderNode,
|
||||||
...inferred,
|
...inferred.map(
|
||||||
if (recipientNode != null) recipientNode,
|
(node) => ResolvedTraceNode(
|
||||||
|
candidates: [node],
|
||||||
|
matchCount: 1,
|
||||||
|
usedOnlineFallback: false,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (recipientNode.node != null) recipientNode,
|
||||||
];
|
];
|
||||||
|
|
||||||
return _TraceResult(
|
return _TraceResult(
|
||||||
@@ -494,16 +573,17 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
bool _isCompleteTrace(_TraceResult trace, {required int expectedRelayCount}) {
|
bool _isCompleteTrace(_TraceResult trace, {required int expectedRelayCount}) {
|
||||||
if (trace.sender == null || trace.recipient == null) {
|
if (trace.sender.node == null || trace.recipient.node == null) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (trace.mode == TraceMode.packetPath) {
|
if (trace.mode == TraceMode.packetPath) {
|
||||||
return trace.matchedPathNodes.length == trace.pathHashes.length &&
|
return trace.matchedPathNodes.length == trace.pathHashes.length &&
|
||||||
trace.matchedPathNodes.every((node) => node != null);
|
trace.matchedPathNodes.every((node) => node.node != null);
|
||||||
}
|
}
|
||||||
|
|
||||||
final concreteCount = trace.matchedPathNodes
|
final concreteCount = trace.matchedPathNodes
|
||||||
|
.map((entry) => entry.node)
|
||||||
.whereType<MeshMapNode>()
|
.whereType<MeshMapNode>()
|
||||||
.length;
|
.length;
|
||||||
return concreteCount >= expectedRelayCount + 2;
|
return concreteCount >= expectedRelayCount + 2;
|
||||||
@@ -544,33 +624,30 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
|
|||||||
return decoded.pathBytes;
|
return decoded.pathBytes;
|
||||||
}
|
}
|
||||||
|
|
||||||
List<MeshMapNode?> _matchNodesFromPathHashes({
|
List<ResolvedTraceNode> _matchNodesFromPathHashes({
|
||||||
required List<MeshMapNode> nodes,
|
required List<MeshMapNode> nodes,
|
||||||
|
required Set<String> localPublicKeys,
|
||||||
required List<String> pathHashes,
|
required List<String> pathHashes,
|
||||||
required String? senderPrefix,
|
required String? senderPrefix,
|
||||||
required String? recipientPrefix,
|
required String? recipientPrefix,
|
||||||
|
required LatLng? senderLatLng,
|
||||||
|
required LatLng? recipientLatLng,
|
||||||
}) {
|
}) {
|
||||||
final result = <MeshMapNode?>[];
|
final result = <ResolvedTraceNode>[];
|
||||||
for (var i = 0; i < pathHashes.length; i++) {
|
for (var i = 0; i < pathHashes.length; i++) {
|
||||||
final hashHex = pathHashes[i].toLowerCase();
|
final hashHex = pathHashes[i].toLowerCase();
|
||||||
final candidates = nodes
|
result.add(
|
||||||
.where((n) => n.publicKey.startsWith(hashHex))
|
TraceNodeResolver.resolveBest(
|
||||||
.toList();
|
nodes: nodes,
|
||||||
if (candidates.isEmpty) {
|
localPublicKeys: localPublicKeys,
|
||||||
result.add(null);
|
prefixHex: hashHex,
|
||||||
continue;
|
preferredPrefix: i == 0
|
||||||
}
|
? senderPrefix
|
||||||
|
: (i == pathHashes.length - 1 ? recipientPrefix : null),
|
||||||
List<MeshMapNode> filtered = candidates;
|
referenceA: senderLatLng,
|
||||||
if (i == 0 && senderPrefix != null) {
|
referenceB: recipientLatLng,
|
||||||
final senderMatches = filtered
|
),
|
||||||
.where((n) => n.publicKey.startsWith(senderPrefix))
|
);
|
||||||
.toList();
|
|
||||||
if (senderMatches.isNotEmpty) filtered = senderMatches;
|
|
||||||
}
|
|
||||||
|
|
||||||
filtered.sort((a, b) => b.updatedAtMs.compareTo(a.updatedAtMs));
|
|
||||||
result.add(filtered.first);
|
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
@@ -639,10 +716,10 @@ enum TraceMode { packetPath, hopCountInference }
|
|||||||
|
|
||||||
class _TraceResult {
|
class _TraceResult {
|
||||||
final TraceMode mode;
|
final TraceMode mode;
|
||||||
final MeshMapNode? sender;
|
final ResolvedTraceNode sender;
|
||||||
final MeshMapNode? recipient;
|
final ResolvedTraceNode recipient;
|
||||||
final List<String> pathHashes;
|
final List<String> pathHashes;
|
||||||
final List<MeshMapNode?> matchedPathNodes;
|
final List<ResolvedTraceNode> matchedPathNodes;
|
||||||
|
|
||||||
const _TraceResult({
|
const _TraceResult({
|
||||||
required this.mode,
|
required this.mode,
|
||||||
@@ -651,27 +728,84 @@ class _TraceResult {
|
|||||||
required this.pathHashes,
|
required this.pathHashes,
|
||||||
required this.matchedPathNodes,
|
required this.matchedPathNodes,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
_TraceResult cycleEntry(_RouteEntryTarget target) {
|
||||||
|
switch (target.kind) {
|
||||||
|
case _RouteEntryKind.sender:
|
||||||
|
return _TraceResult(
|
||||||
|
mode: mode,
|
||||||
|
sender: sender.cycle(),
|
||||||
|
recipient: recipient,
|
||||||
|
pathHashes: pathHashes,
|
||||||
|
matchedPathNodes: matchedPathNodes,
|
||||||
|
);
|
||||||
|
case _RouteEntryKind.recipient:
|
||||||
|
return _TraceResult(
|
||||||
|
mode: mode,
|
||||||
|
sender: sender,
|
||||||
|
recipient: recipient.cycle(),
|
||||||
|
pathHashes: pathHashes,
|
||||||
|
matchedPathNodes: matchedPathNodes,
|
||||||
|
);
|
||||||
|
case _RouteEntryKind.pathNode:
|
||||||
|
final updated = matchedPathNodes.toList();
|
||||||
|
updated[target.index] = updated[target.index].cycle();
|
||||||
|
return _TraceResult(
|
||||||
|
mode: mode,
|
||||||
|
sender: sender,
|
||||||
|
recipient: recipient,
|
||||||
|
pathHashes: pathHashes,
|
||||||
|
matchedPathNodes: updated,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _RouteDisplayEntry {
|
class _RouteDisplayEntry {
|
||||||
final MeshMapNode? node;
|
final ResolvedTraceNode resolved;
|
||||||
final String label;
|
final String label;
|
||||||
final String? keyLabel;
|
final String? keyLabel;
|
||||||
|
final String? matchSummary;
|
||||||
|
final _RouteEntryTarget target;
|
||||||
|
|
||||||
const _RouteDisplayEntry({
|
const _RouteDisplayEntry({
|
||||||
required this.node,
|
required this.resolved,
|
||||||
required this.label,
|
required this.label,
|
||||||
required this.keyLabel,
|
required this.keyLabel,
|
||||||
|
required this.matchSummary,
|
||||||
|
required this.target,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory _RouteDisplayEntry.fromNode(MeshMapNode node) {
|
MeshMapNode? get node => resolved.node;
|
||||||
|
|
||||||
|
factory _RouteDisplayEntry.fromResolved(
|
||||||
|
ResolvedTraceNode resolved, {
|
||||||
|
required _RouteEntryTarget target,
|
||||||
|
}) {
|
||||||
|
final node = resolved.node!;
|
||||||
return _RouteDisplayEntry(
|
return _RouteDisplayEntry(
|
||||||
node: node,
|
resolved: resolved,
|
||||||
label: node.name,
|
label: node.name,
|
||||||
keyLabel: node.publicKey.substring(
|
keyLabel: node.publicKey.substring(
|
||||||
0,
|
0,
|
||||||
math.min(12, node.publicKey.length),
|
math.min(12, node.publicKey.length),
|
||||||
),
|
),
|
||||||
|
matchSummary: resolved.matchSummary,
|
||||||
|
target: target,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum _RouteEntryKind { sender, recipient, pathNode }
|
||||||
|
|
||||||
|
class _RouteEntryTarget {
|
||||||
|
final _RouteEntryKind kind;
|
||||||
|
final int index;
|
||||||
|
|
||||||
|
const _RouteEntryTarget._(this.kind, [this.index = 0]);
|
||||||
|
|
||||||
|
const _RouteEntryTarget.sender() : this._(_RouteEntryKind.sender);
|
||||||
|
const _RouteEntryTarget.recipient() : this._(_RouteEntryKind.recipient);
|
||||||
|
const _RouteEntryTarget.pathNode(int index)
|
||||||
|
: this._(_RouteEntryKind.pathNode, index);
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
|||||||
|
|
||||||
import '../../l10n/app_localizations.dart';
|
import '../../l10n/app_localizations.dart';
|
||||||
import '../../models/message.dart';
|
import '../../models/message.dart';
|
||||||
|
import '../common/bidirectional_refresh.dart';
|
||||||
import '../../widgets/messages/message_bubble.dart';
|
import '../../widgets/messages/message_bubble.dart';
|
||||||
|
|
||||||
class MessagesContent extends StatelessWidget {
|
class MessagesContent extends StatelessWidget {
|
||||||
@@ -30,14 +31,18 @@ class MessagesContent extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return RefreshIndicator(
|
const scrollPhysics = BouncingScrollPhysics(
|
||||||
|
parent: AlwaysScrollableScrollPhysics(),
|
||||||
|
);
|
||||||
|
|
||||||
|
return BidirectionalRefresh(
|
||||||
onRefresh: onRefresh,
|
onRefresh: onRefresh,
|
||||||
child: messages.isEmpty
|
child: messages.isEmpty
|
||||||
? LayoutBuilder(
|
? LayoutBuilder(
|
||||||
builder: (context, constraints) => SingleChildScrollView(
|
builder: (context, constraints) => SingleChildScrollView(
|
||||||
keyboardDismissBehavior:
|
keyboardDismissBehavior:
|
||||||
ScrollViewKeyboardDismissBehavior.onDrag,
|
ScrollViewKeyboardDismissBehavior.onDrag,
|
||||||
physics: const AlwaysScrollableScrollPhysics(),
|
physics: scrollPhysics,
|
||||||
child: ConstrainedBox(
|
child: ConstrainedBox(
|
||||||
constraints: BoxConstraints(minHeight: constraints.maxHeight),
|
constraints: BoxConstraints(minHeight: constraints.maxHeight),
|
||||||
child: Center(
|
child: Center(
|
||||||
@@ -69,6 +74,7 @@ class MessagesContent extends StatelessWidget {
|
|||||||
: ListView.builder(
|
: ListView.builder(
|
||||||
controller: scrollController,
|
controller: scrollController,
|
||||||
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
|
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
|
||||||
|
physics: scrollPhysics,
|
||||||
reverse: true,
|
reverse: true,
|
||||||
padding: EdgeInsets.fromLTRB(
|
padding: EdgeInsets.fromLTRB(
|
||||||
defaultPadding,
|
defaultPadding,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ class RecipientSelectorSheet extends StatefulWidget {
|
|||||||
final Map<String, int> unreadCountsByPublicKey;
|
final Map<String, int> unreadCountsByPublicKey;
|
||||||
final String? currentDestinationType;
|
final String? currentDestinationType;
|
||||||
final String? currentRecipientPublicKey;
|
final String? currentRecipientPublicKey;
|
||||||
|
final bool showAllOption;
|
||||||
final Function(String type, Contact? recipient) onSelect;
|
final Function(String type, Contact? recipient) onSelect;
|
||||||
|
|
||||||
const RecipientSelectorSheet({
|
const RecipientSelectorSheet({
|
||||||
@@ -23,6 +24,7 @@ class RecipientSelectorSheet extends StatefulWidget {
|
|||||||
required this.unreadCountsByPublicKey,
|
required this.unreadCountsByPublicKey,
|
||||||
this.currentDestinationType,
|
this.currentDestinationType,
|
||||||
this.currentRecipientPublicKey,
|
this.currentRecipientPublicKey,
|
||||||
|
this.showAllOption = true,
|
||||||
required this.onSelect,
|
required this.onSelect,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -61,6 +63,11 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
|
|||||||
final filteredContacts = _filterContacts(widget.contacts);
|
final filteredContacts = _filterContacts(widget.contacts);
|
||||||
final filteredRooms = _filterContacts(widget.rooms);
|
final filteredRooms = _filterContacts(widget.rooms);
|
||||||
final filteredChannels = _filterContacts(widget.channels);
|
final filteredChannels = _filterContacts(widget.channels);
|
||||||
|
final showChannelsSection = widget.channels.isNotEmpty;
|
||||||
|
final showContactsSection = widget.contacts.isNotEmpty;
|
||||||
|
final showRoomsSection = widget.rooms.isNotEmpty;
|
||||||
|
final showAnyRecipients =
|
||||||
|
showChannelsSection || showContactsSection || showRoomsSection;
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
constraints: BoxConstraints(
|
constraints: BoxConstraints(
|
||||||
@@ -142,21 +149,23 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
|
|||||||
child: ListView(
|
child: ListView(
|
||||||
shrinkWrap: true,
|
shrinkWrap: true,
|
||||||
children: [
|
children: [
|
||||||
_buildOptionTile(
|
if (widget.showAllOption) ...[
|
||||||
context: context,
|
_buildOptionTile(
|
||||||
icon: Icons.all_inbox,
|
context: context,
|
||||||
title: l10n.showAll,
|
icon: Icons.all_inbox,
|
||||||
subtitle: 'All messages',
|
title: l10n.showAll,
|
||||||
unreadCount: widget.unreadCount,
|
subtitle: 'All messages',
|
||||||
isSelected: _isSelected('all', null),
|
unreadCount: widget.unreadCount,
|
||||||
onTap: () {
|
isSelected: _isSelected('all', null),
|
||||||
widget.onSelect('all', null);
|
onTap: () {
|
||||||
Navigator.pop(context);
|
widget.onSelect('all', null);
|
||||||
},
|
Navigator.pop(context);
|
||||||
),
|
},
|
||||||
const Divider(),
|
),
|
||||||
|
if (showAnyRecipients) const Divider(),
|
||||||
|
],
|
||||||
// Channels section
|
// Channels section
|
||||||
if (widget.channels.isNotEmpty) ...[
|
if (showChannelsSection) ...[
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
horizontal: 16,
|
horizontal: 16,
|
||||||
@@ -204,10 +213,12 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
|
|||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
|
||||||
const Divider(),
|
if (showChannelsSection &&
|
||||||
|
(showContactsSection || showRoomsSection))
|
||||||
|
const Divider(),
|
||||||
|
|
||||||
// Contacts section
|
// Contacts section
|
||||||
if (widget.contacts.isNotEmpty) ...[
|
if (showContactsSection) ...[
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
horizontal: 16,
|
horizontal: 16,
|
||||||
@@ -253,10 +264,10 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
|
|||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
|
||||||
const Divider(),
|
if (showContactsSection && showRoomsSection) const Divider(),
|
||||||
|
|
||||||
// Rooms section
|
// Rooms section
|
||||||
if (widget.rooms.isNotEmpty) ...[
|
if (showRoomsSection) ...[
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
horizontal: 16,
|
horizontal: 16,
|
||||||
@@ -302,9 +313,7 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
|
|||||||
],
|
],
|
||||||
|
|
||||||
// Empty state
|
// Empty state
|
||||||
if (widget.contacts.isEmpty &&
|
if (!showAnyRecipients) ...[
|
||||||
widget.rooms.isEmpty &&
|
|
||||||
widget.channels.isEmpty) ...[
|
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.all(32),
|
padding: const EdgeInsets.all(32),
|
||||||
child: Column(
|
child: Column(
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ PODS:
|
|||||||
- FlutterMacOS
|
- FlutterMacOS
|
||||||
- file_picker (0.0.1):
|
- file_picker (0.0.1):
|
||||||
- FlutterMacOS
|
- FlutterMacOS
|
||||||
|
- file_selector_macos (0.0.1):
|
||||||
|
- FlutterMacOS
|
||||||
|
- flutter_avif_macos (0.0.1):
|
||||||
|
- FlutterMacOS
|
||||||
- flutter_blue_plus_darwin (0.0.2):
|
- flutter_blue_plus_darwin (0.0.2):
|
||||||
- Flutter
|
- Flutter
|
||||||
- FlutterMacOS
|
- FlutterMacOS
|
||||||
@@ -19,10 +23,6 @@ PODS:
|
|||||||
- FlutterMacOS
|
- FlutterMacOS
|
||||||
- nsd_macos (0.0.1):
|
- nsd_macos (0.0.1):
|
||||||
- FlutterMacOS
|
- FlutterMacOS
|
||||||
- ObjectBox (4.4.1)
|
|
||||||
- objectbox_flutter_libs (0.0.1):
|
|
||||||
- FlutterMacOS
|
|
||||||
- ObjectBox (= 4.4.1)
|
|
||||||
- package_info_plus (0.0.1):
|
- package_info_plus (0.0.1):
|
||||||
- FlutterMacOS
|
- FlutterMacOS
|
||||||
- path_provider_foundation (0.0.1):
|
- path_provider_foundation (0.0.1):
|
||||||
@@ -35,6 +35,9 @@ PODS:
|
|||||||
- shared_preferences_foundation (0.0.1):
|
- shared_preferences_foundation (0.0.1):
|
||||||
- Flutter
|
- Flutter
|
||||||
- FlutterMacOS
|
- FlutterMacOS
|
||||||
|
- sqflite_darwin (0.0.4):
|
||||||
|
- Flutter
|
||||||
|
- FlutterMacOS
|
||||||
- url_launcher_macos (0.0.1):
|
- url_launcher_macos (0.0.1):
|
||||||
- FlutterMacOS
|
- FlutterMacOS
|
||||||
|
|
||||||
@@ -43,23 +46,21 @@ DEPENDENCIES:
|
|||||||
- codec2_flutter (from `Flutter/ephemeral/.symlinks/plugins/codec2_flutter/macos`)
|
- codec2_flutter (from `Flutter/ephemeral/.symlinks/plugins/codec2_flutter/macos`)
|
||||||
- device_info_plus (from `Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos`)
|
- device_info_plus (from `Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos`)
|
||||||
- file_picker (from `Flutter/ephemeral/.symlinks/plugins/file_picker/macos`)
|
- file_picker (from `Flutter/ephemeral/.symlinks/plugins/file_picker/macos`)
|
||||||
|
- file_selector_macos (from `Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos`)
|
||||||
|
- flutter_avif_macos (from `Flutter/ephemeral/.symlinks/plugins/flutter_avif_macos/macos`)
|
||||||
- flutter_blue_plus_darwin (from `Flutter/ephemeral/.symlinks/plugins/flutter_blue_plus_darwin/darwin`)
|
- flutter_blue_plus_darwin (from `Flutter/ephemeral/.symlinks/plugins/flutter_blue_plus_darwin/darwin`)
|
||||||
- flutter_local_notifications (from `Flutter/ephemeral/.symlinks/plugins/flutter_local_notifications/macos`)
|
- flutter_local_notifications (from `Flutter/ephemeral/.symlinks/plugins/flutter_local_notifications/macos`)
|
||||||
- FlutterMacOS (from `Flutter/ephemeral`)
|
- FlutterMacOS (from `Flutter/ephemeral`)
|
||||||
- geolocator_apple (from `Flutter/ephemeral/.symlinks/plugins/geolocator_apple/darwin`)
|
- geolocator_apple (from `Flutter/ephemeral/.symlinks/plugins/geolocator_apple/darwin`)
|
||||||
- nsd_macos (from `Flutter/ephemeral/.symlinks/plugins/nsd_macos/macos`)
|
- nsd_macos (from `Flutter/ephemeral/.symlinks/plugins/nsd_macos/macos`)
|
||||||
- objectbox_flutter_libs (from `Flutter/ephemeral/.symlinks/plugins/objectbox_flutter_libs/macos`)
|
|
||||||
- package_info_plus (from `Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos`)
|
- package_info_plus (from `Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos`)
|
||||||
- path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin`)
|
- path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin`)
|
||||||
- record_macos (from `Flutter/ephemeral/.symlinks/plugins/record_macos/macos`)
|
- record_macos (from `Flutter/ephemeral/.symlinks/plugins/record_macos/macos`)
|
||||||
- share_plus (from `Flutter/ephemeral/.symlinks/plugins/share_plus/macos`)
|
- share_plus (from `Flutter/ephemeral/.symlinks/plugins/share_plus/macos`)
|
||||||
- shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`)
|
- shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`)
|
||||||
|
- sqflite_darwin (from `Flutter/ephemeral/.symlinks/plugins/sqflite_darwin/darwin`)
|
||||||
- url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`)
|
- url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`)
|
||||||
|
|
||||||
SPEC REPOS:
|
|
||||||
trunk:
|
|
||||||
- ObjectBox
|
|
||||||
|
|
||||||
EXTERNAL SOURCES:
|
EXTERNAL SOURCES:
|
||||||
audioplayers_darwin:
|
audioplayers_darwin:
|
||||||
:path: Flutter/ephemeral/.symlinks/plugins/audioplayers_darwin/darwin
|
:path: Flutter/ephemeral/.symlinks/plugins/audioplayers_darwin/darwin
|
||||||
@@ -69,6 +70,10 @@ EXTERNAL SOURCES:
|
|||||||
:path: Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos
|
:path: Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos
|
||||||
file_picker:
|
file_picker:
|
||||||
:path: Flutter/ephemeral/.symlinks/plugins/file_picker/macos
|
:path: Flutter/ephemeral/.symlinks/plugins/file_picker/macos
|
||||||
|
file_selector_macos:
|
||||||
|
:path: Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos
|
||||||
|
flutter_avif_macos:
|
||||||
|
:path: Flutter/ephemeral/.symlinks/plugins/flutter_avif_macos/macos
|
||||||
flutter_blue_plus_darwin:
|
flutter_blue_plus_darwin:
|
||||||
:path: Flutter/ephemeral/.symlinks/plugins/flutter_blue_plus_darwin/darwin
|
:path: Flutter/ephemeral/.symlinks/plugins/flutter_blue_plus_darwin/darwin
|
||||||
flutter_local_notifications:
|
flutter_local_notifications:
|
||||||
@@ -79,8 +84,6 @@ EXTERNAL SOURCES:
|
|||||||
:path: Flutter/ephemeral/.symlinks/plugins/geolocator_apple/darwin
|
:path: Flutter/ephemeral/.symlinks/plugins/geolocator_apple/darwin
|
||||||
nsd_macos:
|
nsd_macos:
|
||||||
:path: Flutter/ephemeral/.symlinks/plugins/nsd_macos/macos
|
:path: Flutter/ephemeral/.symlinks/plugins/nsd_macos/macos
|
||||||
objectbox_flutter_libs:
|
|
||||||
:path: Flutter/ephemeral/.symlinks/plugins/objectbox_flutter_libs/macos
|
|
||||||
package_info_plus:
|
package_info_plus:
|
||||||
:path: Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos
|
:path: Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos
|
||||||
path_provider_foundation:
|
path_provider_foundation:
|
||||||
@@ -91,28 +94,31 @@ EXTERNAL SOURCES:
|
|||||||
:path: Flutter/ephemeral/.symlinks/plugins/share_plus/macos
|
:path: Flutter/ephemeral/.symlinks/plugins/share_plus/macos
|
||||||
shared_preferences_foundation:
|
shared_preferences_foundation:
|
||||||
:path: Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin
|
:path: Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin
|
||||||
|
sqflite_darwin:
|
||||||
|
:path: Flutter/ephemeral/.symlinks/plugins/sqflite_darwin/darwin
|
||||||
url_launcher_macos:
|
url_launcher_macos:
|
||||||
:path: Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos
|
:path: Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos
|
||||||
|
|
||||||
SPEC CHECKSUMS:
|
SPEC CHECKSUMS:
|
||||||
audioplayers_darwin: 4f9ca89d92d3d21cec7ec580e78ca888e5fb68bd
|
audioplayers_darwin: 835ced6edd4c9fc8ebb0a7cc9e294a91d99917d5
|
||||||
codec2_flutter: 50d930e6a1a187f05891b89300c6ef589651bfbd
|
codec2_flutter: 50d930e6a1a187f05891b89300c6ef589651bfbd
|
||||||
device_info_plus: 4fb280989f669696856f8b129e4a5e3cd6c48f76
|
device_info_plus: 4fb280989f669696856f8b129e4a5e3cd6c48f76
|
||||||
file_picker: 7584aae6fa07a041af2b36a2655122d42f578c1a
|
file_picker: 7584aae6fa07a041af2b36a2655122d42f578c1a
|
||||||
|
file_selector_macos: 9e9e068e90ebee155097d00e89ae91edb2374db7
|
||||||
|
flutter_avif_macos: 9ed61d67adfbd6964eccb59971020fb55c31fc11
|
||||||
flutter_blue_plus_darwin: 20a08bfeaa0f7804d524858d3d8744bcc1b6dbc3
|
flutter_blue_plus_darwin: 20a08bfeaa0f7804d524858d3d8744bcc1b6dbc3
|
||||||
flutter_local_notifications: 4bf37a31afde695b56091b4ae3e4d9c7a7e6cda0
|
flutter_local_notifications: 1fc7ffb10a83d6a2eeeeddb152d43f1944b0aad0
|
||||||
FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1
|
FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1
|
||||||
geolocator_apple: ab36aa0e8b7d7a2d7639b3b4e48308394e8cef5e
|
geolocator_apple: ab36aa0e8b7d7a2d7639b3b4e48308394e8cef5e
|
||||||
nsd_macos: a472240e770b92f6c6df1022403aa29c90d012e3
|
nsd_macos: a472240e770b92f6c6df1022403aa29c90d012e3
|
||||||
ObjectBox: 7da4aceb5013d041bfafdbc6d744a26918b09757
|
|
||||||
objectbox_flutter_libs: f51d18f6a4b5965c218843373b7dc5ed5e3a2008
|
|
||||||
package_info_plus: f0052d280d17aa382b932f399edf32507174e870
|
package_info_plus: f0052d280d17aa382b932f399edf32507174e870
|
||||||
path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880
|
path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880
|
||||||
record_macos: 7f227161b93c49e7e34fe681c5891c8622c8cc8b
|
record_macos: 7f227161b93c49e7e34fe681c5891c8622c8cc8b
|
||||||
share_plus: 510bf0af1a42cd602274b4629920c9649c52f4cc
|
share_plus: 510bf0af1a42cd602274b4629920c9649c52f4cc
|
||||||
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
|
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
|
||||||
|
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
|
||||||
url_launcher_macos: f87a979182d112f911de6820aefddaf56ee9fbfd
|
url_launcher_macos: f87a979182d112f911de6820aefddaf56ee9fbfd
|
||||||
|
|
||||||
PODFILE CHECKSUM: 54d867c82ac51cbd61b565781b9fada492027009
|
PODFILE CHECKSUM: e3de8f2486e300492cc80a2fe0576e9d269ba22b
|
||||||
|
|
||||||
COCOAPODS: 1.16.2
|
COCOAPODS: 1.16.2
|
||||||
|
|||||||
@@ -795,8 +795,8 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
path: "."
|
path: "."
|
||||||
ref: main
|
ref: "461f010"
|
||||||
resolved-ref: bd3744ee21376b81be5f852cd0c1a82c0df40460
|
resolved-ref: "461f010a2f1fcd343357c152228891e295d391be"
|
||||||
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.0311.1+26
|
version: 2026.0313.2+29
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.9.2
|
sdk: ^3.9.2
|
||||||
@@ -44,7 +44,7 @@ dependencies:
|
|||||||
meshcore_client:
|
meshcore_client:
|
||||||
git:
|
git:
|
||||||
url: https://github.com/dz0ny/meshcore_client.git
|
url: https://github.com/dz0ny/meshcore_client.git
|
||||||
ref: main
|
ref: "461f010"
|
||||||
|
|
||||||
# Codec2 ultra-low-bitrate speech codec (FFI plugin)
|
# Codec2 ultra-low-bitrate speech codec (FFI plugin)
|
||||||
codec2_flutter:
|
codec2_flutter:
|
||||||
|
|||||||
148
test/providers/app_provider_channel_info_test.dart
Normal file
148
test/providers/app_provider_channel_info_test.dart
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:meshcore_sar_app/models/message.dart';
|
||||||
|
import 'package:meshcore_sar_app/providers/app_provider.dart';
|
||||||
|
|
||||||
|
Uint8List _ownPublicKey() =>
|
||||||
|
Uint8List.fromList([1, 2, 3, 4, 5, 6, ...List<int>.filled(26, 0)]);
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('AppProvider channel info handling', () {
|
||||||
|
test('treats zeroed unnamed non-public channel as deleted', () {
|
||||||
|
expect(AppProvider.isDeletedChannelInfo(2, '', Uint8List(16)), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps unnamed non-public channel when secret is configured', () {
|
||||||
|
final secret = Uint8List.fromList([1, ...List<int>.filled(15, 0)]);
|
||||||
|
|
||||||
|
expect(AppProvider.isDeletedChannelInfo(2, '', secret), isFalse);
|
||||||
|
expect(AppProvider.channelContactName(2, ''), 'Channel 2');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('AppProvider self replay handling', () {
|
||||||
|
test('prefers self name over meshcore device name', () {
|
||||||
|
expect(
|
||||||
|
AppProvider.preferredSelfDisplayName(
|
||||||
|
deviceName: 'MeshCore-dz0ny (SI)',
|
||||||
|
selfName: 'dz0ny (SI)',
|
||||||
|
),
|
||||||
|
'dz0ny (SI)',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ignores direct self replay without hops', () {
|
||||||
|
final message = Message(
|
||||||
|
id: 'dm-self',
|
||||||
|
messageType: MessageType.contact,
|
||||||
|
senderPublicKeyPrefix: Uint8List.fromList([1, 2, 3, 4, 5, 6]),
|
||||||
|
pathLen: 0,
|
||||||
|
textType: MessageTextType.plain,
|
||||||
|
senderTimestamp: 1700000000,
|
||||||
|
text: 'hello',
|
||||||
|
receivedAt: DateTime.now(),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
AppProvider.shouldIgnoreSelfReplay(
|
||||||
|
message: message,
|
||||||
|
ownPublicKey: _ownPublicKey(),
|
||||||
|
ownName: 'dz0ny (SI)',
|
||||||
|
),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ignores room self replay without hops', () {
|
||||||
|
final message = Message(
|
||||||
|
id: 'room-self',
|
||||||
|
messageType: MessageType.contact,
|
||||||
|
senderPublicKeyPrefix: Uint8List.fromList([1, 2, 3, 4, 5, 6]),
|
||||||
|
recipientPublicKey: Uint8List.fromList([9, 9, 9, 9, 9, 9]),
|
||||||
|
pathLen: 0,
|
||||||
|
textType: MessageTextType.plain,
|
||||||
|
senderTimestamp: 1700000000,
|
||||||
|
text: 'hello room',
|
||||||
|
receivedAt: DateTime.now(),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
AppProvider.shouldIgnoreSelfReplay(
|
||||||
|
message: message,
|
||||||
|
ownPublicKey: _ownPublicKey(),
|
||||||
|
ownName: 'dz0ny (SI)',
|
||||||
|
),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps direct self replay when it traversed hops', () {
|
||||||
|
final message = Message(
|
||||||
|
id: 'dm-self-routed',
|
||||||
|
messageType: MessageType.contact,
|
||||||
|
senderPublicKeyPrefix: Uint8List.fromList([1, 2, 3, 4, 5, 6]),
|
||||||
|
pathLen: 1,
|
||||||
|
textType: MessageTextType.plain,
|
||||||
|
senderTimestamp: 1700000000,
|
||||||
|
text: 'hello',
|
||||||
|
receivedAt: DateTime.now(),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
AppProvider.shouldIgnoreSelfReplay(
|
||||||
|
message: message,
|
||||||
|
ownPublicKey: _ownPublicKey(),
|
||||||
|
ownName: 'dz0ny (SI)',
|
||||||
|
),
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ignores channel self replay by self name without hops', () {
|
||||||
|
final message = Message(
|
||||||
|
id: 'channel-self',
|
||||||
|
messageType: MessageType.channel,
|
||||||
|
senderName: 'dz0ny (SI)',
|
||||||
|
channelIdx: 0,
|
||||||
|
pathLen: 0,
|
||||||
|
textType: MessageTextType.plain,
|
||||||
|
senderTimestamp: 1700000000,
|
||||||
|
text: 'hello public',
|
||||||
|
receivedAt: DateTime.now(),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
AppProvider.shouldIgnoreSelfReplay(
|
||||||
|
message: message,
|
||||||
|
ownPublicKey: _ownPublicKey(),
|
||||||
|
ownName: 'dz0ny (SI)',
|
||||||
|
),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps channel self replay when it traversed hops', () {
|
||||||
|
final message = Message(
|
||||||
|
id: 'channel-self-routed',
|
||||||
|
messageType: MessageType.channel,
|
||||||
|
senderName: 'dz0ny (SI)',
|
||||||
|
channelIdx: 0,
|
||||||
|
pathLen: 1,
|
||||||
|
textType: MessageTextType.plain,
|
||||||
|
senderTimestamp: 1700000000,
|
||||||
|
text: 'hello public',
|
||||||
|
receivedAt: DateTime.now(),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
AppProvider.shouldIgnoreSelfReplay(
|
||||||
|
message: message,
|
||||||
|
ownPublicKey: _ownPublicKey(),
|
||||||
|
ownName: 'dz0ny (SI)',
|
||||||
|
),
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
86
test/providers/connection_provider_channel_slot_test.dart
Normal file
86
test/providers/connection_provider_channel_slot_test.dart
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:meshcore_sar_app/providers/connection_provider.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('ConnectionProvider channel slot occupancy', () {
|
||||||
|
test('treats non-zero secret as configured', () {
|
||||||
|
expect(
|
||||||
|
ConnectionProvider.channelHasConfiguredSecret(Uint8List(16)),
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
ConnectionProvider.channelHasConfiguredSecret(
|
||||||
|
Uint8List.fromList([1, ...List<int>.filled(15, 0)]),
|
||||||
|
),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('treats duplicate hashtag channels as conflicts', () {
|
||||||
|
expect(
|
||||||
|
ConnectionProvider.isDuplicateChannelName(
|
||||||
|
requestedName: '#sar',
|
||||||
|
existingName: '#sar',
|
||||||
|
),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('allows private channels with the same name to coexist', () {
|
||||||
|
expect(
|
||||||
|
ConnectionProvider.isDuplicateChannelName(
|
||||||
|
requestedName: 'Ops',
|
||||||
|
existingName: 'Ops',
|
||||||
|
),
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not treat arbitrary empty channel info as deletion', () {
|
||||||
|
final provider = ConnectionProvider();
|
||||||
|
expect(
|
||||||
|
provider.shouldTreatChannelInfoAsDeleted(3, '', Uint8List(16)),
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('finds first missing custom channel slot', () {
|
||||||
|
expect(
|
||||||
|
ConnectionProvider.firstAvailableChannelSlot(
|
||||||
|
occupiedIndices: {1, 2, 4},
|
||||||
|
maxChannels: 6,
|
||||||
|
),
|
||||||
|
3,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns null when all custom channel slots are occupied', () {
|
||||||
|
expect(
|
||||||
|
ConnectionProvider.firstAvailableChannelSlot(
|
||||||
|
occupiedIndices: {1, 2, 3, 4},
|
||||||
|
maxChannels: 5,
|
||||||
|
),
|
||||||
|
isNull,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('private channels with same name do not block slot selection', () {
|
||||||
|
expect(
|
||||||
|
ConnectionProvider.isDuplicateChannelName(
|
||||||
|
requestedName: 'RPT_admins',
|
||||||
|
existingName: 'RPT_admins',
|
||||||
|
),
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
ConnectionProvider.firstAvailableChannelSlot(
|
||||||
|
occupiedIndices: {1, 2, 3},
|
||||||
|
maxChannels: 6,
|
||||||
|
),
|
||||||
|
4,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:geolocator/geolocator.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:latlong2/latlong.dart';
|
import 'package:latlong2/latlong.dart';
|
||||||
import 'package:meshcore_sar_app/models/contact.dart';
|
import 'package:meshcore_sar_app/models/contact.dart';
|
||||||
|
import 'package:meshcore_sar_app/models/contact_group.dart';
|
||||||
import 'package:meshcore_sar_app/providers/contacts_provider.dart';
|
import 'package:meshcore_sar_app/providers/contacts_provider.dart';
|
||||||
import 'package:meshcore_sar_app/services/cayenne_lpp_parser.dart';
|
import 'package:meshcore_sar_app/services/cayenne_lpp_parser.dart';
|
||||||
import 'package:meshcore_sar_app/utils/fast_gps_packet.dart';
|
import 'package:meshcore_sar_app/utils/fast_gps_packet.dart';
|
||||||
@@ -426,6 +428,46 @@ void main() {
|
|||||||
expect(reloaded.advertLocation!.longitude, closeTo(13.9999, 0.0001));
|
expect(reloaded.advertLocation!.longitude, closeTo(13.9999, 0.0001));
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
test('prefers a local name override and preserves it across refreshes', () {
|
||||||
|
provider.setContactNameOverride(publicKeyHex(publicKey), 'Rescue One');
|
||||||
|
|
||||||
|
final renamed = provider.findContactByKey(publicKey)!;
|
||||||
|
expect(renamed.nameOverride, 'Rescue One');
|
||||||
|
expect(renamed.displayName, 'Rescue One');
|
||||||
|
|
||||||
|
provider.addOrUpdateContact(
|
||||||
|
Contact(
|
||||||
|
publicKey: publicKey,
|
||||||
|
type: ContactType.chat,
|
||||||
|
flags: 0,
|
||||||
|
outPathLen: 0,
|
||||||
|
outPath: Uint8List(64),
|
||||||
|
advName: 'Updated Advertised Name',
|
||||||
|
lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||||
|
advLat: (46.0569 * 1e6).toInt(),
|
||||||
|
advLon: (14.5058 * 1e6).toInt(),
|
||||||
|
lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final refreshed = provider.findContactByKey(publicKey)!;
|
||||||
|
expect(refreshed.nameOverride, 'Rescue One');
|
||||||
|
expect(refreshed.displayName, 'Rescue One');
|
||||||
|
expect(refreshed.advName, 'Updated Advertised Name');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('persists a local name override across reloads', () async {
|
||||||
|
provider.setContactNameOverride(publicKeyHex(publicKey), 'Rescue One');
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
|
||||||
|
final reloadedProvider = ContactsProvider();
|
||||||
|
await reloadedProvider.initializeEarly();
|
||||||
|
|
||||||
|
final reloaded = reloadedProvider.findContactByKey(publicKey)!;
|
||||||
|
expect(reloaded.nameOverride, 'Rescue One');
|
||||||
|
expect(reloaded.displayName, 'Rescue One');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
group('ContactsProvider route updates', () {
|
group('ContactsProvider route updates', () {
|
||||||
@@ -457,6 +499,24 @@ void main() {
|
|||||||
expect(updated.routeCanonicalText, 'AABB,CCDD');
|
expect(updated.routeCanonicalText, 'AABB,CCDD');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('stores inferred fallback gps when route is set locally', () {
|
||||||
|
final route = ContactRouteCodec.parse('AABB,CCDD');
|
||||||
|
const fallback = LatLng(46.1001, 14.5002);
|
||||||
|
|
||||||
|
provider.setContactRouteLocal(
|
||||||
|
publicKey,
|
||||||
|
signedEncodedPathLen: route.signedEncodedPathLen,
|
||||||
|
paddedPathBytes: route.paddedPathBytes,
|
||||||
|
inferredFallbackLocation: fallback,
|
||||||
|
);
|
||||||
|
|
||||||
|
final updated = provider.findContactByKey(publicKey)!;
|
||||||
|
expect(updated.displayLocation, isNotNull);
|
||||||
|
expect(updated.displayLocation!.latitude, closeTo(46.1001, 0.000001));
|
||||||
|
expect(updated.displayLocation!.longitude, closeTo(14.5002, 0.000001));
|
||||||
|
expect(updated.advertHistory, isNotEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
test('resetContactRouteLocal clears route state', () {
|
test('resetContactRouteLocal clears route state', () {
|
||||||
final route = ContactRouteCodec.parse('AA,BB,CC');
|
final route = ContactRouteCodec.parse('AA,BB,CC');
|
||||||
provider.setContactRouteLocal(
|
provider.setContactRouteLocal(
|
||||||
@@ -471,6 +531,153 @@ void main() {
|
|||||||
expect(updated.routeHasPath, isFalse);
|
expect(updated.routeHasPath, isFalse);
|
||||||
expect(updated.routeSummary, 'Flood/Unknown');
|
expect(updated.routeSummary, 'Flood/Unknown');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('retains existing route when contact refresh omits path', () {
|
||||||
|
final retainedRoute = ContactRouteCodec.parse('AABB,CCDD');
|
||||||
|
provider.setContactRouteLocal(
|
||||||
|
publicKey,
|
||||||
|
signedEncodedPathLen: retainedRoute.signedEncodedPathLen,
|
||||||
|
paddedPathBytes: retainedRoute.paddedPathBytes,
|
||||||
|
);
|
||||||
|
|
||||||
|
provider.addOrUpdateContact(
|
||||||
|
createContact(
|
||||||
|
key: publicKey,
|
||||||
|
type: ContactType.chat,
|
||||||
|
name: 'Routey',
|
||||||
|
).copyWith(outPathLen: -1, outPath: Uint8List(0)),
|
||||||
|
);
|
||||||
|
|
||||||
|
final updated = provider.findContactByKey(publicKey)!;
|
||||||
|
expect(updated.routeHasPath, isTrue);
|
||||||
|
expect(updated.routeCanonicalText, 'AABB,CCDD');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('applies retained pending advert route when contact is resolved', () {
|
||||||
|
final pendingKey = createPublicKey(96);
|
||||||
|
final retainedRoute = ContactRouteCodec.parse('1122,3344');
|
||||||
|
|
||||||
|
provider.retainReceivedRoute(
|
||||||
|
pendingKey,
|
||||||
|
signedEncodedPathLen: retainedRoute.signedEncodedPathLen,
|
||||||
|
paddedPathBytes: retainedRoute.paddedPathBytes,
|
||||||
|
);
|
||||||
|
provider.addPendingAdvert(pendingKey);
|
||||||
|
|
||||||
|
provider.addOrUpdateContact(
|
||||||
|
createContact(
|
||||||
|
key: pendingKey,
|
||||||
|
type: ContactType.chat,
|
||||||
|
name: 'Pending Routey',
|
||||||
|
).copyWith(outPathLen: -1, outPath: Uint8List(0)),
|
||||||
|
);
|
||||||
|
|
||||||
|
final updated = provider.findContactByKey(pendingKey)!;
|
||||||
|
expect(updated.routeHasPath, isTrue);
|
||||||
|
expect(updated.routeCanonicalText, '1122,3344');
|
||||||
|
expect(
|
||||||
|
provider.pendingAdverts.where(
|
||||||
|
(advert) => advert.publicKeyHex == updated.publicKeyHex,
|
||||||
|
),
|
||||||
|
isEmpty,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('infers a fallback location 100m from last-hop repeater', () {
|
||||||
|
final repeaterKey = Uint8List.fromList([
|
||||||
|
0xCC,
|
||||||
|
0xDD,
|
||||||
|
0x10,
|
||||||
|
0x11,
|
||||||
|
0x12,
|
||||||
|
0x13,
|
||||||
|
...List<int>.generate(26, (index) => index + 20),
|
||||||
|
]);
|
||||||
|
provider.addOrUpdateContact(
|
||||||
|
createContact(
|
||||||
|
key: repeaterKey,
|
||||||
|
type: ContactType.repeater,
|
||||||
|
name: 'Relay Alpha',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final targetKey = createPublicKey(120);
|
||||||
|
final route = ContactRouteCodec.parse('AABB,CCDD');
|
||||||
|
provider.addOrUpdateContact(
|
||||||
|
Contact(
|
||||||
|
publicKey: targetKey,
|
||||||
|
type: ContactType.chat,
|
||||||
|
flags: 0,
|
||||||
|
outPathLen: route.signedEncodedPathLen,
|
||||||
|
outPath: route.paddedPathBytes,
|
||||||
|
advName: 'No GPS',
|
||||||
|
lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||||
|
advLat: 0,
|
||||||
|
advLon: 0,
|
||||||
|
lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final updated = provider.findContactByKey(targetKey)!;
|
||||||
|
final inferred = updated.displayLocation;
|
||||||
|
final repeater = provider.findContactByKey(repeaterKey)!;
|
||||||
|
final repeaterLocation = repeater.displayLocation;
|
||||||
|
|
||||||
|
expect(inferred, isNotNull);
|
||||||
|
expect(repeaterLocation, isNotNull);
|
||||||
|
|
||||||
|
final distanceMeters = Geolocator.distanceBetween(
|
||||||
|
repeaterLocation!.latitude,
|
||||||
|
repeaterLocation.longitude,
|
||||||
|
inferred!.latitude,
|
||||||
|
inferred.longitude,
|
||||||
|
);
|
||||||
|
expect(distanceMeters, closeTo(100.0, 8.0));
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'does not infer a fallback location when the contact advertises one',
|
||||||
|
() {
|
||||||
|
final repeaterKey = Uint8List.fromList([
|
||||||
|
0xCC,
|
||||||
|
0xDD,
|
||||||
|
0x10,
|
||||||
|
0x11,
|
||||||
|
0x12,
|
||||||
|
0x13,
|
||||||
|
...List<int>.generate(26, (index) => index + 20),
|
||||||
|
]);
|
||||||
|
provider.addOrUpdateContact(
|
||||||
|
createContact(
|
||||||
|
key: repeaterKey,
|
||||||
|
type: ContactType.repeater,
|
||||||
|
name: 'Relay Alpha',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final targetKey = createPublicKey(121);
|
||||||
|
final route = ContactRouteCodec.parse('AABB,CCDD');
|
||||||
|
provider.addOrUpdateContact(
|
||||||
|
Contact(
|
||||||
|
publicKey: targetKey,
|
||||||
|
type: ContactType.chat,
|
||||||
|
flags: 0,
|
||||||
|
outPathLen: route.signedEncodedPathLen,
|
||||||
|
outPath: route.paddedPathBytes,
|
||||||
|
advName: 'Has Advert',
|
||||||
|
lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||||
|
advLat: (45.1234 * 1e6).toInt(),
|
||||||
|
advLon: (13.8765 * 1e6).toInt(),
|
||||||
|
lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final updated = provider.findContactByKey(targetKey)!;
|
||||||
|
expect(updated.displayLocation, isNotNull);
|
||||||
|
expect(updated.displayLocation!.latitude, closeTo(45.1234, 0.000001));
|
||||||
|
expect(updated.displayLocation!.longitude, closeTo(13.8765, 0.000001));
|
||||||
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
group('ContactsProvider.updateFastGps', () {
|
group('ContactsProvider.updateFastGps', () {
|
||||||
@@ -533,4 +740,79 @@ void main() {
|
|||||||
expect(after.advLon, equals(before.advLon));
|
expect(after.advLon, equals(before.advLon));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('ContactsProvider saved contact groups', () {
|
||||||
|
late ContactsProvider provider;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
provider = ContactsProvider();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('adds and removes saved groups by filter', () async {
|
||||||
|
expect(provider.savedContactGroups, isEmpty);
|
||||||
|
|
||||||
|
await provider.addSavedGroupForFilter('teamMembers', 'alpha');
|
||||||
|
|
||||||
|
expect(provider.savedContactGroups, hasLength(1));
|
||||||
|
expect(provider.hasSavedGroupForFilter('teamMembers', 'alpha'), isTrue);
|
||||||
|
expect(provider.hasSavedGroupForFilter('teamMembers', 'ALPHA'), isTrue);
|
||||||
|
|
||||||
|
await provider.removeSavedGroupForFilter('teamMembers', 'ALPHA');
|
||||||
|
|
||||||
|
expect(provider.savedContactGroups, isEmpty);
|
||||||
|
expect(provider.hasSavedGroupForFilter('teamMembers', 'alpha'), isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('loads persisted saved groups during initialization', () async {
|
||||||
|
await provider.addSavedGroupForFilter('rooms', 'ops');
|
||||||
|
|
||||||
|
final restored = ContactsProvider();
|
||||||
|
await restored.initializeEarly();
|
||||||
|
|
||||||
|
expect(restored.savedGroupsForSection('rooms'), hasLength(1));
|
||||||
|
expect(restored.savedGroupsForSection('rooms').first.query, 'ops');
|
||||||
|
expect(restored.savedGroupsForSection('rooms').first.label, 'ops');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('replaces persisted auto groups for a section', () async {
|
||||||
|
await provider.replaceAutoGroupsForSection('repeaters', [
|
||||||
|
SavedContactGroup(
|
||||||
|
id: '${ContactsProvider.autoGroupIdPrefix}repeaters_al',
|
||||||
|
sectionKey: 'repeaters',
|
||||||
|
label: 'AL-',
|
||||||
|
query: 'AL-',
|
||||||
|
createdAt: DateTime(2026, 3, 10, 12),
|
||||||
|
matchPrefixes: const ['AL-'],
|
||||||
|
isAutoGroup: true,
|
||||||
|
),
|
||||||
|
SavedContactGroup(
|
||||||
|
id: '${ContactsProvider.autoGroupIdPrefix}repeaters_others',
|
||||||
|
sectionKey: 'repeaters',
|
||||||
|
label: 'Others',
|
||||||
|
query: 'Others',
|
||||||
|
createdAt: DateTime(2026, 3, 10, 12),
|
||||||
|
matchPrefixes: const ['CR-', 'DE-'],
|
||||||
|
isAutoGroup: true,
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
final restored = ContactsProvider();
|
||||||
|
await restored.initializeEarly();
|
||||||
|
|
||||||
|
expect(restored.savedGroupsForSection('repeaters'), hasLength(2));
|
||||||
|
expect(
|
||||||
|
restored.savedGroupsForSection('repeaters').first.matchPrefixes,
|
||||||
|
isNotEmpty,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
restored.savedGroupsForSection('repeaters').first.isAutoGroup,
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
String publicKeyHex(Uint8List publicKey) {
|
||||||
|
return publicKey.map((b) => b.toRadixString(16).padLeft(2, '0')).join('');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,46 @@ Message _buildDirectMessage(String id) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Message _buildSentChannelMessage({
|
||||||
|
required String id,
|
||||||
|
required int senderTimestamp,
|
||||||
|
String text = 'broadcast',
|
||||||
|
int channelIdx = 0,
|
||||||
|
}) {
|
||||||
|
return Message(
|
||||||
|
id: id,
|
||||||
|
messageType: MessageType.channel,
|
||||||
|
senderPublicKeyPrefix: Uint8List.fromList([0, 1, 2, 3, 4, 5]),
|
||||||
|
channelIdx: channelIdx,
|
||||||
|
pathLen: 0,
|
||||||
|
textType: MessageTextType.plain,
|
||||||
|
senderTimestamp: senderTimestamp,
|
||||||
|
text: text,
|
||||||
|
receivedAt: DateTime.now(),
|
||||||
|
deliveryStatus: MessageDeliveryStatus.sending,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Message _buildReceivedChannelReplay({
|
||||||
|
required String id,
|
||||||
|
required int senderTimestamp,
|
||||||
|
String text = 'broadcast',
|
||||||
|
int channelIdx = 0,
|
||||||
|
String senderName = 'Radio Alpha',
|
||||||
|
}) {
|
||||||
|
return Message(
|
||||||
|
id: id,
|
||||||
|
messageType: MessageType.channel,
|
||||||
|
channelIdx: channelIdx,
|
||||||
|
pathLen: 1,
|
||||||
|
textType: MessageTextType.plain,
|
||||||
|
senderTimestamp: senderTimestamp,
|
||||||
|
text: text,
|
||||||
|
senderName: senderName,
|
||||||
|
receivedAt: DateTime.now(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
TestWidgetsFlutterBinding.ensureInitialized();
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
@@ -123,19 +163,9 @@ void main() {
|
|||||||
|
|
||||||
test('channel messages are marked sent immediately', () {
|
test('channel messages are marked sent immediately', () {
|
||||||
final provider = MessagesProvider();
|
final provider = MessagesProvider();
|
||||||
|
provider.resolveContactNameCallback = (_) => 'dz0ny (SI)';
|
||||||
provider.addSentMessage(
|
provider.addSentMessage(
|
||||||
Message(
|
_buildSentChannelMessage(id: 'c1', senderTimestamp: 1700000000),
|
||||||
id: 'c1',
|
|
||||||
messageType: MessageType.channel,
|
|
||||||
senderPublicKeyPrefix: Uint8List.fromList([0, 1, 2, 3, 4, 5]),
|
|
||||||
channelIdx: 0,
|
|
||||||
pathLen: 0,
|
|
||||||
textType: MessageTextType.plain,
|
|
||||||
senderTimestamp: 1700000000,
|
|
||||||
text: 'broadcast',
|
|
||||||
receivedAt: DateTime.now(),
|
|
||||||
deliveryStatus: MessageDeliveryStatus.sending,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
provider.markMessageSent('c1', 0, 0);
|
provider.markMessageSent('c1', 0, 0);
|
||||||
@@ -146,6 +176,177 @@ void main() {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('channel warning appears when no echo arrives in time', () {
|
||||||
|
fakeAsync((async) {
|
||||||
|
final provider = MessagesProvider();
|
||||||
|
provider.addSentMessage(
|
||||||
|
_buildSentChannelMessage(id: 'c-warn', senderTimestamp: 1700000000),
|
||||||
|
);
|
||||||
|
|
||||||
|
provider.markMessageSent('c-warn', 0, 0);
|
||||||
|
expect(provider.hasChannelSendWarning('c-warn'), isFalse);
|
||||||
|
|
||||||
|
async.elapse(const Duration(seconds: 12));
|
||||||
|
async.flushMicrotasks();
|
||||||
|
|
||||||
|
expect(provider.hasChannelSendWarning('c-warn'), isTrue);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('channel warning clears when echo arrives before timeout', () {
|
||||||
|
fakeAsync((async) {
|
||||||
|
final provider = MessagesProvider();
|
||||||
|
provider.addSentMessage(
|
||||||
|
_buildSentChannelMessage(
|
||||||
|
id: 'c-warn-echo',
|
||||||
|
senderTimestamp: 1700000000,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
provider.markMessageSent('c-warn-echo', 0, 0);
|
||||||
|
async.elapse(const Duration(seconds: 6));
|
||||||
|
provider.handleMessageEcho('c-warn-echo', 1, 4, -90);
|
||||||
|
async.elapse(const Duration(seconds: 6));
|
||||||
|
async.flushMicrotasks();
|
||||||
|
|
||||||
|
expect(provider.hasChannelSendWarning('c-warn-echo'), isFalse);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('channel warning clears when replay is deduped into sent bubble', () {
|
||||||
|
fakeAsync((async) {
|
||||||
|
final provider = MessagesProvider();
|
||||||
|
provider.resolveContactNameCallback = (_) => 'dz0ny (SI)';
|
||||||
|
provider.addSentMessage(
|
||||||
|
_buildSentChannelMessage(
|
||||||
|
id: 'c-warn-replay',
|
||||||
|
senderTimestamp: 1700000100,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
provider.markMessageSent('c-warn-replay', 0, 0);
|
||||||
|
async.elapse(const Duration(seconds: 12));
|
||||||
|
async.flushMicrotasks();
|
||||||
|
expect(provider.hasChannelSendWarning('c-warn-replay'), isTrue);
|
||||||
|
|
||||||
|
provider.addMessage(
|
||||||
|
_buildReceivedChannelReplay(
|
||||||
|
id: 'c-warn-replay-incoming',
|
||||||
|
senderTimestamp: 1700000101,
|
||||||
|
senderName: 'dz0ny (SI)',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(provider.hasChannelSendWarning('c-warn-replay'), isFalse);
|
||||||
|
expect(provider.messages, hasLength(1));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('channel replay is deduped for self sender within repeat window', () {
|
||||||
|
final provider = MessagesProvider();
|
||||||
|
provider.resolveContactNameCallback = (_) => 'dz0ny (SI)';
|
||||||
|
provider.addSentMessage(
|
||||||
|
_buildSentChannelMessage(id: 'c-echo', senderTimestamp: 1700000100),
|
||||||
|
);
|
||||||
|
provider.markMessageSent('c-echo', 0, 0);
|
||||||
|
|
||||||
|
provider.addMessage(
|
||||||
|
_buildReceivedChannelReplay(
|
||||||
|
id: 'c-echo-incoming',
|
||||||
|
senderTimestamp: 1700000104,
|
||||||
|
senderName: 'dz0ny (SI)',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(provider.messages, hasLength(1));
|
||||||
|
expect(provider.messages.single.id, equals('c-echo'));
|
||||||
|
expect(provider.messages.single.senderName, equals('dz0ny (SI)'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'channel replay is not deduped for different sender with same text',
|
||||||
|
() {
|
||||||
|
final provider = MessagesProvider();
|
||||||
|
provider.resolveContactNameCallback = (_) => 'dz0ny (SI)';
|
||||||
|
provider.addSentMessage(
|
||||||
|
_buildSentChannelMessage(
|
||||||
|
id: 'c-no-echo',
|
||||||
|
senderTimestamp: 1700000200,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
provider.markMessageSent('c-no-echo', 0, 0);
|
||||||
|
|
||||||
|
provider.addMessage(
|
||||||
|
_buildReceivedChannelReplay(
|
||||||
|
id: 'c-no-echo-incoming',
|
||||||
|
senderTimestamp: 1700000201,
|
||||||
|
senderName: 'Radio Alpha',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(provider.messages, hasLength(2));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('channel replay is not deduped outside repeat window', () {
|
||||||
|
final provider = MessagesProvider();
|
||||||
|
provider.resolveContactNameCallback = (_) => 'dz0ny (SI)';
|
||||||
|
provider.addSentMessage(
|
||||||
|
_buildSentChannelMessage(id: 'c-late', senderTimestamp: 1700000300),
|
||||||
|
);
|
||||||
|
provider.markMessageSent('c-late', 0, 0);
|
||||||
|
|
||||||
|
provider.addMessage(
|
||||||
|
_buildReceivedChannelReplay(
|
||||||
|
id: 'c-late-incoming',
|
||||||
|
senderTimestamp: 1700000331,
|
||||||
|
senderName: 'dz0ny (SI)',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(provider.messages, hasLength(2));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('channel replay can dedupe using lazily resolved self name', () {
|
||||||
|
final provider = MessagesProvider();
|
||||||
|
provider.addSentMessage(
|
||||||
|
_buildSentChannelMessage(id: 'c-lazy', senderTimestamp: 1700000400),
|
||||||
|
);
|
||||||
|
provider.markMessageSent('c-lazy', 0, 0);
|
||||||
|
provider.resolveContactNameCallback = (_) => 'dz0ny (SI)';
|
||||||
|
|
||||||
|
provider.addMessage(
|
||||||
|
_buildReceivedChannelReplay(
|
||||||
|
id: 'c-lazy-incoming',
|
||||||
|
senderTimestamp: 1700000406,
|
||||||
|
senderName: 'dz0ny (SI)',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(provider.messages, hasLength(1));
|
||||||
|
expect(provider.messages.single.id, equals('c-lazy'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('channel replay dedupes meshcore-prefixed self sender name', () {
|
||||||
|
final provider = MessagesProvider();
|
||||||
|
provider.resolveContactNameCallback = (_) => 'MeshCore-dz0ny (SI)';
|
||||||
|
provider.addSentMessage(
|
||||||
|
_buildSentChannelMessage(id: 'c-prefix', senderTimestamp: 1700000500),
|
||||||
|
);
|
||||||
|
provider.markMessageSent('c-prefix', 0, 0);
|
||||||
|
|
||||||
|
provider.addMessage(
|
||||||
|
_buildReceivedChannelReplay(
|
||||||
|
id: 'c-prefix-incoming',
|
||||||
|
senderTimestamp: 1700000500,
|
||||||
|
senderName: 'dz0ny (SI)',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(provider.messages, hasLength(1));
|
||||||
|
expect(provider.messages.single.id, equals('c-prefix'));
|
||||||
|
});
|
||||||
|
|
||||||
test('missing ACK schedules a delayed retransmission', () {
|
test('missing ACK schedules a delayed retransmission', () {
|
||||||
fakeAsync((async) {
|
fakeAsync((async) {
|
||||||
final provider = MessagesProvider();
|
final provider = MessagesProvider();
|
||||||
|
|||||||
@@ -131,6 +131,108 @@ void main() {
|
|||||||
expect(snapshot.location.longitude, closeTo(14.5058, 0.000001));
|
expect(snapshot.location.longitude, closeTo(14.5058, 0.000001));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('dedupes repeated incoming contact messages with same text', () {
|
||||||
|
final provider = MessagesProvider();
|
||||||
|
final sender = Uint8List.fromList([0, 1, 2, 3, 4, 5]);
|
||||||
|
|
||||||
|
provider.addMessage(
|
||||||
|
Message(
|
||||||
|
id: 'dup-1',
|
||||||
|
messageType: MessageType.contact,
|
||||||
|
pathLen: 1,
|
||||||
|
textType: MessageTextType.plain,
|
||||||
|
senderTimestamp: 1700001000,
|
||||||
|
text: 'same payload',
|
||||||
|
receivedAt: DateTime.now(),
|
||||||
|
senderPublicKeyPrefix: sender,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
provider.addMessage(
|
||||||
|
Message(
|
||||||
|
id: 'dup-2',
|
||||||
|
messageType: MessageType.contact,
|
||||||
|
pathLen: 1,
|
||||||
|
textType: MessageTextType.plain,
|
||||||
|
senderTimestamp: 1700001999,
|
||||||
|
text: 'same payload',
|
||||||
|
receivedAt: DateTime.now(),
|
||||||
|
senderPublicKeyPrefix: sender,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(provider.messages, hasLength(1));
|
||||||
|
expect(provider.messages.single.id, equals('dup-1'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps separate incoming messages when text differs', () {
|
||||||
|
final provider = MessagesProvider();
|
||||||
|
final sender = Uint8List.fromList([0, 1, 2, 3, 4, 5]);
|
||||||
|
|
||||||
|
provider.addMessage(
|
||||||
|
Message(
|
||||||
|
id: 'unique-1',
|
||||||
|
messageType: MessageType.contact,
|
||||||
|
pathLen: 1,
|
||||||
|
textType: MessageTextType.plain,
|
||||||
|
senderTimestamp: 1700002000,
|
||||||
|
text: 'same payload',
|
||||||
|
receivedAt: DateTime.now(),
|
||||||
|
senderPublicKeyPrefix: sender,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
provider.addMessage(
|
||||||
|
Message(
|
||||||
|
id: 'unique-2',
|
||||||
|
messageType: MessageType.contact,
|
||||||
|
pathLen: 1,
|
||||||
|
textType: MessageTextType.plain,
|
||||||
|
senderTimestamp: 1700002999,
|
||||||
|
text: 'different payload',
|
||||||
|
receivedAt: DateTime.now(),
|
||||||
|
senderPublicKeyPrefix: sender,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(provider.messages, hasLength(2));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('removed SAR markers stay hidden after provider restore', () async {
|
||||||
|
final provider = MessagesProvider();
|
||||||
|
final message = Message(
|
||||||
|
id: 'sar-hidden',
|
||||||
|
messageType: MessageType.channel,
|
||||||
|
channelIdx: 0,
|
||||||
|
pathLen: 0,
|
||||||
|
textType: MessageTextType.plain,
|
||||||
|
senderTimestamp: 1700000003,
|
||||||
|
text: 'S:🧑:0:46.0569,14.5058:Hidden marker',
|
||||||
|
receivedAt: DateTime.now(),
|
||||||
|
senderPublicKeyPrefix: Uint8List.fromList([0, 1, 2, 3, 4, 5]),
|
||||||
|
);
|
||||||
|
|
||||||
|
provider.addMessage(message);
|
||||||
|
expect(
|
||||||
|
provider.sarMarkers.map((marker) => marker.id),
|
||||||
|
contains(message.id),
|
||||||
|
);
|
||||||
|
|
||||||
|
await provider.removeSarMarker(message.id);
|
||||||
|
expect(provider.sarMarkers, isEmpty);
|
||||||
|
|
||||||
|
final restoredProvider = MessagesProvider();
|
||||||
|
await restoredProvider.initialize();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
restoredProvider.messages.map((stored) => stored.id),
|
||||||
|
contains(message.id),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
restoredProvider.sarMarkers.map((marker) => marker.id),
|
||||||
|
isNot(contains(message.id)),
|
||||||
|
);
|
||||||
|
expect(restoredProvider.removedSarMarkerIds, contains(message.id));
|
||||||
|
});
|
||||||
|
|
||||||
test('tracks and persists media transfer counts and downloaders', () async {
|
test('tracks and persists media transfer counts and downloaders', () async {
|
||||||
final provider = MessagesProvider();
|
final provider = MessagesProvider();
|
||||||
final voiceEnvelope = VoiceEnvelope(
|
final voiceEnvelope = VoiceEnvelope(
|
||||||
|
|||||||
195
test/screens/contacts_tab_test.dart
Normal file
195
test/screens/contacts_tab_test.dart
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:meshcore_sar_app/l10n/app_localizations.dart';
|
||||||
|
import 'package:meshcore_sar_app/models/contact.dart';
|
||||||
|
import 'package:meshcore_sar_app/models/contact_group.dart';
|
||||||
|
import 'package:meshcore_sar_app/providers/connection_provider.dart';
|
||||||
|
import 'package:meshcore_sar_app/providers/contacts_provider.dart';
|
||||||
|
import 'package:meshcore_sar_app/providers/map_provider.dart';
|
||||||
|
import 'package:meshcore_sar_app/providers/messages_provider.dart';
|
||||||
|
import 'package:meshcore_sar_app/screens/contacts_tab.dart';
|
||||||
|
import 'package:meshcore_sar_app/utils/contact_grouping.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
setUp(() {
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
});
|
||||||
|
|
||||||
|
Contact buildChannel({required String name, required int channelIndex}) {
|
||||||
|
final publicKey = Uint8List(32);
|
||||||
|
publicKey[0] = 0xFF;
|
||||||
|
publicKey[1] = channelIndex;
|
||||||
|
|
||||||
|
return Contact(
|
||||||
|
publicKey: publicKey,
|
||||||
|
type: ContactType.channel,
|
||||||
|
flags: 0,
|
||||||
|
outPathLen: -1,
|
||||||
|
outPath: Uint8List(0),
|
||||||
|
advName: name,
|
||||||
|
lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||||
|
advLat: 0,
|
||||||
|
advLon: 0,
|
||||||
|
lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Contact buildRepeater({required int seed, required String name}) {
|
||||||
|
final publicKey = Uint8List(32);
|
||||||
|
publicKey[0] = seed;
|
||||||
|
publicKey[1] = seed + 1;
|
||||||
|
|
||||||
|
return Contact(
|
||||||
|
publicKey: publicKey,
|
||||||
|
type: ContactType.repeater,
|
||||||
|
flags: 0,
|
||||||
|
outPathLen: -1,
|
||||||
|
outPath: Uint8List(0),
|
||||||
|
advName: name,
|
||||||
|
lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||||
|
advLat: 46056000 + seed,
|
||||||
|
advLon: 14505000 + seed,
|
||||||
|
lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> pumpContactsTab(
|
||||||
|
WidgetTester tester, {
|
||||||
|
List<Contact> contacts = const [],
|
||||||
|
List<SavedContactGroup> savedGroups = const [],
|
||||||
|
}) async {
|
||||||
|
final contactsProvider = ContactsProvider();
|
||||||
|
for (final contact in contacts) {
|
||||||
|
contactsProvider.addOrUpdateContact(contact);
|
||||||
|
}
|
||||||
|
if (savedGroups.isNotEmpty) {
|
||||||
|
await contactsProvider.replaceAutoGroupsForSection(
|
||||||
|
'repeaters',
|
||||||
|
savedGroups,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MultiProvider(
|
||||||
|
providers: [
|
||||||
|
ChangeNotifierProvider.value(value: contactsProvider),
|
||||||
|
ChangeNotifierProvider(create: (_) => ConnectionProvider()),
|
||||||
|
ChangeNotifierProvider(create: (_) => MessagesProvider()),
|
||||||
|
ChangeNotifierProvider(create: (_) => MapProvider()),
|
||||||
|
],
|
||||||
|
child: MaterialApp(
|
||||||
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||||
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
|
home: const Scaffold(body: ContactsTab()),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
}
|
||||||
|
|
||||||
|
testWidgets('private channel activity card shows delete action in sheet', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await pumpContactsTab(
|
||||||
|
tester,
|
||||||
|
contacts: [buildChannel(name: 'Ops', channelIndex: 3)],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(find.text('Ops'), findsOneWidget);
|
||||||
|
await tester.tap(find.text('Ops'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('Delete Channel'), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.text('Delete Channel'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('Delete Channel'), findsWidgets);
|
||||||
|
expect(
|
||||||
|
find.text(
|
||||||
|
'Are you sure you want to delete channel "Ops"? This action cannot be undone.',
|
||||||
|
),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('repeaters show Others group when multiple groups exist', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final repeaters = [
|
||||||
|
buildRepeater(seed: 10, name: 'AL-1'),
|
||||||
|
buildRepeater(seed: 11, name: 'AL-2'),
|
||||||
|
buildRepeater(seed: 12, name: 'AL-3'),
|
||||||
|
buildRepeater(seed: 13, name: 'AL-4'),
|
||||||
|
buildRepeater(seed: 20, name: 'BR-1'),
|
||||||
|
buildRepeater(seed: 21, name: 'BR-2'),
|
||||||
|
buildRepeater(seed: 22, name: 'BR-3'),
|
||||||
|
buildRepeater(seed: 23, name: 'BR-4'),
|
||||||
|
buildRepeater(seed: 30, name: 'Lone Relay'),
|
||||||
|
];
|
||||||
|
final inferredGroups = ContactGrouping.inferGroups(repeaters);
|
||||||
|
final savedGroups = inferredGroups
|
||||||
|
.map(
|
||||||
|
(group) => SavedContactGroup(
|
||||||
|
id: 'repeaters_${group.key}',
|
||||||
|
sectionKey: 'repeaters',
|
||||||
|
label: group.label,
|
||||||
|
query: group.label,
|
||||||
|
createdAt: DateTime(2026, 3, 13, 10),
|
||||||
|
matchPrefixes: group.matchPrefixes,
|
||||||
|
isAutoGroup: true,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
await pumpContactsTab(
|
||||||
|
tester,
|
||||||
|
contacts: repeaters,
|
||||||
|
savedGroups: savedGroups,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(find.text('AL-'), findsOneWidget);
|
||||||
|
expect(find.text('BR-'), findsOneWidget);
|
||||||
|
expect(find.text('Others'), findsOneWidget);
|
||||||
|
expect(find.text('Lone Relay'), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('repeaters stay flat when only one group exists', (tester) async {
|
||||||
|
final repeaters = [
|
||||||
|
buildRepeater(seed: 40, name: 'AL-1'),
|
||||||
|
buildRepeater(seed: 41, name: 'AL-2'),
|
||||||
|
buildRepeater(seed: 42, name: 'AL-3'),
|
||||||
|
buildRepeater(seed: 43, name: 'AL-4'),
|
||||||
|
buildRepeater(seed: 50, name: 'Lone Relay'),
|
||||||
|
];
|
||||||
|
final inferredGroups = ContactGrouping.inferGroups(repeaters);
|
||||||
|
final savedGroups = inferredGroups
|
||||||
|
.map(
|
||||||
|
(group) => SavedContactGroup(
|
||||||
|
id: 'repeaters_${group.key}',
|
||||||
|
sectionKey: 'repeaters',
|
||||||
|
label: group.label,
|
||||||
|
query: group.label,
|
||||||
|
createdAt: DateTime(2026, 3, 13, 10),
|
||||||
|
matchPrefixes: group.matchPrefixes,
|
||||||
|
isAutoGroup: true,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
await pumpContactsTab(
|
||||||
|
tester,
|
||||||
|
contacts: repeaters,
|
||||||
|
savedGroups: savedGroups,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(find.text('AL-'), findsOneWidget);
|
||||||
|
expect(find.text('Others'), findsNothing);
|
||||||
|
expect(find.text('Lone Relay'), findsOneWidget);
|
||||||
|
});
|
||||||
|
}
|
||||||
179
test/screens/live_traffic_screen_test.dart
Normal file
179
test/screens/live_traffic_screen_test.dart
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:meshcore_sar_app/models/ble_packet_log.dart';
|
||||||
|
import 'package:meshcore_sar_app/screens/live_traffic_screen.dart';
|
||||||
|
|
||||||
|
BlePacketLog _log({
|
||||||
|
required DateTime timestamp,
|
||||||
|
required PacketDirection direction,
|
||||||
|
required List<int> rawData,
|
||||||
|
int? responseCode,
|
||||||
|
double? snrDb,
|
||||||
|
int? rssiDbm,
|
||||||
|
}) {
|
||||||
|
return BlePacketLog(
|
||||||
|
timestamp: timestamp,
|
||||||
|
rawData: Uint8List.fromList(rawData),
|
||||||
|
direction: direction,
|
||||||
|
responseCode: responseCode ?? (rawData.isEmpty ? null : rawData.first),
|
||||||
|
logRxDataInfo: snrDb == null && rssiDbm == null
|
||||||
|
? null
|
||||||
|
: LogRxDataInfo(
|
||||||
|
entropy: 0,
|
||||||
|
isLikelyEncrypted: false,
|
||||||
|
snrDb: snrDb,
|
||||||
|
rssiDbm: rssiDbm,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<int> _multiHopRaw({
|
||||||
|
required List<int> hops,
|
||||||
|
int payloadType = 0x01,
|
||||||
|
int hashSize = 2,
|
||||||
|
}) {
|
||||||
|
final hopCount = hops.length ~/ hashSize;
|
||||||
|
final pathDescriptor = ((hashSize - 1) << 6) | hopCount;
|
||||||
|
return [
|
||||||
|
0x88,
|
||||||
|
0x00,
|
||||||
|
0x00,
|
||||||
|
payloadType << 2,
|
||||||
|
0x00,
|
||||||
|
0x00,
|
||||||
|
0x00,
|
||||||
|
0x00,
|
||||||
|
pathDescriptor,
|
||||||
|
...hops,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
testWidgets('shows empty state before traffic arrives', (tester) async {
|
||||||
|
final logs = <BlePacketLog>[];
|
||||||
|
final refresh = ValueNotifier<int>(0);
|
||||||
|
DateTime now = DateTime(2026, 3, 12, 12, 0, 0);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: LiveTrafficScreen(
|
||||||
|
logReader: () => logs,
|
||||||
|
refreshListenable: refresh,
|
||||||
|
now: () => now,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(find.text('No packets for this filter'), findsOneWidget);
|
||||||
|
expect(find.text('Quiet'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('updates summary and stream for incoming live traffic', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final logs = <BlePacketLog>[];
|
||||||
|
final refresh = ValueNotifier<int>(0);
|
||||||
|
DateTime now = DateTime(2026, 3, 12, 12, 0, 0);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: LiveTrafficScreen(
|
||||||
|
logReader: () => logs,
|
||||||
|
rxCountReader: () => 7,
|
||||||
|
refreshListenable: refresh,
|
||||||
|
now: () => now,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
logs.addAll([
|
||||||
|
_log(
|
||||||
|
timestamp: now.subtract(const Duration(seconds: 10)),
|
||||||
|
direction: PacketDirection.rx,
|
||||||
|
rawData: _multiHopRaw(hops: [0xC0, 0x10, 0x63, 0x01, 0x68, 0xD9]),
|
||||||
|
responseCode: 0x88,
|
||||||
|
snrDb: 13.5,
|
||||||
|
rssiDbm: -84,
|
||||||
|
),
|
||||||
|
_log(
|
||||||
|
timestamp: now.subtract(const Duration(seconds: 3)),
|
||||||
|
direction: PacketDirection.tx,
|
||||||
|
rawData: [0x05, 0x01, 0x02],
|
||||||
|
responseCode: 0x88,
|
||||||
|
),
|
||||||
|
_log(
|
||||||
|
timestamp: now.subtract(const Duration(seconds: 2)),
|
||||||
|
direction: PacketDirection.rx,
|
||||||
|
rawData: [0x05, 0x01, 0x02],
|
||||||
|
responseCode: 0x05,
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
refresh.value += 1;
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(find.text('1 pkt/min'), findsOneWidget);
|
||||||
|
expect(find.text('Device total 7'), findsOneWidget);
|
||||||
|
expect(find.text('Response'), findsWidgets);
|
||||||
|
expect(find.text('MULTI-HOP'), findsOneWidget);
|
||||||
|
expect(find.textContaining('RSSI -84 dBm'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('clear live view only resets transient screen state', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final logs = <BlePacketLog>[];
|
||||||
|
final refresh = ValueNotifier<int>(0);
|
||||||
|
DateTime now = DateTime(2026, 3, 12, 12, 0, 0);
|
||||||
|
|
||||||
|
logs.add(
|
||||||
|
_log(
|
||||||
|
timestamp: now.subtract(const Duration(seconds: 4)),
|
||||||
|
direction: PacketDirection.rx,
|
||||||
|
rawData: _multiHopRaw(hops: [0xC0, 0x10, 0x63, 0x01]),
|
||||||
|
responseCode: 0x88,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: LiveTrafficScreen(
|
||||||
|
logReader: () => logs,
|
||||||
|
refreshListenable: refresh,
|
||||||
|
now: () => now,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(find.text('MULTI-HOP'), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.byTooltip('Clear live view'));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(find.text('No packets for this filter'), findsOneWidget);
|
||||||
|
|
||||||
|
now = now.add(const Duration(seconds: 2));
|
||||||
|
logs.add(
|
||||||
|
_log(
|
||||||
|
timestamp: now,
|
||||||
|
direction: PacketDirection.tx,
|
||||||
|
rawData: [0x03, 0x04],
|
||||||
|
responseCode: 0x88,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
logs.add(
|
||||||
|
_log(
|
||||||
|
timestamp: now,
|
||||||
|
direction: PacketDirection.rx,
|
||||||
|
rawData: [0x88, 0x00, 0x00],
|
||||||
|
responseCode: 0x88,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
refresh.value += 1;
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(find.text('No packets for this filter'), findsNothing);
|
||||||
|
expect(find.textContaining('3 bytes'), findsOneWidget);
|
||||||
|
});
|
||||||
|
}
|
||||||
107
test/screens/messages_tab_test.dart
Normal file
107
test/screens/messages_tab_test.dart
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:meshcore_sar_app/l10n/app_localizations.dart';
|
||||||
|
import 'package:meshcore_sar_app/models/message.dart';
|
||||||
|
import 'package:meshcore_sar_app/providers/app_provider.dart';
|
||||||
|
import 'package:meshcore_sar_app/providers/channels_provider.dart';
|
||||||
|
import 'package:meshcore_sar_app/providers/connection_provider.dart';
|
||||||
|
import 'package:meshcore_sar_app/providers/contacts_provider.dart';
|
||||||
|
import 'package:meshcore_sar_app/providers/drawing_provider.dart';
|
||||||
|
import 'package:meshcore_sar_app/providers/image_provider.dart' as ip;
|
||||||
|
import 'package:meshcore_sar_app/providers/map_provider.dart';
|
||||||
|
import 'package:meshcore_sar_app/providers/messages_provider.dart';
|
||||||
|
import 'package:meshcore_sar_app/providers/voice_provider.dart';
|
||||||
|
import 'package:meshcore_sar_app/screens/messages_tab.dart';
|
||||||
|
import 'package:meshcore_sar_app/services/voice_codec_service.dart';
|
||||||
|
import 'package:meshcore_sar_app/services/voice_player_service.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('marks public channel as read after 5 seconds of viewing', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final connectionProvider = ConnectionProvider();
|
||||||
|
final contactsProvider = ContactsProvider();
|
||||||
|
final messagesProvider = MessagesProvider();
|
||||||
|
final mapProvider = MapProvider();
|
||||||
|
final drawingProvider = DrawingProvider();
|
||||||
|
final channelsProvider = ChannelsProvider()..initializePublicChannel();
|
||||||
|
final voiceProvider = VoiceProvider(
|
||||||
|
codec: VoiceCodecService(),
|
||||||
|
player: VoicePlayerService(),
|
||||||
|
);
|
||||||
|
final imageProvider = ip.ImageProvider();
|
||||||
|
final appProvider = AppProvider(
|
||||||
|
connectionProvider: connectionProvider,
|
||||||
|
contactsProvider: contactsProvider,
|
||||||
|
messagesProvider: messagesProvider,
|
||||||
|
drawingProvider: drawingProvider,
|
||||||
|
channelsProvider: channelsProvider,
|
||||||
|
voiceProvider: voiceProvider,
|
||||||
|
imageProvider: imageProvider,
|
||||||
|
);
|
||||||
|
|
||||||
|
messagesProvider.addMessage(
|
||||||
|
Message(
|
||||||
|
id: 'public-unread',
|
||||||
|
messageType: MessageType.channel,
|
||||||
|
pathLen: 1,
|
||||||
|
textType: MessageTextType.plain,
|
||||||
|
senderTimestamp: 1700000100,
|
||||||
|
text: 'Unread on public channel',
|
||||||
|
receivedAt: DateTime.now(),
|
||||||
|
channelIdx: 0,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MultiProvider(
|
||||||
|
providers: [
|
||||||
|
ChangeNotifierProvider.value(value: connectionProvider),
|
||||||
|
ChangeNotifierProvider.value(value: contactsProvider),
|
||||||
|
ChangeNotifierProvider.value(value: messagesProvider),
|
||||||
|
ChangeNotifierProvider.value(value: mapProvider),
|
||||||
|
ChangeNotifierProvider.value(value: drawingProvider),
|
||||||
|
ChangeNotifierProvider.value(value: channelsProvider),
|
||||||
|
ChangeNotifierProvider.value(value: voiceProvider),
|
||||||
|
ChangeNotifierProvider.value(value: imageProvider),
|
||||||
|
ChangeNotifierProvider.value(value: appProvider),
|
||||||
|
],
|
||||||
|
child: MaterialApp(
|
||||||
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||||
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
|
home: const Scaffold(body: MessagesTab(isActive: true)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(messagesProvider.unreadCount, 1);
|
||||||
|
|
||||||
|
await tester.pump(const Duration(seconds: 4));
|
||||||
|
expect(messagesProvider.unreadCount, 1);
|
||||||
|
|
||||||
|
await tester.pump(const Duration(seconds: 1));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(messagesProvider.unreadCount, 0);
|
||||||
|
|
||||||
|
appProvider.dispose();
|
||||||
|
voiceProvider.dispose();
|
||||||
|
imageProvider.dispose();
|
||||||
|
drawingProvider.dispose();
|
||||||
|
mapProvider.dispose();
|
||||||
|
messagesProvider.dispose();
|
||||||
|
contactsProvider.dispose();
|
||||||
|
connectionProvider.dispose();
|
||||||
|
channelsProvider.dispose();
|
||||||
|
});
|
||||||
|
}
|
||||||
143
test/services/contact_route_resolver_test.dart
Normal file
143
test/services/contact_route_resolver_test.dart
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:latlong2/latlong.dart';
|
||||||
|
import 'package:meshcore_sar_app/models/contact.dart';
|
||||||
|
import 'package:meshcore_sar_app/services/contact_route_resolver.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
test('prefers known repeater route when available', () {
|
||||||
|
final recipient = _contact(
|
||||||
|
name: 'Target',
|
||||||
|
type: ContactType.chat,
|
||||||
|
seed: 90,
|
||||||
|
lat: 46.20,
|
||||||
|
lon: 14.70,
|
||||||
|
);
|
||||||
|
final routedRepeater = _contact(
|
||||||
|
name: 'Routed',
|
||||||
|
type: ContactType.repeater,
|
||||||
|
seed: 10,
|
||||||
|
lat: 46.15,
|
||||||
|
lon: 14.60,
|
||||||
|
signedPathLen: ContactRouteCodec.toSignedDescriptor(1),
|
||||||
|
outPath: Uint8List.fromList([0xAA]),
|
||||||
|
);
|
||||||
|
|
||||||
|
final plan = ContactRouteResolver.resolveAutomaticRoute(
|
||||||
|
senderLocation: const LatLng(46.00, 14.50),
|
||||||
|
recipient: recipient,
|
||||||
|
availableContacts: [routedRepeater],
|
||||||
|
hashSize: 1,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(plan, isNotNull);
|
||||||
|
expect(plan!.tokens, [
|
||||||
|
'AA',
|
||||||
|
routedRepeater.publicKeyHex.substring(0, 2).toUpperCase(),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('falls back to location-only repeaters', () {
|
||||||
|
final recipient = _contact(
|
||||||
|
name: 'Target',
|
||||||
|
type: ContactType.chat,
|
||||||
|
seed: 90,
|
||||||
|
lat: 46.20,
|
||||||
|
lon: 14.70,
|
||||||
|
);
|
||||||
|
final nearRepeater = _contact(
|
||||||
|
name: 'Near',
|
||||||
|
type: ContactType.repeater,
|
||||||
|
seed: 10,
|
||||||
|
lat: 46.08,
|
||||||
|
lon: 14.55,
|
||||||
|
);
|
||||||
|
final farRepeater = _contact(
|
||||||
|
name: 'Far',
|
||||||
|
type: ContactType.repeater,
|
||||||
|
seed: 11,
|
||||||
|
lat: 46.40,
|
||||||
|
lon: 15.10,
|
||||||
|
);
|
||||||
|
|
||||||
|
final plan = ContactRouteResolver.resolveAutomaticRoute(
|
||||||
|
senderLocation: const LatLng(46.00, 14.50),
|
||||||
|
recipient: recipient,
|
||||||
|
availableContacts: [nearRepeater, farRepeater],
|
||||||
|
hashSize: 1,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(plan, isNotNull);
|
||||||
|
expect(plan!.selectedContacts.first.displayName, 'Near');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('for known routes prefers anchor closest to the chain end', () {
|
||||||
|
final recipient = _contact(
|
||||||
|
name: 'Target',
|
||||||
|
type: ContactType.chat,
|
||||||
|
seed: 90,
|
||||||
|
lat: 46.20,
|
||||||
|
lon: 14.70,
|
||||||
|
);
|
||||||
|
final chainHop = _contact(
|
||||||
|
name: 'Chain Hop',
|
||||||
|
type: ContactType.repeater,
|
||||||
|
seed: 0xAA,
|
||||||
|
lat: 46.01,
|
||||||
|
lon: 14.51,
|
||||||
|
);
|
||||||
|
final nearAnchor = _contact(
|
||||||
|
name: 'Near Anchor',
|
||||||
|
type: ContactType.repeater,
|
||||||
|
seed: 10,
|
||||||
|
lat: 46.03,
|
||||||
|
lon: 14.53,
|
||||||
|
signedPathLen: ContactRouteCodec.toSignedDescriptor(1),
|
||||||
|
outPath: Uint8List.fromList([0xAA]),
|
||||||
|
);
|
||||||
|
final farAnchor = _contact(
|
||||||
|
name: 'Far Anchor',
|
||||||
|
type: ContactType.repeater,
|
||||||
|
seed: 11,
|
||||||
|
lat: 46.18,
|
||||||
|
lon: 14.68,
|
||||||
|
signedPathLen: ContactRouteCodec.toSignedDescriptor(1),
|
||||||
|
outPath: Uint8List.fromList([0xAA]),
|
||||||
|
);
|
||||||
|
|
||||||
|
final plan = ContactRouteResolver.resolveAutomaticRoute(
|
||||||
|
senderLocation: const LatLng(46.00, 14.50),
|
||||||
|
recipient: recipient,
|
||||||
|
availableContacts: [chainHop, nearAnchor, farAnchor],
|
||||||
|
hashSize: 1,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(plan, isNotNull);
|
||||||
|
expect(plan!.selectedContacts.last.displayName, 'Near Anchor');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Contact _contact({
|
||||||
|
required String name,
|
||||||
|
required ContactType type,
|
||||||
|
required int seed,
|
||||||
|
required double lat,
|
||||||
|
required double lon,
|
||||||
|
int signedPathLen = -1,
|
||||||
|
Uint8List? outPath,
|
||||||
|
}) {
|
||||||
|
final publicKey = Uint8List(32)..fillRange(0, 32, seed);
|
||||||
|
return Contact(
|
||||||
|
publicKey: publicKey,
|
||||||
|
type: type,
|
||||||
|
flags: 0,
|
||||||
|
outPathLen: signedPathLen,
|
||||||
|
outPath: outPath ?? Uint8List(64),
|
||||||
|
advName: name,
|
||||||
|
lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||||
|
advLat: (lat * 1e6).round(),
|
||||||
|
advLon: (lon * 1e6).round(),
|
||||||
|
lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||||
|
);
|
||||||
|
}
|
||||||
168
test/services/live_traffic_summary_test.dart
Normal file
168
test/services/live_traffic_summary_test.dart
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:meshcore_sar_app/models/ble_packet_log.dart';
|
||||||
|
import 'package:meshcore_sar_app/services/live_traffic_summary.dart';
|
||||||
|
|
||||||
|
BlePacketLog _log({
|
||||||
|
required DateTime timestamp,
|
||||||
|
required PacketDirection direction,
|
||||||
|
required List<int> rawData,
|
||||||
|
int? responseCode,
|
||||||
|
double? snrDb,
|
||||||
|
int? rssiDbm,
|
||||||
|
}) {
|
||||||
|
return BlePacketLog(
|
||||||
|
timestamp: timestamp,
|
||||||
|
rawData: Uint8List.fromList(rawData),
|
||||||
|
direction: direction,
|
||||||
|
responseCode: responseCode ?? (rawData.isEmpty ? null : rawData.first),
|
||||||
|
logRxDataInfo: snrDb == null && rssiDbm == null
|
||||||
|
? null
|
||||||
|
: LogRxDataInfo(
|
||||||
|
entropy: 0,
|
||||||
|
isLikelyEncrypted: false,
|
||||||
|
snrDb: snrDb,
|
||||||
|
rssiDbm: rssiDbm,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<int> _multiHopRaw({
|
||||||
|
required List<int> hops,
|
||||||
|
int payloadType = 0x01,
|
||||||
|
int hashSize = 2,
|
||||||
|
}) {
|
||||||
|
final hopCount = hops.length ~/ hashSize;
|
||||||
|
final pathDescriptor = ((hashSize - 1) << 6) | hopCount;
|
||||||
|
return [
|
||||||
|
0x88,
|
||||||
|
0x00,
|
||||||
|
0x00,
|
||||||
|
payloadType << 2,
|
||||||
|
0x00,
|
||||||
|
0x00,
|
||||||
|
0x00,
|
||||||
|
0x00,
|
||||||
|
pathDescriptor,
|
||||||
|
...hops,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('LiveTrafficSummary', () {
|
||||||
|
test('uses only the rolling 60-second window', () {
|
||||||
|
final now = DateTime(2026, 3, 12, 12, 0, 0);
|
||||||
|
final snapshot = LiveTrafficSummary.fromLogs([
|
||||||
|
_log(
|
||||||
|
timestamp: now.subtract(const Duration(seconds: 61)),
|
||||||
|
direction: PacketDirection.rx,
|
||||||
|
rawData: [0x88, 0x00, 0x00],
|
||||||
|
responseCode: 0x88,
|
||||||
|
),
|
||||||
|
_log(
|
||||||
|
timestamp: now.subtract(const Duration(seconds: 20)),
|
||||||
|
direction: PacketDirection.rx,
|
||||||
|
rawData: [0x88, 0x00, 0x00],
|
||||||
|
responseCode: 0x88,
|
||||||
|
),
|
||||||
|
_log(
|
||||||
|
timestamp: now.subtract(const Duration(seconds: 10)),
|
||||||
|
direction: PacketDirection.tx,
|
||||||
|
rawData: [0x01, 0x02],
|
||||||
|
responseCode: 0x88,
|
||||||
|
),
|
||||||
|
_log(
|
||||||
|
timestamp: now.subtract(const Duration(seconds: 5)),
|
||||||
|
direction: PacketDirection.rx,
|
||||||
|
rawData: [0x01, 0x02],
|
||||||
|
responseCode: 0x01,
|
||||||
|
),
|
||||||
|
], now: now);
|
||||||
|
|
||||||
|
expect(snapshot.totalCount, 1);
|
||||||
|
expect(snapshot.rxCount, 1);
|
||||||
|
expect(snapshot.txCount, 0);
|
||||||
|
expect(snapshot.packetsPerMinute, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('aggregates RSSI, SNR, and multi-hop route metrics', () {
|
||||||
|
final now = DateTime(2026, 3, 12, 12, 0, 0);
|
||||||
|
final snapshot = LiveTrafficSummary.fromLogs([
|
||||||
|
_log(
|
||||||
|
timestamp: now.subtract(const Duration(seconds: 30)),
|
||||||
|
direction: PacketDirection.rx,
|
||||||
|
rawData: _multiHopRaw(hops: [0xC0, 0x10, 0x63, 0x01, 0x68, 0xD9]),
|
||||||
|
responseCode: 0x88,
|
||||||
|
snrDb: 12.0,
|
||||||
|
rssiDbm: -84,
|
||||||
|
),
|
||||||
|
_log(
|
||||||
|
timestamp: now.subtract(const Duration(seconds: 15)),
|
||||||
|
direction: PacketDirection.rx,
|
||||||
|
rawData: _multiHopRaw(hops: [0xDE, 0xAD, 0xBE, 0xEF]),
|
||||||
|
responseCode: 0x88,
|
||||||
|
snrDb: 6.0,
|
||||||
|
rssiDbm: -90,
|
||||||
|
),
|
||||||
|
_log(
|
||||||
|
timestamp: now.subtract(const Duration(seconds: 5)),
|
||||||
|
direction: PacketDirection.tx,
|
||||||
|
rawData: [0x03, 0x04],
|
||||||
|
responseCode: 0x88,
|
||||||
|
),
|
||||||
|
], now: now);
|
||||||
|
|
||||||
|
expect(snapshot.latestRssiDbm, -90);
|
||||||
|
expect(snapshot.latestSnrDb, 6.0);
|
||||||
|
expect(snapshot.avgRssiDbm, closeTo(-87.0, 0.01));
|
||||||
|
expect(snapshot.avgSnrDb, closeTo(9.0, 0.01));
|
||||||
|
expect(snapshot.multiHopCount, 2);
|
||||||
|
expect(snapshot.avgHopCount, closeTo(2.5, 0.01));
|
||||||
|
expect(snapshot.busyness, LiveTrafficBusyness.quiet);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('normalizes packet rate for windows longer than one minute', () {
|
||||||
|
final now = DateTime(2026, 3, 12, 12, 0, 0);
|
||||||
|
final snapshot = LiveTrafficSummary.fromLogs(
|
||||||
|
List.generate(
|
||||||
|
24,
|
||||||
|
(index) => _log(
|
||||||
|
timestamp: now.subtract(Duration(seconds: index * 10)),
|
||||||
|
direction: PacketDirection.rx,
|
||||||
|
rawData: [0x88, 0x00, 0x00],
|
||||||
|
responseCode: 0x88,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
now: now,
|
||||||
|
window: const Duration(minutes: 5),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(snapshot.totalCount, 24);
|
||||||
|
expect(snapshot.windowDuration, const Duration(minutes: 5));
|
||||||
|
expect(snapshot.packetsPerMinute, 5);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('supports clearing the live view without mutating source logs', () {
|
||||||
|
final now = DateTime(2026, 3, 12, 12, 0, 0);
|
||||||
|
final clearAt = now.subtract(const Duration(seconds: 8));
|
||||||
|
final snapshot = LiveTrafficSummary.fromLogs([
|
||||||
|
_log(
|
||||||
|
timestamp: now.subtract(const Duration(seconds: 10)),
|
||||||
|
direction: PacketDirection.rx,
|
||||||
|
rawData: [0x88, 0x00, 0x00],
|
||||||
|
responseCode: 0x88,
|
||||||
|
),
|
||||||
|
_log(
|
||||||
|
timestamp: now.subtract(const Duration(seconds: 4)),
|
||||||
|
direction: PacketDirection.rx,
|
||||||
|
rawData: [0x88, 0x00, 0x00],
|
||||||
|
responseCode: 0x88,
|
||||||
|
),
|
||||||
|
], now: now, clearedAt: clearAt);
|
||||||
|
|
||||||
|
expect(snapshot.totalCount, 1);
|
||||||
|
expect(snapshot.visibleEntries, hasLength(1));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
111
test/services/message_storage_service_test.dart
Normal file
111
test/services/message_storage_service_test.dart
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:meshcore_client/meshcore_client.dart';
|
||||||
|
import 'package:meshcore_sar_app/models/message_reception_details.dart';
|
||||||
|
import 'package:meshcore_sar_app/services/message_storage_service.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'retains path bytes for stored unread message when reception sidecar is missing',
|
||||||
|
() async {
|
||||||
|
final storage = MessageStorageService();
|
||||||
|
final message = Message(
|
||||||
|
id: 'msg-1',
|
||||||
|
messageType: MessageType.contact,
|
||||||
|
senderPublicKeyPrefix: Uint8List.fromList([1, 2, 3, 4, 5, 6]),
|
||||||
|
pathLen: 2,
|
||||||
|
textType: MessageTextType.plain,
|
||||||
|
senderTimestamp: 1700000000,
|
||||||
|
text: 'Unread message',
|
||||||
|
receivedAt: DateTime.fromMillisecondsSinceEpoch(1700000000500),
|
||||||
|
isRead: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
await storage.saveMessages(
|
||||||
|
[message],
|
||||||
|
messageReceptionDetails: {
|
||||||
|
message.id: MessageReceptionDetails(
|
||||||
|
capturedAt: DateTime.fromMillisecondsSinceEpoch(1700000000600),
|
||||||
|
pathBytes: const [0xAA, 0xBB, 0xCC],
|
||||||
|
),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.remove('stored_message_reception_details');
|
||||||
|
|
||||||
|
final restoredDetails = await storage.loadMessageReceptionDetails();
|
||||||
|
|
||||||
|
expect(restoredDetails.keys, contains(message.id));
|
||||||
|
expect(restoredDetails[message.id]?.pathBytes, [0xAA, 0xBB, 0xCC]);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('retains embedded reception details when sidecar is missing', () async {
|
||||||
|
final storage = MessageStorageService();
|
||||||
|
final message = Message(
|
||||||
|
id: 'msg-2',
|
||||||
|
messageType: MessageType.channel,
|
||||||
|
senderPublicKeyPrefix: Uint8List.fromList([6, 5, 4, 3, 2, 1]),
|
||||||
|
channelIdx: 2,
|
||||||
|
pathLen: 3,
|
||||||
|
textType: MessageTextType.plain,
|
||||||
|
senderTimestamp: 1700000100,
|
||||||
|
text: 'Room update',
|
||||||
|
receivedAt: DateTime.fromMillisecondsSinceEpoch(1700000100500),
|
||||||
|
isRead: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
await storage.saveMessages(
|
||||||
|
[message],
|
||||||
|
messageReceptionDetails: {
|
||||||
|
message.id: MessageReceptionDetails(
|
||||||
|
capturedAt: DateTime.fromMillisecondsSinceEpoch(1700000100600),
|
||||||
|
packetLoggedAt: DateTime.fromMillisecondsSinceEpoch(1700000100400),
|
||||||
|
rssiDbm: -91,
|
||||||
|
snrDb: 7.25,
|
||||||
|
pathBytes: const [0xAA, 0xBB, 0xCC, 0xDD],
|
||||||
|
senderToReceiptMs: 1200,
|
||||||
|
estimatedTransmitMs: 800,
|
||||||
|
postTransmitDelayMs: 400,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.remove('stored_message_reception_details');
|
||||||
|
|
||||||
|
final restoredDetails = await storage.loadMessageReceptionDetails();
|
||||||
|
final restored = restoredDetails[message.id];
|
||||||
|
|
||||||
|
expect(restored, isNotNull);
|
||||||
|
expect(restored!.pathBytes, [0xAA, 0xBB, 0xCC, 0xDD]);
|
||||||
|
expect(restored.rssiDbm, -91);
|
||||||
|
expect(restored.snrDb, 7.25);
|
||||||
|
expect(restored.senderToReceiptMs, 1200);
|
||||||
|
expect(restored.estimatedTransmitMs, 800);
|
||||||
|
expect(restored.postTransmitDelayMs, 400);
|
||||||
|
expect(
|
||||||
|
restored.packetLoggedAt,
|
||||||
|
DateTime.fromMillisecondsSinceEpoch(1700000100400),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('persists removed SAR marker IDs', () async {
|
||||||
|
final storage = MessageStorageService();
|
||||||
|
|
||||||
|
await storage.saveRemovedSarMarkerIds({'sar-2', 'sar-1'});
|
||||||
|
|
||||||
|
final restored = await storage.loadRemovedSarMarkerIds();
|
||||||
|
|
||||||
|
expect(restored, equals({'sar-1', 'sar-2'}));
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import 'package:flutter_test/flutter_test.dart';
|
|||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
import 'package:meshcore_sar_app/models/contact.dart';
|
import 'package:meshcore_sar_app/models/contact.dart';
|
||||||
|
import 'package:meshcore_sar_app/models/path_history.dart';
|
||||||
import 'package:meshcore_sar_app/models/path_selection.dart';
|
import 'package:meshcore_sar_app/models/path_selection.dart';
|
||||||
import 'package:meshcore_sar_app/services/path_history_service.dart';
|
import 'package:meshcore_sar_app/services/path_history_service.dart';
|
||||||
|
|
||||||
@@ -161,15 +162,48 @@ void main() {
|
|||||||
expect(selection.mode, PathSelectionMode.flood);
|
expect(selection.mode, PathSelectionMode.flood);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('received public byte path is added to history', () async {
|
test(
|
||||||
final service = PathHistoryService();
|
'received public byte path is reversed before adding to history',
|
||||||
await service.initialize();
|
() async {
|
||||||
await service.recordReceivedBytePath('abc123', [0x01, 0x02, 0x03], 3);
|
final service = PathHistoryService();
|
||||||
|
await service.initialize();
|
||||||
|
await service.recordReceivedBytePath('abc123', [
|
||||||
|
0x01,
|
||||||
|
0x02,
|
||||||
|
0x03,
|
||||||
|
0x04,
|
||||||
|
], 2);
|
||||||
|
|
||||||
final history = service.historyFor('abc123');
|
final history = service.historyFor('abc123');
|
||||||
expect(history.directPaths, hasLength(1));
|
expect(history.directPaths, hasLength(1));
|
||||||
expect(history.directPaths.single.pathBytes, [0x01, 0x02, 0x03]);
|
expect(history.directPaths.single.pathBytes, [0x03, 0x04, 0x01, 0x02]);
|
||||||
expect(history.directPaths.single.hashSize, 3);
|
expect(history.directPaths.single.hashSize, 2);
|
||||||
expect(history.directPaths.single.hopCount, 1);
|
expect(history.directPaths.single.hopCount, 2);
|
||||||
});
|
expect(history.directPaths.single.source, PathRecordSource.observed);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'learned paths stay marked as observed after being seen on-air',
|
||||||
|
() async {
|
||||||
|
final service = PathHistoryService();
|
||||||
|
final contact = _buildContact(
|
||||||
|
seed: 3,
|
||||||
|
pathBytes: [0xAA, 0xBB],
|
||||||
|
hopCount: 2,
|
||||||
|
hashSize: 1,
|
||||||
|
);
|
||||||
|
|
||||||
|
await service.initialize();
|
||||||
|
await service.recordReceivedBytePath(contact.publicKeyHex, [
|
||||||
|
0xBB,
|
||||||
|
0xAA,
|
||||||
|
], 1);
|
||||||
|
await service.recordLearnedPath(contact);
|
||||||
|
|
||||||
|
final history = service.historyFor(contact.publicKeyHex);
|
||||||
|
expect(history.directPaths, hasLength(1));
|
||||||
|
expect(history.directPaths.single.source, PathRecordSource.observed);
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,12 +26,12 @@ void main() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
group('ContactGrouping.buildItems', () {
|
group('ContactGrouping.inferGroups', () {
|
||||||
test(
|
test(
|
||||||
'groups prefixed contacts only when at least four share the prefix',
|
'groups prefixed contacts only when at least four share the prefix',
|
||||||
() {
|
() {
|
||||||
final now = DateTime(2026, 3, 10, 12);
|
final now = DateTime(2026, 3, 10, 12);
|
||||||
final items = ContactGrouping.buildItems([
|
final groups = ContactGrouping.inferGroups([
|
||||||
buildContact(seed: 1, name: 'SI-1', lastSeen: now),
|
buildContact(seed: 1, name: 'SI-1', lastSeen: now),
|
||||||
buildContact(
|
buildContact(
|
||||||
seed: 2,
|
seed: 2,
|
||||||
@@ -55,20 +55,20 @@ void main() {
|
|||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
expect(items, hasLength(2));
|
expect(groups, hasLength(1));
|
||||||
expect(items.first.isGroup, isTrue);
|
expect(groups.first.label, 'SI-');
|
||||||
expect(items.first.group!.label, 'SI-');
|
expect(groups.first.contacts.map((contact) => contact.displayName), [
|
||||||
expect(
|
'SI-1',
|
||||||
items.first.group!.contacts.map((contact) => contact.displayName),
|
'SI-2',
|
||||||
['SI-1', 'SI-2', 'SI-3', 'SI-4'],
|
'SI-3',
|
||||||
);
|
'SI-4',
|
||||||
expect(items.last.contact!.displayName, 'OTHER');
|
]);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
test('does not group when only three contacts share a prefix', () {
|
test('does not group when only three contacts share a prefix', () {
|
||||||
final now = DateTime(2026, 3, 10, 12);
|
final now = DateTime(2026, 3, 10, 12);
|
||||||
final items = ContactGrouping.buildItems([
|
final groups = ContactGrouping.inferGroups([
|
||||||
buildContact(seed: 1, name: 'SI-1', lastSeen: now),
|
buildContact(seed: 1, name: 'SI-1', lastSeen: now),
|
||||||
buildContact(
|
buildContact(
|
||||||
seed: 2,
|
seed: 2,
|
||||||
@@ -82,18 +82,12 @@ void main() {
|
|||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
expect(items, hasLength(3));
|
expect(groups, isEmpty);
|
||||||
expect(items.every((item) => !item.isGroup), isTrue);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('orders groups and ungrouped contacts by latest last seen', () {
|
test('orders groups by latest last seen', () {
|
||||||
final now = DateTime(2026, 3, 10, 12);
|
final now = DateTime(2026, 3, 10, 12);
|
||||||
final items = ContactGrouping.buildItems([
|
final groups = ContactGrouping.inferGroups([
|
||||||
buildContact(
|
|
||||||
seed: 1,
|
|
||||||
name: 'Lone',
|
|
||||||
lastSeen: now.subtract(const Duration(minutes: 1)),
|
|
||||||
),
|
|
||||||
buildContact(seed: 2, name: 'SI-1', lastSeen: now),
|
buildContact(seed: 2, name: 'SI-1', lastSeen: now),
|
||||||
buildContact(
|
buildContact(
|
||||||
seed: 3,
|
seed: 3,
|
||||||
@@ -110,11 +104,261 @@ void main() {
|
|||||||
name: 'SI-4',
|
name: 'SI-4',
|
||||||
lastSeen: now.subtract(const Duration(minutes: 4)),
|
lastSeen: now.subtract(const Duration(minutes: 4)),
|
||||||
),
|
),
|
||||||
|
buildContact(
|
||||||
|
seed: 6,
|
||||||
|
name: 'HR-1',
|
||||||
|
lastSeen: now.subtract(const Duration(minutes: 1)),
|
||||||
|
),
|
||||||
|
buildContact(
|
||||||
|
seed: 7,
|
||||||
|
name: 'HR-2',
|
||||||
|
lastSeen: now.subtract(const Duration(minutes: 5)),
|
||||||
|
),
|
||||||
|
buildContact(
|
||||||
|
seed: 8,
|
||||||
|
name: 'HR-3',
|
||||||
|
lastSeen: now.subtract(const Duration(minutes: 6)),
|
||||||
|
),
|
||||||
|
buildContact(
|
||||||
|
seed: 9,
|
||||||
|
name: 'HR-4',
|
||||||
|
lastSeen: now.subtract(const Duration(minutes: 7)),
|
||||||
|
),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
expect(items, hasLength(2));
|
expect(groups, hasLength(2));
|
||||||
expect(items.first.isGroup, isTrue);
|
expect(groups.first.label, 'SI-');
|
||||||
expect(items.last.contact!.displayName, 'Lone');
|
expect(groups.last.label, 'HR-');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('collapses extra auto-groups into a shared parent prefix', () {
|
||||||
|
final now = DateTime(2026, 3, 10, 12);
|
||||||
|
final groups = ContactGrouping.inferGroups(
|
||||||
|
[
|
||||||
|
buildContact(seed: 1, name: 'AL-1', lastSeen: now),
|
||||||
|
buildContact(
|
||||||
|
seed: 2,
|
||||||
|
name: 'AL-2',
|
||||||
|
lastSeen: now.subtract(const Duration(minutes: 1)),
|
||||||
|
),
|
||||||
|
buildContact(
|
||||||
|
seed: 3,
|
||||||
|
name: 'AL-3',
|
||||||
|
lastSeen: now.subtract(const Duration(minutes: 2)),
|
||||||
|
),
|
||||||
|
buildContact(
|
||||||
|
seed: 4,
|
||||||
|
name: 'AL-4',
|
||||||
|
lastSeen: now.subtract(const Duration(minutes: 3)),
|
||||||
|
),
|
||||||
|
buildContact(
|
||||||
|
seed: 5,
|
||||||
|
name: 'BR-1',
|
||||||
|
lastSeen: now.subtract(const Duration(minutes: 4)),
|
||||||
|
),
|
||||||
|
buildContact(
|
||||||
|
seed: 6,
|
||||||
|
name: 'BR-2',
|
||||||
|
lastSeen: now.subtract(const Duration(minutes: 5)),
|
||||||
|
),
|
||||||
|
buildContact(
|
||||||
|
seed: 7,
|
||||||
|
name: 'BR-3',
|
||||||
|
lastSeen: now.subtract(const Duration(minutes: 6)),
|
||||||
|
),
|
||||||
|
buildContact(
|
||||||
|
seed: 8,
|
||||||
|
name: 'BR-4',
|
||||||
|
lastSeen: now.subtract(const Duration(minutes: 7)),
|
||||||
|
),
|
||||||
|
buildContact(
|
||||||
|
seed: 9,
|
||||||
|
name: 'HU-PE-1',
|
||||||
|
lastSeen: now.subtract(const Duration(minutes: 8)),
|
||||||
|
),
|
||||||
|
buildContact(
|
||||||
|
seed: 10,
|
||||||
|
name: 'HU-PE-2',
|
||||||
|
lastSeen: now.subtract(const Duration(minutes: 9)),
|
||||||
|
),
|
||||||
|
buildContact(
|
||||||
|
seed: 11,
|
||||||
|
name: 'HU-GA-1',
|
||||||
|
lastSeen: now.subtract(const Duration(minutes: 10)),
|
||||||
|
),
|
||||||
|
buildContact(
|
||||||
|
seed: 12,
|
||||||
|
name: 'HU-GA-2',
|
||||||
|
lastSeen: now.subtract(const Duration(minutes: 11)),
|
||||||
|
),
|
||||||
|
buildContact(
|
||||||
|
seed: 13,
|
||||||
|
name: 'HU-PE-3',
|
||||||
|
lastSeen: now.subtract(const Duration(minutes: 12)),
|
||||||
|
),
|
||||||
|
buildContact(
|
||||||
|
seed: 14,
|
||||||
|
name: 'HU-PE-4',
|
||||||
|
lastSeen: now.subtract(const Duration(minutes: 13)),
|
||||||
|
),
|
||||||
|
buildContact(
|
||||||
|
seed: 15,
|
||||||
|
name: 'HU-GA-3',
|
||||||
|
lastSeen: now.subtract(const Duration(minutes: 14)),
|
||||||
|
),
|
||||||
|
buildContact(
|
||||||
|
seed: 16,
|
||||||
|
name: 'HU-GA-4',
|
||||||
|
lastSeen: now.subtract(const Duration(minutes: 15)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
maxNamedGroups: 2,
|
||||||
|
overflowGroupLabel: 'Others',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(groups, hasLength(3));
|
||||||
|
expect(groups[0].label, 'AL-');
|
||||||
|
expect(groups[1].label, 'BR-');
|
||||||
|
expect(groups[2].label, 'HU-');
|
||||||
|
expect(groups[2].contacts.map((contact) => contact.displayName), [
|
||||||
|
'HU-PE-1',
|
||||||
|
'HU-PE-2',
|
||||||
|
'HU-GA-1',
|
||||||
|
'HU-GA-2',
|
||||||
|
'HU-PE-3',
|
||||||
|
'HU-PE-4',
|
||||||
|
'HU-GA-3',
|
||||||
|
'HU-GA-4',
|
||||||
|
]);
|
||||||
|
expect(groups[2].matchPrefixes, ['HU-']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'does not create an Others bucket when overflow has no shared parent',
|
||||||
|
() {
|
||||||
|
final now = DateTime(2026, 3, 10, 12);
|
||||||
|
final groups = ContactGrouping.inferGroups(
|
||||||
|
[
|
||||||
|
buildContact(seed: 1, name: 'AL-1', lastSeen: now),
|
||||||
|
buildContact(
|
||||||
|
seed: 2,
|
||||||
|
name: 'AL-2',
|
||||||
|
lastSeen: now.subtract(const Duration(minutes: 1)),
|
||||||
|
),
|
||||||
|
buildContact(
|
||||||
|
seed: 3,
|
||||||
|
name: 'AL-3',
|
||||||
|
lastSeen: now.subtract(const Duration(minutes: 2)),
|
||||||
|
),
|
||||||
|
buildContact(
|
||||||
|
seed: 4,
|
||||||
|
name: 'AL-4',
|
||||||
|
lastSeen: now.subtract(const Duration(minutes: 3)),
|
||||||
|
),
|
||||||
|
buildContact(
|
||||||
|
seed: 5,
|
||||||
|
name: 'BR-1',
|
||||||
|
lastSeen: now.subtract(const Duration(minutes: 4)),
|
||||||
|
),
|
||||||
|
buildContact(
|
||||||
|
seed: 6,
|
||||||
|
name: 'BR-2',
|
||||||
|
lastSeen: now.subtract(const Duration(minutes: 5)),
|
||||||
|
),
|
||||||
|
buildContact(
|
||||||
|
seed: 7,
|
||||||
|
name: 'BR-3',
|
||||||
|
lastSeen: now.subtract(const Duration(minutes: 6)),
|
||||||
|
),
|
||||||
|
buildContact(
|
||||||
|
seed: 8,
|
||||||
|
name: 'BR-4',
|
||||||
|
lastSeen: now.subtract(const Duration(minutes: 7)),
|
||||||
|
),
|
||||||
|
buildContact(
|
||||||
|
seed: 9,
|
||||||
|
name: 'CR-1',
|
||||||
|
lastSeen: now.subtract(const Duration(minutes: 8)),
|
||||||
|
),
|
||||||
|
buildContact(
|
||||||
|
seed: 10,
|
||||||
|
name: 'CR-2',
|
||||||
|
lastSeen: now.subtract(const Duration(minutes: 9)),
|
||||||
|
),
|
||||||
|
buildContact(
|
||||||
|
seed: 11,
|
||||||
|
name: 'CR-3',
|
||||||
|
lastSeen: now.subtract(const Duration(minutes: 10)),
|
||||||
|
),
|
||||||
|
buildContact(
|
||||||
|
seed: 12,
|
||||||
|
name: 'CR-4',
|
||||||
|
lastSeen: now.subtract(const Duration(minutes: 11)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
maxNamedGroups: 2,
|
||||||
|
overflowGroupLabel: 'Others',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(groups.map((group) => group.label), ['AL-', 'BR-', 'CR-']);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
group('ContactGrouping.contactMatchesInferredGroupLabel', () {
|
||||||
|
test('matches the inferred auto-group label for prefixed contacts', () {
|
||||||
|
final contact = buildContact(
|
||||||
|
seed: 99,
|
||||||
|
name: 'SI-1',
|
||||||
|
lastSeen: DateTime(2026, 3, 10, 12),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
ContactGrouping.contactMatchesInferredGroupLabel(contact, 'SI-'),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
ContactGrouping.contactMatchesInferredGroupLabel(contact, 'si'),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns false when a contact has no inferred auto-group label', () {
|
||||||
|
final contact = buildContact(
|
||||||
|
seed: 100,
|
||||||
|
name: 'Lone Contact',
|
||||||
|
lastSeen: DateTime(2026, 3, 10, 12),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
ContactGrouping.contactMatchesInferredGroupLabel(contact, 'SI'),
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('finds a shared inferred label for related repeater names', () {
|
||||||
|
final contacts = [
|
||||||
|
buildContact(
|
||||||
|
seed: 101,
|
||||||
|
name: 'HU-PE',
|
||||||
|
lastSeen: DateTime(2026, 3, 10, 12),
|
||||||
|
),
|
||||||
|
buildContact(
|
||||||
|
seed: 102,
|
||||||
|
name: 'HU-GA',
|
||||||
|
lastSeen: DateTime(2026, 3, 10, 11, 59),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(ContactGrouping.sharedInferredGroupLabel(contacts), 'HU-');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('finds a shared parent label for related subgroup prefixes', () {
|
||||||
|
expect(
|
||||||
|
ContactGrouping.sharedParentGroupLabel(['HU-PE-', 'HU-GA-']),
|
||||||
|
'HU-',
|
||||||
|
);
|
||||||
|
expect(ContactGrouping.sharedParentGroupLabel(['AL-', 'BR-']), isNull);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -116,6 +116,21 @@ void main() {
|
|||||||
expect(resolved.matchCount, 2);
|
expect(resolved.matchCount, 2);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('LogRxRouteDecoder.reverseHopBytes', () {
|
||||||
|
test('reverses path by hop size', () {
|
||||||
|
final reversed = LogRxRouteDecoder.reverseHopBytes([
|
||||||
|
0xc2,
|
||||||
|
0xba,
|
||||||
|
0x5f,
|
||||||
|
0xde,
|
||||||
|
0xaa,
|
||||||
|
0xbb,
|
||||||
|
], hashSize: 2);
|
||||||
|
|
||||||
|
expect(reversed, [0xaa, 0xbb, 0x5f, 0xde, 0xc2, 0xba]);
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Contact _contact({required String name, required List<int> keyPrefix}) {
|
Contact _contact({required String name, required List<int> keyPrefix}) {
|
||||||
|
|||||||
166
test/utils/trace_node_resolver_test.dart
Normal file
166
test/utils/trace_node_resolver_test.dart
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:latlong2/latlong.dart';
|
||||||
|
import 'package:meshcore_sar_app/services/mesh_map_nodes_service.dart';
|
||||||
|
import 'package:meshcore_sar_app/utils/trace_node_resolver.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
test(
|
||||||
|
'prefers closest local repeater over online fallback for shared prefix',
|
||||||
|
() {
|
||||||
|
final localNear = _node(
|
||||||
|
name: 'Near Local',
|
||||||
|
publicKey: 'aa1100',
|
||||||
|
latitude: 46.05,
|
||||||
|
longitude: 14.50,
|
||||||
|
);
|
||||||
|
final localFar = _node(
|
||||||
|
name: 'Far Local',
|
||||||
|
publicKey: 'aa2200',
|
||||||
|
latitude: 46.40,
|
||||||
|
longitude: 14.90,
|
||||||
|
);
|
||||||
|
final online = _node(
|
||||||
|
name: 'Online',
|
||||||
|
publicKey: 'aa3300',
|
||||||
|
latitude: 46.06,
|
||||||
|
longitude: 14.51,
|
||||||
|
);
|
||||||
|
|
||||||
|
final resolved = TraceNodeResolver.resolveBest(
|
||||||
|
nodes: [localNear, localFar, online],
|
||||||
|
localPublicKeys: {localNear.publicKey, localFar.publicKey},
|
||||||
|
prefixHex: 'aa',
|
||||||
|
referenceA: const LatLng(46.0, 14.5),
|
||||||
|
referenceB: const LatLng(46.1, 14.5),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(resolved.node?.name, 'Near Local');
|
||||||
|
expect(resolved.usedOnlineFallback, isFalse);
|
||||||
|
expect(resolved.matchCount, 2);
|
||||||
|
expect(resolved.matchSummary, '2 local matches');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('falls back to online node only when local match is missing', () {
|
||||||
|
final online = _node(
|
||||||
|
name: 'Online Only',
|
||||||
|
publicKey: 'bb1100',
|
||||||
|
latitude: 46.06,
|
||||||
|
longitude: 14.51,
|
||||||
|
);
|
||||||
|
|
||||||
|
final resolved = TraceNodeResolver.resolveBest(
|
||||||
|
nodes: [online],
|
||||||
|
localPublicKeys: const {},
|
||||||
|
prefixHex: 'bb',
|
||||||
|
referenceA: const LatLng(46.0, 14.5),
|
||||||
|
referenceB: const LatLng(46.1, 14.5),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(resolved.node?.name, 'Online Only');
|
||||||
|
expect(resolved.usedOnlineFallback, isTrue);
|
||||||
|
expect(resolved.matchCount, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cycles through ambiguous local prefix matches', () {
|
||||||
|
final first = _node(
|
||||||
|
name: 'First Match',
|
||||||
|
publicKey: 'cc1100',
|
||||||
|
latitude: 46.08,
|
||||||
|
longitude: 14.52,
|
||||||
|
);
|
||||||
|
final second = _node(
|
||||||
|
name: 'Second Match',
|
||||||
|
publicKey: 'cc11ff',
|
||||||
|
latitude: 46.09,
|
||||||
|
longitude: 14.53,
|
||||||
|
);
|
||||||
|
|
||||||
|
final resolved = TraceNodeResolver.resolveBest(
|
||||||
|
nodes: [second, first],
|
||||||
|
localPublicKeys: {first.publicKey, second.publicKey},
|
||||||
|
prefixHex: 'cc11',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(resolved.matchCount, 2);
|
||||||
|
expect(resolved.canCycle, isTrue);
|
||||||
|
expect(resolved.node?.name, 'Second Match');
|
||||||
|
expect(resolved.cycle().node?.name, 'First Match');
|
||||||
|
expect(resolved.cycle().cycle().node?.name, 'Second Match');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('aligns ambiguous hops to the closest continuous path', () {
|
||||||
|
final start = _node(
|
||||||
|
name: 'Start',
|
||||||
|
publicKey: 'start00',
|
||||||
|
latitude: 46.000,
|
||||||
|
longitude: 14.000,
|
||||||
|
);
|
||||||
|
final end = _node(
|
||||||
|
name: 'End',
|
||||||
|
publicKey: 'end000',
|
||||||
|
latitude: 46.300,
|
||||||
|
longitude: 14.300,
|
||||||
|
);
|
||||||
|
final hop1Near = _node(
|
||||||
|
name: 'Hop 1 Near',
|
||||||
|
publicKey: 'aa1100',
|
||||||
|
latitude: 46.100,
|
||||||
|
longitude: 14.100,
|
||||||
|
);
|
||||||
|
final hop1Far = _node(
|
||||||
|
name: 'Hop 1 Far',
|
||||||
|
publicKey: 'aa11ff',
|
||||||
|
latitude: 46.250,
|
||||||
|
longitude: 14.000,
|
||||||
|
);
|
||||||
|
final hop2Near = _node(
|
||||||
|
name: 'Hop 2 Near',
|
||||||
|
publicKey: 'bb2200',
|
||||||
|
latitude: 46.200,
|
||||||
|
longitude: 14.200,
|
||||||
|
);
|
||||||
|
final hop2Far = _node(
|
||||||
|
name: 'Hop 2 Far',
|
||||||
|
publicKey: 'bb22ff',
|
||||||
|
latitude: 46.050,
|
||||||
|
longitude: 14.280,
|
||||||
|
);
|
||||||
|
|
||||||
|
final aligned = TraceNodeResolver.alignPathSelections(
|
||||||
|
nodes: [
|
||||||
|
TraceNodeResolver.resolveBest(
|
||||||
|
nodes: [hop1Far, hop1Near],
|
||||||
|
localPublicKeys: {hop1Near.publicKey, hop1Far.publicKey},
|
||||||
|
prefixHex: 'aa11',
|
||||||
|
),
|
||||||
|
TraceNodeResolver.resolveBest(
|
||||||
|
nodes: [hop2Far, hop2Near],
|
||||||
|
localPublicKeys: {hop2Near.publicKey, hop2Far.publicKey},
|
||||||
|
prefixHex: 'bb22',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
startNode: start,
|
||||||
|
endNode: end,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(aligned[0].node?.name, 'Hop 1 Near');
|
||||||
|
expect(aligned[1].node?.name, 'Hop 2 Near');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
MeshMapNode _node({
|
||||||
|
required String name,
|
||||||
|
required String publicKey,
|
||||||
|
required double latitude,
|
||||||
|
required double longitude,
|
||||||
|
}) {
|
||||||
|
return MeshMapNode(
|
||||||
|
type: 1,
|
||||||
|
name: name,
|
||||||
|
publicKey: publicKey,
|
||||||
|
latitude: latitude,
|
||||||
|
longitude: longitude,
|
||||||
|
updatedAtMs: 1,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ import 'package:meshcore_sar_app/l10n/app_localizations.dart';
|
|||||||
import 'package:meshcore_sar_app/widgets/contacts/add_channel_dialog.dart';
|
import 'package:meshcore_sar_app/widgets/contacts/add_channel_dialog.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
Future<void> pumpDialog(
|
Future<void> pumpSheet(
|
||||||
WidgetTester tester, {
|
WidgetTester tester, {
|
||||||
required Future<void> Function(String name, String secret) onCreateChannel,
|
required Future<void> Function(String name, String secret) onCreateChannel,
|
||||||
}) async {
|
}) async {
|
||||||
@@ -15,7 +15,7 @@ void main() {
|
|||||||
home: Scaffold(
|
home: Scaffold(
|
||||||
body: Builder(
|
body: Builder(
|
||||||
builder: (context) => Center(
|
builder: (context) => Center(
|
||||||
child: AddChannelDialog(onCreateChannel: onCreateChannel),
|
child: AddChannelSheet(onCreateChannel: onCreateChannel),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -27,7 +27,7 @@ void main() {
|
|||||||
String? submittedName;
|
String? submittedName;
|
||||||
String? submittedSecret;
|
String? submittedSecret;
|
||||||
|
|
||||||
await pumpDialog(
|
await pumpSheet(
|
||||||
tester,
|
tester,
|
||||||
onCreateChannel: (name, secret) async {
|
onCreateChannel: (name, secret) async {
|
||||||
submittedName = name;
|
submittedName = name;
|
||||||
@@ -47,7 +47,7 @@ void main() {
|
|||||||
testWidgets('uses done action for hash channels', (tester) async {
|
testWidgets('uses done action for hash channels', (tester) async {
|
||||||
String? submittedName;
|
String? submittedName;
|
||||||
|
|
||||||
await pumpDialog(
|
await pumpSheet(
|
||||||
tester,
|
tester,
|
||||||
onCreateChannel: (name, secret) async {
|
onCreateChannel: (name, secret) async {
|
||||||
submittedName = name;
|
submittedName = name;
|
||||||
|
|||||||
90
test/widgets/bidirectional_refresh_test.dart
Normal file
90
test/widgets/bidirectional_refresh_test.dart
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:meshcore_sar_app/widgets/common/bidirectional_refresh.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
Future<BuildContext> pumpRefreshHarness(
|
||||||
|
WidgetTester tester, {
|
||||||
|
required Future<void> Function() onRefresh,
|
||||||
|
}) {
|
||||||
|
late BuildContext childContext;
|
||||||
|
|
||||||
|
return tester
|
||||||
|
.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: Scaffold(
|
||||||
|
body: BidirectionalRefresh(
|
||||||
|
onRefresh: onRefresh,
|
||||||
|
child: Builder(
|
||||||
|
builder: (context) {
|
||||||
|
childContext = context;
|
||||||
|
return const SizedBox.expand();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.then((_) => childContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
OverscrollNotification topOverscroll(BuildContext context) {
|
||||||
|
return OverscrollNotification(
|
||||||
|
metrics: FixedScrollMetrics(
|
||||||
|
minScrollExtent: 0,
|
||||||
|
maxScrollExtent: 500,
|
||||||
|
pixels: 0,
|
||||||
|
viewportDimension: 300,
|
||||||
|
axisDirection: AxisDirection.down,
|
||||||
|
devicePixelRatio: 1,
|
||||||
|
),
|
||||||
|
context: context,
|
||||||
|
overscroll: -120,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
OverscrollNotification bottomOverscroll(BuildContext context) {
|
||||||
|
return OverscrollNotification(
|
||||||
|
metrics: FixedScrollMetrics(
|
||||||
|
minScrollExtent: 0,
|
||||||
|
maxScrollExtent: 500,
|
||||||
|
pixels: 500,
|
||||||
|
viewportDimension: 300,
|
||||||
|
axisDirection: AxisDirection.down,
|
||||||
|
devicePixelRatio: 1,
|
||||||
|
),
|
||||||
|
context: context,
|
||||||
|
overscroll: 120,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
testWidgets('refresh triggers from top overscroll', (tester) async {
|
||||||
|
var refreshCount = 0;
|
||||||
|
final childContext = await pumpRefreshHarness(
|
||||||
|
tester,
|
||||||
|
onRefresh: () async {
|
||||||
|
refreshCount++;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
topOverscroll(childContext).dispatch(childContext);
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(refreshCount, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('refresh triggers from bottom overscroll', (tester) async {
|
||||||
|
var refreshCount = 0;
|
||||||
|
final childContext = await pumpRefreshHarness(
|
||||||
|
tester,
|
||||||
|
onRefresh: () async {
|
||||||
|
refreshCount++;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
bottomOverscroll(childContext).dispatch(childContext);
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(refreshCount, 1);
|
||||||
|
});
|
||||||
|
}
|
||||||
107
test/widgets/contact_tile_test.dart
Normal file
107
test/widgets/contact_tile_test.dart
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:meshcore_sar_app/l10n/app_localizations.dart';
|
||||||
|
import 'package:meshcore_sar_app/models/contact.dart';
|
||||||
|
import 'package:meshcore_sar_app/providers/connection_provider.dart';
|
||||||
|
import 'package:meshcore_sar_app/providers/contacts_provider.dart';
|
||||||
|
import 'package:meshcore_sar_app/providers/map_provider.dart';
|
||||||
|
import 'package:meshcore_sar_app/providers/messages_provider.dart';
|
||||||
|
import 'package:meshcore_sar_app/providers/sensors_provider.dart';
|
||||||
|
import 'package:meshcore_sar_app/widgets/contacts/contact_tile.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
setUp(() {
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
});
|
||||||
|
|
||||||
|
Contact buildContact({
|
||||||
|
required String name,
|
||||||
|
required ContactType type,
|
||||||
|
int secondByte = 1,
|
||||||
|
}) {
|
||||||
|
final publicKey = Uint8List(32);
|
||||||
|
publicKey[1] = secondByte;
|
||||||
|
|
||||||
|
return Contact(
|
||||||
|
publicKey: publicKey,
|
||||||
|
type: type,
|
||||||
|
flags: 0,
|
||||||
|
outPathLen: 0,
|
||||||
|
outPath: Uint8List(64),
|
||||||
|
advName: name,
|
||||||
|
lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||||
|
advLat: 46562000,
|
||||||
|
advLon: 14950000,
|
||||||
|
lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> pumpTile(WidgetTester tester, Contact contact) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MultiProvider(
|
||||||
|
providers: [
|
||||||
|
ChangeNotifierProvider(create: (_) => ConnectionProvider()),
|
||||||
|
ChangeNotifierProvider(create: (_) => ContactsProvider()),
|
||||||
|
ChangeNotifierProvider(create: (_) => MessagesProvider()),
|
||||||
|
ChangeNotifierProvider(create: (_) => SensorsProvider()),
|
||||||
|
ChangeNotifierProvider(create: (_) => MapProvider()),
|
||||||
|
],
|
||||||
|
child: MaterialApp(
|
||||||
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||||
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
|
home: Scaffold(body: ContactTile(contact: contact)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
testWidgets('shows trace action for non-channel contacts', (tester) async {
|
||||||
|
await pumpTile(
|
||||||
|
tester,
|
||||||
|
buildContact(name: 'John Smith', type: ContactType.chat),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.tap(find.text('John Smith'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('Trace'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('does not show trace action for channels', (tester) async {
|
||||||
|
await pumpTile(
|
||||||
|
tester,
|
||||||
|
buildContact(name: 'Ops', type: ContactType.channel, secondByte: 3),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.tap(find.text('Ops'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('Trace'), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('shows overridden contact name as primary label', (tester) async {
|
||||||
|
await pumpTile(
|
||||||
|
tester,
|
||||||
|
buildContact(
|
||||||
|
name: 'John Smith',
|
||||||
|
type: ContactType.chat,
|
||||||
|
).copyWith(nameOverride: 'Rescue One'),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(find.text('Rescue One'), findsOneWidget);
|
||||||
|
expect(find.text('John Smith'), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('hides public key in contact tile', (tester) async {
|
||||||
|
final contact = buildContact(name: 'John Smith', type: ContactType.chat);
|
||||||
|
|
||||||
|
await pumpTile(tester, contact);
|
||||||
|
|
||||||
|
expect(find.text(contact.publicKeyShort), findsNothing);
|
||||||
|
expect(find.byIcon(Icons.key_outlined), findsNothing);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -71,4 +71,31 @@ void main() {
|
|||||||
expect(find.text('7'), findsOneWidget);
|
expect(find.text('7'), findsOneWidget);
|
||||||
expect(find.text('3'), findsOneWidget);
|
expect(find.text('3'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('can hide show all option for contact-only flows', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final contact = buildContact(name: 'John Smith', type: ContactType.chat);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||||
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
|
home: Scaffold(
|
||||||
|
body: RecipientSelectorSheet(
|
||||||
|
contacts: [contact],
|
||||||
|
rooms: const [],
|
||||||
|
channels: const [],
|
||||||
|
unreadCount: 0,
|
||||||
|
unreadCountsByPublicKey: const {},
|
||||||
|
showAllOption: false,
|
||||||
|
onSelect: (selectedRecipient, draftMessage) {},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(find.text('Show all'), findsNothing);
|
||||||
|
expect(find.text('John Smith'), findsOneWidget);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user