diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj
index 8d47f3c..a732963 100644
--- a/ios/Runner.xcodeproj/project.pbxproj
+++ b/ios/Runner.xcodeproj/project.pbxproj
@@ -489,7 +489,7 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
- CURRENT_PROJECT_VERSION = 105;
+ CURRENT_PROJECT_VERSION = 106;
DEVELOPMENT_TEAM = JND55328G8;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@@ -511,7 +511,7 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 105;
+ CURRENT_PROJECT_VERSION = 106;
DEVELOPMENT_TEAM = JND55328G8;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
@@ -530,7 +530,7 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 105;
+ CURRENT_PROJECT_VERSION = 106;
DEVELOPMENT_TEAM = JND55328G8;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
@@ -547,7 +547,7 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 105;
+ CURRENT_PROJECT_VERSION = 106;
DEVELOPMENT_TEAM = JND55328G8;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
@@ -679,7 +679,7 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
- CURRENT_PROJECT_VERSION = 105;
+ CURRENT_PROJECT_VERSION = 106;
DEVELOPMENT_TEAM = JND55328G8;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@@ -702,7 +702,7 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
- CURRENT_PROJECT_VERSION = 105;
+ CURRENT_PROJECT_VERSION = 106;
DEVELOPMENT_TEAM = JND55328G8;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist
index a45b3dd..5f673a1 100644
--- a/ios/Runner/Info.plist
+++ b/ios/Runner/Info.plist
@@ -43,7 +43,7 @@
CFBundleSignature
????
CFBundleVersion
- 105
+ 106
LSRequiresIPhoneOS
ITSAppUsesNonExemptEncryption
diff --git a/lib/models/message_route_metadata.dart b/lib/models/message_route_metadata.dart
new file mode 100644
index 0000000..c20b40c
--- /dev/null
+++ b/lib/models/message_route_metadata.dart
@@ -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 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 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?,
+ );
+ }
+}
diff --git a/lib/models/path_history.dart b/lib/models/path_history.dart
new file mode 100644
index 0000000..83ffbb7
--- /dev/null
+++ b/lib/models/path_history.dart
@@ -0,0 +1,182 @@
+class PathRecord {
+ final List 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? 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 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 json) {
+ return PathRecord(
+ pathBytes: (json['path_bytes'] as List? ?? const [])
+ .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 toJson() {
+ return {
+ 'success_count': successCount,
+ 'failure_count': failureCount,
+ 'last_round_trip_time_ms': lastRoundTripTimeMs,
+ 'last_used_at': lastUsedAt?.toIso8601String(),
+ };
+ }
+
+ factory FloodPathStats.fromJson(Map 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 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 [],
+ floodStats = const FloodPathStats.empty(),
+ rotationIndex = 0;
+
+ ContactPathHistory copyWith({
+ List? directPaths,
+ FloodPathStats? floodStats,
+ int? rotationIndex,
+ }) {
+ return ContactPathHistory(
+ contactPublicKeyHex: contactPublicKeyHex,
+ directPaths: directPaths ?? this.directPaths,
+ floodStats: floodStats ?? this.floodStats,
+ rotationIndex: rotationIndex ?? this.rotationIndex,
+ );
+ }
+
+ Map toJson() {
+ return {
+ 'direct_paths': directPaths.map((record) => record.toJson()).toList(),
+ 'flood_stats': floodStats.toJson(),
+ 'rotation_index': rotationIndex,
+ };
+ }
+
+ factory ContactPathHistory.fromJson(
+ String contactPublicKeyHex,
+ Map json,
+ ) {
+ return ContactPathHistory(
+ contactPublicKeyHex: contactPublicKeyHex,
+ directPaths: (json['direct_paths'] as List? ?? const [])
+ .whereType