Compare commits

..

27 Commits

Author SHA1 Message Date
Janez T
43eb857663 Bump iOS build to 133 2026-04-05 20:07:19 +02:00
Janez T
faa396769e feat: Add buzzer alerts toggle 2026-04-05 20:02:54 +02:00
Janez T
1a35b790a7 feat: Add GPS diagnostics #0 2026-04-05 19:58:34 +02:00
Janez T
573921e002 fix: Clarify shared location labels and onboarding copy #123 2026-04-05 19:54:48 +02:00
Janez T
14a41b210e feat: Add manual TCP IP entry 2026-04-05 19:45:16 +02:00
Janez T
56a7fa2fc6 fix: Use location pin icon 2026-04-05 19:39:40 +02:00
Janez T
7f4b4c4560 feat: Add message location details 2026-04-05 19:33:41 +02:00
Janez T
7f7f4aa975 fix: Widen worker charts 2026-04-05 18:01:05 +02:00
Janez T
1baab06627 fix: Widen worker stats graph 2026-04-04 20:20:36 +02:00
Janez T
5320fa1a2b fix: Guard null history decode #123 2026-04-04 15:58:21 +02:00
Janez T
512b64db9f fix: Preserve custom path priority 2026-04-04 15:57:41 +02:00
Janez T
4690ceead1 chore: Remove reporter map dependencies 2026-04-04 10:01:03 +02:00
Janez T
d431a79aa8 Fix: Limit stats retention to 7 days 2026-04-03 22:42:52 +02:00
Janez T
d66bbca82c feat: Add dark theme charts ref: 2026-04-03 22:38:21 +02:00
Janez T
d2fee94678 feat: Reorder stats cards cache dashboard 2026-04-03 22:34:06 +02:00
Janez T
61a856e84c feat: Clarify stats dashboard ref: 2026-04-03 22:29:46 +02:00
Janez T
5feb07aad7 feat: Enable stats by default and enrich public dashboard 2026-04-03 22:24:09 +02:00
Janez T
59931f402a feat: Add traffic over time chart 2026-04-03 22:19:23 +02:00
Janez T
0cd563063a feat: Add Leaflet map to stats dashboard #123 2026-04-03 22:17:23 +02:00
Janez T
9244368b49 chore: Rename worker stats #0 2026-04-03 20:00:14 +02:00
Janez T
c089db5c62 feat: Add anonymous RX stats
ref:
2026-04-03 19:52:45 +02:00
Janez T
7c1bc3b84f feat: Add shadcn worker UI 2026-04-03 19:46:51 +02:00
Janez T
724f4b38e8 fix: Tighten ping sheet timing 2026-04-03 12:04:33 +02:00
Janez T
caefbdbcef fix: Clamp auto ping history 2026-04-03 12:01:39 +02:00
Janez T
79f8f8dd04 fix: Reuse Ping sheet for Sensors #123 2026-04-03 11:55:52 +02:00
Janez T
e4eb40cd34 fix: Hide Cleared Paths, Unify Sensor Actions #0 2026-04-03 11:38:57 +02:00
Janez T
7c9dfd6f63 fix: Promote confirmed DM paths 2026-04-03 10:27:06 +02:00
73 changed files with 11543 additions and 1884 deletions

6
.gitignore vendored
View File

@@ -79,9 +79,15 @@ play-store/
create_feature_graphic.py create_feature_graphic.py
# Tool caches and local state # Tool caches and local state
.devenv/
.cachebro/ .cachebro/
.osgrep/ .osgrep/
third_party/lpcnet_flutter/ third_party/lpcnet_flutter/
worker/node_modules/
worker/.wrangler/
worker/.bun/
worker/.astro/
worker/dist/
# Claude Code local settings (permissions, personal config) # Claude Code local settings (permissions, personal config)
.claude/settings.local.json .claude/settings.local.json

View File

@@ -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 = 129; CURRENT_PROJECT_VERSION = 133;
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 = 129; CURRENT_PROJECT_VERSION = 133;
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 = 129; CURRENT_PROJECT_VERSION = 133;
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 = 129; CURRENT_PROJECT_VERSION = 133;
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 = 129; CURRENT_PROJECT_VERSION = 133;
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 = 129; CURRENT_PROJECT_VERSION = 133;
DEVELOPMENT_TEAM = JND55328G8; DEVELOPMENT_TEAM = JND55328G8;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;

View File

@@ -43,7 +43,7 @@
<key>CFBundleSignature</key> <key>CFBundleSignature</key>
<string>????</string> <string>????</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>129</string> <string>133</string>
<key>LSRequiresIPhoneOS</key> <key>LSRequiresIPhoneOS</key>
<true/> <true/>
<key>ITSAppUsesNonExemptEncryption</key> <key>ITSAppUsesNonExemptEncryption</key>

View File

@@ -5,22 +5,22 @@
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000201"> <testcase classname="fastlane.lanes" name="0: default_platform" time="0.000253">
</testcase> </testcase>
<testcase classname="fastlane.lanes" name="1: increment_build_number" time="0.700922"> <testcase classname="fastlane.lanes" name="1: increment_build_number" time="0.303267">
</testcase> </testcase>
<testcase classname="fastlane.lanes" name="2: build_app" time="102.603385"> <testcase classname="fastlane.lanes" name="2: build_app" time="131.526213">
</testcase> </testcase>
<testcase classname="fastlane.lanes" name="3: upload_to_app_store" time="2538.71069"> <testcase classname="fastlane.lanes" name="3: upload_to_app_store" time="206.79174">
</testcase> </testcase>

View File

@@ -2317,7 +2317,7 @@
"@wizardOverviewFeature1": { "@wizardOverviewFeature1": {
"description": "Updated onboarding overview feature 1" "description": "Updated onboarding overview feature 1"
}, },
"wizardOverviewFeature2": "Share SAR markers, map drawings, voice clips, and images over the mesh.", "wizardOverviewFeature2": "Share live location to a private channel, plus SAR markers, map drawings, voice clips, and images over the mesh.",
"@wizardOverviewFeature2": { "@wizardOverviewFeature2": {
"description": "Updated onboarding overview feature 2" "description": "Updated onboarding overview feature 2"
}, },
@@ -2458,7 +2458,7 @@
"@wizardMapOpsDescription": { "@wizardMapOpsDescription": {
"description": "Updated onboarding map page description" "description": "Updated onboarding map page description"
}, },
"wizardMapOpsFeature1": "Track your own position, teammate locations, and movement trails on the map.", "wizardMapOpsFeature1": "Track your own position, teammate locations, and share live location to a private channel from the channel menu.",
"@wizardMapOpsFeature1": { "@wizardMapOpsFeature1": {
"description": "Updated onboarding map feature 1" "description": "Updated onboarding map feature 1"
}, },

View File

@@ -3267,7 +3267,7 @@ abstract class AppLocalizations {
/// Updated onboarding overview feature 2 /// Updated onboarding overview feature 2
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Share SAR markers, map drawings, voice clips, and images over the mesh.'** /// **'Share live location to a private channel, plus SAR markers, map drawings, voice clips, and images over the mesh.'**
String get wizardOverviewFeature2; String get wizardOverviewFeature2;
/// Updated onboarding overview feature 3 /// Updated onboarding overview feature 3
@@ -3459,7 +3459,7 @@ abstract class AppLocalizations {
/// Updated onboarding map feature 1 /// Updated onboarding map feature 1
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Track your own position, teammate locations, and movement trails on the map.'** /// **'Track your own position, teammate locations, and share live location to a private channel from the channel menu.'**
String get wizardMapOpsFeature1; String get wizardMapOpsFeature1;
/// Updated onboarding map feature 2 /// Updated onboarding map feature 2

View File

@@ -1743,7 +1743,7 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get wizardOverviewFeature2 => String get wizardOverviewFeature2 =>
'Share SAR markers, map drawings, voice clips, and images over the mesh.'; 'Share live location to a private channel, plus SAR markers, map drawings, voice clips, and images over the mesh.';
@override @override
String get wizardOverviewFeature3 => String get wizardOverviewFeature3 =>
@@ -1860,7 +1860,7 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get wizardMapOpsFeature1 => String get wizardMapOpsFeature1 =>
'Track your own position, teammate locations, and movement trails on the map.'; 'Track your own position, teammate locations, and share live location to a private channel from the channel menu.';
@override @override
String get wizardMapOpsFeature2 => String get wizardMapOpsFeature2 =>

View File

@@ -18,6 +18,7 @@ import 'providers/channels_provider.dart';
import 'providers/voice_provider.dart'; import 'providers/voice_provider.dart';
import 'providers/image_provider.dart' as ip; import 'providers/image_provider.dart' as ip;
import 'providers/app_provider.dart'; import 'providers/app_provider.dart';
import 'providers/offline_tiles_provider.dart';
import 'providers/sensors_provider.dart'; import 'providers/sensors_provider.dart';
import 'services/voice_codec_service.dart'; import 'services/voice_codec_service.dart';
import 'services/voice_player_service.dart'; import 'services/voice_player_service.dart';
@@ -285,6 +286,7 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
), ),
ChangeNotifierProvider(create: (_) => ChannelsProvider()), ChangeNotifierProvider(create: (_) => ChannelsProvider()),
ChangeNotifierProvider(create: (_) => SensorsProvider()), ChangeNotifierProvider(create: (_) => SensorsProvider()),
ChangeNotifierProvider(create: (_) => OfflineTilesProvider()),
ChangeNotifierProvider( ChangeNotifierProvider(
create: (_) { create: (_) {
final manager = ProfileManager(); final manager = ProfileManager();

View File

@@ -116,7 +116,6 @@ class AppSettingsProfileSection {
final bool? voiceEchoCancellationEnabled; final bool? voiceEchoCancellationEnabled;
final bool? voiceNoiseSuppressionEnabled; final bool? voiceNoiseSuppressionEnabled;
final double? messageFontScale; final double? messageFontScale;
final bool? autoRouteRotationEnabled;
final bool? clearPathOnMaxRetry; final bool? clearPathOnMaxRetry;
final bool? nearestRelayFallbackEnabled; final bool? nearestRelayFallbackEnabled;
final int? voiceBitrate; final int? voiceBitrate;
@@ -142,7 +141,6 @@ class AppSettingsProfileSection {
this.voiceEchoCancellationEnabled, this.voiceEchoCancellationEnabled,
this.voiceNoiseSuppressionEnabled, this.voiceNoiseSuppressionEnabled,
this.messageFontScale, this.messageFontScale,
this.autoRouteRotationEnabled,
this.clearPathOnMaxRetry, this.clearPathOnMaxRetry,
this.nearestRelayFallbackEnabled, this.nearestRelayFallbackEnabled,
this.voiceBitrate, this.voiceBitrate,
@@ -169,7 +167,6 @@ class AppSettingsProfileSection {
voiceEchoCancellationEnabled == null && voiceEchoCancellationEnabled == null &&
voiceNoiseSuppressionEnabled == null && voiceNoiseSuppressionEnabled == null &&
messageFontScale == null && messageFontScale == null &&
autoRouteRotationEnabled == null &&
clearPathOnMaxRetry == null && clearPathOnMaxRetry == null &&
nearestRelayFallbackEnabled == null && nearestRelayFallbackEnabled == null &&
voiceBitrate == null && voiceBitrate == null &&
@@ -195,7 +192,6 @@ class AppSettingsProfileSection {
'voiceEchoCancellationEnabled': voiceEchoCancellationEnabled, 'voiceEchoCancellationEnabled': voiceEchoCancellationEnabled,
'voiceNoiseSuppressionEnabled': voiceNoiseSuppressionEnabled, 'voiceNoiseSuppressionEnabled': voiceNoiseSuppressionEnabled,
'messageFontScale': messageFontScale, 'messageFontScale': messageFontScale,
'autoRouteRotationEnabled': autoRouteRotationEnabled,
'clearPathOnMaxRetry': clearPathOnMaxRetry, 'clearPathOnMaxRetry': clearPathOnMaxRetry,
'nearestRelayFallbackEnabled': nearestRelayFallbackEnabled, 'nearestRelayFallbackEnabled': nearestRelayFallbackEnabled,
'voiceBitrate': voiceBitrate, 'voiceBitrate': voiceBitrate,
@@ -225,7 +221,6 @@ class AppSettingsProfileSection {
voiceNoiseSuppressionEnabled: voiceNoiseSuppressionEnabled:
json['voiceNoiseSuppressionEnabled'] as bool?, json['voiceNoiseSuppressionEnabled'] as bool?,
messageFontScale: (json['messageFontScale'] as num?)?.toDouble(), messageFontScale: (json['messageFontScale'] as num?)?.toDouble(),
autoRouteRotationEnabled: json['autoRouteRotationEnabled'] as bool?,
clearPathOnMaxRetry: json['clearPathOnMaxRetry'] as bool?, clearPathOnMaxRetry: json['clearPathOnMaxRetry'] as bool?,
nearestRelayFallbackEnabled: json['nearestRelayFallbackEnabled'] as bool?, nearestRelayFallbackEnabled: json['nearestRelayFallbackEnabled'] as bool?,
voiceBitrate: json['voiceBitrate'] as int?, voiceBitrate: json['voiceBitrate'] as int?,

View File

@@ -15,10 +15,14 @@ class MessageContactLocation {
String get technicalSourceLabel { String get technicalSourceLabel {
switch (source) { switch (source) {
case 'shared':
return 'shared location';
case 'gps':
return 'shared location';
case 'telemetry': case 'telemetry':
return 'telemetry'; return 'live telemetry';
case 'advert': case 'advert':
return 'advert'; return 'shared advert';
default: default:
return source; return source;
} }

View File

@@ -1,229 +0,0 @@
enum PathRecordSource { learned, observed }
class PathRecord {
final List<int> pathBytes;
final int hopCount;
final int hashSize;
final PathRecordSource source;
final int successCount;
final int failureCount;
final int lastRoundTripTimeMs;
final DateTime lastUsedAt;
final DateTime? lastSucceededAt;
final double? senderLatitude;
final double? senderLongitude;
final double? recipientLatitude;
final double? recipientLongitude;
const PathRecord({
required this.pathBytes,
required this.hopCount,
required this.hashSize,
required this.source,
required this.successCount,
required this.failureCount,
required this.lastRoundTripTimeMs,
required this.lastUsedAt,
required this.lastSucceededAt,
required this.senderLatitude,
required this.senderLongitude,
required this.recipientLatitude,
required this.recipientLongitude,
});
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,
PathRecordSource? source,
int? successCount,
int? failureCount,
int? lastRoundTripTimeMs,
DateTime? lastUsedAt,
DateTime? lastSucceededAt,
double? senderLatitude,
double? senderLongitude,
double? recipientLatitude,
double? recipientLongitude,
}) {
return PathRecord(
pathBytes: pathBytes ?? this.pathBytes,
hopCount: hopCount ?? this.hopCount,
hashSize: hashSize ?? this.hashSize,
source: source ?? this.source,
successCount: successCount ?? this.successCount,
failureCount: failureCount ?? this.failureCount,
lastRoundTripTimeMs: lastRoundTripTimeMs ?? this.lastRoundTripTimeMs,
lastUsedAt: lastUsedAt ?? this.lastUsedAt,
lastSucceededAt: lastSucceededAt ?? this.lastSucceededAt,
senderLatitude: senderLatitude ?? this.senderLatitude,
senderLongitude: senderLongitude ?? this.senderLongitude,
recipientLatitude: recipientLatitude ?? this.recipientLatitude,
recipientLongitude: recipientLongitude ?? this.recipientLongitude,
);
}
Map<String, dynamic> toJson() {
return {
'path_bytes': pathBytes,
'hop_count': hopCount,
'hash_size': hashSize,
'source': source.name,
'success_count': successCount,
'failure_count': failureCount,
'last_round_trip_time_ms': lastRoundTripTimeMs,
'last_used_at': lastUsedAt.toIso8601String(),
'last_succeeded_at': lastSucceededAt?.toIso8601String(),
'sender_latitude': senderLatitude,
'sender_longitude': senderLongitude,
'recipient_latitude': recipientLatitude,
'recipient_longitude': recipientLongitude,
};
}
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,
source: PathRecordSource.values.firstWhere(
(value) => value.name == (json['source'] as String? ?? 'learned'),
orElse: () => PathRecordSource.learned,
),
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),
lastSucceededAt: DateTime.tryParse(
json['last_succeeded_at'] as String? ?? '',
),
senderLatitude: (json['sender_latitude'] as num?)?.toDouble(),
senderLongitude: (json['sender_longitude'] as num?)?.toDouble(),
recipientLatitude: (json['recipient_latitude'] as num?)?.toDouble(),
recipientLongitude: (json['recipient_longitude'] as num?)?.toDouble(),
);
}
}
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,
};
}
List<PathRecord> get observedPaths => directPaths
.where((record) => record.source == PathRecordSource.observed)
.toList();
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,
);
}
}

View File

@@ -19,6 +19,7 @@ import '../services/messaging_route_preferences.dart';
import '../services/nearest_router_selector.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/path_history_service.dart';
import '../services/traffic_stats_reporting_service.dart';
import '../utils/rssi_location_estimator.dart'; import '../utils/rssi_location_estimator.dart';
import '../services/profiles_feature_service.dart'; import '../services/profiles_feature_service.dart';
import '../services/route_hash_preferences.dart'; import '../services/route_hash_preferences.dart';
@@ -117,6 +118,34 @@ enum ContactsTabSection {
channels, channels,
} }
enum ChannelLocationSharingMode { hardware, appFallback }
class ChannelLocationSharingState {
final ChannelLocationSharingMode mode;
final bool isSharing;
final bool hardwareSupported;
final bool isConnected;
const ChannelLocationSharingState({
required this.mode,
required this.isSharing,
required this.hardwareSupported,
required this.isConnected,
});
bool get usesHardware => mode == ChannelLocationSharingMode.hardware;
}
class ChannelLocationSharingResult {
final ChannelLocationSharingState state;
final String message;
const ChannelLocationSharingResult({
required this.state,
required this.message,
});
}
/// 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;
@@ -159,6 +188,8 @@ class AppProvider with ChangeNotifier {
LocationTrackingService(); LocationTrackingService();
final PacketCaptureStorageService packetCaptureStorageService = final PacketCaptureStorageService packetCaptureStorageService =
PacketCaptureStorageService(); PacketCaptureStorageService();
final TrafficStatsReportingService trafficStatsReportingService =
TrafficStatsReportingService();
final NotificationService _notificationService = NotificationService(); final NotificationService _notificationService = NotificationService();
bool _isInitialized = false; bool _isInitialized = false;
@@ -192,9 +223,6 @@ class AppProvider with ChangeNotifier {
bool get isVoiceNoiseSuppressionEnabled => _isVoiceNoiseSuppressionEnabled; bool get isVoiceNoiseSuppressionEnabled => _isVoiceNoiseSuppressionEnabled;
double _messageFontScale = 1.0; double _messageFontScale = 1.0;
double get messageFontScale => _messageFontScale; double get messageFontScale => _messageFontScale;
bool _autoRouteRotationEnabled =
MessagingRoutePreferences.defaultAutoRouteRotationEnabled;
bool get autoRouteRotationEnabled => _autoRouteRotationEnabled;
bool _clearPathOnMaxRetry = bool _clearPathOnMaxRetry =
MessagingRoutePreferences.defaultClearPathOnMaxRetry; MessagingRoutePreferences.defaultClearPathOnMaxRetry;
bool get clearPathOnMaxRetry => _clearPathOnMaxRetry; bool get clearPathOnMaxRetry => _clearPathOnMaxRetry;
@@ -206,6 +234,7 @@ class AppProvider with ChangeNotifier {
const NearestRouterSelector(); const NearestRouterSelector();
final Map<String, _DirectMessageRouteSession> _directMessageRouteSessions = final Map<String, _DirectMessageRouteSession> _directMessageRouteSessions =
{}; {};
final Set<String> _pendingDeliveredRouteRefreshContacts = <String>{};
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);
@@ -218,6 +247,8 @@ class AppProvider with ChangeNotifier {
final Map<String, int> _voiceMissingRetryAttempts = {}; final Map<String, int> _voiceMissingRetryAttempts = {};
final Map<String, Timer> _imageMissingRetryTimers = {}; final Map<String, Timer> _imageMissingRetryTimers = {};
final Map<String, int> _imageMissingRetryAttempts = {}; final Map<String, int> _imageMissingRetryAttempts = {};
bool _hardwareChannelLocationSharingSupported = false;
int? _hardwareChannelLocationSharingChannelIdx;
final FragmentAckWaitRegistry _rawProbeWaiters = FragmentAckWaitRegistry(); final FragmentAckWaitRegistry _rawProbeWaiters = FragmentAckWaitRegistry();
final Map<String, Future<bool>> _pendingRawRouteProbes = {}; final Map<String, Future<bool>> _pendingRawRouteProbes = {};
final Map<String, Future<bool>> _pendingMediaSwarmFetches = {}; final Map<String, Future<bool>> _pendingMediaSwarmFetches = {};
@@ -264,6 +295,11 @@ class AppProvider with ChangeNotifier {
_loadVoiceNoiseSuppressionEnabled(); _loadVoiceNoiseSuppressionEnabled();
_loadMessageFontScale(); _loadMessageFontScale();
_loadMessagingRouteSettings(); _loadMessagingRouteSettings();
unawaited(
trafficStatsReportingService.initialize(
deviceKey6Provider: _deviceKey6Hex,
),
);
unawaited(_pathHistoryService.initialize()); unawaited(_pathHistoryService.initialize());
_startPacketCapturePersistence(); _startPacketCapturePersistence();
_startLowBatteryWatcher(); _startLowBatteryWatcher();
@@ -397,6 +433,7 @@ class AppProvider with ChangeNotifier {
if (toPersist.isNotEmpty) { if (toPersist.isNotEmpty) {
await packetCaptureStorageService.appendLogs(toPersist); await packetCaptureStorageService.appendLogs(toPersist);
await trafficStatsReportingService.processLogs(toPersist);
} }
_lastPersistedPacketSignature = _packetLogSignature(logs.last); _lastPersistedPacketSignature = _packetLogSignature(logs.last);
} catch (e) { } catch (e) {
@@ -860,8 +897,7 @@ class AppProvider with ChangeNotifier {
Future<void> _loadMessagingRouteSettings() async { Future<void> _loadMessagingRouteSettings() async {
try { try {
_autoRouteRotationEnabled = await MessagingRoutePreferences.cleanupLegacySettings();
await MessagingRoutePreferences.getAutoRouteRotationEnabled();
_clearPathOnMaxRetry = _clearPathOnMaxRetry =
await MessagingRoutePreferences.getClearPathOnMaxRetry(); await MessagingRoutePreferences.getClearPathOnMaxRetry();
_nearestRelayFallbackEnabled = _nearestRelayFallbackEnabled =
@@ -872,16 +908,6 @@ class AppProvider with ChangeNotifier {
} }
} }
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 { Future<void> toggleClearPathOnMaxRetry(bool enabled) async {
try { try {
_clearPathOnMaxRetry = enabled; _clearPathOnMaxRetry = enabled;
@@ -1035,7 +1061,14 @@ class AppProvider with ChangeNotifier {
contact, contact,
devicePublicKey: devicePublicKey, devicePublicKey: devicePublicKey,
); );
unawaited(_pathHistoryService.recordLearnedPath(contact));
final updatedContact =
contactsProvider.findContactByKey(contact.publicKey) ?? contact;
if (_pendingDeliveredRouteRefreshContacts.remove(
updatedContact.publicKeyHex,
)) {
messagesProvider.applyDeliveredMessageRouteFromContact(updatedContact);
}
}; };
// When all contacts are received // When all contacts are received
@@ -1045,9 +1078,6 @@ 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');
}; };
@@ -1245,18 +1275,6 @@ class AppProvider with ChangeNotifier {
enrichedMessage, enrichedMessage,
); );
final receivedPathBytes = receptionDetailsSnapshot?.pathBytes; final receivedPathBytes = receptionDetailsSnapshot?.pathBytes;
if (senderContact != null &&
enrichedMessage.isChannelMessage &&
receivedPathBytes != null &&
receivedPathBytes.isNotEmpty) {
unawaited(
_learnPathFromPublicMessage(
contact: senderContact,
pathBytes: receivedPathBytes,
),
);
}
// Estimate location for contacts without GPS using received path // Estimate location for contacts without GPS using received path
if (senderContact != null && if (senderContact != null &&
senderContact.displayLocation == null && senderContact.displayLocation == null &&
@@ -1676,6 +1694,7 @@ class AppProvider with ChangeNotifier {
// When a contact's routing path is updated in the mesh network // When a contact's routing path is updated in the mesh network
connectionProvider.onPathUpdated = (publicKey) { connectionProvider.onPathUpdated = (publicKey) {
_pendingDeliveredRouteRefreshContacts.add(_publicKeyHex(publicKey));
debugPrint( debugPrint(
'🔄 [AppProvider] Path updated for contact: ${publicKey.sublist(0, 6).map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}...', '🔄 [AppProvider] Path updated for contact: ${publicKey.sublist(0, 6).map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}...',
); );
@@ -1837,17 +1856,11 @@ class AppProvider with ChangeNotifier {
contactsProvider.findContactByKey(contact.publicKey) ?? contact; contactsProvider.findContactByKey(contact.publicKey) ?? contact;
var session = _directMessageRouteSessions[messageId]; var session = _directMessageRouteSessions[messageId];
if (session == null) { if (session == null) {
final selection = latestContact.routeHasPath && latestContact.routeHopCount > 0 final manualSelection = await _pathHistoryService
? PathSelection( .getManualSelectionForContact(latestContact);
mode: PathSelectionMode.directCurrent, final selection =
pathBytes: Uint8List.fromList(latestContact.routePathBytes), manualSelection ??
hopCount: latestContact.routeHopCount, await _pathHistoryService.getSelectionForContact(latestContact);
hashSize: latestContact.routeHashSize,
)
: await _pathHistoryService.getSelectionForContact(
latestContact,
autoRouteRotationEnabled: _autoRouteRotationEnabled,
);
session = _DirectMessageRouteSession( session = _DirectMessageRouteSession(
currentSelection: selection, currentSelection: selection,
originalRoute: ContactRouteCodec.fromContact(latestContact), originalRoute: ContactRouteCodec.fromContact(latestContact),
@@ -1856,16 +1869,9 @@ class AppProvider with ChangeNotifier {
} }
if (!session.routerFallbackAttempted) { if (!session.routerFallbackAttempted) {
final currentSignature =
latestContact.routeHasPath && latestContact.routeHopCount > 0
? latestContact.routePathBytes
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
.join()
: null;
final selection = await _resolveDirectMessageSelectionForRetry( final selection = await _resolveDirectMessageSelectionForRetry(
latestContact, latestContact,
retryAttempt: retryAttempt, retryAttempt: retryAttempt,
currentSignature: currentSignature,
fallbackSelection: session.currentSelection, fallbackSelection: session.currentSelection,
); );
session = session.copyWith(currentSelection: selection); session = session.copyWith(currentSelection: selection);
@@ -1885,35 +1891,17 @@ class AppProvider with ChangeNotifier {
Future<PathSelection> _resolveDirectMessageSelectionForRetry( Future<PathSelection> _resolveDirectMessageSelectionForRetry(
Contact contact, { Contact contact, {
required int retryAttempt, required int retryAttempt,
required String? currentSignature,
required PathSelection fallbackSelection, required PathSelection fallbackSelection,
}) async { }) async {
if (contact.routeHasPath && contact.routeHopCount > 0 && retryAttempt <= 1) {
return PathSelection(
mode: PathSelectionMode.directCurrent,
pathBytes: Uint8List.fromList(contact.routePathBytes),
hopCount: contact.routeHopCount,
hashSize: contact.routeHashSize,
);
}
if (retryAttempt == 2) { if (retryAttempt == 2) {
return PathSelection.flood(); return PathSelection.flood();
} }
if (retryAttempt >= 3) { if (retryAttempt < 2 &&
final historicalSelection = await _pathHistoryService !fallbackSelection.hasDirectPath &&
.getLastSuccessfulDirectSelection( contact.routeHasPath &&
contact, contact.routeHopCount > 0) {
excludeSignature: currentSignature, return _pathHistoryService.getSelectionForContact(contact);
senderLatitude: locationTrackingService.currentPosition?.latitude,
senderLongitude: locationTrackingService.currentPosition?.longitude,
recipientLatitude: contact.displayLocation?.latitude,
recipientLongitude: contact.displayLocation?.longitude,
);
if (historicalSelection != null) {
return historicalSelection;
}
} }
return fallbackSelection; return fallbackSelection;
@@ -2023,27 +2011,19 @@ class AppProvider with ChangeNotifier {
final latestContact = final latestContact =
contactsProvider.findContactByKey(contact.publicKey) ?? contact; contactsProvider.findContactByKey(contact.publicKey) ?? contact;
final manualSelection = await _pathHistoryService.getManualSelectionForContact(
latestContact,
);
final session = final session =
_directMessageRouteSessions[messageId] ?? _directMessageRouteSessions[messageId] ??
_DirectMessageRouteSession( _DirectMessageRouteSession(
currentSelection: latestContact.routeHasPath currentSelection:
? PathSelection( manualSelection ??
mode: PathSelectionMode.directCurrent, await _pathHistoryService.getSelectionForContact(latestContact),
pathBytes: Uint8List.fromList(latestContact.routePathBytes),
hopCount: latestContact.routeHopCount,
hashSize: latestContact.routeHashSize,
)
: PathSelection.flood(),
originalRoute: ContactRouteCodec.fromContact(latestContact), originalRoute: ContactRouteCodec.fromContact(latestContact),
routerFallbackAttempted: false, routerFallbackAttempted: false,
); );
await _pathHistoryService.recordPathResult(
latestContact.publicKeyHex,
session.currentSelection,
success: false,
);
final repeater = _nearestRouterSelector.select( final repeater = _nearestRouterSelector.select(
senderPosition: locationTrackingService.currentPosition, senderPosition: locationTrackingService.currentPosition,
repeaters: contactsProvider.repeaters, repeaters: contactsProvider.repeaters,
@@ -2087,18 +2067,10 @@ class AppProvider with ChangeNotifier {
return; return;
} }
unawaited( if (session.currentSelection.usesFlood ||
_pathHistoryService.recordPathResult( session.currentSelection.mode == PathSelectionMode.nearestRouter) {
contact.publicKeyHex, messagesProvider.queueDeliveredMessageRouteRefresh(messageId, contact);
session.currentSelection, }
success: true,
roundTripTimeMs: roundTripTimeMs,
senderLatitude: locationTrackingService.currentPosition?.latitude,
senderLongitude: locationTrackingService.currentPosition?.longitude,
recipientLatitude: contact.displayLocation?.latitude,
recipientLongitude: contact.displayLocation?.longitude,
),
);
} }
Future<void> _handleDirectMessageFinalFailure({ Future<void> _handleDirectMessageFinalFailure({
@@ -2109,11 +2081,6 @@ class AppProvider with ChangeNotifier {
contactsProvider.findContactByKey(contact.publicKey) ?? contact; contactsProvider.findContactByKey(contact.publicKey) ?? contact;
final session = _directMessageRouteSessions.remove(messageId); final session = _directMessageRouteSessions.remove(messageId);
if (session != null) { if (session != null) {
await _pathHistoryService.recordPathResult(
latestContact.publicKeyHex,
session.currentSelection,
success: false,
);
if (session.routerFallbackAttempted) { if (session.routerFallbackAttempted) {
await _restoreRouteOnDevice(latestContact, session.originalRoute); await _restoreRouteOnDevice(latestContact, session.originalRoute);
} }
@@ -2135,6 +2102,12 @@ class AppProvider with ChangeNotifier {
return bytes.map((byte) => byte.toRadixString(16).padLeft(2, '0')).join(); return bytes.map((byte) => byte.toRadixString(16).padLeft(2, '0')).join();
} }
String _publicKeyHex(Uint8List publicKey) {
return publicKey
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
.join();
}
/// Estimate contact location from the received message path. /// Estimate contact location from the received message path.
/// ///
/// When we receive a message, the path bytes describe how it traveled: /// When we receive a message, the path bytes describe how it traveled:
@@ -2185,22 +2158,6 @@ class AppProvider with ChangeNotifier {
); );
} }
Future<void> _learnPathFromPublicMessage({
required Contact contact,
required List<int> pathBytes,
}) async {
final preferred = await RouteHashPreferences.getHashSize();
final inferredHashSize = _inferReceivedPathHashSize(
pathBytes,
preferredHashSize: preferred,
);
await _pathHistoryService.recordReceivedBytePath(
contact.publicKeyHex,
pathBytes,
inferredHashSize,
);
}
Future<void> _retainAdvertRxPath(Uint8List publicKey) async { Future<void> _retainAdvertRxPath(Uint8List publicKey) async {
final decoded = _findBestMatchingAdvertRxRoute(publicKey); final decoded = _findBestMatchingAdvertRxRoute(publicKey);
if (decoded == null || decoded.pathBytes.isEmpty) { if (decoded == null || decoded.pathBytes.isEmpty) {
@@ -2226,11 +2183,6 @@ class AppProvider with ChangeNotifier {
paddedPathBytes: parsedRoute.paddedPathBytes, paddedPathBytes: parsedRoute.paddedPathBytes,
devicePublicKey: connectionProvider.deviceInfo.publicKey, devicePublicKey: connectionProvider.deviceInfo.publicKey,
); );
await _pathHistoryService.recordReceivedBytePath(
publicKey.map((byte) => byte.toRadixString(16).padLeft(2, '0')).join(),
decoded.pathBytes,
decoded.hashSize,
);
} }
/// Handle pushAdvert (0x80) — matches the official MeshCore app flow: /// Handle pushAdvert (0x80) — matches the official MeshCore app flow:
@@ -2571,22 +2523,6 @@ class AppProvider with ChangeNotifier {
} }
} }
int _inferReceivedPathHashSize(
List<int> pathBytes, {
required int preferredHashSize,
}) {
final preferred = preferredHashSize;
final candidates = {preferred, 3, 2, 1}.toList();
for (final candidate in candidates) {
if (candidate >= 1 &&
candidate <= 3 &&
pathBytes.length % candidate == 0) {
return candidate;
}
}
return 1;
}
/// Initialize the app (load contacts, sync time, etc.) /// Initialize the app (load contacts, sync time, etc.)
Future<void> initialize() async { Future<void> initialize() async {
if (!connectionProvider.deviceInfo.isConnected) return; if (!connectionProvider.deviceInfo.isConnected) return;
@@ -2638,6 +2574,7 @@ class AppProvider with ChangeNotifier {
'📍 [AppProvider] Starting location tracking after successful initialization', '📍 [AppProvider] Starting location tracking after successful initialization',
); );
await _startLocationTracking(); await _startLocationTracking();
await refreshChannelLocationSharingState();
// Sync drawing messages with DrawingProvider // Sync drawing messages with DrawingProvider
// This restores any drawings that may be missing from storage // This restores any drawings that may be missing from storage
@@ -2681,6 +2618,7 @@ class AppProvider with ChangeNotifier {
await connectionProvider.syncChannels( await connectionProvider.syncChannels(
maxChannels: connectionProvider.deviceInfo.maxChannels, maxChannels: connectionProvider.deviceInfo.maxChannels,
); );
await refreshChannelLocationSharingState();
final messageCount = await connectionProvider.syncAllMessages( final messageCount = await connectionProvider.syncAllMessages(
force: true, force: true,
@@ -2838,6 +2776,229 @@ class AppProvider with ChangeNotifier {
]); ]);
} }
Future<void> refreshChannelLocationSharingState() async {
if (!connectionProvider.deviceInfo.isConnected) {
return;
}
final vars = await connectionProvider.getCustomVars();
final hardwareSupported = _supportsHardwareChannelLocationSharing(vars);
final rawChannelIdx = hardwareSupported
? int.tryParse(vars['fast_gps_channel'] ?? '')
: null;
final normalizedChannelIdx = rawChannelIdx != null && rawChannelIdx > 0
? rawChannelIdx
: null;
if (_hardwareChannelLocationSharingSupported == hardwareSupported &&
_hardwareChannelLocationSharingChannelIdx == normalizedChannelIdx) {
return;
}
_hardwareChannelLocationSharingSupported = hardwareSupported;
_hardwareChannelLocationSharingChannelIdx = normalizedChannelIdx;
notifyListeners();
}
ChannelLocationSharingMode? channelLocationSharingModeForChannel(
int channelIdx,
) {
if (channelIdx <= 0) {
return null;
}
if (_hardwareChannelLocationSharingChannelIdx == channelIdx) {
return ChannelLocationSharingMode.hardware;
}
if (_isAppFallbackLocationSharingActiveForChannel(channelIdx)) {
return ChannelLocationSharingMode.appFallback;
}
return null;
}
void notifyChannelLocationSharingChanged() {
notifyListeners();
}
bool _isAppFallbackLocationSharingActiveForChannel(int channelIdx) {
return locationTrackingService.fastLocationUpdatesEnabled &&
locationTrackingService.fastLocationChannelIdx == channelIdx;
}
bool _supportsHardwareChannelLocationSharing(Map<String, String> vars) {
return vars.containsKey('gps') && vars.containsKey('fast_gps_channel');
}
int? _hardwareChannelLocationSharingIdxFromVars(Map<String, String> vars) {
final rawChannelIdx = int.tryParse(vars['fast_gps_channel'] ?? '');
return rawChannelIdx != null && rawChannelIdx > 0 ? rawChannelIdx : null;
}
Future<void> _setDeviceCustomVarOrThrow(String key, String value) async {
connectionProvider.clearError();
await connectionProvider.setCustomVar(key, value);
final error = connectionProvider.error;
if (error != null) {
connectionProvider.clearError();
throw StateError(error);
}
}
Future<ChannelLocationSharingState> getChannelLocationSharingState(
int channelIdx,
) async {
if (channelIdx <= 0) {
return const ChannelLocationSharingState(
mode: ChannelLocationSharingMode.appFallback,
isSharing: false,
hardwareSupported: false,
isConnected: false,
);
}
if (connectionProvider.deviceInfo.isConnected) {
await refreshChannelLocationSharingState();
}
final sharingMode = channelLocationSharingModeForChannel(channelIdx);
return ChannelLocationSharingState(
mode: sharingMode ??
(_hardwareChannelLocationSharingSupported
? ChannelLocationSharingMode.hardware
: ChannelLocationSharingMode.appFallback),
isSharing: sharingMode != null,
hardwareSupported: _hardwareChannelLocationSharingSupported,
isConnected: connectionProvider.deviceInfo.isConnected,
);
}
Future<ChannelLocationSharingResult> setChannelLocationSharingEnabled(
int channelIdx,
bool enabled,
) async {
if (channelIdx <= 0) {
throw ArgumentError.value(
channelIdx,
'channelIdx',
'Location sharing requires a non-public channel',
);
}
if (!connectionProvider.deviceInfo.isConnected) {
throw StateError('Connect to a device first');
}
final vars = await connectionProvider.getCustomVars();
final hardwareSupported = _supportsHardwareChannelLocationSharing(vars);
if (enabled) {
if (hardwareSupported) {
await _setDeviceCustomVarOrThrow('gps', '1');
await _setDeviceCustomVarOrThrow(
'fast_gps_channel',
channelIdx.toString(),
);
_hardwareChannelLocationSharingSupported = true;
_hardwareChannelLocationSharingChannelIdx = channelIdx;
await locationTrackingService.updateFastLocationChannelIdx(null);
await locationTrackingService.setFastLocationUpdatesEnabled(false);
await refreshChannelLocationSharingState();
if (_hardwareChannelLocationSharingChannelIdx != channelIdx) {
throw StateError('Radio location sharing did not enable for this channel');
}
notifyListeners();
return const ChannelLocationSharingResult(
state: ChannelLocationSharingState(
mode: ChannelLocationSharingMode.hardware,
isSharing: true,
hardwareSupported: true,
isConnected: true,
),
message: 'Sharing location on this channel from the radio.',
);
}
_hardwareChannelLocationSharingSupported = false;
_hardwareChannelLocationSharingChannelIdx = null;
final previousEnabled = locationTrackingService.fastLocationUpdatesEnabled;
final previousChannelIdx = locationTrackingService.fastLocationChannelIdx;
try {
await locationTrackingService.updateFastLocationChannelIdx(channelIdx);
await locationTrackingService.setFastLocationUpdatesEnabled(true);
if (!locationTrackingService.isTracking) {
final started = await locationTrackingService.startTracking();
if (!started) {
throw StateError('Phone location tracking could not be started');
}
}
} catch (e) {
await locationTrackingService.updateFastLocationChannelIdx(
previousChannelIdx,
);
await locationTrackingService.setFastLocationUpdatesEnabled(
previousEnabled,
);
rethrow;
}
notifyListeners();
return const ChannelLocationSharingResult(
state: ChannelLocationSharingState(
mode: ChannelLocationSharingMode.appFallback,
isSharing: true,
hardwareSupported: false,
isConnected: true,
),
message: 'Sharing location on this channel from the phone.',
);
}
final activeHardwareChannelIdx = _hardwareChannelLocationSharingIdxFromVars(
vars,
);
final shouldAttemptHardwareStop =
hardwareSupported ||
_hardwareChannelLocationSharingSupported ||
activeHardwareChannelIdx == channelIdx ||
_hardwareChannelLocationSharingChannelIdx == channelIdx;
if (locationTrackingService.fastLocationChannelIdx == channelIdx) {
await locationTrackingService.updateFastLocationChannelIdx(null);
await locationTrackingService.setFastLocationUpdatesEnabled(false);
}
if (shouldAttemptHardwareStop) {
await _setDeviceCustomVarOrThrow('fast_gps_channel', '-1');
_hardwareChannelLocationSharingSupported = true;
_hardwareChannelLocationSharingChannelIdx = null;
await refreshChannelLocationSharingState();
if (_hardwareChannelLocationSharingChannelIdx == channelIdx) {
throw StateError('Radio location sharing is still enabled for this channel');
}
notifyListeners();
return const ChannelLocationSharingResult(
state: ChannelLocationSharingState(
mode: ChannelLocationSharingMode.hardware,
isSharing: false,
hardwareSupported: true,
isConnected: true,
),
message: 'Stopped sharing location on this channel.',
);
}
_hardwareChannelLocationSharingSupported = false;
_hardwareChannelLocationSharingChannelIdx = null;
notifyListeners();
return const ChannelLocationSharingResult(
state: ChannelLocationSharingState(
mode: ChannelLocationSharingMode.appFallback,
isSharing: false,
hardwareSupported: false,
isConnected: true,
),
message: 'Stopped sharing location on this channel.',
);
}
Future<void> _sendFastLocationUpdate( Future<void> _sendFastLocationUpdate(
dynamic position, { dynamic position, {
required String reason, required String reason,
@@ -4135,6 +4296,7 @@ class AppProvider with ChangeNotifier {
} }
_pendingChannelVoicePackets.clear(); _pendingChannelVoicePackets.clear();
_pendingChannelImageFragments.clear(); _pendingChannelImageFragments.clear();
trafficStatsReportingService.dispose();
super.dispose(); super.dispose();
} }
} }

View File

@@ -1,5 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:latlong2/latlong.dart';
import '../models/message.dart'; import '../models/message.dart';
import '../models/contact.dart'; import '../models/contact.dart';
import '../models/message_contact_location.dart'; import '../models/message_contact_location.dart';
@@ -10,6 +11,7 @@ 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';
import '../services/location_tracking_service.dart';
import '../services/notification_service.dart'; import '../services/notification_service.dart';
import '../utils/sar_message_parser.dart'; import '../utils/sar_message_parser.dart';
import '../utils/drawing_message_parser.dart'; import '../utils/drawing_message_parser.dart';
@@ -24,6 +26,7 @@ typedef DisplayMessageEntry = ({Message message, int occurrenceCount});
class MessagesProvider with ChangeNotifier { class MessagesProvider with ChangeNotifier {
static const Duration _channelEchoWarningDelay = Duration(seconds: 12); static const Duration _channelEchoWarningDelay = Duration(seconds: 12);
static const Duration _receivedDuplicateWindow = Duration(seconds: 5); static const Duration _receivedDuplicateWindow = Duration(seconds: 5);
static const Duration _deliveredRouteRefreshWindow = Duration(seconds: 30);
final List<Message> _messages = []; final List<Message> _messages = [];
final Map<String, SarMarker> _sarMarkers = {}; final Map<String, SarMarker> _sarMarkers = {};
@@ -38,6 +41,9 @@ class MessagesProvider with ChangeNotifier {
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 = {}; final Map<String, MessageRouteMetadata> _messageRouteMetadata = {};
final Map<String, List<_PendingDeliveredRouteRefresh>>
_pendingDeliveredRouteRefreshByContact =
<String, List<_PendingDeliveredRouteRefresh>>{};
String? _storageNamespace; String? _storageNamespace;
// Track pending sent messages by expected ACK/TAG // Track pending sent messages by expected ACK/TAG
@@ -199,6 +205,83 @@ class MessagesProvider with ChangeNotifier {
notifyListeners(); notifyListeners();
} }
void queueDeliveredMessageRouteRefresh(String messageId, Contact contact) {
if (messageId.isEmpty || contact.publicKeyHex.isEmpty) {
return;
}
_prunePendingDeliveredRouteRefresh();
final queue = _pendingDeliveredRouteRefreshByContact.putIfAbsent(
contact.publicKeyHex,
() => <_PendingDeliveredRouteRefresh>[],
);
queue.removeWhere((entry) => entry.messageId == messageId);
queue.add(
_PendingDeliveredRouteRefresh(
messageId: messageId,
queuedAt: DateTime.now(),
),
);
}
bool applyDeliveredMessageRouteFromContact(Contact contact) {
if (!contact.routeHasPath ||
contact.routeHopCount <= 0 ||
contact.publicKeyHex.isEmpty) {
return false;
}
_prunePendingDeliveredRouteRefresh();
final queue = _pendingDeliveredRouteRefreshByContact[contact.publicKeyHex];
if (queue == null || queue.isEmpty) {
return false;
}
while (queue.isNotEmpty) {
final pending = queue.removeAt(0);
final index = _messages.indexWhere(
(message) => message.id == pending.messageId,
);
if (index == -1) {
continue;
}
final message = _messages[index];
if (!message.isContactMessage ||
message.deliveryStatus != MessageDeliveryStatus.delivered) {
continue;
}
final existingMetadata = _messageRouteMetadata[pending.messageId];
_messageRouteMetadata[pending.messageId] = MessageRouteMetadata(
mode: PathSelectionMode.directCurrent,
routerFallbackAttempted:
existingMetadata?.routerFallbackAttempted ?? false,
canonicalPath: contact.routeCanonicalText.isEmpty
? null
: contact.routeCanonicalText,
hopCount: contact.routeHopCount,
);
_messages[index] = message.copyWith(
pathLen: contact.routeHopCount,
usedFloodFallback: false,
);
if (queue.isEmpty) {
_pendingDeliveredRouteRefreshByContact.remove(contact.publicKeyHex);
}
_persistMessages();
notifyListeners();
return true;
}
if (queue.isEmpty) {
_pendingDeliveredRouteRefreshByContact.remove(contact.publicKeyHex);
}
return false;
}
/// Set localizations for notifications /// Set localizations for notifications
void setLocalizations(AppLocalizations localizations) { void setLocalizations(AppLocalizations localizations) {
_localizations = localizations; _localizations = localizations;
@@ -1727,12 +1810,17 @@ class MessagesProvider with ChangeNotifier {
} }
} }
enhancedMessage = _resolveSenderNameIfNeeded(enhancedMessage); enhancedMessage = _resolveSenderNameIfNeeded(enhancedMessage);
final senderLocationSnapshot = _buildSentMessageLocationSnapshot();
// Check for duplicates (shouldn't happen for sent messages, but be safe) // Check for duplicates (shouldn't happen for sent messages, but be safe)
if (_findDuplicateMessageIndex(enhancedMessage) != -1) { if (_findDuplicateMessageIndex(enhancedMessage) != -1) {
debugPrint( debugPrint(
'⚠️ [MessagesProvider] Duplicate sent message detected, skipping: ${enhancedMessage.id}', '⚠️ [MessagesProvider] Duplicate sent message detected, skipping: ${enhancedMessage.id}',
); );
if (senderLocationSnapshot != null) {
_messageContactLocations[enhancedMessage.id] = senderLocationSnapshot;
_persistMessages();
}
return; return;
} }
@@ -1742,6 +1830,9 @@ class MessagesProvider with ChangeNotifier {
isRead: true, // Sent messages are always marked as read isRead: true, // Sent messages are always marked as read
); );
_messages.add(sendingMessage); _messages.add(sendingMessage);
if (senderLocationSnapshot != null) {
_messageContactLocations[sendingMessage.id] = senderLocationSnapshot;
}
debugPrint(' ✅ Message added to list at index ${_messages.length - 1}'); debugPrint(' ✅ Message added to list at index ${_messages.length - 1}');
debugPrint(' Total messages in list: ${_messages.length}'); debugPrint(' Total messages in list: ${_messages.length}');
@@ -1769,6 +1860,20 @@ class MessagesProvider with ChangeNotifier {
debugPrint(' ✅ notifyListeners() called - UI should update'); debugPrint(' ✅ notifyListeners() called - UI should update');
} }
MessageContactLocation? _buildSentMessageLocationSnapshot() {
final position = LocationTrackingService().currentPosition;
if (position == null) {
return null;
}
return MessageContactLocation(
location: LatLng(position.latitude, position.longitude),
source: 'gps',
capturedAt: DateTime.now(),
sourceTimestamp: position.timestamp,
);
}
/// Register an individual message ID as part of a grouped message /// Register an individual message ID as part of a grouped message
void registerGroupedMessageSend( void registerGroupedMessageSend(
String individualMessageId, String individualMessageId,
@@ -2555,6 +2660,7 @@ class MessagesProvider with ChangeNotifier {
_pendingSentMessages.remove(message.expectedAckTag); _pendingSentMessages.remove(message.expectedAckTag);
} }
_clearAckHistoryForMessage(messageId); _clearAckHistoryForMessage(messageId);
_removePendingDeliveredRouteRefresh(messageId);
// Clear retry tracking // Clear retry tracking
_retryManager.clearRetry(messageId); _retryManager.clearRetry(messageId);
@@ -2595,6 +2701,7 @@ class MessagesProvider with ChangeNotifier {
_pendingSentMessages.remove(message.expectedAckTag); _pendingSentMessages.remove(message.expectedAckTag);
} }
_clearAckHistoryForMessage(messageId); _clearAckHistoryForMessage(messageId);
_removePendingDeliveredRouteRefresh(messageId);
_retryManager.clearRetry(messageId); _retryManager.clearRetry(messageId);
_messageRouteMetadata.remove(messageId); _messageRouteMetadata.remove(messageId);
onManualRetryPreparedCallback?.call(messageId); onManualRetryPreparedCallback?.call(messageId);
@@ -2767,4 +2874,51 @@ class MessagesProvider with ChangeNotifier {
} }
} }
} }
void _prunePendingDeliveredRouteRefresh() {
if (_pendingDeliveredRouteRefreshByContact.isEmpty) {
return;
}
final cutoff = DateTime.now().subtract(_deliveredRouteRefreshWindow);
final emptyKeys = <String>[];
for (final entry in _pendingDeliveredRouteRefreshByContact.entries) {
entry.value.removeWhere(
(pending) => pending.queuedAt.isBefore(cutoff),
);
if (entry.value.isEmpty) {
emptyKeys.add(entry.key);
}
}
for (final key in emptyKeys) {
_pendingDeliveredRouteRefreshByContact.remove(key);
}
}
void _removePendingDeliveredRouteRefresh(String messageId) {
if (_pendingDeliveredRouteRefreshByContact.isEmpty) {
return;
}
final emptyKeys = <String>[];
for (final entry in _pendingDeliveredRouteRefreshByContact.entries) {
entry.value.removeWhere((pending) => pending.messageId == messageId);
if (entry.value.isEmpty) {
emptyKeys.add(entry.key);
}
}
for (final key in emptyKeys) {
_pendingDeliveredRouteRefreshByContact.remove(key);
}
}
}
class _PendingDeliveredRouteRefresh {
final String messageId;
final DateTime queuedAt;
const _PendingDeliveredRouteRefresh({
required this.messageId,
required this.queuedAt,
});
} }

View File

@@ -0,0 +1,541 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:latlong2/latlong.dart';
import '../models/map_layer.dart';
import '../services/offline_tile_cache_service.dart';
import '../services/tile_download_service.dart';
import '../services/tile_math_service.dart';
import '../services/tile_sharing_service.dart';
export '../services/tile_sharing_service.dart' show TilePeer, PeerCatalog;
export '../services/offline_tile_cache_service.dart'
show StyleInfo, DownloadRegion;
/// Download progress state.
class DownloadProgress {
final int downloaded;
final int skipped;
final int failed;
final int total;
const DownloadProgress({
this.downloaded = 0,
this.skipped = 0,
this.failed = 0,
this.total = 0,
});
int get processed => downloaded + skipped + failed;
double get percent => total == 0 ? 0 : processed / total;
bool get isComplete => total > 0 && processed >= total;
}
/// A downloaded/skipped tile rectangle for map overlay.
class TileOverlay {
final double north, south, east, west;
final bool isSkipped;
const TileOverlay({
required this.north,
required this.south,
required this.east,
required this.west,
this.isSkipped = false,
});
}
/// Drawing mode for polygon selection.
enum DrawingMode { none, polygon, rectangle }
/// State management for offline tile downloading.
class OfflineTilesProvider extends ChangeNotifier {
final OfflineTileCacheService _cache = OfflineTileCacheService.instance;
final TileSharingService _sharing = TileSharingService.instance;
TileDownloadService? _downloadService;
StreamSubscription<TileDownloadEvent>? _downloadSubscription;
StreamSubscription<Set<TilePeer>>? _peersSubscription;
// Drawing state
DrawingMode _drawingMode = DrawingMode.none;
final List<List<LatLng>> _polygons = [];
List<LatLng> _currentVertices = [];
LatLng? _rectangleFirstCorner;
// Download settings
int _minZoom = 8;
int _maxZoom = 14;
MapLayer _selectedLayer = MapLayer.openStreetMap;
// Download progress
bool _isDownloading = false;
DownloadProgress _progress = const DownloadProgress();
final List<TileOverlay> _tileOverlays = [];
// Cache info
int _cacheSizeBytes = 0;
// Sharing state
bool _isServerRunning = false;
Set<TilePeer> _discoveredPeers = {};
List<PeerCatalog> _peerCatalogs = [];
bool _isFetchingCatalogs = false;
bool _isSyncing = false;
String _syncStatus = '';
double _syncProgress = 0;
// Local style info
List<StyleInfo> _localStyles = [];
// Coverage overlay — which cached style's tiles to show on the map
StyleInfo? _coverageStyle;
List<TileOverlay> _coverageOverlays = [];
// Getters
DrawingMode get drawingMode => _drawingMode;
List<List<LatLng>> get polygons => List.unmodifiable(_polygons);
List<LatLng> get currentVertices => List.unmodifiable(_currentVertices);
LatLng? get rectangleFirstCorner => _rectangleFirstCorner;
int get minZoom => _minZoom;
int get maxZoom => _maxZoom;
MapLayer get selectedLayer => _selectedLayer;
bool get isDownloading => _isDownloading;
DownloadProgress get progress => _progress;
List<TileOverlay> get tileOverlays => _tileOverlays;
int get cacheSizeBytes => _cacheSizeBytes;
bool get hasPolygons => _polygons.isNotEmpty;
bool get isServerRunning => _isServerRunning;
Set<TilePeer> get discoveredPeers => _discoveredPeers;
List<PeerCatalog> get peerCatalogs => _peerCatalogs;
bool get isFetchingCatalogs => _isFetchingCatalogs;
bool get isSyncing => _isSyncing;
String get syncStatus => _syncStatus;
double get syncProgress => _syncProgress;
List<StyleInfo> get localStyles => _localStyles;
StyleInfo? get coverageStyle => _coverageStyle;
List<TileOverlay> get coverageOverlays => _coverageOverlays;
/// Estimated tile count for the current selection.
int get estimatedTileCount {
if (_polygons.isEmpty) return 0;
return TileMathService.estimateTileCount(_polygons, _minZoom, _maxZoom);
}
// Drawing methods
void setDrawingMode(DrawingMode mode) {
_drawingMode = mode;
_currentVertices = [];
_rectangleFirstCorner = null;
notifyListeners();
}
void addVertex(LatLng point) {
if (_drawingMode == DrawingMode.polygon) {
_currentVertices = [..._currentVertices, point];
notifyListeners();
} else if (_drawingMode == DrawingMode.rectangle) {
if (_rectangleFirstCorner == null) {
_rectangleFirstCorner = point;
notifyListeners();
} else {
// Complete rectangle
final corner1 = _rectangleFirstCorner!;
final corner2 = point;
final rect = [
LatLng(corner1.latitude, corner1.longitude),
LatLng(corner1.latitude, corner2.longitude),
LatLng(corner2.latitude, corner2.longitude),
LatLng(corner2.latitude, corner1.longitude),
];
_polygons.add(rect);
_rectangleFirstCorner = null;
_drawingMode = DrawingMode.none;
notifyListeners();
}
}
}
void finishPolygon() {
if (_drawingMode == DrawingMode.polygon && _currentVertices.length >= 3) {
_polygons.add(List.from(_currentVertices));
_currentVertices = [];
_drawingMode = DrawingMode.none;
notifyListeners();
}
}
void removePolygon(int index) {
if (index >= 0 && index < _polygons.length) {
_polygons.removeAt(index);
notifyListeners();
}
}
void clearPolygons() {
_polygons.clear();
_currentVertices = [];
_rectangleFirstCorner = null;
_drawingMode = DrawingMode.none;
notifyListeners();
}
void undoLastVertex() {
if (_currentVertices.isNotEmpty) {
_currentVertices = _currentVertices.sublist(0, _currentVertices.length - 1);
notifyListeners();
}
}
// Download settings
void setMinZoom(int zoom) {
_minZoom = zoom.clamp(0, 19);
if (_maxZoom < _minZoom) _maxZoom = _minZoom;
notifyListeners();
}
void setMaxZoom(int zoom) {
_maxZoom = zoom.clamp(0, 19);
if (_minZoom > _maxZoom) _minZoom = _maxZoom;
notifyListeners();
}
void setSelectedLayer(MapLayer layer) {
_selectedLayer = layer;
notifyListeners();
}
// Download control
Future<void> startDownload() async {
if (_isDownloading || _polygons.isEmpty) return;
_isDownloading = true;
_progress = const DownloadProgress();
_tileOverlays.clear();
notifyListeners();
_downloadService = TileDownloadService();
final stream = _downloadService!.downloadTiles(
polygons: _polygons,
minZoom: _minZoom,
maxZoom: _maxZoom,
urlTemplate: _selectedLayer.urlTemplate,
displayName: _selectedLayer.name,
);
await for (final event in stream) {
switch (event) {
case TileDownloadStarted(:final totalTiles):
_progress = DownloadProgress(total: totalTiles);
notifyListeners();
case TileDownloaded(:final north, :final south, :final east, :final west):
_progress = DownloadProgress(
downloaded: _progress.downloaded + 1,
skipped: _progress.skipped,
failed: _progress.failed,
total: _progress.total,
);
_addOverlay(TileOverlay(
north: north, south: south, east: east, west: west,
));
notifyListeners();
case TileSkipped():
_progress = DownloadProgress(
downloaded: _progress.downloaded,
skipped: _progress.skipped + 1,
failed: _progress.failed,
total: _progress.total,
);
notifyListeners();
case TileBatchSkipped(:final count):
_progress = DownloadProgress(
downloaded: _progress.downloaded,
skipped: _progress.skipped + count,
failed: _progress.failed,
total: _progress.total,
);
notifyListeners();
case TileFailed():
_progress = DownloadProgress(
downloaded: _progress.downloaded,
skipped: _progress.skipped,
failed: _progress.failed + 1,
total: _progress.total,
);
notifyListeners();
case TileDownloadComplete():
_isDownloading = false;
notifyListeners();
case TileDownloadCancelled():
_isDownloading = false;
notifyListeners();
}
}
_isDownloading = false;
_downloadService?.dispose();
_downloadService = null;
notifyListeners();
}
void cancelDownload() {
_downloadService?.cancel();
}
void clearOverlays() {
_tileOverlays.clear();
notifyListeners();
}
void _addOverlay(TileOverlay overlay) {
_tileOverlays.add(overlay);
// Limit overlays to prevent OOM
if (_tileOverlays.length > 500) {
_tileOverlays.removeRange(0, _tileOverlays.length - 500);
}
}
// Cache management
Future<void> refreshCacheSize() async {
_cacheSizeBytes = await _cache.getCacheSize();
notifyListeners();
}
Future<void> deleteStyle(StyleInfo style) async {
if (_coverageStyle?.hash == style.hash) hideCoverage();
await _cache.deleteStyle(style.hash);
await refreshCacheSize();
await refreshLocalStyles();
}
Future<void> clearCache() async {
hideCoverage();
await _cache.clearCache();
_cacheSizeBytes = 0;
_localStyles = [];
notifyListeners();
}
// Coverage overlay — show cached tile bounds on the map
/// Show the coverage of a cached style on the map.
/// Loads tile coordinates from the manifest and converts to bounds.
Future<void> showCoverage(StyleInfo style) async {
if (_coverageStyle?.hash == style.hash) {
// Toggle off if same style tapped again
hideCoverage();
return;
}
_coverageStyle = style;
_coverageOverlays = [];
notifyListeners();
final manifest = await _cache.loadManifest(style.hash);
// Convert manifest keys to tile bound overlays
final overlays = <TileOverlay>[];
for (final key in manifest) {
final parts = key.split('/');
if (parts.length != 3) continue;
final z = int.tryParse(parts[0]);
final x = int.tryParse(parts[1]);
final y = int.tryParse(parts[2]);
if (z == null || x == null || y == null) continue;
final bounds = TileMathService.tileBounds(x, y, z);
overlays.add(TileOverlay(
north: bounds.north,
south: bounds.south,
east: bounds.east,
west: bounds.west,
isSkipped: true, // green color
));
}
_coverageOverlays = overlays;
notifyListeners();
}
void hideCoverage() {
_coverageStyle = null;
_coverageOverlays = [];
notifyListeners();
}
// Sharing controls
Future<void> toggleServer() async {
if (_isServerRunning) {
await _sharing.stopServer();
_isServerRunning = false;
} else {
await _sharing.startServer();
_isServerRunning = _sharing.isRunning;
}
notifyListeners();
}
Future<void> startPeerDiscovery() async {
_peersSubscription?.cancel();
_peersSubscription = _sharing.peersStream.listen((peers) {
_discoveredPeers = peers;
notifyListeners();
});
await _sharing.startDiscovery();
}
Future<void> stopPeerDiscovery() async {
_peersSubscription?.cancel();
_peersSubscription = null;
await _sharing.stopPeerDiscovery();
_discoveredPeers = {};
notifyListeners();
}
void addManualPeer(String ipAddress) {
_sharing.addManualPeer(ipAddress);
_discoveredPeers = _sharing.discoveredPeers;
notifyListeners();
}
void removePeer(TilePeer peer) {
_sharing.removePeer(peer);
_discoveredPeers = _sharing.discoveredPeers;
notifyListeners();
}
// Peer catalog & P2P sync
/// Refresh local style info.
Future<void> refreshLocalStyles() async {
_localStyles = await _cache.listStylesDetailed();
notifyListeners();
}
/// Fetch catalogs from all discovered peers to see what they have.
Future<void> refreshPeerCatalogs() async {
_isFetchingCatalogs = true;
notifyListeners();
_peerCatalogs = await _sharing.fetchAllPeerCatalogs();
_isFetchingCatalogs = false;
notifyListeners();
}
/// Sync a style from one or more peers that have it.
/// Finds all peers offering [styleHash] and pulls missing tiles.
Future<void> syncStyleFromPeers(StyleInfo style) async {
if (_isSyncing) return;
// Find all peers that have this style
final peersWithStyle = <TilePeer>[];
for (final catalog in _peerCatalogs) {
if (catalog.styles.any((s) => s.hash == style.hash)) {
peersWithStyle.add(catalog.peer);
}
}
if (peersWithStyle.isEmpty) return;
_isSyncing = true;
_syncStatus = 'Starting sync of ${style.displayName}...';
_syncProgress = 0;
notifyListeners();
final stream = _sharing.syncStyleFromPeers(
peers: peersWithStyle,
styleHash: style.hash,
styleMeta: style,
);
await for (final event in stream) {
switch (event) {
case PeerSyncStarted(:final totalTiles):
_syncStatus = 'Syncing ${style.displayName}: 0/$totalTiles tiles';
_syncProgress = 0;
notifyListeners();
case PeerSyncTileDownloaded(:final downloaded, :final total):
_syncStatus =
'Syncing ${style.displayName}: $downloaded/$total tiles';
_syncProgress = total > 0 ? downloaded / total : 0;
notifyListeners();
case PeerSyncTileSkipped(:final skipped, :final total):
_syncProgress = total > 0 ? skipped / total : 0;
notifyListeners();
case PeerSyncComplete(:final downloaded, :final skipped, :final failed):
_syncStatus =
'Done! $downloaded new, $skipped cached, $failed failed';
_isSyncing = false;
notifyListeners();
await refreshCacheSize();
await refreshLocalStyles();
case PeerSyncCancelled():
_syncStatus = 'Sync cancelled';
_isSyncing = false;
notifyListeners();
}
}
}
void cancelSync() {
_sharing.cancelSync();
}
// Presets — load a previously downloaded region for quick re-download
/// Load a saved download region as the current selection.
/// Restores polygons, zoom range, and map layer.
void loadPreset(StyleInfo style) {
if (style.region == null) return;
final region = style.region!;
// Restore polygons
_polygons.clear();
for (final polyData in region.polygons) {
final poly = polyData.map((v) => LatLng(v[0], v[1])).toList();
if (poly.length >= 3) _polygons.add(poly);
}
// Restore zoom range
_minZoom = region.minZoom;
_maxZoom = region.maxZoom;
// Try to find the matching map layer
if (style.urlTemplate.isNotEmpty) {
final matchingLayer = MapLayer.allLayers.where(
(l) => l.urlTemplate == style.urlTemplate,
);
if (matchingLayer.isNotEmpty) {
_selectedLayer = matchingLayer.first;
}
}
_currentVertices = [];
_rectangleFirstCorner = null;
_drawingMode = DrawingMode.none;
notifyListeners();
}
@override
void dispose() {
_downloadSubscription?.cancel();
_downloadService?.dispose();
_peersSubscription?.cancel();
super.dispose();
}
}

View File

@@ -516,73 +516,174 @@ class _ContactsTabState extends State<ContactsTab> {
messenger.showSnackBar(SnackBar(content: Text(copiedMessage))); messenger.showSnackBar(SnackBar(content: Text(copiedMessage)));
} }
String _locationSharingErrorMessage(Object error) {
final message = error.toString();
if (message.startsWith('Bad state: ')) {
return message.substring('Bad state: '.length);
}
return message;
}
Future<void> _setChannelLocationSharing(
BuildContext context,
Contact channel,
bool enabled,
) async {
final channelIdx = channel.publicKey.length > 1 ? channel.publicKey[1] : 0;
try {
final result = await context.read<AppProvider>().setChannelLocationSharingEnabled(
channelIdx,
enabled,
);
if (!context.mounted) return;
ToastLogger.success(context, result.message);
} catch (error) {
if (!context.mounted) return;
ToastLogger.error(context, _locationSharingErrorMessage(error));
}
}
Future<void> _handleChannelLocationSharingAction(
BuildContext context,
Contact channel,
) async {
if (channel.isPublicChannel) {
if (!context.mounted) return;
ToastLogger.error(
context,
'Select a private channel to share your location.',
);
return;
}
final channelIdx = channel.publicKey.length > 1 ? channel.publicKey[1] : 0;
if (channelIdx <= 0) {
if (!context.mounted) return;
ToastLogger.error(
context,
'Select a private channel to share your location.',
);
return;
}
try {
final sharingState = await context
.read<AppProvider>()
.getChannelLocationSharingState(channelIdx);
if (!context.mounted) return;
await _setChannelLocationSharing(context, channel, !sharingState.isSharing);
} catch (error) {
if (!context.mounted) return;
ToastLogger.error(context, _locationSharingErrorMessage(error));
}
}
void _showChannelActionSheet(BuildContext context, Contact channel) { void _showChannelActionSheet(BuildContext context, Contact channel) {
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context)!;
final canExportHashChannelPsk = Channel.isHashChannelName( final canExportHashChannelPsk = Channel.isHashChannelName(
channel.advName.trim(), channel.advName.trim(),
); );
final actions = <_ChannelSheetAction>[ final channelLocationSharingFuture =
_ChannelSheetAction( !channel.isPublicChannel && channel.publicKey.length > 1
icon: Icons.message_outlined, ? context
label: l10n.messages, .read<AppProvider>()
onTap: () async { .getChannelLocationSharingState(channel.publicKey[1])
Navigator.pop(context); : null;
await _openMessagesForChannel(context, channel);
},
),
if (channel.displayLocation != null)
_ChannelSheetAction(
icon: Icons.map_outlined,
label: l10n.viewOnMap,
onTap: () async {
Navigator.pop(context);
_showChannelOnMap(context, channel);
},
),
if (canExportHashChannelPsk)
_ChannelSheetAction(
icon: Icons.key_outlined,
label: '${l10n.exportToClipboard} psk_base64',
onTap: () async {
Navigator.pop(context);
await _exportHashChannelPskBase64(context, channel);
},
),
_ChannelSheetAction(
icon: Icons.language_rounded,
label: l10n.setRegionScope,
onTap: () async {
Navigator.pop(context);
if (!context.mounted) return;
_showRegionScopeForChannel(context, channel);
},
),
if (!channel.isPublicChannel)
_ChannelSheetAction(
icon: Icons.delete_outline_rounded,
label: l10n.deleteChannel,
destructive: true,
onTap: () async {
Navigator.pop(context);
await Future<void>.delayed(Duration.zero);
if (!context.mounted) return;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!context.mounted) return;
_showDeleteChannelDialog(context, channel);
});
},
),
];
showModalBottomSheet<void>( showModalBottomSheet<void>(
context: context, context: context,
isScrollControlled: true, isScrollControlled: true,
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
builder: (sheetContext) => _ChannelActionSheet( builder: (sheetContext) {
channel: channel, List<_ChannelSheetAction> buildActions(
actions: actions, ChannelLocationSharingState? sharingState,
onClose: () => Navigator.pop(sheetContext), ) {
), return <_ChannelSheetAction>[
_ChannelSheetAction(
icon: Icons.message_outlined,
label: l10n.messages,
onTap: () async {
Navigator.pop(context);
await _openMessagesForChannel(context, channel);
},
),
if (!channel.isPublicChannel)
_ChannelSheetAction(
icon: sharingState?.isSharing == true
? Icons.location_off_rounded
: Icons.share_location_rounded,
label: sharingState?.isSharing == true
? 'Stop sharing my location'
: 'Share my location',
onTap: () async {
Navigator.pop(context);
await _handleChannelLocationSharingAction(context, channel);
},
),
if (channel.displayLocation != null)
_ChannelSheetAction(
icon: Icons.map_outlined,
label: l10n.viewOnMap,
onTap: () async {
Navigator.pop(context);
_showChannelOnMap(context, channel);
},
),
if (canExportHashChannelPsk)
_ChannelSheetAction(
icon: Icons.key_outlined,
label: '${l10n.exportToClipboard} psk_base64',
onTap: () async {
Navigator.pop(context);
await _exportHashChannelPskBase64(context, channel);
},
),
_ChannelSheetAction(
icon: Icons.language_rounded,
label: l10n.setRegionScope,
onTap: () async {
Navigator.pop(context);
if (!context.mounted) return;
_showRegionScopeForChannel(context, channel);
},
),
if (!channel.isPublicChannel)
_ChannelSheetAction(
icon: Icons.delete_outline_rounded,
label: l10n.deleteChannel,
destructive: true,
onTap: () async {
Navigator.pop(context);
await Future<void>.delayed(Duration.zero);
if (!context.mounted) return;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!context.mounted) return;
_showDeleteChannelDialog(context, channel);
});
},
),
];
}
Widget buildSheet(ChannelLocationSharingState? sharingState) {
return _ChannelActionSheet(
channel: channel,
actions: buildActions(sharingState),
onClose: () => Navigator.pop(sheetContext),
);
}
if (channelLocationSharingFuture == null) {
return buildSheet(null);
}
return FutureBuilder<ChannelLocationSharingState>(
future: channelLocationSharingFuture,
builder: (context, snapshot) {
return buildSheet(snapshot.data);
},
);
},
); );
} }
@@ -1820,6 +1921,27 @@ class _ChannelActivityCard extends StatelessWidget {
return l10n.daysAgo(diff.inDays); return l10n.daysAgo(diff.inDays);
} }
Widget _buildChannelLocationSharingMarker(
BuildContext context,
ChannelLocationSharingMode mode,
) {
const accent = Color(0xFF16A34A);
return Container(
padding: const EdgeInsets.all(3),
decoration: BoxDecoration(
color: accent,
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 2),
),
child: Icon(
Icons.location_pin,
size: 11,
color: Colors.white,
),
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
@@ -1828,6 +1950,9 @@ class _ChannelActivityCard extends StatelessWidget {
letterSpacing: -0.3, letterSpacing: -0.3,
); );
final channelIdx = channel.publicKey.length > 1 ? channel.publicKey[1] : 0; final channelIdx = channel.publicKey.length > 1 ? channel.publicKey[1] : 0;
final channelLocationSharingMode = context
.watch<AppProvider>()
.channelLocationSharingModeForChannel(channelIdx);
final channelMessages = messagesProvider.getMessagesForChannel(channelIdx) final channelMessages = messagesProvider.getMessagesForChannel(channelIdx)
..sort((a, b) => b.sentAt.compareTo(a.sentAt)); ..sort((a, b) => b.sentAt.compareTo(a.sentAt));
final lastActivityAt = messagesProvider.getLastActivityForDestination( final lastActivityAt = messagesProvider.getLastActivityForDestination(
@@ -1870,7 +1995,21 @@ class _ChannelActivityCard extends StatelessWidget {
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
ContactAvatar(contact: channel, radius: 24), Stack(
clipBehavior: Clip.none,
children: [
ContactAvatar(contact: channel, radius: 24),
if (channelLocationSharingMode != null)
Positioned(
right: -2,
bottom: -2,
child: _buildChannelLocationSharingMarker(
context,
channelLocationSharingMode,
),
),
],
),
const SizedBox(width: 10), const SizedBox(width: 10),
Expanded( Expanded(
child: Column( child: Column(

View File

@@ -190,6 +190,15 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
bool _repeatEnabled = false; bool _repeatEnabled = false;
bool? _gpsEnabled; // null = not supported by hardware bool? _gpsEnabled; // null = not supported by hardware
bool _gpsLoading = false; bool _gpsLoading = false;
bool? _buzzerEnabled;
bool _buzzerLoading = false;
bool? _gpsHasFix;
int? _gpsSatelliteCount;
int? _gpsLatE6;
int? _gpsLonE6;
int? _gpsLastFixAgeSeconds;
DateTime? _gpsStatsLoadedAt;
Timer? _gpsStatsTicker;
bool _isSyncingDeviceTime = false; bool _isSyncingDeviceTime = false;
int? _selectedPathHashMode; int? _selectedPathHashMode;
bool _autoAddDiscoveredContactsEnabled = true; bool _autoAddDiscoveredContactsEnabled = true;
@@ -235,6 +244,10 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
super.initState(); super.initState();
_connectionProvider = context.read<ConnectionProvider>(); _connectionProvider = context.read<ConnectionProvider>();
_connectionProvider.addListener(_handleConnectionProviderChanged); _connectionProvider.addListener(_handleConnectionProviderChanged);
_gpsStatsTicker = Timer.periodic(const Duration(seconds: 1), (_) {
if (!mounted || _gpsLastFixAgeSeconds == null) return;
setState(() {});
});
final deviceInfo = _connectionProvider.deviceInfo; final deviceInfo = _connectionProvider.deviceInfo;
_nameController = TextEditingController( _nameController = TextEditingController(
@@ -329,6 +342,7 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
_txPowerController.dispose(); _txPowerController.dispose();
_gpsIntervalController.dispose(); _gpsIntervalController.dispose();
_autoAddMaxHopsController.dispose(); _autoAddMaxHopsController.dispose();
_gpsStatsTicker?.cancel();
super.dispose(); super.dispose();
} }
@@ -493,6 +507,66 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
.toString(); .toString();
} }
int? _currentGpsLastFixAgeSeconds() {
final baseAgeSeconds = _gpsLastFixAgeSeconds;
final loadedAt = _gpsStatsLoadedAt;
if (baseAgeSeconds == null || loadedAt == null) {
return null;
}
final elapsedSeconds = DateTime.now().difference(loadedAt).inSeconds;
if (elapsedSeconds <= 0) {
return baseAgeSeconds;
}
return baseAgeSeconds + elapsedSeconds;
}
String _formatGpsFixValue() {
if (_gpsEnabled == false) {
return 'Off';
}
if (_gpsHasFix == true) {
return 'Locked';
}
if (_gpsEnabled == true) {
return 'Searching';
}
return 'Unavailable';
}
String _formatGpsLocationValue() {
final latE6 = _gpsLatE6;
final lonE6 = _gpsLonE6;
if (latE6 == null || lonE6 == null) {
return 'No fix yet';
}
return '${(latE6 / 1000000).toStringAsFixed(6)}, ${(lonE6 / 1000000).toStringAsFixed(6)}';
}
String _formatGpsLastFixValue(AppLocalizations l10n) {
final ageSeconds = _currentGpsLastFixAgeSeconds();
if (ageSeconds == null) {
return 'Never';
}
if (ageSeconds <= 0) {
return l10n.justNow;
}
if (ageSeconds < 60) {
return l10n.secondsAgo(ageSeconds);
}
final ageMinutes = ageSeconds ~/ 60;
if (ageMinutes < 60) {
return l10n.minutesAgo(ageMinutes);
}
final ageHours = ageMinutes ~/ 60;
if (ageHours < 24) {
return l10n.hoursAgo(ageHours);
}
return l10n.daysAgo(ageHours ~/ 24);
}
int _telemetryModesForSave() { int _telemetryModesForSave() {
return (_environmentTelemetryMode << 4) | return (_environmentTelemetryMode << 4) |
(_locationTelemetryMode << 2) | (_locationTelemetryMode << 2) |
@@ -746,17 +820,49 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
if (!mounted) return; if (!mounted) return;
final gpsValue = vars['gps']; final gpsValue = vars['gps'];
final gpsIntervalValue = vars['gps_interval']; final gpsIntervalValue = vars['gps_interval'];
final gpsFixValue = vars['gps_fix'];
final gpsSatsValue = vars['gps_sats'];
final gpsLatValue = vars['gps_lat_e6'];
final gpsLonValue = vars['gps_lon_e6'];
final gpsLastFixAgeValue = vars['gps_last_fix_age_s'];
final buzzerValue = vars['buzzer'];
setState(() { setState(() {
_gpsEnabled = gpsValue != null ? gpsValue == '1' : null; _gpsEnabled = gpsValue != null ? gpsValue == '1' : null;
_buzzerEnabled = buzzerValue != null ? buzzerValue == '1' : null;
if (gpsIntervalValue != null) { if (gpsIntervalValue != null) {
_gpsIntervalController.text = gpsIntervalValue; _gpsIntervalController.text = gpsIntervalValue;
} }
_gpsHasFix = gpsFixValue != null ? gpsFixValue == '1' : null;
_gpsSatelliteCount = int.tryParse(gpsSatsValue ?? '');
_gpsLatE6 = int.tryParse(gpsLatValue ?? '');
_gpsLonE6 = int.tryParse(gpsLonValue ?? '');
_gpsLastFixAgeSeconds = int.tryParse(gpsLastFixAgeValue ?? '');
_gpsStatsLoadedAt = DateTime.now();
}); });
} catch (_) { } catch (_) {
// Device may not support custom vars (old firmware / no GPS hardware) // Device may not support custom vars (old firmware / no GPS hardware)
} }
} }
Future<void> _setBuzzerMode(bool enabled) async {
setState(() => _buzzerLoading = true);
try {
await _connectionProvider.setCustomVar('buzzer', enabled ? '1' : '0');
if (!mounted) return;
setState(() {
_buzzerEnabled = enabled;
_buzzerLoading = false;
});
unawaited(_loadGpsMode());
} catch (e) {
if (!mounted) return;
setState(() => _buzzerLoading = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to set buzzer mode: $e')),
);
}
}
Future<void> _setGpsMode(bool enabled) async { Future<void> _setGpsMode(bool enabled) async {
setState(() => _gpsLoading = true); setState(() => _gpsLoading = true);
try { try {
@@ -766,6 +872,7 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
_gpsEnabled = enabled; _gpsEnabled = enabled;
_gpsLoading = false; _gpsLoading = false;
}); });
unawaited(_loadGpsMode());
} catch (e) { } catch (e) {
if (!mounted) return; if (!mounted) return;
setState(() => _gpsLoading = false); setState(() => _gpsLoading = false);
@@ -1663,6 +1770,42 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
onChanged: _setGpsMode, onChanged: _setGpsMode,
), ),
), ),
const SizedBox(height: 12),
_GpsDiagnosticsCard(
fixValue: _formatGpsFixValue(),
satellitesValue:
_gpsSatelliteCount?.toString() ?? '',
locationValue: _formatGpsLocationValue(),
lastFixValue: _formatGpsLastFixValue(
AppLocalizations.of(context)!,
),
),
],
if (_buzzerEnabled != null) ...[
const SizedBox(height: 12),
_SettingHighlightCard(
icon: _buzzerEnabled!
? Icons.volume_up_rounded
: Icons.volume_off_rounded,
title: 'Buzzer alerts',
description:
'Enable or mute the onboard buzzer for radio alerts.',
accentColor: _buzzerEnabled!
? colorScheme.primary
: colorScheme.onSurfaceVariant,
trailing: _buzzerLoading
? const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: Switch(
value: _buzzerEnabled!,
onChanged: _setBuzzerMode,
),
),
], ],
const SizedBox(height: 16), const SizedBox(height: 16),
TextField( TextField(
@@ -2860,6 +3003,115 @@ class _SettingHighlightCard extends StatelessWidget {
} }
} }
class _GpsDiagnosticsCard extends StatelessWidget {
final String fixValue;
final String satellitesValue;
final String locationValue;
final String lastFixValue;
const _GpsDiagnosticsCard({
required this.fixValue,
required this.satellitesValue,
required this.locationValue,
required this.lastFixValue,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(22),
border: Border.all(color: colorScheme.outlineVariant),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.location_searching_rounded,
color: colorScheme.primary,
size: 18,
),
const SizedBox(width: 8),
Text(
'GPS diagnostics',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w800,
),
),
],
),
const SizedBox(height: 12),
_GpsDiagnosticsRow(label: 'Fix', value: fixValue),
const SizedBox(height: 8),
_GpsDiagnosticsRow(label: 'Satellites', value: satellitesValue),
const SizedBox(height: 8),
_GpsDiagnosticsRow(label: 'Last fix', value: lastFixValue),
const SizedBox(height: 8),
_GpsDiagnosticsRow(
label: 'Location',
value: locationValue,
maxLines: 2,
),
],
),
);
}
}
class _GpsDiagnosticsRow extends StatelessWidget {
final String label;
final String value;
final int maxLines;
const _GpsDiagnosticsRow({
required this.label,
required this.value,
this.maxLines = 1,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 84,
child: Text(
label,
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w600,
),
),
),
const SizedBox(width: 12),
Expanded(
child: Text(
value,
maxLines: maxLines,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.w700,
),
),
),
],
);
}
}
class _RadioPreset { class _RadioPreset {
final String id; final String id;
final String label; final String label;

View File

@@ -3,6 +3,7 @@ import 'package:crypto/crypto.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:geolocator/geolocator.dart'; import 'package:geolocator/geolocator.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:url_launcher/url_launcher.dart';
import '../models/ble_packet_log.dart'; import '../models/ble_packet_log.dart';
import '../models/contact.dart'; import '../models/contact.dart';
@@ -12,6 +13,7 @@ import '../providers/connection_provider.dart';
import '../services/live_traffic_summary.dart'; import '../services/live_traffic_summary.dart';
import '../services/location_tracking_service.dart'; import '../services/location_tracking_service.dart';
import '../services/route_hash_preferences.dart'; import '../services/route_hash_preferences.dart';
import '../services/traffic_stats_reporting_service.dart';
import '../utils/log_rx_route_decoder.dart'; import '../utils/log_rx_route_decoder.dart';
import '../widgets/compact_signal_indicator.dart'; import '../widgets/compact_signal_indicator.dart';
import '../widgets/messages/message_trace_sheet.dart'; import '../widgets/messages/message_trace_sheet.dart';
@@ -156,6 +158,11 @@ class _LiveTrafficScreenState extends State<LiveTrafficScreen> {
tooltip: 'Open packet logs', tooltip: 'Open packet logs',
icon: const Icon(Icons.list_alt_rounded), icon: const Icon(Icons.list_alt_rounded),
), ),
IconButton(
onPressed: _openStatsDashboard,
tooltip: 'View public stats',
icon: const Icon(Icons.open_in_new),
),
IconButton( IconButton(
onPressed: () => _showPacketTypeHelpSheet(context), onPressed: () => _showPacketTypeHelpSheet(context),
tooltip: 'Packet type help', tooltip: 'Packet type help',
@@ -256,6 +263,13 @@ class _LiveTrafficScreenState extends State<LiveTrafficScreen> {
); );
} }
Future<void> _openStatsDashboard() async {
final url = TrafficStatsReportingService.dashboardUri;
if (await canLaunchUrl(url)) {
await launchUrl(url, mode: LaunchMode.externalApplication);
}
}
Future<void> _showWindowPicker(BuildContext context) async { Future<void> _showWindowPicker(BuildContext context) async {
final selected = await showModalBottomSheet<Duration>( final selected = await showModalBottomSheet<Duration>(
context: context, context: context,

View File

@@ -43,6 +43,8 @@ import '../widgets/messages/sar_update_sheet.dart';
import '../utils/key_comparison.dart'; import '../utils/key_comparison.dart';
import '../utils/sar_message_parser.dart'; import '../utils/sar_message_parser.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
import '../services/offline_map_caching_provider.dart';
import 'offline_map_screen.dart';
class MapTab extends StatefulWidget { class MapTab extends StatefulWidget {
final Function(bool)? onFullscreenChanged; final Function(bool)? onFullscreenChanged;
@@ -61,9 +63,11 @@ class MapTab extends StatefulWidget {
class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin { class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
final MapController _mapController = MapController(); final MapController _mapController = MapController();
static final TileProvider _tileProvider = NetworkTileProvider( static final TileProvider _tileProvider = NetworkTileProvider(
cachingProvider: BuiltInMapCachingProvider.getOrCreateInstance( cachingProvider: OfflineMapCachingProvider(
maxCacheSize: 10_000_000_000, BuiltInMapCachingProvider.getOrCreateInstance(
overrideFreshAge: const Duration(days: 365), maxCacheSize: 10_000_000_000,
overrideFreshAge: const Duration(days: 365),
),
), ),
); );
// DO NOT create a new LocationTrackingService instance here // DO NOT create a new LocationTrackingService instance here
@@ -3229,6 +3233,17 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
child: const Icon(Icons.layers), child: const Icon(Icons.layers),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
FloatingActionButton.small(
heroTag: 'offline_maps',
onPressed: () => Navigator.push(
context,
MaterialPageRoute(
builder: (_) => const OfflineMapScreen(),
),
),
child: const Icon(Icons.download_for_offline),
),
const SizedBox(height: 8),
FloatingActionButton.small( FloatingActionButton.small(
heroTag: 'fullscreen_toggle', heroTag: 'fullscreen_toggle',
onPressed: () { onPressed: () {

View File

@@ -1746,7 +1746,85 @@ class _MessagesTabState extends State<MessagesTab> {
await action(); await action();
} }
int? _selectedPrivateChannelIdx() {
if (_destinationType !=
MessageDestinationPreferences.destinationTypeChannel) {
return null;
}
final recipient = _selectedRecipient;
if (recipient == null ||
recipient.isPublicChannel ||
recipient.publicKey.length < 2) {
return null;
}
return recipient.publicKey[1];
}
String _locationSharingErrorMessage(Object error) {
final message = error.toString();
if (message.startsWith('Bad state: ')) {
return message.substring('Bad state: '.length);
}
return message;
}
Future<void> _setSelectedChannelLocationSharing(
int channelIdx,
bool enabled,
) async {
try {
final result = await context.read<AppProvider>().setChannelLocationSharingEnabled(
channelIdx,
enabled,
);
if (!mounted) return;
ToastLogger.success(context, result.message);
} catch (error) {
if (!mounted) return;
ToastLogger.error(context, _locationSharingErrorMessage(error));
}
}
Future<void> _handleChannelLocationSharingAction() async {
final recipient = _selectedRecipient;
if (recipient == null || recipient.isPublicChannel) {
if (!mounted) return;
ToastLogger.error(
context,
'Select a private channel to share your location.',
);
return;
}
final channelIdx = recipient.publicKey.length > 1 ? recipient.publicKey[1] : 0;
if (channelIdx <= 0) {
if (!mounted) return;
ToastLogger.error(
context,
'Select a private channel to share your location.',
);
return;
}
try {
final sharingState = await context
.read<AppProvider>()
.getChannelLocationSharingState(channelIdx);
if (!mounted) return;
await _setSelectedChannelLocationSharing(channelIdx, !sharingState.isSharing);
} catch (error) {
if (!mounted) return;
ToastLogger.error(context, _locationSharingErrorMessage(error));
}
}
void _showComposerActions() { void _showComposerActions() {
final privateChannelIdx = _selectedPrivateChannelIdx();
final locationSharingStateFuture = privateChannelIdx == null
? null
: context
.read<AppProvider>()
.getChannelLocationSharingState(privateChannelIdx);
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
showDragHandle: true, showDragHandle: true,
@@ -1759,114 +1837,142 @@ class _MessagesTabState extends State<MessagesTab> {
await _runAfterSheetDismissal(sheetContext, action); await _runAfterSheetDismissal(sheetContext, action);
} }
final actions = <Widget>[ Widget buildActionsSheet(ChannelLocationSharingState? sharingState) {
_ComposerActionTile( final actions = <Widget>[
icon: Icons.search_rounded,
title: l10n.searchMessages,
color: const Color(0xFF2B6CB0),
onTap: () => runAction(() async {
_showFilteredMessageSearch();
}),
),
_ComposerActionTile(
icon: Icons.add_location_alt_rounded,
title: l10n.sendSarMarker,
color: const Color(0xFFB45309),
onTap: () => runAction(() async {
_showSarDialog();
}),
),
if (_voiceSupported)
_ComposerActionTile( _ComposerActionTile(
icon: _isRecording ? Icons.stop_rounded : Icons.mic_rounded, icon: Icons.search_rounded,
title: _isRecording ? 'Stop recording' : 'Record voice', title: l10n.searchMessages,
color: const Color(0xFF7C3AED), color: const Color(0xFF2B6CB0),
enabled: !_isSendingVoice, onTap: () => runAction(() async {
busy: _isSendingVoice, _showFilteredMessageSearch();
onTap: !_isSendingVoice }),
),
_ComposerActionTile(
icon: Icons.add_location_alt_rounded,
title: l10n.sendSarMarker,
color: const Color(0xFFB45309),
onTap: () => runAction(() async {
_showSarDialog();
}),
),
if (_destinationType ==
MessageDestinationPreferences.destinationTypeChannel)
_ComposerActionTile(
icon: sharingState?.isSharing == true
? Icons.location_off_rounded
: Icons.share_location_rounded,
title: sharingState?.isSharing == true
? 'Stop sharing my location'
: 'Share my location',
color: const Color(0xFF0F766E),
onTap: () => runAction(() async {
await _handleChannelLocationSharingAction();
}),
),
if (_voiceSupported)
_ComposerActionTile(
icon: _isRecording ? Icons.stop_rounded : Icons.mic_rounded,
title: _isRecording ? 'Stop recording' : 'Record voice',
color: const Color(0xFF7C3AED),
enabled: !_isSendingVoice,
busy: _isSendingVoice,
onTap: !_isSendingVoice
? () => runAction(() async {
if (_isRecording) {
await _stopAndSendVoice();
} else {
await _startVoiceRecording();
}
})
: null,
),
_ComposerActionTile(
icon: Icons.photo_library_rounded,
title: l10n.sendImageFromGallery,
color: const Color(0xFF0F766E),
enabled: !_isSendingImage,
busy: _isSendingImage,
onTap: !_isSendingImage
? () => runAction(() async { ? () => runAction(() async {
if (_isRecording) { await _pickAndSendImage(source: ImageSource.gallery);
await _stopAndSendVoice();
} else {
await _startVoiceRecording();
}
}) })
: null, : null,
), ),
_ComposerActionTile(
icon: Icons.photo_library_rounded,
title: l10n.sendImageFromGallery,
color: const Color(0xFF0F766E),
enabled: !_isSendingImage,
busy: _isSendingImage,
onTap: !_isSendingImage
? () => runAction(() async {
await _pickAndSendImage(source: ImageSource.gallery);
})
: null,
),
_ComposerActionTile(
icon: Icons.camera_alt_rounded,
title: l10n.takePhoto,
color: const Color(0xFF2563EB),
enabled: !_isSendingImage,
busy: _isSendingImage,
onTap: !_isSendingImage
? () => runAction(() async {
await _pickAndSendImage(source: ImageSource.camera);
})
: null,
),
_ComposerActionTile(
icon: Icons.grid_3x3_rounded,
title: l10n.startTictactoe,
color: const Color(0xFFBE185D),
onTap: () => runAction(() async {
await _startTicTacToeGame();
}),
),
if (_destinationType ==
MessageDestinationPreferences.destinationTypeChannel)
_ComposerActionTile( _ComposerActionTile(
icon: _channelRegionScopeName != null icon: Icons.camera_alt_rounded,
? Icons.language_rounded title: l10n.takePhoto,
: Icons.public_rounded, color: const Color(0xFF2563EB),
title: _channelRegionScopeName != null enabled: !_isSendingImage,
? '${l10n.regionScope}: $_channelRegionScopeName' busy: _isSendingImage,
: l10n.setRegionScope, onTap: !_isSendingImage
color: const Color(0xFF7C3AED), ? () => runAction(() async {
await _pickAndSendImage(source: ImageSource.camera);
})
: null,
),
_ComposerActionTile(
icon: Icons.grid_3x3_rounded,
title: l10n.startTictactoe,
color: const Color(0xFFBE185D),
onTap: () => runAction(() async { onTap: () => runAction(() async {
_showRegionScopeSheet(); await _startTicTacToeGame();
}), }),
), ),
]; if (_destinationType ==
MessageDestinationPreferences.destinationTypeChannel)
_ComposerActionTile(
icon: _channelRegionScopeName != null
? Icons.language_rounded
: Icons.public_rounded,
title: _channelRegionScopeName != null
? '${l10n.regionScope}: $_channelRegionScopeName'
: l10n.setRegionScope,
color: const Color(0xFF7C3AED),
onTap: () => runAction(() async {
_showRegionScopeSheet();
}),
),
];
return SafeArea( return SafeArea(
child: ConstrainedBox( child: ConstrainedBox(
constraints: BoxConstraints( constraints: BoxConstraints(
maxHeight: MediaQuery.of(sheetContext).size.height * 0.72, maxHeight: MediaQuery.of(sheetContext).size.height * 0.72,
), ),
child: SingleChildScrollView( child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 20), padding: const EdgeInsets.fromLTRB(20, 8, 20, 20),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
'More actions', 'More actions',
style: theme.textTheme.titleLarge?.copyWith( style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
),
), ),
), const SizedBox(height: 16),
const SizedBox(height: 16), for (var index = 0; index < actions.length; index++) ...[
for (var index = 0; index < actions.length; index++) ...[ actions[index],
actions[index], if (index != actions.length - 1)
if (index != actions.length - 1) const SizedBox(height: 8), const SizedBox(height: 8),
],
], ],
], ),
), ),
), ),
), );
}
if (locationSharingStateFuture == null) {
return buildActionsSheet(null);
}
return FutureBuilder<ChannelLocationSharingState>(
future: locationSharingStateFuture,
builder: (context, snapshot) {
return buildActionsSheet(snapshot.data);
},
); );
}, },
); );
@@ -2027,10 +2133,43 @@ class _MessagesTabState extends State<MessagesTab> {
Widget _buildDestinationAvatar(BuildContext context) { Widget _buildDestinationAvatar(BuildContext context) {
final recipient = _selectedRecipient; final recipient = _selectedRecipient;
if (recipient != null) { if (recipient != null) {
return ContactAvatar( final sharingMode =
contact: recipient, recipient.isChannel &&
radius: 14, !recipient.isPublicChannel &&
displayName: _getDestinationLabel(), recipient.publicKey.length > 1
? context.watch<AppProvider>().channelLocationSharingModeForChannel(
recipient.publicKey[1],
)
: null;
return Stack(
clipBehavior: Clip.none,
children: [
ContactAvatar(
contact: recipient,
radius: 14,
displayName: _getDestinationLabel(),
),
if (sharingMode != null)
Positioned(
right: -3,
bottom: -3,
child: Container(
width: 14,
height: 14,
decoration: BoxDecoration(
color: const Color(0xFF16A34A),
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 1.5),
),
alignment: Alignment.center,
child: Icon(
Icons.location_pin,
size: 9,
color: Colors.white,
),
),
),
],
); );
} }

View File

@@ -0,0 +1,842 @@
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart';
import 'package:provider/provider.dart';
import '../models/map_layer.dart';
import '../providers/offline_tiles_provider.dart';
import '../widgets/map/polygon_draw_handler.dart';
/// Screen for downloading offline map tiles.
///
/// Allows drawing polygons/rectangles to select areas, choosing zoom levels,
/// and downloading tiles with real-time progress visualization.
class OfflineMapScreen extends StatefulWidget {
const OfflineMapScreen({super.key});
@override
State<OfflineMapScreen> createState() => _OfflineMapScreenState();
}
class _OfflineMapScreenState extends State<OfflineMapScreen> {
final MapController _mapController = MapController();
late OfflineTilesProvider _provider;
double _currentZoom = 8;
@override
void initState() {
super.initState();
_provider = context.read<OfflineTilesProvider>();
_provider.refreshCacheSize();
_provider.refreshLocalStyles();
_provider.startPeerDiscovery();
}
@override
void dispose() {
_provider.stopPeerDiscovery();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Offline Maps'),
actions: [
Consumer<OfflineTilesProvider>(
builder: (context, provider, _) {
return IconButton(
icon: Badge(
isLabelVisible: provider.discoveredPeers.isNotEmpty,
label: Text('${provider.discoveredPeers.length}'),
child: Icon(
provider.isServerRunning
? Icons.wifi_tethering
: Icons.wifi_tethering_off,
),
),
tooltip: provider.isServerRunning
? 'Sharing tiles'
: 'Tile sharing off',
onPressed: () => _showSharingSheet(context, provider),
);
},
),
Consumer<OfflineTilesProvider>(
builder: (context, provider, _) {
return PopupMenuButton<MapLayer>(
icon: const Icon(Icons.layers),
tooltip: 'Map Style',
onSelected: (layer) => provider.setSelectedLayer(layer),
itemBuilder: (_) => [
for (final layer in MapLayer.allLayers)
PopupMenuItem(
value: layer,
child: Row(
children: [
if (layer.type == provider.selectedLayer.type)
const Icon(Icons.check, size: 18)
else
const SizedBox(width: 18),
const SizedBox(width: 8),
Text(layer.name),
],
),
),
],
);
},
),
],
),
body: Stack(
children: [
Consumer<OfflineTilesProvider>(
builder: (context, provider, _) {
return FlutterMap(
mapController: _mapController,
options: MapOptions(
initialCenter: const LatLng(46.05, 14.5), // Slovenia
initialZoom: 8,
onPositionChanged: (camera, hasGesture) {
if (camera.zoom != _currentZoom) {
setState(() => _currentZoom = camera.zoom);
}
},
onTap: (tapPosition, point) {
if (provider.drawingMode != DrawingMode.none) {
provider.addVertex(point);
}
},
),
children: [
TileLayer(
urlTemplate: provider.selectedLayer.urlTemplate,
userAgentPackageName: 'com.meshcore.sar',
maxZoom: provider.selectedLayer.maxZoom,
),
CoverageLayer(currentZoom: _currentZoom),
const PolygonDrawLayer(),
const DownloadProgressLayer(),
],
);
},
),
const DrawingToolbar(),
Positioned(
left: 16,
top: 16,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface.withValues(alpha: 0.85),
borderRadius: BorderRadius.circular(8),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.15),
blurRadius: 4,
),
],
),
child: Text(
'Z ${_currentZoom.toStringAsFixed(1)}',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
),
_buildBottomPanel(),
],
),
);
}
Widget _buildBottomPanel() {
return Positioned(
left: 0,
right: 0,
bottom: 0,
child: Consumer<OfflineTilesProvider>(
builder: (context, provider, _) {
return Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius:
const BorderRadius.vertical(top: Radius.circular(16)),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.15),
blurRadius: 10,
offset: const Offset(0, -2),
),
],
),
child: SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Saved region presets dropdown
if (!provider.isDownloading &&
provider.localStyles
.any((s) => s.region != null)) ...[
DropdownButtonFormField<StyleInfo>(
decoration: const InputDecoration(
prefixIcon: Icon(Icons.bookmark, size: 20),
labelText: 'Saved regions',
isDense: true,
contentPadding: EdgeInsets.symmetric(
horizontal: 12, vertical: 8),
border: OutlineInputBorder(),
),
isExpanded: true,
hint: const Text('Load a saved region'),
items: provider.localStyles
.where((s) => s.region != null)
.map((style) => DropdownMenuItem(
value: style,
child: Text(
'${style.displayName} '
'(z${style.region!.minZoom}-${style.region!.maxZoom}, '
'${_formatNumber(style.tileCount)} tiles)',
overflow: TextOverflow.ellipsis,
),
))
.toList(),
onChanged: (style) {
if (style != null) {
provider.loadPreset(style);
_fitMapToPolygons(provider);
}
},
),
const SizedBox(height: 8),
],
// Zoom range
if (!provider.isDownloading) ...[
Row(
children: [
Expanded(
child: _ZoomSelector(
label: 'Min Zoom',
value: provider.minZoom,
onChanged: provider.setMinZoom,
),
),
const SizedBox(width: 16),
Expanded(
child: _ZoomSelector(
label: 'Max Zoom',
value: provider.maxZoom,
onChanged: provider.setMaxZoom,
),
),
],
),
const SizedBox(height: 8),
// Tile estimate
Text(
provider.hasPolygons
? '~${_formatNumber(provider.estimatedTileCount)} tiles'
: 'Draw an area to download',
style: Theme.of(context).textTheme.bodySmall,
textAlign: TextAlign.center,
),
const SizedBox(height: 4),
Text(
'Cache: ${_formatBytes(provider.cacheSizeBytes)}',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context)
.colorScheme
.onSurface
.withValues(alpha: 0.6),
),
textAlign: TextAlign.center,
),
],
// Progress
if (provider.isDownloading) ...[
LinearProgressIndicator(
value: provider.progress.percent,
),
const SizedBox(height: 8),
Text(
'${(provider.progress.percent * 100).toStringAsFixed(1)}% — '
'Downloaded: ${provider.progress.downloaded}, '
'Cached: ${provider.progress.skipped}, '
'Failed: ${provider.progress.failed} / '
'${provider.progress.total}',
style: Theme.of(context).textTheme.bodySmall,
textAlign: TextAlign.center,
),
],
// Download complete summary
if (!provider.isDownloading &&
provider.progress.isComplete) ...[
const SizedBox(height: 4),
Text(
'Done! ${provider.progress.downloaded} downloaded, '
'${provider.progress.skipped} cached, '
'${provider.progress.failed} failed',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Colors.green,
),
textAlign: TextAlign.center,
),
],
const SizedBox(height: 12),
// Action buttons
Row(
children: [
if (!provider.isDownloading) ...[
Expanded(
child: FilledButton.icon(
onPressed: provider.hasPolygons
? () => provider.startDownload()
: null,
icon: const Icon(Icons.download),
label: const Text('Download'),
),
),
if (provider.tileOverlays.isNotEmpty) ...[
const SizedBox(width: 8),
IconButton(
onPressed: provider.clearOverlays,
icon: const Icon(Icons.layers_clear),
tooltip: 'Clear overlay',
),
],
if (provider.cacheSizeBytes > 0) ...[
const SizedBox(width: 8),
IconButton(
onPressed: () => _confirmClearCache(provider),
icon: const Icon(Icons.delete_forever),
tooltip: 'Clear cache',
),
],
],
if (provider.isDownloading) ...[
Expanded(
child: OutlinedButton.icon(
onPressed: () => provider.cancelDownload(),
icon: const Icon(Icons.cancel),
label: const Text('Cancel'),
style: OutlinedButton.styleFrom(
foregroundColor: Colors.red,
),
),
),
],
],
),
],
),
),
),
);
},
),
);
}
void _showSharingSheet(BuildContext context, OfflineTilesProvider provider) {
// Refresh catalogs and local styles when opening
provider.refreshPeerCatalogs();
provider.refreshLocalStyles();
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (sheetContext) {
return DraggableScrollableSheet(
initialChildSize: 0.65,
minChildSize: 0.3,
maxChildSize: 0.9,
expand: false,
builder: (context, scrollController) {
return Consumer<OfflineTilesProvider>(
builder: (context, provider, _) {
return ListView(
controller: scrollController,
padding: const EdgeInsets.all(16),
children: [
// Header
Center(
child: Container(
width: 32,
height: 4,
margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(
color: Colors.grey[400],
borderRadius: BorderRadius.circular(2),
),
),
),
Text(
'Tile Sharing',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 12),
// Server toggle
SwitchListTile(
title: const Text('Share my tiles'),
subtitle: Text(
provider.isServerRunning
? 'Other devices can fetch tiles from this device'
: 'Start serving cached tiles to nearby devices',
),
secondary: Icon(
provider.isServerRunning
? Icons.wifi_tethering
: Icons.wifi_tethering_off,
),
value: provider.isServerRunning,
onChanged: (_) => provider.toggleServer(),
),
// My cached maps
if (provider.localStyles.isNotEmpty) ...[
const Divider(),
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Text(
'My Cached Maps',
style: Theme.of(context).textTheme.titleSmall,
),
),
...provider.localStyles.map((style) {
final isShowing =
provider.coverageStyle?.hash == style.hash;
return ListTile(
dense: true,
leading: const Icon(Icons.map, size: 20),
title: Text(style.displayName),
subtitle: Text(
'${_formatNumber(style.tileCount)} tiles, '
'${_formatBytes(style.sizeBytes)}',
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: Icon(
isShowing
? Icons.visibility
: Icons.visibility_off,
size: 20,
color: isShowing
? Colors.blue
: null,
),
tooltip: isShowing
? 'Hide on map'
: 'Show on map',
onPressed: () {
provider.showCoverage(style);
},
),
IconButton(
icon: const Icon(Icons.delete_outline,
size: 20),
tooltip: 'Delete',
onPressed: () => _confirmDeleteStyle(
context, provider, style),
),
],
),
);
}),
],
// Peers section
const Divider(),
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
children: [
Expanded(
child: Text(
'Nearby Devices',
style: Theme.of(context).textTheme.titleSmall,
),
),
if (provider.isFetchingCatalogs)
const SizedBox(
width: 16,
height: 16,
child:
CircularProgressIndicator(strokeWidth: 2),
)
else
IconButton(
icon: const Icon(Icons.refresh, size: 20),
tooltip: 'Refresh',
onPressed: () =>
provider.refreshPeerCatalogs(),
),
IconButton(
icon: const Icon(Icons.add, size: 20),
tooltip: 'Add peer manually',
onPressed: () =>
_showAddPeerDialog(context, provider),
),
],
),
),
if (provider.discoveredPeers.isEmpty)
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Text(
'No peers found on the local network.\n'
'Make sure other devices have tile sharing enabled.',
style: Theme.of(context)
.textTheme
.bodySmall
?.copyWith(
color: Theme.of(context)
.colorScheme
.onSurface
.withValues(alpha: 0.6),
),
),
),
// Peer catalogs — show each peer and their available styles
...provider.peerCatalogs.map((catalog) => _buildPeerCard(
context, provider, catalog)),
// Peers without catalogs yet (just discovered, not queried)
...provider.discoveredPeers
.where((peer) => !provider.peerCatalogs
.any((c) => c.peer == peer))
.map((peer) => ListTile(
leading: const Icon(Icons.devices),
title: Text(peer.ipAddress),
subtitle: const Text('Fetching catalog...'),
trailing: IconButton(
icon: const Icon(
Icons.remove_circle_outline,
size: 20),
onPressed: () =>
provider.removePeer(peer),
),
)),
// Sync progress
if (provider.isSyncing || provider.syncStatus.isNotEmpty) ...[
const Divider(),
if (provider.isSyncing)
LinearProgressIndicator(
value: provider.syncProgress > 0
? provider.syncProgress
: null,
),
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
children: [
Expanded(
child: Text(
provider.syncStatus,
style: Theme.of(context).textTheme.bodySmall,
),
),
if (provider.isSyncing)
TextButton(
onPressed: () => provider.cancelSync(),
child: const Text('Cancel'),
),
],
),
),
],
],
);
},
);
},
);
},
);
}
Widget _buildPeerCard(
BuildContext context,
OfflineTilesProvider provider,
PeerCatalog catalog,
) {
return Card(
margin: const EdgeInsets.symmetric(vertical: 4),
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.devices, size: 18),
const SizedBox(width: 8),
Expanded(
child: Text(
catalog.peer.ipAddress,
style: Theme.of(context).textTheme.titleSmall,
),
),
IconButton(
icon: const Icon(Icons.remove_circle_outline, size: 18),
onPressed: () => provider.removePeer(catalog.peer),
visualDensity: VisualDensity.compact,
),
],
),
if (catalog.styles.isEmpty)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
'No cached tiles on this device',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context)
.colorScheme
.onSurface
.withValues(alpha: 0.6),
),
),
),
...catalog.styles.map((style) {
// Check if we already have this style locally
final localMatch = provider.localStyles
.where((s) => s.hash == style.hash);
final localCount =
localMatch.isNotEmpty ? localMatch.first.tileCount : 0;
final missingTiles = style.tileCount - localCount;
return ListTile(
dense: true,
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.map_outlined, size: 20),
title: Text(style.displayName),
subtitle: Text(
'${_formatNumber(style.tileCount)} tiles, '
'${_formatBytes(style.sizeBytes)}'
'${localCount > 0 ? ' (you have ${_formatNumberInline(localCount)})' : ''}',
),
trailing: missingTiles > 0
? TextButton.icon(
onPressed: provider.isSyncing
? null
: () => provider.syncStyleFromPeers(style),
icon: const Icon(Icons.download, size: 16),
label: Text(
missingTiles == style.tileCount
? 'Get all'
: '+${_formatNumber(missingTiles)}',
),
)
: const Icon(Icons.check_circle,
color: Colors.green, size: 20),
);
}),
],
),
),
);
}
String _formatNumberInline(int n) {
if (n < 1000) return '$n';
if (n < 1000000) return '${(n / 1000).toStringAsFixed(1)}K';
return '${(n / 1000000).toStringAsFixed(1)}M';
}
void _fitMapToPolygons(OfflineTilesProvider provider) {
if (provider.polygons.isEmpty) return;
var minLat = 90.0, maxLat = -90.0;
var minLng = 180.0, maxLng = -180.0;
for (final poly in provider.polygons) {
for (final p in poly) {
if (p.latitude < minLat) minLat = p.latitude;
if (p.latitude > maxLat) maxLat = p.latitude;
if (p.longitude < minLng) minLng = p.longitude;
if (p.longitude > maxLng) maxLng = p.longitude;
}
}
_mapController.fitCamera(
CameraFit.bounds(
bounds: LatLngBounds(
LatLng(minLat, minLng),
LatLng(maxLat, maxLng),
),
padding: const EdgeInsets.all(50),
),
);
}
void _showAddPeerDialog(
BuildContext context, OfflineTilesProvider provider) {
final controller = TextEditingController();
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Add Peer'),
content: TextField(
controller: controller,
decoration: const InputDecoration(
labelText: 'IP Address',
hintText: '192.168.1.100',
),
keyboardType: TextInputType.number,
autofocus: true,
onSubmitted: (value) {
if (value.trim().isNotEmpty) {
provider.addManualPeer(value.trim());
Navigator.pop(context);
}
},
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
TextButton(
onPressed: () {
final ip = controller.text.trim();
if (ip.isNotEmpty) {
provider.addManualPeer(ip);
Navigator.pop(context);
}
},
child: const Text('Add'),
),
],
),
);
}
void _confirmDeleteStyle(
BuildContext context,
OfflineTilesProvider provider,
StyleInfo style,
) {
showDialog(
context: context,
builder: (dialogContext) => AlertDialog(
title: Text('Delete ${style.displayName}?'),
content: Text(
'${_formatNumber(style.tileCount)} tiles, '
'${_formatBytes(style.sizeBytes)} will be deleted.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext),
child: const Text('Cancel'),
),
TextButton(
onPressed: () {
provider.deleteStyle(style);
Navigator.pop(dialogContext);
},
child:
const Text('Delete', style: TextStyle(color: Colors.red)),
),
],
),
);
}
void _confirmClearCache(OfflineTilesProvider provider) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Clear offline cache?'),
content: Text(
'This will delete ${_formatBytes(provider.cacheSizeBytes)} of cached tiles.'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
TextButton(
onPressed: () {
provider.clearCache();
Navigator.pop(context);
},
child:
const Text('Delete', style: TextStyle(color: Colors.red)),
),
],
),
);
}
String _formatBytes(int bytes) {
if (bytes < 1024) return '$bytes B';
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
if (bytes < 1024 * 1024 * 1024) {
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
}
return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB';
}
String _formatNumber(int n) {
if (n < 1000) return '$n';
if (n < 1000000) return '${(n / 1000).toStringAsFixed(1)}K';
return '${(n / 1000000).toStringAsFixed(1)}M';
}
}
class _ZoomSelector extends StatelessWidget {
final String label;
final int value;
final ValueChanged<int> onChanged;
const _ZoomSelector({
required this.label,
required this.value,
required this.onChanged,
});
@override
Widget build(BuildContext context) {
return Row(
children: [
Text(label, style: Theme.of(context).textTheme.bodySmall),
const SizedBox(width: 8),
Expanded(
child: Slider(
value: value.toDouble(),
min: 0,
max: 19,
divisions: 19,
label: '$value',
onChanged: (v) => onChanged(v.round()),
),
),
SizedBox(
width: 24,
child: Text(
'$value',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
),
],
);
}
}

View File

@@ -8,6 +8,7 @@ import '../providers/connection_provider.dart';
import '../providers/contacts_provider.dart'; import '../providers/contacts_provider.dart';
import '../providers/map_provider.dart'; import '../providers/map_provider.dart';
import '../providers/sensors_provider.dart'; import '../providers/sensors_provider.dart';
import '../widgets/contacts/ping_contact_sheet.dart';
import '../widgets/sensors/bthome_met_history_sheet.dart'; import '../widgets/sensors/bthome_met_history_sheet.dart';
import '../widgets/sensors/sensor_telemetry_card.dart'; import '../widgets/sensors/sensor_telemetry_card.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
@@ -337,6 +338,7 @@ class _SensorsTabState extends State<SensorsTab> {
child: SensorTelemetryCard( child: SensorTelemetryCard(
contact: contact, contact: contact,
state: sensorsProvider.stateFor(key), state: sensorsProvider.stateFor(key),
showActionSheetOnTap: true,
visibleFields: visibleFields, visibleFields: visibleFields,
fieldOrder: sensorsProvider.metricOrderFor( fieldOrder: sensorsProvider.metricOrderFor(
key, key,
@@ -369,6 +371,23 @@ class _SensorsTabState extends State<SensorsTab> {
contactsProvider: contactsProvider, contactsProvider: contactsProvider,
connectionProvider: connectionProvider, connectionProvider: connectionProvider,
), ),
onPing: contact == null
? null
: () async {
await showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
backgroundColor:
Theme.of(context).colorScheme.surface,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(
top: Radius.circular(16),
),
),
builder: (context) =>
PingContactSheet(contact: contact),
);
},
), ),
), ),
); );

View File

@@ -38,6 +38,7 @@ import '../utils/voice_message_parser.dart';
import '../theme/app_theme.dart'; import '../theme/app_theme.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
import '../widgets/update_dialog.dart'; import '../widgets/update_dialog.dart';
import '../widgets/settings/traffic_stats_reporting_section.dart';
import 'sar_template_management_screen.dart'; import 'sar_template_management_screen.dart';
import 'profiles_screen.dart'; import 'profiles_screen.dart';
import 'welcome_wizard_screen.dart'; import 'welcome_wizard_screen.dart';
@@ -419,6 +420,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
Future<void> _setFastLocationUpdatesEnabled(bool enabled) async { Future<void> _setFastLocationUpdatesEnabled(bool enabled) async {
await _locationService.setFastLocationUpdatesEnabled(enabled); await _locationService.setFastLocationUpdatesEnabled(enabled);
if (!mounted) return; if (!mounted) return;
context.read<AppProvider>().notifyChannelLocationSharingChanged();
setState(() { setState(() {
_fastLocationUpdatesEnabled = _locationService.fastLocationUpdatesEnabled; _fastLocationUpdatesEnabled = _locationService.fastLocationUpdatesEnabled;
}); });
@@ -592,6 +594,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
selected < 0 ? null : selected, selected < 0 ? null : selected,
); );
if (!mounted) return; if (!mounted) return;
context.read<AppProvider>().notifyChannelLocationSharingChanged();
setState(() { setState(() {
_fastLocationChannelIdx = _locationService.fastLocationChannelIdx; _fastLocationChannelIdx = _locationService.fastLocationChannelIdx;
}); });
@@ -1485,19 +1488,6 @@ 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: Icon(Icons.swap_horiz),
title: Text(AppLocalizations.of(context)!.autoRouteRotation),
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>( Consumer<AppProvider>(
builder: (context, appProvider, child) => SwitchListTile( builder: (context, appProvider, child) => SwitchListTile(
secondary: Icon(Icons.route), secondary: Icon(Icons.route),
@@ -2162,6 +2152,14 @@ class _SettingsScreenState extends State<SettingsScreen> {
), ),
_buildSectionHeader('Developer & Data'), _buildSectionHeader('Developer & Data'),
_buildSettingsCard([ _buildSettingsCard([
Consumer<AppProvider>(
builder: (context, appProvider, child) => ListenableBuilder(
listenable: appProvider.trafficStatsReportingService,
builder: (context, child) => TrafficStatsReportingSection(
service: appProvider.trafficStatsReportingService,
),
),
),
ListTile( ListTile(
leading: Icon(Icons.bug_report), leading: Icon(Icons.bug_report),
title: Text(AppLocalizations.of(context)!.packageName), title: Text(AppLocalizations.of(context)!.packageName),

View File

@@ -23,7 +23,6 @@ class AppConfigSnapshotService {
voiceEchoCancellationEnabled: appProvider.isVoiceEchoCancellationEnabled, voiceEchoCancellationEnabled: appProvider.isVoiceEchoCancellationEnabled,
voiceNoiseSuppressionEnabled: appProvider.isVoiceNoiseSuppressionEnabled, voiceNoiseSuppressionEnabled: appProvider.isVoiceNoiseSuppressionEnabled,
messageFontScale: appProvider.messageFontScale, messageFontScale: appProvider.messageFontScale,
autoRouteRotationEnabled: appProvider.autoRouteRotationEnabled,
clearPathOnMaxRetry: appProvider.clearPathOnMaxRetry, clearPathOnMaxRetry: appProvider.clearPathOnMaxRetry,
nearestRelayFallbackEnabled: appProvider.nearestRelayFallbackEnabled, nearestRelayFallbackEnabled: appProvider.nearestRelayFallbackEnabled,
voiceBitrate: await VoiceBitratePreferences.getBitrate(), voiceBitrate: await VoiceBitratePreferences.getBitrate(),
@@ -98,11 +97,6 @@ class AppConfigSnapshotService {
if (section.messageFontScale != null) { if (section.messageFontScale != null) {
await appProvider.setMessageFontScale(section.messageFontScale!); await appProvider.setMessageFontScale(section.messageFontScale!);
} }
if (section.autoRouteRotationEnabled != null) {
await appProvider.toggleAutoRouteRotationEnabled(
section.autoRouteRotationEnabled!,
);
}
if (section.clearPathOnMaxRetry != null) { if (section.clearPathOnMaxRetry != null) {
await appProvider.toggleClearPathOnMaxRetry(section.clearPathOnMaxRetry!); await appProvider.toggleClearPathOnMaxRetry(section.clearPathOnMaxRetry!);
} }

View File

@@ -2,30 +2,20 @@ import 'package:shared_preferences/shared_preferences.dart';
import 'profiles_feature_service.dart'; import 'profiles_feature_service.dart';
class MessagingRoutePreferences { class MessagingRoutePreferences {
static const bool defaultAutoRouteRotationEnabled = false;
static const bool defaultClearPathOnMaxRetry = false; static const bool defaultClearPathOnMaxRetry = false;
static const bool defaultNearestRelayFallbackEnabled = true; static const bool defaultNearestRelayFallbackEnabled = true;
static const String _autoRouteRotationKey = static const String _legacyAutoRouteRotationKey =
'messaging_auto_route_rotation_enabled'; 'messaging_auto_route_rotation_enabled';
static const String _clearPathOnMaxRetryKey = static const String _clearPathOnMaxRetryKey =
'messaging_clear_path_on_max_retry'; 'messaging_clear_path_on_max_retry';
static const String _nearestRelayFallbackKey = static const String _nearestRelayFallbackKey =
'messaging_nearest_relay_fallback_enabled'; 'messaging_nearest_relay_fallback_enabled';
static Future<bool> getAutoRouteRotationEnabled() async { static Future<void> cleanupLegacySettings() async {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
return prefs.getBool( await prefs.remove(
ProfileStorageScope.scopedKey(_autoRouteRotationKey), ProfileStorageScope.scopedKey(_legacyAutoRouteRotationKey),
) ??
defaultAutoRouteRotationEnabled;
}
static Future<void> setAutoRouteRotationEnabled(bool enabled) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(
ProfileStorageScope.scopedKey(_autoRouteRotationKey),
enabled,
); );
} }

View File

@@ -0,0 +1,115 @@
import 'dart:typed_data';
import 'package:flutter/foundation.dart';
import 'package:flutter_map/flutter_map.dart';
import 'offline_tile_cache_service.dart';
import 'tile_sharing_service.dart';
/// A [MapCachingProvider] that checks the offline AVIF tile cache (and
/// optionally peers) before falling through to the built-in cache.
///
/// This allows preloaded tiles to be served during normal map browsing.
class OfflineMapCachingProvider implements MapCachingProvider {
final MapCachingProvider _delegate;
final OfflineTileCacheService _cache = OfflineTileCacheService.instance;
final TileSharingService _sharing = TileSharingService.instance;
OfflineMapCachingProvider(this._delegate);
@override
bool get isSupported => true;
@override
Future<CachedMapTile?> getTile(String url) async {
// Extract tile coordinates from URL to check our AVIF cache
final coords = _parseTileUrl(url);
if (coords != null) {
final styleHash = _cache.styleHashFromUrl(_extractUrlTemplate(url));
// Check local AVIF cache first
final pngBytes = await _cache.getTileAsPng(
styleHash, coords.z, coords.x, coords.y);
if (pngBytes != null) {
return (
bytes: pngBytes,
metadata: CachedMapTileMetadata(
staleAt: DateTime.now().add(const Duration(days: 365)),
lastModified: null,
etag: null,
),
);
}
// Try peers
if (_sharing.discoveredPeers.isNotEmpty) {
final avifBytes = await _sharing.fetchFromAnyPeer(
styleHash, coords.z, coords.x, coords.y);
if (avifBytes != null) {
// Cache locally for next time
await _cache.putRawTile(
styleHash, coords.z, coords.x, coords.y, avifBytes);
final decoded = await OfflineTileCacheService.getTileAsPngStatic(avifBytes);
if (decoded != null) {
return (
bytes: decoded,
metadata: CachedMapTileMetadata(
staleAt: DateTime.now().add(const Duration(days: 365)),
lastModified: null,
etag: null,
),
);
}
}
}
}
// Fall through to delegate (built-in cache)
return _delegate.getTile(url);
}
@override
Future<void> putTile({
required String url,
required CachedMapTileMetadata metadata,
Uint8List? bytes,
}) {
// Only delegate to built-in cache for normal browsing tiles
return _delegate.putTile(url: url, metadata: metadata, bytes: bytes);
}
/// Parse z/x/y from a tile URL.
static _TileCoords? _parseTileUrl(String url) {
// Match common patterns: /{z}/{x}/{y}.png, /tile/{z}/{y}/{x}, etc.
final patterns = [
RegExp(r'/(\d+)/(\d+)/(\d+)\.(?:png|jpg|jpeg|webp)'),
RegExp(r'/(\d+)/(\d+)/(\d+)$'),
];
for (final pattern in patterns) {
final match = pattern.firstMatch(url);
if (match != null) {
return _TileCoords(
z: int.parse(match.group(1)!),
x: int.parse(match.group(2)!),
y: int.parse(match.group(3)!),
);
}
}
return null;
}
/// Extract a URL template from a concrete URL by replacing coordinates.
static String _extractUrlTemplate(String url) {
// Replace the last three numeric path segments with placeholders
return url.replaceAllMapped(
RegExp(r'/(\d+)/(\d+)/(\d+)(\.(?:png|jpg|jpeg|webp))?$'),
(m) => '/{z}/{x}/{y}${m.group(4) ?? ''}',
);
}
}
class _TileCoords {
final int z, x, y;
const _TileCoords({required this.z, required this.x, required this.y});
}

View File

@@ -0,0 +1,481 @@
import 'dart:convert';
import 'dart:io';
import 'dart:ui' as ui;
import 'package:crypto/crypto.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_avif/flutter_avif.dart';
import 'package:path_provider/path_provider.dart';
/// A saved download region (polygons + zoom range) for quick re-download.
class DownloadRegion {
final List<List<List<double>>> polygons; // [polygon][vertex][lat, lng]
final int minZoom;
final int maxZoom;
const DownloadRegion({
required this.polygons,
required this.minZoom,
required this.maxZoom,
});
Map<String, dynamic> toJson() => {
'polygons': polygons,
'minZoom': minZoom,
'maxZoom': maxZoom,
};
factory DownloadRegion.fromJson(Map<String, dynamic> json) {
final rawPolygons = json['polygons'] as List<dynamic>? ?? [];
final polygons = rawPolygons.map<List<List<double>>>((poly) {
return (poly as List<dynamic>).map<List<double>>((vertex) {
final v = vertex as List<dynamic>;
return [
(v[0] as num).toDouble(),
(v[1] as num).toDouble(),
];
}).toList();
}).toList();
return DownloadRegion(
polygons: polygons,
minZoom: json['minZoom'] as int? ?? 8,
maxZoom: json['maxZoom'] as int? ?? 14,
);
}
}
/// Metadata about a cached map style.
class StyleInfo {
final String hash;
final String displayName;
final String urlTemplate;
final int tileCount;
final int sizeBytes;
final DownloadRegion? region;
const StyleInfo({
required this.hash,
required this.displayName,
required this.urlTemplate,
this.tileCount = 0,
this.sizeBytes = 0,
this.region,
});
Map<String, dynamic> toJson() => {
'hash': hash,
'displayName': displayName,
'urlTemplate': urlTemplate,
'tileCount': tileCount,
'sizeBytes': sizeBytes,
if (region != null) 'region': region!.toJson(),
};
factory StyleInfo.fromJson(Map<String, dynamic> json) => StyleInfo(
hash: json['hash'] as String,
displayName: json['displayName'] as String? ?? json['hash'] as String,
urlTemplate: json['urlTemplate'] as String? ?? '',
tileCount: json['tileCount'] as int? ?? 0,
sizeBytes: json['sizeBytes'] as int? ?? 0,
region: json['region'] != null
? DownloadRegion.fromJson(json['region'] as Map<String, dynamic>)
: null,
);
}
/// A tile coordinate in the cache (z/x/y).
class CachedTileCoord {
final int z, x, y;
const CachedTileCoord(this.z, this.x, this.y);
Map<String, int> toJson() => {'z': z, 'x': x, 'y': y};
factory CachedTileCoord.fromJson(Map<String, dynamic> json) =>
CachedTileCoord(
json['z'] as int,
json['x'] as int,
json['y'] as int,
);
}
/// Manages the offline AVIF tile cache on disk.
///
/// Tiles are stored as `{baseDir}/offline_tiles/{styleHash}/{z}/{x}/{y}.avif`.
/// Style metadata is stored as `{baseDir}/offline_tiles/{styleHash}/meta.json`.
/// This cache is separate from flutter_map's built-in cache and is used for
/// proactively downloaded tiles and WiFi sharing.
class OfflineTileCacheService {
OfflineTileCacheService._();
static final instance = OfflineTileCacheService._();
String? _baseDir;
Future<String> get baseDir async {
if (_baseDir != null) return _baseDir!;
final docs = await getApplicationDocumentsDirectory();
_baseDir = '${docs.path}/offline_tiles';
return _baseDir!;
}
/// Derive a short deterministic hash from a URL template.
String styleHashFromUrl(String urlTemplate) {
final bytes = sha256.convert(urlTemplate.codeUnits).bytes;
return bytes
.take(6)
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join();
}
String _tilePath(String base, String styleHash, int z, int x, int y) {
return '$base/$styleHash/$z/$x/$y.avif';
}
String _tileDir(String base, String styleHash, int z, int x) {
return '$base/$styleHash/$z/$x';
}
// In-memory manifest cache: styleHash → set of "z/x/y" keys.
// Loaded lazily, kept in sync with writes.
final Map<String, Set<String>> _manifests = {};
static String _tileKey(int z, int x, int y) => '$z/$x/$y';
String _manifestPath(String base, String styleHash) =>
'$base/$styleHash/manifest.txt';
/// Load the manifest for a style into memory (if not already loaded).
Future<Set<String>> loadManifest(String styleHash) async {
if (_manifests.containsKey(styleHash)) return _manifests[styleHash]!;
final base = await baseDir;
final file = File(_manifestPath(base, styleHash));
final Set<String> manifest;
if (await file.exists()) {
final lines = await file.readAsLines();
manifest = lines.where((l) => l.isNotEmpty).toSet();
} else {
// First time — scan the filesystem and build the manifest
manifest = {};
final styleDir = Directory('$base/$styleHash');
if (await styleDir.exists()) {
final avifPattern = RegExp(r'/(\d+)/(\d+)/(\d+)\.avif$');
await for (final entity in styleDir.list(recursive: true)) {
if (entity is! File) continue;
final match = avifPattern.firstMatch(entity.path);
if (match != null) {
manifest.add('${match.group(1)}/${match.group(2)}/${match.group(3)}');
}
}
// Persist the scanned manifest
await _writeManifest(base, styleHash, manifest);
}
}
_manifests[styleHash] = manifest;
return manifest;
}
Future<void> _writeManifest(
String base, String styleHash, Set<String> manifest) async {
final dir = Directory('$base/$styleHash');
if (!await dir.exists()) await dir.create(recursive: true);
await File(_manifestPath(base, styleHash))
.writeAsString(manifest.join('\n'), flush: true);
}
/// Append a tile key to the manifest (both in-memory and on disk).
Future<void> _addToManifest(
String base, String styleHash, String key) async {
_manifests[styleHash] ??= {};
if (_manifests[styleHash]!.add(key)) {
final file = File(_manifestPath(base, styleHash));
await file.writeAsString('$key\n',
mode: FileMode.append, flush: true);
}
}
/// Check if a tile exists in the cache (uses in-memory manifest).
Future<bool> hasTile(String styleHash, int z, int x, int y) async {
final manifest = await loadManifest(styleHash);
return manifest.contains(_tileKey(z, x, y));
}
/// Read a cached tile's raw AVIF bytes (for serving to peers).
Future<Uint8List?> getRawTile(String styleHash, int z, int x, int y) async {
final base = await baseDir;
final file = File(_tilePath(base, styleHash, z, x, y));
if (!await file.exists()) return null;
return file.readAsBytes();
}
/// Read a cached tile and decode AVIF → PNG bytes for flutter_map display.
Future<Uint8List?> getTileAsPng(
String styleHash, int z, int x, int y) async {
final avifBytes = await getRawTile(styleHash, z, x, y);
if (avifBytes == null) return null;
return _avifToPng(avifBytes);
}
/// Store a tile: encode PNG bytes → AVIF, write to disk, update manifest.
Future<void> putTile(
String styleHash,
int z,
int x,
int y,
Uint8List pngBytes,
) async {
final base = await baseDir;
final dir = Directory(_tileDir(base, styleHash, z, x));
if (!await dir.exists()) {
await dir.create(recursive: true);
}
final avifBytes = await _pngToAvif(pngBytes);
if (avifBytes == null) {
await File(_tilePath(base, styleHash, z, x, y))
.writeAsBytes(pngBytes, flush: true);
} else {
await File(_tilePath(base, styleHash, z, x, y))
.writeAsBytes(avifBytes, flush: true);
}
await _addToManifest(base, styleHash, _tileKey(z, x, y));
}
/// Store raw AVIF bytes directly (from a peer), update manifest.
Future<void> putRawTile(
String styleHash,
int z,
int x,
int y,
Uint8List avifBytes,
) async {
final base = await baseDir;
final dir = Directory(_tileDir(base, styleHash, z, x));
if (!await dir.exists()) {
await dir.create(recursive: true);
}
await File(_tilePath(base, styleHash, z, x, y))
.writeAsBytes(avifBytes, flush: true);
await _addToManifest(base, styleHash, _tileKey(z, x, y));
}
/// Get total cache size in bytes.
Future<int> getCacheSize() async {
final base = await baseDir;
final dir = Directory(base);
if (!await dir.exists()) return 0;
var totalSize = 0;
await for (final entity in dir.list(recursive: true)) {
if (entity is File) {
totalSize += await entity.length();
}
}
return totalSize;
}
/// List all style hashes that have cached tiles.
Future<List<String>> listStyles() async {
final base = await baseDir;
final dir = Directory(base);
if (!await dir.exists()) return [];
final styles = <String>[];
await for (final entity in dir.list()) {
if (entity is Directory) {
styles.add(entity.path.split('/').last);
}
}
return styles;
}
/// Save metadata for a style (name, URL template, download region).
Future<void> saveStyleMeta(
String styleHash, {
required String displayName,
required String urlTemplate,
DownloadRegion? region,
}) async {
final base = await baseDir;
final dir = Directory('$base/$styleHash');
if (!await dir.exists()) {
await dir.create(recursive: true);
}
// Merge with existing meta to preserve region if not provided
final metaFile = File('$base/$styleHash/meta.json');
Map<String, dynamic> meta = {
'displayName': displayName,
'urlTemplate': urlTemplate,
};
if (region != null) {
meta['region'] = region.toJson();
} else if (await metaFile.exists()) {
try {
final existing = jsonDecode(await metaFile.readAsString());
if (existing['region'] != null) {
meta['region'] = existing['region'];
}
} catch (_) {}
}
await metaFile.writeAsString(jsonEncode(meta), flush: true);
}
/// Read metadata for a style.
Future<StyleInfo?> getStyleMeta(String styleHash) async {
final base = await baseDir;
final metaFile = File('$base/$styleHash/meta.json');
if (!await metaFile.exists()) return null;
try {
final json = jsonDecode(await metaFile.readAsString());
return StyleInfo(
hash: styleHash,
displayName: json['displayName'] as String? ?? styleHash,
urlTemplate: json['urlTemplate'] as String? ?? '',
region: json['region'] != null
? DownloadRegion.fromJson(json['region'] as Map<String, dynamic>)
: null,
);
} catch (_) {
return null;
}
}
/// List all styles with metadata, tile counts, and sizes.
Future<List<StyleInfo>> listStylesDetailed() async {
final base = await baseDir;
final dir = Directory(base);
if (!await dir.exists()) return [];
final results = <StyleInfo>[];
await for (final entity in dir.list()) {
if (entity is! Directory) continue;
final hash = entity.path.split('/').last;
// Read meta
String displayName = hash;
String urlTemplate = '';
DownloadRegion? region;
final metaFile = File('${entity.path}/meta.json');
if (await metaFile.exists()) {
try {
final json = jsonDecode(await metaFile.readAsString());
displayName = json['displayName'] as String? ?? hash;
urlTemplate = json['urlTemplate'] as String? ?? '';
if (json['region'] != null) {
region = DownloadRegion.fromJson(
json['region'] as Map<String, dynamic>);
}
} catch (_) {}
}
// Count tiles and size
var tileCount = 0;
var sizeBytes = 0;
await for (final file in entity.list(recursive: true)) {
if (file is File && file.path.endsWith('.avif')) {
tileCount++;
sizeBytes += await file.length();
}
}
results.add(StyleInfo(
hash: hash,
displayName: displayName,
urlTemplate: urlTemplate,
tileCount: tileCount,
sizeBytes: sizeBytes,
region: region,
));
}
return results;
}
/// List all tile coordinates cached for a given style.
Future<List<CachedTileCoord>> listTilesForStyle(String styleHash) async {
final base = await baseDir;
final styleDir = Directory('$base/$styleHash');
if (!await styleDir.exists()) return [];
final tiles = <CachedTileCoord>[];
final avifPattern = RegExp(r'/(\d+)/(\d+)/(\d+)\.avif$');
await for (final entity in styleDir.list(recursive: true)) {
if (entity is! File) continue;
final match = avifPattern.firstMatch(entity.path);
if (match != null) {
tiles.add(CachedTileCoord(
int.parse(match.group(1)!),
int.parse(match.group(2)!),
int.parse(match.group(3)!),
));
}
}
return tiles;
}
/// Delete a single style's tiles, manifest, and metadata.
Future<void> deleteStyle(String styleHash) async {
_manifests.remove(styleHash);
final base = await baseDir;
final dir = Directory('$base/$styleHash');
if (await dir.exists()) {
await dir.delete(recursive: true);
}
}
/// Delete all cached tiles, manifests, and metadata.
Future<void> clearCache() async {
_manifests.clear();
final base = await baseDir;
final dir = Directory(base);
if (await dir.exists()) {
await dir.delete(recursive: true);
}
}
/// Decode AVIF bytes to PNG (static helper for use by caching provider).
static Future<Uint8List?> getTileAsPngStatic(Uint8List avifBytes) {
return _avifToPng(avifBytes);
}
/// Encode PNG → AVIF for tile storage.
/// Uses moderate quality for good compression with acceptable quality.
static Future<Uint8List?> _pngToAvif(Uint8List pngBytes) async {
try {
final avif = await encodeAvif(
pngBytes,
maxThreads: 2,
maxQuantizer: 40, // Good quality (0=lossless, 63=worst)
minQuantizer: 25,
maxQuantizerAlpha: 63,
minQuantizerAlpha: 63,
speed: 6,
keepExif: false,
);
if (avif.isEmpty) return null;
return avif;
} catch (e) {
debugPrint('[OfflineTileCache] AVIF encode error: $e');
return null;
}
}
/// Decode AVIF → PNG bytes for display.
static Future<Uint8List?> _avifToPng(Uint8List avifBytes) async {
try {
// Use Flutter's image codec which can handle AVIF via flutter_avif
final codec = await ui.instantiateImageCodec(avifBytes);
final frame = await codec.getNextFrame();
final image = frame.image;
final byteData =
await image.toByteData(format: ui.ImageByteFormat.png);
image.dispose();
return byteData?.buffer.asUint8List();
} catch (e) {
debugPrint('[OfflineTileCache] AVIF decode error: $e');
return null;
}
}
}

View File

@@ -1,427 +1,148 @@
import 'dart:convert'; import 'dart:convert';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:geolocator/geolocator.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import '../models/contact.dart'; import '../models/contact.dart';
import '../models/path_history.dart';
import '../models/path_selection.dart'; import '../models/path_selection.dart';
import '../utils/log_rx_route_decoder.dart';
class _ManualPathSelectionRecord {
final List<int> pathBytes;
final int hopCount;
final int hashSize;
const _ManualPathSelectionRecord({
required this.pathBytes,
required this.hopCount,
required this.hashSize,
});
factory _ManualPathSelectionRecord.fromJson(Map<String, dynamic> json) {
final pathBytes = (json['pathBytes'] as List<dynamic>? ?? const <dynamic>[])
.whereType<int>()
.toList();
return _ManualPathSelectionRecord(
pathBytes: pathBytes,
hopCount: json['hopCount'] as int? ?? 0,
hashSize: json['hashSize'] as int? ?? 1,
);
}
Map<String, dynamic> toJson() => {
'pathBytes': pathBytes,
'hopCount': hopCount,
'hashSize': hashSize,
};
PathSelection toSelection() => PathSelection(
mode: PathSelectionMode.directCurrent,
pathBytes: Uint8List.fromList(pathBytes),
hopCount: hopCount,
hashSize: hashSize,
);
}
class PathHistoryService { class PathHistoryService {
static const String _storageKey = 'contact_path_history_v2'; static const String _legacyStorageKey = 'contact_path_history_v2';
static const int _maxDirectPaths = 20; static const String _manualRouteStorageKey =
static const int _topRotationCount = 3; 'contact_manual_path_overrides_v1';
final Map<String, ContactPathHistory> _cache = {}; final Map<String, _ManualPathSelectionRecord> _manualSelections = {};
bool _isLoaded = false; bool _isLoaded = false;
Future<void> initialize() async { Future<void> initialize() async {
if (_isLoaded) return; if (_isLoaded) return;
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
final raw = prefs.getString(_storageKey); await prefs.remove(_legacyStorageKey);
if (raw == null || raw.isEmpty) {
_isLoaded = true;
return;
}
final manualRaw = prefs.getString(_manualRouteStorageKey);
try { try {
final decoded = jsonDecode(raw); if (manualRaw != null && manualRaw.isNotEmpty) {
if (decoded is Map<String, dynamic>) { final decoded = jsonDecode(manualRaw);
for (final entry in decoded.entries) { if (decoded is Map<String, dynamic>) {
final value = entry.value; for (final entry in decoded.entries) {
if (value is Map<String, dynamic>) { final value = entry.value;
_cache[entry.key] = ContactPathHistory.fromJson(entry.key, value); if (value is Map<String, dynamic>) {
_manualSelections[entry.key] =
_ManualPathSelectionRecord.fromJson(value);
}
} }
} }
} }
} catch (error) { } catch (error) {
debugPrint('⚠️ [PathHistoryService] Failed to load history: $error'); debugPrint(
'⚠️ [PathHistoryService] Failed to load manual routes: $error',
);
} }
_isLoaded = true; _isLoaded = true;
} }
Future<void> recordLearnedPath(Contact contact) async { Future<PathSelection> getSelectionForContact(Contact contact) async {
await initialize(); await initialize();
if (!contact.routeHasPath || contact.routeHopCount <= 0) { final manualSelection = _manualSelections[contact.publicKeyHex];
return; if (manualSelection != null) {
return manualSelection.toSelection();
} }
final history = _historyFor(contact.publicKeyHex); final route = ContactRouteCodec.fromContact(contact);
final signature = _signature(contact.routePathBytes); if (route == null) {
final existing = _findDirectPath(history.directPaths, signature); return PathSelection.flood();
final updated = PathRecord( }
pathBytes: contact.routePathBytes.toList(),
hopCount: contact.routeHopCount,
hashSize: contact.routeHashSize,
source: existing?.source ?? PathRecordSource.learned,
successCount: existing?.successCount ?? 0,
failureCount: existing?.failureCount ?? 0,
lastRoundTripTimeMs: existing?.lastRoundTripTimeMs ?? 0,
lastUsedAt: DateTime.now(),
lastSucceededAt: existing?.lastSucceededAt,
senderLatitude: existing?.senderLatitude,
senderLongitude: existing?.senderLongitude,
recipientLatitude: existing?.recipientLatitude,
recipientLongitude: existing?.recipientLongitude,
);
await _saveHistory( return PathSelection(
mode: PathSelectionMode.directCurrent,
pathBytes: Uint8List.fromList(route.pathBytes),
hopCount: route.hopCount,
hashSize: route.hashSize,
);
}
Future<void> setManualRouteForContact(
Contact contact,
ParsedContactRoute route,
) async {
await setManualSelectionFor(
contact.publicKeyHex, contact.publicKeyHex,
history.copyWith( PathSelection(
directPaths: _upsertDirectPath(history.directPaths, updated), mode: PathSelectionMode.directCurrent,
pathBytes: Uint8List.fromList(route.pathBytes),
hopCount: route.hopCount,
hashSize: route.hashSize,
), ),
); );
} }
Future<void> recordReceivedBytePath( Future<void> setManualSelectionFor(
String contactPublicKeyHex, String contactPublicKeyHex,
List<int> pathBytes, PathSelection selection,
int hashSize,
) async { ) async {
await initialize(); await initialize();
if (pathBytes.isEmpty) { _manualSelections[contactPublicKeyHex] = _ManualPathSelectionRecord(
return;
}
if (hashSize < 1 || hashSize > 3) {
return;
}
if (pathBytes.length % hashSize != 0) {
return;
}
final normalizedPathBytes = LogRxRouteDecoder.reverseHopBytes(
pathBytes,
hashSize: hashSize,
);
final history = _historyFor(contactPublicKeyHex);
final signature = normalizedPathBytes
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
.join();
final existing = _findDirectPath(history.directPaths, signature);
final updated = PathRecord(
pathBytes: normalizedPathBytes,
hopCount: normalizedPathBytes.length ~/ hashSize,
hashSize: hashSize,
source: PathRecordSource.observed,
successCount: existing?.successCount ?? 0,
failureCount: existing?.failureCount ?? 0,
lastRoundTripTimeMs: existing?.lastRoundTripTimeMs ?? 0,
lastUsedAt: DateTime.now(),
lastSucceededAt: existing?.lastSucceededAt,
senderLatitude: existing?.senderLatitude,
senderLongitude: existing?.senderLongitude,
recipientLatitude: existing?.recipientLatitude,
recipientLongitude: existing?.recipientLongitude,
);
await _saveHistory(
contactPublicKeyHex,
history.copyWith(
directPaths: _upsertDirectPath(history.directPaths, updated),
),
);
}
Future<PathSelection> getSelectionForContact(
Contact contact, {
required bool autoRouteRotationEnabled,
}) async {
await initialize();
await recordLearnedPath(contact);
if (contact.routeHasPath && contact.routeHopCount > 0) {
return PathSelection(
mode: PathSelectionMode.directCurrent,
pathBytes: Uint8List.fromList(contact.routePathBytes),
hopCount: contact.routeHopCount,
hashSize: contact.routeHashSize,
);
}
if (!autoRouteRotationEnabled) {
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,
double? senderLatitude,
double? senderLongitude,
double? recipientLatitude,
double? recipientLongitude,
}) 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(), pathBytes: selection.pathBytes.toList(),
hopCount: selection.hopCount, hopCount: selection.hopCount,
hashSize: selection.hashSize, hashSize: selection.hashSize,
source: existing?.source ?? PathRecordSource.learned,
successCount: (existing?.successCount ?? 0) + (success ? 1 : 0),
failureCount: (existing?.failureCount ?? 0) + (success ? 0 : 1),
lastRoundTripTimeMs: success
? (roundTripTimeMs ?? existing?.lastRoundTripTimeMs ?? 0)
: (existing?.lastRoundTripTimeMs ?? 0),
lastUsedAt: DateTime.now(),
lastSucceededAt: success ? DateTime.now() : existing?.lastSucceededAt,
senderLatitude: success ? senderLatitude : existing?.senderLatitude,
senderLongitude: success ? senderLongitude : existing?.senderLongitude,
recipientLatitude:
success ? recipientLatitude : existing?.recipientLatitude,
recipientLongitude:
success ? recipientLongitude : existing?.recipientLongitude,
);
await _saveHistory(
contactPublicKeyHex,
history.copyWith(
directPaths: _upsertDirectPath(history.directPaths, updated),
),
); );
await _persistManualSelections();
} }
Future<PathSelection?> getLastSuccessfulDirectSelection( Future<PathSelection?> getManualSelectionForContact(Contact contact) async {
Contact contact, {
String? excludeSignature,
double? senderLatitude,
double? senderLongitude,
double? recipientLatitude,
double? recipientLongitude,
}) async {
await initialize(); await initialize();
final history = _historyFor(contact.publicKeyHex); return _manualSelections[contact.publicKeyHex]?.toSelection();
final ranked = history.directPaths
.where(
(record) =>
record.successCount > 0 &&
record.lastSucceededAt != null &&
record.signature != excludeSignature,
)
.toList()
..sort((a, b) {
final locationCompare = _compareLocationFit(
a,
b,
senderLatitude: senderLatitude,
senderLongitude: senderLongitude,
recipientLatitude: recipientLatitude,
recipientLongitude: recipientLongitude,
);
if (locationCompare != 0) return locationCompare;
final succeededCompare = b.lastSucceededAt!.compareTo(
a.lastSucceededAt!,
);
if (succeededCompare != 0) return succeededCompare;
return _comparePathRecords(a, b);
});
if (ranked.isEmpty) {
return null;
}
final record = ranked.first;
return PathSelection(
mode: PathSelectionMode.directHistorical,
pathBytes: Uint8List.fromList(record.pathBytes),
hopCount: record.hopCount,
hashSize: record.hashSize,
);
} }
ContactPathHistory historyFor(String contactPublicKeyHex) { Future<void> clearManualRouteFor(String contactPublicKeyHex) async {
return _cache[contactPublicKeyHex] ??
ContactPathHistory.empty(contactPublicKeyHex);
}
Future<void> clearHistoryFor(String contactPublicKeyHex) async {
await initialize(); await initialize();
_cache.remove(contactPublicKeyHex); _manualSelections.remove(contactPublicKeyHex);
await _persistManualSelections();
}
Future<void> _persistManualSelections() async {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
final payload = <String, dynamic>{}; final manualPayload = <String, dynamic>{};
for (final entry in _cache.entries) { for (final entry in _manualSelections.entries) {
payload[entry.key] = entry.value.toJson(); manualPayload[entry.key] = entry.value.toJson();
} }
await prefs.setString(_storageKey, jsonEncode(payload)); await prefs.setString(_manualRouteStorageKey, jsonEncode(manualPayload));
}
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();
int _compareLocationFit(
PathRecord a,
PathRecord b, {
required double? senderLatitude,
required double? senderLongitude,
required double? recipientLatitude,
required double? recipientLongitude,
}) {
final aDistance = _locationDistanceScore(
a,
senderLatitude: senderLatitude,
senderLongitude: senderLongitude,
recipientLatitude: recipientLatitude,
recipientLongitude: recipientLongitude,
);
final bDistance = _locationDistanceScore(
b,
senderLatitude: senderLatitude,
senderLongitude: senderLongitude,
recipientLatitude: recipientLatitude,
recipientLongitude: recipientLongitude,
);
return aDistance.compareTo(bDistance);
}
double _locationDistanceScore(
PathRecord record, {
required double? senderLatitude,
required double? senderLongitude,
required double? recipientLatitude,
required double? recipientLongitude,
}) {
var total = 0.0;
var matched = false;
if (senderLatitude != null &&
senderLongitude != null &&
record.senderLatitude != null &&
record.senderLongitude != null) {
matched = true;
total += Geolocator.distanceBetween(
senderLatitude,
senderLongitude,
record.senderLatitude!,
record.senderLongitude!,
);
}
if (recipientLatitude != null &&
recipientLongitude != null &&
record.recipientLatitude != null &&
record.recipientLongitude != null) {
matched = true;
total += Geolocator.distanceBetween(
recipientLatitude,
recipientLongitude,
record.recipientLatitude!,
record.recipientLongitude!,
);
}
return matched ? total : double.infinity;
} }
} }

View File

@@ -0,0 +1,332 @@
import 'dart:async';
import 'package:http/http.dart' as http;
import 'package:latlong2/latlong.dart';
import 'offline_tile_cache_service.dart';
import 'tile_math_service.dart';
/// Events emitted during tile download.
sealed class TileDownloadEvent {}
class TileDownloadStarted extends TileDownloadEvent {
final int totalTiles;
TileDownloadStarted(this.totalTiles);
}
class TileDownloaded extends TileDownloadEvent {
final double north, south, east, west;
TileDownloaded({
required this.north,
required this.south,
required this.east,
required this.west,
});
}
class TileSkipped extends TileDownloadEvent {
final double north, south, east, west;
TileSkipped({
required this.north,
required this.south,
required this.east,
required this.west,
});
}
class TileFailed extends TileDownloadEvent {
final TileCoord coord;
final String error;
TileFailed(this.coord, this.error);
}
class TileDownloadComplete extends TileDownloadEvent {
final int downloaded;
final int skipped;
final int failed;
final int total;
TileDownloadComplete({
required this.downloaded,
required this.skipped,
required this.failed,
required this.total,
});
}
class TileBatchSkipped extends TileDownloadEvent {
final int count;
final int total;
TileBatchSkipped({required this.count, required this.total});
}
class TileDownloadCancelled extends TileDownloadEvent {}
/// Downloads map tiles for given polygons and zoom levels.
class TileDownloadService {
final OfflineTileCacheService _cache = OfflineTileCacheService.instance;
final http.Client _httpClient = http.Client();
bool _cancelled = false;
/// Cancel an ongoing download.
void cancel() {
_cancelled = true;
}
/// Download tiles for the given polygons and zoom range.
///
/// Returns a stream of [TileDownloadEvent]s.
/// [urlTemplate] should contain `{z}`, `{x}`, `{y}` placeholders,
/// and optionally `{s}` for subdomains.
Stream<TileDownloadEvent> downloadTiles({
required List<List<LatLng>> polygons,
required int minZoom,
required int maxZoom,
required String urlTemplate,
String? displayName,
int maxConcurrency = 6,
int rateLimit = 30,
}) {
final controller = StreamController<TileDownloadEvent>();
_runDownload(
controller: controller,
polygons: polygons,
minZoom: minZoom,
maxZoom: maxZoom,
urlTemplate: urlTemplate,
displayName: displayName,
maxConcurrency: maxConcurrency,
rateLimit: rateLimit,
);
return controller.stream;
}
Future<void> _runDownload({
required StreamController<TileDownloadEvent> controller,
required List<List<LatLng>> polygons,
required int minZoom,
required int maxZoom,
required String urlTemplate,
String? displayName,
required int maxConcurrency,
required int rateLimit,
}) async {
_cancelled = false;
final styleHash = _cache.styleHashFromUrl(urlTemplate);
// Save style metadata with download region so it can be reused
final region = DownloadRegion(
polygons: polygons
.map((poly) =>
poly.map((p) => [p.latitude, p.longitude]).toList())
.toList(),
minZoom: minZoom,
maxZoom: maxZoom,
);
await _cache.saveStyleMeta(
styleHash,
displayName: displayName ?? urlTemplate,
urlTemplate: urlTemplate,
region: region,
);
final allTiles =
TileMathService.getTilesForPolygons(polygons, minZoom, maxZoom);
// Load manifest once and partition tiles into needed vs already cached
final manifest = await _cache.loadManifest(styleHash);
final tilesToDownload = <TileCoord>[];
var skipped = 0;
for (final tile in allTiles) {
final key = '${tile.z}/${tile.x}/${tile.y}';
if (manifest.contains(key)) {
skipped++;
} else {
tilesToDownload.add(tile);
}
}
final total = allTiles.length;
controller.add(TileDownloadStarted(total));
// Report all skipped tiles immediately (no per-tile filesystem check)
if (skipped > 0) {
controller.add(TileBatchSkipped(count: skipped, total: total));
}
if (tilesToDownload.isEmpty) {
controller.add(TileDownloadComplete(
downloaded: 0, skipped: skipped, failed: 0, total: total));
await controller.close();
return;
}
var downloaded = 0;
var failed = 0;
final semaphore = _Semaphore(maxConcurrency);
final rateLimiter = _RateLimiter(rateLimit);
final futures = <Future<void>>[];
for (final tile in tilesToDownload) {
if (_cancelled) break;
await rateLimiter.wait();
if (_cancelled) break;
await semaphore.acquire();
if (_cancelled) {
semaphore.release();
break;
}
final future = _downloadSingleTile(tile, urlTemplate, styleHash)
.then((event) {
if (!controller.isClosed) {
controller.add(event);
if (event is TileDownloaded) {
downloaded++;
} else if (event is TileFailed) {
failed++;
}
}
semaphore.release();
});
futures.add(future);
}
// Wait for all in-flight downloads to finish
await Future.wait(futures);
if (_cancelled) {
controller.add(TileDownloadCancelled());
} else {
controller.add(TileDownloadComplete(
downloaded: downloaded,
skipped: skipped,
failed: failed,
total: total,
));
}
await controller.close();
}
Future<TileDownloadEvent> _downloadSingleTile(
TileCoord tile,
String urlTemplate,
String styleHash,
) async {
final bounds = TileMathService.tileBounds(tile.x, tile.y, tile.z);
// Build URL
final subdomains = ['a', 'b', 'c'];
var url = urlTemplate
.replaceAll('{s}', subdomains[tile.x % 3])
.replaceAll('{z}', '${tile.z}')
.replaceAll('{x}', '${tile.x}')
.replaceAll('{y}', '${tile.y}');
// Download with retries
const maxRetries = 3;
for (var attempt = 0; attempt < maxRetries; attempt++) {
if (_cancelled) {
return TileFailed(tile, 'Cancelled');
}
try {
final response = await _httpClient.get(
Uri.parse(url),
headers: {'User-Agent': 'MeshCoreSAR/1.0'},
);
if (response.statusCode != 200) {
if (attempt < maxRetries - 1) {
await Future.delayed(Duration(seconds: 1 << attempt));
continue;
}
return TileFailed(tile, 'HTTP ${response.statusCode}');
}
// Store tile (PNG → AVIF conversion happens inside cache service)
await _cache.putTile(
styleHash, tile.z, tile.x, tile.y, response.bodyBytes);
return TileDownloaded(
north: bounds.north,
south: bounds.south,
east: bounds.east,
west: bounds.west,
);
} catch (e) {
if (attempt < maxRetries - 1) {
await Future.delayed(Duration(seconds: 1 << attempt));
continue;
}
return TileFailed(tile, e.toString());
}
}
return TileFailed(tile, 'Max retries exceeded');
}
void dispose() {
_cancelled = true;
_httpClient.close();
}
}
/// Simple counting semaphore for concurrency limiting.
class _Semaphore {
final int maxCount;
int _currentCount = 0;
final _waitQueue = <Completer<void>>[];
_Semaphore(this.maxCount);
Future<void> acquire() async {
if (_currentCount < maxCount) {
_currentCount++;
return;
}
final completer = Completer<void>();
_waitQueue.add(completer);
await completer.future;
}
void release() {
if (_waitQueue.isNotEmpty) {
_waitQueue.removeAt(0).complete();
} else {
_currentCount--;
}
}
}
/// Rate limiter that ensures no more than [maxPerSecond] operations per second.
class _RateLimiter {
final int maxPerSecond;
final _timestamps = <DateTime>[];
_RateLimiter(this.maxPerSecond);
Future<void> wait() async {
final now = DateTime.now();
_timestamps
.removeWhere((t) => now.difference(t) > const Duration(seconds: 1));
if (_timestamps.length >= maxPerSecond) {
final oldest = _timestamps.first;
final waitTime = const Duration(seconds: 1) - now.difference(oldest);
if (waitTime > Duration.zero) {
await Future.delayed(waitTime);
}
_timestamps.removeAt(0);
}
_timestamps.add(DateTime.now());
}
}

View File

@@ -0,0 +1,268 @@
import 'dart:math';
import 'package:latlong2/latlong.dart';
/// A tile coordinate with x, y, and zoom level.
class TileCoord {
final int x;
final int y;
final int z;
const TileCoord(this.x, this.y, this.z);
@override
bool operator ==(Object other) =>
other is TileCoord && other.x == x && other.y == y && other.z == z;
@override
int get hashCode => Object.hash(x, y, z);
@override
String toString() => 'TileCoord($z/$x/$y)';
}
/// A geographical bounding box.
class TileBounds {
final double north;
final double south;
final double east;
final double west;
const TileBounds({
required this.north,
required this.south,
required this.east,
required this.west,
});
}
/// Pure math utilities for slippy map tile calculations.
///
/// Ported from the Go offline-map-tile-downloader.
class TileMathService {
const TileMathService._();
/// Convert latitude/longitude to tile coordinates at the given zoom level.
static (int x, int y) latLonToTile(double lat, double lon, int zoom) {
final latRad = lat * pi / 180;
final n = pow(2, zoom).toDouble();
final x = (n * ((lon + 180) / 360)).floor();
final y =
(n * (1 - (log(tan(latRad) + 1 / cos(latRad)) / pi)) / 2).floor();
return (x, y);
}
/// Calculate the geographical bounding box of a tile.
static TileBounds tileBounds(int x, int y, int z) {
final n = pow(2.0, z).toDouble();
final lonDeg = x / n * 360.0 - 180.0;
final latRad = atan(sinh(pi * (1 - 2 * y / n)));
final latDeg = latRad * 180.0 / pi;
final lon2Deg = (x + 1) / n * 360.0 - 180.0;
final lat2Rad = atan(sinh(pi * (1 - 2 * (y + 1) / n)));
final lat2Deg = lat2Rad * 180.0 / pi;
return TileBounds(
north: latDeg,
south: lat2Deg,
east: lon2Deg,
west: lonDeg,
);
}
/// Hyperbolic sine.
static double sinh(double x) => (exp(x) - exp(-x)) / 2;
/// Check if a point is inside a polygon using the ray casting algorithm.
static bool polygonContains(List<LatLng> poly, LatLng point) {
var inside = false;
for (int i = 0, j = poly.length - 1; i < poly.length; j = i++) {
if ((poly[i].latitude > point.latitude) !=
(poly[j].latitude > point.latitude) &&
(point.longitude <
(poly[j].longitude - poly[i].longitude) *
(point.latitude - poly[i].latitude) /
(poly[j].latitude - poly[i].latitude) +
poly[i].longitude)) {
inside = !inside;
}
}
return inside;
}
/// Check if a bounding box contains a point.
static bool boundsContains(TileBounds bounds, LatLng point) {
return point.latitude <= bounds.north &&
point.latitude >= bounds.south &&
point.longitude >= bounds.west &&
point.longitude <= bounds.east;
}
/// Check if a polygon intersects with a tile bounding box.
static bool polygonIntersects(List<LatLng> poly, TileBounds bounds) {
// Check if any polygon vertex is inside the tile
for (final p in poly) {
if (boundsContains(bounds, p)) return true;
}
// Check if any tile corner is inside the polygon
final corners = [
LatLng(bounds.north, bounds.west),
LatLng(bounds.north, bounds.east),
LatLng(bounds.south, bounds.west),
LatLng(bounds.south, bounds.east),
];
for (final corner in corners) {
if (polygonContains(poly, corner)) return true;
}
// Check if any polygon edge intersects any tile edge
final tileEdges = [
(corners[0], corners[1]),
(corners[1], corners[3]),
(corners[3], corners[2]),
(corners[2], corners[0]),
];
for (int i = 0; i < poly.length; i++) {
final p1 = poly[i];
final p2 = poly[(i + 1) % poly.length];
for (final edge in tileEdges) {
if (_lineIntersects(p1, p2, edge.$1, edge.$2)) return true;
}
}
return false;
}
/// Check if two line segments intersect.
static bool _lineIntersects(LatLng p1, LatLng q1, LatLng p2, LatLng q2) {
final o1 = _orientation(p1, q1, p2);
final o2 = _orientation(p1, q1, q2);
final o3 = _orientation(p2, q2, p1);
final o4 = _orientation(p2, q2, q1);
if (o1 != o2 && o3 != o4) return true;
if (o1 == 0 && _onSegment(p1, p2, q1)) return true;
if (o2 == 0 && _onSegment(p1, q2, q1)) return true;
if (o3 == 0 && _onSegment(p2, p1, q2)) return true;
if (o4 == 0 && _onSegment(p2, q1, q2)) return true;
return false;
}
/// Find orientation of ordered triplet (p, q, r).
/// Returns 0 for collinear, 1 for clockwise, 2 for counterclockwise.
static int _orientation(LatLng p, LatLng q, LatLng r) {
final val = (q.longitude - p.longitude) * (r.latitude - q.latitude) -
(q.latitude - p.latitude) * (r.longitude - q.longitude);
if (val == 0) return 0;
return val > 0 ? 1 : 2;
}
/// Check if point q lies on segment pr.
static bool _onSegment(LatLng p, LatLng q, LatLng r) {
return q.latitude <= max(p.latitude, r.latitude) &&
q.latitude >= min(p.latitude, r.latitude) &&
q.longitude <= max(p.longitude, r.longitude) &&
q.longitude >= min(p.longitude, r.longitude);
}
/// Get all tiles that overlap with the given polygons across zoom levels.
static List<TileCoord> getTilesForPolygons(
List<List<LatLng>> polygons,
int minZoom,
int maxZoom,
) {
final tileSet = <TileCoord>{};
for (final poly in polygons) {
if (poly.length < 3) continue;
// Find bounding box of polygon
var minLat = 90.0, minLon = 180.0;
var maxLat = -90.0, maxLon = -180.0;
for (final p in poly) {
if (p.latitude < minLat) minLat = p.latitude;
if (p.latitude > maxLat) maxLat = p.latitude;
if (p.longitude < minLon) minLon = p.longitude;
if (p.longitude > maxLon) maxLon = p.longitude;
}
for (int z = minZoom; z <= maxZoom; z++) {
final (tlx, tly) = latLonToTile(maxLat, minLon, z);
final (brx, bry) = latLonToTile(minLat, maxLon, z);
for (int x = tlx; x <= brx; x++) {
for (int y = tly; y <= bry; y++) {
final tile = TileCoord(x, y, z);
if (tileSet.contains(tile)) continue;
final bounds = tileBounds(x, y, z);
// Check if all tile corners are inside the polygon
final allCornersInside = polygonContains(
poly, LatLng(bounds.north, bounds.west)) &&
polygonContains(poly, LatLng(bounds.north, bounds.east)) &&
polygonContains(poly, LatLng(bounds.south, bounds.west)) &&
polygonContains(poly, LatLng(bounds.south, bounds.east));
if (allCornersInside) {
tileSet.add(tile);
continue;
}
// Check if all polygon vertices are inside the tile
var polyInTile = true;
for (final p in poly) {
if (!boundsContains(bounds, p)) {
polyInTile = false;
break;
}
}
if (polyInTile) {
tileSet.add(tile);
continue;
}
// Check for intersection
if (polygonIntersects(poly, bounds)) {
tileSet.add(tile);
}
}
}
}
}
return tileSet.toList();
}
/// Estimate the number of tiles for given polygons and zoom range.
/// Faster than getTilesForPolygons — uses bounding box approximation.
static int estimateTileCount(
List<List<LatLng>> polygons,
int minZoom,
int maxZoom,
) {
var count = 0;
for (final poly in polygons) {
if (poly.length < 3) continue;
var minLat = 90.0, minLon = 180.0;
var maxLat = -90.0, maxLon = -180.0;
for (final p in poly) {
if (p.latitude < minLat) minLat = p.latitude;
if (p.latitude > maxLat) maxLat = p.latitude;
if (p.longitude < minLon) minLon = p.longitude;
if (p.longitude > maxLon) maxLon = p.longitude;
}
for (int z = minZoom; z <= maxZoom; z++) {
final (tlx, tly) = latLonToTile(maxLat, minLon, z);
final (brx, bry) = latLonToTile(minLat, maxLon, z);
count += (brx - tlx + 1) * (bry - tly + 1);
}
}
return count;
}
}

View File

@@ -0,0 +1,518 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import 'package:nsd/nsd.dart' as nsd;
import 'offline_tile_cache_service.dart';
/// A discovered tile-serving peer on the local network.
class TilePeer {
final String ipAddress;
final int port;
const TilePeer({required this.ipAddress, required this.port});
String get baseUrl => 'http://$ipAddress:$port';
@override
bool operator ==(Object other) =>
other is TilePeer && other.ipAddress == ipAddress && other.port == port;
@override
int get hashCode => Object.hash(ipAddress, port);
@override
String toString() => 'TilePeer($ipAddress:$port)';
}
/// What a remote peer has available.
class PeerCatalog {
final TilePeer peer;
final List<StyleInfo> styles;
const PeerCatalog({required this.peer, required this.styles});
}
/// Progress events during a P2P sync.
sealed class PeerSyncEvent {}
class PeerSyncStarted extends PeerSyncEvent {
final int totalTiles;
PeerSyncStarted(this.totalTiles);
}
class PeerSyncTileDownloaded extends PeerSyncEvent {
final int downloaded;
final int total;
PeerSyncTileDownloaded({required this.downloaded, required this.total});
}
class PeerSyncTileSkipped extends PeerSyncEvent {
final int skipped;
final int total;
PeerSyncTileSkipped({required this.skipped, required this.total});
}
class PeerSyncComplete extends PeerSyncEvent {
final int downloaded;
final int skipped;
final int failed;
PeerSyncComplete({
required this.downloaded,
required this.skipped,
required this.failed,
});
}
class PeerSyncCancelled extends PeerSyncEvent {}
/// HTTP server that serves cached AVIF tiles to other devices on the
/// local network, with mDNS advertisement, peer discovery, and P2P sync.
///
/// Protocol:
/// GET /styles → JSON array of StyleInfo
/// GET /tiles/{hash}/list → JSON array of {z, x, y}
/// GET /tiles/{hash}/{z}/{x}/{y}.avif → AVIF bytes | 404
class TileSharingService {
TileSharingService._();
static final instance = TileSharingService._();
static const int defaultPort = 8347;
static const String serviceType = '_sartiles._tcp';
final OfflineTileCacheService _cache = OfflineTileCacheService.instance;
final http.Client _httpClient = http.Client();
HttpServer? _server;
nsd.Discovery? _activeDiscovery;
nsd.Registration? _activeRegistration;
final _peersController = StreamController<Set<TilePeer>>.broadcast();
final Set<TilePeer> _discoveredPeers = {};
bool _syncCancelled = false;
bool get isRunning => _server != null;
Stream<Set<TilePeer>> get peersStream => _peersController.stream;
Set<TilePeer> get discoveredPeers => Set.unmodifiable(_discoveredPeers);
// ── Server ──────────────────────────────────────────────────────────────
Future<void> startServer() async {
if (_server != null) return;
try {
_server = await HttpServer.bind(InternetAddress.anyIPv4, defaultPort);
debugPrint('[TileSharing] Server started on port $defaultPort');
_server!.listen(_handleRequest, onError: (error) {
debugPrint('[TileSharing] Server error: $error');
});
await _advertise();
} catch (e) {
debugPrint('[TileSharing] Failed to start server: $e');
_server = null;
}
}
Future<void> stopServer() async {
await _stopAdvertising();
await _server?.close();
_server = null;
debugPrint('[TileSharing] Server stopped');
}
// ── Discovery ───────────────────────────────────────────────────────────
Future<void> startDiscovery() async {
if (_activeDiscovery != null) return;
try {
_activeDiscovery = await nsd.startDiscovery(serviceType);
_activeDiscovery!.addServiceListener((service, status) {
if (service.host == null || service.port == null) return;
final peer = TilePeer(
ipAddress: service.host!,
port: service.port!,
);
if (status == nsd.ServiceStatus.found) {
_discoveredPeers.add(peer);
} else {
_discoveredPeers.remove(peer);
}
_peersController.add(Set.unmodifiable(_discoveredPeers));
});
} catch (e) {
debugPrint('[TileSharing] Discovery error: $e');
}
}
Future<void> stopPeerDiscovery() async {
if (_activeDiscovery != null) {
await nsd.stopDiscovery(_activeDiscovery!);
_activeDiscovery = null;
}
_discoveredPeers.clear();
_peersController.add(const {});
}
void addManualPeer(String ipAddress, {int port = defaultPort}) {
_discoveredPeers.add(TilePeer(ipAddress: ipAddress, port: port));
_peersController.add(Set.unmodifiable(_discoveredPeers));
}
void removePeer(TilePeer peer) {
_discoveredPeers.remove(peer);
_peersController.add(Set.unmodifiable(_discoveredPeers));
}
// ── Peer queries ────────────────────────────────────────────────────────
/// Fetch the catalog (available styles + tile counts) from a peer.
Future<PeerCatalog?> fetchPeerCatalog(TilePeer peer) async {
try {
final uri = Uri.parse('${peer.baseUrl}/styles');
final response =
await _httpClient.get(uri).timeout(const Duration(seconds: 5));
if (response.statusCode != 200) return null;
final List<dynamic> data = jsonDecode(response.body);
final styles = data
.map((e) => StyleInfo.fromJson(e as Map<String, dynamic>))
.toList();
return PeerCatalog(peer: peer, styles: styles);
} catch (e) {
debugPrint('[TileSharing] fetchPeerCatalog(${peer.ipAddress}): $e');
return null;
}
}
/// Fetch catalogs from all discovered peers.
Future<List<PeerCatalog>> fetchAllPeerCatalogs() async {
final futures =
_discoveredPeers.map((peer) => fetchPeerCatalog(peer)).toList();
final results = await Future.wait(futures);
return results.whereType<PeerCatalog>().toList();
}
/// Fetch the tile list for a style from a peer.
Future<List<CachedTileCoord>?> fetchPeerTileList(
TilePeer peer,
String styleHash,
) async {
try {
final uri = Uri.parse('${peer.baseUrl}/tiles/$styleHash/list');
final response =
await _httpClient.get(uri).timeout(const Duration(seconds: 10));
if (response.statusCode != 200) return null;
final List<dynamic> data = jsonDecode(response.body);
return data
.map((e) => CachedTileCoord.fromJson(e as Map<String, dynamic>))
.toList();
} catch (e) {
debugPrint('[TileSharing] fetchPeerTileList(${peer.ipAddress}): $e');
return null;
}
}
/// Fetch a single tile from a peer. Returns raw AVIF bytes or null.
Future<Uint8List?> fetchTileFromPeer(
TilePeer peer,
String styleHash,
int z,
int x,
int y,
) async {
try {
final uri =
Uri.parse('${peer.baseUrl}/tiles/$styleHash/$z/$x/$y.avif');
final response =
await _httpClient.get(uri).timeout(const Duration(seconds: 5));
if (response.statusCode == 200) return response.bodyBytes;
} catch (e) {
// Silently fail — caller will try next peer
}
return null;
}
/// Try fetching a tile from any available peer (for the caching provider).
Future<Uint8List?> fetchFromAnyPeer(
String styleHash,
int z,
int x,
int y,
) async {
for (final peer in _discoveredPeers) {
final bytes = await fetchTileFromPeer(peer, styleHash, z, x, y);
if (bytes != null) return bytes;
}
return null;
}
// ── P2P Sync ────────────────────────────────────────────────────────────
void cancelSync() {
_syncCancelled = true;
}
/// Sync a style from peers: fetch their tile list, download tiles we
/// don't have, trying multiple peers in round-robin for speed.
///
/// [peers] — which peers to pull from (all that have this style).
/// [styleHash] — which style to sync.
/// [styleMeta] — metadata to save locally (name, URL template).
Stream<PeerSyncEvent> syncStyleFromPeers({
required List<TilePeer> peers,
required String styleHash,
required StyleInfo styleMeta,
int maxConcurrency = 8,
}) {
final controller = StreamController<PeerSyncEvent>();
_runSync(
controller: controller,
peers: peers,
styleHash: styleHash,
styleMeta: styleMeta,
maxConcurrency: maxConcurrency,
);
return controller.stream;
}
Future<void> _runSync({
required StreamController<PeerSyncEvent> controller,
required List<TilePeer> peers,
required String styleHash,
required StyleInfo styleMeta,
required int maxConcurrency,
}) async {
_syncCancelled = false;
// Save style metadata locally
await _cache.saveStyleMeta(
styleHash,
displayName: styleMeta.displayName,
urlTemplate: styleMeta.urlTemplate,
);
// Collect tile lists from all peers and merge (union)
final allTiles = <String, CachedTileCoord>{};
for (final peer in peers) {
if (_syncCancelled) break;
final tiles = await fetchPeerTileList(peer, styleHash);
if (tiles != null) {
for (final t in tiles) {
allTiles['${t.z}/${t.x}/${t.y}'] = t;
}
}
}
final tilesToSync = allTiles.values.toList();
controller.add(PeerSyncStarted(tilesToSync.length));
if (tilesToSync.isEmpty || _syncCancelled) {
controller
.add(PeerSyncComplete(downloaded: 0, skipped: 0, failed: 0));
await controller.close();
return;
}
var downloaded = 0;
var skipped = 0;
var failed = 0;
final total = tilesToSync.length;
final semaphore = _Semaphore(maxConcurrency);
final futures = <Future<void>>[];
var peerIndex = 0;
for (final tile in tilesToSync) {
if (_syncCancelled) break;
await semaphore.acquire();
if (_syncCancelled) {
semaphore.release();
break;
}
// Round-robin across peers for parallel throughput
final peer = peers[peerIndex % peers.length];
peerIndex++;
final future = () async {
try {
// Skip if we already have it
if (await _cache.hasTile(styleHash, tile.z, tile.x, tile.y)) {
skipped++;
controller.add(
PeerSyncTileSkipped(skipped: skipped, total: total));
return;
}
// Try this peer, then fallback to others
Uint8List? bytes =
await fetchTileFromPeer(peer, styleHash, tile.z, tile.x, tile.y);
if (bytes == null) {
for (final fallback in peers) {
if (fallback == peer) continue;
bytes = await fetchTileFromPeer(
fallback, styleHash, tile.z, tile.x, tile.y);
if (bytes != null) break;
}
}
if (bytes != null) {
await _cache.putRawTile(
styleHash, tile.z, tile.x, tile.y, bytes);
downloaded++;
controller.add(PeerSyncTileDownloaded(
downloaded: downloaded, total: total));
} else {
failed++;
}
} catch (_) {
failed++;
} finally {
semaphore.release();
}
}();
futures.add(future);
}
await Future.wait(futures);
if (_syncCancelled) {
controller.add(PeerSyncCancelled());
} else {
controller.add(PeerSyncComplete(
downloaded: downloaded,
skipped: skipped,
failed: failed,
));
}
await controller.close();
}
// ── mDNS ────────────────────────────────────────────────────────────────
Future<void> _advertise() async {
try {
final styles = await _cache.listStyles();
_activeRegistration = await nsd.register(nsd.Service(
name: 'MeshCore SAR Tiles',
type: serviceType,
port: defaultPort,
txt: {
'styles':
Uint8List.fromList(utf8.encode(styles.join(','))),
},
));
} catch (e) {
debugPrint('[TileSharing] mDNS registration error: $e');
}
}
Future<void> _stopAdvertising() async {
if (_activeRegistration != null) {
await nsd.unregister(_activeRegistration!);
_activeRegistration = null;
}
}
// ── HTTP Server ─────────────────────────────────────────────────────────
void _handleRequest(HttpRequest request) async {
request.response.headers.add('Access-Control-Allow-Origin', '*');
final path = request.uri.path;
// GET /styles → detailed style list
if (path == '/styles') {
final styles = await _cache.listStylesDetailed();
request.response
..statusCode = HttpStatus.ok
..headers.contentType = ContentType.json
..write(jsonEncode(styles.map((s) => s.toJson()).toList()));
await request.response.close();
return;
}
// GET /tiles/{hash}/list → tile coordinate inventory
final listPattern = RegExp(r'^/tiles/([a-f0-9]+)/list$');
final listMatch = listPattern.firstMatch(path);
if (listMatch != null) {
final styleHash = listMatch.group(1)!;
final tiles = await _cache.listTilesForStyle(styleHash);
request.response
..statusCode = HttpStatus.ok
..headers.contentType = ContentType.json
..write(jsonEncode(tiles.map((t) => t.toJson()).toList()));
await request.response.close();
return;
}
// GET /tiles/{hash}/{z}/{x}/{y}.avif → tile bytes
final tilePattern =
RegExp(r'^/tiles/([a-f0-9]+)/(\d+)/(\d+)/(\d+)\.avif$');
final tileMatch = tilePattern.firstMatch(path);
if (tileMatch != null) {
final styleHash = tileMatch.group(1)!;
final z = int.parse(tileMatch.group(2)!);
final x = int.parse(tileMatch.group(3)!);
final y = int.parse(tileMatch.group(4)!);
final bytes = await _cache.getRawTile(styleHash, z, x, y);
if (bytes != null) {
request.response
..statusCode = HttpStatus.ok
..headers.contentType = ContentType('image', 'avif')
..add(bytes);
await request.response.close();
return;
}
}
request.response.statusCode = HttpStatus.notFound;
await request.response.close();
}
void dispose() {
stopServer();
stopPeerDiscovery();
_httpClient.close();
_peersController.close();
}
}
/// Simple counting semaphore for concurrency limiting.
class _Semaphore {
final int maxCount;
int _currentCount = 0;
final _waitQueue = <Completer<void>>[];
_Semaphore(this.maxCount);
Future<void> acquire() async {
if (_currentCount < maxCount) {
_currentCount++;
return;
}
final completer = Completer<void>();
_waitQueue.add(completer);
await completer.future;
}
void release() {
if (_waitQueue.isNotEmpty) {
_waitQueue.removeAt(0).complete();
} else {
_currentCount--;
}
}
}

View File

@@ -0,0 +1,508 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import 'package:package_info_plus/package_info_plus.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../models/ble_packet_log.dart';
import '../utils/log_rx_route_decoder.dart';
import 'live_traffic_summary.dart';
class TrafficStatsCounts {
static const List<String> packetTypeKeys = <String>[
'pt_00',
'pt_01',
'pt_02',
'pt_03',
'pt_04',
'pt_05',
'pt_06',
'pt_07',
'pt_08',
'pt_09',
'pt_0a',
'pt_0b',
'pt_0c',
'pt_0d',
'pt_0e',
'pt_0f',
];
static const List<String> pathModeKeys = <String>[
'path_mode_1b',
'path_mode_2b',
'path_mode_3b',
'path_mode_none',
'path_mode_unknown',
];
static const String decodeFailKey = 'decode_fail';
static const List<String> allKeys = <String>[
...packetTypeKeys,
decodeFailKey,
...pathModeKeys,
];
final Map<String, int> _values;
TrafficStatsCounts._(this._values);
factory TrafficStatsCounts.empty() {
return TrafficStatsCounts._(
<String, int>{for (final key in allKeys) key: 0},
);
}
factory TrafficStatsCounts.fromJson(Map<String, dynamic>? json) {
final counts = TrafficStatsCounts.empty();
if (json == null) {
return counts;
}
for (final key in allKeys) {
counts._values[key] = (json[key] as num?)?.toInt() ?? 0;
}
return counts;
}
int operator [](String key) => _values[key] ?? 0;
bool get isEmpty => _values.values.every((value) => value == 0);
Map<String, int> toJson() {
return <String, int>{
for (final key in allKeys) key: _values[key] ?? 0,
};
}
void increment(String key, [int amount = 1]) {
_values[key] = (_values[key] ?? 0) + amount;
}
void incrementPacketType(int payloadType) {
if (payloadType < 0 || payloadType > 0x0F) {
increment(decodeFailKey);
return;
}
increment('pt_${payloadType.toRadixString(16).padLeft(2, '0')}');
}
void mergeFrom(TrafficStatsCounts other) {
for (final key in allKeys) {
increment(key, other[key]);
}
}
}
class TrafficStatsQueuedReport {
final String reportId;
final String deviceKey6;
final DateTime windowStart;
final DateTime windowEnd;
final String appVersion;
final TrafficStatsCounts counts;
const TrafficStatsQueuedReport({
required this.reportId,
required this.deviceKey6,
required this.windowStart,
required this.windowEnd,
required this.appVersion,
required this.counts,
});
factory TrafficStatsQueuedReport.fromJson(Map<String, dynamic> json) {
return TrafficStatsQueuedReport(
reportId: (json['reportId'] as String?) ?? '',
deviceKey6: (json['deviceKey6'] as String?) ?? '',
windowStart: DateTime.parse(json['windowStart'] as String).toUtc(),
windowEnd: DateTime.parse(json['windowEnd'] as String).toUtc(),
appVersion: (json['appVersion'] as String?) ?? 'unknown',
counts: TrafficStatsCounts.fromJson(
json['counts'] as Map<String, dynamic>?,
),
);
}
Map<String, dynamic> toJson() {
return <String, dynamic>{
'reportId': reportId,
'deviceKey6': deviceKey6,
'windowStart': windowStart.toIso8601String(),
'windowEnd': windowEnd.toIso8601String(),
'appVersion': appVersion,
'counts': counts.toJson(),
};
}
}
class TrafficStatsReportingService extends ChangeNotifier {
static const String workerBaseUrl = 'https://mcstats.dz0ny.dev';
static final Uri dashboardUri = Uri.parse(workerBaseUrl);
static final Uri ingestUri = Uri.parse('$workerBaseUrl/api/ingest');
static const bool defaultEnabled = true;
static const int defaultIntervalMinutes = 5;
static const String _enabledKey = 'traffic_stats_reporting_enabled';
static const String _legacyIntervalKey =
'traffic_stats_reporting_interval_minutes';
static const String _queueKey = 'traffic_stats_reporting_queue';
static const String _lastSuccessAtKey =
'traffic_stats_reporting_last_success_at';
static const String _lastErrorKey = 'traffic_stats_reporting_last_error';
static const Duration _retryInterval = Duration(seconds: 30);
final http.Client _client;
final bool _ownsClient;
final DateTime Function() _now;
final Future<SharedPreferences> Function() _prefsProvider;
final Future<String> Function() _appVersionProvider;
final Map<int, TrafficStatsCounts> _openWindows =
<int, TrafficStatsCounts>{};
final List<TrafficStatsQueuedReport> _queue = <TrafficStatsQueuedReport>[];
String? Function()? _deviceKey6Provider;
Timer? _retryTimer;
String? _appVersion;
bool _enabled = defaultEnabled;
DateTime? _lastSuccessAt;
String? _lastError;
bool _isInitialized = false;
bool _isFlushingQueue = false;
TrafficStatsReportingService({
http.Client? client,
DateTime Function()? now,
Future<SharedPreferences> Function()? prefsProvider,
Future<String> Function()? appVersionProvider,
}) : _client = client ?? http.Client(),
_ownsClient = client == null,
_now = now ?? DateTime.now,
_prefsProvider = prefsProvider ?? SharedPreferences.getInstance,
_appVersionProvider = appVersionProvider ?? _defaultAppVersionProvider;
bool get isEnabled => _enabled;
int get intervalMinutes => defaultIntervalMinutes;
DateTime? get lastSuccessAt => _lastSuccessAt;
String? get lastError => _lastError;
bool get isInitialized => _isInitialized;
bool get isFlushingQueue => _isFlushingQueue;
int get pendingUploadCount => _queue.length;
Future<void> initialize({
required String? Function() deviceKey6Provider,
}) async {
_deviceKey6Provider = deviceKey6Provider;
final prefs = await _prefsProvider();
_enabled = prefs.getBool(_enabledKey) ?? defaultEnabled;
if (prefs.containsKey(_legacyIntervalKey)) {
await prefs.remove(_legacyIntervalKey);
}
final queueJson = prefs.getString(_queueKey);
if (queueJson != null && queueJson.isNotEmpty) {
final decoded = jsonDecode(queueJson);
if (decoded is List) {
_queue
..clear()
..addAll(
decoded.whereType<Map<String, dynamic>>().map(
TrafficStatsQueuedReport.fromJson,
),
);
}
}
final lastSuccessAt = prefs.getString(_lastSuccessAtKey);
if (lastSuccessAt != null && lastSuccessAt.isNotEmpty) {
_lastSuccessAt = DateTime.tryParse(lastSuccessAt)?.toUtc();
}
final lastError = prefs.getString(_lastErrorKey);
if (lastError != null && lastError.isNotEmpty) {
_lastError = lastError;
}
try {
_appVersion = await _appVersionProvider();
} catch (_) {
_appVersion = 'unknown';
}
_retryTimer?.cancel();
_retryTimer = Timer.periodic(_retryInterval, (_) {
unawaited(flushPendingUploads());
});
_isInitialized = true;
notifyListeners();
await flushPendingUploads();
}
Future<void> setEnabled(bool enabled) async {
if (_enabled == enabled) {
return;
}
_enabled = enabled;
_lastError = null;
if (!enabled) {
_openWindows.clear();
}
await _saveState();
notifyListeners();
if (enabled) {
unawaited(flushPendingUploads());
}
}
Future<void> processLogs(List<BlePacketLog> logs) async {
if (!_enabled || logs.isEmpty) {
if (_enabled) {
await flushPendingUploads();
}
return;
}
var changed = false;
for (final log in logs) {
if (!LiveTrafficSummary.isRxDataLog(log)) {
continue;
}
final counts = _openWindows.putIfAbsent(
_windowStartFor(log.timestamp).millisecondsSinceEpoch,
TrafficStatsCounts.empty,
);
final route = LogRxRouteDecoder.decode(log.rawData);
if (route == null) {
counts.increment(TrafficStatsCounts.decodeFailKey);
} else {
counts.incrementPacketType(route.payloadType);
}
counts.increment(_pathModeKeyFor(log.rawData, route));
changed = true;
}
if (!changed) {
await flushPendingUploads();
return;
}
final queuedReports = _queueClosedWindows();
if (queuedReports > 0) {
await _saveState();
}
notifyListeners();
await flushPendingUploads();
}
Future<void> flushPendingUploads() async {
if (!_enabled || _queue.isEmpty || _isFlushingQueue) {
return;
}
final deviceKey6 = _deviceKey6Provider?.call();
if (deviceKey6 == null || deviceKey6.isEmpty) {
return;
}
_isFlushingQueue = true;
notifyListeners();
try {
while (_queue.isNotEmpty && _enabled) {
final report = _queue.first;
final response = await _client.post(
ingestUri,
headers: const <String, String>{
'content-type': 'application/json',
},
body: jsonEncode(report.toJson()),
);
if (response.statusCode < 200 || response.statusCode >= 300) {
_lastError = 'Upload failed (${response.statusCode})';
await _saveState();
notifyListeners();
return;
}
_queue.removeAt(0);
_lastError = null;
_lastSuccessAt = _now().toUtc();
await _saveState();
notifyListeners();
}
} catch (error) {
_lastError = 'Upload failed: $error';
await _saveState();
notifyListeners();
} finally {
_isFlushingQueue = false;
notifyListeners();
}
}
int _queueClosedWindows() {
final deviceKey6 = _deviceKey6Provider?.call();
if (deviceKey6 == null || deviceKey6.isEmpty) {
return 0;
}
final now = _now().toUtc();
final closable = _openWindows.keys
.where((windowStartMs) {
final windowStart = DateTime.fromMillisecondsSinceEpoch(
windowStartMs,
isUtc: true,
);
final windowEnd = windowStart.add(
Duration(minutes: defaultIntervalMinutes),
);
return !windowEnd.isAfter(now);
})
.toList()
..sort();
for (final windowStartMs in closable) {
final windowStart = DateTime.fromMillisecondsSinceEpoch(
windowStartMs,
isUtc: true,
);
final counts = _openWindows.remove(windowStartMs);
if (counts == null || counts.isEmpty) {
continue;
}
final reportId = '$deviceKey6:${windowStart.toIso8601String()}';
final existingIndex = _queue.indexWhere(
(report) => report.reportId == reportId,
);
if (existingIndex != -1) {
_queue[existingIndex].counts.mergeFrom(counts);
continue;
}
_queue.add(
TrafficStatsQueuedReport(
reportId: reportId,
deviceKey6: deviceKey6,
windowStart: windowStart,
windowEnd: windowStart.add(
Duration(minutes: defaultIntervalMinutes),
),
appVersion: _appVersion ?? 'unknown',
counts: counts,
),
);
}
return closable.length;
}
DateTime _windowStartFor(DateTime timestamp) {
final utc = timestamp.toUtc();
final alignedMinute =
utc.minute - (utc.minute % defaultIntervalMinutes);
return DateTime.utc(
utc.year,
utc.month,
utc.day,
utc.hour,
alignedMinute,
);
}
String _pathModeKeyFor(Uint8List rawData, DecodedLogRxRoute? route) {
if (route != null) {
if (route.pathBytes.isEmpty) {
return 'path_mode_none';
}
switch (route.hashSize) {
case 1:
return 'path_mode_1b';
case 2:
return 'path_mode_2b';
case 3:
return 'path_mode_3b';
}
return 'path_mode_unknown';
}
return _pathModeKeyFromRawData(rawData);
}
String _pathModeKeyFromRawData(Uint8List rawData) {
if (rawData.length < 5 ||
rawData.first != LiveTrafficSummary.logRxDataResponseCode) {
return 'path_mode_unknown';
}
final rawPacketData = rawData.sublist(3);
if (rawPacketData.length < 2) {
return 'path_mode_unknown';
}
final header = rawPacketData[0];
final routeType = header & 0x03;
var index = 1;
if (routeType == 0x00 || routeType == 0x03) {
if (rawPacketData.length < index + 5) {
return 'path_mode_unknown';
}
index += 4;
}
if (rawPacketData.length <= index) {
return 'path_mode_unknown';
}
final pathDescriptor = rawPacketData[index];
final pathByteLen = LogRxRouteDecoder.descriptorByteLength(pathDescriptor);
if (pathByteLen == null) {
return 'path_mode_unknown';
}
if (rawPacketData.length < index + 1 + pathByteLen) {
return 'path_mode_unknown';
}
if (pathByteLen == 0) {
return 'path_mode_none';
}
final hashSize = LogRxRouteDecoder.descriptorHashSize(pathDescriptor);
switch (hashSize) {
case 1:
return 'path_mode_1b';
case 2:
return 'path_mode_2b';
case 3:
return 'path_mode_3b';
}
return 'path_mode_unknown';
}
Future<void> _saveState() async {
final prefs = await _prefsProvider();
await prefs.setBool(_enabledKey, _enabled);
await prefs.remove(_legacyIntervalKey);
await prefs.setString(
_queueKey,
jsonEncode(_queue.map((report) => report.toJson()).toList()),
);
if (_lastSuccessAt == null) {
await prefs.remove(_lastSuccessAtKey);
} else {
await prefs.setString(
_lastSuccessAtKey,
_lastSuccessAt!.toIso8601String(),
);
}
if (_lastError == null || _lastError!.isEmpty) {
await prefs.remove(_lastErrorKey);
} else {
await prefs.setString(_lastErrorKey, _lastError!);
}
}
static Future<String> _defaultAppVersionProvider() async {
final info = await PackageInfo.fromPlatform();
if (info.buildNumber.isEmpty || info.buildNumber == '0') {
return info.version;
}
return '${info.version}+${info.buildNumber}';
}
@override
void dispose() {
_retryTimer?.cancel();
if (_ownsClient) {
_client.close();
}
super.dispose();
}
}

View File

@@ -1,3 +1,5 @@
import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@@ -119,7 +121,9 @@ Future<bool> showConnectionDialogFlow(
/// Connection Dialog with tabs for BLE devices and Network servers /// Connection Dialog with tabs for BLE devices and Network servers
class ConnectionDialog extends StatefulWidget { class ConnectionDialog extends StatefulWidget {
const ConnectionDialog({super.key}); final NetworkScannerService? networkScanner;
const ConnectionDialog({super.key, this.networkScanner});
@override @override
State<ConnectionDialog> createState() => _ConnectionDialogState(); State<ConnectionDialog> createState() => _ConnectionDialogState();
@@ -129,7 +133,7 @@ class _ConnectionDialogState extends State<ConnectionDialog>
with SingleTickerProviderStateMixin { with SingleTickerProviderStateMixin {
late TabController _tabController; late TabController _tabController;
late final ConnectionProvider _connectionProvider; late final ConnectionProvider _connectionProvider;
final NetworkScannerService _networkScanner = NetworkScannerService(); late final NetworkScannerService _networkScanner;
final List<DiscoveredServer> _discoveredServers = []; final List<DiscoveredServer> _discoveredServers = [];
int _scannedCount = 0; int _scannedCount = 0;
int _totalToScan = 0; int _totalToScan = 0;
@@ -162,6 +166,7 @@ class _ConnectionDialogState extends State<ConnectionDialog>
context, context,
listen: false, listen: false,
); );
_networkScanner = widget.networkScanner ?? NetworkScannerService();
_networkScanner.onServerDiscovered = (server) { _networkScanner.onServerDiscovered = (server) {
if (!mounted) return; if (!mounted) return;
@@ -334,6 +339,9 @@ class _ConnectionDialogState extends State<ConnectionDialog>
required IconData icon, required IconData icon,
required String message, required String message,
required VoidCallback onRefresh, required VoidCallback onRefresh,
IconData? secondaryActionIcon,
String? secondaryActionTooltip,
VoidCallback? onSecondaryAction,
}) { }) {
final theme = Theme.of(context); final theme = Theme.of(context);
return Container( return Container(
@@ -355,18 +363,77 @@ class _ConnectionDialogState extends State<ConnectionDialog>
), ),
), ),
), ),
IconButton( Row(
icon: Icon( mainAxisSize: MainAxisSize.min,
Icons.refresh_rounded, children: [
color: theme.colorScheme.onPrimaryContainer, if (secondaryActionIcon != null)
), IconButton(
onPressed: onRefresh, tooltip: secondaryActionTooltip,
icon: Icon(
secondaryActionIcon,
color: theme.colorScheme.onPrimaryContainer,
),
onPressed: onSecondaryAction,
),
IconButton(
icon: Icon(
Icons.refresh_rounded,
color: theme.colorScheme.onPrimaryContainer,
),
onPressed: onRefresh,
),
],
), ),
], ],
), ),
); );
} }
Future<String?> _promptForManualTcpHost() async {
return showDialog<String>(
context: context,
builder: (dialogContext) => _ManualTcpHostDialog(
initialHost: _connectionProvider.tcpHost,
),
);
}
Future<void> _connectManualTcpHost() async {
final host = await _promptForManualTcpHost();
if (host == null || !mounted) {
return;
}
final serverKey = '$host:${NetworkScannerService.defaultPort}';
final connectionProvider = context.read<ConnectionProvider>();
setState(() {
_connectingToServerKey = serverKey;
});
try {
final success = await connectionProvider.connectTcp(
host,
NetworkScannerService.defaultPort,
);
if (!success) {
throw Exception(
connectionProvider.error ??
'Failed to connect to $host:${NetworkScannerService.defaultPort}',
);
}
_closeOnSuccessfulConnection();
} catch (error) {
if (!mounted) {
return;
}
setState(() {
_connectingToServerKey = null;
});
_showConnectionError(error);
}
}
Widget _buildErrorBanner(String message) { Widget _buildErrorBanner(String message) {
final theme = Theme.of(context); final theme = Theme.of(context);
return Container( return Container(
@@ -404,42 +471,47 @@ class _ConnectionDialogState extends State<ConnectionDialog>
required VoidCallback onAction, required VoidCallback onAction,
}) { }) {
final theme = Theme.of(context); final theme = Theme.of(context);
return Center( return LayoutBuilder(
child: Padding( builder: (context, constraints) => SingleChildScrollView(
padding: const EdgeInsets.all(24), padding: const EdgeInsets.all(24),
child: Column( child: ConstrainedBox(
mainAxisAlignment: MainAxisAlignment.center, constraints: BoxConstraints(minHeight: constraints.maxHeight),
children: [ child: Center(
Container( child: Column(
width: 80, mainAxisAlignment: MainAxisAlignment.center,
height: 80, children: [
decoration: BoxDecoration( Container(
color: theme.colorScheme.surfaceContainerHighest, width: 80,
shape: BoxShape.circle, height: 80,
), decoration: BoxDecoration(
child: Icon( color: theme.colorScheme.surfaceContainerHighest,
icon, shape: BoxShape.circle,
size: 36, ),
color: theme.colorScheme.onSurfaceVariant.withValues( child: Icon(
alpha: 0.8, icon,
size: 36,
color: theme.colorScheme.onSurfaceVariant.withValues(
alpha: 0.8,
),
),
), ),
), const SizedBox(height: 16),
Text(
title,
textAlign: TextAlign.center,
style: theme.textTheme.titleMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 12),
FilledButton.tonalIcon(
onPressed: onAction,
icon: const Icon(Icons.refresh_rounded),
label: Text(actionLabel),
),
],
), ),
const SizedBox(height: 16), ),
Text(
title,
textAlign: TextAlign.center,
style: theme.textTheme.titleMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 12),
FilledButton.tonalIcon(
onPressed: onAction,
icon: const Icon(Icons.refresh_rounded),
label: Text(actionLabel),
),
],
), ),
), ),
); );
@@ -620,6 +692,11 @@ class _ConnectionDialogState extends State<ConnectionDialog>
message: showingCachedResults message: showingCachedResults
? 'Showing cached results. Tap refresh to rescan.' ? 'Showing cached results. Tap refresh to rescan.'
: 'Scanning local network for MeshCore WiFi devices on port 5000', : 'Scanning local network for MeshCore WiFi devices on port 5000',
secondaryActionIcon: Icons.add_rounded,
secondaryActionTooltip: 'Add IP address',
onSecondaryAction: _connectingToServerKey != null
? null
: _connectManualTcpHost,
onRefresh: _startNetworkScan, onRefresh: _startNetworkScan,
), ),
if (_networkScanner.isScanning) if (_networkScanner.isScanning)
@@ -768,6 +845,82 @@ class _ConnectionDialogState extends State<ConnectionDialog>
} }
} }
class _ManualTcpHostDialog extends StatefulWidget {
final String? initialHost;
const _ManualTcpHostDialog({this.initialHost});
@override
State<_ManualTcpHostDialog> createState() => _ManualTcpHostDialogState();
}
class _ManualTcpHostDialogState extends State<_ManualTcpHostDialog> {
late final TextEditingController _controller;
String? _errorText;
@override
void initState() {
super.initState();
_controller = TextEditingController(text: widget.initialHost);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _submit() {
final host = _controller.text.trim();
final parsedAddress = InternetAddress.tryParse(host);
if (parsedAddress == null) {
setState(() {
_errorText = 'Enter a valid IP address';
});
return;
}
Navigator.of(context).pop(parsedAddress.address);
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Connect by IP address'),
content: TextField(
controller: _controller,
autofocus: true,
keyboardType: TextInputType.url,
decoration: InputDecoration(
labelText: 'IP address',
hintText: '192.168.1.42',
helperText: 'Uses TCP port 5000',
border: const OutlineInputBorder(),
errorText: _errorText,
),
onChanged: (_) {
if (_errorText == null) {
return;
}
setState(() {
_errorText = null;
});
},
onSubmitted: (_) => _submit(),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
FilledButton(
onPressed: _submit,
child: const Text('Connect'),
),
],
);
}
}
typedef _TransportCardBuilder = typedef _TransportCardBuilder =
Widget Function({ Widget Function({
required IconData icon, required IconData icon,

View File

@@ -6,11 +6,9 @@ import 'package:latlong2/latlong.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../../models/contact.dart'; import '../../models/contact.dart';
import '../../models/path_history.dart';
import '../../l10n/app_localizations.dart'; import '../../l10n/app_localizations.dart';
import '../../providers/app_provider.dart'; import '../../providers/app_provider.dart';
import '../../providers/connection_provider.dart'; import '../../providers/connection_provider.dart';
import '../../services/path_history_service.dart';
import '../../services/relay_candidate_sorter.dart'; import '../../services/relay_candidate_sorter.dart';
import '../../services/route_hash_preferences.dart'; import '../../services/route_hash_preferences.dart';
@@ -72,7 +70,6 @@ class ContactRouteDialog extends StatefulWidget {
class _ContactRouteDialogState extends State<ContactRouteDialog> { class _ContactRouteDialogState extends State<ContactRouteDialog> {
late final TextEditingController _controller; late final TextEditingController _controller;
late final TextEditingController _relaySearchController; late final TextEditingController _relaySearchController;
final PathHistoryService _pathHistoryService = PathHistoryService();
final RelayCandidateSorter _relayCandidateSorter = final RelayCandidateSorter _relayCandidateSorter =
const RelayCandidateSorter(); const RelayCandidateSorter();
int _selectedHashSize = RouteHashPreferences.defaultHashSize; int _selectedHashSize = RouteHashPreferences.defaultHashSize;
@@ -80,7 +77,6 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
String? _errorText; String? _errorText;
bool _showRoutingInfo = false; bool _showRoutingInfo = false;
List<Contact> _selectedMapHops = const []; List<Contact> _selectedMapHops = const [];
ContactPathHistory? _pathHistory;
@override @override
void initState() { void initState() {
@@ -91,7 +87,6 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
_relaySearchController = TextEditingController(); _relaySearchController = TextEditingController();
_controller.addListener(_reparse); _controller.addListener(_reparse);
_loadHashSizePreference(); _loadHashSizePreference();
_loadPathHistory();
_reparse(); _reparse();
} }
@@ -182,16 +177,6 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
_reparse(); _reparse();
} }
Future<void> _loadPathHistory() async {
await _pathHistoryService.initialize();
if (!mounted) return;
setState(() {
_pathHistory = _pathHistoryService.historyFor(
widget.contact.publicKeyHex,
);
});
}
String _tokenFor(Contact contact, int hashSize) { String _tokenFor(Contact contact, int hashSize) {
final hex = contact.publicKeyHex.toUpperCase(); final hex = contact.publicKeyHex.toUpperCase();
final length = hashSize * 2; final length = hashSize * 2;
@@ -237,21 +222,6 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
}); });
} }
void _applyHistoryRecord(PathRecord record) {
final canonicalText = _canonicalRouteFromBytes(
record.pathBytes,
hashSize: record.hashSize,
);
setState(() {
_controller.text = canonicalText;
_controller.selection = TextSelection.fromPosition(
TextPosition(offset: _controller.text.length),
);
_errorText = null;
});
_reparse();
}
LatLng? _resolveLastHopLocation() { LatLng? _resolveLastHopLocation() {
if (_selectedMapHops.isNotEmpty) { if (_selectedMapHops.isNotEmpty) {
return _selectedMapHops.last.displayLocation == null return _selectedMapHops.last.displayLocation == null
@@ -302,86 +272,6 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
); );
} }
String _canonicalRouteFromBytes(
List<int> pathBytes, {
required int hashSize,
}) {
final hops = <String>[];
for (var i = 0; i < pathBytes.length; i += hashSize) {
final hop = pathBytes.sublist(i, i + hashSize);
hops.add(
hop
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
.join()
.toUpperCase(),
);
}
return hops.join(',');
}
String _historySubtitle(PathRecord record) {
final attempts = record.successCount + record.failureCount;
final lastSeen = MaterialLocalizations.of(
context,
).formatShortDate(record.lastUsedAt);
final sourceLabel = switch (record.source) {
PathRecordSource.observed => 'Observed on mesh',
PathRecordSource.learned => 'Learned route',
};
final successRate = attempts == 0
? 'No send stats yet'
: '${(record.successRate * 100).round()}% success over $attempts send${attempts == 1 ? '' : 's'}';
final latency = record.lastRoundTripTimeMs > 0
? '${record.lastRoundTripTimeMs} ms'
: '';
return '$sourceLabel$successRate • Last used $lastSeen$latency';
}
Widget _buildHistoryRecordTile(PathRecord record, {String? title}) {
final canonicalText = _canonicalRouteFromBytes(
record.pathBytes,
hashSize: record.hashSize,
);
return Card(
margin: EdgeInsets.zero,
child: ListTile(
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 10,
),
leading: title == null ? null : const Icon(Icons.alt_route),
title: title == null
? Text(
canonicalText,
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(fontFamily: 'monospace'),
)
: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: Theme.of(context).textTheme.titleSmall),
const SizedBox(height: 6),
Text(
canonicalText,
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(fontFamily: 'monospace'),
),
],
),
subtitle: Padding(
padding: const EdgeInsets.only(top: 6),
child: Text(_historySubtitle(record)),
),
trailing: FilledButton.tonal(
onPressed: () => _applyHistoryRecord(record),
child: Text(AppLocalizations.of(context)!.use),
),
),
);
}
Widget _buildPreviewSection() { Widget _buildPreviewSection() {
final previewRoute = _effectiveRoute; final previewRoute = _effectiveRoute;
if (previewRoute == null) { if (previewRoute == null) {
@@ -676,7 +566,6 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
_showRoutingInfo = !_showRoutingInfo; _showRoutingInfo = !_showRoutingInfo;
}); });
}, },
autoRouteRotationEnabled: appProvider.autoRouteRotationEnabled,
nearestRelayFallbackEnabled: appProvider.nearestRelayFallbackEnabled, nearestRelayFallbackEnabled: appProvider.nearestRelayFallbackEnabled,
clearPathOnMaxRetry: appProvider.clearPathOnMaxRetry, clearPathOnMaxRetry: appProvider.clearPathOnMaxRetry,
), ),
@@ -685,81 +574,6 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
); );
} }
Widget _buildHistoryTab() {
final records = List<PathRecord>.from(_pathHistory?.directPaths ?? const [])
..sort((a, b) => b.lastUsedAt.compareTo(a.lastUsedAt));
if (records.isEmpty) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Text(
'No historical paths for this contact yet.',
style: Theme.of(context).textTheme.bodyMedium,
textAlign: TextAlign.center,
),
),
);
}
PathRecord? observedRecord;
for (final record in records) {
if (record.source == PathRecordSource.observed) {
observedRecord = record;
break;
}
}
final remainingRecords = observedRecord == null
? records
: records
.where((record) => !identical(record, observedRecord))
.toList();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Align(
alignment: Alignment.centerRight,
child: TextButton(
onPressed: () async {
await _pathHistoryService.clearHistoryFor(
widget.contact.publicKeyHex,
);
if (!mounted) return;
setState(() {
_pathHistory = _pathHistoryService.historyFor(
widget.contact.publicKeyHex,
);
});
},
child: const Text('Clear history'),
),
),
const SizedBox(height: 8),
if (observedRecord != null) ...[
_buildHistoryRecordTile(observedRecord, title: AppLocalizations.of(context)!.observedMeshRoute),
const SizedBox(height: 16),
],
if (remainingRecords.isEmpty)
Text(
observedRecord == null
? 'No additional route history yet.'
: 'Observed routes you start using will continue to build history here.',
style: Theme.of(context).textTheme.bodyMedium,
)
else
ListView.separated(
itemCount: remainingRecords.length,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
separatorBuilder: (_, _) => const SizedBox(height: 8),
itemBuilder: (context, index) {
return _buildHistoryRecordTile(remainingRecords[index]);
},
),
],
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final effectiveRoute = _effectiveRoute; final effectiveRoute = _effectiveRoute;
@@ -804,14 +618,13 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
]; ];
return DefaultTabController( return DefaultTabController(
length: 3, length: 2,
child: Scaffold( child: Scaffold(
appBar: AppBar( appBar: AppBar(
title: Text('Set Path for ${widget.contact.displayName}'), title: Text('Set Path for ${widget.contact.displayName}'),
bottom: const TabBar( bottom: const TabBar(
tabs: [ tabs: [
Tab(text: 'Build'), Tab(text: 'Build'),
Tab(text: 'History'),
Tab(text: 'Info'), Tab(text: 'Info'),
], ],
), ),
@@ -829,12 +642,6 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
const SizedBox(height: 24), const SizedBox(height: 24),
], ],
), ),
ListView(
children: [
_buildHistoryTab(),
const SizedBox(height: 24),
],
),
_buildInfoTab( _buildInfoTab(
appProvider: appProvider, appProvider: appProvider,
routeCandidates: routeCandidates, routeCandidates: routeCandidates,
@@ -914,14 +721,12 @@ class _RouteMarkerDot extends StatelessWidget {
class _AutomationRoutingInfo extends StatelessWidget { class _AutomationRoutingInfo extends StatelessWidget {
final bool isExpanded; final bool isExpanded;
final VoidCallback onToggle; final VoidCallback onToggle;
final bool autoRouteRotationEnabled;
final bool nearestRelayFallbackEnabled; final bool nearestRelayFallbackEnabled;
final bool clearPathOnMaxRetry; final bool clearPathOnMaxRetry;
const _AutomationRoutingInfo({ const _AutomationRoutingInfo({
required this.isExpanded, required this.isExpanded,
required this.onToggle, required this.onToggle,
required this.autoRouteRotationEnabled,
required this.nearestRelayFallbackEnabled, required this.nearestRelayFallbackEnabled,
required this.clearPathOnMaxRetry, required this.clearPathOnMaxRetry,
}); });
@@ -970,7 +775,7 @@ class _AutomationRoutingInfo extends StatelessWidget {
if (isExpanded) ...[ if (isExpanded) ...[
const SizedBox(height: 8), const SizedBox(height: 8),
Text( 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.', 'Room/contact sends use the current direct path when one is known, switch to flood on the last normal retry, then try one final nearest repeater if everything else fails.',
style: Theme.of(context).textTheme.bodySmall, style: Theme.of(context).textTheme.bodySmall,
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
@@ -983,12 +788,6 @@ class _AutomationRoutingInfo extends StatelessWidget {
spacing: 8, spacing: 8,
runSpacing: 8, runSpacing: 8,
children: [ children: [
_InfoChip(
label: autoRouteRotationEnabled
? 'Auto route rotation on'
: 'Auto route rotation off',
icon: Icons.swap_horiz,
),
_InfoChip( _InfoChip(
label: nearestRelayFallbackEnabled label: nearestRelayFallbackEnabled
? 'Nearest repeater fallback on' ? 'Nearest repeater fallback on'
@@ -1006,7 +805,7 @@ class _AutomationRoutingInfo extends StatelessWidget {
] else ...[ ] else ...[
const SizedBox(height: 6), const SizedBox(height: 6),
Text( Text(
'Shows retry, rotation, and final repeater fallback behavior.', 'Shows retry and final repeater fallback behavior.',
style: Theme.of(context).textTheme.bodySmall, style: Theme.of(context).textTheme.bodySmall,
), ),
], ],

View File

@@ -6,6 +6,7 @@ import 'package:geolocator/geolocator.dart';
import 'package:latlong2/latlong.dart'; import 'package:latlong2/latlong.dart';
import '../../models/contact.dart'; import '../../models/contact.dart';
import '../../models/room_login_state.dart'; import '../../models/room_login_state.dart';
import '../../providers/app_provider.dart';
import '../../providers/connection_provider.dart'; import '../../providers/connection_provider.dart';
import '../../providers/contacts_provider.dart'; import '../../providers/contacts_provider.dart';
import '../../providers/map_provider.dart'; import '../../providers/map_provider.dart';
@@ -13,7 +14,9 @@ import '../../providers/messages_provider.dart';
import '../../providers/sensors_provider.dart'; import '../../providers/sensors_provider.dart';
import '../../services/location_tracking_service.dart'; import '../../services/location_tracking_service.dart';
import '../../services/message_destination_preferences.dart'; import '../../services/message_destination_preferences.dart';
import '../../services/path_history_service.dart';
import 'contact_route_dialog.dart'; import 'contact_route_dialog.dart';
import 'ping_contact_sheet.dart';
import 'contact_trace_sheet.dart'; import 'contact_trace_sheet.dart';
import 'room_login_sheet.dart'; import 'room_login_sheet.dart';
import '../common/contact_avatar.dart'; import '../common/contact_avatar.dart';
@@ -64,6 +67,27 @@ class ContactTile extends StatelessWidget {
.toUpperCase(); .toUpperCase();
} }
Widget _buildChannelLocationSharingMarker(
BuildContext context,
ChannelLocationSharingMode mode,
) {
const accent = Color(0xFF16A34A);
return Container(
padding: const EdgeInsets.all(3),
decoration: BoxDecoration(
color: accent,
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 2),
),
child: Icon(
Icons.location_pin,
size: 11,
color: Colors.white,
),
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final isChannel = contact.type == ContactType.channel; final isChannel = contact.type == ContactType.channel;
@@ -88,6 +112,7 @@ class ContactTile extends StatelessWidget {
// Get room login state if this is a room // Get room login state if this is a room
final connectionProvider = context.watch<ConnectionProvider>(); final connectionProvider = context.watch<ConnectionProvider>();
final messagesProvider = context.watch<MessagesProvider>(); final messagesProvider = context.watch<MessagesProvider>();
final appProvider = context.watch<AppProvider>();
final isPingInProgress = connectionProvider.isPingInProgress( final isPingInProgress = connectionProvider.isPingInProgress(
contact.publicKey, contact.publicKey,
); );
@@ -132,6 +157,10 @@ class ContactTile extends StatelessWidget {
final lastActivityAt = isRoomOrChannel final lastActivityAt = isRoomOrChannel
? messagesProvider.getLastActivityForDestination(contact) ? messagesProvider.getLastActivityForDestination(contact)
: null; : null;
final channelLocationSharingMode =
isChannel && !contact.isPublicChannel && contact.publicKey.length > 1
? appProvider.channelLocationSharingModeForChannel(contact.publicKey[1])
: null;
final timeAgoText = _getLocalizedRelativeTime( final timeAgoText = _getLocalizedRelativeTime(
context, context,
lastActivityAt ?? contact.lastSeenTime, lastActivityAt ?? contact.lastSeenTime,
@@ -237,6 +266,15 @@ class ContactTile extends StatelessWidget {
), ),
), ),
), ),
if (isChannel && channelLocationSharingMode != null)
Positioned(
bottom: -2,
right: -2,
child: _buildChannelLocationSharingMarker(
context,
channelLocationSharingMode,
),
),
if (contact.type == ContactType.room && if (contact.type == ContactType.room &&
roomLoginState != null) roomLoginState != null)
Positioned( Positioned(
@@ -381,6 +419,68 @@ class ContactTile extends StatelessWidget {
_showContactActionSheet(context, contact); _showContactActionSheet(context, contact);
} }
String _locationSharingErrorMessage(Object error) {
final message = error.toString();
if (message.startsWith('Bad state: ')) {
return message.substring('Bad state: '.length);
}
return message;
}
Future<void> _setChannelLocationSharing(
BuildContext context,
Contact contact,
bool enabled,
) async {
final channelIdx = contact.publicKey.length > 1 ? contact.publicKey[1] : 0;
try {
final result = await context.read<AppProvider>().setChannelLocationSharingEnabled(
channelIdx,
enabled,
);
if (!context.mounted) return;
ToastLogger.success(context, result.message);
} catch (error) {
if (!context.mounted) return;
ToastLogger.error(context, _locationSharingErrorMessage(error));
}
}
Future<void> _handleChannelLocationSharingAction(
BuildContext context,
Contact contact,
) async {
if (contact.isPublicChannel) {
if (!context.mounted) return;
ToastLogger.error(
context,
'Select a private channel to share your location.',
);
return;
}
final channelIdx = contact.publicKey.length > 1 ? contact.publicKey[1] : 0;
if (channelIdx <= 0) {
if (!context.mounted) return;
ToastLogger.error(
context,
'Select a private channel to share your location.',
);
return;
}
try {
final sharingState = await context
.read<AppProvider>()
.getChannelLocationSharingState(channelIdx);
if (!context.mounted) return;
await _setChannelLocationSharing(context, contact, !sharingState.isSharing);
} catch (error) {
if (!context.mounted) return;
ToastLogger.error(context, _locationSharingErrorMessage(error));
}
}
void _showContactActionSheet(BuildContext context, Contact contact) { void _showContactActionSheet(BuildContext context, Contact contact) {
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context)!;
final canToggleFavourite = !contact.isChannel; final canToggleFavourite = !contact.isChannel;
@@ -400,179 +500,220 @@ class ContactTile extends StatelessWidget {
final canPreviewSensor = contact.isSensor; final canPreviewSensor = contact.isSensor;
final sensorsProvider = context.read<SensorsProvider>(); final sensorsProvider = context.read<SensorsProvider>();
final isInSensors = sensorsProvider.isWatched(contact.publicKeyHex); final isInSensors = sensorsProvider.isWatched(contact.publicKeyHex);
final channelLocationSharingFuture =
final primaryActions = <_ContactSheetAction>[ contact.type == ContactType.channel &&
if (canMessage) !contact.isPublicChannel &&
_ContactSheetAction( contact.publicKey.length > 1
icon: Icons.message_outlined, ? context
label: l10n.messages, .read<AppProvider>()
onTap: () async { .getChannelLocationSharingState(contact.publicKey[1])
Navigator.pop(context); : null;
await _openMessagesForContact(context, contact);
},
),
if (!contact.isChannel)
_ContactSheetAction(
icon: Icons.share_outlined,
label: l10n.share,
onTap: () async {
Navigator.pop(context);
final connectionProvider = context.read<ConnectionProvider>();
final url = await connectionProvider.exportContactUrl(
contact.publicKey,
);
if (url != null && context.mounted) {
await Clipboard.setData(ClipboardData(text: url));
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l10n.contactLinkCopiedToClipboard)),
);
}
} else if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l10n.failedToExportContact)),
);
}
},
),
if (canSetPath)
_ContactSheetAction(
icon: Icons.alt_route,
label: l10n.contactSetPath,
onTap: () async {
Navigator.pop(context);
await _showSetRouteDialog(context, contact);
},
),
if (!contact.isPublicChannel)
_ContactSheetAction(
icon: Icons.delete_outline_rounded,
label: l10n.delete,
destructive: true,
onTap: () async {
Navigator.pop(context);
await Future<void>.delayed(Duration.zero);
if (!context.mounted) return;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!context.mounted) return;
if (contact.isChannel) {
_showDeleteChannelDialog(context, contact);
} else {
_showDeleteConfirmation(context, contact);
}
});
},
),
];
final secondaryActions = <_ContactSheetAction>[
if (contact.displayLocation != null)
_ContactSheetAction(
icon: Icons.map_outlined,
label: l10n.viewOnMap,
onTap: () async {
Navigator.pop(context);
_showContactOnMap(context, contact);
},
),
if (contact.type == ContactType.room && !contact.isPublicChannel)
_ContactSheetAction(
icon: Icons.login,
label:
context
.read<ConnectionProvider>()
.getRoomLoginState(contact.publicKeyPrefix)
?.isLoggedIn ==
true
? l10n.reLoginToRoom
: l10n.loginToRoom,
onTap: () async {
Navigator.pop(context);
_showRoomLoginDialog(context, contact);
},
),
if (canPreviewSensor)
_ContactSheetAction(
icon: Icons.visibility_outlined,
label: l10n.preview,
onTap: () async {
Navigator.pop(context);
await Future<void>.delayed(Duration.zero);
if (!context.mounted) return;
await _showSensorPreviewView(context, contact);
},
),
if (canAddToSensors)
_ContactSheetAction(
icon: isInSensors ? Icons.sensors : Icons.sensors_outlined,
label: isInSensors ? l10n.contactInSensors : l10n.contactAddToSensors,
enabled: !isInSensors,
onTap: () async {
Navigator.pop(context);
await _addContactToSensors(context, contact);
},
),
if (!contact.isChannel)
_ContactSheetAction(
icon: Icons.route,
label: l10n.trace,
onTap: () async {
Navigator.pop(context);
_showTraceSheet(context, contact);
},
),
if (contact.type == ContactType.repeater)
_ContactSheetAction(
icon: Icons.hub_outlined,
label: 'View Neighbours',
onTap: () async {
Navigator.pop(context);
_showNeighbours(context, contact);
},
),
if (contact.type == ContactType.repeater ||
contact.type == ContactType.room)
_ContactSheetAction(
icon: Icons.network_ping,
label: 'Ping',
onTap: () async {
Navigator.pop(context);
_pingRelay(context, contact);
},
),
if (!contact.isPublicChannel)
_ContactSheetAction(
icon: Icons.edit_outlined,
label: l10n.editName,
onTap: () async {
Navigator.pop(context);
_showNameOverrideDialog(context, contact);
},
),
];
showModalBottomSheet<void>( showModalBottomSheet<void>(
context: context, context: context,
isScrollControlled: true, isScrollControlled: true,
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
builder: (sheetContext) => _ContactActionSheet( builder: (sheetContext) {
contact: contact, Widget buildSheet(ChannelLocationSharingState? sharingState) {
primaryActions: primaryActions, final primaryActions = <_ContactSheetAction>[
secondaryActions: secondaryActions, if (canMessage)
showFavouriteButton: canToggleFavourite, _ContactSheetAction(
initialFavourite: contact.isFavourite, icon: Icons.message_outlined,
onClose: () => Navigator.pop(sheetContext), label: l10n.messages,
onToggleFavourite: !canToggleFavourite onTap: () async {
? null Navigator.pop(context);
: () async { await _openMessagesForContact(context, contact);
final toggled = contact.toggleFavourite(); },
final connectionProvider = context.read<ConnectionProvider>(); ),
await connectionProvider.addOrUpdateContact(toggled); if (contact.type == ContactType.channel)
if (context.mounted) { _ContactSheetAction(
await connectionProvider.getContact(contact.publicKey); icon: sharingState?.isSharing == true
} ? Icons.location_off_rounded
}, : Icons.share_location_rounded,
), label: sharingState?.isSharing == true
? 'Stop sharing my location'
: 'Share my location',
onTap: () async {
Navigator.pop(context);
await _handleChannelLocationSharingAction(context, contact);
},
),
if (!contact.isChannel)
_ContactSheetAction(
icon: Icons.share_outlined,
label: l10n.share,
onTap: () async {
Navigator.pop(context);
final connectionProvider = context.read<ConnectionProvider>();
final url = await connectionProvider.exportContactUrl(
contact.publicKey,
);
if (url != null && context.mounted) {
await Clipboard.setData(ClipboardData(text: url));
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(l10n.contactLinkCopiedToClipboard),
),
);
}
} else if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l10n.failedToExportContact)),
);
}
},
),
if (canSetPath)
_ContactSheetAction(
icon: Icons.alt_route,
label: l10n.contactSetPath,
onTap: () async {
Navigator.pop(context);
await _showSetRouteDialog(context, contact);
},
),
if (!contact.isPublicChannel)
_ContactSheetAction(
icon: Icons.delete_outline_rounded,
label: l10n.delete,
destructive: true,
onTap: () async {
Navigator.pop(context);
await Future<void>.delayed(Duration.zero);
if (!context.mounted) return;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!context.mounted) return;
if (contact.isChannel) {
_showDeleteChannelDialog(context, contact);
} else {
_showDeleteConfirmation(context, contact);
}
});
},
),
];
final secondaryActions = <_ContactSheetAction>[
if (contact.displayLocation != null)
_ContactSheetAction(
icon: Icons.map_outlined,
label: l10n.viewOnMap,
onTap: () async {
Navigator.pop(context);
_showContactOnMap(context, contact);
},
),
if (contact.type == ContactType.room && !contact.isPublicChannel)
_ContactSheetAction(
icon: Icons.login,
label:
context
.read<ConnectionProvider>()
.getRoomLoginState(contact.publicKeyPrefix)
?.isLoggedIn ==
true
? l10n.reLoginToRoom
: l10n.loginToRoom,
onTap: () async {
Navigator.pop(context);
_showRoomLoginDialog(context, contact);
},
),
if (canPreviewSensor)
_ContactSheetAction(
icon: Icons.visibility_outlined,
label: l10n.preview,
onTap: () async {
Navigator.pop(context);
await Future<void>.delayed(Duration.zero);
if (!context.mounted) return;
await _showSensorPreviewView(context, contact);
},
),
if (canAddToSensors)
_ContactSheetAction(
icon: isInSensors ? Icons.sensors : Icons.sensors_outlined,
label: isInSensors
? l10n.contactInSensors
: l10n.contactAddToSensors,
enabled: !isInSensors,
onTap: () async {
Navigator.pop(context);
await _addContactToSensors(context, contact);
},
),
if (!contact.isChannel)
_ContactSheetAction(
icon: Icons.route,
label: l10n.trace,
onTap: () async {
Navigator.pop(context);
_showTraceSheet(context, contact);
},
),
if (contact.type == ContactType.repeater)
_ContactSheetAction(
icon: Icons.hub_outlined,
label: 'View Neighbours',
onTap: () async {
Navigator.pop(context);
_showNeighbours(context, contact);
},
),
if (contact.type == ContactType.repeater ||
contact.type == ContactType.room)
_ContactSheetAction(
icon: Icons.network_ping,
label: 'Ping',
onTap: () async {
Navigator.pop(context);
_pingRelay(context, contact);
},
),
if (!contact.isPublicChannel)
_ContactSheetAction(
icon: Icons.edit_outlined,
label: l10n.editName,
onTap: () async {
Navigator.pop(context);
_showNameOverrideDialog(context, contact);
},
),
];
return _ContactActionSheet(
contact: contact,
primaryActions: primaryActions,
secondaryActions: secondaryActions,
showFavouriteButton: canToggleFavourite,
initialFavourite: contact.isFavourite,
onClose: () => Navigator.pop(sheetContext),
onToggleFavourite: !canToggleFavourite
? null
: () async {
final toggled = contact.toggleFavourite();
final connectionProvider =
context.read<ConnectionProvider>();
await connectionProvider.addOrUpdateContact(toggled);
if (context.mounted) {
await connectionProvider.getContact(contact.publicKey);
}
},
);
}
if (channelLocationSharingFuture == null) {
return buildSheet(null);
}
return FutureBuilder<ChannelLocationSharingState>(
future: channelLocationSharingFuture,
builder: (context, snapshot) {
return buildSheet(snapshot.data);
},
);
},
); );
} }
@@ -683,7 +824,7 @@ class ContactTile extends StatelessWidget {
shape: const RoundedRectangleBorder( shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)), borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
), ),
builder: (context) => _PingRelaySheet(contact: contact), builder: (context) => PingContactSheet(contact: contact),
); );
} }
@@ -786,6 +927,7 @@ class ContactTile extends StatelessWidget {
) async { ) async {
final contactsProvider = context.read<ContactsProvider>(); final contactsProvider = context.read<ContactsProvider>();
final connectionProvider = context.read<ConnectionProvider>(); final connectionProvider = context.read<ConnectionProvider>();
final pathHistoryService = PathHistoryService();
final availableContacts = contactsProvider.contacts final availableContacts = contactsProvider.contacts
.where((candidate) => candidate.publicKeyHex != contact.publicKeyHex) .where((candidate) => candidate.publicKeyHex != contact.publicKeyHex)
.toList(); .toList();
@@ -822,6 +964,7 @@ class ContactTile extends StatelessWidget {
return; return;
} }
await pathHistoryService.clearManualRouteFor(contact.publicKeyHex);
if (context.mounted) { if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
@@ -846,6 +989,7 @@ class ContactTile extends StatelessWidget {
signedEncodedPathLen: parsedRoute.signedEncodedPathLen, signedEncodedPathLen: parsedRoute.signedEncodedPathLen,
paddedPathBytes: parsedRoute.paddedPathBytes, paddedPathBytes: parsedRoute.paddedPathBytes,
); );
await pathHistoryService.setManualRouteForContact(contact, parsedRoute);
if (context.mounted) { if (context.mounted) {
final routeLabel = parsedRoute.hopCount == 0 final routeLabel = parsedRoute.hopCount == 0
? AppLocalizations.of(context)!.direct ? AppLocalizations.of(context)!.direct

View File

@@ -0,0 +1,638 @@
import 'dart:async';
import 'dart:io';
import 'dart:math' as math;
import 'dart:typed_data';
import 'package:audioplayers/audioplayers.dart';
import 'package:flutter/material.dart';
import 'package:geolocator/geolocator.dart';
import 'package:path_provider/path_provider.dart';
import 'package:provider/provider.dart';
import '../../models/contact.dart';
import '../../providers/connection_provider.dart';
import '../../services/location_tracking_service.dart';
import '../../utils/link_quality.dart';
import '../../utils/time_ago_extensions.dart';
class PingContactSheet extends StatefulWidget {
final Contact contact;
const PingContactSheet({super.key, required this.contact});
@override
State<PingContactSheet> createState() => _PingContactSheetState();
}
class _PingEntry {
final RelayPingResult result;
final DateTime timestamp;
final String? distance;
const _PingEntry({
required this.result,
required this.timestamp,
this.distance,
});
}
class _PingContactSheetState extends State<PingContactSheet> {
static const Duration _autoPingInterval = Duration(seconds: 2);
static const Duration _maxAutoPingDelay = Duration(seconds: 5);
static const int _maxHistoryEntries = 24;
static const double _maxHistoryHeight = 320;
static const Duration _timeoutPenalty = Duration(seconds: 10);
bool _pinging = false;
bool _autoPingEnabled = false;
final List<_PingEntry> _history = [];
final _PingCuePlayer _cuePlayer = _PingCuePlayer();
Timer? _autoPingTimer;
@override
void initState() {
super.initState();
_doPing();
}
@override
void dispose() {
_autoPingTimer?.cancel();
_cuePlayer.dispose();
super.dispose();
}
Future<void> _doPing() async {
if (_pinging) {
return;
}
setState(() => _pinging = true);
final connectionProvider = context.read<ConnectionProvider>();
final distance = _distanceText();
final result = await connectionProvider.pingRelay(widget.contact);
if (!mounted) return;
setState(() {
_pinging = false;
_history.insert(
0,
_PingEntry(
result: result,
timestamp: DateTime.now(),
distance: distance,
),
);
if (_history.length > _maxHistoryEntries) {
_history.removeRange(_maxHistoryEntries, _history.length);
}
});
await _maybePlayCue(result);
_scheduleNextAutoPing(result);
}
Future<void> _maybePlayCue(RelayPingResult result) async {
if (!_autoPingEnabled) {
return;
}
final previous = _history.length >= 2 ? _history[1].result : null;
await _cuePlayer.playCue(result, previous: previous);
}
void _setAutoPingEnabled(bool enabled) {
setState(() {
_autoPingEnabled = enabled;
});
_autoPingTimer?.cancel();
if (!enabled) {
return;
}
_scheduleNextAutoPing();
}
void _scheduleNextAutoPing([RelayPingResult? result]) {
_autoPingTimer?.cancel();
if (!_autoPingEnabled || !mounted) {
return;
}
final responseDelay = result == null
? Duration.zero
: result.success
? Duration(milliseconds: result.durationMs)
: _timeoutPenalty;
final nextDelay = _autoPingInterval + responseDelay;
final clampedDelay = nextDelay > _maxAutoPingDelay
? _maxAutoPingDelay
: nextDelay;
_autoPingTimer = Timer(clampedDelay, () {
unawaited(_doPing());
});
}
String? _distanceText() {
final location = widget.contact.displayLocation;
if (location == null) return null;
final currentPosition = LocationTrackingService().currentPosition;
if (currentPosition == null) return null;
final meters = Geolocator.distanceBetween(
currentPosition.latitude,
currentPosition.longitude,
location.latitude,
location.longitude,
);
if (meters < 1000) return '${meters.round()} m';
if (meters < 10000) return '${(meters / 1000).toStringAsFixed(2)} km';
return '${(meters / 1000).toStringAsFixed(1)} km';
}
Widget _buildPill(
BuildContext context, {
required IconData icon,
required String label,
Color? iconColor,
}) {
final colorScheme = Theme.of(context).colorScheme;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.75),
borderRadius: BorderRadius.circular(999),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 12, color: iconColor ?? colorScheme.onSurfaceVariant),
const SizedBox(width: 4),
Text(
label,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w700,
),
),
],
),
);
}
Widget _buildSnrPill(BuildContext context, String direction, double snrDb) {
final quality = linkQualityLabel(null, snrDb);
final color = linkQualityColor(quality);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(999),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
direction == 'there'
? Icons.arrow_upward_rounded
: Icons.arrow_downward_rounded,
size: 12,
color: color,
),
const SizedBox(width: 4),
Text(
'${snrDb.toStringAsFixed(1)} dB',
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: color,
fontWeight: FontWeight.w700,
),
),
],
),
);
}
Widget _buildResultRow(BuildContext context, _PingEntry entry, int seq) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final r = entry.result;
final age = DateTime.now().difference(entry.timestamp);
final timeAgo = age.toLocalizedTimeAgoWithSeconds(context);
if (!r.success) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
children: [
CircleAvatar(
radius: 12,
backgroundColor: colorScheme.error.withValues(alpha: 0.15),
child: Text(
'$seq',
style: TextStyle(
color: colorScheme.error,
fontSize: 10,
fontWeight: FontWeight.bold,
),
),
),
const SizedBox(width: 10),
Text(
'Timeout',
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.error,
fontWeight: FontWeight.w600,
),
),
const Spacer(),
Text(
timeAgo,
style: theme.textTheme.labelSmall?.copyWith(
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.6),
),
),
],
),
);
}
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
CircleAvatar(
radius: 12,
backgroundColor: colorScheme.surfaceContainerHighest,
child: Text(
'$seq',
style: TextStyle(
color: colorScheme.onSurfaceVariant,
fontSize: 10,
fontWeight: FontWeight.bold,
),
),
),
const SizedBox(width: 10),
_buildPill(
context,
icon: Icons.timer_outlined,
label: '${r.durationMs} ms',
),
const SizedBox(width: 6),
_buildSnrPill(context, 'there', r.snrThere),
const SizedBox(width: 6),
_buildSnrPill(context, 'back', r.snrBack),
const Spacer(),
Text(
timeAgo,
style: theme.textTheme.labelSmall?.copyWith(
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.6),
fontSize: 10,
),
),
],
),
Padding(
padding: const EdgeInsets.only(left: 34, top: 4),
child: Row(
children: [
if (entry.distance != null) ...[
Icon(
Icons.straighten,
size: 10,
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.5),
),
const SizedBox(width: 3),
Text(
entry.distance!,
style: theme.textTheme.labelSmall?.copyWith(
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.6),
fontSize: 10,
),
),
const SizedBox(width: 8),
],
],
),
),
],
),
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
'Ping ${widget.contact.displayName}',
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w800,
),
),
),
FilledButton.icon(
onPressed: _pinging ? null : _doPing,
icon: _pinging
? SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
color: colorScheme.onPrimary,
),
)
: const Icon(Icons.network_ping, size: 18),
label: Text(_pinging ? 'Pinging...' : 'Ping again'),
),
],
),
const SizedBox(height: 8),
Text(
widget.contact.publicKeyShort,
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
fontFamily: 'monospace',
),
),
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: colorScheme.outlineVariant.withValues(alpha: 0.4),
),
),
child: Row(
children: [
Icon(
_autoPingEnabled
? Icons.radar_rounded
: Icons.radar_outlined,
color: _autoPingEnabled
? colorScheme.primary
: colorScheme.onSurfaceVariant,
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Auto ping',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
),
),
Text(
'2s sonar cues for there/back SNR',
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
Switch.adaptive(
value: _autoPingEnabled,
onChanged: _setAutoPingEnabled,
),
],
),
),
const SizedBox(height: 16),
if (_history.isEmpty && !_pinging)
Text(
'No ping results yet.',
style: theme.textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
)
else
ConstrainedBox(
constraints: const BoxConstraints(
maxHeight: _maxHistoryHeight,
),
child: ListView.separated(
shrinkWrap: true,
itemCount: _history.length,
separatorBuilder: (_, _) => Divider(
height: 1,
color: colorScheme.outlineVariant.withValues(alpha: 0.4),
),
itemBuilder: (context, index) =>
_buildResultRow(context, _history[index], index + 1),
),
),
],
),
),
);
}
}
class _PingCuePlayer {
static const double _fairSnrThresholdDb = 0;
AudioPlayer? _player;
Future<void> playCue(
RelayPingResult result, {
RelayPingResult? previous,
}) async {
_player ??= AudioPlayer();
final player = _player!;
final filePath = await _writeCueFile(
result,
previous: previous,
);
await player.stop();
await player.setVolume(1.0);
await player.play(DeviceFileSource(filePath));
}
Future<String> _writeCueFile(
RelayPingResult result, {
RelayPingResult? previous,
}) async {
final tempDir = await getTemporaryDirectory();
final file = File('${tempDir.path}/ping_sonar_alert.wav');
final samples = _buildCueSamples(result, previous: previous);
final wavBytes = _buildWav(samples, sampleRate: 16000);
await file.writeAsBytes(wavBytes, flush: true);
return file.path;
}
Int16List _buildCueSamples(
RelayPingResult result, {
RelayPingResult? previous,
}) {
const sampleRate = 16000;
final segments = <double>[];
if (!result.success) {
segments.addAll(
_timeoutAlert(sampleRate),
);
} else {
segments.addAll(
_tone(
sampleRate,
frequencyHz: _frequencyForSnr(result.snrThere),
durationMs: 90,
amplitude: 0.42,
),
);
segments.addAll(_silence(sampleRate, durationMs: 35));
segments.addAll(
_tone(
sampleRate,
frequencyHz: _frequencyForSnr(result.snrBack),
durationMs: 90,
amplitude: 0.42,
),
);
final belowFair =
result.snrThere < _fairSnrThresholdDb &&
result.snrBack < _fairSnrThresholdDb;
final worsening =
previous != null &&
result.snrThere < previous.snrThere &&
result.snrBack < previous.snrBack;
if (belowFair && worsening) {
segments.addAll(_silence(sampleRate, durationMs: 40));
segments.addAll(
_descendingAlert(sampleRate, baseFrequencyHz: 520),
);
}
}
final pcm = Int16List(segments.length);
for (var i = 0; i < segments.length; i++) {
pcm[i] = (segments[i] * 32767).round().clamp(-32768, 32767);
}
return pcm;
}
double _frequencyForSnr(double snrDb) {
final clamped = snrDb.clamp(-12.0, 12.0);
final normalized = (clamped + 12.0) / 24.0;
return 320 + (normalized * 900);
}
List<double> _descendingAlert(int sampleRate, {required double baseFrequencyHz}) {
return <double>[
..._tone(
sampleRate,
frequencyHz: baseFrequencyHz,
durationMs: 80,
amplitude: 0.44,
),
..._silence(sampleRate, durationMs: 30),
..._tone(
sampleRate,
frequencyHz: baseFrequencyHz * 0.82,
durationMs: 110,
amplitude: 0.40,
),
];
}
List<double> _timeoutAlert(int sampleRate) {
return <double>[
..._tone(
sampleRate,
frequencyHz: 240,
durationMs: 180,
amplitude: 0.46,
),
..._silence(sampleRate, durationMs: 60),
..._tone(
sampleRate,
frequencyHz: 180,
durationMs: 220,
amplitude: 0.52,
),
];
}
List<double> _tone(
int sampleRate, {
required double frequencyHz,
required int durationMs,
required double amplitude,
}) {
final sampleCount = (sampleRate * durationMs / 1000).round();
return List<double>.generate(sampleCount, (index) {
final t = index / sampleRate;
final envelope = math.sin(math.pi * index / sampleCount);
return math.sin(2 * math.pi * frequencyHz * t) * amplitude * envelope;
});
}
List<double> _silence(int sampleRate, {required int durationMs}) {
final sampleCount = (sampleRate * durationMs / 1000).round();
return List<double>.filled(sampleCount, 0);
}
Uint8List _buildWav(Int16List samples, {required int sampleRate}) {
const numChannels = 1;
const bitsPerSample = 16;
const audioFormat = 1;
final dataSize = samples.length * 2;
final byteRate = sampleRate * numChannels * bitsPerSample ~/ 8;
final blockAlign = numChannels * bitsPerSample ~/ 8;
final totalSize = 36 + dataSize;
final buffer = ByteData(44 + dataSize);
var offset = 0;
void writeString(String value) {
for (final codeUnit in value.codeUnits) {
buffer.setUint8(offset++, codeUnit);
}
}
void writeUint32(int value) {
buffer.setUint32(offset, value, Endian.little);
offset += 4;
}
void writeUint16(int value) {
buffer.setUint16(offset, value, Endian.little);
offset += 2;
}
writeString('RIFF');
writeUint32(totalSize);
writeString('WAVE');
writeString('fmt ');
writeUint32(16);
writeUint16(audioFormat);
writeUint16(numChannels);
writeUint32(sampleRate);
writeUint32(byteRate);
writeUint16(blockAlign);
writeUint16(bitsPerSample);
writeString('data');
writeUint32(dataSize);
for (final sample in samples) {
buffer.setInt16(offset, sample, Endian.little);
offset += 2;
}
return buffer.buffer.asUint8List();
}
void dispose() {
_player?.dispose();
}
}

View File

@@ -0,0 +1,303 @@
import 'dart:math' show log, ln2;
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart';
import 'package:provider/provider.dart';
import '../../providers/offline_tiles_provider.dart';
/// Renders polygon/rectangle drawing interaction on the map.
///
/// Shows completed polygons, in-progress vertices, and handles tap events.
class PolygonDrawLayer extends StatelessWidget {
const PolygonDrawLayer({super.key});
@override
Widget build(BuildContext context) {
return Consumer<OfflineTilesProvider>(
builder: (context, provider, _) {
final polygons = <Polygon>[];
final markers = <Marker>[];
// Completed polygons
for (int i = 0; i < provider.polygons.length; i++) {
final poly = provider.polygons[i];
polygons.add(Polygon(
points: poly,
color: Colors.blue.withValues(alpha: 0.2),
borderColor: Colors.blue,
borderStrokeWidth: 2,
));
}
// In-progress polygon vertices
if (provider.drawingMode == DrawingMode.polygon &&
provider.currentVertices.isNotEmpty) {
final verts = provider.currentVertices;
// Draw lines between vertices
if (verts.length >= 2) {
polygons.add(Polygon(
points: verts,
color: Colors.orange.withValues(alpha: 0.1),
borderColor: Colors.orange,
borderStrokeWidth: 2,
));
}
// Draw vertex markers
for (final v in verts) {
markers.add(Marker(
point: v,
width: 12,
height: 12,
child: Container(
decoration: BoxDecoration(
color: Colors.orange,
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 2),
),
),
));
}
}
// Rectangle first corner marker
if (provider.drawingMode == DrawingMode.rectangle &&
provider.rectangleFirstCorner != null) {
markers.add(Marker(
point: provider.rectangleFirstCorner!,
width: 14,
height: 14,
child: Container(
decoration: BoxDecoration(
color: Colors.orange,
shape: BoxShape.rectangle,
border: Border.all(color: Colors.white, width: 2),
),
),
));
}
return Stack(
children: [
if (polygons.isNotEmpty) PolygonLayer(polygons: polygons),
if (markers.isNotEmpty) MarkerLayer(markers: markers),
],
);
},
);
}
}
/// Renders the download progress overlay tiles.
class DownloadProgressLayer extends StatelessWidget {
const DownloadProgressLayer({super.key});
@override
Widget build(BuildContext context) {
return Consumer<OfflineTilesProvider>(
builder: (context, provider, _) {
if (provider.tileOverlays.isEmpty) return const SizedBox.shrink();
final polygons = provider.tileOverlays.map((overlay) {
return Polygon(
points: [
LatLng(overlay.north, overlay.west),
LatLng(overlay.north, overlay.east),
LatLng(overlay.south, overlay.east),
LatLng(overlay.south, overlay.west),
],
color: overlay.isSkipped
? Colors.green.withValues(alpha: 0.15)
: Colors.orange.withValues(alpha: 0.15),
borderColor:
overlay.isSkipped ? Colors.green : Colors.orange,
borderStrokeWidth: 1,
);
}).toList();
return PolygonLayer(polygons: polygons);
},
);
}
}
/// Renders the coverage overlay for a cached style.
///
/// Only renders tiles at the zoom level closest to the current map zoom
/// to avoid drawing thousands of rectangles at once.
class CoverageLayer extends StatelessWidget {
final double currentZoom;
const CoverageLayer({super.key, required this.currentZoom});
@override
Widget build(BuildContext context) {
return Consumer<OfflineTilesProvider>(
builder: (context, provider, _) {
if (provider.coverageOverlays.isEmpty) {
return const SizedBox.shrink();
}
// Filter to tiles near the current zoom level to keep rendering fast.
// Show the zoom level that's <= current map zoom (best visual match).
final targetZoom = currentZoom.floor();
// Group overlays by approximate zoom level based on tile size.
// A tile at zoom z covers roughly (360/2^z) degrees of longitude.
// We filter by checking if the tile width matches the target zoom.
final filtered = <Polygon>[];
for (final overlay in provider.coverageOverlays) {
// Estimate the zoom level from the tile's longitude span
final lonSpan = (overlay.east - overlay.west).abs();
if (lonSpan <= 0) continue;
final estimatedZoom = (log(360.0 / lonSpan) / ln2).round();
if (estimatedZoom == targetZoom ||
estimatedZoom == targetZoom - 1 ||
estimatedZoom == targetZoom + 1) {
filtered.add(Polygon(
points: [
LatLng(overlay.north, overlay.west),
LatLng(overlay.north, overlay.east),
LatLng(overlay.south, overlay.east),
LatLng(overlay.south, overlay.west),
],
color: Colors.blue.withValues(alpha: 0.1),
borderColor: Colors.blue.withValues(alpha: 0.4),
borderStrokeWidth: 1,
));
}
// Cap at 1000 visible tiles to avoid jank
if (filtered.length >= 1000) break;
}
if (filtered.isEmpty) return const SizedBox.shrink();
return PolygonLayer(polygons: filtered);
},
);
}
}
/// Toolbar for drawing controls.
class DrawingToolbar extends StatelessWidget {
const DrawingToolbar({super.key});
@override
Widget build(BuildContext context) {
return Consumer<OfflineTilesProvider>(
builder: (context, provider, _) {
if (provider.isDownloading) return const SizedBox.shrink();
return Positioned(
right: 16,
top: 100,
child: Column(
children: [
_ToolButton(
icon: Icons.crop_square,
label: 'Rectangle',
isActive: provider.drawingMode == DrawingMode.rectangle,
onTap: () => provider.setDrawingMode(
provider.drawingMode == DrawingMode.rectangle
? DrawingMode.none
: DrawingMode.rectangle,
),
),
const SizedBox(height: 8),
_ToolButton(
icon: Icons.pentagon_outlined,
label: 'Polygon',
isActive: provider.drawingMode == DrawingMode.polygon,
onTap: () => provider.setDrawingMode(
provider.drawingMode == DrawingMode.polygon
? DrawingMode.none
: DrawingMode.polygon,
),
),
if (provider.drawingMode == DrawingMode.polygon &&
provider.currentVertices.length >= 3) ...[
const SizedBox(height: 8),
_ToolButton(
icon: Icons.check,
label: 'Finish',
isActive: false,
color: Colors.green,
onTap: () => provider.finishPolygon(),
),
],
if (provider.drawingMode == DrawingMode.polygon &&
provider.currentVertices.isNotEmpty) ...[
const SizedBox(height: 8),
_ToolButton(
icon: Icons.undo,
label: 'Undo',
isActive: false,
onTap: () => provider.undoLastVertex(),
),
],
if (provider.hasPolygons) ...[
const SizedBox(height: 8),
_ToolButton(
icon: Icons.delete_outline,
label: 'Clear',
isActive: false,
color: Colors.red,
onTap: () => provider.clearPolygons(),
),
],
],
),
);
},
);
}
}
class _ToolButton extends StatelessWidget {
final IconData icon;
final String label;
final bool isActive;
final Color? color;
final VoidCallback onTap;
const _ToolButton({
required this.icon,
required this.label,
required this.isActive,
this.color,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final activeColor = color ?? theme.colorScheme.primary;
return Tooltip(
message: label,
child: Material(
elevation: 2,
shape: const CircleBorder(),
color: isActive ? activeColor : theme.colorScheme.surface,
child: InkWell(
customBorder: const CircleBorder(),
onTap: onTap,
child: SizedBox(
width: 48,
height: 48,
child: Icon(
icon,
color: isActive
? theme.colorScheme.onPrimary
: (color ?? theme.colorScheme.onSurface),
),
),
),
),
);
}
}

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter/gestures.dart'; import 'package:flutter/gestures.dart';
import 'package:flutter_map/flutter_map.dart' as flutter_map;
import 'package:latlong2/latlong.dart'; import 'package:latlong2/latlong.dart';
import 'package:share_plus/share_plus.dart'; import 'package:share_plus/share_plus.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@@ -21,7 +22,9 @@ import '../../providers/image_provider.dart' as ip;
import '../../providers/app_provider.dart'; import '../../providers/app_provider.dart';
import '../drawing_minimap_preview.dart'; import '../drawing_minimap_preview.dart';
import '../../models/ble_packet_log.dart'; import '../../models/ble_packet_log.dart';
import '../../models/message_contact_location.dart';
import '../../services/sar_template_service.dart'; import '../../services/sar_template_service.dart';
import '../../services/location_tracking_service.dart';
import '../../utils/toast_logger.dart'; import '../../utils/toast_logger.dart';
import '../../utils/sar_message_parser.dart'; import '../../utils/sar_message_parser.dart';
import '../../utils/key_comparison.dart'; import '../../utils/key_comparison.dart';
@@ -420,6 +423,8 @@ class _MessageBubbleState extends State<MessageBubble> {
} }
void _showMessageOptions(BuildContext context) { void _showMessageOptions(BuildContext context) {
final parentContext = context;
final l10n = AppLocalizations.of(context)!;
final connectionProvider = context.read<ConnectionProvider>(); final connectionProvider = context.read<ConnectionProvider>();
final selfPublicKey = connectionProvider.deviceInfo.publicKey; final selfPublicKey = connectionProvider.deviceInfo.publicKey;
final isOwnMessage = final isOwnMessage =
@@ -432,7 +437,7 @@ class _MessageBubbleState extends State<MessageBubble> {
shape: const RoundedRectangleBorder( shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)), borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
), ),
builder: (context) => Padding( builder: (sheetContext) => Padding(
padding: const EdgeInsets.symmetric(vertical: 16), padding: const EdgeInsets.symmetric(vertical: 16),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@@ -440,29 +445,29 @@ class _MessageBubbleState extends State<MessageBubble> {
if (!isOwnMessage && widget.onReply != null) if (!isOwnMessage && widget.onReply != null)
ListTile( ListTile(
leading: Icon(Icons.reply), leading: Icon(Icons.reply),
title: Text(AppLocalizations.of(context)!.reply), title: Text(l10n.reply),
onTap: () { onTap: () {
Navigator.pop(context); Navigator.pop(sheetContext);
widget.onReply?.call(); widget.onReply?.call();
}, },
), ),
// Copy text option // Copy text option
ListTile( ListTile(
leading: Icon(Icons.copy), leading: Icon(Icons.copy),
title: Text(AppLocalizations.of(context)!.copyText), title: Text(l10n.copyText),
onTap: () { onTap: () {
Clipboard.setData(ClipboardData(text: widget.message.text)); Clipboard.setData(ClipboardData(text: widget.message.text));
Navigator.pop(context); Navigator.pop(sheetContext);
ToastLogger.success( ToastLogger.success(
context, parentContext,
AppLocalizations.of(context)!.textCopiedToClipboard, l10n.textCopiedToClipboard,
); );
}, },
), ),
// Save as Template option (only for SAR markers without existing template) // Save as Template option (only for SAR markers without existing template)
if (widget.message.isSarMarker) if (widget.message.isSarMarker)
Builder( Builder(
builder: (context) { builder: (_) {
// Extract emoji from SAR message // Extract emoji from SAR message
final sarInfo = SarMessageParser.parse(widget.message.text); final sarInfo = SarMessageParser.parse(widget.message.text);
if (sarInfo == null || sarInfo.emoji.isEmpty) { if (sarInfo == null || sarInfo.emoji.isEmpty) {
@@ -481,10 +486,10 @@ class _MessageBubbleState extends State<MessageBubble> {
return ListTile( return ListTile(
leading: Icon(Icons.bookmark_add), leading: Icon(Icons.bookmark_add),
title: Text(AppLocalizations.of(context)!.saveAsTemplate), title: Text(l10n.saveAsTemplate),
onTap: () { onTap: () {
Navigator.pop(context); Navigator.pop(sheetContext);
_saveAsTemplate(context); _saveAsTemplate(parentContext);
}, },
); );
}, },
@@ -494,49 +499,49 @@ class _MessageBubbleState extends State<MessageBubble> {
widget.message.sarGpsCoordinates != null) widget.message.sarGpsCoordinates != null)
ListTile( ListTile(
leading: Icon(Icons.share_location), leading: Icon(Icons.share_location),
title: Text(AppLocalizations.of(context)!.shareLocation), title: Text(l10n.shareLocation),
onTap: () { onTap: () {
Navigator.pop(context); Navigator.pop(sheetContext);
_shareLocation(context); _shareLocation(parentContext);
}, },
), ),
// Navigate to drawing option (only for drawing messages) // Navigate to drawing option (only for drawing messages)
if (widget.message.isDrawing && widget.message.drawingId != null) if (widget.message.isDrawing && widget.message.drawingId != null)
ListTile( ListTile(
leading: Icon(Icons.map), leading: Icon(Icons.map),
title: Text(AppLocalizations.of(context)!.navigateToDrawing), title: Text(l10n.navigateToDrawing),
onTap: () { onTap: () {
Navigator.pop(context); Navigator.pop(sheetContext);
_navigateToDrawing(context); _navigateToDrawing(parentContext);
}, },
), ),
// Copy coordinates option (only for drawing messages) // Copy coordinates option (only for drawing messages)
if (widget.message.isDrawing && widget.message.drawingId != null) if (widget.message.isDrawing && widget.message.drawingId != null)
ListTile( ListTile(
leading: Icon(Icons.copy), leading: Icon(Icons.copy),
title: Text(AppLocalizations.of(context)!.copyCoordinates), title: Text(l10n.copyCoordinates),
onTap: () { onTap: () {
Navigator.pop(context); Navigator.pop(sheetContext);
_copyDrawingCoordinates(context); _copyDrawingCoordinates(parentContext);
}, },
), ),
// Hide from map option (only for drawing messages) // Hide from map option (only for drawing messages)
if (widget.message.isDrawing && widget.message.drawingId != null) if (widget.message.isDrawing && widget.message.drawingId != null)
ListTile( ListTile(
leading: Icon(Icons.visibility_off), leading: Icon(Icons.visibility_off),
title: Text(AppLocalizations.of(context)!.hideFromMap), title: Text(l10n.hideFromMap),
onTap: () { onTap: () {
Navigator.pop(context); Navigator.pop(sheetContext);
_hideDrawingFromMap(context); _hideDrawingFromMap(parentContext);
}, },
), ),
// Technical details option // Technical details option
ListTile( ListTile(
leading: Icon(Icons.data_object), leading: Icon(Icons.data_object),
title: Text(AppLocalizations.of(context)!.technicalDetails), title: Text(l10n.technicalDetails),
onTap: () { onTap: () {
Navigator.pop(context); Navigator.pop(sheetContext);
_showTechnicalDetails(context); _showTechnicalDetails(parentContext);
}, },
), ),
if (!isOwnMessage && if (!isOwnMessage &&
@@ -544,22 +549,22 @@ class _MessageBubbleState extends State<MessageBubble> {
widget.message.pathLen < 255) widget.message.pathLen < 255)
ListTile( ListTile(
leading: Icon(Icons.route), leading: Icon(Icons.route),
title: Text(AppLocalizations.of(context)!.trace), title: Text(l10n.trace),
onTap: () { onTap: () {
Navigator.pop(context); Navigator.pop(sheetContext);
_showTraceSheet(context); _showTraceSheet(parentContext);
}, },
), ),
// Delete message option // Delete message option
ListTile( ListTile(
leading: Icon(Icons.delete, color: Colors.red), leading: Icon(Icons.delete, color: Colors.red),
title: Text( title: Text(
AppLocalizations.of(context)!.delete, l10n.delete,
style: const TextStyle(color: Colors.red), style: const TextStyle(color: Colors.red),
), ),
onTap: () { onTap: () {
Navigator.pop(context); Navigator.pop(sheetContext);
_showDeleteConfirmation(context); _showDeleteConfirmation(parentContext);
}, },
), ),
], ],
@@ -581,6 +586,7 @@ class _MessageBubbleState extends State<MessageBubble> {
} }
void _showTechnicalDetails(BuildContext context) { void _showTechnicalDetails(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final connectionProvider = context.read<ConnectionProvider>(); final connectionProvider = context.read<ConnectionProvider>();
final radioBw = connectionProvider.deviceInfo.radioBw; final radioBw = connectionProvider.deviceInfo.radioBw;
final radioSf = connectionProvider.deviceInfo.radioSf; final radioSf = connectionProvider.deviceInfo.radioSf;
@@ -595,6 +601,7 @@ class _MessageBubbleState extends State<MessageBubble> {
widget.message.isFromSelf(selfPublicKey); widget.message.isFromSelf(selfPublicKey);
String? senderName; String? senderName;
Contact? senderContact;
if (widget.message.senderPublicKeyPrefix != null) { if (widget.message.senderPublicKeyPrefix != null) {
final senderKeyHex = widget.message.senderPublicKeyPrefix! final senderKeyHex = widget.message.senderPublicKeyPrefix!
.sublist( .sublist(
@@ -605,7 +612,7 @@ class _MessageBubbleState extends State<MessageBubble> {
) )
.map((b) => b.toRadixString(16).padLeft(2, '0')) .map((b) => b.toRadixString(16).padLeft(2, '0'))
.join(''); .join('');
final senderContact = contactsProvider.contacts senderContact = contactsProvider.contacts
.where((c) => c.publicKeyHex.startsWith(senderKeyHex)) .where((c) => c.publicKeyHex.startsWith(senderKeyHex))
.firstOrNull; .firstOrNull;
senderName = senderContact?.advName; senderName = senderContact?.advName;
@@ -628,7 +635,7 @@ class _MessageBubbleState extends State<MessageBubble> {
recipientName = recipientContact?.advName; recipientName = recipientContact?.advName;
} }
final senderLocationSnapshot = messagesProvider.getMessageContactLocation( final storedLocationSnapshot = messagesProvider.getMessageContactLocation(
widget.message.id, widget.message.id,
); );
final receptionDetails = messagesProvider.getMessageReceptionDetails( final receptionDetails = messagesProvider.getMessageReceptionDetails(
@@ -709,6 +716,12 @@ class _MessageBubbleState extends State<MessageBubble> {
final routeMetadata = context final routeMetadata = context
.read<MessagesProvider>() .read<MessagesProvider>()
.getMessageRouteMetadata(widget.message.id); .getMessageRouteMetadata(widget.message.id);
final messageLocationSnapshot = _resolveDetailsLocationSnapshot(
storedSnapshot: storedLocationSnapshot,
senderContact: senderContact,
connectionProvider: connectionProvider,
isOwnMessage: isOwnMessage,
);
final rawLines = <String>[ final rawLines = <String>[
'Message ID: ${widget.message.id}', 'Message ID: ${widget.message.id}',
@@ -742,9 +755,9 @@ class _MessageBubbleState extends State<MessageBubble> {
'Retry result: ${retryResult ?? '-'}', 'Retry result: ${retryResult ?? '-'}',
'Sender key prefix: ${senderPrefixHex ?? '-'}', 'Sender key prefix: ${senderPrefixHex ?? '-'}',
'Sender name: ${senderName ?? widget.message.senderName ?? '-'}', 'Sender name: ${senderName ?? widget.message.senderName ?? '-'}',
'Sender location at receipt: ${senderLocationSnapshot?.formattedCoordinates ?? '-'}', '${widget.message.isSentMessage ? "Sent from location" : "Sender location at receipt"}: ${messageLocationSnapshot?.formattedCoordinates ?? '-'}',
'Sender location source: ${senderLocationSnapshot?.technicalSourceLabel ?? '-'}', '${widget.message.isSentMessage ? "Sent from source" : "Sender location source"}: ${messageLocationSnapshot?.technicalSourceLabel ?? '-'}',
'Sender location timestamp: ${senderLocationSnapshot?.sourceTimestamp?.toIso8601String() ?? '-'}', '${widget.message.isSentMessage ? "Sent from timestamp" : "Sender location timestamp"}: ${messageLocationSnapshot?.sourceTimestamp?.toIso8601String() ?? '-'}',
'Recipient key prefix: ${recipientPrefixHex ?? '-'}', 'Recipient key prefix: ${recipientPrefixHex ?? '-'}',
'Recipient name: ${recipientName ?? '-'}', 'Recipient name: ${recipientName ?? '-'}',
'Drawing flag: ${widget.message.isDrawing}', 'Drawing flag: ${widget.message.isDrawing}',
@@ -833,12 +846,9 @@ class _MessageBubbleState extends State<MessageBubble> {
} }
} }
void copyField(String value) { void copyField(BuildContext feedbackContext, String value) {
Clipboard.setData(ClipboardData(text: value)); Clipboard.setData(ClipboardData(text: value));
ToastLogger.success( ToastLogger.success(feedbackContext, l10n.textCopiedToClipboard);
context,
AppLocalizations.of(context)!.textCopiedToClipboard,
);
} }
showModalBottomSheet( showModalBottomSheet(
@@ -860,14 +870,14 @@ class _MessageBubbleState extends State<MessageBubble> {
children: [ children: [
Expanded( Expanded(
child: Text( child: Text(
AppLocalizations.of(context)!.messageTechnicalDetails, l10n.messageTechnicalDetails,
style: Theme.of(sheetContext).textTheme.titleLarge, style: Theme.of(sheetContext).textTheme.titleLarge,
), ),
), ),
IconButton( IconButton(
onPressed: () => Navigator.pop(sheetContext), onPressed: () => Navigator.pop(sheetContext),
icon: Icon(Icons.close), icon: Icon(Icons.close),
tooltip: AppLocalizations.of(context)!.close, tooltip: l10n.close,
), ),
], ],
), ),
@@ -907,6 +917,54 @@ class _MessageBubbleState extends State<MessageBubble> {
), ),
], ],
), ),
if (messageLocationSnapshot != null) ...[
const SizedBox(height: 12),
_techSection(
sheetContext,
icon: Icons.location_on,
title: l10n.location,
child: Column(
children: [
_buildLocationPreviewMap(
sheetContext,
messageLocationSnapshot.location,
),
const SizedBox(height: 12),
_detailRow(
sheetContext,
label: l10n.coordinates,
value: messageLocationSnapshot
.formattedCoordinates,
onCopy: () => copyField(
sheetContext,
messageLocationSnapshot.formattedCoordinates,
),
),
_detailRow(
sheetContext,
label: l10n.source,
value: messageLocationSnapshot
.technicalSourceLabel,
),
_detailRow(
sheetContext,
label: l10n.captured,
value: _formatRfc3339(
messageLocationSnapshot.sourceTimestamp ??
messageLocationSnapshot.capturedAt,
),
onCopy: () => copyField(
sheetContext,
_formatRfc3339(
messageLocationSnapshot.sourceTimestamp ??
messageLocationSnapshot.capturedAt,
),
),
),
],
),
),
],
if (widget.message.lastEchoRssiDbm != null || if (widget.message.lastEchoRssiDbm != null ||
snrDb != null || snrDb != null ||
rssiDbm != null) ...[ rssiDbm != null) ...[
@@ -914,13 +972,13 @@ class _MessageBubbleState extends State<MessageBubble> {
_techSection( _techSection(
sheetContext, sheetContext,
icon: Icons.network_check, icon: Icons.network_check,
title: AppLocalizations.of(context)!.linkQuality, title: l10n.linkQuality,
child: Column( child: Column(
children: [ children: [
if (rssiDbm != null) if (rssiDbm != null)
_signalRow( _signalRow(
sheetContext, sheetContext,
label: AppLocalizations.of(context)!.rssi, label: l10n.rssi,
valueLabel: '$rssiDbm dBm', valueLabel: '$rssiDbm dBm',
normalized: normalized:
((rssiDbm.toDouble() + 120.0) / 70.0) ((rssiDbm.toDouble() + 120.0) / 70.0)
@@ -935,7 +993,7 @@ class _MessageBubbleState extends State<MessageBubble> {
SizedBox(height: 8), SizedBox(height: 8),
_signalRow( _signalRow(
sheetContext, sheetContext,
label: AppLocalizations.of(context)!.snr, label: l10n.snr,
valueLabel: '${snrDb.toStringAsFixed(1)} dB', valueLabel: '${snrDb.toStringAsFixed(1)} dB',
normalized: ((snrDb + 20.0) / 40.0).clamp( normalized: ((snrDb + 20.0) / 40.0).clamp(
0.0, 0.0,
@@ -956,39 +1014,34 @@ class _MessageBubbleState extends State<MessageBubble> {
_techSection( _techSection(
sheetContext, sheetContext,
icon: Icons.tune, icon: Icons.tune,
title: AppLocalizations.of(context)!.delivery, title: l10n.delivery,
child: Column( child: Column(
children: [ children: [
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of(context)!.status, label: l10n.status,
value: widget.message.deliveryStatus.name, value: widget.message.deliveryStatus.name,
), ),
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of( label: l10n.receivedRfc3339,
context,
)!.receivedRfc3339,
value: _formatRfc3339(widget.message.receivedAt), value: _formatRfc3339(widget.message.receivedAt),
onCopy: () => copyField( onCopy: () => copyField(
sheetContext,
_formatRfc3339(widget.message.receivedAt), _formatRfc3339(widget.message.receivedAt),
), ),
), ),
if (widget.message.expectedAckTag != null) if (widget.message.expectedAckTag != null)
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of( label: l10n.expectedAckTag,
context,
)!.expectedAckTag,
value: widget.message.expectedAckTag! value: widget.message.expectedAckTag!
.toString(), .toString(),
), ),
if (receptionDetails?.senderToReceiptMs != null) if (receptionDetails?.senderToReceiptMs != null)
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of( label: l10n.senderToReceipt,
context,
)!.senderToReceipt,
value: _formatDurationMs( value: _formatDurationMs(
receptionDetails!.senderToReceiptMs!, receptionDetails!.senderToReceiptMs!,
), ),
@@ -996,9 +1049,7 @@ class _MessageBubbleState extends State<MessageBubble> {
if (receptionDetails?.estimatedTransmitMs != null) if (receptionDetails?.estimatedTransmitMs != null)
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of( label: l10n.estimatedTx,
context,
)!.estimatedTx,
value: _formatDurationMs( value: _formatDurationMs(
receptionDetails!.estimatedTransmitMs!, receptionDetails!.estimatedTransmitMs!,
), ),
@@ -1006,9 +1057,7 @@ class _MessageBubbleState extends State<MessageBubble> {
if (receptionDetails?.postTransmitDelayMs != null) if (receptionDetails?.postTransmitDelayMs != null)
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of( label: l10n.posttxDelay,
context,
)!.posttxDelay,
value: _formatDurationMs( value: _formatDurationMs(
receptionDetails!.postTransmitDelayMs!, receptionDetails!.postTransmitDelayMs!,
), ),
@@ -1016,87 +1065,80 @@ class _MessageBubbleState extends State<MessageBubble> {
if (widget.receivedCopies > 1) if (widget.receivedCopies > 1)
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of( label: l10n.receivedCopies,
context,
)!.receivedCopies,
value: '${widget.receivedCopies}', value: '${widget.receivedCopies}',
), ),
if (widget.message.suggestedTimeoutMs != null) if (widget.message.suggestedTimeoutMs != null)
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of(context)!.ackTimeout, label: l10n.ackTimeout,
value: value:
'${widget.message.suggestedTimeoutMs} ms', '${widget.message.suggestedTimeoutMs} ms',
), ),
if (retryCause != null) if (retryCause != null)
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of(context)!.retryCause, label: l10n.retryCause,
value: retryCause, value: retryCause,
), ),
if (retryMode != null) if (retryMode != null)
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of(context)!.retryMode, label: l10n.retryMode,
value: retryMode, value: retryMode,
), ),
if (widget.message.roundTripTimeMs != null) if (widget.message.roundTripTimeMs != null)
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of(context)!.roundTrip, label: l10n.roundTrip,
value: '${widget.message.roundTripTimeMs} ms', value: '${widget.message.roundTripTimeMs} ms',
), ),
if (widget.message.retryAttempt > 0) if (widget.message.retryAttempt > 0)
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of( label: l10n.retryAttempt,
context,
)!.retryAttempt,
value: '${widget.message.retryAttempt}/4', value: '${widget.message.retryAttempt}/4',
), ),
if (widget.message.lastRetryAt != null) if (widget.message.lastRetryAt != null)
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of(context)!.lastRetry, label: l10n.lastRetry,
value: _formatRfc3339( value: _formatRfc3339(
widget.message.lastRetryAt!, widget.message.lastRetryAt!,
), ),
onCopy: () => copyField( onCopy: () => copyField(
sheetContext,
_formatRfc3339(widget.message.lastRetryAt!), _formatRfc3339(widget.message.lastRetryAt!),
), ),
), ),
if (widget.message.usedFloodFallback) if (widget.message.usedFloodFallback)
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of( label: l10n.floodFallback,
context, value: l10n.yes,
)!.floodFallback,
value: AppLocalizations.of(context)!.yes,
), ),
if (routeMetadata?.canonicalPath if (routeMetadata?.canonicalPath
case final routePath?) case final routePath?)
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of( label: l10n.selectedPath,
context,
)!.selectedPath,
value: routePath, value: routePath,
onCopy: () => copyField(routePath), onCopy: () =>
copyField(sheetContext, routePath),
), ),
if (retryResult != null) if (retryResult != null)
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of( label: l10n.retryResult,
context,
)!.retryResult,
value: retryResult, value: retryResult,
), ),
if (packetPathHex != null) if (packetPathHex != null)
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of(context)!.pathBytes, label: l10n.pathBytes,
value: packetPathHex, value: packetPathHex,
onCopy: () => copyField(packetPathHex), onCopy: () =>
copyField(sheetContext, packetPathHex),
), ),
], ],
), ),
@@ -1105,18 +1147,19 @@ class _MessageBubbleState extends State<MessageBubble> {
_techSection( _techSection(
sheetContext, sheetContext,
icon: Icons.badge, icon: Icons.badge,
title: AppLocalizations.of(context)!.identity, title: l10n.identity,
child: Column( child: Column(
children: [ children: [
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of(context)!.messageId, label: l10n.messageId,
value: widget.message.id, value: widget.message.id,
onCopy: () => copyField(widget.message.id), onCopy: () =>
copyField(sheetContext, widget.message.id),
), ),
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of(context)!.sender, label: l10n.sender,
value: value:
senderName ?? senderName ??
widget.message.senderName ?? widget.message.senderName ??
@@ -1125,24 +1168,26 @@ class _MessageBubbleState extends State<MessageBubble> {
if (senderPrefixHex != null) if (senderPrefixHex != null)
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of(context)!.senderKey, label: l10n.senderKey,
value: senderPrefixHex, value: senderPrefixHex,
onCopy: () => copyField(senderPrefixHex), onCopy: () =>
copyField(sheetContext, senderPrefixHex),
), ),
if (recipientName != null) if (recipientName != null)
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of(context)!.recipient, label: l10n.recipient,
value: recipientName, value: recipientName,
), ),
if (recipientPrefixHex != null) if (recipientPrefixHex != null)
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of( label: l10n.recipientKey,
context,
)!.recipientKey,
value: recipientPrefixHex, value: recipientPrefixHex,
onCopy: () => copyField(recipientPrefixHex), onCopy: () => copyField(
sheetContext,
recipientPrefixHex,
),
), ),
], ],
), ),
@@ -1152,53 +1197,47 @@ class _MessageBubbleState extends State<MessageBubble> {
_techSection( _techSection(
sheetContext, sheetContext,
icon: Icons.graphic_eq, icon: Icons.graphic_eq,
title: AppLocalizations.of(context)!.voice, title: l10n.voice,
child: Column( child: Column(
children: [ children: [
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of(context)!.voiceId, label: l10n.voiceId,
value: widget.message.voiceId ?? '-', value: widget.message.voiceId ?? '-',
), ),
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of(context)!.envelope, label: l10n.envelope,
value: envelope != null value: envelope != null
? 'VE3 compact' ? 'VE3 compact'
: AppLocalizations.of(context)!.unknown, : l10n.unknown,
), ),
if (voiceSession != null) if (voiceSession != null)
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of( label: l10n.sessionProgress,
context,
)!.sessionProgress,
value: value:
'${voiceSession.receivedCount}/${voiceSession.total} segments', '${voiceSession.receivedCount}/${voiceSession.total} segments',
), ),
if (voiceSession != null) if (voiceSession != null)
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of(context)!.complete, label: l10n.complete,
value: voiceSession.isComplete value: voiceSession.isComplete
? AppLocalizations.of(context)!.yes ? l10n.yes
: AppLocalizations.of(context)!.no, : l10n.no,
), ),
if (transferDetails != null) if (transferDetails != null)
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of( label: l10n.transfers,
context,
)!.transfers,
value: '${transferDetails.totalTransfers}', value: '${transferDetails.totalTransfers}',
), ),
if (transferDetails != null && if (transferDetails != null &&
transferDetails.downloaders.isNotEmpty) transferDetails.downloaders.isNotEmpty)
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of( label: l10n.downloadedBy,
context,
)!.downloadedBy,
value: _formatDownloaderSummary( value: _formatDownloaderSummary(
transferDetails, transferDetails,
), ),
@@ -1206,9 +1245,7 @@ class _MessageBubbleState extends State<MessageBubble> {
if (voiceTxEstimate > Duration.zero) if (voiceTxEstimate > Duration.zero)
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of( label: l10n.estimatedTx,
context,
)!.estimatedTx,
value: voiceTxEstimate.inSeconds < 60 value: voiceTxEstimate.inSeconds < 60
? '~${voiceTxEstimate.inSeconds}s' ? '~${voiceTxEstimate.inSeconds}s'
: '~${voiceTxEstimate.inMinutes}m ${voiceTxEstimate.inSeconds % 60}s', : '~${voiceTxEstimate.inMinutes}m ${voiceTxEstimate.inSeconds % 60}s',
@@ -1222,28 +1259,28 @@ class _MessageBubbleState extends State<MessageBubble> {
_techSection( _techSection(
sheetContext, sheetContext,
icon: Icons.image_outlined, icon: Icons.image_outlined,
title: AppLocalizations.of(context)!.image, title: l10n.image,
child: Column( child: Column(
children: [ children: [
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of(context)!.envelope, label: l10n.envelope,
value: 'IE1', value: 'IE1',
), ),
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of(context)!.format, label: l10n.format,
value: imageEnvelope.format.label, value: imageEnvelope.format.label,
), ),
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of(context)!.dimensions, label: l10n.dimensions,
value: value:
'${imageEnvelope.width}×${imageEnvelope.height}', '${imageEnvelope.width}×${imageEnvelope.height}',
), ),
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of(context)!.segments, label: l10n.segments,
value: imageSession != null value: imageSession != null
? '${imageSession.receivedCount}/${imageSession.total}' ? '${imageSession.receivedCount}/${imageSession.total}'
: '${imageEnvelope.total}', : '${imageEnvelope.total}',
@@ -1251,26 +1288,22 @@ class _MessageBubbleState extends State<MessageBubble> {
if (imageSession != null) if (imageSession != null)
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of(context)!.complete, label: l10n.complete,
value: imageSession.isComplete value: imageSession.isComplete
? AppLocalizations.of(context)!.yes ? l10n.yes
: AppLocalizations.of(context)!.no, : l10n.no,
), ),
if (transferDetails != null) if (transferDetails != null)
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of( label: l10n.transfers,
context,
)!.transfers,
value: '${transferDetails.totalTransfers}', value: '${transferDetails.totalTransfers}',
), ),
if (transferDetails != null && if (transferDetails != null &&
transferDetails.downloaders.isNotEmpty) transferDetails.downloaders.isNotEmpty)
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of( label: l10n.downloadedBy,
context,
)!.downloadedBy,
value: _formatDownloaderSummary( value: _formatDownloaderSummary(
transferDetails, transferDetails,
), ),
@@ -1278,9 +1311,7 @@ class _MessageBubbleState extends State<MessageBubble> {
if (imageTxEstimate > Duration.zero) if (imageTxEstimate > Duration.zero)
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of( label: l10n.estimatedTx,
context,
)!.estimatedTx,
value: imageTxEstimate.inSeconds < 60 value: imageTxEstimate.inSeconds < 60
? '~${imageTxEstimate.inSeconds}s' ? '~${imageTxEstimate.inSeconds}s'
: '~${imageTxEstimate.inMinutes}m ${imageTxEstimate.inSeconds % 60}s', : '~${imageTxEstimate.inMinutes}m ${imageTxEstimate.inSeconds % 60}s',
@@ -1295,7 +1326,7 @@ class _MessageBubbleState extends State<MessageBubble> {
dense: true, dense: true,
visualDensity: VisualDensity.compact, visualDensity: VisualDensity.compact,
title: Text( title: Text(
AppLocalizations.of(context)!.rawDump, l10n.rawDump,
style: const TextStyle( style: const TextStyle(
fontSize: 12, fontSize: 12,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
@@ -1333,6 +1364,108 @@ class _MessageBubbleState extends State<MessageBubble> {
); );
} }
Widget _buildLocationPreviewMap(BuildContext context, LatLng location) {
return ClipRRect(
borderRadius: BorderRadius.circular(12),
child: SizedBox(
height: 180,
child: DecoratedBox(
decoration: BoxDecoration(
border: Border.all(
color: Theme.of(context).colorScheme.outlineVariant,
),
),
child: flutter_map.FlutterMap(
options: flutter_map.MapOptions(
initialCenter: location,
initialZoom: 15,
interactionOptions: const flutter_map.InteractionOptions(
flags: flutter_map.InteractiveFlag.none,
),
),
children: [
flutter_map.TileLayer(
urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
userAgentPackageName: 'com.meshcore.sar',
),
flutter_map.MarkerLayer(
markers: [
flutter_map.Marker(
point: location,
width: 40,
height: 40,
child: Icon(
widget.message.isSentMessage
? Icons.near_me
: Icons.location_on,
color: Theme.of(context).colorScheme.primary,
size: 32,
),
),
],
),
],
),
),
),
);
}
MessageContactLocation? _resolveDetailsLocationSnapshot({
required MessageContactLocation? storedSnapshot,
required Contact? senderContact,
required ConnectionProvider connectionProvider,
required bool isOwnMessage,
}) {
if (storedSnapshot != null) {
return storedSnapshot;
}
if (isOwnMessage) {
final advLat = connectionProvider.deviceInfo.advLat;
final advLon = connectionProvider.deviceInfo.advLon;
if (advLat != null &&
advLon != null &&
(advLat != 0 || advLon != 0)) {
return MessageContactLocation(
location: LatLng(advLat / 1e6, advLon / 1e6),
source: 'shared',
capturedAt: widget.message.sentAt,
sourceTimestamp: widget.message.sentAt,
);
}
final currentPosition = LocationTrackingService().currentPosition;
if (currentPosition != null) {
return MessageContactLocation(
location: LatLng(
currentPosition.latitude,
currentPosition.longitude,
),
source: 'shared',
capturedAt: currentPosition.timestamp,
sourceTimestamp: currentPosition.timestamp,
);
}
}
final contactLocation = senderContact?.displayLocation;
if (contactLocation != null) {
final source =
senderContact?.telemetry?.gpsLocation != null
? 'telemetry'
: 'advert';
return MessageContactLocation(
location: contactLocation,
source: source,
capturedAt: senderContact?.locationUpdateTime ?? widget.message.receivedAt,
sourceTimestamp: senderContact?.locationUpdateTime,
);
}
return null;
}
Widget _techSection( Widget _techSection(
BuildContext context, { BuildContext context, {
required IconData icon, required IconData icon,

View File

@@ -88,24 +88,26 @@ Widget buildBubbleMetaFooter(
).textTheme.labelSmall?.copyWith(color: metaColor), ).textTheme.labelSmall?.copyWith(color: metaColor),
), ),
]); ]);
} else if (!isSarMarker && _effectivePathLen(message, routeMetadata) < 255) { } else if (!isSarMarker) {
final effectivePathLen = _effectivePathLen(message, routeMetadata); final routeLabel = _effectiveRouteFooterLabel(message, routeMetadata);
items.addAll([ if (routeLabel != null) {
Icon(Icons.alt_route, size: 11, color: metaColor), items.addAll([
const SizedBox(width: 3), Icon(Icons.alt_route, size: 11, color: metaColor),
Text( const SizedBox(width: 3),
effectivePathLen == 0 ? 'direct' : '${effectivePathLen}hop', Text(
style: Theme.of( routeLabel,
context, style: Theme.of(
).textTheme.labelSmall?.copyWith(color: metaColor), context,
), ).textTheme.labelSmall?.copyWith(color: metaColor),
Text( ),
'', Text(
style: Theme.of( '',
context, style: Theme.of(
).textTheme.labelSmall?.copyWith(color: metaColor), context,
), ).textTheme.labelSmall?.copyWith(color: metaColor),
]); ),
]);
}
} }
items.add( items.add(
@@ -127,8 +129,31 @@ Widget buildBubbleMetaFooter(
); );
} }
int _effectivePathLen(Message message, MessageRouteMetadata? routeMetadata) => String? _effectiveRouteFooterLabel(
routeMetadata?.hopCount ?? message.pathLen; Message message,
MessageRouteMetadata? routeMetadata,
) {
if (routeMetadata?.mode.name == 'flood') {
return 'flood';
}
final effectivePathLen = _effectivePathLen(message, routeMetadata);
if (effectivePathLen >= 255) {
return null;
}
return effectivePathLen == 0 ? 'direct' : '${effectivePathLen}hop';
}
int _effectivePathLen(Message message, MessageRouteMetadata? routeMetadata) {
if (routeMetadata?.hopCount != null) {
return routeMetadata!.hopCount!;
}
if (routeMetadata?.mode.name == 'flood') {
return 255;
}
return message.pathLen;
}
Widget buildChannelHeaderPill( Widget buildChannelHeaderPill(
BuildContext context, { BuildContext context, {

View File

@@ -3,6 +3,7 @@ import 'package:provider/provider.dart';
import '../../l10n/app_localizations.dart'; import '../../l10n/app_localizations.dart';
import '../../models/contact.dart'; import '../../models/contact.dart';
import '../../providers/app_provider.dart';
import '../../providers/messages_provider.dart'; import '../../providers/messages_provider.dart';
import '../../utils/avatar_label_helper.dart'; import '../../utils/avatar_label_helper.dart';
import '../common/contact_avatar.dart'; import '../common/contact_avatar.dart';
@@ -259,6 +260,21 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
return _formatRelativeTime(context, lastActivityAt); return _formatRelativeTime(context, lastActivityAt);
} }
IconData _channelLocationSharingIcon(ChannelLocationSharingMode mode) {
switch (mode) {
case ChannelLocationSharingMode.hardware:
case ChannelLocationSharingMode.appFallback:
return Icons.location_pin;
}
}
Color _channelLocationSharingColor(
BuildContext context,
ChannelLocationSharingMode mode,
) {
return const Color(0xFF16A34A);
}
Widget _buildTextSubtitle( Widget _buildTextSubtitle(
BuildContext context, BuildContext context,
Contact contact, Contact contact,
@@ -312,6 +328,9 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
MessagesProvider? messagesProvider, MessagesProvider? messagesProvider,
) { ) {
final previewData = _channelPreviewData(context, channel, messagesProvider); final previewData = _channelPreviewData(context, channel, messagesProvider);
final sharingMode = context.watch<AppProvider>().channelLocationSharingModeForChannel(
channel.publicKey.length > 1 ? channel.publicKey[1] : 0,
);
return _buildRecipientCard( return _buildRecipientCard(
context: context, context: context,
@@ -319,6 +338,7 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
contact: channel, contact: channel,
title: channel.getLocalizedDisplayName(context), title: channel.getLocalizedDisplayName(context),
subtitle: _buildChannelSubtitle(context, channel, previewData), subtitle: _buildChannelSubtitle(context, channel, previewData),
avatarMarkerMode: sharingMode,
unreadCount: _unreadFor(channel), unreadCount: _unreadFor(channel),
isSelected: _isSelected('channel', channel), isSelected: _isSelected('channel', channel),
compact: true, compact: true,
@@ -838,6 +858,7 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
required Contact contact, required Contact contact,
required String title, required String title,
Widget? subtitle, Widget? subtitle,
ChannelLocationSharingMode? avatarMarkerMode,
required int unreadCount, required int unreadCount,
required bool isSelected, required bool isSelected,
bool compact = false, bool compact = false,
@@ -846,6 +867,12 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
}) { }) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final accentColor = _sectionColor(context, type); final accentColor = _sectionColor(context, type);
final markerColor = avatarMarkerMode == null
? accentColor
: _channelLocationSharingColor(context, avatarMarkerMode);
final markerIcon = avatarMarkerMode == null
? _typeIcon(contact)
: _channelLocationSharingIcon(avatarMarkerMode);
return Material( return Material(
color: Colors.transparent, color: Colors.transparent,
@@ -886,14 +913,14 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
color: colorScheme.surface, color: colorScheme.surface,
shape: BoxShape.circle, shape: BoxShape.circle,
border: Border.all( border: Border.all(
color: accentColor.withValues(alpha: 0.3), color: markerColor.withValues(alpha: 0.3),
), ),
), ),
alignment: Alignment.center, alignment: Alignment.center,
child: Icon( child: Icon(
_typeIcon(contact), markerIcon,
size: compact ? 9 : 10, size: compact ? 9 : 10,
color: accentColor, color: markerColor,
), ),
), ),
), ),
@@ -945,7 +972,7 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
?.copyWith( ?.copyWith(
color: accentColor, color: accentColor,
fontWeight: FontWeight.w800, fontWeight: FontWeight.w800,
), ),
), ),
), ),
], ],

View File

@@ -1064,6 +1064,7 @@ class SensorTelemetryCard extends StatelessWidget {
final Map<String, int> fieldSpans; final Map<String, int> fieldSpans;
final Future<void> Function()? onRemove; final Future<void> Function()? onRemove;
final Future<void> Function()? onRefresh; final Future<void> Function()? onRefresh;
final Future<void> Function()? onPing;
final VoidCallback? onCustomize; final VoidCallback? onCustomize;
final Future<void> Function(Contact contact)? onShowMetHistory; final Future<void> Function(Contact contact)? onShowMetHistory;
final Future<void> Function()? onMoveUp; final Future<void> Function()? onMoveUp;
@@ -1071,6 +1072,7 @@ class SensorTelemetryCard extends StatelessWidget {
final EdgeInsetsGeometry margin; final EdgeInsetsGeometry margin;
final String emptyMetricsMessage; final String emptyMetricsMessage;
final Map<String, String> labelOverrides; final Map<String, String> labelOverrides;
final bool showActionSheetOnTap;
const SensorTelemetryCard({ const SensorTelemetryCard({
super.key, super.key,
@@ -1081,6 +1083,7 @@ class SensorTelemetryCard extends StatelessWidget {
required this.fieldSpans, required this.fieldSpans,
this.onRemove, this.onRemove,
this.onRefresh, this.onRefresh,
this.onPing,
this.onCustomize, this.onCustomize,
this.onShowMetHistory, this.onShowMetHistory,
this.onMoveUp, this.onMoveUp,
@@ -1089,6 +1092,7 @@ class SensorTelemetryCard extends StatelessWidget {
this.emptyMetricsMessage = this.emptyMetricsMessage =
'All fields are hidden. Use Visible fields to choose what to show.', 'All fields are hidden. Use Visible fields to choose what to show.',
this.labelOverrides = const <String, String>{}, this.labelOverrides = const <String, String>{},
this.showActionSheetOnTap = false,
}); });
String _formatSpeed(num metersPerSecond) { String _formatSpeed(num metersPerSecond) {
@@ -1103,6 +1107,7 @@ class SensorTelemetryCard extends StatelessWidget {
bool get _showsMenu => bool get _showsMenu =>
onRefresh != null || onRefresh != null ||
onPing != null ||
onCustomize != null || onCustomize != null ||
onRemove != null || onRemove != null ||
onMoveUp != null || onMoveUp != null ||
@@ -1112,6 +1117,116 @@ class SensorTelemetryCard extends StatelessWidget {
onShowMetHistory != null && onShowMetHistory != null &&
supportsBTHomeMetHistory(contact)); supportsBTHomeMetHistory(contact));
Future<void> _handleAction(BuildContext context, String value) async {
if (value == 'refresh' && onRefresh != null) {
await onRefresh!();
return;
}
if (value == 'ping' && onPing != null) {
await onPing!();
return;
}
if (value == 'move_up' && onMoveUp != null) {
await onMoveUp!();
return;
}
if (value == 'move_down' && onMoveDown != null) {
await onMoveDown!();
return;
}
if (value == 'copy_raw') {
final rawTelemetry = _rawTelemetryHex(contact?.telemetry);
if (rawTelemetry != null && context.mounted) {
await Clipboard.setData(ClipboardData(text: rawTelemetry));
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Raw response copied')),
);
}
}
return;
}
if (value == 'remove' && onRemove != null) {
await onRemove!();
return;
}
if (value == 'customize' && onCustomize != null) {
onCustomize!();
return;
}
if (value == 'met_history' &&
contact != null &&
onShowMetHistory != null) {
await onShowMetHistory!(contact!);
}
}
List<_SensorSheetAction> _buildSheetActions(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final actions = <_SensorSheetAction>[
if (onRefresh != null)
_SensorSheetAction(
icon: Icons.refresh,
label: l10n.refresh,
onTap: () => _handleAction(context, 'refresh'),
),
if (onPing != null)
_SensorSheetAction(
icon: Icons.network_ping,
label: 'Ping',
onTap: () => _handleAction(context, 'ping'),
),
if (_rawTelemetryHex(contact?.telemetry) != null)
_SensorSheetAction(
icon: Icons.copy_all_outlined,
label: 'Copy raw response',
onTap: () => _handleAction(context, 'copy_raw'),
),
if (onCustomize != null)
_SensorSheetAction(
icon: Icons.tune,
label: l10n.customizeFields,
onTap: () => _handleAction(context, 'customize'),
),
if (contact != null &&
onShowMetHistory != null &&
supportsBTHomeMetHistory(contact))
_SensorSheetAction(
icon: Icons.show_chart,
label: 'MET history',
onTap: () => _handleAction(context, 'met_history'),
),
if (onRemove != null)
_SensorSheetAction(
icon: Icons.delete_outline_rounded,
label: l10n.remove,
destructive: true,
onTap: () => _handleAction(context, 'remove'),
),
];
return actions;
}
Future<void> _showActionSheet(BuildContext context) async {
if (!_showsMenu) {
return;
}
final actions = _buildSheetActions(context);
if (actions.isEmpty) {
return;
}
await showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (sheetContext) => _SensorActionSheet(
contact: contact,
actions: actions,
onClose: () => Navigator.pop(sheetContext),
),
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context)!;
@@ -1124,7 +1239,7 @@ class SensorTelemetryCard extends StatelessWidget {
_buildMetricCards(l10n, telemetry, contact!), _buildMetricCards(l10n, telemetry, contact!),
); );
return Container( final card = Container(
margin: margin, margin: margin,
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: BorderRadius.circular(28), borderRadius: BorderRadius.circular(28),
@@ -1212,102 +1327,11 @@ class SensorTelemetryCard extends StatelessWidget {
), ),
), ),
if (_showsMenu) if (_showsMenu)
PopupMenuButton<String>( Icon(
onSelected: (value) async { showActionSheetOnTap
if (value == 'refresh' && onRefresh != null) { ? Icons.chevron_right_rounded
await onRefresh!(); : Icons.more_horiz,
} else if (value == 'move_up' && onMoveUp != null) { color: colorScheme.onSurfaceVariant,
await onMoveUp!();
} else if (value == 'move_down' && onMoveDown != null) {
await onMoveDown!();
} else if (value == 'copy_raw') {
final rawTelemetry = _rawTelemetryHex(
contact?.telemetry,
);
if (rawTelemetry != null && context.mounted) {
await Clipboard.setData(
ClipboardData(text: rawTelemetry),
);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Raw response copied'),
),
);
}
}
} else if (value == 'remove' && onRemove != null) {
await onRemove!();
} else if (value == 'customize' && onCustomize != null) {
onCustomize!();
} else if (value == 'met_history' &&
contact != null &&
onShowMetHistory != null) {
await onShowMetHistory!(contact!);
}
},
itemBuilder: (context) {
final items = <PopupMenuEntry<String>>[];
if (onRefresh != null) {
items.add(
PopupMenuItem<String>(
value: 'refresh',
child: Text(l10n.refresh),
),
);
}
if (onMoveUp != null) {
items.add(
const PopupMenuItem<String>(
value: 'move_up',
child: Text('Move up'),
),
);
}
if (onMoveDown != null) {
items.add(
const PopupMenuItem<String>(
value: 'move_down',
child: Text('Move down'),
),
);
}
if (_rawTelemetryHex(contact?.telemetry) != null) {
items.add(
const PopupMenuItem<String>(
value: 'copy_raw',
child: Text('Copy raw response'),
),
);
}
if (onCustomize != null) {
items.add(
PopupMenuItem<String>(
value: 'customize',
child: Text(l10n.customizeFields),
),
);
}
if (contact != null &&
onShowMetHistory != null &&
supportsBTHomeMetHistory(contact)) {
items.add(
const PopupMenuItem<String>(
value: 'met_history',
child: Text('MET history'),
),
);
}
if (onRemove != null) {
items.add(
PopupMenuItem<String>(
value: 'remove',
child: Text(l10n.remove),
),
);
}
return items;
},
), ),
], ],
), ),
@@ -1355,6 +1379,16 @@ class SensorTelemetryCard extends StatelessWidget {
), ),
), ),
); );
if (!_showsMenu || !showActionSheetOnTap) {
return card;
}
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => _showActionSheet(context),
child: card,
);
} }
List<SensorMetricCardData> _buildMetricCards( List<SensorMetricCardData> _buildMetricCards(
@@ -2826,6 +2860,213 @@ class SensorMetricCardData {
}); });
} }
class _SensorSheetAction {
final IconData icon;
final String label;
final Future<void> Function() onTap;
final bool destructive;
const _SensorSheetAction({
required this.icon,
required this.label,
required this.onTap,
this.destructive = false,
});
}
class _SensorActionSheet extends StatelessWidget {
final Contact? contact;
final List<_SensorSheetAction> actions;
final VoidCallback onClose;
const _SensorActionSheet({
required this.contact,
required this.actions,
required this.onClose,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final bottomInset = MediaQuery.of(context).viewPadding.bottom;
final title = contact?.displayName ?? 'Sensor';
final subtitle = contact == null ? 'Unavailable node' : contact!.publicKeyShort;
return Container(
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * 0.82,
),
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
color: colorScheme.surface,
borderRadius: const BorderRadius.vertical(top: Radius.circular(32)),
boxShadow: [
BoxShadow(
color: colorScheme.shadow.withValues(alpha: 0.12),
blurRadius: 24,
offset: const Offset(0, -4),
),
],
),
child: Material(
color: colorScheme.surface,
child: SingleChildScrollView(
padding: EdgeInsets.fromLTRB(16, 12, 16, 16 + bottomInset),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Center(
child: Container(
width: 44,
height: 4,
decoration: BoxDecoration(
color: colorScheme.outlineVariant,
borderRadius: BorderRadius.circular(999),
),
),
),
const SizedBox(height: 12),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 56,
height: 56,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(18),
gradient: LinearGradient(
colors: [
colorScheme.primaryContainer,
colorScheme.secondaryContainer,
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
),
child: Icon(
Icons.sensors_rounded,
color: colorScheme.onPrimaryContainer,
),
),
const SizedBox(width: 14),
Expanded(
child: Padding(
padding: const EdgeInsets.only(top: 2),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w900,
letterSpacing: -0.45,
),
),
const SizedBox(height: 4),
Text(
subtitle,
style: theme.textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
fontFamily: contact == null ? null : 'monospace',
),
),
],
),
),
),
IconButton(
onPressed: onClose,
icon: const Icon(Icons.close),
tooltip: MaterialLocalizations.of(context).closeButtonTooltip,
),
],
),
const SizedBox(height: 18),
...actions.map(
(action) => _SensorActionTile(
action: action,
onClose: onClose,
),
),
],
),
),
),
);
}
}
class _SensorActionTile extends StatelessWidget {
final _SensorSheetAction action;
final VoidCallback onClose;
const _SensorActionTile({
required this.action,
required this.onClose,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final foreground = action.destructive
? colorScheme.error
: colorScheme.onSurface;
final iconBackground = action.destructive
? colorScheme.error.withValues(alpha: 0.12)
: colorScheme.surfaceContainerHigh;
return Padding(
padding: const EdgeInsets.only(bottom: 10),
child: InkWell(
borderRadius: BorderRadius.circular(22),
onTap: () async {
onClose();
await action.onTap();
},
child: Ink(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(22),
border: Border.all(
color: colorScheme.outlineVariant.withValues(alpha: 0.45),
),
),
child: Row(
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: iconBackground,
borderRadius: BorderRadius.circular(14),
),
child: Icon(action.icon, color: foreground),
),
const SizedBox(width: 12),
Expanded(
child: Text(
action.label,
style: theme.textTheme.titleMedium?.copyWith(
color: foreground,
fontWeight: FontWeight.w700,
),
),
),
Icon(
Icons.chevron_right_rounded,
color: colorScheme.onSurfaceVariant,
),
],
),
),
),
);
}
}
class _ParsedMetricKey { class _ParsedMetricKey {
final String baseKey; final String baseKey;
final int? channel; final int? channel;

View File

@@ -0,0 +1,107 @@
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../services/traffic_stats_reporting_service.dart';
class TrafficStatsReportingSection extends StatelessWidget {
final TrafficStatsReportingService service;
const TrafficStatsReportingSection({super.key, required this.service});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SwitchListTile(
secondary: const Icon(Icons.cloud_upload_outlined),
title: const Text('Anonymous RX stats reporting'),
subtitle: const Text(
'Upload RX live-traffic packet type and path mode totals to the fixed Cloudflare worker every 5 minutes.',
),
value: service.isEnabled,
onChanged: (value) async {
await service.setEnabled(value);
},
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
child: DecoratedBox(
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: theme.colorScheme.outlineVariant.withValues(alpha: 0.5),
),
),
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Upload status',
style: theme.textTheme.titleSmall,
),
const SizedBox(height: 8),
Text(
_statusText(service),
style: theme.textTheme.bodyMedium,
),
const SizedBox(height: 6),
Text(
'Ingest URL: ${TrafficStatsReportingService.ingestUri}',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 8),
TextButton.icon(
onPressed: _openStatsDashboard,
icon: const Icon(Icons.open_in_new),
label: const Text('View public stats'),
),
],
),
),
),
),
],
);
}
static Future<void> _openStatsDashboard() async {
final url = TrafficStatsReportingService.dashboardUri;
if (await canLaunchUrl(url)) {
await launchUrl(url, mode: LaunchMode.externalApplication);
}
}
static String _statusText(TrafficStatsReportingService service) {
final buffer = StringBuffer();
buffer.write('Pending uploads: ${service.pendingUploadCount}');
if (service.lastSuccessAt != null) {
buffer.write(
'\nLast sent: ${_formatDateTime(service.lastSuccessAt!.toLocal())}',
);
} else {
buffer.write('\nLast sent: Never');
}
if (service.lastError != null && service.lastError!.isNotEmpty) {
buffer.write('\nLast error: ${service.lastError}');
} else {
buffer.write('\nLast error: None');
}
return buffer.toString();
}
static String _formatDateTime(DateTime value) {
final month = value.month.toString().padLeft(2, '0');
final day = value.day.toString().padLeft(2, '0');
final hour = value.hour.toString().padLeft(2, '0');
final minute = value.minute.toString().padLeft(2, '0');
final second = value.second.toString().padLeft(2, '0');
return '${value.year}-$month-$day $hour:$minute:$second';
}
}

View File

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

View File

@@ -10,13 +10,16 @@ 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'; import 'package:shared_preferences/shared_preferences.dart';
Contact _buildContact() { Contact _buildContact({
int outPathLen = 1,
List<int> outPath = const [1, 2, 3, 4],
}) {
return Contact( return Contact(
publicKey: Uint8List.fromList(List<int>.generate(32, (i) => i)), publicKey: Uint8List.fromList(List<int>.generate(32, (i) => i)),
type: ContactType.chat, type: ContactType.chat,
flags: 0, flags: 0,
outPathLen: 1, outPathLen: outPathLen,
outPath: Uint8List.fromList([1, 2, 3, 4]), outPath: Uint8List.fromList(outPath),
advName: 'Teammate', advName: 'Teammate',
lastAdvert: 1700000000, lastAdvert: 1700000000,
advLat: 0, advLat: 0,
@@ -162,6 +165,42 @@ void main() {
expect(provider.messages.single.roundTripTimeMs, 190); expect(provider.messages.single.roundTripTimeMs, 190);
}); });
test('delivered flood message upgrades to learned direct route from ACK path', () {
final provider = MessagesProvider();
final contactWithoutRoute = _buildContact(outPathLen: -1, outPath: []);
provider.addSentMessage(
_buildDirectMessage('m1d'),
contact: contactWithoutRoute,
);
provider.updateMessageRouteSelection(
'm1d',
PathSelection.flood(),
routerFallbackAttempted: false,
);
provider.markMessageSent('m1d', 80, 250);
provider.markMessageDelivered(80, 200);
provider.queueDeliveredMessageRouteRefresh('m1d', contactWithoutRoute);
final applied = provider.applyDeliveredMessageRouteFromContact(
_buildContact(outPathLen: 2, outPath: const [0xAA, 0xBB]),
);
expect(applied, isTrue);
expect(provider.messages.single.deliveryStatus, MessageDeliveryStatus.delivered);
expect(provider.messages.single.usedFloodFallback, isFalse);
expect(provider.messages.single.pathLen, 2);
expect(
provider.getMessageRouteMetadata('m1d')?.mode,
PathSelectionMode.directCurrent,
);
expect(
provider.getMessageRouteMetadata('m1d')?.canonicalPath,
'AA,BB',
);
expect(provider.getMessageRouteMetadata('m1d')?.hopCount, 2);
});
test('channel messages are marked sent immediately', () { test('channel messages are marked sent immediately', () {
final provider = MessagesProvider(); final provider = MessagesProvider();
provider.resolveContactNameCallback = (_) => 'dz0ny (SI)'; provider.resolveContactNameCallback = (_) => 'dz0ny (SI)';

View File

@@ -1,9 +1,11 @@
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:geolocator/geolocator.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package: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/message_contact_location.dart'; import 'package:meshcore_sar_app/models/message_contact_location.dart';
import 'package:meshcore_sar_app/providers/messages_provider.dart'; import 'package:meshcore_sar_app/providers/messages_provider.dart';
import 'package:meshcore_sar_app/services/location_tracking_service.dart';
import 'package:meshcore_sar_app/utils/image_message_parser.dart'; import 'package:meshcore_sar_app/utils/image_message_parser.dart';
import 'package:meshcore_sar_app/utils/voice_message_parser.dart'; import 'package:meshcore_sar_app/utils/voice_message_parser.dart';
import 'package:latlong2/latlong.dart'; import 'package:latlong2/latlong.dart';
@@ -39,6 +41,7 @@ void main() {
group('MessagesProvider voice detection', () { group('MessagesProvider voice detection', () {
setUp(() { setUp(() {
SharedPreferences.setMockInitialValues({}); SharedPreferences.setMockInitialValues({});
LocationTrackingService().currentPosition = null;
}); });
test('marks VE3 envelope messages as voice', () { test('marks VE3 envelope messages as voice', () {
@@ -131,6 +134,50 @@ void main() {
expect(snapshot.location.longitude, closeTo(14.5058, 0.000001)); expect(snapshot.location.longitude, closeTo(14.5058, 0.000001));
}); });
test('persists current device location for sent messages', () async {
final provider = MessagesProvider();
LocationTrackingService().currentPosition = Position(
latitude: 46.0569,
longitude: 14.5058,
timestamp: DateTime.fromMillisecondsSinceEpoch(1700000003000),
accuracy: 4.5,
altitude: 310.0,
altitudeAccuracy: 6.0,
heading: 0.0,
headingAccuracy: 0.0,
speed: 0.0,
speedAccuracy: 0.0,
);
provider.addSentMessage(
Message(
id: 'sent-1',
messageType: MessageType.contact,
pathLen: 0,
textType: MessageTextType.plain,
senderTimestamp: 1700000003,
text: 'outgoing status',
receivedAt: DateTime.now(),
senderPublicKeyPrefix: Uint8List.fromList([0, 1, 2, 3, 4, 5]),
deliveryStatus: MessageDeliveryStatus.sending,
),
);
await Future<void>.delayed(const Duration(milliseconds: 50));
final restoredProvider = MessagesProvider();
await restoredProvider.initialize();
final snapshot = restoredProvider.getMessageContactLocation('sent-1');
expect(snapshot, isNotNull);
expect(snapshot!.source, equals('gps'));
expect(snapshot.location.latitude, closeTo(46.0569, 0.000001));
expect(snapshot.location.longitude, closeTo(14.5058, 0.000001));
expect(
snapshot.sourceTimestamp,
DateTime.fromMillisecondsSinceEpoch(1700000003000),
);
});
test('dedupes repeated incoming contact messages with same text', () { test('dedupes repeated incoming contact messages with same text', () {
final provider = MessagesProvider(); final provider = MessagesProvider();
final sender = Uint8List.fromList([0, 1, 2, 3, 4, 5]); final sender = Uint8List.fromList([0, 1, 2, 3, 4, 5]);

View File

@@ -1,6 +1,5 @@
import 'dart:typed_data';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_sar_app/models/channel.dart'; import 'package:meshcore_sar_app/models/channel.dart';
import 'package:meshcore_sar_app/models/ble_packet_log.dart'; import 'package:meshcore_sar_app/models/ble_packet_log.dart';
@@ -67,6 +66,39 @@ Widget _testApp(Widget child, {ChannelsProvider? channelsProvider}) {
} }
void main() { void main() {
TestWidgetsFlutterBinding.ensureInitialized();
final launchedUrls = <String>[];
setUp(() {
launchedUrls.clear();
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(
const MethodChannel('plugins.flutter.io/url_launcher'),
(call) async {
switch (call.method) {
case 'canLaunch':
return true;
case 'launch':
final arguments = Map<dynamic, dynamic>.from(
call.arguments as Map<dynamic, dynamic>,
);
launchedUrls.add(arguments['url'] as String);
return true;
}
return null;
},
);
});
tearDown(() {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(
const MethodChannel('plugins.flutter.io/url_launcher'),
null,
);
});
testWidgets('shows empty state before traffic arrives', (tester) async { testWidgets('shows empty state before traffic arrives', (tester) async {
final logs = <BlePacketLog>[]; final logs = <BlePacketLog>[];
final refresh = ValueNotifier<int>(0); final refresh = ValueNotifier<int>(0);
@@ -273,6 +305,27 @@ void main() {
expect(find.text('FLOOD CONTROL'), findsOneWidget); expect(find.text('FLOOD CONTROL'), findsOneWidget);
}); });
testWidgets('opens public stats from the app bar', (tester) async {
final logs = <BlePacketLog>[];
final refresh = ValueNotifier<int>(0);
final now = DateTime(2026, 3, 12, 12, 0, 0);
await tester.pumpWidget(
_testApp(
LiveTrafficScreen(
logReader: () => logs,
refreshListenable: refresh,
now: () => now,
),
),
);
await tester.tap(find.byTooltip('View public stats'));
await tester.pump();
expect(launchedUrls, ['https://mcstats.dz0ny.dev']);
});
testWidgets('summary metrics expand across wide layouts', (tester) async { testWidgets('summary metrics expand across wide layouts', (tester) async {
tester.view.physicalSize = const Size(1200, 900); tester.view.physicalSize = const Size(1200, 900);
tester.view.devicePixelRatio = 1; tester.view.devicePixelRatio = 1;

View File

@@ -10,11 +10,7 @@ void main() {
SharedPreferences.setMockInitialValues({}); SharedPreferences.setMockInitialValues({});
}); });
test('route preference defaults are disabled', () async { test('route preference defaults are clear-path disabled and fallback enabled', () async {
expect(
await MessagingRoutePreferences.getAutoRouteRotationEnabled(),
isFalse,
);
expect(await MessagingRoutePreferences.getClearPathOnMaxRetry(), isFalse); expect(await MessagingRoutePreferences.getClearPathOnMaxRetry(), isFalse);
expect( expect(
await MessagingRoutePreferences.getNearestRelayFallbackEnabled(), await MessagingRoutePreferences.getNearestRelayFallbackEnabled(),
@@ -23,18 +19,29 @@ void main() {
}); });
test('route preferences persist changes', () async { test('route preferences persist changes', () async {
await MessagingRoutePreferences.setAutoRouteRotationEnabled(true);
await MessagingRoutePreferences.setClearPathOnMaxRetry(true); await MessagingRoutePreferences.setClearPathOnMaxRetry(true);
await MessagingRoutePreferences.setNearestRelayFallbackEnabled(false); await MessagingRoutePreferences.setNearestRelayFallbackEnabled(false);
expect(
await MessagingRoutePreferences.getAutoRouteRotationEnabled(),
isTrue,
);
expect(await MessagingRoutePreferences.getClearPathOnMaxRetry(), isTrue); expect(await MessagingRoutePreferences.getClearPathOnMaxRetry(), isTrue);
expect( expect(
await MessagingRoutePreferences.getNearestRelayFallbackEnabled(), await MessagingRoutePreferences.getNearestRelayFallbackEnabled(),
isFalse, isFalse,
); );
}); });
test('legacy auto route rotation preference is removed during cleanup', () async {
SharedPreferences.setMockInitialValues({
'messaging_auto_route_rotation_enabled': true,
});
final prefs = await SharedPreferences.getInstance();
expect(prefs.getBool('messaging_auto_route_rotation_enabled'), isTrue);
await MessagingRoutePreferences.cleanupLegacySettings();
expect(
prefs.containsKey('messaging_auto_route_rotation_enabled'),
isFalse,
);
});
} }

View File

@@ -1,44 +1,31 @@
import 'dart:convert';
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'package:meshcore_sar_app/models/contact.dart'; import 'package:meshcore_sar_app/models/contact.dart';
import 'package:meshcore_sar_app/models/path_history.dart';
import 'package:meshcore_sar_app/models/path_selection.dart'; import 'package:meshcore_sar_app/models/path_selection.dart';
import 'package:meshcore_sar_app/services/path_history_service.dart'; import 'package:meshcore_sar_app/services/path_history_service.dart';
Contact _buildContact({ Contact _buildContact({
required int seed, required int seed,
required List<int> pathBytes, List<int> pathBytes = const [],
required int hopCount, int hopCount = 0,
required int hashSize, int hashSize = 1,
}) { }) {
final encoded = ((hashSize - 1) << 6) | (hopCount & 0x3F); final encoded = pathBytes.isEmpty ? -1 : ((hashSize - 1) << 6) | (hopCount & 0x3F);
final outPath = Uint8List(ContactRouteCodec.maxPathBytes) final outPath = Uint8List(ContactRouteCodec.maxPathBytes);
..setRange(0, pathBytes.length, pathBytes); if (pathBytes.isNotEmpty) {
outPath.setRange(0, pathBytes.length, pathBytes);
}
return Contact( return Contact(
publicKey: Uint8List.fromList(List<int>.generate(32, (i) => i + seed)), publicKey: Uint8List.fromList(List<int>.generate(32, (i) => i + seed)),
type: ContactType.chat, type: ContactType.chat,
flags: 0, flags: 0,
outPathLen: ContactRouteCodec.toSignedDescriptor(encoded), outPathLen: encoded == -1 ? -1 : ContactRouteCodec.toSignedDescriptor(encoded),
outPath: outPath, outPath: encoded == -1 ? Uint8List(0) : outPath,
advName: 'Contact $seed',
lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000,
advLat: 0,
advLon: 0,
lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000,
);
}
Contact _buildContactWithoutRoute({required int seed}) {
return Contact(
publicKey: Uint8List.fromList(List<int>.generate(32, (i) => i + seed)),
type: ContactType.chat,
flags: 0,
outPathLen: -1,
outPath: Uint8List(0),
advName: 'Contact $seed', advName: 'Contact $seed',
lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000, lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000,
advLat: 0, advLat: 0,
@@ -54,227 +41,143 @@ void main() {
SharedPreferences.setMockInitialValues({}); SharedPreferences.setMockInitialValues({});
}); });
test('auto rotation ranks best paths before flood', () async { test('manual route override persists across reloads', () async {
final contact = _buildContact(seed: 1);
final service = PathHistoryService(); final service = PathHistoryService();
final contact = _buildContactWithoutRoute(seed: 0);
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.initialize();
await service.recordLearnedPath(contact); await service.setManualSelectionFor(
await service.recordPathResult(
contact.publicKeyHex, contact.publicKeyHex,
best, PathSelection(
success: true, mode: PathSelectionMode.directCurrent,
roundTripTimeMs: 120, pathBytes: Uint8List.fromList([0xAA, 0xBB]),
);
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(
'current learned route is reused first even with rotation enabled',
() async {
final service = PathHistoryService();
final contact = _buildContact(
seed: 9,
pathBytes: [0xAA, 0xBB, 0xCC],
hopCount: 1,
hashSize: 3,
);
await service.initialize();
await service.recordPathResult(
contact.publicKeyHex,
PathSelection(
mode: PathSelectionMode.directHistorical,
pathBytes: Uint8List.fromList([0x11, 0x22, 0x33]),
hopCount: 1,
hashSize: 3,
),
success: true,
roundTripTimeMs: 90,
);
final selection = await service.getSelectionForContact(
contact,
autoRouteRotationEnabled: true,
);
expect(selection.mode, PathSelectionMode.directCurrent);
expect(selection.canonicalPath, 'AABBCC');
},
);
test('no history falls back to flood', () async {
final service = PathHistoryService();
final contact = _buildContactWithoutRoute(seed: 0);
final selection = await service.getSelectionForContact(
contact,
autoRouteRotationEnabled: true,
);
expect(selection.mode, PathSelectionMode.flood);
});
test(
'received public byte path is reversed before adding to history',
() async {
final service = PathHistoryService();
await service.initialize();
await service.recordReceivedBytePath('abc123', [
0x01,
0x02,
0x03,
0x04,
], 2);
final history = service.historyFor('abc123');
expect(history.directPaths, hasLength(1));
expect(history.directPaths.single.pathBytes, [0x03, 0x04, 0x01, 0x02]);
expect(history.directPaths.single.hashSize, 2);
expect(history.directPaths.single.hopCount, 2);
expect(history.directPaths.single.source, PathRecordSource.observed);
},
);
test(
'learned paths stay marked as observed after being seen on-air',
() async {
final service = PathHistoryService();
final contact = _buildContact(
seed: 3,
pathBytes: [0xAA, 0xBB],
hopCount: 2, hopCount: 2,
hashSize: 1, hashSize: 1,
);
await service.initialize();
await service.recordReceivedBytePath(contact.publicKeyHex, [
0xBB,
0xAA,
], 1);
await service.recordLearnedPath(contact);
final history = service.historyFor(contact.publicKeyHex);
expect(history.directPaths, hasLength(1));
expect(history.directPaths.single.source, PathRecordSource.observed);
},
);
test('clear history removes stored direct paths for one contact', () async {
final service = PathHistoryService();
await service.initialize();
await service.recordReceivedBytePath('abc123', [0x01, 0x02], 1);
await service.recordReceivedBytePath('def456', [0x03, 0x04], 1);
expect(service.historyFor('abc123').directPaths, hasLength(1));
expect(service.historyFor('def456').directPaths, hasLength(1));
await service.clearHistoryFor('abc123');
expect(service.historyFor('abc123').directPaths, isEmpty);
expect(service.historyFor('def456').directPaths, hasLength(1));
});
test('last successful direct path is chosen by location fit', () async {
final service = PathHistoryService();
final contact = _buildContact(
seed: 7,
pathBytes: [0xAA],
hopCount: 1,
hashSize: 1,
);
await service.initialize();
await service.recordPathResult(
contact.publicKeyHex,
PathSelection(
mode: PathSelectionMode.directHistorical,
pathBytes: Uint8List.fromList([0x11]),
hopCount: 1,
hashSize: 1,
), ),
success: true,
roundTripTimeMs: 120,
senderLatitude: 46.0,
senderLongitude: 14.0,
recipientLatitude: 46.1,
recipientLongitude: 14.1,
);
await service.recordPathResult(
contact.publicKeyHex,
PathSelection(
mode: PathSelectionMode.directHistorical,
pathBytes: Uint8List.fromList([0x22]),
hopCount: 1,
hashSize: 1,
),
success: true,
roundTripTimeMs: 90,
senderLatitude: 46.0001,
senderLongitude: 14.0001,
recipientLatitude: 46.1001,
recipientLongitude: 14.1001,
); );
final selection = await service.getLastSuccessfulDirectSelection( final reloaded = PathHistoryService();
contact, final selection = await reloaded.getManualSelectionForContact(contact);
excludeSignature: 'aa',
senderLatitude: 46.0002,
senderLongitude: 14.0002,
recipientLatitude: 46.1002,
recipientLongitude: 14.1002,
);
expect(selection, isNotNull); expect(selection, isNotNull);
expect(selection!.mode, PathSelectionMode.directHistorical); expect(selection!.mode, PathSelectionMode.directCurrent);
expect(selection.canonicalPath, '22'); expect(selection.canonicalPath, 'AA,BB');
});
test('selection uses stored manual route before contact route', () async {
final contact = _buildContact(
seed: 2,
pathBytes: const [0x11, 0x22],
hopCount: 2,
hashSize: 1,
);
final service = PathHistoryService();
await service.initialize();
await service.setManualSelectionFor(
contact.publicKeyHex,
PathSelection(
mode: PathSelectionMode.directCurrent,
pathBytes: Uint8List.fromList([0xAA, 0xBB]),
hopCount: 2,
hashSize: 1,
),
);
final selection = await service.getSelectionForContact(contact);
expect(selection.mode, PathSelectionMode.directCurrent);
expect(selection.canonicalPath, 'AA,BB');
});
test('selection falls back to the current contact route', () async {
final contact = _buildContact(
seed: 3,
pathBytes: const [0x10, 0x20, 0x30],
hopCount: 1,
hashSize: 3,
);
final service = PathHistoryService();
final selection = await service.getSelectionForContact(contact);
expect(selection.mode, PathSelectionMode.directCurrent);
expect(selection.canonicalPath, '102030');
expect(selection.hashSize, 3);
expect(selection.hopCount, 1);
});
test('selection falls back to flood when no route exists', () async {
final contact = _buildContact(seed: 4);
final service = PathHistoryService();
final selection = await service.getSelectionForContact(contact);
expect(selection.mode, PathSelectionMode.flood);
expect(selection.pathBytes, isEmpty);
});
test('clearing manual route falls back to the contact route', () async {
final contact = _buildContact(
seed: 5,
pathBytes: const [0x01, 0x02],
hopCount: 2,
hashSize: 1,
);
final service = PathHistoryService();
await service.initialize();
await service.setManualSelectionFor(
contact.publicKeyHex,
PathSelection(
mode: PathSelectionMode.directCurrent,
pathBytes: Uint8List.fromList([0xAA, 0xBB]),
hopCount: 2,
hashSize: 1,
),
);
await service.clearManualRouteFor(contact.publicKeyHex);
final selection = await service.getSelectionForContact(contact);
expect(selection.mode, PathSelectionMode.directCurrent);
expect(selection.canonicalPath, '01,02');
});
test('initialize removes legacy path history storage', () async {
final contact = Contact(
publicKey: Uint8List.fromList([
0xAB,
0xC1,
0x23,
...List<int>.filled(29, 0),
]),
type: ContactType.chat,
flags: 0,
outPathLen: -1,
outPath: Uint8List(0),
advName: 'Legacy Contact',
lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000,
advLat: 0,
advLon: 0,
lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000,
);
SharedPreferences.setMockInitialValues({
'contact_path_history_v2': '{"abc123":{"direct_paths":[]}}',
'contact_manual_path_overrides_v1': jsonEncode({
contact.publicKeyHex: {
'pathBytes': [0xAA, 0xBB],
'hopCount': 2,
'hashSize': 1,
},
}),
});
final service = PathHistoryService();
await service.initialize();
final prefs = await SharedPreferences.getInstance();
expect(prefs.containsKey('contact_path_history_v2'), isFalse);
final selection = await service.getManualSelectionForContact(contact);
expect(selection, isNotNull);
expect(selection!.canonicalPath, 'AA,BB');
}); });
} }

View File

@@ -0,0 +1,280 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:meshcore_sar_app/models/ble_packet_log.dart';
import 'package:meshcore_sar_app/services/profiles_feature_service.dart';
import 'package:meshcore_sar_app/services/traffic_stats_reporting_service.dart';
BlePacketLog _log({
required DateTime timestamp,
required List<int> rawData,
int responseCode = 0x88,
}) {
return BlePacketLog(
timestamp: timestamp,
rawData: Uint8List.fromList(rawData),
direction: PacketDirection.rx,
responseCode: responseCode,
);
}
List<int> _routeRaw({
required int payloadType,
required int pathDescriptor,
List<int> pathBytes = const <int>[],
}) {
return <int>[
0x88,
0x00,
0x00,
payloadType << 2,
0x00,
0x00,
0x00,
0x00,
pathDescriptor,
...pathBytes,
];
}
void main() {
setUp(() {
SharedPreferences.setMockInitialValues({});
ProfileStorageScope.setScope(
profilesEnabled: true,
activeProfileId: 'alpha',
);
});
test('uploads fixed packet type and path mode counters', () async {
final capturedPayloads = <Map<String, dynamic>>[];
DateTime now = DateTime.utc(2026, 4, 3, 10, 6);
final service = TrafficStatsReportingService(
client: MockClient((request) async {
capturedPayloads.add(
jsonDecode(request.body) as Map<String, dynamic>,
);
return http.Response('{}', 200);
}),
now: () => now,
appVersionProvider: () async => '2026.0402.1+44',
);
await service.initialize(
deviceKey6Provider: () => 'a1b2c3d4e5f6',
);
expect(service.isEnabled, isTrue);
await service.processLogs(<BlePacketLog>[
_log(
timestamp: DateTime.utc(2026, 4, 3, 10, 0, 5),
rawData: _routeRaw(
payloadType: 0x04,
pathDescriptor: 0x01,
pathBytes: const <int>[0xC0],
),
),
_log(
timestamp: DateTime.utc(2026, 4, 3, 10, 0, 10),
rawData: _routeRaw(
payloadType: 0x05,
pathDescriptor: 0x41,
pathBytes: const <int>[0xC0, 0x10],
),
),
_log(
timestamp: DateTime.utc(2026, 4, 3, 10, 0, 15),
rawData: _routeRaw(
payloadType: 0x08,
pathDescriptor: 0x81,
pathBytes: const <int>[0xC0, 0x10, 0x63],
),
),
_log(
timestamp: DateTime.utc(2026, 4, 3, 10, 0, 20),
rawData: _routeRaw(
payloadType: 0x01,
pathDescriptor: 0x00,
),
),
]);
expect(capturedPayloads, hasLength(1));
final payload = capturedPayloads.single;
final counts = payload['counts'] as Map<String, dynamic>;
expect(payload['deviceKey6'], 'a1b2c3d4e5f6');
expect(payload.containsKey('publicKey'), isFalse);
expect(payload.containsKey('location'), isFalse);
expect(counts['pt_04'], 1);
expect(counts['pt_05'], 1);
expect(counts['pt_08'], 1);
expect(counts['pt_01'], 1);
expect(counts['path_mode_1b'], 1);
expect(counts['path_mode_2b'], 1);
expect(counts['path_mode_3b'], 1);
expect(counts['path_mode_none'], 1);
expect(service.pendingUploadCount, 0);
service.dispose();
});
test('classifies malformed route descriptors as decode and path failures', () async {
final capturedPayloads = <Map<String, dynamic>>[];
final service = TrafficStatsReportingService(
client: MockClient((request) async {
capturedPayloads.add(
jsonDecode(request.body) as Map<String, dynamic>,
);
return http.Response('{}', 200);
}),
now: () => DateTime.utc(2026, 4, 3, 10, 6),
appVersionProvider: () async => '2026.0402.1+44',
);
await service.initialize(
deviceKey6Provider: () => 'a1b2c3d4e5f6',
);
await service.setEnabled(true);
await service.processLogs(<BlePacketLog>[
_log(
timestamp: DateTime.utc(2026, 4, 3, 10, 0, 30),
rawData: _routeRaw(
payloadType: 0x04,
pathDescriptor: 0x41,
),
),
]);
final counts =
(capturedPayloads.single['counts'] as Map<String, dynamic>);
expect(counts['decode_fail'], 1);
expect(counts['path_mode_unknown'], 1);
service.dispose();
});
test('persists queue and retries deterministically', () async {
final requestBodies = <Map<String, dynamic>>[];
var shouldFail = true;
DateTime now = DateTime.utc(2026, 4, 3, 10, 6);
final failingService = TrafficStatsReportingService(
client: MockClient((request) async {
requestBodies.add(
jsonDecode(request.body) as Map<String, dynamic>,
);
if (shouldFail) {
return http.Response('nope', 503);
}
return http.Response('{}', 200);
}),
now: () => now,
appVersionProvider: () async => '2026.0402.1+44',
);
await failingService.initialize(
deviceKey6Provider: () => 'a1b2c3d4e5f6',
);
await failingService.setEnabled(true);
await failingService.processLogs(<BlePacketLog>[
_log(
timestamp: DateTime.utc(2026, 4, 3, 10, 0, 5),
rawData: _routeRaw(
payloadType: 0x04,
pathDescriptor: 0x01,
pathBytes: const <int>[0xC0],
),
),
]);
expect(failingService.pendingUploadCount, 1);
expect(failingService.lastError, 'Upload failed (503)');
final prefs = await SharedPreferences.getInstance();
expect(
prefs.containsKey('traffic_stats_reporting_queue'),
isTrue,
);
expect(
requestBodies.single['reportId'],
'a1b2c3d4e5f6:2026-04-03T10:00:00.000Z',
);
failingService.dispose();
shouldFail = false;
now = DateTime.utc(2026, 4, 3, 10, 7);
final retryService = TrafficStatsReportingService(
client: MockClient((request) async {
requestBodies.add(
jsonDecode(request.body) as Map<String, dynamic>,
);
return http.Response('{}', 200);
}),
now: () => now,
appVersionProvider: () async => '2026.0402.1+44',
);
await retryService.initialize(
deviceKey6Provider: () => 'a1b2c3d4e5f6',
);
await retryService.flushPendingUploads();
expect(retryService.pendingUploadCount, 0);
expect(retryService.lastError, isNull);
expect(retryService.lastSuccessAt, now);
retryService.dispose();
});
test('ignores legacy interval preferences and keeps 5 minute windows', () async {
SharedPreferences.setMockInitialValues({
'traffic_stats_reporting_interval_minutes': 15,
});
final capturedPayloads = <Map<String, dynamic>>[];
final service = TrafficStatsReportingService(
client: MockClient((request) async {
capturedPayloads.add(
jsonDecode(request.body) as Map<String, dynamic>,
);
return http.Response('{}', 200);
}),
now: () => DateTime.utc(2026, 4, 3, 10, 6),
appVersionProvider: () async => '2026.0402.1+44',
);
await service.initialize(
deviceKey6Provider: () => 'a1b2c3d4e5f6',
);
await service.setEnabled(true);
await service.processLogs(<BlePacketLog>[
_log(
timestamp: DateTime.utc(2026, 4, 3, 10, 0, 5),
rawData: _routeRaw(
payloadType: 0x04,
pathDescriptor: 0x01,
pathBytes: const <int>[0xC0],
),
),
]);
final prefs = await SharedPreferences.getInstance();
expect(prefs.getBool('traffic_stats_reporting_enabled'), isTrue);
expect(prefs.containsKey('traffic_stats_reporting_interval_minutes'), isFalse);
expect(
prefs.containsKey('profile.alpha.traffic_stats_reporting_enabled'),
isFalse,
);
expect(
prefs.containsKey(
'profile.alpha.traffic_stats_reporting_interval_minutes',
),
isFalse,
);
expect(service.intervalMinutes, 5);
expect(capturedPayloads, hasLength(1));
service.dispose();
});
}

View File

@@ -3,6 +3,7 @@ import 'package:flutter_blue_plus/flutter_blue_plus.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_sar_app/l10n/app_localizations.dart'; import 'package:meshcore_sar_app/l10n/app_localizations.dart';
import 'package:meshcore_sar_app/providers/connection_provider.dart'; import 'package:meshcore_sar_app/providers/connection_provider.dart';
import 'package:meshcore_sar_app/services/network_scanner_service.dart';
import 'package:meshcore_sar_app/widgets/connection_dialog.dart'; import 'package:meshcore_sar_app/widgets/connection_dialog.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@@ -53,6 +54,51 @@ class _ConnectableFakeConnectionProvider extends ConnectionProvider {
} }
} }
class _TcpConnectableFakeConnectionProvider extends ConnectionProvider {
int connectTcpCalls = 0;
String? connectedHost;
int? connectedPort;
@override
List<ScannedDevice> get scannedDevices => const [];
@override
String? get error => null;
@override
Future<bool> connectTcp(String host, int port) async {
connectTcpCalls += 1;
connectedHost = host;
connectedPort = port;
return true;
}
}
class _FakeNetworkScannerService extends NetworkScannerService {
int scanCalls = 0;
@override
bool get isScanning => false;
@override
bool get hasCachedResults => false;
@override
List<DiscoveredServer> get cachedServers => const [];
@override
Future<List<DiscoveredServer>> scan({int? port}) async {
scanCalls += 1;
return const [];
}
@override
void clearCache() {}
@override
void stopScan() {}
}
void main() { void main() {
testWidgets('BLE scan waits for explicit user action', (tester) async { testWidgets('BLE scan waits for explicit user action', (tester) async {
final connectionProvider = _FakeConnectionProvider(); final connectionProvider = _FakeConnectionProvider();
@@ -126,4 +172,59 @@ void main() {
expect(connectionProvider.connectCalls, 1); expect(connectionProvider.connectCalls, 1);
expect(find.byType(ConnectionDialog), findsNothing); expect(find.byType(ConnectionDialog), findsNothing);
}); });
testWidgets('manual TCP connect accepts an IP address from the network tab', (
tester,
) async {
final connectionProvider = _TcpConnectableFakeConnectionProvider();
final networkScanner = _FakeNetworkScannerService();
await tester.pumpWidget(
ChangeNotifierProvider<ConnectionProvider>.value(
value: connectionProvider,
child: MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Builder(
builder: (context) => Scaffold(
body: Center(
child: FilledButton(
onPressed: () {
showModalBottomSheet<Object?>(
context: context,
isScrollControlled: true,
builder: (_) => ConnectionDialog(
networkScanner: networkScanner,
),
);
},
child: const Text('Open'),
),
),
),
),
),
),
);
await tester.tap(find.text('Open'));
await tester.pumpAndSettle();
await tester.tap(find.text('Network'));
await tester.pumpAndSettle();
expect(networkScanner.scanCalls, 1);
await tester.tap(find.byTooltip('Add IP address'));
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField), '192.168.1.42');
await tester.tap(find.widgetWithText(FilledButton, 'Connect'));
await tester.pumpAndSettle();
expect(connectionProvider.connectTcpCalls, 1);
expect(connectionProvider.connectedHost, '192.168.1.42');
expect(connectionProvider.connectedPort, NetworkScannerService.defaultPort);
expect(find.byType(ConnectionDialog), findsNothing);
});
} }

View File

@@ -1,9 +1,12 @@
import 'package:flutter/gestures.dart'; import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart' as flutter_map;
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:geolocator/geolocator.dart';
import 'package:meshcore_sar_app/l10n/app_localizations.dart'; import 'package:meshcore_sar_app/l10n/app_localizations.dart';
import 'package:meshcore_sar_app/models/message.dart'; import 'package:meshcore_sar_app/models/message.dart';
import 'package:meshcore_sar_app/models/message_contact_location.dart';
import 'package:meshcore_sar_app/providers/app_provider.dart'; import 'package:meshcore_sar_app/providers/app_provider.dart';
import 'package:meshcore_sar_app/providers/channels_provider.dart'; import 'package:meshcore_sar_app/providers/channels_provider.dart';
import 'package:meshcore_sar_app/providers/connection_provider.dart'; import 'package:meshcore_sar_app/providers/connection_provider.dart';
@@ -12,9 +15,11 @@ import 'package:meshcore_sar_app/providers/drawing_provider.dart';
import 'package:meshcore_sar_app/providers/image_provider.dart' as ip; import 'package:meshcore_sar_app/providers/image_provider.dart' as ip;
import 'package:meshcore_sar_app/providers/messages_provider.dart'; import 'package:meshcore_sar_app/providers/messages_provider.dart';
import 'package:meshcore_sar_app/providers/voice_provider.dart'; import 'package:meshcore_sar_app/providers/voice_provider.dart';
import 'package:meshcore_sar_app/services/location_tracking_service.dart';
import 'package:meshcore_sar_app/services/voice_codec_service.dart'; import 'package:meshcore_sar_app/services/voice_codec_service.dart';
import 'package:meshcore_sar_app/services/voice_player_service.dart'; import 'package:meshcore_sar_app/services/voice_player_service.dart';
import 'package:meshcore_sar_app/widgets/messages/message_bubble.dart'; import 'package:meshcore_sar_app/widgets/messages/message_bubble.dart';
import 'package:latlong2/latlong.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
@@ -25,6 +30,7 @@ void main() {
setUp(() { setUp(() {
SharedPreferences.setMockInitialValues({}); SharedPreferences.setMockInitialValues({});
LocationTrackingService().currentPosition = null;
launchedUrls.clear(); launchedUrls.clear();
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler( .setMockMethodCallHandler(
@@ -363,6 +369,99 @@ void main() {
await _disposeHarness(tester, harness); await _disposeHarness(tester, harness);
} }
}); });
testWidgets('technical details show a location map for stored snapshots', (
tester,
) async {
final harness = await _TestHarness.create();
try {
final message = Message(
id: 'message-location-details',
messageType: MessageType.contact,
senderPublicKeyPrefix: _prefix(71),
pathLen: 1,
textType: MessageTextType.plain,
senderTimestamp: 1700000000,
text: 'Location details',
receivedAt: DateTime.fromMillisecondsSinceEpoch(1700000000500),
deliveryStatus: MessageDeliveryStatus.received,
);
harness.messagesProvider.addMessage(
message,
contactLocationSnapshot: MessageContactLocation(
location: const LatLng(46.0569, 14.5058),
source: 'advert',
capturedAt: DateTime.fromMillisecondsSinceEpoch(1700000000600),
sourceTimestamp: DateTime.fromMillisecondsSinceEpoch(1700000000400),
),
);
await tester.pumpWidget(_buildApp(harness, message));
await tester.pumpAndSettle();
await tester.longPress(find.text('Location details'));
await tester.pumpAndSettle();
await tester.tap(find.text('Technical details'));
await tester.pumpAndSettle();
expect(find.byType(flutter_map.FlutterMap), findsOneWidget);
expect(find.text('46.056900, 14.505800'), findsOneWidget);
expect(find.text('shared advert'), findsOneWidget);
} finally {
await _disposeHarness(tester, harness);
}
});
testWidgets(
'technical details show a location map for sent channel messages using live position fallback',
(tester) async {
final harness = await _TestHarness.create();
try {
LocationTrackingService().currentPosition = Position(
latitude: 46.0569,
longitude: 14.5058,
timestamp: DateTime.fromMillisecondsSinceEpoch(1700000003000),
accuracy: 6.0,
altitude: 300.0,
altitudeAccuracy: 6.0,
heading: 0.0,
headingAccuracy: 0.0,
speed: 0.0,
speedAccuracy: 0.0,
);
final message = Message(
id: 'channel-live-fallback',
messageType: MessageType.channel,
senderPublicKeyPrefix: _prefix(81),
channelIdx: 0,
pathLen: 0,
textType: MessageTextType.plain,
senderTimestamp: 1700000000,
text: 'Channel fallback',
receivedAt: DateTime.fromMillisecondsSinceEpoch(1700000000500),
deliveryStatus: MessageDeliveryStatus.sent,
);
await tester.pumpWidget(_buildApp(harness, message));
await tester.pumpAndSettle();
await tester.longPress(find.text('Channel fallback'));
await tester.pumpAndSettle();
await tester.tap(find.text('Technical details'));
await tester.pumpAndSettle();
expect(find.byType(flutter_map.FlutterMap), findsOneWidget);
expect(find.text('46.056900, 14.505800'), findsOneWidget);
expect(find.text('shared location'), findsOneWidget);
} finally {
await _disposeHarness(tester, harness);
}
},
);
} }
Widget _buildApp(_TestHarness harness, Message message) { Widget _buildApp(_TestHarness harness, Message message) {

View File

@@ -0,0 +1,94 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:meshcore_sar_app/services/traffic_stats_reporting_service.dart';
import 'package:meshcore_sar_app/widgets/settings/traffic_stats_reporting_section.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
final launchedUrls = <String>[];
setUp(() {
launchedUrls.clear();
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(
const MethodChannel('plugins.flutter.io/url_launcher'),
(call) async {
switch (call.method) {
case 'canLaunch':
return true;
case 'launch':
final arguments = Map<dynamic, dynamic>.from(
call.arguments as Map<dynamic, dynamic>,
);
launchedUrls.add(arguments['url'] as String);
return true;
}
return null;
},
);
});
tearDown(() {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(
const MethodChannel('plugins.flutter.io/url_launcher'),
null,
);
});
testWidgets('starts enabled by default and can be disabled', (
tester,
) async {
SharedPreferences.setMockInitialValues({});
final service = TrafficStatsReportingService(
client: MockClient((request) async => http.Response('{}', 200)),
now: () => DateTime.utc(2026, 4, 3, 10, 6),
appVersionProvider: () async => '2026.0402.1+44',
);
await service.initialize(
deviceKey6Provider: () => 'a1b2c3d4e5f6',
);
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: ListenableBuilder(
listenable: service,
builder: (context, child) => TrafficStatsReportingSection(
service: service,
),
),
),
),
);
expect(find.text('Anonymous RX stats reporting'), findsOneWidget);
expect(
find.text(
'Upload RX live-traffic packet type and path mode totals to the fixed Cloudflare worker every 5 minutes.',
),
findsOneWidget,
);
expect(find.text('Reporting interval'), findsNothing);
expect(service.isEnabled, isTrue);
await tester.tap(find.byType(Switch));
await tester.pumpAndSettle();
expect(service.isEnabled, isFalse);
expect(service.intervalMinutes, 5);
await tester.tap(find.widgetWithText(TextButton, 'View public stats'));
await tester.pump();
expect(launchedUrls, ['https://mcstats.dz0ny.dev']);
service.dispose();
});
}

29
worker/README.md Normal file
View File

@@ -0,0 +1,29 @@
# MeshCore SAR Stats Worker
This worker follows the same split as `/Users/dz0ny/site-vendorvigilance`:
- Astro builds the dashboard UI into `dist`
- a small Cloudflare Worker handles `/api/*` before assets are served
## Layout
- `src/pages/index.astro` - dashboard shell
- `worker/index.ts` - Cloudflare Worker entrypoint
- `worker/stats.ts` - D1 queries, payload validation, and aggregation helpers
- `public/.assetsignore` - keeps Astro's private `_worker.js` bundle out of public asset uploads
- `schema.sql` - D1 schema
## Setup
1. Install dependencies with `bun install`.
2. Create a D1 database with `bunx wrangler d1 create meshcore_sar_rx_stats`.
3. Apply the schema with `bunx wrangler d1 execute meshcore_sar_rx_stats --remote --file=./schema.sql`.
4. Add the real D1 binding id to `wrangler.toml` when deploying.
5. Build the dashboard with `bun run build`.
6. Run checks with `bun run check`, `bun run test`, and `bun run typecheck`.
## Routes
- `GET /` - static Astro dashboard
- `GET /api/dashboard?window=24h|7d|30d` - aggregated dashboard JSON
- `POST /api/ingest` - anonymous RX stats ingest

30
worker/astro.config.mjs Normal file
View File

@@ -0,0 +1,30 @@
import { defineConfig } from "astro/config";
import cloudflare from "@astrojs/cloudflare";
import react from "@astrojs/react";
import tailwindcss from "@tailwindcss/vite";
export default defineConfig({
imageService: "compile",
integrations: [react()],
vite: {
cacheDir: ".astro/vite",
plugins: [tailwindcss()],
resolve: {
alias: {
"@": "/src",
},
},
},
build: {
concurrency: 4,
},
server: {
port: 4321,
host: "0.0.0.0",
allowedHosts: true,
},
devToolbar: {
enabled: false,
},
adapter: cloudflare(),
});

1291
worker/bun.lock Normal file

File diff suppressed because it is too large Load Diff

38
worker/package.json Normal file
View File

@@ -0,0 +1,38 @@
{
"name": "meshcore-sar-stats",
"private": true,
"version": "0.0.0",
"type": "module",
"packageManager": "bun@1.3.8",
"scripts": {
"dev": "astro dev --host 0.0.0.0",
"build": "astro build",
"check": "astro check",
"test": "bun test",
"typecheck": "tsc --noEmit",
"deploy": "wrangler deploy"
},
"dependencies": {
"@astrojs/check": "^0.9.6",
"@astrojs/cloudflare": "^12.6.12",
"@astrojs/react": "^4.4.2",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.13",
"@tailwindcss/vite": "^4.1.18",
"astro": "^5.17.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.18"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20260403.1",
"@types/bun": "^1.3.11",
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.2",
"typescript": "^5.9.3",
"wrangler": "^4.80.0"
}
}

View File

@@ -0,0 +1,2 @@
_worker.js
_routes.json

BIN
worker/public/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 668 B

42
worker/schema.sql Normal file
View File

@@ -0,0 +1,42 @@
CREATE TABLE IF NOT EXISTS reports (
report_id TEXT PRIMARY KEY,
device_key6 TEXT NOT NULL,
window_start TEXT NOT NULL,
window_end TEXT NOT NULL,
received_at TEXT NOT NULL,
app_version TEXT,
cf_country TEXT,
cf_region TEXT,
cf_city TEXT,
cf_latitude REAL,
cf_longitude REAL,
cf_colo TEXT,
pt_00 INTEGER NOT NULL DEFAULT 0,
pt_01 INTEGER NOT NULL DEFAULT 0,
pt_02 INTEGER NOT NULL DEFAULT 0,
pt_03 INTEGER NOT NULL DEFAULT 0,
pt_04 INTEGER NOT NULL DEFAULT 0,
pt_05 INTEGER NOT NULL DEFAULT 0,
pt_06 INTEGER NOT NULL DEFAULT 0,
pt_07 INTEGER NOT NULL DEFAULT 0,
pt_08 INTEGER NOT NULL DEFAULT 0,
pt_09 INTEGER NOT NULL DEFAULT 0,
pt_0a INTEGER NOT NULL DEFAULT 0,
pt_0b INTEGER NOT NULL DEFAULT 0,
pt_0c INTEGER NOT NULL DEFAULT 0,
pt_0d INTEGER NOT NULL DEFAULT 0,
pt_0e INTEGER NOT NULL DEFAULT 0,
pt_0f INTEGER NOT NULL DEFAULT 0,
decode_fail INTEGER NOT NULL DEFAULT 0,
path_mode_1b INTEGER NOT NULL DEFAULT 0,
path_mode_2b INTEGER NOT NULL DEFAULT 0,
path_mode_3b INTEGER NOT NULL DEFAULT 0,
path_mode_none INTEGER NOT NULL DEFAULT 0,
path_mode_unknown INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_reports_device_window_end
ON reports (device_key6, window_end);
CREATE INDEX IF NOT EXISTS idx_reports_window_end
ON reports (window_end);

View File

@@ -0,0 +1,809 @@
import { useEffect, useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
type WindowKey = "24h" | "7d";
type PacketTypeEntry = {
key: string;
label: string;
total: number;
};
type PathModeEntry = {
key: string;
label: string;
total: number;
};
type ReporterSummary = {
key6: string;
lastSeen: string;
packetTotal: number;
country: string;
city: string;
latitude: number | null;
longitude: number | null;
};
type ChartPoint = {
label: string;
totalPackets: number;
reports: number;
};
type AppVersionEntry = {
version: string;
reporters: number;
packets: number;
};
type TrafficComposition = {
human: number;
overhead: number;
acks: number;
};
type MultiHopRatio = {
direct: number;
multiHop: number;
};
type CompositionPoint = {
label: string;
human: number;
overhead: number;
};
type DashboardResponse = {
generatedAt: string;
filter: {
windowKey: WindowKey;
label: string;
sinceIso: string;
bucket: "hour" | "day";
};
reportCount: number;
uniqueDevices: number;
decodedPackets: number;
decodeFailures: number;
packetTypeTotals: PacketTypeEntry[];
pathModeTotals: PathModeEntry[];
recentReporters: ReporterSummary[];
chartPoints: ChartPoint[];
appVersions: AppVersionEntry[];
trafficComposition: TrafficComposition;
multiHopRatio: MultiHopRatio;
compositionOverTime: CompositionPoint[];
};
const WINDOW_OPTIONS: Array<{ key: WindowKey; label: string }> = [
{ key: "24h", label: "24h" },
{ key: "7d", label: "7 days" },
];
const PACKET_TYPE_INFO: Record<string, { title: string; summary: string }> = {
pt_00: { title: "FLOOD REQUEST", summary: "Encrypted request to a known peer" },
pt_01: { title: "FLOOD RESPONSE", summary: "Encrypted reply to a request" },
pt_02: { title: "FLOOD TEXT", summary: "Encrypted direct text with timestamp and retry flags" },
pt_03: { title: "FLOOD ACK", summary: "4-byte acknowledgement for an earlier message" },
pt_04: { title: "FLOOD ADVERTISEMENT", summary: "Signed node identity broadcast" },
pt_05: { title: "FLOOD GROUP_TEXT", summary: "Encrypted channel text matched by channel hash" },
pt_06: { title: "FLOOD GROUP_DATA", summary: "Encrypted channel data with type and length" },
pt_07: { title: "FLOOD ANON_REQUEST", summary: "Request using an ephemeral sender key" },
pt_08: { title: "FLOOD RETURNED_PATH", summary: "Return route back to sender, with optional bundled ACK" },
pt_09: { title: "FLOOD TRACE_PATH", summary: "Direct trace that records SNR at each hop" },
pt_0a: { title: "FLOOD MULTIPART", summary: "Wrapper for one packet in a multipart sequence" },
pt_0b: { title: "FLOOD CONTROL", summary: "Discovery or other control data" },
pt_0c: { title: "RESERVED 0x0C", summary: "Reserved protocol type" },
pt_0d: { title: "RESERVED 0x0D", summary: "Reserved protocol type" },
pt_0e: { title: "RESERVED 0x0E", summary: "Reserved protocol type" },
pt_0f: { title: "RAW CUSTOM", summary: "Application-defined custom packet" },
};
const PATH_MODE_ICONS: Record<string, string> = {
path_mode_1b: "1B",
path_mode_2b: "2B",
path_mode_3b: "3B",
path_mode_none: "--",
path_mode_unknown: "??",
};
export function DashboardShell() {
const [windowKey, setWindowKey] = useState<WindowKey>("24h");
const [summary, setSummary] = useState<DashboardResponse | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let isCancelled = false;
async function load() {
setIsLoading(true);
setError(null);
try {
const response = await fetch(`/api/dashboard?window=${windowKey}`, {
headers: { accept: "application/json" },
});
if (!response.ok) {
throw new Error(`Dashboard request failed (${response.status})`);
}
const nextSummary = (await response.json()) as DashboardResponse;
if (!isCancelled) setSummary(nextSummary);
} catch (nextError) {
if (!isCancelled) {
setError(nextError instanceof Error ? nextError.message : String(nextError));
}
} finally {
if (!isCancelled) setIsLoading(false);
}
}
void load();
return () => { isCancelled = true; };
}, [windowKey]);
const activePacketTypes = useMemo(
() => (summary?.packetTypeTotals ?? []).filter((e) => e.total > 0),
[summary],
);
const activePathModes = useMemo(
() => (summary?.pathModeTotals ?? []).filter((e) => e.total > 0),
[summary],
);
const totalPackets = useMemo(
() => (summary ? summary.decodedPackets + summary.decodeFailures : 0),
[summary],
);
const decodeRate = totalPackets > 0
? ((summary!.decodedPackets / totalPackets) * 100).toFixed(1)
: "0";
const maxTrend = Math.max(...(summary?.chartPoints ?? []).map((p) => p.totalPackets), 1);
const multiHopTotal = (summary?.multiHopRatio.direct ?? 0) + (summary?.multiHopRatio.multiHop ?? 0);
const multiHopPct = multiHopTotal > 0
? ((summary!.multiHopRatio.multiHop / multiHopTotal) * 100).toFixed(1)
: "0";
const comp = summary?.trafficComposition;
const compTotal = comp ? comp.human + comp.overhead + comp.acks : 0;
const humanPct = compTotal > 0 ? ((comp!.human / compTotal) * 100).toFixed(1) : "0";
const nodeCount = summary?.uniqueDevices ?? 0;
const avgPerNode = nodeCount > 0 ? Math.round(totalPackets / nodeCount) : 0;
return (
<div className="mx-auto max-w-[1320px] px-5 py-8">
<Tabs value={windowKey} onValueChange={(v) => setWindowKey(v as WindowKey)}>
{/* Header */}
<header className="mb-8 flex flex-wrap items-end justify-between gap-4">
<div className="space-y-2">
<div className="flex items-center gap-2.5">
<img src="/favicon.png" alt="MeshCore SAR" className="h-8 w-8 rounded-lg" />
<h1 className="text-2xl font-semibold tracking-tight">MeshCore SAR</h1>
</div>
<p className="max-w-xl text-sm text-muted-foreground">
Aggregated observations from opt-in mesh nodes. Counts reflect what reporting nodes observed, not unique network packets.
Ratios and percentages are statistically valid; absolute numbers scale with reporter count.
</p>
</div>
<div className="flex items-center gap-3">
<TabsList className="h-9 rounded-full bg-secondary/60 p-1">
{WINDOW_OPTIONS.map((o) => (
<TabsTrigger key={o.key} value={o.key} className="rounded-full px-4 text-xs">
{o.label}
</TabsTrigger>
))}
</TabsList>
{summary && (
<span className="text-xs text-muted-foreground">
Updated {formatRelative(summary.generatedAt)}
</span>
)}
</div>
</header>
{error && (
<div className="mb-6 flex items-center gap-3 rounded-2xl border border-destructive/20 bg-destructive/10 px-5 py-3 text-sm text-destructive">
<span className="flex-1">{error}</span>
<Button size="sm" variant="outline" onClick={() => setWindowKey((c) => c)}>Retry</Button>
</div>
)}
<TabsContent value={windowKey} className="space-y-6">
{/* Key metrics */}
<section className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
<MetricCard
label="Reporting Nodes"
value={nodeCount}
note="Opt-in nodes in this window"
/>
<MetricCard
label="Avg / Node"
value={avgPerNode}
note={`${totalPackets.toLocaleString()} total obs.`}
/>
<MetricCard
label="Decode Rate"
value={`${decodeRate}%`}
note={`${summary?.decodeFailures ?? 0} failed`}
/>
<MetricCard
label="Multi-Hop"
value={`${multiHopPct}%`}
note="Relayed through mesh"
/>
<MetricCard
label="Messages"
value={`${humanPct}%`}
note="Text + group text"
/>
<MetricCard
label="Protocol Types"
value={activePacketTypes.length}
note="of 16 types observed"
/>
</section>
{/* Traffic breakdown */}
<section className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Traffic Breakdown</CardTitle>
<CardDescription>Observed traffic composition across all reporting nodes</CardDescription>
</CardHeader>
<CardContent className="space-y-5">
{comp && compTotal > 0 ? (
<>
<div className="flex h-6 overflow-hidden rounded-full">
<div className="bg-primary" style={{ width: `${(comp.human / compTotal) * 100}%` }} title={`Messages: ${comp.human}`} />
<div className="bg-amber-400" style={{ width: `${(comp.acks / compTotal) * 100}%` }} title={`Acks: ${comp.acks}`} />
<div className="bg-secondary" style={{ width: `${(comp.overhead / compTotal) * 100}%` }} title={`Overhead: ${comp.overhead}`} />
</div>
<div className="grid grid-cols-3 gap-3 text-center">
<div>
<div className="mx-auto mb-1 h-2.5 w-2.5 rounded-full bg-primary" />
<div className="text-lg font-semibold tabular-nums">{comp.human.toLocaleString()}</div>
<div className="text-xs text-muted-foreground">Messages</div>
</div>
<div>
<div className="mx-auto mb-1 h-2.5 w-2.5 rounded-full bg-amber-400" />
<div className="text-lg font-semibold tabular-nums">{comp.acks.toLocaleString()}</div>
<div className="text-xs text-muted-foreground">Acks</div>
</div>
<div>
<div className="mx-auto mb-1 h-2.5 w-2.5 rounded-full bg-secondary" />
<div className="text-lg font-semibold tabular-nums">{comp.overhead.toLocaleString()}</div>
<div className="text-xs text-muted-foreground">Overhead</div>
</div>
</div>
</>
) : (
<EmptyState label="No traffic data yet." />
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Messages vs Protocol Overhead</CardTitle>
<CardDescription>
Human messages (text + group text) compared to protocol overhead (acks, advertisements, routing, control) over time.
</CardDescription>
</CardHeader>
<CardContent>
{summary?.compositionOverTime.length ? (
<CompositionChart points={summary.compositionOverTime} />
) : (
<EmptyState label="No composition data yet." />
)}
</CardContent>
</Card>
</section>
{/* Protocol breakdown */}
<section className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Protocol Packet Types</CardTitle>
<CardDescription>
MeshCore protocol uses 16 packet type codes (0x00 - 0x0F).
Showing types with traffic in this window.
</CardDescription>
</CardHeader>
<CardContent>
{activePacketTypes.length ? (
<div className="grid gap-2 sm:grid-cols-2">
{activePacketTypes.map((entry) => {
const pct = totalPackets > 0 ? ((entry.total / totalPackets) * 100).toFixed(1) : "0";
const info = PACKET_TYPE_INFO[entry.key];
return (
<div key={entry.key} className="flex gap-3 rounded-xl border border-border/50 bg-secondary/30 p-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-xs font-mono font-semibold text-primary">
{entry.key.replace("pt_", "0x").toUpperCase()}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-baseline justify-between gap-2">
<span className="text-sm font-medium">{entry.label}</span>
<span className="shrink-0 text-xs text-muted-foreground">{pct}%</span>
</div>
<div className="mt-0.5 font-mono text-[0.65rem] text-muted-foreground/70">
{info?.title ?? entry.key}
</div>
<p className="mt-0.5 text-xs text-muted-foreground">
{info?.summary ?? ""}
</p>
<div className="mt-1.5 flex items-center gap-2">
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-secondary">
<div
className="h-full rounded-full bg-primary/70"
style={{ width: `${pct}%` }}
/>
</div>
<span className="text-xs font-semibold tabular-nums">{entry.total.toLocaleString()} obs.</span>
</div>
</div>
</div>
);
})}
</div>
) : (
<EmptyState label="No packet data yet." />
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Path Routing Modes</CardTitle>
<CardDescription>
Path hash byte length determines routing precision.
Longer hashes allow more specific multi-hop paths.
</CardDescription>
</CardHeader>
<CardContent>
{activePathModes.length ? (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-5">
{activePathModes.map((entry) => {
const pct = totalPackets > 0 ? ((entry.total / totalPackets) * 100).toFixed(1) : "0";
return (
<div key={entry.key} className="rounded-xl border border-border/50 bg-secondary/30 p-4 text-center">
<div className="mx-auto mb-2 flex h-10 w-10 items-center justify-center rounded-full bg-primary/10 text-sm font-bold text-primary">
{PATH_MODE_ICONS[entry.key] ?? "?"}
</div>
<div className="text-lg font-semibold tabular-nums">{entry.total.toLocaleString()}</div>
<div className="mt-0.5 text-xs text-muted-foreground">{entry.label}</div>
<div className="mt-1 text-xs text-muted-foreground">{pct}%</div>
</div>
);
})}
</div>
) : (
<EmptyState label="No path mode samples." />
)}
</CardContent>
</Card>
</section>
{/* Map + Traffic trend */}
<section className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Observations Over Time</CardTitle>
<CardDescription>
Per-node average observations per {summary?.filter.bucket ?? "time"} bucket.
Normalizing by reporter count removes the bias of more nodes = higher numbers.
</CardDescription>
</CardHeader>
<CardContent>
{isLoading && !summary ? (
<EmptyState label="Loading..." />
) : summary?.chartPoints.length ? (
<TrafficChart points={summary.chartPoints} maxValue={maxTrend} />
) : (
<EmptyState label="No data for this window." />
)}
</CardContent>
</Card>
</section>
{/* App versions */}
<section>
<Card>
<CardHeader>
<CardTitle>App Versions</CardTitle>
<CardDescription>Distribution of reporting app versions by node count and observations</CardDescription>
</CardHeader>
<CardContent>
{summary?.appVersions.length ? (
<div className="space-y-3">
{summary.appVersions.map((entry) => {
const maxPkts = summary.appVersions[0].packets;
return (
<div key={entry.version} className="space-y-1.5">
<div className="flex items-baseline justify-between gap-3 text-sm">
<span className="font-mono font-medium">{entry.version}</span>
<span className="text-xs text-muted-foreground">
{entry.reporters} {entry.reporters === 1 ? "node" : "nodes"} / {entry.packets.toLocaleString()} obs.
</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-secondary">
<div
className="h-full rounded-full bg-primary/70"
style={{ width: `${(entry.packets / maxPkts) * 100}%` }}
/>
</div>
</div>
);
})}
</div>
) : (
<EmptyState label="No version data yet." />
)}
</CardContent>
</Card>
</section>
</TabsContent>
</Tabs>
</div>
);
}
// --- Composition stacked area chart ---
const MIN_CHART_W = 320;
const COMPOSITION_CHART_H = 236;
function useResponsiveChartWidth(minWidth = MIN_CHART_W) {
const [element, setElement] = useState<HTMLDivElement | null>(null);
const [width, setWidth] = useState(minWidth);
useEffect(() => {
if (!element) return;
const sync = () => setWidth(Math.max(element.clientWidth, minWidth));
sync();
if (typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver(sync);
observer.observe(element);
return () => { observer.disconnect(); };
}, [element, minWidth]);
return { setElement, width };
}
function CompositionChart({ points }: { points: CompositionPoint[] }) {
const [hover, setHover] = useState<number | null>(null);
const { setElement, width } = useResponsiveChartWidth();
const count = points.length;
if (count === 0) return null;
const maxVal = Math.max(...points.map((p) => p.human + p.overhead), 1);
const pad = { top: 16, right: 16, bottom: 32, left: 48 };
const innerW = Math.max(width - pad.left - pad.right, 240);
const innerH = COMPOSITION_CHART_H - pad.top - pad.bottom;
const xs = points.map((_, i) => i / Math.max(count - 1, 1));
// stacked: overhead on bottom, human on top
const overheadYs = points.map((p) => 1 - p.overhead / maxVal);
const totalYs = points.map((p) => 1 - (p.overhead + p.human) / maxVal);
const overheadArea = buildAreaPath(xs, overheadYs, innerW, innerH);
const humanArea = buildStackedAreaPath(xs, totalYs, overheadYs, innerW, innerH);
const labelStep = Math.max(1, Math.floor(count / 6));
const gridLines = niceGridLines(maxVal, 3);
return (
<div ref={setElement} className="relative select-none">
<svg
viewBox={`${-pad.left} ${-pad.top} ${innerW + pad.left + pad.right} ${innerH + pad.top + pad.bottom}`}
className="h-[236px] w-full"
onMouseLeave={() => setHover(null)}
>
<defs>
<linearGradient id="humanFill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="hsl(var(--chart-primary))" stopOpacity={0.5} />
<stop offset="100%" stopColor="hsl(var(--chart-primary))" stopOpacity={0.1} />
</linearGradient>
<linearGradient id="overheadFill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="hsl(var(--chart-secondary))" stopOpacity={0.5} />
<stop offset="100%" stopColor="hsl(var(--chart-secondary))" stopOpacity={0.1} />
</linearGradient>
</defs>
{gridLines.map((val) => {
const y = (1 - val / maxVal) * innerH;
return (
<g key={val}>
<line x1={0} y1={y} x2={innerW} y2={y} stroke="hsl(var(--chart-grid))" strokeWidth={0.3} />
<text x={-6} y={y} textAnchor="end" dominantBaseline="middle" fill="hsl(var(--chart-label))" fontSize={3.2} fontFamily="var(--font-sans)">
{formatCompact(val)}
</text>
</g>
);
})}
<line x1={0} y1={innerH} x2={innerW} y2={innerH} stroke="hsl(var(--chart-grid))" strokeWidth={0.4} />
<path d={overheadArea} fill="url(#overheadFill)" />
<path d={humanArea} fill="url(#humanFill)" />
{/* X labels */}
{points.map((p, i) => (i % labelStep === 0 || i === count - 1) ? (
<text key={i} x={xs[i] * innerW} y={innerH + 10} textAnchor="middle" fill="hsl(var(--chart-label))" fontSize={2.8} fontFamily="var(--font-sans)">
{p.label.slice(5)}
</text>
) : null)}
{/* hover zones */}
{points.map((point, i) => (
<rect
key={point.label}
x={xs[i] * innerW - innerW / count / 2}
y={0}
width={innerW / count}
height={innerH}
fill="transparent"
onMouseEnter={() => setHover(i)}
/>
))}
{hover !== null && (
<line x1={xs[hover] * innerW} y1={0} x2={xs[hover] * innerW} y2={innerH} stroke="hsl(var(--chart-primary))" strokeWidth={0.3} strokeDasharray="1.5 1" />
)}
</svg>
{hover !== null && (
<div
className="pointer-events-none absolute -top-2 z-10 -translate-x-1/2 rounded-lg border border-border/50 bg-card px-3 py-1.5 text-xs shadow-lg"
style={{ left: `${(pad.left + xs[hover] * innerW) / (innerW + pad.left + pad.right) * 100}%` }}
>
<div className="font-semibold">{points[hover].label}</div>
<div className="flex items-center gap-1.5"><span className="inline-block h-2 w-2 rounded-full bg-primary" /> Messages: {points[hover].human.toLocaleString()}</div>
<div className="flex items-center gap-1.5"><span className="inline-block h-2 w-2 rounded-full bg-secondary" /> Overhead: {points[hover].overhead.toLocaleString()}</div>
</div>
)}
<div className="mt-2 flex items-center justify-center gap-4 text-xs text-muted-foreground">
<span className="flex items-center gap-1.5"><span className="inline-block h-2.5 w-2.5 rounded-full bg-primary/60" /> Messages</span>
<span className="flex items-center gap-1.5"><span className="inline-block h-2.5 w-2.5 rounded-full bg-secondary/70" /> Overhead</span>
</div>
</div>
);
}
function buildAreaPath(xs: number[], ys: number[], w: number, h: number): string {
const line = xs.map((x, i) => `${i === 0 ? "M" : "L"}${x * w},${ys[i] * h}`).join(" ");
return `${line} L${xs[xs.length - 1] * w},${h} L0,${h} Z`;
}
function buildStackedAreaPath(xs: number[], topYs: number[], bottomYs: number[], w: number, h: number): string {
const top = xs.map((x, i) => `${i === 0 ? "M" : "L"}${x * w},${topYs[i] * h}`).join(" ");
const bottom = [...xs].reverse().map((x, i) => `L${x * w},${bottomYs[xs.length - 1 - i] * h}`).join(" ");
return `${top} ${bottom} Z`;
}
// --- Traffic trend chart ---
const CHART_H = 240;
const CHART_PAD = { top: 20, right: 16, bottom: 32, left: 48 };
function TrafficChart({ points, maxValue: _rawMax }: { points: ChartPoint[]; maxValue: number }) {
const [hover, setHover] = useState<number | null>(null);
const { setElement, width } = useResponsiveChartWidth();
const count = points.length;
if (count === 0) return null;
// Normalize: per-node average per bucket
const normalized = points.map((p) => ({
...p,
perNode: p.reports > 0 ? Math.round(p.totalPackets / p.reports) : 0,
}));
const maxValue = Math.max(...normalized.map((p) => p.perNode), 1);
const innerW = Math.max(width - CHART_PAD.left - CHART_PAD.right, 240);
const innerH = CHART_H - CHART_PAD.top - CHART_PAD.bottom;
const peakIdx = normalized.reduce((best, p, i) => (p.perNode > normalized[best].perNode ? i : best), 0);
const gridLines = niceGridLines(maxValue, 4);
const xs = normalized.map((_, i) => i / Math.max(count - 1, 1));
const ys = normalized.map((p) => 1 - p.perNode / maxValue);
const linePath = xs.map((x, i) => `${i === 0 ? "M" : "L"}${x * innerW},${ys[i] * innerH}`).join(" ");
const areaPath = `${linePath} L${xs[xs.length - 1] * innerW},${innerH} L0,${innerH} Z`;
// X-axis labels: show ~6 evenly spaced
const labelStep = Math.max(1, Math.floor(count / 6));
const xLabels = points
.map((p, i) => ({ i, label: p.label.slice(5) }))
.filter((_, i) => i % labelStep === 0 || i === count - 1);
return (
<div ref={setElement} className="relative select-none">
<svg
viewBox={`${-CHART_PAD.left} ${-CHART_PAD.top} ${innerW + CHART_PAD.left + CHART_PAD.right} ${CHART_H}`}
className="h-[240px] w-full"
onMouseLeave={() => setHover(null)}
>
<defs>
<linearGradient id="areaFill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="hsl(var(--chart-primary))" stopOpacity={0.35} />
<stop offset="100%" stopColor="hsl(var(--chart-primary))" stopOpacity={0.03} />
</linearGradient>
<linearGradient id="lineStroke" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stopColor="hsl(var(--chart-primary-light))" />
<stop offset="100%" stopColor="hsl(var(--chart-primary-dark))" />
</linearGradient>
</defs>
{/* Y grid lines */}
{gridLines.map((val) => {
const y = (1 - val / maxValue) * innerH;
return (
<g key={val}>
<line x1={0} y1={y} x2={innerW} y2={y} stroke="hsl(var(--chart-grid))" strokeWidth={0.3} />
<text x={-6} y={y} textAnchor="end" dominantBaseline="middle" fill="hsl(var(--chart-label))" fontSize={3.2} fontFamily="var(--font-sans)">
{formatCompact(val)}
</text>
</g>
);
})}
{/* baseline */}
<line x1={0} y1={innerH} x2={innerW} y2={innerH} stroke="hsl(var(--chart-grid))" strokeWidth={0.4} />
{/* area fill */}
<path d={areaPath} fill="url(#areaFill)" />
{/* line */}
<path d={linePath} fill="none" stroke="url(#lineStroke)" strokeWidth={0.7} strokeLinecap="round" strokeLinejoin="round" />
{/* peak dot */}
<circle
cx={xs[peakIdx] * innerW}
cy={ys[peakIdx] * innerH}
r={1.5}
fill="hsl(var(--chart-primary))"
stroke="white"
strokeWidth={0.6}
/>
{/* peak label */}
<text
x={xs[peakIdx] * innerW}
y={ys[peakIdx] * innerH - 4}
textAnchor="middle"
fill="hsl(var(--chart-primary-accent))"
fontSize={3}
fontWeight={600}
fontFamily="var(--font-sans)"
>
{normalized[peakIdx].perNode.toLocaleString()}
</text>
{/* X-axis labels */}
{xLabels.map(({ i, label }) => (
<text
key={i}
x={xs[i] * innerW}
y={innerH + 10}
textAnchor="middle"
fill="hsl(var(--chart-label))"
fontSize={2.8}
fontFamily="var(--font-sans)"
>
{label}
</text>
))}
{/* invisible hover zones */}
{points.map((point, i) => {
const sliceW = innerW / count;
return (
<rect
key={point.label}
x={xs[i] * innerW - sliceW / 2}
y={0}
width={sliceW}
height={innerH}
fill="transparent"
onMouseEnter={() => setHover(i)}
/>
);
})}
{/* hover indicator */}
{hover !== null && (
<>
<line
x1={xs[hover] * innerW}
y1={0}
x2={xs[hover] * innerW}
y2={innerH}
stroke="hsl(var(--chart-primary))"
strokeWidth={0.3}
strokeDasharray="1.5 1"
/>
<circle
cx={xs[hover] * innerW}
cy={ys[hover] * innerH}
r={1.2}
fill="white"
stroke="hsl(var(--chart-primary))"
strokeWidth={0.6}
/>
</>
)}
</svg>
{/* hover tooltip */}
{hover !== null && (
<div
className="pointer-events-none absolute -top-2 z-10 -translate-x-1/2 rounded-lg border border-border/50 bg-card px-3 py-1.5 text-xs shadow-lg"
style={{
left: `${(CHART_PAD.left + xs[hover] * innerW) / (innerW + CHART_PAD.left + CHART_PAD.right) * 100}%`,
}}
>
<div className="font-semibold tabular-nums">{normalized[hover].perNode.toLocaleString()} avg/node</div>
<div className="text-muted-foreground">{normalized[hover].totalPackets.toLocaleString()} total from {normalized[hover].reports} {normalized[hover].reports === 1 ? "node" : "nodes"}</div>
<div className="text-muted-foreground">{normalized[hover].label}</div>
</div>
)}
</div>
);
}
function niceGridLines(max: number, count: number): number[] {
if (max <= 0) return [0];
const rough = max / count;
const magnitude = 10 ** Math.floor(Math.log10(rough));
const residual = rough / magnitude;
const nice = residual <= 1.5 ? 1 : residual <= 3 ? 2 : residual <= 7 ? 5 : 10;
const step = nice * magnitude;
const lines: number[] = [];
for (let v = step; v <= max; v += step) {
lines.push(Math.round(v));
}
return lines;
}
function formatCompact(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(n >= 10_000 ? 0 : 1)}K`;
return String(n);
}
function MetricCard({ label, value, note }: { label: string; value: number | string; note: string }) {
return (
<Card>
<CardHeader className="gap-1.5">
<CardDescription className="text-xs uppercase tracking-wider">{label}</CardDescription>
<CardTitle className="text-3xl tabular-nums">{typeof value === "number" ? value.toLocaleString() : value}</CardTitle>
<CardDescription>{note}</CardDescription>
</CardHeader>
</Card>
);
}
function EmptyState({ label }: { label: string }) {
return (
<div className="rounded-2xl border border-dashed border-border bg-secondary/20 px-4 py-8 text-center text-sm text-muted-foreground">
{label}
</div>
);
}
function formatRelative(value: string) {
const diffMs = Date.now() - new Date(value).getTime();
const diffMin = Math.floor(diffMs / 60000);
if (diffMin < 1) return "just now";
if (diffMin < 60) return `${diffMin}m ago`;
const diffHr = Math.floor(diffMin / 60);
if (diffHr < 24) return `${diffHr}h ago`;
return `${Math.floor(diffHr / 24)}d ago`;
}

View File

@@ -0,0 +1,31 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors",
{
variants: {
variant: {
default: "border-transparent bg-primary text-primary-foreground",
secondary: "border-transparent bg-secondary text-secondary-foreground",
destructive: "border-transparent bg-destructive text-destructive-foreground",
outline: "text-foreground bg-card/60",
},
},
defaultVariants: {
variant: "default",
},
},
);
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
}
export { Badge, badgeVariants };

View File

@@ -0,0 +1,54 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
type ButtonProps = React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
};
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all cursor-pointer disabled:pointer-events-none disabled:opacity-50 outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline: "border bg-card hover:bg-muted",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/90",
ghost: "hover:bg-muted",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3",
lg: "h-10 rounded-md px-6",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
function Button({
className,
variant,
size,
asChild = false,
...props
}: ButtonProps) {
const Comp = asChild ? Slot : "button";
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
}
export { Button, buttonVariants, type ButtonProps };

View File

@@ -0,0 +1,52 @@
import * as React from "react";
import { cn } from "@/lib/utils";
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card/88 text-card-foreground flex flex-col gap-6 rounded-[1.5rem] border border-border/50 py-6 shadow-[0_18px_48px_rgba(16,33,47,0.08)] dark:shadow-[0_18px_48px_rgba(0,0,0,0.3)] backdrop-blur-md",
className,
)}
{...props}
/>
);
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn("grid auto-rows-min items-start gap-2 px-6", className)}
{...props}
/>
);
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
);
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="card-content" className={cn("px-6", className)} {...props} />;
}
export { Card, CardHeader, CardTitle, CardDescription, CardContent };

View File

@@ -0,0 +1,64 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableElement>>(
({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table ref={ref} className={cn("w-full caption-bottom text-sm", className)} {...props} />
</div>
),
);
Table.displayName = "Table";
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
));
TableHeader.displayName = "TableHeader";
const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody ref={ref} className={cn("[&_tr:last-child]:border-0", className)} {...props} />
));
TableBody.displayName = "TableBody";
const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTMLTableRowElement>>(
({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
"border-b transition-colors hover:bg-muted/50",
className,
)}
{...props}
/>
),
);
TableRow.displayName = "TableRow";
const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn("h-12 px-4 text-left align-middle font-medium text-muted-foreground", className)}
{...props}
/>
));
TableHead.displayName = "TableHead";
const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<td ref={ref} className={cn("p-4 align-middle", className)} {...props} />
));
TableCell.displayName = "TableCell";
export { Table, TableHeader, TableBody, TableRow, TableHead, TableCell };

View File

@@ -0,0 +1,50 @@
import * as React from "react";
import * as TabsPrimitive from "@radix-ui/react-tabs";
import { cn } from "@/lib/utils";
const Tabs = TabsPrimitive.Root;
const TabsList = React.forwardRef<
React.ComponentRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-11 items-center justify-center rounded-full border border-white/70 bg-white/60 p-1 text-muted-foreground shadow-sm backdrop-blur",
className,
)}
{...props}
/>
));
TabsList.displayName = TabsPrimitive.List.displayName;
const TabsTrigger = React.forwardRef<
React.ComponentRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-full px-4 py-2 text-sm font-medium transition-all focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-primary data-[state=active]:text-primary-foreground data-[state=active]:shadow-sm",
className,
)}
{...props}
/>
));
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
const TabsContent = React.forwardRef<
React.ComponentRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn("mt-6 focus-visible:outline-none", className)}
{...props}
/>
));
TabsContent.displayName = TabsPrimitive.Content.displayName;
export { Tabs, TabsList, TabsTrigger, TabsContent };

99
worker/src/index.css Normal file
View File

@@ -0,0 +1,99 @@
@import "tailwindcss";
@theme {
--font-sans: "IBM Plex Sans", "Avenir Next", "Segoe UI", sans-serif;
--font-mono: "IBM Plex Mono", "SFMono-Regular", monospace;
--color-border: hsl(var(--border));
--color-input: hsl(var(--input));
--color-ring: hsl(var(--ring));
--color-background: hsl(var(--background));
--color-foreground: hsl(var(--foreground));
--color-primary: hsl(var(--primary));
--color-primary-foreground: hsl(var(--primary-foreground));
--color-secondary: hsl(var(--secondary));
--color-secondary-foreground: hsl(var(--secondary-foreground));
--color-muted: hsl(var(--muted));
--color-muted-foreground: hsl(var(--muted-foreground));
--color-accent: hsl(var(--accent));
--color-accent-foreground: hsl(var(--accent-foreground));
--color-destructive: hsl(var(--destructive));
--color-destructive-foreground: hsl(var(--destructive-foreground));
--color-card: hsl(var(--card));
--color-card-foreground: hsl(var(--card-foreground));
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
}
@layer base {
:root {
--background: 220 33% 97%;
--foreground: 222 47% 11%;
--card: 0 0% 100%;
--card-foreground: 222 47% 11%;
--primary: 217 91% 60%;
--primary-foreground: 0 0% 100%;
--secondary: 220 50% 93%;
--secondary-foreground: 222 47% 11%;
--muted: 220 26% 93%;
--muted-foreground: 220 13% 46%;
--accent: 217 91% 60%;
--accent-foreground: 0 0% 100%;
--destructive: 0 72% 51%;
--destructive-foreground: 0 0% 100%;
--border: 220 20% 88%;
--input: 220 20% 88%;
--ring: 217 91% 60%;
--radius: 1rem;
--chart-grid: 220 20% 88%;
--chart-label: 220 13% 46%;
--chart-primary: 217 91% 60%;
--chart-primary-light: 217 91% 65%;
--chart-primary-dark: 217 70% 50%;
--chart-primary-accent: 217 91% 45%;
--chart-secondary: 206 22% 75%;
}
.dark {
--background: 222 47% 8%;
--foreground: 220 20% 90%;
--card: 222 40% 12%;
--card-foreground: 220 20% 90%;
--primary: 217 91% 60%;
--primary-foreground: 0 0% 100%;
--secondary: 220 30% 18%;
--secondary-foreground: 220 20% 90%;
--muted: 220 20% 16%;
--muted-foreground: 220 13% 55%;
--accent: 217 91% 60%;
--accent-foreground: 0 0% 100%;
--destructive: 0 72% 51%;
--destructive-foreground: 0 0% 100%;
--border: 220 20% 20%;
--input: 220 20% 20%;
--ring: 217 91% 60%;
--chart-grid: 220 20% 22%;
--chart-label: 220 13% 50%;
--chart-primary: 217 91% 65%;
--chart-primary-light: 217 91% 70%;
--chart-primary-dark: 217 70% 55%;
--chart-primary-accent: 217 91% 55%;
--chart-secondary: 220 20% 35%;
}
* {
border-color: hsl(var(--border));
}
html {
font-family: var(--font-sans);
color-scheme: light dark;
}
body {
min-height: 100vh;
background: hsl(var(--background));
color: hsl(var(--foreground));
}
}

View File

@@ -0,0 +1,28 @@
---
interface Props {
title?: string;
}
const { title = "MeshCore SAR RX Stats" } = Astro.props;
import "../index.css";
---
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/png" href="/favicon.png" />
<title>{title}</title>
</head>
<body>
<slot />
<script>
const mq = window.matchMedia("(prefers-color-scheme: dark)");
function apply(e: MediaQueryList | MediaQueryListEvent) { document.documentElement.classList.toggle("dark", e.matches); }
apply(mq);
mq.addEventListener("change", apply);
</script>
</body>
</html>

6
worker/src/lib/utils.ts Normal file
View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

View File

@@ -0,0 +1,8 @@
---
import BaseLayout from "../layouts/BaseLayout.astro";
import { DashboardShell } from "@/components/dashboard/dashboard-shell";
---
<BaseLayout title="MeshCore SAR RX Stats">
<DashboardShell client:load />
</BaseLayout>

169
worker/test/index.test.ts Normal file
View File

@@ -0,0 +1,169 @@
import { describe, expect, test } from 'bun:test';
import {
REPORT_INSERT_SQL,
buildWindowFilter,
createEmptyCounts,
extractCfGeo,
loadDashboardSummary,
summarizeRows,
validateIngestPayload,
} from '../worker/stats';
describe('validateIngestPayload', () => {
test('accepts a complete payload with fixed columns', () => {
const counts = createEmptyCounts();
counts.pt_04 = 12;
counts.path_mode_2b = 4;
const payload = validateIngestPayload({
reportId: 'a1b2c3d4e5f6:2026-04-03T10:00:00.000Z',
deviceKey6: 'a1b2c3d4e5f6',
windowStart: '2026-04-03T10:00:00.000Z',
windowEnd: '2026-04-03T10:05:00.000Z',
appVersion: '2026.0402.1+44',
counts,
});
expect(payload.counts.pt_04).toBe(12);
expect(payload.counts.path_mode_2b).toBe(4);
});
test('rejects missing fixed count keys', () => {
expect(() =>
validateIngestPayload({
reportId: 'a1b2c3d4e5f6:2026-04-03T10:00:00.000Z',
deviceKey6: 'a1b2c3d4e5f6',
windowStart: '2026-04-03T10:00:00.000Z',
windowEnd: '2026-04-03T10:05:00.000Z',
appVersion: '2026.0402.1+44',
counts: {},
}),
).toThrow('counts.pt_00 must be a number.');
});
});
describe('worker helpers', () => {
test('uses insert-or-ignore semantics for idempotent reports', () => {
expect(REPORT_INSERT_SQL).toContain('INSERT OR IGNORE INTO reports');
});
test('extractCfGeo reads Cloudflare request metadata', () => {
const request = new Request('https://example.com/') as Request & {
cf?: Record<string, unknown>;
};
request.cf = {
country: 'SI',
region: 'Ljubljana',
city: 'Ljubljana',
latitude: '46.0569',
longitude: '14.5058',
colo: 'LJU',
};
const geo = extractCfGeo(request);
expect(geo.country).toBe('SI');
expect(geo.latitude).toBe(46.0569);
expect(geo.longitude).toBe(14.5058);
expect(geo.colo).toBe('LJU');
});
test('summarizeRows aggregates packet and path mode totals', () => {
const filter = buildWindowFilter('24h', new Date('2026-04-03T12:00:00.000Z'));
const rows = [
{
...createEmptyCounts(),
report_id: 'a1',
device_key6: 'a1b2c3d4e5f6',
window_start: '2026-04-03T10:00:00.000Z',
window_end: '2026-04-03T10:05:00.000Z',
received_at: '2026-04-03T10:05:03.000Z',
app_version: '2026.0402.1+44',
cf_country: 'SI',
cf_region: 'Ljubljana',
cf_city: 'Ljubljana',
cf_latitude: 46.0569,
cf_longitude: 14.5058,
cf_colo: 'LJU',
pt_04: 12,
path_mode_2b: 12,
},
{
...createEmptyCounts(),
report_id: 'a2',
device_key6: '001122334455',
window_start: '2026-04-03T11:00:00.000Z',
window_end: '2026-04-03T11:05:00.000Z',
received_at: '2026-04-03T11:05:02.000Z',
app_version: '2026.0402.1+44',
cf_country: 'DE',
cf_region: 'Berlin',
cf_city: 'Berlin',
cf_latitude: 52.52,
cf_longitude: 13.405,
cf_colo: 'FRA',
pt_05: 3,
decode_fail: 1,
path_mode_none: 1,
path_mode_3b: 2,
},
];
const summary = summarizeRows(rows, filter);
expect(summary.reportCount).toBe(2);
expect(summary.uniqueDevices).toBe(2);
expect(summary.decodedPackets).toBe(15);
expect(summary.decodeFailures).toBe(1);
expect(summary.pathModeTotals[0]?.total).toBe(12);
expect(summary.locationPoints).toHaveLength(2);
});
test('loadDashboardSummary reads D1 rows for the selected window', async () => {
const rows = [
{
...createEmptyCounts(),
report_id: 'a1',
device_key6: 'a1b2c3d4e5f6',
window_start: '2026-04-03T10:00:00.000Z',
window_end: '2026-04-03T10:05:00.000Z',
received_at: '2026-04-03T10:05:03.000Z',
app_version: '2026.0402.1+44',
cf_country: 'SI',
cf_region: 'Ljubljana',
cf_city: 'Ljubljana',
cf_latitude: 46.0569,
cf_longitude: 14.5058,
cf_colo: 'LJU',
pt_04: 5,
},
];
const env = {
DB: {
prepare() {
return {
bind() {
return {
async all() {
return { results: rows };
},
};
},
};
},
},
} as any;
const summary = await loadDashboardSummary(
env,
'24h',
new Date('2026-04-03T12:00:00.000Z'),
);
expect(summary.reportCount).toBe(1);
expect(summary.packetTypeTotals[0]?.key).toBe('pt_04');
expect(summary.packetTypeTotals[0]?.total).toBe(5);
});
});

20
worker/tsconfig.json Normal file
View File

@@ -0,0 +1,20 @@
{
"extends": "astro/tsconfigs/strict",
"include": [
".astro/types.d.ts",
"**/*"
],
"exclude": [
"dist",
"node_modules"
],
"compilerOptions": {
"baseUrl": ".",
"jsx": "react-jsx",
"jsxImportSource": "react",
"paths": {
"@/*": ["./src/*"]
},
"types": ["@cloudflare/workers-types", "bun-types"]
}
}

128
worker/worker/index.ts Normal file
View File

@@ -0,0 +1,128 @@
import {
COUNT_KEYS,
REPORT_INSERT_SQL,
extractCfGeo,
jsonHeaders,
loadDashboardSummary,
purgeOldReports,
validateIngestPayload,
type Env,
type IngestPayload,
} from "./stats";
const ROUTES = {
"/api/ingest": handleIngest,
"/api/dashboard": handleDashboard,
} as const;
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const pathname = new URL(request.url).pathname;
const route = Object.entries(ROUTES).find(([prefix]) =>
pathname.startsWith(prefix),
);
if (!route) {
return new Response("Not Found", { status: 404 });
}
try {
return await route[1](request, env);
} catch (error) {
console.error(`Worker route failed for ${pathname}:`, error);
return Response.json(
{ error: "Internal Server Error" },
{
headers: jsonHeaders,
status: 500,
},
);
}
},
};
async function handleIngest(request: Request, env: Env): Promise<Response> {
if (request.method !== "POST") {
return Response.json(
{ error: "Method not allowed" },
{
headers: jsonHeaders,
status: 405,
},
);
}
let payload: IngestPayload;
try {
payload = validateIngestPayload(await request.json());
} catch (error) {
return Response.json(
{ error: error instanceof Error ? error.message : "Invalid payload" },
{
headers: jsonHeaders,
status: 400,
},
);
}
const geo = extractCfGeo(request);
const values = [
payload.reportId,
payload.deviceKey6,
payload.windowStart,
payload.windowEnd,
new Date().toISOString(),
payload.appVersion,
geo.country,
geo.region,
geo.city,
geo.latitude,
geo.longitude,
geo.colo,
...COUNT_KEYS.map((key) => payload.counts[key]),
];
const result = await env.DB.prepare(REPORT_INSERT_SQL).bind(...values).run();
const changes = Number((result.meta as { changes?: number }).changes ?? 0);
// Purge reports older than 7 days (best-effort, don't block response)
void purgeOldReports(env).catch(() => {});
return Response.json(
{
ok: true,
duplicate: changes === 0,
},
{
headers: jsonHeaders,
},
);
}
async function handleDashboard(request: Request, env: Env): Promise<Response> {
if (request.method !== "GET") {
return Response.json(
{ error: "Method not allowed" },
{
headers: jsonHeaders,
status: 405,
},
);
}
const url = new URL(request.url);
const windowParam = url.searchParams.get("window");
const summary = await loadDashboardSummary(env, windowParam);
const cacheTtl = windowParam === "7d" ? 86400 : 60;
return Response.json(
{
generatedAt: new Date().toISOString(),
...summary,
},
{
headers: {
...jsonHeaders,
"cache-control": `public, max-age=${cacheTtl}, s-maxage=${cacheTtl}`,
"cdn-cache-control": `max-age=${cacheTtl}`,
},
},
);
}

516
worker/worker/stats.ts Normal file
View File

@@ -0,0 +1,516 @@
export const PACKET_TYPE_KEYS = [
"pt_00",
"pt_01",
"pt_02",
"pt_03",
"pt_04",
"pt_05",
"pt_06",
"pt_07",
"pt_08",
"pt_09",
"pt_0a",
"pt_0b",
"pt_0c",
"pt_0d",
"pt_0e",
"pt_0f",
] as const;
export const PATH_MODE_KEYS = [
"path_mode_1b",
"path_mode_2b",
"path_mode_3b",
"path_mode_none",
"path_mode_unknown",
] as const;
export const COUNT_KEYS = [
...PACKET_TYPE_KEYS,
"decode_fail",
...PATH_MODE_KEYS,
] as const;
const REPORT_COLUMNS = [
"report_id",
"device_key6",
"window_start",
"window_end",
"received_at",
"app_version",
"cf_country",
"cf_region",
"cf_city",
"cf_latitude",
"cf_longitude",
"cf_colo",
...COUNT_KEYS,
] as const;
const PACKET_TYPE_LABELS: Record<(typeof PACKET_TYPE_KEYS)[number], string> = {
pt_00: "Request",
pt_01: "Response",
pt_02: "Text message",
pt_03: "Ack",
pt_04: "Advertisement",
pt_05: "Group text",
pt_06: "Group datagram",
pt_07: "Anonymous request",
pt_08: "Returned path",
pt_09: "Trace path",
pt_0a: "Multipart packet",
pt_0b: "Control packet",
pt_0c: "Reserved 0x0C",
pt_0d: "Reserved 0x0D",
pt_0e: "Reserved 0x0E",
pt_0f: "Custom packet",
};
const PATH_MODE_LABELS: Record<(typeof PATH_MODE_KEYS)[number], string> = {
path_mode_1b: "1-byte path hash",
path_mode_2b: "2-byte path hash",
path_mode_3b: "3-byte path hash",
path_mode_none: "No path bytes",
path_mode_unknown: "Unknown path mode",
};
const WINDOW_OPTIONS = {
"24h": {
label: "Last 24 hours",
durationMs: 24 * 60 * 60 * 1000,
bucket: "hour",
},
"7d": {
label: "Last 7 days",
durationMs: 7 * 24 * 60 * 60 * 1000,
bucket: "day",
},
} as const;
const RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
export const jsonHeaders = {
"content-type": "application/json; charset=utf-8",
} as const;
type CountKey = (typeof COUNT_KEYS)[number];
type PacketTypeKey = (typeof PACKET_TYPE_KEYS)[number];
type PathModeKey = (typeof PATH_MODE_KEYS)[number];
type WindowKey = keyof typeof WINDOW_OPTIONS;
export type Counts = Record<CountKey, number>;
export interface Env {
DB: D1Database;
}
export interface IngestPayload {
reportId: string;
deviceKey6: string;
windowStart: string;
windowEnd: string;
appVersion: string;
counts: Counts;
}
export interface CfGeo {
country: string | null;
region: string | null;
city: string | null;
latitude: number | null;
longitude: number | null;
colo: string | null;
}
export interface ReportRow extends Counts {
report_id: string;
device_key6: string;
window_start: string;
window_end: string;
received_at: string;
app_version: string | null;
cf_country: string | null;
cf_region: string | null;
cf_city: string | null;
cf_latitude: number | null;
cf_longitude: number | null;
cf_colo: string | null;
}
export interface WindowFilter {
windowKey: WindowKey;
label: string;
sinceIso: string;
bucket: "hour" | "day";
}
export interface ChartPoint {
label: string;
totalPackets: number;
reports: number;
}
export interface LocationPoint {
key6: string;
city: string;
country: string;
latitude: number;
longitude: number;
}
export interface ReporterSummary {
key6: string;
lastSeen: string;
packetTotal: number;
country: string;
city: string;
latitude: number | null;
longitude: number | null;
}
export interface AppVersionEntry {
version: string;
reporters: number;
packets: number;
}
export interface TrafficComposition {
human: number;
overhead: number;
acks: number;
}
export interface MultiHopRatio {
direct: number;
multiHop: number;
}
export interface CompositionPoint {
label: string;
human: number;
overhead: number;
}
export interface DashboardSummary {
filter: WindowFilter;
reportCount: number;
uniqueDevices: number;
decodedPackets: number;
decodeFailures: number;
packetTypeTotals: Array<{ key: PacketTypeKey; label: string; total: number }>;
pathModeTotals: Array<{ key: PathModeKey; label: string; total: number }>;
recentReporters: ReporterSummary[];
chartPoints: ChartPoint[];
locationPoints: LocationPoint[];
appVersions: AppVersionEntry[];
trafficComposition: TrafficComposition;
multiHopRatio: MultiHopRatio;
compositionOverTime: CompositionPoint[];
}
export const REPORT_INSERT_SQL = `
INSERT OR IGNORE INTO reports (
${REPORT_COLUMNS.join(", ")}
) VALUES (
${REPORT_COLUMNS.map(() => "?").join(", ")}
)`.trim();
export function createEmptyCounts(): Counts {
return Object.fromEntries(
COUNT_KEYS.map((key) => [key, 0]),
) as Counts;
}
export function validateIngestPayload(payload: unknown): IngestPayload {
if (typeof payload !== "object" || payload === null) {
throw new Error("Body must be a JSON object.");
}
const record = payload as Record<string, unknown>;
const reportId = asTrimmedString(record.reportId, "reportId");
const deviceKey6 = asTrimmedString(record.deviceKey6, "deviceKey6");
if (!/^[0-9a-f]{12}$/.test(deviceKey6)) {
throw new Error("deviceKey6 must be 12 lowercase hex characters.");
}
const windowStart = asIsoString(record.windowStart, "windowStart");
const windowEnd = asIsoString(record.windowEnd, "windowEnd");
if (Date.parse(windowEnd) < Date.parse(windowStart)) {
throw new Error("windowEnd must not be earlier than windowStart.");
}
const appVersion = asTrimmedString(record.appVersion, "appVersion");
const counts = validateCounts(record.counts);
return {
reportId,
deviceKey6,
windowStart,
windowEnd,
appVersion,
counts,
};
}
export function extractCfGeo(request: Request): CfGeo {
const cf = (request as Request & { cf?: Record<string, unknown> }).cf;
return {
country: asNullableString(cf?.country),
region: asNullableString(cf?.region),
city: asNullableString(cf?.city),
latitude: asNullableNumber(cf?.latitude),
longitude: asNullableNumber(cf?.longitude),
colo: asNullableString(cf?.colo),
};
}
export function buildWindowFilter(
requestedWindow: string | null,
now: Date = new Date(),
): WindowFilter {
const windowKey =
requestedWindow === "24h" ||
requestedWindow === "7d"
? requestedWindow
: "24h";
const option = WINDOW_OPTIONS[windowKey];
return {
windowKey,
label: option.label,
sinceIso: new Date(now.getTime() - option.durationMs).toISOString(),
bucket: option.bucket,
};
}
export async function purgeOldReports(
env: Env,
now: Date = new Date(),
): Promise<number> {
const cutoff = new Date(now.getTime() - RETENTION_MS).toISOString();
const result = await env.DB.prepare(
"DELETE FROM reports WHERE window_end < ?",
)
.bind(cutoff)
.run();
return Number((result.meta as { changes?: number }).changes ?? 0);
}
export async function loadDashboardSummary(
env: Env,
requestedWindow: string | null,
now: Date = new Date(),
): Promise<DashboardSummary> {
const filter = buildWindowFilter(requestedWindow, now);
const query = await env.DB.prepare(
"SELECT * FROM reports WHERE window_end >= ? ORDER BY window_end DESC",
)
.bind(filter.sinceIso)
.all<ReportRow>();
const rows = (query.results ?? []) as ReportRow[];
return summarizeRows(rows, filter);
}
export function summarizeRows(
rows: ReportRow[],
filter: WindowFilter,
): DashboardSummary {
const packetTypeTotals = PACKET_TYPE_KEYS.map((key) => ({
key,
label: PACKET_TYPE_LABELS[key],
total: sumRows(rows, key),
})).sort((left, right) => right.total - left.total);
const pathModeTotals = PATH_MODE_KEYS.map((key) => ({
key,
label: PATH_MODE_LABELS[key],
total: sumRows(rows, key),
})).sort((left, right) => right.total - left.total);
const decodedPackets = PACKET_TYPE_KEYS.reduce(
(total, key) => total + sumRows(rows, key),
0,
);
const decodeFailures = sumRows(rows, "decode_fail");
const reporterMap = new Map<string, ReporterSummary>();
const chartBuckets = new Map<string, ChartPoint>();
// per-version and per-colo accumulators
const versionMap = new Map<string, { reporters: Set<string>; packets: number }>();
const compositionBuckets = new Map<string, { human: number; overhead: number }>();
for (const row of rows) {
const packetTotal =
decodeFailuresForRow(row) +
PACKET_TYPE_KEYS.reduce((total, key) => total + row[key], 0);
const existingReporter = reporterMap.get(row.device_key6);
if (!existingReporter) {
reporterMap.set(row.device_key6, {
key6: row.device_key6,
lastSeen: row.window_end,
packetTotal,
country: row.cf_country ?? "Unknown",
city: row.cf_city ?? row.cf_region ?? "Unknown",
latitude: row.cf_latitude,
longitude: row.cf_longitude,
});
} else {
existingReporter.packetTotal += packetTotal;
if (row.window_end > existingReporter.lastSeen) {
existingReporter.lastSeen = row.window_end;
existingReporter.country = row.cf_country ?? existingReporter.country;
existingReporter.city =
row.cf_city ?? row.cf_region ?? existingReporter.city;
existingReporter.latitude = row.cf_latitude;
existingReporter.longitude = row.cf_longitude;
}
}
const bucketKey = formatBucket(row.window_end, filter.bucket);
const existingBucket = chartBuckets.get(bucketKey);
if (existingBucket) {
existingBucket.totalPackets += packetTotal;
existingBucket.reports += 1;
} else {
chartBuckets.set(bucketKey, {
label: bucketKey,
totalPackets: packetTotal,
reports: 1,
});
}
// app version
const ver = row.app_version ?? "unknown";
const verEntry = versionMap.get(ver);
if (verEntry) {
verEntry.reporters.add(row.device_key6);
verEntry.packets += packetTotal;
} else {
versionMap.set(ver, { reporters: new Set([row.device_key6]), packets: packetTotal });
}
// composition over time (human = text + group_text, overhead = rest)
const humanPackets = row.pt_02 + row.pt_05;
const overheadPackets = packetTotal - humanPackets;
const compBucket = compositionBuckets.get(bucketKey);
if (compBucket) {
compBucket.human += humanPackets;
compBucket.overhead += overheadPackets;
} else {
compositionBuckets.set(bucketKey, { human: humanPackets, overhead: overheadPackets });
}
}
const recentReporters = [...reporterMap.values()]
.sort((left, right) => right.lastSeen.localeCompare(left.lastSeen))
.slice(0, 12);
const locationPoints = recentReporters
.filter(
(
reporter,
): reporter is ReporterSummary & { latitude: number; longitude: number } =>
reporter.latitude !== null && reporter.longitude !== null,
)
.map((reporter) => ({
key6: reporter.key6,
city: reporter.city,
country: reporter.country,
latitude: reporter.latitude,
longitude: reporter.longitude,
}));
// human = text messages (pt_02 + pt_05), acks = pt_03, overhead = everything else
const humanTotal = sumRows(rows, "pt_02") + sumRows(rows, "pt_05");
const acksTotal = sumRows(rows, "pt_03");
const overheadTotal = decodedPackets - humanTotal - acksTotal;
// multi-hop: direct = path_mode_none, multiHop = 1b+2b+3b
const directTotal = sumRows(rows, "path_mode_none");
const multiHopTotal = sumRows(rows, "path_mode_1b") + sumRows(rows, "path_mode_2b") + sumRows(rows, "path_mode_3b");
return {
filter,
reportCount: rows.length,
uniqueDevices: reporterMap.size,
decodedPackets,
decodeFailures,
packetTypeTotals,
pathModeTotals,
recentReporters,
chartPoints: [...chartBuckets.values()].sort((left, right) =>
left.label.localeCompare(right.label),
),
locationPoints,
appVersions: [...versionMap.entries()]
.map(([version, entry]) => ({ version, reporters: entry.reporters.size, packets: entry.packets }))
.sort((left, right) => right.packets - left.packets),
trafficComposition: { human: humanTotal, overhead: overheadTotal, acks: acksTotal },
multiHopRatio: { direct: directTotal, multiHop: multiHopTotal },
compositionOverTime: [...compositionBuckets.entries()]
.map(([label, entry]) => ({ label, ...entry }))
.sort((left, right) => left.label.localeCompare(right.label)),
};
}
function validateCounts(value: unknown): Counts {
if (typeof value !== "object" || value === null) {
throw new Error("counts must be an object.");
}
const record = value as Record<string, unknown>;
const counts = createEmptyCounts();
for (const key of COUNT_KEYS) {
const rawValue = record[key];
if (typeof rawValue !== "number" || !Number.isFinite(rawValue)) {
throw new Error(`counts.${key} must be a number.`);
}
if (rawValue < 0) {
throw new Error(`counts.${key} must be zero or greater.`);
}
counts[key] = Math.trunc(rawValue);
}
return counts;
}
function sumRows(rows: ReportRow[], key: CountKey): number {
return rows.reduce((total, row) => total + (row[key] ?? 0), 0);
}
function decodeFailuresForRow(row: ReportRow): number {
return row.decode_fail ?? 0;
}
function formatBucket(value: string, bucket: "hour" | "day"): string {
const date = new Date(value);
const month = `${date.getUTCMonth() + 1}`.padStart(2, "0");
const day = `${date.getUTCDate()}`.padStart(2, "0");
if (bucket === "day") {
return `${date.getUTCFullYear()}-${month}-${day}`;
}
const hour = `${date.getUTCHours()}`.padStart(2, "0");
return `${date.getUTCFullYear()}-${month}-${day} ${hour}:00`;
}
function asTrimmedString(value: unknown, fieldName: string): string {
if (typeof value !== "string" || value.trim().length === 0) {
throw new Error(`${fieldName} must be a non-empty string.`);
}
return value.trim();
}
function asIsoString(value: unknown, fieldName: string): string {
const stringValue = asTrimmedString(value, fieldName);
if (Number.isNaN(Date.parse(stringValue))) {
throw new Error(`${fieldName} must be a valid ISO-8601 timestamp.`);
}
return new Date(stringValue).toISOString();
}
function asNullableString(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0
? value.trim()
: null;
}
function asNullableNumber(value: unknown): number | null {
if (typeof value === "number" && Number.isFinite(value)) {
return value;
}
if (typeof value === "string" && value.trim().length > 0) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
return null;
}

20
worker/wrangler.toml Normal file
View File

@@ -0,0 +1,20 @@
name = "meshcore-sar-stats"
main = "worker/index.ts"
compatibility_date = "2026-04-03"
compatibility_flags = ["nodejs_compat"]
[observability.logs]
enabled = false
[placement]
mode = "smart"
[assets]
directory = "./dist"
not_found_handling = "404-page"
run_worker_first = ["/api/*"]
[[d1_databases]]
binding = "DB"
database_name = "meshcore_sar_rx_stats"
database_id = "94a3978f-13d9-4dc8-9a3c-2beb102699e5"