mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-14 01:40:28 +00:00
Implement meshcore-open route reload
This commit is contained in:
@@ -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 = 105;
|
CURRENT_PROJECT_VERSION = 106;
|
||||||
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 = 105;
|
CURRENT_PROJECT_VERSION = 106;
|
||||||
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 = 105;
|
CURRENT_PROJECT_VERSION = 106;
|
||||||
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 = 105;
|
CURRENT_PROJECT_VERSION = 106;
|
||||||
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 = 105;
|
CURRENT_PROJECT_VERSION = 106;
|
||||||
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 = 105;
|
CURRENT_PROJECT_VERSION = 106;
|
||||||
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>105</string>
|
<string>106</string>
|
||||||
<key>LSRequiresIPhoneOS</key>
|
<key>LSRequiresIPhoneOS</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>ITSAppUsesNonExemptEncryption</key>
|
<key>ITSAppUsesNonExemptEncryption</key>
|
||||||
|
|||||||
79
lib/models/message_route_metadata.dart
Normal file
79
lib/models/message_route_metadata.dart
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import 'path_selection.dart';
|
||||||
|
|
||||||
|
class MessageRouteMetadata {
|
||||||
|
final PathSelectionMode mode;
|
||||||
|
final bool routerFallbackAttempted;
|
||||||
|
final String? relayName;
|
||||||
|
final String? relayKey6;
|
||||||
|
final String? canonicalPath;
|
||||||
|
final int? hopCount;
|
||||||
|
|
||||||
|
const MessageRouteMetadata({
|
||||||
|
required this.mode,
|
||||||
|
required this.routerFallbackAttempted,
|
||||||
|
this.relayName,
|
||||||
|
this.relayKey6,
|
||||||
|
this.canonicalPath,
|
||||||
|
this.hopCount,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory MessageRouteMetadata.fromSelection(
|
||||||
|
PathSelection selection, {
|
||||||
|
required bool routerFallbackAttempted,
|
||||||
|
}) {
|
||||||
|
return MessageRouteMetadata(
|
||||||
|
mode: selection.mode,
|
||||||
|
routerFallbackAttempted: routerFallbackAttempted,
|
||||||
|
relayName: selection.relayName,
|
||||||
|
relayKey6: selection.relayKey6,
|
||||||
|
canonicalPath: selection.canonicalPath.isEmpty
|
||||||
|
? null
|
||||||
|
: selection.canonicalPath,
|
||||||
|
hopCount: selection.hopCount > 0 ? selection.hopCount : null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String get modeLabel {
|
||||||
|
switch (mode) {
|
||||||
|
case PathSelectionMode.directCurrent:
|
||||||
|
return 'Current direct path';
|
||||||
|
case PathSelectionMode.directHistorical:
|
||||||
|
return 'Rotated direct path';
|
||||||
|
case PathSelectionMode.flood:
|
||||||
|
return 'Flood route';
|
||||||
|
case PathSelectionMode.nearestRouter:
|
||||||
|
final suffix = relayName?.trim().isNotEmpty == true
|
||||||
|
? ' via $relayName'
|
||||||
|
: relayKey6?.trim().isNotEmpty == true
|
||||||
|
? ' via $relayKey6'
|
||||||
|
: '';
|
||||||
|
return 'Nearest router$suffix';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'mode': mode.name,
|
||||||
|
'router_fallback_attempted': routerFallbackAttempted,
|
||||||
|
'relay_name': relayName,
|
||||||
|
'relay_key6': relayKey6,
|
||||||
|
'canonical_path': canonicalPath,
|
||||||
|
'hop_count': hopCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
factory MessageRouteMetadata.fromJson(Map<String, dynamic> json) {
|
||||||
|
return MessageRouteMetadata(
|
||||||
|
mode: PathSelectionMode.values.firstWhere(
|
||||||
|
(value) => value.name == json['mode'],
|
||||||
|
orElse: () => PathSelectionMode.directCurrent,
|
||||||
|
),
|
||||||
|
routerFallbackAttempted:
|
||||||
|
json['router_fallback_attempted'] as bool? ?? false,
|
||||||
|
relayName: json['relay_name'] as String?,
|
||||||
|
relayKey6: json['relay_key6'] as String?,
|
||||||
|
canonicalPath: json['canonical_path'] as String?,
|
||||||
|
hopCount: json['hop_count'] as int?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
182
lib/models/path_history.dart
Normal file
182
lib/models/path_history.dart
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
class PathRecord {
|
||||||
|
final List<int> pathBytes;
|
||||||
|
final int hopCount;
|
||||||
|
final int hashSize;
|
||||||
|
final int successCount;
|
||||||
|
final int failureCount;
|
||||||
|
final int lastRoundTripTimeMs;
|
||||||
|
final DateTime lastUsedAt;
|
||||||
|
|
||||||
|
const PathRecord({
|
||||||
|
required this.pathBytes,
|
||||||
|
required this.hopCount,
|
||||||
|
required this.hashSize,
|
||||||
|
required this.successCount,
|
||||||
|
required this.failureCount,
|
||||||
|
required this.lastRoundTripTimeMs,
|
||||||
|
required this.lastUsedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
String get signature =>
|
||||||
|
pathBytes.map((byte) => byte.toRadixString(16).padLeft(2, '0')).join();
|
||||||
|
|
||||||
|
double get successRate =>
|
||||||
|
(successCount + 1) / (successCount + failureCount + 2);
|
||||||
|
|
||||||
|
PathRecord copyWith({
|
||||||
|
List<int>? pathBytes,
|
||||||
|
int? hopCount,
|
||||||
|
int? hashSize,
|
||||||
|
int? successCount,
|
||||||
|
int? failureCount,
|
||||||
|
int? lastRoundTripTimeMs,
|
||||||
|
DateTime? lastUsedAt,
|
||||||
|
}) {
|
||||||
|
return PathRecord(
|
||||||
|
pathBytes: pathBytes ?? this.pathBytes,
|
||||||
|
hopCount: hopCount ?? this.hopCount,
|
||||||
|
hashSize: hashSize ?? this.hashSize,
|
||||||
|
successCount: successCount ?? this.successCount,
|
||||||
|
failureCount: failureCount ?? this.failureCount,
|
||||||
|
lastRoundTripTimeMs: lastRoundTripTimeMs ?? this.lastRoundTripTimeMs,
|
||||||
|
lastUsedAt: lastUsedAt ?? this.lastUsedAt,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'path_bytes': pathBytes,
|
||||||
|
'hop_count': hopCount,
|
||||||
|
'hash_size': hashSize,
|
||||||
|
'success_count': successCount,
|
||||||
|
'failure_count': failureCount,
|
||||||
|
'last_round_trip_time_ms': lastRoundTripTimeMs,
|
||||||
|
'last_used_at': lastUsedAt.toIso8601String(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
factory PathRecord.fromJson(Map<String, dynamic> json) {
|
||||||
|
return PathRecord(
|
||||||
|
pathBytes: (json['path_bytes'] as List<dynamic>? ?? const <dynamic>[])
|
||||||
|
.map((value) => value as int)
|
||||||
|
.toList(),
|
||||||
|
hopCount: json['hop_count'] as int? ?? 0,
|
||||||
|
hashSize: json['hash_size'] as int? ?? 1,
|
||||||
|
successCount: json['success_count'] as int? ?? 0,
|
||||||
|
failureCount: json['failure_count'] as int? ?? 0,
|
||||||
|
lastRoundTripTimeMs: json['last_round_trip_time_ms'] as int? ?? 0,
|
||||||
|
lastUsedAt:
|
||||||
|
DateTime.tryParse(json['last_used_at'] as String? ?? '') ??
|
||||||
|
DateTime.fromMillisecondsSinceEpoch(0),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class FloodPathStats {
|
||||||
|
final int successCount;
|
||||||
|
final int failureCount;
|
||||||
|
final int lastRoundTripTimeMs;
|
||||||
|
final DateTime? lastUsedAt;
|
||||||
|
|
||||||
|
const FloodPathStats({
|
||||||
|
required this.successCount,
|
||||||
|
required this.failureCount,
|
||||||
|
required this.lastRoundTripTimeMs,
|
||||||
|
required this.lastUsedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
const FloodPathStats.empty()
|
||||||
|
: successCount = 0,
|
||||||
|
failureCount = 0,
|
||||||
|
lastRoundTripTimeMs = 0,
|
||||||
|
lastUsedAt = null;
|
||||||
|
|
||||||
|
FloodPathStats copyWith({
|
||||||
|
int? successCount,
|
||||||
|
int? failureCount,
|
||||||
|
int? lastRoundTripTimeMs,
|
||||||
|
DateTime? lastUsedAt,
|
||||||
|
}) {
|
||||||
|
return FloodPathStats(
|
||||||
|
successCount: successCount ?? this.successCount,
|
||||||
|
failureCount: failureCount ?? this.failureCount,
|
||||||
|
lastRoundTripTimeMs: lastRoundTripTimeMs ?? this.lastRoundTripTimeMs,
|
||||||
|
lastUsedAt: lastUsedAt ?? this.lastUsedAt,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'success_count': successCount,
|
||||||
|
'failure_count': failureCount,
|
||||||
|
'last_round_trip_time_ms': lastRoundTripTimeMs,
|
||||||
|
'last_used_at': lastUsedAt?.toIso8601String(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
factory FloodPathStats.fromJson(Map<String, dynamic> json) {
|
||||||
|
return FloodPathStats(
|
||||||
|
successCount: json['success_count'] as int? ?? 0,
|
||||||
|
failureCount: json['failure_count'] as int? ?? 0,
|
||||||
|
lastRoundTripTimeMs: json['last_round_trip_time_ms'] as int? ?? 0,
|
||||||
|
lastUsedAt: DateTime.tryParse(json['last_used_at'] as String? ?? ''),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ContactPathHistory {
|
||||||
|
final String contactPublicKeyHex;
|
||||||
|
final List<PathRecord> directPaths;
|
||||||
|
final FloodPathStats floodStats;
|
||||||
|
final int rotationIndex;
|
||||||
|
|
||||||
|
const ContactPathHistory({
|
||||||
|
required this.contactPublicKeyHex,
|
||||||
|
required this.directPaths,
|
||||||
|
required this.floodStats,
|
||||||
|
required this.rotationIndex,
|
||||||
|
});
|
||||||
|
|
||||||
|
const ContactPathHistory.empty(this.contactPublicKeyHex)
|
||||||
|
: directPaths = const <PathRecord>[],
|
||||||
|
floodStats = const FloodPathStats.empty(),
|
||||||
|
rotationIndex = 0;
|
||||||
|
|
||||||
|
ContactPathHistory copyWith({
|
||||||
|
List<PathRecord>? directPaths,
|
||||||
|
FloodPathStats? floodStats,
|
||||||
|
int? rotationIndex,
|
||||||
|
}) {
|
||||||
|
return ContactPathHistory(
|
||||||
|
contactPublicKeyHex: contactPublicKeyHex,
|
||||||
|
directPaths: directPaths ?? this.directPaths,
|
||||||
|
floodStats: floodStats ?? this.floodStats,
|
||||||
|
rotationIndex: rotationIndex ?? this.rotationIndex,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'direct_paths': directPaths.map((record) => record.toJson()).toList(),
|
||||||
|
'flood_stats': floodStats.toJson(),
|
||||||
|
'rotation_index': rotationIndex,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
factory ContactPathHistory.fromJson(
|
||||||
|
String contactPublicKeyHex,
|
||||||
|
Map<String, dynamic> json,
|
||||||
|
) {
|
||||||
|
return ContactPathHistory(
|
||||||
|
contactPublicKeyHex: contactPublicKeyHex,
|
||||||
|
directPaths: (json['direct_paths'] as List<dynamic>? ?? const <dynamic>[])
|
||||||
|
.whereType<Map<String, dynamic>>()
|
||||||
|
.map(PathRecord.fromJson)
|
||||||
|
.toList(),
|
||||||
|
floodStats: json['flood_stats'] is Map<String, dynamic>
|
||||||
|
? FloodPathStats.fromJson(json['flood_stats'] as Map<String, dynamic>)
|
||||||
|
: const FloodPathStats.empty(),
|
||||||
|
rotationIndex: json['rotation_index'] as int? ?? 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
65
lib/models/path_selection.dart
Normal file
65
lib/models/path_selection.dart
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
enum PathSelectionMode { directCurrent, directHistorical, flood, nearestRouter }
|
||||||
|
|
||||||
|
class PathSelection {
|
||||||
|
final PathSelectionMode mode;
|
||||||
|
final Uint8List pathBytes;
|
||||||
|
final int hopCount;
|
||||||
|
final int hashSize;
|
||||||
|
final String? relayName;
|
||||||
|
final String? relayKey6;
|
||||||
|
|
||||||
|
const PathSelection({
|
||||||
|
required this.mode,
|
||||||
|
required this.pathBytes,
|
||||||
|
required this.hopCount,
|
||||||
|
required this.hashSize,
|
||||||
|
this.relayName,
|
||||||
|
this.relayKey6,
|
||||||
|
});
|
||||||
|
|
||||||
|
PathSelection.flood()
|
||||||
|
: mode = PathSelectionMode.flood,
|
||||||
|
pathBytes = Uint8List(0),
|
||||||
|
hopCount = -1,
|
||||||
|
hashSize = 1,
|
||||||
|
relayName = null,
|
||||||
|
relayKey6 = null;
|
||||||
|
|
||||||
|
bool get usesFlood => mode == PathSelectionMode.flood;
|
||||||
|
bool get hasDirectPath => !usesFlood && pathBytes.isNotEmpty && hopCount > 0;
|
||||||
|
|
||||||
|
String get canonicalPath {
|
||||||
|
if (!hasDirectPath) return '';
|
||||||
|
final hops = <String>[];
|
||||||
|
for (var index = 0; index < pathBytes.length; index += hashSize) {
|
||||||
|
hops.add(
|
||||||
|
pathBytes
|
||||||
|
.sublist(index, index + hashSize)
|
||||||
|
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
|
||||||
|
.join()
|
||||||
|
.toUpperCase(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return hops.join(',');
|
||||||
|
}
|
||||||
|
|
||||||
|
PathSelection copyWith({
|
||||||
|
PathSelectionMode? mode,
|
||||||
|
Uint8List? pathBytes,
|
||||||
|
int? hopCount,
|
||||||
|
int? hashSize,
|
||||||
|
String? relayName,
|
||||||
|
String? relayKey6,
|
||||||
|
}) {
|
||||||
|
return PathSelection(
|
||||||
|
mode: mode ?? this.mode,
|
||||||
|
pathBytes: pathBytes ?? this.pathBytes,
|
||||||
|
hopCount: hopCount ?? this.hopCount,
|
||||||
|
hashSize: hashSize ?? this.hashSize,
|
||||||
|
relayName: relayName ?? this.relayName,
|
||||||
|
relayKey6: relayKey6 ?? this.relayKey6,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,10 +12,15 @@ import 'image_provider.dart' as ip;
|
|||||||
import 'helpers/fragment_ack_wait_registry.dart';
|
import 'helpers/fragment_ack_wait_registry.dart';
|
||||||
import 'helpers/session_metadata_restore.dart';
|
import 'helpers/session_metadata_restore.dart';
|
||||||
import '../services/location_tracking_service.dart';
|
import '../services/location_tracking_service.dart';
|
||||||
|
import '../services/messaging_route_preferences.dart';
|
||||||
|
import '../services/nearest_router_selector.dart';
|
||||||
import '../services/packet_capture_storage_service.dart';
|
import '../services/packet_capture_storage_service.dart';
|
||||||
|
import '../services/path_history_service.dart';
|
||||||
|
import '../services/route_hash_preferences.dart';
|
||||||
import '../models/contact.dart';
|
import '../models/contact.dart';
|
||||||
import '../models/message.dart';
|
import '../models/message.dart';
|
||||||
import '../models/ble_packet_log.dart';
|
import '../models/ble_packet_log.dart';
|
||||||
|
import '../models/path_selection.dart';
|
||||||
import '../models/message_reception_details.dart';
|
import '../models/message_reception_details.dart';
|
||||||
import '../utils/drawing_message_parser.dart';
|
import '../utils/drawing_message_parser.dart';
|
||||||
import '../utils/raw_route_probe.dart';
|
import '../utils/raw_route_probe.dart';
|
||||||
@@ -25,6 +30,31 @@ import '../utils/media_swarm_protocol.dart';
|
|||||||
import '../utils/message_airtime_estimator.dart';
|
import '../utils/message_airtime_estimator.dart';
|
||||||
import '../utils/fast_gps_packet.dart';
|
import '../utils/fast_gps_packet.dart';
|
||||||
|
|
||||||
|
class _DirectMessageRouteSession {
|
||||||
|
final PathSelection currentSelection;
|
||||||
|
final ParsedContactRoute? originalRoute;
|
||||||
|
final bool routerFallbackAttempted;
|
||||||
|
|
||||||
|
const _DirectMessageRouteSession({
|
||||||
|
required this.currentSelection,
|
||||||
|
required this.originalRoute,
|
||||||
|
required this.routerFallbackAttempted,
|
||||||
|
});
|
||||||
|
|
||||||
|
_DirectMessageRouteSession copyWith({
|
||||||
|
PathSelection? currentSelection,
|
||||||
|
ParsedContactRoute? originalRoute,
|
||||||
|
bool? routerFallbackAttempted,
|
||||||
|
}) {
|
||||||
|
return _DirectMessageRouteSession(
|
||||||
|
currentSelection: currentSelection ?? this.currentSelection,
|
||||||
|
originalRoute: originalRoute ?? this.originalRoute,
|
||||||
|
routerFallbackAttempted:
|
||||||
|
routerFallbackAttempted ?? this.routerFallbackAttempted,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 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;
|
||||||
@@ -62,6 +92,17 @@ class AppProvider with ChangeNotifier {
|
|||||||
bool get isVoiceLimiterEnabled => _isVoiceLimiterEnabled;
|
bool get isVoiceLimiterEnabled => _isVoiceLimiterEnabled;
|
||||||
bool _autoAddDiscoveredContacts = false;
|
bool _autoAddDiscoveredContacts = false;
|
||||||
bool get autoAddDiscoveredContacts => _autoAddDiscoveredContacts;
|
bool get autoAddDiscoveredContacts => _autoAddDiscoveredContacts;
|
||||||
|
bool _autoRouteRotationEnabled =
|
||||||
|
MessagingRoutePreferences.defaultAutoRouteRotationEnabled;
|
||||||
|
bool get autoRouteRotationEnabled => _autoRouteRotationEnabled;
|
||||||
|
bool _clearPathOnMaxRetry =
|
||||||
|
MessagingRoutePreferences.defaultClearPathOnMaxRetry;
|
||||||
|
bool get clearPathOnMaxRetry => _clearPathOnMaxRetry;
|
||||||
|
final PathHistoryService _pathHistoryService = PathHistoryService();
|
||||||
|
final NearestRouterSelector _nearestRouterSelector =
|
||||||
|
const NearestRouterSelector();
|
||||||
|
final Map<String, _DirectMessageRouteSession> _directMessageRouteSessions =
|
||||||
|
{};
|
||||||
|
|
||||||
static const Duration _packetRetryDelay = Duration(milliseconds: 1200);
|
static const Duration _packetRetryDelay = Duration(milliseconds: 1200);
|
||||||
static const Duration _mediaSwarmResponseWindow = Duration(seconds: 10);
|
static const Duration _mediaSwarmResponseWindow = Duration(seconds: 10);
|
||||||
@@ -101,6 +142,8 @@ class AppProvider with ChangeNotifier {
|
|||||||
_loadVoiceCompressorEnabled();
|
_loadVoiceCompressorEnabled();
|
||||||
_loadVoiceLimiterEnabled();
|
_loadVoiceLimiterEnabled();
|
||||||
_loadAutoAddDiscoveredContacts();
|
_loadAutoAddDiscoveredContacts();
|
||||||
|
_loadMessagingRouteSettings();
|
||||||
|
unawaited(_pathHistoryService.initialize());
|
||||||
_startPacketCapturePersistence();
|
_startPacketCapturePersistence();
|
||||||
_syncDrawingsOnStartup(); // Sync drawings immediately after providers load
|
_syncDrawingsOnStartup(); // Sync drawings immediately after providers load
|
||||||
_isInitialized = true;
|
_isInitialized = true;
|
||||||
@@ -409,6 +452,38 @@ class AppProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _loadMessagingRouteSettings() async {
|
||||||
|
try {
|
||||||
|
_autoRouteRotationEnabled =
|
||||||
|
await MessagingRoutePreferences.getAutoRouteRotationEnabled();
|
||||||
|
_clearPathOnMaxRetry =
|
||||||
|
await MessagingRoutePreferences.getClearPathOnMaxRetry();
|
||||||
|
notifyListeners();
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Error loading messaging route settings: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> toggleAutoRouteRotationEnabled(bool enabled) async {
|
||||||
|
try {
|
||||||
|
_autoRouteRotationEnabled = enabled;
|
||||||
|
await MessagingRoutePreferences.setAutoRouteRotationEnabled(enabled);
|
||||||
|
notifyListeners();
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Error saving auto route rotation setting: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> toggleClearPathOnMaxRetry(bool enabled) async {
|
||||||
|
try {
|
||||||
|
_clearPathOnMaxRetry = enabled;
|
||||||
|
await MessagingRoutePreferences.setClearPathOnMaxRetry(enabled);
|
||||||
|
notifyListeners();
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Error saving clear path on max retry setting: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Initialize location tracking service
|
/// Initialize location tracking service
|
||||||
Future<void> _initializeLocationTracking() async {
|
Future<void> _initializeLocationTracking() async {
|
||||||
try {
|
try {
|
||||||
@@ -488,6 +563,7 @@ class AppProvider with ChangeNotifier {
|
|||||||
contact,
|
contact,
|
||||||
devicePublicKey: connectionProvider.deviceInfo.publicKey,
|
devicePublicKey: connectionProvider.deviceInfo.publicKey,
|
||||||
);
|
);
|
||||||
|
unawaited(_pathHistoryService.recordLearnedPath(contact));
|
||||||
|
|
||||||
// Broadcast to SSE clients if server is running
|
// Broadcast to SSE clients if server is running
|
||||||
connectionProvider.broadcastContactToSseClients(contact);
|
connectionProvider.broadcastContactToSseClients(contact);
|
||||||
@@ -500,6 +576,9 @@ class AppProvider with ChangeNotifier {
|
|||||||
contacts,
|
contacts,
|
||||||
devicePublicKey: connectionProvider.deviceInfo.publicKey,
|
devicePublicKey: connectionProvider.deviceInfo.publicKey,
|
||||||
);
|
);
|
||||||
|
for (final contact in contacts) {
|
||||||
|
unawaited(_pathHistoryService.recordLearnedPath(contact));
|
||||||
|
}
|
||||||
debugPrint('Received ${contacts.length} contacts');
|
debugPrint('Received ${contacts.length} contacts');
|
||||||
|
|
||||||
// Broadcast all contacts to SSE clients if server is running
|
// Broadcast all contacts to SSE clients if server is running
|
||||||
@@ -1117,6 +1196,14 @@ class AppProvider with ChangeNotifier {
|
|||||||
messagesProvider.handleMessageEcho(messageId, echoCount, snrRaw, rssiDbm);
|
messagesProvider.handleMessageEcho(messageId, echoCount, snrRaw, rssiDbm);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
connectionProvider.prepareDirectMessageSendCallback =
|
||||||
|
({required messageId, required contact, required retryAttempt}) async {
|
||||||
|
return _prepareDirectMessageSend(
|
||||||
|
messageId: messageId,
|
||||||
|
contact: contact,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// Wire up MessagesProvider's sendMessageCallback for retry logic
|
// Wire up MessagesProvider's sendMessageCallback for retry logic
|
||||||
messagesProvider.sendMessageCallback =
|
messagesProvider.sendMessageCallback =
|
||||||
({
|
({
|
||||||
@@ -1134,32 +1221,274 @@ class AppProvider with ChangeNotifier {
|
|||||||
retryAttempt: retryAttempt,
|
retryAttempt: retryAttempt,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
messagesProvider.onFinalRouterFallbackCallback =
|
||||||
messagesProvider.onDirectPathFailedCallback =
|
({required messageId, required contact, required message}) async {
|
||||||
({required contact, required failureStreak}) async {
|
return _sendWithFinalNearestRouterFallback(
|
||||||
debugPrint(
|
messageId: messageId,
|
||||||
'🧭 [AppProvider] Clearing unhealthy path for ${contact.advName} after $failureStreak failed send chain(s)',
|
contact: contact,
|
||||||
|
message: message,
|
||||||
);
|
);
|
||||||
|
|
||||||
contactsProvider.markPathUnhealthy(contact.publicKey);
|
|
||||||
|
|
||||||
if (!connectionProvider.deviceInfo.isConnected) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await connectionProvider.resetPath(contact.publicKey);
|
|
||||||
Future.delayed(const Duration(milliseconds: 150), () {
|
|
||||||
if (connectionProvider.deviceInfo.isConnected) {
|
|
||||||
connectionProvider.getContact(contact.publicKey);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint(
|
|
||||||
'⚠️ [AppProvider] Failed to reset path for ${contact.advName}: $e',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
messagesProvider.onFinalDirectMessageFailureCallback =
|
||||||
|
({required messageId, required contact, required message}) async {
|
||||||
|
await _handleDirectMessageFinalFailure(
|
||||||
|
messageId: messageId,
|
||||||
|
contact: contact,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
messagesProvider.onDirectMessageDeliveredCallback =
|
||||||
|
({
|
||||||
|
required messageId,
|
||||||
|
required contact,
|
||||||
|
required message,
|
||||||
|
required roundTripTimeMs,
|
||||||
|
}) {
|
||||||
|
_handleDirectMessageDelivered(
|
||||||
|
messageId: messageId,
|
||||||
|
contact: contact,
|
||||||
|
roundTripTimeMs: roundTripTimeMs,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Contact> _prepareDirectMessageSend({
|
||||||
|
required String messageId,
|
||||||
|
required Contact contact,
|
||||||
|
}) async {
|
||||||
|
final latestContact =
|
||||||
|
contactsProvider.findContactByKey(contact.publicKey) ?? contact;
|
||||||
|
var session = _directMessageRouteSessions[messageId];
|
||||||
|
if (session == null) {
|
||||||
|
final selection = await _pathHistoryService.getSelectionForContact(
|
||||||
|
latestContact,
|
||||||
|
autoRouteRotationEnabled: _autoRouteRotationEnabled,
|
||||||
|
);
|
||||||
|
session = _DirectMessageRouteSession(
|
||||||
|
currentSelection: selection,
|
||||||
|
originalRoute: ContactRouteCodec.fromContact(latestContact),
|
||||||
|
routerFallbackAttempted: false,
|
||||||
|
);
|
||||||
|
_directMessageRouteSessions[messageId] = session;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _applyPathSelection(
|
||||||
|
latestContact,
|
||||||
|
session.currentSelection,
|
||||||
|
messageId: messageId,
|
||||||
|
routerFallbackAttempted: session.routerFallbackAttempted,
|
||||||
|
);
|
||||||
|
return contactsProvider.findContactByKey(contact.publicKey) ??
|
||||||
|
latestContact;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _applyPathSelection(
|
||||||
|
Contact contact,
|
||||||
|
PathSelection selection, {
|
||||||
|
required String messageId,
|
||||||
|
required bool routerFallbackAttempted,
|
||||||
|
}) async {
|
||||||
|
final previousRoute = ContactRouteCodec.fromContact(contact);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (selection.usesFlood) {
|
||||||
|
contactsProvider.resetContactRouteLocal(contact.publicKey);
|
||||||
|
if (connectionProvider.deviceInfo.isConnected) {
|
||||||
|
await connectionProvider.resetPath(contact.publicKey);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
final pathDescriptor =
|
||||||
|
((selection.hashSize - 1) << 6) | (selection.hopCount & 0x3F);
|
||||||
|
final signedDescriptor = ContactRouteCodec.toSignedDescriptor(
|
||||||
|
pathDescriptor,
|
||||||
|
);
|
||||||
|
final paddedPathBytes = Uint8List(ContactRouteCodec.maxPathBytes)
|
||||||
|
..setRange(0, selection.pathBytes.length, selection.pathBytes);
|
||||||
|
|
||||||
|
contactsProvider.setContactRouteLocal(
|
||||||
|
contact.publicKey,
|
||||||
|
signedEncodedPathLen: signedDescriptor,
|
||||||
|
paddedPathBytes: paddedPathBytes,
|
||||||
|
);
|
||||||
|
if (connectionProvider.deviceInfo.isConnected) {
|
||||||
|
await connectionProvider.setContactRoute(
|
||||||
|
contact,
|
||||||
|
signedEncodedPathLen: signedDescriptor,
|
||||||
|
paddedPathBytes: paddedPathBytes,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
_restoreRouteLocal(contact.publicKey, previousRoute);
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
|
||||||
|
messagesProvider.updateMessageRouteSelection(
|
||||||
|
messageId,
|
||||||
|
selection,
|
||||||
|
routerFallbackAttempted: routerFallbackAttempted,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _restoreRouteLocal(Uint8List publicKey, ParsedContactRoute? route) {
|
||||||
|
if (route == null) {
|
||||||
|
contactsProvider.resetContactRouteLocal(publicKey);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
contactsProvider.setContactRouteLocal(
|
||||||
|
publicKey,
|
||||||
|
signedEncodedPathLen: route.signedEncodedPathLen,
|
||||||
|
paddedPathBytes: route.paddedPathBytes,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _restoreRouteOnDevice(
|
||||||
|
Contact contact,
|
||||||
|
ParsedContactRoute? route,
|
||||||
|
) async {
|
||||||
|
_restoreRouteLocal(contact.publicKey, route);
|
||||||
|
if (!connectionProvider.deviceInfo.isConnected) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (route == null) {
|
||||||
|
await connectionProvider.resetPath(contact.publicKey);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await connectionProvider.setContactRoute(
|
||||||
|
contact,
|
||||||
|
signedEncodedPathLen: route.signedEncodedPathLen,
|
||||||
|
paddedPathBytes: route.paddedPathBytes,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
PathSelection _buildNearestRouterSelection(Contact repeater, int hashSize) {
|
||||||
|
return PathSelection(
|
||||||
|
mode: PathSelectionMode.nearestRouter,
|
||||||
|
pathBytes: Uint8List.fromList(repeater.publicKey.sublist(0, hashSize)),
|
||||||
|
hopCount: 1,
|
||||||
|
hashSize: hashSize,
|
||||||
|
relayName: repeater.advName,
|
||||||
|
relayKey6: _key6(repeater.publicKey),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> _sendWithFinalNearestRouterFallback({
|
||||||
|
required String messageId,
|
||||||
|
required Contact contact,
|
||||||
|
required Message message,
|
||||||
|
}) async {
|
||||||
|
final latestContact =
|
||||||
|
contactsProvider.findContactByKey(contact.publicKey) ?? contact;
|
||||||
|
final session =
|
||||||
|
_directMessageRouteSessions[messageId] ??
|
||||||
|
_DirectMessageRouteSession(
|
||||||
|
currentSelection: latestContact.routeHasPath
|
||||||
|
? PathSelection(
|
||||||
|
mode: PathSelectionMode.directCurrent,
|
||||||
|
pathBytes: Uint8List.fromList(latestContact.routePathBytes),
|
||||||
|
hopCount: latestContact.routeHopCount,
|
||||||
|
hashSize: latestContact.routeHashSize,
|
||||||
|
)
|
||||||
|
: PathSelection.flood(),
|
||||||
|
originalRoute: ContactRouteCodec.fromContact(latestContact),
|
||||||
|
routerFallbackAttempted: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
await _pathHistoryService.recordPathResult(
|
||||||
|
latestContact.publicKeyHex,
|
||||||
|
session.currentSelection,
|
||||||
|
success: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
final repeater = _nearestRouterSelector.select(
|
||||||
|
senderPosition: locationTrackingService.currentPosition,
|
||||||
|
repeaters: contactsProvider.repeaters,
|
||||||
|
recipient: latestContact,
|
||||||
|
);
|
||||||
|
if (repeater == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
final routeHashSize = await RouteHashPreferences.getHashSize();
|
||||||
|
final fallbackSelection = _buildNearestRouterSelection(
|
||||||
|
repeater,
|
||||||
|
routeHashSize,
|
||||||
|
);
|
||||||
|
_directMessageRouteSessions[messageId] = session.copyWith(
|
||||||
|
currentSelection: fallbackSelection,
|
||||||
|
routerFallbackAttempted: true,
|
||||||
|
);
|
||||||
|
messagesProvider.updateMessageRouteSelection(
|
||||||
|
messageId,
|
||||||
|
fallbackSelection,
|
||||||
|
routerFallbackAttempted: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
return connectionProvider.sendTextMessage(
|
||||||
|
contactPublicKey: latestContact.publicKey,
|
||||||
|
text: message.text,
|
||||||
|
messageId: messageId,
|
||||||
|
contact: latestContact,
|
||||||
|
retryAttempt: message.retryAttempt + 1,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleDirectMessageDelivered({
|
||||||
|
required String messageId,
|
||||||
|
required Contact contact,
|
||||||
|
required int roundTripTimeMs,
|
||||||
|
}) {
|
||||||
|
final session = _directMessageRouteSessions.remove(messageId);
|
||||||
|
if (session == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
unawaited(
|
||||||
|
_pathHistoryService.recordPathResult(
|
||||||
|
contact.publicKeyHex,
|
||||||
|
session.currentSelection,
|
||||||
|
success: true,
|
||||||
|
roundTripTimeMs: roundTripTimeMs,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _handleDirectMessageFinalFailure({
|
||||||
|
required String messageId,
|
||||||
|
required Contact contact,
|
||||||
|
}) async {
|
||||||
|
final latestContact =
|
||||||
|
contactsProvider.findContactByKey(contact.publicKey) ?? contact;
|
||||||
|
final session = _directMessageRouteSessions.remove(messageId);
|
||||||
|
if (session != null) {
|
||||||
|
await _pathHistoryService.recordPathResult(
|
||||||
|
latestContact.publicKeyHex,
|
||||||
|
session.currentSelection,
|
||||||
|
success: false,
|
||||||
|
);
|
||||||
|
if (session.routerFallbackAttempted) {
|
||||||
|
await _restoreRouteOnDevice(latestContact, session.originalRoute);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_clearPathOnMaxRetry) {
|
||||||
|
contactsProvider.resetContactRouteLocal(latestContact.publicKey);
|
||||||
|
if (connectionProvider.deviceInfo.isConnected) {
|
||||||
|
await connectionProvider.resetPath(latestContact.publicKey);
|
||||||
|
Future.delayed(const Duration(milliseconds: 150), () {
|
||||||
|
if (connectionProvider.deviceInfo.isConnected) {
|
||||||
|
connectionProvider.getContact(latestContact.publicKey);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _key6(Uint8List publicKey) {
|
||||||
|
final bytes = publicKey.sublist(0, math.min(6, publicKey.length));
|
||||||
|
return bytes.map((byte) => byte.toRadixString(16).padLeft(2, '0')).join();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Initialize the app (load contacts, sync time, etc.)
|
/// Initialize the app (load contacts, sync time, etc.)
|
||||||
|
|||||||
@@ -175,6 +175,12 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
Function(String messageId, int expectedAckTag, int suggestedTimeoutMs)?
|
Function(String messageId, int expectedAckTag, int suggestedTimeoutMs)?
|
||||||
onMessageSent;
|
onMessageSent;
|
||||||
Function(int ackCode, int roundTripTimeMs)? onMessageDelivered;
|
Function(int ackCode, int roundTripTimeMs)? onMessageDelivered;
|
||||||
|
Future<Contact?> Function({
|
||||||
|
required String messageId,
|
||||||
|
required Contact contact,
|
||||||
|
required int retryAttempt,
|
||||||
|
})?
|
||||||
|
prepareDirectMessageSendCallback;
|
||||||
Function(String messageId, int echoCount, int snrRaw, int rssiDbm)?
|
Function(String messageId, int echoCount, int snrRaw, int rssiDbm)?
|
||||||
onMessageEchoDetected;
|
onMessageEchoDetected;
|
||||||
Function(Uint8List publicKeyPrefix, Uint8List statusData)? onStatusResponse;
|
Function(Uint8List publicKeyPrefix, Uint8List statusData)? onStatusResponse;
|
||||||
@@ -1073,7 +1079,7 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
///
|
///
|
||||||
/// [messageId] - optional message ID to track delivery status
|
/// [messageId] - optional message ID to track delivery status
|
||||||
/// [contact] - optional contact object for path status logging
|
/// [contact] - optional contact object for path status logging
|
||||||
/// [retryAttempt] - retry attempt number (0 = first send, 1-3 = retries)
|
/// [retryAttempt] - retry attempt number (0 = first send, >0 = retries)
|
||||||
Future<bool> sendTextMessage({
|
Future<bool> sendTextMessage({
|
||||||
required Uint8List contactPublicKey,
|
required Uint8List contactPublicKey,
|
||||||
required String text,
|
required String text,
|
||||||
@@ -1087,6 +1093,17 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var effectiveContact = contact;
|
||||||
|
if (messageId != null &&
|
||||||
|
effectiveContact != null &&
|
||||||
|
prepareDirectMessageSendCallback != null) {
|
||||||
|
effectiveContact = await prepareDirectMessageSendCallback!(
|
||||||
|
messageId: messageId,
|
||||||
|
contact: effectiveContact,
|
||||||
|
retryAttempt: retryAttempt,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// CRITICAL: Check firmware ACK limit (8 max in circular buffer)
|
// CRITICAL: Check firmware ACK limit (8 max in circular buffer)
|
||||||
// Rate limit at 7 to stay under the limit
|
// Rate limit at 7 to stay under the limit
|
||||||
if (_messageDeliveryTracker.shouldRateLimit) {
|
if (_messageDeliveryTracker.shouldRateLimit) {
|
||||||
@@ -1111,33 +1128,33 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Log path status and retry info
|
// Log path status and retry info
|
||||||
if (contact != null) {
|
if (effectiveContact != null) {
|
||||||
if (retryAttempt > 0) {
|
if (retryAttempt > 0) {
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'🔄 [ConnectionProvider] Sending message to ${contact.advName} (retry $retryAttempt/3)',
|
'🔄 [ConnectionProvider] Sending message to ${effectiveContact.advName} (retry $retryAttempt)',
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'📤 [ConnectionProvider] Sending message to ${contact.advName}',
|
'📤 [ConnectionProvider] Sending message to ${effectiveContact.advName}',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
debugPrint(' Type: ${contact.type.displayName}');
|
debugPrint(' Type: ${effectiveContact.type.displayName}');
|
||||||
debugPrint(' Path status: ${contact.routeSummary}');
|
debugPrint(' Path status: ${effectiveContact.routeSummary}');
|
||||||
if (contact.routeHasPath) {
|
if (effectiveContact.routeHasPath) {
|
||||||
debugPrint(
|
debugPrint(
|
||||||
' ✅ Using learned path (${contact.routeHopCount} hop(s), ${contact.routeHashSize}-byte hashes)',
|
' ✅ Using learned path (${effectiveContact.routeHopCount} hop(s), ${effectiveContact.routeHashSize}-byte hashes)',
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
debugPrint(' ⚠️ No path available - will use flood mode');
|
debugPrint(' ⚠️ No path available - will use flood mode');
|
||||||
}
|
}
|
||||||
} else if (retryAttempt > 0) {
|
} else if (retryAttempt > 0) {
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'🔄 [ConnectionProvider] Sending message (retry $retryAttempt/3)',
|
'🔄 [ConnectionProvider] Sending message (retry $retryAttempt)',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Track pending operation for auto-recovery (if contact not found in radio)
|
// Track pending operation for auto-recovery (if contact not found in radio)
|
||||||
if (contact != null) {
|
if (effectiveContact != null) {
|
||||||
final operationId = contactPublicKey
|
final operationId = contactPublicKey
|
||||||
.sublist(0, 6)
|
.sublist(0, 6)
|
||||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||||
@@ -1146,7 +1163,7 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
contactPublicKey: contactPublicKey,
|
contactPublicKey: contactPublicKey,
|
||||||
text: text,
|
text: text,
|
||||||
messageId: messageId,
|
messageId: messageId,
|
||||||
contact: contact,
|
contact: effectiveContact,
|
||||||
retryAttempt: retryAttempt,
|
retryAttempt: retryAttempt,
|
||||||
);
|
);
|
||||||
debugPrint(
|
debugPrint(
|
||||||
@@ -1191,7 +1208,7 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
|
|
||||||
// Clear pending operation after successful send (no error)
|
// Clear pending operation after successful send (no error)
|
||||||
// If ERR_CODE_NOT_FOUND occurs, the operation will be recovered automatically
|
// If ERR_CODE_NOT_FOUND occurs, the operation will be recovered automatically
|
||||||
if (contact != null) {
|
if (effectiveContact != null) {
|
||||||
final operationId = contactPublicKey
|
final operationId = contactPublicKey
|
||||||
.sublist(0, 6)
|
.sublist(0, 6)
|
||||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||||
|
|||||||
@@ -5,15 +5,13 @@ import '../../models/contact.dart';
|
|||||||
|
|
||||||
/// Manages message retry state and logic
|
/// Manages message retry state and logic
|
||||||
///
|
///
|
||||||
/// This helper class centralizes retry logic for direct messages, implementing
|
/// This helper class centralizes retry logic for direct messages.
|
||||||
/// a progressive timeout strategy (4s, 8s, 12s) for messages sent to contacts
|
|
||||||
/// with learned routing paths.
|
|
||||||
///
|
///
|
||||||
/// IMPORTANT: Based on MeshCore firmware analysis:
|
/// IMPORTANT: Based on MeshCore firmware analysis:
|
||||||
/// - Firmware calculates timeout based on path length and airtime
|
/// - Firmware calculates timeout based on path length and airtime
|
||||||
/// - Direct mode: ~(path_len * airtime * 2) + margin
|
/// - Direct mode: ~(path_len * airtime * 2) + margin
|
||||||
/// - Flood mode: ~10-30 seconds for multi-hop
|
/// - Flood mode: ~10-30 seconds for multi-hop
|
||||||
/// - Our timeouts (4s, 8s, 12s) are conservative for direct paths
|
/// - Our retry delays (1s, 2s, 4s, 8s) are app-level backoff timers
|
||||||
/// - Firmware does NOT automatically retry - app must implement
|
/// - Firmware does NOT automatically retry - app must implement
|
||||||
class MessageRetryManager {
|
class MessageRetryManager {
|
||||||
// Track retry state for each message ID
|
// Track retry state for each message ID
|
||||||
@@ -21,10 +19,10 @@ class MessageRetryManager {
|
|||||||
final Map<String, DateTime> _lastRetryTimes = {};
|
final Map<String, DateTime> _lastRetryTimes = {};
|
||||||
final Map<String, int> _pathFailureStreaks = {};
|
final Map<String, int> _pathFailureStreaks = {};
|
||||||
|
|
||||||
// Progressive timeout values in milliseconds
|
static const int maxRetryAttempts = 4;
|
||||||
// These are app-level timeouts, separate from firmware's suggested timeout
|
|
||||||
// Firmware timeout is for ACK arrival, these are for retry attempts
|
// Retry backoff values in milliseconds.
|
||||||
static const List<int> _timeouts = [4000, 8000, 12000];
|
static const List<int> _retryDelays = [1000, 2000, 4000, 8000];
|
||||||
static const int _defaultLoRaSf = 10;
|
static const int _defaultLoRaSf = 10;
|
||||||
static const int _defaultLoRaCr = 5;
|
static const int _defaultLoRaCr = 5;
|
||||||
static const int _defaultLoRaBwHz = 250000;
|
static const int _defaultLoRaBwHz = 250000;
|
||||||
@@ -32,13 +30,12 @@ class MessageRetryManager {
|
|||||||
static const int _defaultLoRaCrcEnabled = 1;
|
static const int _defaultLoRaCrcEnabled = 1;
|
||||||
static const int _defaultLoRaExplicitHeader = 1;
|
static const int _defaultLoRaExplicitHeader = 1;
|
||||||
|
|
||||||
/// Get timeout for a specific retry attempt (0-2)
|
/// Get backoff delay for the next retry attempt.
|
||||||
/// Returns: 4000ms for attempt 0, 8000ms for attempt 1, 12000ms for attempt 2
|
int getDelayForAttempt(int attempt) {
|
||||||
int getTimeoutForAttempt(int attempt) {
|
if (attempt < 0 || attempt >= _retryDelays.length) {
|
||||||
if (attempt < 0 || attempt >= _timeouts.length) {
|
return _retryDelays.last;
|
||||||
return _timeouts.last; // Default to last timeout if out of range
|
|
||||||
}
|
}
|
||||||
return _timeouts[attempt];
|
return _retryDelays[attempt];
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Calculate a conservative delivery-ACK timeout when firmware doesn't
|
/// Calculate a conservative delivery-ACK timeout when firmware doesn't
|
||||||
@@ -65,43 +62,8 @@ class MessageRetryManager {
|
|||||||
return ((airtimeMs * (hopCount + 1) * 2) + 1500).clamp(4000, 20000);
|
return ((airtimeMs * (hopCount + 1) * 2) + 1500).clamp(4000, 20000);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if a message is eligible for retry
|
|
||||||
///
|
|
||||||
/// Returns true if:
|
|
||||||
/// - The message has retryAttempt < 3
|
|
||||||
/// - The contact has a learned path (contact.hasPath == true)
|
|
||||||
/// - The message hasn't used flood fallback yet
|
|
||||||
///
|
|
||||||
/// Messages to contacts without paths should NOT retry (flood mode already broadcasts)
|
|
||||||
bool canRetry(Message message, Contact contact) {
|
bool canRetry(Message message, Contact contact) {
|
||||||
// Never retry if already tried flood mode
|
return message.retryAttempt < maxRetryAttempts;
|
||||||
if (message.usedFloodFallback) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Never retry beyond 3 attempts
|
|
||||||
if (message.retryAttempt >= 3) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only retry if contact has a learned path
|
|
||||||
// If no path, the device uses flood mode automatically - retrying won't help
|
|
||||||
return contact.routeHasPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check if should fall back to flood mode
|
|
||||||
///
|
|
||||||
/// Returns true if:
|
|
||||||
/// - Message has exhausted all 3 retry attempts with direct mode
|
|
||||||
/// - Contact HAS a learned path (so direct mode was used)
|
|
||||||
/// - Hasn't already used flood fallback
|
|
||||||
///
|
|
||||||
/// IMPORTANT: Only contacts WITH paths need flood fallback.
|
|
||||||
/// Contacts without paths already use flood mode automatically.
|
|
||||||
bool shouldUseFloodFallback(Message message, Contact contact) {
|
|
||||||
return message.retryAttempt >= 3 &&
|
|
||||||
contact.routeHasPath &&
|
|
||||||
!message.usedFloodFallback;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Track a retry attempt for a message
|
/// Track a retry attempt for a message
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import '../models/contact.dart';
|
|||||||
import '../models/message_contact_location.dart';
|
import '../models/message_contact_location.dart';
|
||||||
import '../models/message_reception_details.dart';
|
import '../models/message_reception_details.dart';
|
||||||
import '../models/message_transfer_details.dart';
|
import '../models/message_transfer_details.dart';
|
||||||
|
import '../models/message_route_metadata.dart';
|
||||||
|
import '../models/path_selection.dart';
|
||||||
import '../models/sar_marker.dart';
|
import '../models/sar_marker.dart';
|
||||||
import '../models/map_drawing.dart';
|
import '../models/map_drawing.dart';
|
||||||
import '../services/message_storage_service.dart';
|
import '../services/message_storage_service.dart';
|
||||||
@@ -27,6 +29,7 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
final Map<String, MessageContactLocation> _messageContactLocations = {};
|
final Map<String, MessageContactLocation> _messageContactLocations = {};
|
||||||
final Map<String, MessageReceptionDetails> _messageReceptionDetails = {};
|
final Map<String, MessageReceptionDetails> _messageReceptionDetails = {};
|
||||||
final Map<String, MessageTransferDetails> _messageTransferDetails = {};
|
final Map<String, MessageTransferDetails> _messageTransferDetails = {};
|
||||||
|
final Map<String, MessageRouteMetadata> _messageRouteMetadata = {};
|
||||||
|
|
||||||
// Track pending sent messages by expected ACK/TAG
|
// Track pending sent messages by expected ACK/TAG
|
||||||
final Map<int, Message> _pendingSentMessages = {};
|
final Map<int, Message> _pendingSentMessages = {};
|
||||||
@@ -81,6 +84,25 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
|
|
||||||
Future<void> Function({required Contact contact, required int failureStreak})?
|
Future<void> Function({required Contact contact, required int failureStreak})?
|
||||||
onDirectPathFailedCallback;
|
onDirectPathFailedCallback;
|
||||||
|
Future<bool> Function({
|
||||||
|
required String messageId,
|
||||||
|
required Contact contact,
|
||||||
|
required Message message,
|
||||||
|
})?
|
||||||
|
onFinalRouterFallbackCallback;
|
||||||
|
Future<void> Function({
|
||||||
|
required String messageId,
|
||||||
|
required Contact contact,
|
||||||
|
required Message message,
|
||||||
|
})?
|
||||||
|
onFinalDirectMessageFailureCallback;
|
||||||
|
void Function({
|
||||||
|
required String messageId,
|
||||||
|
required Contact contact,
|
||||||
|
required Message message,
|
||||||
|
required int roundTripTimeMs,
|
||||||
|
})?
|
||||||
|
onDirectMessageDeliveredCallback;
|
||||||
|
|
||||||
String? Function(Uint8List? publicKey)? resolveContactNameCallback;
|
String? Function(Uint8List? publicKey)? resolveContactNameCallback;
|
||||||
String Function(int channelIdx)? resolveChannelNameCallback;
|
String Function(int channelIdx)? resolveChannelNameCallback;
|
||||||
@@ -126,6 +148,30 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
MessageTransferDetails? getMessageTransferDetails(String messageId) =>
|
MessageTransferDetails? getMessageTransferDetails(String messageId) =>
|
||||||
_messageTransferDetails[messageId];
|
_messageTransferDetails[messageId];
|
||||||
|
|
||||||
|
MessageRouteMetadata? getMessageRouteMetadata(String messageId) =>
|
||||||
|
_messageRouteMetadata[messageId];
|
||||||
|
|
||||||
|
void updateMessageRouteSelection(
|
||||||
|
String messageId,
|
||||||
|
PathSelection selection, {
|
||||||
|
required bool routerFallbackAttempted,
|
||||||
|
}) {
|
||||||
|
_messageRouteMetadata[messageId] = MessageRouteMetadata.fromSelection(
|
||||||
|
selection,
|
||||||
|
routerFallbackAttempted: routerFallbackAttempted,
|
||||||
|
);
|
||||||
|
|
||||||
|
final index = _messages.indexWhere((message) => message.id == messageId);
|
||||||
|
if (index != -1) {
|
||||||
|
_messages[index] = _messages[index].copyWith(
|
||||||
|
usedFloodFallback: selection.usesFlood,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
_persistMessages();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
/// Set localizations for notifications
|
/// Set localizations for notifications
|
||||||
void setLocalizations(AppLocalizations localizations) {
|
void setLocalizations(AppLocalizations localizations) {
|
||||||
_localizations = localizations;
|
_localizations = localizations;
|
||||||
@@ -160,6 +206,8 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
.loadMessageReceptionDetails();
|
.loadMessageReceptionDetails();
|
||||||
final storedTransferDetails = await _storageService
|
final storedTransferDetails = await _storageService
|
||||||
.loadMessageTransferDetails();
|
.loadMessageTransferDetails();
|
||||||
|
final storedRouteMetadata = await _storageService
|
||||||
|
.loadMessageRouteMetadata();
|
||||||
_messageContactLocations
|
_messageContactLocations
|
||||||
..clear()
|
..clear()
|
||||||
..addAll(storedContactLocations);
|
..addAll(storedContactLocations);
|
||||||
@@ -169,6 +217,9 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
_messageTransferDetails
|
_messageTransferDetails
|
||||||
..clear()
|
..clear()
|
||||||
..addAll(storedTransferDetails);
|
..addAll(storedTransferDetails);
|
||||||
|
_messageRouteMetadata
|
||||||
|
..clear()
|
||||||
|
..addAll(storedRouteMetadata);
|
||||||
|
|
||||||
// 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) {
|
||||||
@@ -688,6 +739,7 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
messageContactLocations: _messageContactLocations,
|
messageContactLocations: _messageContactLocations,
|
||||||
messageReceptionDetails: _messageReceptionDetails,
|
messageReceptionDetails: _messageReceptionDetails,
|
||||||
messageTransferDetails: _messageTransferDetails,
|
messageTransferDetails: _messageTransferDetails,
|
||||||
|
messageRouteMetadata: _messageRouteMetadata,
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('❌ [MessagesProvider] Error persisting messages: $e');
|
debugPrint('❌ [MessagesProvider] Error persisting messages: $e');
|
||||||
@@ -806,6 +858,7 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
_messageContactLocations.remove(messageId);
|
_messageContactLocations.remove(messageId);
|
||||||
_messageReceptionDetails.remove(messageId);
|
_messageReceptionDetails.remove(messageId);
|
||||||
_messageTransferDetails.remove(messageId);
|
_messageTransferDetails.remove(messageId);
|
||||||
|
_messageRouteMetadata.remove(messageId);
|
||||||
|
|
||||||
debugPrint('🗑️ [MessagesProvider] Message $messageId deleted');
|
debugPrint('🗑️ [MessagesProvider] Message $messageId deleted');
|
||||||
|
|
||||||
@@ -837,6 +890,7 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
_messageContactLocations.clear();
|
_messageContactLocations.clear();
|
||||||
_messageReceptionDetails.clear();
|
_messageReceptionDetails.clear();
|
||||||
_messageTransferDetails.clear();
|
_messageTransferDetails.clear();
|
||||||
|
_messageRouteMetadata.clear();
|
||||||
_persistMessages();
|
_persistMessages();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
@@ -854,6 +908,7 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
_messageContactLocations.clear();
|
_messageContactLocations.clear();
|
||||||
_messageReceptionDetails.clear();
|
_messageReceptionDetails.clear();
|
||||||
_messageTransferDetails.clear();
|
_messageTransferDetails.clear();
|
||||||
|
_messageRouteMetadata.clear();
|
||||||
_persistMessages();
|
_persistMessages();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
@@ -1549,6 +1604,12 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
final deliveredContact = _messageContactMap[message.id];
|
final deliveredContact = _messageContactMap[message.id];
|
||||||
if (deliveredContact != null) {
|
if (deliveredContact != null) {
|
||||||
_retryManager.recordDeliverySuccess(deliveredContact);
|
_retryManager.recordDeliverySuccess(deliveredContact);
|
||||||
|
onDirectMessageDeliveredCallback?.call(
|
||||||
|
messageId: message.id,
|
||||||
|
contact: deliveredContact,
|
||||||
|
message: updatedMessage,
|
||||||
|
roundTripTimeMs: roundTripTimeMs,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
debugPrint(
|
debugPrint(
|
||||||
@@ -1592,6 +1653,12 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
final deliveredContact = _messageContactMap[historicalMessageId];
|
final deliveredContact = _messageContactMap[historicalMessageId];
|
||||||
if (deliveredContact != null) {
|
if (deliveredContact != null) {
|
||||||
_retryManager.recordDeliverySuccess(deliveredContact);
|
_retryManager.recordDeliverySuccess(deliveredContact);
|
||||||
|
onDirectMessageDeliveredCallback?.call(
|
||||||
|
messageId: historicalMessageId,
|
||||||
|
contact: deliveredContact,
|
||||||
|
message: _messages[historicalIndex],
|
||||||
|
roundTripTimeMs: roundTripTimeMs,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
_persistMessages();
|
_persistMessages();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
@@ -1677,29 +1744,29 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
debugPrint(' Contact has path: ${contact?.routeHasPath ?? false}');
|
debugPrint(' Contact has path: ${contact?.routeHasPath ?? false}');
|
||||||
debugPrint(' Used flood fallback: ${message.usedFloodFallback}');
|
debugPrint(' Used flood fallback: ${message.usedFloodFallback}');
|
||||||
|
|
||||||
// Decision tree for retry/flood/fail
|
final routeMetadata = _messageRouteMetadata[messageId];
|
||||||
|
final routerFallbackAttempted =
|
||||||
|
routeMetadata?.routerFallbackAttempted ?? false;
|
||||||
|
|
||||||
|
// Decision tree for retry/final-router-fallback/fail
|
||||||
if (contact != null && _retryManager.canRetry(message, contact)) {
|
if (contact != null && _retryManager.canRetry(message, contact)) {
|
||||||
// RETRY: Contact has path and retry attempts < 3
|
|
||||||
_scheduleRetry(messageId, message, contact);
|
_scheduleRetry(messageId, message, contact);
|
||||||
} else if (contact != null &&
|
} else if (contact != null && !routerFallbackAttempted) {
|
||||||
_retryManager.shouldUseFloodFallback(message, contact)) {
|
unawaited(_sendWithFinalRouterFallback(messageId, message, contact));
|
||||||
// FLOOD FALLBACK: After 3 retries failed, try flood once
|
|
||||||
_sendWithFloodMode(messageId, message, contact);
|
|
||||||
} else {
|
} else {
|
||||||
// PERMANENTLY FAILED: No retry possible
|
|
||||||
_markAsPermanentlyFailed(messageId, message);
|
_markAsPermanentlyFailed(messageId, message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Schedule a retry with progressive timeout
|
/// Schedule a retry with exponential backoff.
|
||||||
void _scheduleRetry(String messageId, Message message, Contact contact) {
|
void _scheduleRetry(String messageId, Message message, Contact contact) {
|
||||||
final nextAttempt = message.retryAttempt + 1;
|
final nextAttempt = message.retryAttempt + 1;
|
||||||
final timeout = _retryManager.getTimeoutForAttempt(message.retryAttempt);
|
final delayMs = _retryManager.getDelayForAttempt(message.retryAttempt);
|
||||||
|
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'🔄 [MessagesProvider] Scheduling retry $nextAttempt/3 for message $messageId',
|
'🔄 [MessagesProvider] Scheduling retry $nextAttempt/${MessageRetryManager.maxRetryAttempts} for message $messageId',
|
||||||
);
|
);
|
||||||
debugPrint(' Timeout: ${timeout}ms');
|
debugPrint(' Delay: ${delayMs}ms');
|
||||||
|
|
||||||
// Update message with new retry attempt
|
// Update message with new retry attempt
|
||||||
final index = _messages.indexWhere((m) => m.id == messageId);
|
final index = _messages.indexWhere((m) => m.id == messageId);
|
||||||
@@ -1720,10 +1787,10 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
// Track retry
|
// Track retry
|
||||||
_retryManager.trackRetry(messageId, nextAttempt);
|
_retryManager.trackRetry(messageId, nextAttempt);
|
||||||
|
|
||||||
notifyListeners(); // Update UI to show "Retrying (X/3)..."
|
notifyListeners();
|
||||||
|
|
||||||
// Schedule actual retry after delay
|
// Schedule actual retry after delay
|
||||||
Timer(Duration(milliseconds: timeout), () async {
|
Timer(Duration(milliseconds: delayMs), () async {
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'⏰ [MessagesProvider] Executing retry $nextAttempt for message $messageId',
|
'⏰ [MessagesProvider] Executing retry $nextAttempt for message $messageId',
|
||||||
);
|
);
|
||||||
@@ -1759,49 +1826,46 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Send message with flood mode as last resort
|
Future<void> _sendWithFinalRouterFallback(
|
||||||
Future<void> _sendWithFloodMode(
|
|
||||||
String messageId,
|
String messageId,
|
||||||
Message message,
|
Message message,
|
||||||
Contact contact,
|
Contact contact,
|
||||||
) async {
|
) async {
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'🌊 [MessagesProvider] Trying flood mode for message $messageId',
|
'🛟 [MessagesProvider] Trying final router fallback for $messageId',
|
||||||
);
|
);
|
||||||
|
|
||||||
final index = _messages.indexWhere((m) => m.id == messageId);
|
final index = _messages.indexWhere((m) => m.id == messageId);
|
||||||
if (index != -1) {
|
if (index != -1) {
|
||||||
_messages[index] = message.copyWith(
|
_messages[index] = message.copyWith(
|
||||||
usedFloodFallback: true,
|
|
||||||
deliveryStatus: MessageDeliveryStatus.sending,
|
deliveryStatus: MessageDeliveryStatus.sending,
|
||||||
|
lastRetryAt: DateTime.now(),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Cancel old timeout timer
|
|
||||||
_timeoutTimers[message.id]?.cancel();
|
_timeoutTimers[message.id]?.cancel();
|
||||||
_timeoutTimers.remove(message.id);
|
_timeoutTimers.remove(message.id);
|
||||||
if (message.expectedAckTag != null) {
|
if (message.expectedAckTag != null) {
|
||||||
_pendingSentMessages.remove(message.expectedAckTag);
|
_pendingSentMessages.remove(message.expectedAckTag);
|
||||||
}
|
}
|
||||||
|
_clearAckHistoryForMessage(messageId);
|
||||||
|
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|
||||||
// Send with flood mode (no retry after this)
|
if (onFinalRouterFallbackCallback == null) {
|
||||||
if (sendMessageCallback != null) {
|
|
||||||
final queued = await sendMessageCallback!(
|
|
||||||
contactPublicKey: contact.publicKey,
|
|
||||||
text: message.text,
|
|
||||||
messageId: messageId,
|
|
||||||
contact: contact,
|
|
||||||
retryAttempt: 0, // Reset attempt for flood
|
|
||||||
);
|
|
||||||
if (!queued) {
|
|
||||||
_markAsPermanentlyFailed(messageId, _messages[index]);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'⚠️ [MessagesProvider] sendMessageCallback not set, cannot send flood',
|
'⚠️ [MessagesProvider] onFinalRouterFallbackCallback not set',
|
||||||
);
|
);
|
||||||
_markAsPermanentlyFailed(messageId, _messages[index]);
|
_markAsPermanentlyFailed(messageId, _messages[index]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final queued = await onFinalRouterFallbackCallback!(
|
||||||
|
messageId: messageId,
|
||||||
|
contact: contact,
|
||||||
|
message: _messages[index],
|
||||||
|
);
|
||||||
|
if (!queued) {
|
||||||
|
_markAsPermanentlyFailed(messageId, _messages[index]);
|
||||||
}
|
}
|
||||||
|
|
||||||
_persistMessages();
|
_persistMessages();
|
||||||
@@ -1830,19 +1894,15 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
_retryManager.clearRetry(messageId);
|
_retryManager.clearRetry(messageId);
|
||||||
|
|
||||||
final failedContact = _messageContactMap[messageId];
|
final failedContact = _messageContactMap[messageId];
|
||||||
if (failedContact != null && failedContact.routeHasPath) {
|
if (failedContact != null &&
|
||||||
final failureStreak = _retryManager.recordPathFailure(failedContact);
|
onFinalDirectMessageFailureCallback != null) {
|
||||||
debugPrint(
|
unawaited(
|
||||||
' Path failure streak for ${failedContact.advName}: $failureStreak',
|
onFinalDirectMessageFailureCallback!(
|
||||||
|
messageId: messageId,
|
||||||
|
contact: failedContact,
|
||||||
|
message: _messages[index],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
if (failureStreak >= 2 && onDirectPathFailedCallback != null) {
|
|
||||||
unawaited(
|
|
||||||
onDirectPathFailedCallback!(
|
|
||||||
contact: failedContact,
|
|
||||||
failureStreak: failureStreak,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_persistMessages();
|
_persistMessages();
|
||||||
@@ -1870,6 +1930,7 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
_clearAckHistoryForMessage(messageId);
|
_clearAckHistoryForMessage(messageId);
|
||||||
_retryManager.clearRetry(messageId);
|
_retryManager.clearRetry(messageId);
|
||||||
|
_messageRouteMetadata.remove(messageId);
|
||||||
|
|
||||||
_messages[index] = Message(
|
_messages[index] = Message(
|
||||||
id: message.id,
|
id: message.id,
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import '../l10n/app_localizations.dart';
|
|||||||
import '../widgets/permission_request_dialog.dart';
|
import '../widgets/permission_request_dialog.dart';
|
||||||
import '../widgets/connection_dialog.dart';
|
import '../widgets/connection_dialog.dart';
|
||||||
import '../utils/battery_display_helper.dart';
|
import '../utils/battery_display_helper.dart';
|
||||||
|
import '../services/developer_mode_service.dart';
|
||||||
|
|
||||||
enum _HomeTab { messages, contacts, sensors, map }
|
enum _HomeTab { messages, contacts, sensors, map }
|
||||||
|
|
||||||
@@ -55,6 +56,7 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
int _currentIndex = 0;
|
int _currentIndex = 0;
|
||||||
bool _isMapFullscreen = false;
|
bool _isMapFullscreen = false;
|
||||||
bool _showRxTxIndicators = true;
|
bool _showRxTxIndicators = true;
|
||||||
|
bool _isDeveloperModeEnabled = false;
|
||||||
bool _isMapEnabled = true;
|
bool _isMapEnabled = true;
|
||||||
bool _isContactsEnabled = true;
|
bool _isContactsEnabled = true;
|
||||||
bool _isSensorsEnabled = false;
|
bool _isSensorsEnabled = false;
|
||||||
@@ -90,6 +92,7 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
// Initialize synchronously so first build always has a valid controller.
|
// Initialize synchronously so first build always has a valid controller.
|
||||||
_initTabController();
|
_initTabController();
|
||||||
_loadRxTxPreference();
|
_loadRxTxPreference();
|
||||||
|
_loadDeveloperModePreference();
|
||||||
|
|
||||||
// Show permission dialog after the first frame if needed
|
// Show permission dialog after the first frame if needed
|
||||||
if (widget.shouldShowPermissionDialog) {
|
if (widget.shouldShowPermissionDialog) {
|
||||||
@@ -228,6 +231,14 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _loadDeveloperModePreference() async {
|
||||||
|
final isEnabled = await DeveloperModeService.isEnabled();
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_isDeveloperModeEnabled = isEnabled;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
WidgetsBinding.instance.removeObserver(this);
|
WidgetsBinding.instance.removeObserver(this);
|
||||||
@@ -598,56 +609,67 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
),
|
),
|
||||||
PopupMenuButton(
|
PopupMenuButton(
|
||||||
icon: const Icon(Icons.more_vert),
|
icon: const Icon(Icons.more_vert),
|
||||||
itemBuilder: (context) => [
|
itemBuilder: (context) {
|
||||||
PopupMenuItem(
|
final items = <PopupMenuEntry<void>>[];
|
||||||
child: Row(
|
|
||||||
children: [
|
if (_isDeveloperModeEnabled) {
|
||||||
const Icon(Icons.radar),
|
items.add(
|
||||||
const SizedBox(width: 8),
|
PopupMenuItem(
|
||||||
const Text('Spectrum Scan'),
|
child: Row(
|
||||||
],
|
children: [
|
||||||
),
|
const Icon(Icons.radar),
|
||||||
onTap: () {
|
const SizedBox(width: 8),
|
||||||
final navigator = Navigator.of(context);
|
const Text('Spectrum Scan'),
|
||||||
Future.delayed(Duration.zero, () {
|
],
|
||||||
if (!mounted) return;
|
),
|
||||||
navigator.push(
|
onTap: () {
|
||||||
MaterialPageRoute(
|
final navigator = Navigator.of(context);
|
||||||
builder: (context) => const SpectrumScanScreen(),
|
Future.delayed(Duration.zero, () {
|
||||||
),
|
if (!mounted) return;
|
||||||
);
|
navigator.push(
|
||||||
});
|
MaterialPageRoute(
|
||||||
},
|
builder: (context) =>
|
||||||
),
|
const SpectrumScanScreen(),
|
||||||
PopupMenuItem(
|
),
|
||||||
child: Row(
|
);
|
||||||
children: [
|
});
|
||||||
const Icon(Icons.settings),
|
},
|
||||||
const SizedBox(width: 8),
|
),
|
||||||
Text(AppLocalizations.of(context)!.settings),
|
);
|
||||||
],
|
}
|
||||||
),
|
|
||||||
onTap: () {
|
items.add(
|
||||||
// Capture context-dependent objects before async gap
|
PopupMenuItem(
|
||||||
final navigator = Navigator.of(context);
|
child: Row(
|
||||||
Future.delayed(Duration.zero, () async {
|
children: [
|
||||||
if (!mounted) return;
|
const Icon(Icons.settings),
|
||||||
await navigator.push(
|
const SizedBox(width: 8),
|
||||||
MaterialPageRoute(
|
Text(AppLocalizations.of(context)!.settings),
|
||||||
builder: (context) => SettingsScreen(
|
],
|
||||||
onThemeChanged: widget.onThemeChanged,
|
),
|
||||||
onLocaleChanged: widget.onLocaleChanged,
|
onTap: () {
|
||||||
currentTheme: widget.currentTheme,
|
final navigator = Navigator.of(context);
|
||||||
currentLocale: widget.currentLocale,
|
Future.delayed(Duration.zero, () async {
|
||||||
|
if (!mounted) return;
|
||||||
|
await navigator.push(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (context) => SettingsScreen(
|
||||||
|
onThemeChanged: widget.onThemeChanged,
|
||||||
|
onLocaleChanged: widget.onLocaleChanged,
|
||||||
|
currentTheme: widget.currentTheme,
|
||||||
|
currentLocale: widget.currentLocale,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
);
|
_loadRxTxPreference();
|
||||||
// Reload preference when returning from settings
|
_loadDeveloperModePreference();
|
||||||
_loadRxTxPreference();
|
});
|
||||||
});
|
},
|
||||||
},
|
),
|
||||||
),
|
);
|
||||||
],
|
|
||||||
|
return items;
|
||||||
|
},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ 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';
|
||||||
import '../services/image_codec_service.dart';
|
import '../services/image_codec_service.dart';
|
||||||
|
import '../services/developer_mode_service.dart';
|
||||||
import '../utils/sample_data_generator.dart';
|
import '../utils/sample_data_generator.dart';
|
||||||
import '../utils/image_message_parser.dart';
|
import '../utils/image_message_parser.dart';
|
||||||
import '../utils/voice_message_parser.dart';
|
import '../utils/voice_message_parser.dart';
|
||||||
@@ -70,6 +71,8 @@ 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 _isDeveloperModeEnabled = false;
|
||||||
|
int _versionTapCount = 0;
|
||||||
final ImagePicker _imagePicker = ImagePicker();
|
final ImagePicker _imagePicker = ImagePicker();
|
||||||
final LocationTrackingService _locationService = LocationTrackingService();
|
final LocationTrackingService _locationService = LocationTrackingService();
|
||||||
|
|
||||||
@@ -85,6 +88,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
_loadRouteHashSizePreference();
|
_loadRouteHashSizePreference();
|
||||||
_loadImagePreferences();
|
_loadImagePreferences();
|
||||||
_loadFastLocationSettings();
|
_loadFastLocationSettings();
|
||||||
|
_loadDeveloperMode();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -114,6 +118,48 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _loadDeveloperMode() async {
|
||||||
|
final isEnabled = await DeveloperModeService.isEnabled();
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_isDeveloperModeEnabled = isEnabled;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _handleVersionTap() async {
|
||||||
|
if (_isDeveloperModeEnabled) {
|
||||||
|
await DeveloperModeService.setEnabled(false);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_isDeveloperModeEnabled = false;
|
||||||
|
_versionTapCount = 0;
|
||||||
|
});
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(const SnackBar(content: Text('Developer mode disabled')));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final nextTapCount = _versionTapCount + 1;
|
||||||
|
if (nextTapCount >= 3) {
|
||||||
|
await DeveloperModeService.setEnabled(true);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_isDeveloperModeEnabled = true;
|
||||||
|
_versionTapCount = 0;
|
||||||
|
});
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(const SnackBar(content: Text('Developer mode enabled')));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_versionTapCount = nextTapCount;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _saveRxTxPreference(bool value) async {
|
Future<void> _saveRxTxPreference(bool value) async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
await prefs.setBool('show_rx_tx_indicators', value);
|
await prefs.setBool('show_rx_tx_indicators', value);
|
||||||
@@ -929,6 +975,32 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
trailing: const Icon(Icons.chevron_right),
|
trailing: const Icon(Icons.chevron_right),
|
||||||
onTap: _showRouteHashSizeDialog,
|
onTap: _showRouteHashSizeDialog,
|
||||||
),
|
),
|
||||||
|
Consumer<AppProvider>(
|
||||||
|
builder: (context, appProvider, child) => SwitchListTile(
|
||||||
|
secondary: const Icon(Icons.swap_horiz),
|
||||||
|
title: const Text('Auto route rotation'),
|
||||||
|
subtitle: const Text(
|
||||||
|
'Rotate between best known direct paths and flood mode for room/contact sends',
|
||||||
|
),
|
||||||
|
value: appProvider.autoRouteRotationEnabled,
|
||||||
|
onChanged: (value) async {
|
||||||
|
await appProvider.toggleAutoRouteRotationEnabled(value);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Consumer<AppProvider>(
|
||||||
|
builder: (context, appProvider, child) => SwitchListTile(
|
||||||
|
secondary: const Icon(Icons.route),
|
||||||
|
title: const Text('Clear path on max retry'),
|
||||||
|
subtitle: const Text(
|
||||||
|
'Clear the route only after all retries and final router fallback fail',
|
||||||
|
),
|
||||||
|
value: appProvider.clearPathOnMaxRetry,
|
||||||
|
onChanged: (value) async {
|
||||||
|
await appProvider.toggleClearPathOnMaxRetry(value);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.delete_sweep, color: Colors.red),
|
leading: const Icon(Icons.delete_sweep, color: Colors.red),
|
||||||
title: const Text(
|
title: const Text(
|
||||||
@@ -1197,6 +1269,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
? '${_packageInfo!.version} (${_packageInfo!.buildNumber})'
|
? '${_packageInfo!.version} (${_packageInfo!.buildNumber})'
|
||||||
: 'Loading...',
|
: 'Loading...',
|
||||||
),
|
),
|
||||||
|
onTap: _handleVersionTap,
|
||||||
),
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.badge),
|
leading: const Icon(Icons.badge),
|
||||||
|
|||||||
15
lib/services/developer_mode_service.dart
Normal file
15
lib/services/developer_mode_service.dart
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
class DeveloperModeService {
|
||||||
|
static const String _developerModeKey = 'developer_mode_enabled';
|
||||||
|
|
||||||
|
static Future<bool> isEnabled() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
return prefs.getBool(_developerModeKey) ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> setEnabled(bool enabled) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setBool(_developerModeKey, enabled);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import '../models/message.dart';
|
|||||||
import '../models/message_contact_location.dart';
|
import '../models/message_contact_location.dart';
|
||||||
import '../models/message_reception_details.dart';
|
import '../models/message_reception_details.dart';
|
||||||
import '../models/message_transfer_details.dart';
|
import '../models/message_transfer_details.dart';
|
||||||
|
import '../models/message_route_metadata.dart';
|
||||||
import 'package:latlong2/latlong.dart';
|
import 'package:latlong2/latlong.dart';
|
||||||
|
|
||||||
/// Service for persisting messages to local storage
|
/// Service for persisting messages to local storage
|
||||||
@@ -16,6 +17,8 @@ class MessageStorageService {
|
|||||||
'stored_message_reception_details';
|
'stored_message_reception_details';
|
||||||
static const String _messageTransferDetailsKey =
|
static const String _messageTransferDetailsKey =
|
||||||
'stored_message_transfer_details';
|
'stored_message_transfer_details';
|
||||||
|
static const String _messageRouteMetadataKey =
|
||||||
|
'stored_message_route_metadata';
|
||||||
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
|
||||||
@@ -24,6 +27,7 @@ class MessageStorageService {
|
|||||||
Map<String, MessageContactLocation> messageContactLocations = const {},
|
Map<String, MessageContactLocation> messageContactLocations = const {},
|
||||||
Map<String, MessageReceptionDetails> messageReceptionDetails = const {},
|
Map<String, MessageReceptionDetails> messageReceptionDetails = const {},
|
||||||
Map<String, MessageTransferDetails> messageTransferDetails = const {},
|
Map<String, MessageTransferDetails> messageTransferDetails = const {},
|
||||||
|
Map<String, MessageRouteMetadata> messageRouteMetadata = const {},
|
||||||
}) async {
|
}) async {
|
||||||
try {
|
try {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
@@ -44,6 +48,7 @@ class MessageStorageService {
|
|||||||
final locationJson = <String, dynamic>{};
|
final locationJson = <String, dynamic>{};
|
||||||
final receptionJson = <String, dynamic>{};
|
final receptionJson = <String, dynamic>{};
|
||||||
final transferJson = <String, dynamic>{};
|
final transferJson = <String, dynamic>{};
|
||||||
|
final routeMetadataJson = <String, dynamic>{};
|
||||||
for (final entry in messageContactLocations.entries) {
|
for (final entry in messageContactLocations.entries) {
|
||||||
if (retainedMessageIds.contains(entry.key)) {
|
if (retainedMessageIds.contains(entry.key)) {
|
||||||
locationJson[entry.key] = entry.value.toJson();
|
locationJson[entry.key] = entry.value.toJson();
|
||||||
@@ -59,6 +64,11 @@ class MessageStorageService {
|
|||||||
transferJson[entry.key] = entry.value.toJson();
|
transferJson[entry.key] = entry.value.toJson();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for (final entry in messageRouteMetadata.entries) {
|
||||||
|
if (retainedMessageIds.contains(entry.key)) {
|
||||||
|
routeMetadataJson[entry.key] = entry.value.toJson();
|
||||||
|
}
|
||||||
|
}
|
||||||
await prefs.setString(
|
await prefs.setString(
|
||||||
_messageContactLocationsKey,
|
_messageContactLocationsKey,
|
||||||
jsonEncode(locationJson),
|
jsonEncode(locationJson),
|
||||||
@@ -71,6 +81,10 @@ class MessageStorageService {
|
|||||||
_messageTransferDetailsKey,
|
_messageTransferDetailsKey,
|
||||||
jsonEncode(transferJson),
|
jsonEncode(transferJson),
|
||||||
);
|
);
|
||||||
|
await prefs.setString(
|
||||||
|
_messageRouteMetadataKey,
|
||||||
|
jsonEncode(routeMetadataJson),
|
||||||
|
);
|
||||||
|
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'✅ [MessageStorage] Saved ${limitedList.length} messages to storage',
|
'✅ [MessageStorage] Saved ${limitedList.length} messages to storage',
|
||||||
@@ -170,6 +184,32 @@ class MessageStorageService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<Map<String, MessageRouteMetadata>> loadMessageRouteMetadata() async {
|
||||||
|
try {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final jsonString = prefs.getString(_messageRouteMetadataKey);
|
||||||
|
if (jsonString == null || jsonString.isEmpty) {
|
||||||
|
return const {};
|
||||||
|
}
|
||||||
|
|
||||||
|
final decoded = jsonDecode(jsonString);
|
||||||
|
if (decoded is! Map<String, dynamic>) {
|
||||||
|
return const {};
|
||||||
|
}
|
||||||
|
|
||||||
|
final result = <String, MessageRouteMetadata>{};
|
||||||
|
for (final entry in decoded.entries) {
|
||||||
|
final value = entry.value;
|
||||||
|
if (value is! Map<String, dynamic>) continue;
|
||||||
|
result[entry.key] = MessageRouteMetadata.fromJson(value);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('❌ [MessageStorage] Error loading route metadata: $e');
|
||||||
|
return const {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Load messages from persistent storage
|
/// Load messages from persistent storage
|
||||||
Future<List<Message>> loadMessages() async {
|
Future<List<Message>> loadMessages() async {
|
||||||
try {
|
try {
|
||||||
@@ -206,6 +246,7 @@ class MessageStorageService {
|
|||||||
await prefs.remove(_messageContactLocationsKey);
|
await prefs.remove(_messageContactLocationsKey);
|
||||||
await prefs.remove(_messageReceptionDetailsKey);
|
await prefs.remove(_messageReceptionDetailsKey);
|
||||||
await prefs.remove(_messageTransferDetailsKey);
|
await prefs.remove(_messageTransferDetailsKey);
|
||||||
|
await prefs.remove(_messageRouteMetadataKey);
|
||||||
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');
|
||||||
|
|||||||
32
lib/services/messaging_route_preferences.dart
Normal file
32
lib/services/messaging_route_preferences.dart
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
class MessagingRoutePreferences {
|
||||||
|
static const bool defaultAutoRouteRotationEnabled = false;
|
||||||
|
static const bool defaultClearPathOnMaxRetry = false;
|
||||||
|
|
||||||
|
static const String _autoRouteRotationKey =
|
||||||
|
'messaging_auto_route_rotation_enabled';
|
||||||
|
static const String _clearPathOnMaxRetryKey =
|
||||||
|
'messaging_clear_path_on_max_retry';
|
||||||
|
|
||||||
|
static Future<bool> getAutoRouteRotationEnabled() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
return prefs.getBool(_autoRouteRotationKey) ??
|
||||||
|
defaultAutoRouteRotationEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> setAutoRouteRotationEnabled(bool enabled) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setBool(_autoRouteRotationKey, enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<bool> getClearPathOnMaxRetry() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
return prefs.getBool(_clearPathOnMaxRetryKey) ?? defaultClearPathOnMaxRetry;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> setClearPathOnMaxRetry(bool enabled) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setBool(_clearPathOnMaxRetryKey, enabled);
|
||||||
|
}
|
||||||
|
}
|
||||||
69
lib/services/nearest_router_selector.dart
Normal file
69
lib/services/nearest_router_selector.dart
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import 'package:geolocator/geolocator.dart';
|
||||||
|
|
||||||
|
import '../models/contact.dart';
|
||||||
|
|
||||||
|
class NearestRouterSelector {
|
||||||
|
const NearestRouterSelector();
|
||||||
|
|
||||||
|
Contact? select({
|
||||||
|
required Position? senderPosition,
|
||||||
|
required List<Contact> repeaters,
|
||||||
|
required Contact recipient,
|
||||||
|
}) {
|
||||||
|
if (senderPosition == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final eligible = repeaters.where((contact) {
|
||||||
|
if (contact.publicKeyHex == recipient.publicKeyHex) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!contact.isRecentlySeen) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return contact.displayLocation != null;
|
||||||
|
}).toList();
|
||||||
|
if (eligible.isEmpty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
eligible.sort((a, b) {
|
||||||
|
final locationA = a.displayLocation!;
|
||||||
|
final locationB = b.displayLocation!;
|
||||||
|
final distanceA = Geolocator.distanceBetween(
|
||||||
|
senderPosition.latitude,
|
||||||
|
senderPosition.longitude,
|
||||||
|
locationA.latitude,
|
||||||
|
locationA.longitude,
|
||||||
|
);
|
||||||
|
final distanceB = Geolocator.distanceBetween(
|
||||||
|
senderPosition.latitude,
|
||||||
|
senderPosition.longitude,
|
||||||
|
locationB.latitude,
|
||||||
|
locationB.longitude,
|
||||||
|
);
|
||||||
|
final distanceCompare = distanceA.compareTo(distanceB);
|
||||||
|
if (distanceCompare != 0) {
|
||||||
|
return distanceCompare;
|
||||||
|
}
|
||||||
|
|
||||||
|
final advertCompare = b.lastAdvert.compareTo(a.lastAdvert);
|
||||||
|
if (advertCompare != 0) {
|
||||||
|
return advertCompare;
|
||||||
|
}
|
||||||
|
|
||||||
|
final hopCompare = a.routeHopCount.compareTo(b.routeHopCount);
|
||||||
|
if (hopCompare != 0) {
|
||||||
|
return hopCompare;
|
||||||
|
}
|
||||||
|
|
||||||
|
final nameCompare = a.advName.compareTo(b.advName);
|
||||||
|
if (nameCompare != 0) {
|
||||||
|
return nameCompare;
|
||||||
|
}
|
||||||
|
return a.publicKeyHex.compareTo(b.publicKeyHex);
|
||||||
|
});
|
||||||
|
|
||||||
|
return eligible.first;
|
||||||
|
}
|
||||||
|
}
|
||||||
233
lib/services/path_history_service.dart
Normal file
233
lib/services/path_history_service.dart
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
import '../models/contact.dart';
|
||||||
|
import '../models/path_history.dart';
|
||||||
|
import '../models/path_selection.dart';
|
||||||
|
|
||||||
|
class PathHistoryService {
|
||||||
|
static const String _storageKey = 'contact_path_history_v1';
|
||||||
|
static const int _maxDirectPaths = 20;
|
||||||
|
static const int _topRotationCount = 3;
|
||||||
|
|
||||||
|
final Map<String, ContactPathHistory> _cache = {};
|
||||||
|
bool _isLoaded = false;
|
||||||
|
|
||||||
|
Future<void> initialize() async {
|
||||||
|
if (_isLoaded) return;
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final raw = prefs.getString(_storageKey);
|
||||||
|
if (raw == null || raw.isEmpty) {
|
||||||
|
_isLoaded = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final decoded = jsonDecode(raw);
|
||||||
|
if (decoded is Map<String, dynamic>) {
|
||||||
|
for (final entry in decoded.entries) {
|
||||||
|
final value = entry.value;
|
||||||
|
if (value is Map<String, dynamic>) {
|
||||||
|
_cache[entry.key] = ContactPathHistory.fromJson(entry.key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
debugPrint('⚠️ [PathHistoryService] Failed to load history: $error');
|
||||||
|
}
|
||||||
|
_isLoaded = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> recordLearnedPath(Contact contact) async {
|
||||||
|
await initialize();
|
||||||
|
if (!contact.routeHasPath || contact.routeHopCount <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final history = _historyFor(contact.publicKeyHex);
|
||||||
|
final signature = _signature(contact.routePathBytes);
|
||||||
|
final existing = _findDirectPath(history.directPaths, signature);
|
||||||
|
final updated = PathRecord(
|
||||||
|
pathBytes: contact.routePathBytes.toList(),
|
||||||
|
hopCount: contact.routeHopCount,
|
||||||
|
hashSize: contact.routeHashSize,
|
||||||
|
successCount: existing?.successCount ?? 0,
|
||||||
|
failureCount: existing?.failureCount ?? 0,
|
||||||
|
lastRoundTripTimeMs: existing?.lastRoundTripTimeMs ?? 0,
|
||||||
|
lastUsedAt: DateTime.now(),
|
||||||
|
);
|
||||||
|
|
||||||
|
await _saveHistory(
|
||||||
|
contact.publicKeyHex,
|
||||||
|
history.copyWith(
|
||||||
|
directPaths: _upsertDirectPath(history.directPaths, updated),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<PathSelection> getSelectionForContact(
|
||||||
|
Contact contact, {
|
||||||
|
required bool autoRouteRotationEnabled,
|
||||||
|
}) async {
|
||||||
|
await initialize();
|
||||||
|
await recordLearnedPath(contact);
|
||||||
|
|
||||||
|
if (!autoRouteRotationEnabled) {
|
||||||
|
if (contact.routeHasPath && contact.routeHopCount > 0) {
|
||||||
|
return PathSelection(
|
||||||
|
mode: PathSelectionMode.directCurrent,
|
||||||
|
pathBytes: Uint8List.fromList(contact.routePathBytes),
|
||||||
|
hopCount: contact.routeHopCount,
|
||||||
|
hashSize: contact.routeHashSize,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return PathSelection.flood();
|
||||||
|
}
|
||||||
|
|
||||||
|
final history = _historyFor(contact.publicKeyHex);
|
||||||
|
final ranked = List<PathRecord>.from(history.directPaths)
|
||||||
|
..sort(_comparePathRecords);
|
||||||
|
final topPaths = ranked.take(_topRotationCount).toList();
|
||||||
|
if (topPaths.isEmpty) {
|
||||||
|
final nextFloodHistory = history.copyWith(
|
||||||
|
rotationIndex: history.rotationIndex + 1,
|
||||||
|
);
|
||||||
|
await _saveHistory(contact.publicKeyHex, nextFloodHistory);
|
||||||
|
return PathSelection.flood();
|
||||||
|
}
|
||||||
|
|
||||||
|
final selections =
|
||||||
|
topPaths
|
||||||
|
.map(
|
||||||
|
(record) => PathSelection(
|
||||||
|
mode: PathSelectionMode.directHistorical,
|
||||||
|
pathBytes: Uint8List.fromList(record.pathBytes),
|
||||||
|
hopCount: record.hopCount,
|
||||||
|
hashSize: record.hashSize,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList()
|
||||||
|
..add(PathSelection.flood());
|
||||||
|
|
||||||
|
final index = history.rotationIndex % selections.length;
|
||||||
|
final updatedHistory = history.copyWith(
|
||||||
|
rotationIndex: history.rotationIndex + 1,
|
||||||
|
);
|
||||||
|
await _saveHistory(contact.publicKeyHex, updatedHistory);
|
||||||
|
return selections[index];
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> recordPathResult(
|
||||||
|
String contactPublicKeyHex,
|
||||||
|
PathSelection selection, {
|
||||||
|
required bool success,
|
||||||
|
int? roundTripTimeMs,
|
||||||
|
}) async {
|
||||||
|
await initialize();
|
||||||
|
final history = _historyFor(contactPublicKeyHex);
|
||||||
|
if (selection.usesFlood) {
|
||||||
|
final current = history.floodStats;
|
||||||
|
await _saveHistory(
|
||||||
|
contactPublicKeyHex,
|
||||||
|
history.copyWith(
|
||||||
|
floodStats: current.copyWith(
|
||||||
|
successCount: current.successCount + (success ? 1 : 0),
|
||||||
|
failureCount: current.failureCount + (success ? 0 : 1),
|
||||||
|
lastRoundTripTimeMs: success
|
||||||
|
? (roundTripTimeMs ?? current.lastRoundTripTimeMs)
|
||||||
|
: current.lastRoundTripTimeMs,
|
||||||
|
lastUsedAt: DateTime.now(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final signature = _signature(selection.pathBytes);
|
||||||
|
final existing = _findDirectPath(history.directPaths, signature);
|
||||||
|
final updated = PathRecord(
|
||||||
|
pathBytes: selection.pathBytes.toList(),
|
||||||
|
hopCount: selection.hopCount,
|
||||||
|
hashSize: selection.hashSize,
|
||||||
|
successCount: (existing?.successCount ?? 0) + (success ? 1 : 0),
|
||||||
|
failureCount: (existing?.failureCount ?? 0) + (success ? 0 : 1),
|
||||||
|
lastRoundTripTimeMs: success
|
||||||
|
? (roundTripTimeMs ?? existing?.lastRoundTripTimeMs ?? 0)
|
||||||
|
: (existing?.lastRoundTripTimeMs ?? 0),
|
||||||
|
lastUsedAt: DateTime.now(),
|
||||||
|
);
|
||||||
|
await _saveHistory(
|
||||||
|
contactPublicKeyHex,
|
||||||
|
history.copyWith(
|
||||||
|
directPaths: _upsertDirectPath(history.directPaths, updated),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ContactPathHistory historyFor(String contactPublicKeyHex) {
|
||||||
|
return _cache[contactPublicKeyHex] ??
|
||||||
|
ContactPathHistory.empty(contactPublicKeyHex);
|
||||||
|
}
|
||||||
|
|
||||||
|
ContactPathHistory _historyFor(String contactPublicKeyHex) {
|
||||||
|
return _cache.putIfAbsent(
|
||||||
|
contactPublicKeyHex,
|
||||||
|
() => ContactPathHistory.empty(contactPublicKeyHex),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _saveHistory(
|
||||||
|
String contactPublicKeyHex,
|
||||||
|
ContactPathHistory history,
|
||||||
|
) async {
|
||||||
|
_cache[contactPublicKeyHex] = history;
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final payload = <String, dynamic>{};
|
||||||
|
for (final entry in _cache.entries) {
|
||||||
|
payload[entry.key] = entry.value.toJson();
|
||||||
|
}
|
||||||
|
await prefs.setString(_storageKey, jsonEncode(payload));
|
||||||
|
}
|
||||||
|
|
||||||
|
List<PathRecord> _upsertDirectPath(
|
||||||
|
List<PathRecord> existing,
|
||||||
|
PathRecord updatedRecord,
|
||||||
|
) {
|
||||||
|
final updated = List<PathRecord>.from(existing)
|
||||||
|
..removeWhere((record) => record.signature == updatedRecord.signature)
|
||||||
|
..insert(0, updatedRecord);
|
||||||
|
if (updated.length > _maxDirectPaths) {
|
||||||
|
return updated.take(_maxDirectPaths).toList();
|
||||||
|
}
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
int _comparePathRecords(PathRecord a, PathRecord b) {
|
||||||
|
final successRateCompare = b.successRate.compareTo(a.successRate);
|
||||||
|
if (successRateCompare != 0) return successRateCompare;
|
||||||
|
|
||||||
|
final successCountCompare = b.successCount.compareTo(a.successCount);
|
||||||
|
if (successCountCompare != 0) return successCountCompare;
|
||||||
|
|
||||||
|
final aRtt = a.lastRoundTripTimeMs == 0 ? 1 << 30 : a.lastRoundTripTimeMs;
|
||||||
|
final bRtt = b.lastRoundTripTimeMs == 0 ? 1 << 30 : b.lastRoundTripTimeMs;
|
||||||
|
final rttCompare = aRtt.compareTo(bRtt);
|
||||||
|
if (rttCompare != 0) return rttCompare;
|
||||||
|
|
||||||
|
return b.lastUsedAt.compareTo(a.lastUsedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
PathRecord? _findDirectPath(List<PathRecord> records, String signature) {
|
||||||
|
for (final record in records) {
|
||||||
|
if (record.signature == signature) {
|
||||||
|
return record;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String _signature(Uint8List bytes) =>
|
||||||
|
bytes.map((byte) => byte.toRadixString(16).padLeft(2, '0')).join();
|
||||||
|
}
|
||||||
@@ -1,12 +1,17 @@
|
|||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
import '../models/message.dart';
|
import '../models/message.dart';
|
||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
|
import '../providers/messages_provider.dart';
|
||||||
|
|
||||||
/// Extension for Message to provide localized delivery status
|
/// Extension for Message to provide localized delivery status
|
||||||
extension MessageLocalization on Message {
|
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
|
||||||
|
.read<MessagesProvider>()
|
||||||
|
.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) {
|
||||||
@@ -26,29 +31,51 @@ extension MessageLocalization on Message {
|
|||||||
case MessageDeliveryStatus.sending:
|
case MessageDeliveryStatus.sending:
|
||||||
if (isContactMessage) {
|
if (isContactMessage) {
|
||||||
if (retryAttempt > 0) {
|
if (retryAttempt > 0) {
|
||||||
return '${l10n.pending} • ${l10n.retryAttempt} $retryAttempt/3';
|
final routeSuffix = routeMetadata != null
|
||||||
|
? ' • ${routeMetadata.modeLabel}'
|
||||||
|
: '';
|
||||||
|
return '${l10n.pending} • ${l10n.retryAttempt} $retryAttempt/4$routeSuffix';
|
||||||
}
|
}
|
||||||
return l10n.pending;
|
return routeMetadata == null
|
||||||
|
? l10n.pending
|
||||||
|
: '${l10n.pending} • ${routeMetadata.modeLabel}';
|
||||||
}
|
}
|
||||||
return l10n.sending;
|
return l10n.sending;
|
||||||
case MessageDeliveryStatus.sent:
|
case MessageDeliveryStatus.sent:
|
||||||
return l10n.sent;
|
return routeMetadata == null
|
||||||
|
? l10n.sent
|
||||||
|
: '${l10n.sent} • ${routeMetadata.modeLabel}';
|
||||||
case MessageDeliveryStatus.delivered:
|
case MessageDeliveryStatus.delivered:
|
||||||
if (retryAttempt > 0 && roundTripTimeMs != null) {
|
if (retryAttempt > 0 && roundTripTimeMs != null) {
|
||||||
return '${l10n.deliveredWithTime(roundTripTimeMs!)} • ${l10n.retryAttempt} $retryAttempt/3';
|
final routeSuffix = routeMetadata != null
|
||||||
|
? ' • ${routeMetadata.modeLabel}'
|
||||||
|
: '';
|
||||||
|
return '${l10n.deliveredWithTime(roundTripTimeMs!)} • ${l10n.retryAttempt} $retryAttempt/4$routeSuffix';
|
||||||
}
|
}
|
||||||
if (retryAttempt > 0) {
|
if (retryAttempt > 0) {
|
||||||
return '${l10n.delivered} • ${l10n.retryAttempt} $retryAttempt/3';
|
final routeSuffix = routeMetadata != null
|
||||||
|
? ' • ${routeMetadata.modeLabel}'
|
||||||
|
: '';
|
||||||
|
return '${l10n.delivered} • ${l10n.retryAttempt} $retryAttempt/4$routeSuffix';
|
||||||
}
|
}
|
||||||
if (roundTripTimeMs != null) {
|
if (roundTripTimeMs != null) {
|
||||||
return l10n.deliveredWithTime(roundTripTimeMs!);
|
return routeMetadata == null
|
||||||
|
? l10n.deliveredWithTime(roundTripTimeMs!)
|
||||||
|
: '${l10n.deliveredWithTime(roundTripTimeMs!)} • ${routeMetadata.modeLabel}';
|
||||||
}
|
}
|
||||||
return l10n.delivered;
|
return routeMetadata == null
|
||||||
|
? l10n.delivered
|
||||||
|
: '${l10n.delivered} • ${routeMetadata.modeLabel}';
|
||||||
case MessageDeliveryStatus.failed:
|
case MessageDeliveryStatus.failed:
|
||||||
if (retryAttempt > 0) {
|
if (retryAttempt > 0) {
|
||||||
return '${l10n.failed} • ${l10n.retryAttempt} $retryAttempt/3';
|
final routeSuffix = routeMetadata != null
|
||||||
|
? ' • ${routeMetadata.modeLabel}'
|
||||||
|
: '';
|
||||||
|
return '${l10n.failed} • ${l10n.retryAttempt} $retryAttempt/4$routeSuffix';
|
||||||
}
|
}
|
||||||
return l10n.failed;
|
return routeMetadata == null
|
||||||
|
? l10n.failed
|
||||||
|
: '${l10n.failed} • ${routeMetadata.modeLabel}';
|
||||||
case MessageDeliveryStatus.received:
|
case MessageDeliveryStatus.received:
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
import '../../models/contact.dart';
|
import '../../models/contact.dart';
|
||||||
|
import '../../providers/app_provider.dart';
|
||||||
import '../../services/route_hash_preferences.dart';
|
import '../../services/route_hash_preferences.dart';
|
||||||
|
|
||||||
class ContactRouteDialogResult {
|
class ContactRouteDialogResult {
|
||||||
@@ -133,6 +135,7 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final appProvider = context.watch<AppProvider>();
|
||||||
final routeCandidates =
|
final routeCandidates =
|
||||||
widget.availableContacts
|
widget.availableContacts
|
||||||
.where((contact) => contact.isRepeater || contact.isRoom)
|
.where((contact) => contact.isRepeater || contact.isRoom)
|
||||||
@@ -184,6 +187,11 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
_AutomationRoutingInfo(
|
||||||
|
autoRouteRotationEnabled: appProvider.autoRouteRotationEnabled,
|
||||||
|
clearPathOnMaxRetry: appProvider.clearPathOnMaxRetry,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
Text(
|
Text(
|
||||||
'Pick hops from contacts',
|
'Pick hops from contacts',
|
||||||
style: Theme.of(context).textTheme.labelLarge,
|
style: Theme.of(context).textTheme.labelLarge,
|
||||||
@@ -203,10 +211,6 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
|
|||||||
dense: true,
|
dense: true,
|
||||||
contentPadding: EdgeInsets.zero,
|
contentPadding: EdgeInsets.zero,
|
||||||
title: Text(candidate.displayName),
|
title: Text(candidate.displayName),
|
||||||
subtitle: Text(
|
|
||||||
'1B ${_tokenFor(candidate, 1)} • 2B ${_tokenFor(candidate, 2)} • 3B ${_tokenFor(candidate, 3)}',
|
|
||||||
style: const TextStyle(fontFamily: 'monospace'),
|
|
||||||
),
|
|
||||||
trailing: TextButton(
|
trailing: TextButton(
|
||||||
onPressed: () => _appendHop(candidate),
|
onPressed: () => _appendHop(candidate),
|
||||||
child: Text(
|
child: Text(
|
||||||
@@ -248,3 +252,97 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _AutomationRoutingInfo extends StatelessWidget {
|
||||||
|
final bool autoRouteRotationEnabled;
|
||||||
|
final bool clearPathOnMaxRetry;
|
||||||
|
|
||||||
|
const _AutomationRoutingInfo({
|
||||||
|
required this.autoRouteRotationEnabled,
|
||||||
|
required this.clearPathOnMaxRetry,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
return Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.45),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(color: colorScheme.outlineVariant),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.info_outline, size: 18, color: colorScheme.primary),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
'Automatic direct-send routing',
|
||||||
|
style: Theme.of(context).textTheme.titleSmall,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Room/contact sends keep one selected path for the whole send chain, retry up to 5 total attempts with 1s, 2s, 4s, and 8s backoff, then try one final nearest repeater if everything else fails.',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Public and channel broadcasts are not affected by this automation.',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: [
|
||||||
|
_InfoChip(
|
||||||
|
label: autoRouteRotationEnabled
|
||||||
|
? 'Auto route rotation on'
|
||||||
|
: 'Auto route rotation off',
|
||||||
|
icon: Icons.swap_horiz,
|
||||||
|
),
|
||||||
|
_InfoChip(
|
||||||
|
label: clearPathOnMaxRetry
|
||||||
|
? 'Clear path on max retry on'
|
||||||
|
: 'Clear path on max retry off',
|
||||||
|
icon: Icons.route,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _InfoChip extends StatelessWidget {
|
||||||
|
final String label;
|
||||||
|
final IconData icon;
|
||||||
|
|
||||||
|
const _InfoChip({required this.label, required this.icon});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(999),
|
||||||
|
color: Theme.of(context).colorScheme.surface,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(icon, size: 14),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Text(label, style: Theme.of(context).textTheme.labelMedium),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -96,10 +96,6 @@ class ContactTile extends StatelessWidget {
|
|||||||
void handleTap() {
|
void handleTap() {
|
||||||
if (contact.type == ContactType.chat) {
|
if (contact.type == ContactType.chat) {
|
||||||
_showSetRouteDialog(context, contact);
|
_showSetRouteDialog(context, contact);
|
||||||
} else if (contact.type == ContactType.repeater) {
|
|
||||||
_jumpToMapForRepeater(context, contact);
|
|
||||||
} else if (contact.type == ContactType.room && !contact.isPublicChannel) {
|
|
||||||
_showRoomLoginDialog(context, contact);
|
|
||||||
} else {
|
} else {
|
||||||
_showContactDetails(context, contact);
|
_showContactDetails(context, contact);
|
||||||
}
|
}
|
||||||
@@ -307,23 +303,6 @@ class ContactTile extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _jumpToMapForRepeater(BuildContext context, Contact contact) {
|
|
||||||
final location = contact.displayLocation;
|
|
||||||
if (location != null) {
|
|
||||||
final mapProvider = context.read<MapProvider>();
|
|
||||||
|
|
||||||
// Navigate to map location
|
|
||||||
mapProvider.navigateToLocation(
|
|
||||||
location: LatLng(location.latitude, location.longitude),
|
|
||||||
zoom: 15.0,
|
|
||||||
animate: true,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Switch to map tab using callback
|
|
||||||
onNavigateToMap?.call();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _showDeleteConfirmation(BuildContext context, Contact contact) {
|
void _showDeleteConfirmation(BuildContext context, Contact contact) {
|
||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
|
|||||||
@@ -469,6 +469,9 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
final retryCause = _retryCauseLabel(widget.message);
|
final retryCause = _retryCauseLabel(widget.message);
|
||||||
final retryResult = _retryResultLabel(widget.message);
|
final retryResult = _retryResultLabel(widget.message);
|
||||||
final retryMode = _retryModeLabel(widget.message);
|
final retryMode = _retryModeLabel(widget.message);
|
||||||
|
final routeMetadata = context
|
||||||
|
.read<MessagesProvider>()
|
||||||
|
.getMessageRouteMetadata(widget.message.id);
|
||||||
|
|
||||||
final rawLines = <String>[
|
final rawLines = <String>[
|
||||||
'Message ID: ${widget.message.id}',
|
'Message ID: ${widget.message.id}',
|
||||||
@@ -790,7 +793,7 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
_detailRow(
|
_detailRow(
|
||||||
context,
|
context,
|
||||||
label: l10n.retryAttempt,
|
label: l10n.retryAttempt,
|
||||||
value: '${widget.message.retryAttempt}/3',
|
value: '${widget.message.retryAttempt}/4',
|
||||||
),
|
),
|
||||||
if (widget.message.lastRetryAt != null)
|
if (widget.message.lastRetryAt != null)
|
||||||
_detailRow(
|
_detailRow(
|
||||||
@@ -809,6 +812,20 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
label: l10n.floodFallback,
|
label: l10n.floodFallback,
|
||||||
value: l10n.yes,
|
value: l10n.yes,
|
||||||
),
|
),
|
||||||
|
if (routeMetadata?.relayName case final relayName?)
|
||||||
|
_detailRow(
|
||||||
|
context,
|
||||||
|
label: 'Relay',
|
||||||
|
value: relayName,
|
||||||
|
),
|
||||||
|
if (routeMetadata?.canonicalPath
|
||||||
|
case final routePath?)
|
||||||
|
_detailRow(
|
||||||
|
context,
|
||||||
|
label: 'Selected path',
|
||||||
|
value: routePath,
|
||||||
|
onCopy: () => copyField(routePath),
|
||||||
|
),
|
||||||
if (retryResult != null)
|
if (retryResult != null)
|
||||||
_detailRow(
|
_detailRow(
|
||||||
context,
|
context,
|
||||||
@@ -885,7 +902,9 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
_detailRow(
|
_detailRow(
|
||||||
context,
|
context,
|
||||||
label: l10n.envelope,
|
label: l10n.envelope,
|
||||||
value: envelope != null ? 'VE3 compact' : l10n.unknown,
|
value: envelope != null
|
||||||
|
? 'VE3 compact'
|
||||||
|
: l10n.unknown,
|
||||||
),
|
),
|
||||||
if (voiceSession != null)
|
if (voiceSession != null)
|
||||||
_detailRow(
|
_detailRow(
|
||||||
@@ -1545,8 +1564,15 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final routeMetadata = context
|
||||||
|
.read<MessagesProvider>()
|
||||||
|
.getMessageRouteMetadata(message.id);
|
||||||
|
if (routeMetadata != null) {
|
||||||
|
return routeMetadata.modeLabel;
|
||||||
|
}
|
||||||
|
|
||||||
if (message.usedFloodFallback) {
|
if (message.usedFloodFallback) {
|
||||||
return 'Flood fallback';
|
return 'Flood route';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (message.retryAttempt > 0 || message.expectedAckTag != null) {
|
if (message.retryAttempt > 0 || message.expectedAckTag != null) {
|
||||||
@@ -1561,9 +1587,17 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final routeMetadata = context
|
||||||
|
.read<MessagesProvider>()
|
||||||
|
.getMessageRouteMetadata(message.id);
|
||||||
|
final routeLabel = routeMetadata?.modeLabel.toLowerCase();
|
||||||
|
|
||||||
if (message.deliveryStatus == MessageDeliveryStatus.delivered) {
|
if (message.deliveryStatus == MessageDeliveryStatus.delivered) {
|
||||||
|
if (routeLabel != null) {
|
||||||
|
return 'Delivered via $routeLabel';
|
||||||
|
}
|
||||||
if (message.usedFloodFallback) {
|
if (message.usedFloodFallback) {
|
||||||
return 'Delivered after flood fallback';
|
return 'Delivered after flood route';
|
||||||
}
|
}
|
||||||
if (message.retryAttempt > 0) {
|
if (message.retryAttempt > 0) {
|
||||||
return 'Delivered after retry';
|
return 'Delivered after retry';
|
||||||
@@ -1574,8 +1608,11 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (message.deliveryStatus == MessageDeliveryStatus.sending) {
|
if (message.deliveryStatus == MessageDeliveryStatus.sending) {
|
||||||
|
if (routeLabel != null) {
|
||||||
|
return '$routeLabel in progress';
|
||||||
|
}
|
||||||
if (message.usedFloodFallback) {
|
if (message.usedFloodFallback) {
|
||||||
return 'Flood fallback in progress';
|
return 'Flood route in progress';
|
||||||
}
|
}
|
||||||
if (message.retryAttempt > 0) {
|
if (message.retryAttempt > 0) {
|
||||||
return 'Retry in progress';
|
return 'Retry in progress';
|
||||||
@@ -1586,8 +1623,11 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (message.deliveryStatus == MessageDeliveryStatus.failed) {
|
if (message.deliveryStatus == MessageDeliveryStatus.failed) {
|
||||||
|
if (routeLabel != null) {
|
||||||
|
return 'Failed via $routeLabel';
|
||||||
|
}
|
||||||
if (message.usedFloodFallback) {
|
if (message.usedFloodFallback) {
|
||||||
return 'Failed after flood fallback';
|
return 'Failed after flood route';
|
||||||
}
|
}
|
||||||
if (message.retryAttempt > 0) {
|
if (message.retryAttempt > 0) {
|
||||||
return 'Failed after retry attempts';
|
return 'Failed after retry attempts';
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
import '../../models/message.dart';
|
import '../../models/message.dart';
|
||||||
|
import '../../models/path_selection.dart';
|
||||||
import '../../models/message_reception_details.dart';
|
import '../../models/message_reception_details.dart';
|
||||||
|
import '../../providers/messages_provider.dart';
|
||||||
|
|
||||||
IconData getDeliveryStatusIcon(MessageDeliveryStatus status) {
|
IconData getDeliveryStatusIcon(MessageDeliveryStatus status) {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
@@ -185,6 +188,9 @@ Widget buildSentDirectSignalStatus(
|
|||||||
required int roundTripTimeMs,
|
required int roundTripTimeMs,
|
||||||
required Duration txEstimate,
|
required Duration txEstimate,
|
||||||
}) {
|
}) {
|
||||||
|
final routeMetadata = context
|
||||||
|
.read<MessagesProvider>()
|
||||||
|
.getMessageRouteMetadata(message.id);
|
||||||
final estimatedTransmitMs = sanitizeEstimatedTransmitMs(
|
final estimatedTransmitMs = sanitizeEstimatedTransmitMs(
|
||||||
estimatedTransmitMs: txEstimate > Duration.zero
|
estimatedTransmitMs: txEstimate > Duration.zero
|
||||||
? txEstimate.inMilliseconds
|
? txEstimate.inMilliseconds
|
||||||
@@ -230,7 +236,7 @@ Widget buildSentDirectSignalStatus(
|
|||||||
_techChip(
|
_techChip(
|
||||||
context,
|
context,
|
||||||
icon: Icons.refresh,
|
icon: Icons.refresh,
|
||||||
label: 'retry ${message.retryAttempt}/3',
|
label: 'retry ${message.retryAttempt}/4',
|
||||||
color: Colors.redAccent,
|
color: Colors.redAccent,
|
||||||
),
|
),
|
||||||
if (message.suggestedTimeoutMs != null)
|
if (message.suggestedTimeoutMs != null)
|
||||||
@@ -244,7 +250,7 @@ Widget buildSentDirectSignalStatus(
|
|||||||
_techChip(
|
_techChip(
|
||||||
context,
|
context,
|
||||||
icon: Icons.waves,
|
icon: Icons.waves,
|
||||||
label: 'flood fallback',
|
label: 'flood route',
|
||||||
color: Colors.teal,
|
color: Colors.teal,
|
||||||
)
|
)
|
||||||
else if (message.expectedAckTag != null)
|
else if (message.expectedAckTag != null)
|
||||||
@@ -254,6 +260,17 @@ Widget buildSentDirectSignalStatus(
|
|||||||
label: 'direct ACK',
|
label: 'direct ACK',
|
||||||
color: Colors.indigo,
|
color: Colors.indigo,
|
||||||
),
|
),
|
||||||
|
if (routeMetadata != null)
|
||||||
|
_techChip(
|
||||||
|
context,
|
||||||
|
icon: routeMetadata.mode == PathSelectionMode.nearestRouter
|
||||||
|
? Icons.router
|
||||||
|
: Icons.alt_route,
|
||||||
|
label: routeMetadata.modeLabel,
|
||||||
|
color: routeMetadata.mode == PathSelectionMode.nearestRouter
|
||||||
|
? Colors.deepPurple
|
||||||
|
: Colors.indigo,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,10 @@ import 'package:fake_async/fake_async.dart';
|
|||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:meshcore_sar_app/models/contact.dart';
|
import 'package:meshcore_sar_app/models/contact.dart';
|
||||||
import 'package:meshcore_sar_app/models/message.dart';
|
import 'package:meshcore_sar_app/models/message.dart';
|
||||||
|
import 'package:meshcore_sar_app/models/path_selection.dart';
|
||||||
|
import 'package:meshcore_sar_app/providers/helpers/message_retry_manager.dart';
|
||||||
import 'package:meshcore_sar_app/providers/messages_provider.dart';
|
import 'package:meshcore_sar_app/providers/messages_provider.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
Contact _buildContact() {
|
Contact _buildContact() {
|
||||||
return Contact(
|
return Contact(
|
||||||
@@ -39,6 +42,10 @@ Message _buildDirectMessage(String id) {
|
|||||||
void main() {
|
void main() {
|
||||||
TestWidgetsFlutterBinding.ensureInitialized();
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
});
|
||||||
|
|
||||||
group('MessagesProvider retransmission', () {
|
group('MessagesProvider retransmission', () {
|
||||||
test('direct messages become sent before delivery ACK arrives', () {
|
test('direct messages become sent before delivery ACK arrives', () {
|
||||||
final provider = MessagesProvider();
|
final provider = MessagesProvider();
|
||||||
@@ -171,7 +178,11 @@ void main() {
|
|||||||
);
|
);
|
||||||
expect(retryCalls, 0);
|
expect(retryCalls, 0);
|
||||||
|
|
||||||
async.elapse(const Duration(seconds: 4));
|
async.elapse(const Duration(milliseconds: 998));
|
||||||
|
async.flushMicrotasks();
|
||||||
|
expect(retryCalls, 0);
|
||||||
|
|
||||||
|
async.elapse(const Duration(milliseconds: 1));
|
||||||
async.flushMicrotasks();
|
async.flushMicrotasks();
|
||||||
|
|
||||||
expect(retryCalls, 1);
|
expect(retryCalls, 1);
|
||||||
@@ -237,68 +248,89 @@ void main() {
|
|||||||
expect(provider.messages.single.retryAttempt, 0);
|
expect(provider.messages.single.retryAttempt, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('repeated max-retry failures request path reset', () async {
|
test(
|
||||||
|
'final router fallback runs after all normal retries are exhausted',
|
||||||
|
() async {
|
||||||
|
final provider = MessagesProvider();
|
||||||
|
final fallbackCalls = <String>[];
|
||||||
|
provider.onFinalRouterFallbackCallback =
|
||||||
|
({required messageId, required contact, required message}) async {
|
||||||
|
fallbackCalls.add(messageId);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
provider.addSentMessage(
|
||||||
|
_buildDirectMessage(
|
||||||
|
'm5',
|
||||||
|
).copyWith(retryAttempt: MessageRetryManager.maxRetryAttempts),
|
||||||
|
contact: _buildContact(),
|
||||||
|
);
|
||||||
|
|
||||||
|
provider.markMessageFailed('m5');
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
|
||||||
|
expect(fallbackCalls, ['m5']);
|
||||||
|
expect(
|
||||||
|
provider.messages.single.deliveryStatus,
|
||||||
|
MessageDeliveryStatus.sending,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('final router fallback is not retried twice', () async {
|
||||||
final provider = MessagesProvider();
|
final provider = MessagesProvider();
|
||||||
final contact = _buildContact();
|
|
||||||
final resetRequests = <(String, int)>[];
|
|
||||||
provider.onDirectPathFailedCallback =
|
|
||||||
({required contact, required failureStreak}) async {
|
|
||||||
resetRequests.add((contact.advName, failureStreak));
|
|
||||||
};
|
|
||||||
|
|
||||||
provider.addSentMessage(
|
|
||||||
_buildDirectMessage(
|
|
||||||
'm5',
|
|
||||||
).copyWith(retryAttempt: 3, usedFloodFallback: true),
|
|
||||||
contact: contact,
|
|
||||||
);
|
|
||||||
provider.markMessageFailed('m5');
|
|
||||||
|
|
||||||
provider.addSentMessage(
|
provider.addSentMessage(
|
||||||
_buildDirectMessage(
|
_buildDirectMessage(
|
||||||
'm6',
|
'm6',
|
||||||
).copyWith(retryAttempt: 3, usedFloodFallback: true),
|
).copyWith(retryAttempt: MessageRetryManager.maxRetryAttempts),
|
||||||
contact: contact,
|
contact: _buildContact(),
|
||||||
);
|
);
|
||||||
|
provider.updateMessageRouteSelection(
|
||||||
|
'm6',
|
||||||
|
PathSelection.flood(),
|
||||||
|
routerFallbackAttempted: true,
|
||||||
|
);
|
||||||
|
|
||||||
provider.markMessageFailed('m6');
|
provider.markMessageFailed('m6');
|
||||||
|
|
||||||
await Future<void>.delayed(Duration.zero);
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
|
||||||
expect(resetRequests, [('Teammate', 2)]);
|
expect(
|
||||||
|
provider.messages.single.deliveryStatus,
|
||||||
|
MessageDeliveryStatus.failed,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('successful delivery clears path failure streak', () async {
|
test(
|
||||||
final provider = MessagesProvider();
|
'final permanent failure callback runs after router fallback failure',
|
||||||
final contact = _buildContact();
|
() async {
|
||||||
final resetRequests = <int>[];
|
final provider = MessagesProvider();
|
||||||
provider.onDirectPathFailedCallback =
|
final failedMessageIds = <String>[];
|
||||||
({required contact, required failureStreak}) async {
|
provider.onFinalDirectMessageFailureCallback =
|
||||||
resetRequests.add(failureStreak);
|
({required messageId, required contact, required message}) async {
|
||||||
};
|
failedMessageIds.add(messageId);
|
||||||
|
};
|
||||||
|
|
||||||
provider.addSentMessage(
|
provider.addSentMessage(
|
||||||
_buildDirectMessage(
|
_buildDirectMessage(
|
||||||
|
'm7',
|
||||||
|
).copyWith(retryAttempt: MessageRetryManager.maxRetryAttempts),
|
||||||
|
contact: _buildContact(),
|
||||||
|
);
|
||||||
|
provider.updateMessageRouteSelection(
|
||||||
'm7',
|
'm7',
|
||||||
).copyWith(retryAttempt: 3, usedFloodFallback: true),
|
PathSelection.flood(),
|
||||||
contact: contact,
|
routerFallbackAttempted: true,
|
||||||
);
|
);
|
||||||
provider.markMessageFailed('m7');
|
|
||||||
|
|
||||||
provider.addSentMessage(_buildDirectMessage('m8'), contact: contact);
|
provider.markMessageFailed('m7');
|
||||||
provider.markMessageSent('m8', 123, 10);
|
await Future<void>.delayed(Duration.zero);
|
||||||
provider.markMessageDelivered(123, 150);
|
|
||||||
|
|
||||||
provider.addSentMessage(
|
expect(failedMessageIds, ['m7']);
|
||||||
_buildDirectMessage(
|
expect(
|
||||||
'm9',
|
provider.messages.single.deliveryStatus,
|
||||||
).copyWith(retryAttempt: 3, usedFloodFallback: true),
|
MessageDeliveryStatus.failed,
|
||||||
contact: contact,
|
);
|
||||||
);
|
},
|
||||||
provider.markMessageFailed('m9');
|
);
|
||||||
|
|
||||||
await Future<void>.delayed(Duration.zero);
|
|
||||||
|
|
||||||
expect(resetRequests, isEmpty);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
31
test/services/messaging_route_preferences_test.dart
Normal file
31
test/services/messaging_route_preferences_test.dart
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
import 'package:meshcore_sar_app/services/messaging_route_preferences.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('route preference defaults are disabled', () async {
|
||||||
|
expect(
|
||||||
|
await MessagingRoutePreferences.getAutoRouteRotationEnabled(),
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
expect(await MessagingRoutePreferences.getClearPathOnMaxRetry(), isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('route preferences persist changes', () async {
|
||||||
|
await MessagingRoutePreferences.setAutoRouteRotationEnabled(true);
|
||||||
|
await MessagingRoutePreferences.setClearPathOnMaxRetry(true);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await MessagingRoutePreferences.getAutoRouteRotationEnabled(),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
expect(await MessagingRoutePreferences.getClearPathOnMaxRetry(), isTrue);
|
||||||
|
});
|
||||||
|
}
|
||||||
115
test/services/nearest_router_selector_test.dart
Normal file
115
test/services/nearest_router_selector_test.dart
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:geolocator/geolocator.dart';
|
||||||
|
|
||||||
|
import 'package:meshcore_sar_app/models/contact.dart';
|
||||||
|
import 'package:meshcore_sar_app/services/nearest_router_selector.dart';
|
||||||
|
|
||||||
|
Contact _buildRepeater({
|
||||||
|
required int seed,
|
||||||
|
required String name,
|
||||||
|
required double latitude,
|
||||||
|
required double longitude,
|
||||||
|
required int lastAdvert,
|
||||||
|
int outPathLen = -1,
|
||||||
|
}) {
|
||||||
|
return Contact(
|
||||||
|
publicKey: Uint8List.fromList(List<int>.generate(32, (i) => i + seed)),
|
||||||
|
type: ContactType.repeater,
|
||||||
|
flags: 0,
|
||||||
|
outPathLen: outPathLen,
|
||||||
|
outPath: Uint8List(0),
|
||||||
|
advName: name,
|
||||||
|
lastAdvert: lastAdvert,
|
||||||
|
advLat: (latitude * 1e6).round(),
|
||||||
|
advLon: (longitude * 1e6).round(),
|
||||||
|
lastMod: lastAdvert,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Position _position(double latitude, double longitude) {
|
||||||
|
return Position(
|
||||||
|
latitude: latitude,
|
||||||
|
longitude: longitude,
|
||||||
|
timestamp: DateTime.now(),
|
||||||
|
accuracy: 1,
|
||||||
|
altitude: 0,
|
||||||
|
altitudeAccuracy: 1,
|
||||||
|
heading: 0,
|
||||||
|
headingAccuracy: 1,
|
||||||
|
speed: 0,
|
||||||
|
speedAccuracy: 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
test('selector chooses nearest eligible repeater', () {
|
||||||
|
final selector = NearestRouterSelector();
|
||||||
|
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||||||
|
final recipient = _buildRepeater(
|
||||||
|
seed: 90,
|
||||||
|
name: 'Recipient',
|
||||||
|
latitude: 46.05,
|
||||||
|
longitude: 14.50,
|
||||||
|
lastAdvert: now,
|
||||||
|
).copyWith(type: ContactType.chat);
|
||||||
|
|
||||||
|
final selected = selector.select(
|
||||||
|
senderPosition: _position(46.0569, 14.5058),
|
||||||
|
repeaters: [
|
||||||
|
_buildRepeater(
|
||||||
|
seed: 1,
|
||||||
|
name: 'Far',
|
||||||
|
latitude: 46.10,
|
||||||
|
longitude: 14.60,
|
||||||
|
lastAdvert: now,
|
||||||
|
outPathLen: 1,
|
||||||
|
),
|
||||||
|
_buildRepeater(
|
||||||
|
seed: 2,
|
||||||
|
name: 'Near',
|
||||||
|
latitude: 46.0570,
|
||||||
|
longitude: 14.5060,
|
||||||
|
lastAdvert: now - 5,
|
||||||
|
outPathLen: 1,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
recipient: recipient,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(selected?.advName, 'Near');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('selector skips stale repeaters', () {
|
||||||
|
final selector = NearestRouterSelector();
|
||||||
|
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||||||
|
final staleAdvert = now - (11 * 60);
|
||||||
|
final recipient = _buildRepeater(
|
||||||
|
seed: 91,
|
||||||
|
name: 'Recipient',
|
||||||
|
latitude: 46.05,
|
||||||
|
longitude: 14.50,
|
||||||
|
lastAdvert: now,
|
||||||
|
).copyWith(type: ContactType.chat);
|
||||||
|
|
||||||
|
final selected = selector.select(
|
||||||
|
senderPosition: _position(46.0569, 14.5058),
|
||||||
|
repeaters: [
|
||||||
|
_buildRepeater(
|
||||||
|
seed: 3,
|
||||||
|
name: 'Stale',
|
||||||
|
latitude: 46.0570,
|
||||||
|
longitude: 14.5060,
|
||||||
|
lastAdvert: staleAdvert,
|
||||||
|
outPathLen: 1,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
recipient: recipient,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(selected, isNull);
|
||||||
|
});
|
||||||
|
}
|
||||||
130
test/services/path_history_service_test.dart
Normal file
130
test/services/path_history_service_test.dart
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
import 'package:meshcore_sar_app/models/contact.dart';
|
||||||
|
import 'package:meshcore_sar_app/models/path_selection.dart';
|
||||||
|
import 'package:meshcore_sar_app/services/path_history_service.dart';
|
||||||
|
|
||||||
|
Contact _buildContact({
|
||||||
|
required int seed,
|
||||||
|
required List<int> pathBytes,
|
||||||
|
required int hopCount,
|
||||||
|
required int hashSize,
|
||||||
|
}) {
|
||||||
|
final encoded = ((hashSize - 1) << 6) | (hopCount & 0x3F);
|
||||||
|
final outPath = Uint8List(ContactRouteCodec.maxPathBytes)
|
||||||
|
..setRange(0, pathBytes.length, pathBytes);
|
||||||
|
|
||||||
|
return Contact(
|
||||||
|
publicKey: Uint8List.fromList(List<int>.generate(32, (i) => i + seed)),
|
||||||
|
type: ContactType.chat,
|
||||||
|
flags: 0,
|
||||||
|
outPathLen: ContactRouteCodec.toSignedDescriptor(encoded),
|
||||||
|
outPath: outPath,
|
||||||
|
advName: 'Contact $seed',
|
||||||
|
lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||||
|
advLat: 0,
|
||||||
|
advLon: 0,
|
||||||
|
lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('auto rotation ranks best paths before flood', () async {
|
||||||
|
final service = PathHistoryService();
|
||||||
|
final contact = _buildContact(
|
||||||
|
seed: 0,
|
||||||
|
pathBytes: [0xAA, 0xBB],
|
||||||
|
hopCount: 2,
|
||||||
|
hashSize: 1,
|
||||||
|
);
|
||||||
|
final best = PathSelection(
|
||||||
|
mode: PathSelectionMode.directHistorical,
|
||||||
|
pathBytes: Uint8List.fromList([0xAA, 0xBB]),
|
||||||
|
hopCount: 2,
|
||||||
|
hashSize: 1,
|
||||||
|
);
|
||||||
|
final second = PathSelection(
|
||||||
|
mode: PathSelectionMode.directHistorical,
|
||||||
|
pathBytes: Uint8List.fromList([0xCC, 0xDD]),
|
||||||
|
hopCount: 2,
|
||||||
|
hashSize: 1,
|
||||||
|
);
|
||||||
|
|
||||||
|
await service.initialize();
|
||||||
|
await service.recordLearnedPath(contact);
|
||||||
|
await service.recordPathResult(
|
||||||
|
contact.publicKeyHex,
|
||||||
|
best,
|
||||||
|
success: true,
|
||||||
|
roundTripTimeMs: 120,
|
||||||
|
);
|
||||||
|
await service.recordPathResult(
|
||||||
|
contact.publicKeyHex,
|
||||||
|
best,
|
||||||
|
success: true,
|
||||||
|
roundTripTimeMs: 110,
|
||||||
|
);
|
||||||
|
await service.recordPathResult(
|
||||||
|
contact.publicKeyHex,
|
||||||
|
second,
|
||||||
|
success: true,
|
||||||
|
roundTripTimeMs: 200,
|
||||||
|
);
|
||||||
|
await service.recordPathResult(
|
||||||
|
contact.publicKeyHex,
|
||||||
|
second,
|
||||||
|
success: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
final first = await service.getSelectionForContact(
|
||||||
|
contact,
|
||||||
|
autoRouteRotationEnabled: true,
|
||||||
|
);
|
||||||
|
final third = await service.getSelectionForContact(
|
||||||
|
contact,
|
||||||
|
autoRouteRotationEnabled: true,
|
||||||
|
);
|
||||||
|
final secondPick = await service.getSelectionForContact(
|
||||||
|
contact,
|
||||||
|
autoRouteRotationEnabled: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(first.mode, PathSelectionMode.directHistorical);
|
||||||
|
expect(first.canonicalPath, 'AA,BB');
|
||||||
|
expect(third.mode, PathSelectionMode.directHistorical);
|
||||||
|
expect(third.canonicalPath, 'CC,DD');
|
||||||
|
expect(secondPick.mode, PathSelectionMode.flood);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('no history falls back to flood', () async {
|
||||||
|
final service = PathHistoryService();
|
||||||
|
final contact = Contact(
|
||||||
|
publicKey: Uint8List.fromList(List<int>.generate(32, (i) => i)),
|
||||||
|
type: ContactType.chat,
|
||||||
|
flags: 0,
|
||||||
|
outPathLen: -1,
|
||||||
|
outPath: Uint8List(0),
|
||||||
|
advName: 'No Route',
|
||||||
|
lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||||
|
advLat: 0,
|
||||||
|
advLon: 0,
|
||||||
|
lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||||
|
);
|
||||||
|
|
||||||
|
final selection = await service.getSelectionForContact(
|
||||||
|
contact,
|
||||||
|
autoRouteRotationEnabled: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(selection.mode, PathSelectionMode.flood);
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user