mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-12 00:40:28 +00:00
Compare commits
26 Commits
v2026.0302
...
v2026.0305
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f4e3f2e834 | ||
|
|
50a4322f44 | ||
|
|
c83eaa4d98 | ||
|
|
5beac70644 | ||
|
|
99cd378e6a | ||
|
|
6a52e7cfa1 | ||
|
|
af2e969640 | ||
|
|
b12567f613 | ||
|
|
13b91e3cc9 | ||
|
|
40d786eb25 | ||
|
|
b6aa0bcfaa | ||
|
|
f31083694f | ||
|
|
1460bdb51f | ||
|
|
537d98e2d4 | ||
|
|
7ffc46438f | ||
|
|
efab2cd057 | ||
|
|
d6077d1588 | ||
|
|
84a6f81c26 | ||
|
|
c6b3b86c2f | ||
|
|
d1e6aaafbe | ||
|
|
3036ba620c | ||
|
|
9854dd2034 | ||
|
|
e9a08fab4d | ||
|
|
9814c95cee | ||
|
|
85ebf51bcb | ||
|
|
e85e290265 |
45
.github/workflows/build-artifacts.yml
vendored
45
.github/workflows/build-artifacts.yml
vendored
@@ -8,6 +8,8 @@ on:
|
|||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
|
pages: write
|
||||||
|
id-token: write
|
||||||
|
|
||||||
env:
|
env:
|
||||||
FLUTTER_VERSION: "3.35.6"
|
FLUTTER_VERSION: "3.35.6"
|
||||||
@@ -256,6 +258,49 @@ jobs:
|
|||||||
${{ env.IOS_RUNNER_ZIP }}
|
${{ env.IOS_RUNNER_ZIP }}
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
|
|
||||||
|
build-web:
|
||||||
|
name: Build Web App
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Flutter
|
||||||
|
uses: subosito/flutter-action@v2
|
||||||
|
with:
|
||||||
|
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||||
|
channel: stable
|
||||||
|
cache: true
|
||||||
|
|
||||||
|
- name: Enable web
|
||||||
|
run: flutter config --enable-web
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: flutter pub get
|
||||||
|
|
||||||
|
- name: Build web release
|
||||||
|
run: flutter build web --release --base-href /meshcore_sar_app/
|
||||||
|
|
||||||
|
- name: Upload pages artifact
|
||||||
|
uses: actions/upload-pages-artifact@v3
|
||||||
|
with:
|
||||||
|
path: build/web
|
||||||
|
|
||||||
|
deploy-pages:
|
||||||
|
name: Deploy to GitHub Pages
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: build-web
|
||||||
|
if: github.ref == 'refs/heads/main'
|
||||||
|
environment:
|
||||||
|
name: github-pages
|
||||||
|
url: ${{ steps.deployment.outputs.page_url }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Deploy to GitHub Pages
|
||||||
|
id: deployment
|
||||||
|
uses: actions/deploy-pages@v4
|
||||||
|
|
||||||
upload-release-assets:
|
upload-release-assets:
|
||||||
name: Upload Assets To Release
|
name: Upload Assets To Release
|
||||||
if: github.event_name == 'release'
|
if: github.event_name == 'release'
|
||||||
|
|||||||
15
Makefile
15
Makefile
@@ -34,8 +34,9 @@ DAILY_BUILD := $(shell \
|
|||||||
# Version format: YYYY.MMDD.DAILY+BUILD
|
# Version format: YYYY.MMDD.DAILY+BUILD
|
||||||
# DAILY resets each day (for readability), BUILD always increments (for Android)
|
# DAILY resets each day (for readability), BUILD always increments (for Android)
|
||||||
NEW_VERSION := $(YEAR).$(MMDD).$(DAILY_BUILD)+$(NEW_BUILD_NUMBER)
|
NEW_VERSION := $(YEAR).$(MMDD).$(DAILY_BUILD)+$(NEW_BUILD_NUMBER)
|
||||||
|
NEW_BUILD_NAME := $(shell echo $(NEW_VERSION) | cut -d'+' -f1)
|
||||||
|
|
||||||
.PHONY: help version bump build release release-android release-ios clean deps analyze test icon bundle bundle-no-bump
|
.PHONY: help version bump make-bump sync-ios-version build release release-android release-ios clean deps analyze test icon bundle bundle-no-bump
|
||||||
|
|
||||||
help: ## Show this help
|
help: ## Show this help
|
||||||
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-15s\033[0m %s\n", $$1, $$2}'
|
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-15s\033[0m %s\n", $$1, $$2}'
|
||||||
@@ -49,8 +50,19 @@ version: ## Show current and next version
|
|||||||
bump: ## Bump version in pubspec.yaml
|
bump: ## Bump version in pubspec.yaml
|
||||||
@echo "Bumping version to $(NEW_VERSION)..."
|
@echo "Bumping version to $(NEW_VERSION)..."
|
||||||
@sed -i '' 's/^version: .*/version: $(NEW_VERSION)/' $(PUBSPEC)
|
@sed -i '' 's/^version: .*/version: $(NEW_VERSION)/' $(PUBSPEC)
|
||||||
|
@$(MAKE) sync-ios-version BUILD_NAME=$(NEW_BUILD_NAME) BUILD_NUMBER=$(NEW_BUILD_NUMBER)
|
||||||
@echo "Version bumped to $(NEW_VERSION)"
|
@echo "Version bumped to $(NEW_VERSION)"
|
||||||
|
|
||||||
|
make-bump: bump ## Alias for bump
|
||||||
|
|
||||||
|
sync-ios-version: ## Sync iOS FLUTTER_BUILD_NAME/NUMBER from pubspec or provided BUILD_NAME/BUILD_NUMBER
|
||||||
|
@$(eval IOS_VERSION := $(shell grep '^version:' $(PUBSPEC) | sed 's/version: //'))
|
||||||
|
@$(eval IOS_BUILD_NAME := $(if $(BUILD_NAME),$(BUILD_NAME),$(shell echo $(IOS_VERSION) | cut -d'+' -f1)))
|
||||||
|
@$(eval IOS_BUILD_NUMBER := $(if $(BUILD_NUMBER),$(BUILD_NUMBER),$(shell echo $(IOS_VERSION) | cut -d'+' -f2)))
|
||||||
|
@echo "Syncing iOS version to $(IOS_BUILD_NAME)+$(IOS_BUILD_NUMBER)..."
|
||||||
|
@flutter build ios --config-only --build-name "$(IOS_BUILD_NAME)" --build-number "$(IOS_BUILD_NUMBER)" > /dev/null
|
||||||
|
@echo "iOS version synced"
|
||||||
|
|
||||||
deps: ## Install dependencies
|
deps: ## Install dependencies
|
||||||
flutter pub get
|
flutter pub get
|
||||||
|
|
||||||
@@ -115,6 +127,7 @@ release-android: build ## Build APK and create GitHub release (Android only)
|
|||||||
|
|
||||||
release-ios: ## Build iOS and upload to TestFlight
|
release-ios: ## Build iOS and upload to TestFlight
|
||||||
@echo "Building iOS and uploading to TestFlight..."
|
@echo "Building iOS and uploading to TestFlight..."
|
||||||
|
@$(MAKE) sync-ios-version
|
||||||
cd ios && fastlane release
|
cd ios && fastlane release
|
||||||
@echo "iOS release uploaded!"
|
@echo "iOS release uploaded!"
|
||||||
|
|
||||||
|
|||||||
18
README.md
18
README.md
@@ -15,13 +15,19 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
MeshCore SAR helps teams coordinate in low-connectivity or no-connectivity environments with messaging, voice, images, maps, and live location context in one app.
|
MeshCore SAR helps teams coordinate in low-connectivity or no-connectivity environments with messaging, voice, images, maps, and live location context in one app.
|
||||||
|
It uses the MeshCore protocol over LoRa for long-range, infrastructure-free communication.
|
||||||
|
`iOS TestFlight:` https://testflight.apple.com/join/HhzerdHp
|
||||||
|
|
||||||
## Highlights
|
## Highlights
|
||||||
|
|
||||||
- Fast mesh messaging for direct and group coordination
|
- Rapid mesh chat for both 1:1 and group coordination
|
||||||
- On-demand voice and image transfer optimized for constrained links
|
- On-demand voice (Codec2) and image (AVIF) transfer tuned for low-bandwidth links
|
||||||
- Offline-first mapping with tactical overlays and SAR markers
|
- Offline-first mapping with tactical overlays and SAR incident markers
|
||||||
- Live team tracking, trails, and shareable map drawings
|
- Live team location, movement trails, and shareable tactical drawings
|
||||||
|
|
||||||
|
## Demo Video
|
||||||
|
|
||||||
|
[<img width="1280" height="720" alt="MeshCore SAR demo preview" src="https://github.com/user-attachments/assets/13ccacee-7306-4976-a408-f31f3336828a" />](https://youtu.be/rLsKeLJBpFg)
|
||||||
|
|
||||||
## Screenshots
|
## Screenshots
|
||||||
|
|
||||||
@@ -38,8 +44,8 @@ MeshCore SAR helps teams coordinate in low-connectivity or no-connectivity envir
|
|||||||
| Area | What you get |
|
| Area | What you get |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Messaging | Direct and group chat over mesh, with contact/room awareness from live telemetry |
|
| Messaging | Direct and group chat over mesh, with contact/room awareness from live telemetry |
|
||||||
| Voice | Push-to-talk voice clips, fetched on demand when play is pressed, auto-play on completion |
|
| Voice | Push-to-talk voice clips (Codec2), fetched on demand when play is pressed, auto-play on completion |
|
||||||
| Images | Camera/gallery image sending, auto-compression, tap-to-load receiving, full-screen viewer |
|
| Images | Camera/gallery image sending (AVIF), auto-compression, tap-to-load receiving, full-screen viewer |
|
||||||
| Maps | Street/topo/satellite/terrain layers, offline tile downloads, optional MBTiles import |
|
| Maps | Street/topo/satellite/terrain layers, offline tile downloads, optional MBTiles import |
|
||||||
| SAR Operations | Team markers with freshness indicators, SAR markers for incidents and staging points |
|
| SAR Operations | Team markers with freshness indicators, SAR markers for incidents and staging points |
|
||||||
| Tracking | Continuous GPS updates, personal trails, distance/duration trail stats |
|
| Tracking | Continuous GPS updates, personal trails, distance/duration trail stats |
|
||||||
|
|||||||
@@ -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 = 68;
|
CURRENT_PROJECT_VERSION = 74;
|
||||||
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 = 68;
|
CURRENT_PROJECT_VERSION = 74;
|
||||||
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 = 68;
|
CURRENT_PROJECT_VERSION = 74;
|
||||||
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 = 68;
|
CURRENT_PROJECT_VERSION = 74;
|
||||||
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 = 68;
|
CURRENT_PROJECT_VERSION = 74;
|
||||||
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 = 68;
|
CURRENT_PROJECT_VERSION = 74;
|
||||||
DEVELOPMENT_TEAM = JND55328G8;
|
DEVELOPMENT_TEAM = JND55328G8;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
|
|||||||
@@ -43,7 +43,7 @@
|
|||||||
<key>CFBundleSignature</key>
|
<key>CFBundleSignature</key>
|
||||||
<string>????</string>
|
<string>????</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>68</string>
|
<string>74</string>
|
||||||
<key>LSRequiresIPhoneOS</key>
|
<key>LSRequiresIPhoneOS</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>NSBluetoothAlwaysUsageDescription</key>
|
<key>NSBluetoothAlwaysUsageDescription</key>
|
||||||
@@ -68,8 +68,12 @@
|
|||||||
<string>MeshCore SAR needs access to the compass to show your heading direction on the map</string>
|
<string>MeshCore SAR needs access to the compass to show your heading direction on the map</string>
|
||||||
<key>NSMicrophoneUsageDescription</key>
|
<key>NSMicrophoneUsageDescription</key>
|
||||||
<string>MeshCore SAR needs microphone access to send voice messages over the mesh radio network during SAR operations</string>
|
<string>MeshCore SAR needs microphone access to send voice messages over the mesh radio network during SAR operations</string>
|
||||||
|
<key>NSCameraUsageDescription</key>
|
||||||
|
<string>MeshCore SAR needs camera access to take photos and attach them to SAR messages</string>
|
||||||
<key>NSPhotoLibraryUsageDescription</key>
|
<key>NSPhotoLibraryUsageDescription</key>
|
||||||
<string>MeshCore SAR may need access to your photo library to attach images to messages or save map screenshots for documentation during SAR operations</string>
|
<string>MeshCore SAR may need access to your photo library to attach images to messages or save map screenshots for documentation during SAR operations</string>
|
||||||
|
<key>NSPhotoLibraryAddUsageDescription</key>
|
||||||
|
<string>MeshCore SAR needs permission to save exported screenshots and SAR documentation images to your photo library</string>
|
||||||
<key>UIApplicationSceneManifest</key>
|
<key>UIApplicationSceneManifest</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>UIApplicationSupportsMultipleScenes</key>
|
<key>UIApplicationSupportsMultipleScenes</key>
|
||||||
|
|||||||
@@ -5,22 +5,22 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000238">
|
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000195">
|
||||||
|
|
||||||
</testcase>
|
</testcase>
|
||||||
|
|
||||||
|
|
||||||
<testcase classname="fastlane.lanes" name="1: increment_build_number" time="0.34961">
|
<testcase classname="fastlane.lanes" name="1: increment_build_number" time="0.405396">
|
||||||
|
|
||||||
</testcase>
|
</testcase>
|
||||||
|
|
||||||
|
|
||||||
<testcase classname="fastlane.lanes" name="2: build_app" time="83.968525">
|
<testcase classname="fastlane.lanes" name="2: build_app" time="101.387002">
|
||||||
|
|
||||||
</testcase>
|
</testcase>
|
||||||
|
|
||||||
|
|
||||||
<testcase classname="fastlane.lanes" name="3: upload_to_app_store" time="186.029813">
|
<testcase classname="fastlane.lanes" name="3: upload_to_app_store" time="209.873722">
|
||||||
|
|
||||||
</testcase>
|
</testcase>
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import 'app_localizations_fr.dart';
|
|||||||
import 'app_localizations_hr.dart';
|
import 'app_localizations_hr.dart';
|
||||||
import 'app_localizations_it.dart';
|
import 'app_localizations_it.dart';
|
||||||
import 'app_localizations_sl.dart';
|
import 'app_localizations_sl.dart';
|
||||||
|
import 'app_localizations_zh.dart';
|
||||||
|
|
||||||
// ignore_for_file: type=lint
|
// ignore_for_file: type=lint
|
||||||
|
|
||||||
@@ -108,6 +109,7 @@ abstract class AppLocalizations {
|
|||||||
Locale('hr'),
|
Locale('hr'),
|
||||||
Locale('it'),
|
Locale('it'),
|
||||||
Locale('sl'),
|
Locale('sl'),
|
||||||
|
Locale('zh'),
|
||||||
];
|
];
|
||||||
|
|
||||||
/// The application title
|
/// The application title
|
||||||
@@ -4225,6 +4227,7 @@ class _AppLocalizationsDelegate
|
|||||||
'hr',
|
'hr',
|
||||||
'it',
|
'it',
|
||||||
'sl',
|
'sl',
|
||||||
|
'zh',
|
||||||
].contains(locale.languageCode);
|
].contains(locale.languageCode);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -4250,6 +4253,8 @@ AppLocalizations lookupAppLocalizations(Locale locale) {
|
|||||||
return AppLocalizationsIt();
|
return AppLocalizationsIt();
|
||||||
case 'sl':
|
case 'sl':
|
||||||
return AppLocalizationsSl();
|
return AppLocalizationsSl();
|
||||||
|
case 'zh':
|
||||||
|
return AppLocalizationsZh();
|
||||||
}
|
}
|
||||||
|
|
||||||
throw FlutterError(
|
throw FlutterError(
|
||||||
|
|||||||
2271
lib/l10n/app_localizations_zh.dart
Normal file
2271
lib/l10n/app_localizations_zh.dart
Normal file
File diff suppressed because it is too large
Load Diff
3848
lib/l10n/app_zh.arb
Normal file
3848
lib/l10n/app_zh.arb
Normal file
File diff suppressed because it is too large
Load Diff
@@ -17,8 +17,8 @@ enum ConnectionMode {
|
|||||||
/// Act as SSE server - share BLE device with multiple clients
|
/// Act as SSE server - share BLE device with multiple clients
|
||||||
sseServer,
|
sseServer,
|
||||||
|
|
||||||
/// Connect to remote SSE server - no direct BLE connection
|
/// Direct TCP/WiFi connection to MeshCore device (port 5000)
|
||||||
sseClient,
|
tcp,
|
||||||
}
|
}
|
||||||
|
|
||||||
extension ConnectionModeExtension on ConnectionMode {
|
extension ConnectionModeExtension on ConnectionMode {
|
||||||
@@ -28,8 +28,8 @@ extension ConnectionModeExtension on ConnectionMode {
|
|||||||
return 'Direct (BLE)';
|
return 'Direct (BLE)';
|
||||||
case ConnectionMode.sseServer:
|
case ConnectionMode.sseServer:
|
||||||
return 'Share Device (Server)';
|
return 'Share Device (Server)';
|
||||||
case ConnectionMode.sseClient:
|
case ConnectionMode.tcp:
|
||||||
return 'Connect to Server';
|
return 'Direct (WiFi)';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,8 +39,8 @@ extension ConnectionModeExtension on ConnectionMode {
|
|||||||
return 'Direct BLE connection to MeshCore device';
|
return 'Direct BLE connection to MeshCore device';
|
||||||
case ConnectionMode.sseServer:
|
case ConnectionMode.sseServer:
|
||||||
return 'Share BLE device with multiple clients over network';
|
return 'Share BLE device with multiple clients over network';
|
||||||
case ConnectionMode.sseClient:
|
case ConnectionMode.tcp:
|
||||||
return 'Connect to remote server without BLE';
|
return 'Direct WiFi/TCP connection to MeshCore device';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,14 +10,17 @@ import 'voice_provider.dart';
|
|||||||
import 'image_provider.dart' as ip;
|
import 'image_provider.dart' as ip;
|
||||||
import '../services/tile_cache_service.dart';
|
import '../services/tile_cache_service.dart';
|
||||||
import '../services/location_tracking_service.dart';
|
import '../services/location_tracking_service.dart';
|
||||||
|
import '../services/packet_capture_storage_service.dart';
|
||||||
import '../models/contact.dart';
|
import '../models/contact.dart';
|
||||||
import '../models/message.dart';
|
import '../models/message.dart';
|
||||||
|
import '../models/ble_packet_log.dart';
|
||||||
import '../utils/drawing_message_parser.dart';
|
import '../utils/drawing_message_parser.dart';
|
||||||
import '../utils/voice_message_parser.dart';
|
import '../utils/voice_message_parser.dart';
|
||||||
import '../utils/image_message_parser.dart';
|
import '../utils/image_message_parser.dart';
|
||||||
|
|
||||||
/// 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;
|
||||||
final ConnectionProvider connectionProvider;
|
final ConnectionProvider connectionProvider;
|
||||||
final ContactsProvider contactsProvider;
|
final ContactsProvider contactsProvider;
|
||||||
final MessagesProvider messagesProvider;
|
final MessagesProvider messagesProvider;
|
||||||
@@ -28,6 +31,8 @@ class AppProvider with ChangeNotifier {
|
|||||||
final TileCacheService tileCacheService;
|
final TileCacheService tileCacheService;
|
||||||
final LocationTrackingService locationTrackingService =
|
final LocationTrackingService locationTrackingService =
|
||||||
LocationTrackingService();
|
LocationTrackingService();
|
||||||
|
final PacketCaptureStorageService packetCaptureStorageService =
|
||||||
|
PacketCaptureStorageService();
|
||||||
|
|
||||||
bool _isInitialized = false;
|
bool _isInitialized = false;
|
||||||
bool get isInitialized => _isInitialized;
|
bool get isInitialized => _isInitialized;
|
||||||
@@ -46,15 +51,17 @@ class AppProvider with ChangeNotifier {
|
|||||||
bool get isVoiceCompressorEnabled => _isVoiceCompressorEnabled;
|
bool get isVoiceCompressorEnabled => _isVoiceCompressorEnabled;
|
||||||
bool _isVoiceLimiterEnabled = true;
|
bool _isVoiceLimiterEnabled = true;
|
||||||
bool get isVoiceLimiterEnabled => _isVoiceLimiterEnabled;
|
bool get isVoiceLimiterEnabled => _isVoiceLimiterEnabled;
|
||||||
|
bool _autoAddDiscoveredContacts = false;
|
||||||
|
bool get autoAddDiscoveredContacts => _autoAddDiscoveredContacts;
|
||||||
|
|
||||||
static const Duration _packetRetryDelay = Duration(milliseconds: 1200);
|
static const Duration _packetRetryDelay = Duration(milliseconds: 1200);
|
||||||
static const int _maxPacketRetryAttempts = 4;
|
static const int _maxPacketRetryAttempts = 4;
|
||||||
final Map<String, String> _voiceSessionSenderKey6 = {};
|
final Map<String, String> _voiceSessionSenderKey6 = {};
|
||||||
final Map<String, String> _imageSessionSenderKey6 = {};
|
|
||||||
final Map<String, Timer> _voiceMissingRetryTimers = {};
|
final Map<String, Timer> _voiceMissingRetryTimers = {};
|
||||||
final Map<String, Timer> _imageMissingRetryTimers = {};
|
|
||||||
final Map<String, int> _voiceMissingRetryAttempts = {};
|
final Map<String, int> _voiceMissingRetryAttempts = {};
|
||||||
final Map<String, int> _imageMissingRetryAttempts = {};
|
Timer? _packetCaptureFlushTimer;
|
||||||
|
String? _lastPersistedPacketSignature;
|
||||||
|
bool _isPersistingPacketCapture = false;
|
||||||
|
|
||||||
AppProvider({
|
AppProvider({
|
||||||
required this.connectionProvider,
|
required this.connectionProvider,
|
||||||
@@ -75,10 +82,70 @@ class AppProvider with ChangeNotifier {
|
|||||||
_loadVoiceBandPassFilterEnabled();
|
_loadVoiceBandPassFilterEnabled();
|
||||||
_loadVoiceCompressorEnabled();
|
_loadVoiceCompressorEnabled();
|
||||||
_loadVoiceLimiterEnabled();
|
_loadVoiceLimiterEnabled();
|
||||||
|
_loadAutoAddDiscoveredContacts();
|
||||||
|
_startPacketCapturePersistence();
|
||||||
_syncDrawingsOnStartup(); // Sync drawings immediately after providers load
|
_syncDrawingsOnStartup(); // Sync drawings immediately after providers load
|
||||||
_isInitialized = true;
|
_isInitialized = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _startPacketCapturePersistence() {
|
||||||
|
_packetCaptureFlushTimer?.cancel();
|
||||||
|
_packetCaptureFlushTimer = Timer.periodic(const Duration(seconds: 2), (_) {
|
||||||
|
unawaited(_flushPacketCaptureLogs());
|
||||||
|
});
|
||||||
|
unawaited(_flushPacketCaptureLogs());
|
||||||
|
}
|
||||||
|
|
||||||
|
String _packetLogSignature(BlePacketLog log) {
|
||||||
|
final prefix = log.rawData.length <= 12
|
||||||
|
? log.rawData
|
||||||
|
: log.rawData.sublist(0, 12);
|
||||||
|
final prefixHex = prefix
|
||||||
|
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||||
|
.join();
|
||||||
|
return '${log.timestamp.microsecondsSinceEpoch}|'
|
||||||
|
'${log.direction.name}|${log.responseCode ?? -1}|'
|
||||||
|
'${log.rawData.length}|$prefixHex';
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _flushPacketCaptureLogs() async {
|
||||||
|
if (_isPersistingPacketCapture) return;
|
||||||
|
_isPersistingPacketCapture = true;
|
||||||
|
try {
|
||||||
|
final logs = connectionProvider.bleService.packetLogs;
|
||||||
|
if (logs.isEmpty) return;
|
||||||
|
|
||||||
|
List<BlePacketLog> toPersist = const [];
|
||||||
|
if (_lastPersistedPacketSignature == null) {
|
||||||
|
toPersist = logs;
|
||||||
|
} else {
|
||||||
|
final lastSig = _lastPersistedPacketSignature!;
|
||||||
|
var lastIndex = -1;
|
||||||
|
for (var i = logs.length - 1; i >= 0; i--) {
|
||||||
|
if (_packetLogSignature(logs[i]) == lastSig) {
|
||||||
|
lastIndex = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (lastIndex == -1) {
|
||||||
|
// In-memory log rotated or cleared; persist current window to avoid gaps.
|
||||||
|
toPersist = logs;
|
||||||
|
} else if (lastIndex < logs.length - 1) {
|
||||||
|
toPersist = logs.sublist(lastIndex + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toPersist.isNotEmpty) {
|
||||||
|
await packetCaptureStorageService.appendLogs(toPersist);
|
||||||
|
}
|
||||||
|
_lastPersistedPacketSignature = _packetLogSignature(logs.last);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('❌ [AppProvider] Packet capture flush failed: $e');
|
||||||
|
} finally {
|
||||||
|
_isPersistingPacketCapture = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Sync drawings from messages on app startup (before BLE connection)
|
/// Sync drawings from messages on app startup (before BLE connection)
|
||||||
Future<void> _syncDrawingsOnStartup() async {
|
Future<void> _syncDrawingsOnStartup() async {
|
||||||
// Wait for MessagesProvider to finish initializing
|
// Wait for MessagesProvider to finish initializing
|
||||||
@@ -239,6 +306,30 @@ class AppProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Load auto-add discovered contacts setting from shared preferences.
|
||||||
|
Future<void> _loadAutoAddDiscoveredContacts() async {
|
||||||
|
try {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
_autoAddDiscoveredContacts =
|
||||||
|
prefs.getBool('auto_add_discovered_contacts') ?? false;
|
||||||
|
notifyListeners();
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Error loading auto-add discovered contacts setting: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Toggle auto-add discovered contacts on/off.
|
||||||
|
Future<void> toggleAutoAddDiscoveredContacts(bool enabled) async {
|
||||||
|
try {
|
||||||
|
_autoAddDiscoveredContacts = enabled;
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setBool('auto_add_discovered_contacts', enabled);
|
||||||
|
notifyListeners();
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Error saving auto-add discovered contacts setting: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Initialize tile cache service
|
/// Initialize tile cache service
|
||||||
Future<void> _initializeTileCache() async {
|
Future<void> _initializeTileCache() async {
|
||||||
try {
|
try {
|
||||||
@@ -475,6 +566,22 @@ class AppProvider with ChangeNotifier {
|
|||||||
debugPrint(
|
debugPrint(
|
||||||
'⚠️ [AppProvider] Voice fetch requester contact not found',
|
'⚠️ [AppProvider] Voice fetch requester contact not found',
|
||||||
);
|
);
|
||||||
|
messagesProvider.logSystemMessage(
|
||||||
|
text:
|
||||||
|
'Cannot fetch voice: requester contact is unknown. Add/sync contacts first.',
|
||||||
|
level: 'warning',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (requester.outPathLen > _maxDirectPayloadHops) {
|
||||||
|
debugPrint(
|
||||||
|
'⚠️ [AppProvider] Voice fetch requester too far: ${requester.outPathLen} hops',
|
||||||
|
);
|
||||||
|
messagesProvider.logSystemMessage(
|
||||||
|
text:
|
||||||
|
'Cannot fetch voice for ${requester.advName}: message is too far (${requester.outPathLen} hops, max $_maxDirectPayloadHops).',
|
||||||
|
level: 'warning',
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
unawaited(
|
unawaited(
|
||||||
@@ -575,6 +682,17 @@ class AppProvider with ChangeNotifier {
|
|||||||
senderPrefix,
|
senderPrefix,
|
||||||
);
|
);
|
||||||
if (requester != null) {
|
if (requester != null) {
|
||||||
|
if (requester.outPathLen > _maxDirectPayloadHops) {
|
||||||
|
debugPrint(
|
||||||
|
'⚠️ [AppProvider] Image fetch requester too far: ${requester.outPathLen} hops',
|
||||||
|
);
|
||||||
|
messagesProvider.logSystemMessage(
|
||||||
|
text:
|
||||||
|
'Cannot fetch image for ${requester.advName}: message is too far (${requester.outPathLen} hops, max $_maxDirectPayloadHops).',
|
||||||
|
level: 'warning',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
unawaited(
|
unawaited(
|
||||||
imageProvider.serveSessionTo(
|
imageProvider.serveSessionTo(
|
||||||
sessionId: imageFetchRequest.sessionId,
|
sessionId: imageFetchRequest.sessionId,
|
||||||
@@ -584,6 +702,15 @@ class AppProvider with ChangeNotifier {
|
|||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
} else {
|
||||||
|
debugPrint(
|
||||||
|
'⚠️ [AppProvider] Image fetch requester contact not found',
|
||||||
|
);
|
||||||
|
messagesProvider.logSystemMessage(
|
||||||
|
text:
|
||||||
|
'Cannot fetch image: requester contact is unknown. Add/sync contacts first.',
|
||||||
|
level: 'warning',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -593,9 +720,6 @@ class AppProvider with ChangeNotifier {
|
|||||||
// Image envelope (IE1): announce image availability.
|
// Image envelope (IE1): announce image availability.
|
||||||
final imageEnvelope = ImageEnvelope.tryParse(enrichedMessage.text);
|
final imageEnvelope = ImageEnvelope.tryParse(enrichedMessage.text);
|
||||||
if (imageEnvelope != null) {
|
if (imageEnvelope != null) {
|
||||||
_imageSessionSenderKey6[imageEnvelope.sessionId] = imageEnvelope
|
|
||||||
.senderKey6
|
|
||||||
.toLowerCase();
|
|
||||||
imageProvider.registerEnvelope(imageEnvelope);
|
imageProvider.registerEnvelope(imageEnvelope);
|
||||||
messagesProvider.addMessage(
|
messagesProvider.addMessage(
|
||||||
enrichedMessage,
|
enrichedMessage,
|
||||||
@@ -683,12 +807,11 @@ class AppProvider with ChangeNotifier {
|
|||||||
if (frag == null) return;
|
if (frag == null) return;
|
||||||
debugPrint('📷 [AppProvider] Binary image fragment received: $frag');
|
debugPrint('📷 [AppProvider] Binary image fragment received: $frag');
|
||||||
final session = imageProvider.session(frag.sessionId);
|
final session = imageProvider.session(frag.sessionId);
|
||||||
final justComplete = imageProvider.addFragment(
|
imageProvider.addFragment(
|
||||||
frag,
|
frag,
|
||||||
width: session?.width ?? 0,
|
width: session?.width ?? 0,
|
||||||
height: session?.height ?? 0,
|
height: session?.height ?? 0,
|
||||||
);
|
);
|
||||||
_scheduleImageMissingRetry(frag.sessionId, justComplete: justComplete);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -737,13 +860,22 @@ class AppProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
contactsProvider.addPendingAdvert(
|
if (_autoAddDiscoveredContacts) {
|
||||||
publicKey,
|
debugPrint(' Unknown contact - auto-add enabled, fetching details');
|
||||||
devicePublicKey: connectionProvider.deviceInfo.publicKey,
|
Future.delayed(const Duration(milliseconds: 100), () {
|
||||||
);
|
if (connectionProvider.deviceInfo.isConnected) {
|
||||||
debugPrint(
|
connectionProvider.getContact(publicKey);
|
||||||
' Unknown contact - added to pending adverts list and waiting for details',
|
}
|
||||||
);
|
});
|
||||||
|
} else {
|
||||||
|
contactsProvider.addPendingAdvert(
|
||||||
|
publicKey,
|
||||||
|
devicePublicKey: connectionProvider.deviceInfo.publicKey,
|
||||||
|
);
|
||||||
|
debugPrint(
|
||||||
|
' Unknown contact - added to pending adverts list and waiting for details',
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1094,79 +1226,6 @@ class AppProvider with ChangeNotifier {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void _scheduleImageMissingRetry(
|
|
||||||
String sessionId, {
|
|
||||||
required bool justComplete,
|
|
||||||
}) {
|
|
||||||
if (justComplete || imageProvider.isComplete(sessionId)) {
|
|
||||||
_imageMissingRetryTimers.remove(sessionId)?.cancel();
|
|
||||||
_imageMissingRetryAttempts.remove(sessionId);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_imageMissingRetryAttempts[sessionId] = 0;
|
|
||||||
_imageMissingRetryTimers[sessionId]?.cancel();
|
|
||||||
_imageMissingRetryTimers[sessionId] = Timer(_packetRetryDelay, () {
|
|
||||||
unawaited(_requestMissingImageFragments(sessionId));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _requestMissingImageFragments(String sessionId) async {
|
|
||||||
if (imageProvider.isComplete(sessionId)) {
|
|
||||||
_imageMissingRetryTimers.remove(sessionId)?.cancel();
|
|
||||||
_imageMissingRetryAttempts.remove(sessionId);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final attempt = _imageMissingRetryAttempts[sessionId] ?? 0;
|
|
||||||
if (attempt >= _maxPacketRetryAttempts) {
|
|
||||||
debugPrint(
|
|
||||||
'⚠️ [AppProvider] Image re-request limit reached for $sessionId',
|
|
||||||
);
|
|
||||||
_imageMissingRetryTimers.remove(sessionId)?.cancel();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final senderKey6 = _imageSessionSenderKey6[sessionId];
|
|
||||||
if (senderKey6 == null) return;
|
|
||||||
final sender = _resolveContactByPrefixHex(senderKey6);
|
|
||||||
final deviceKey = connectionProvider.deviceInfo.publicKey;
|
|
||||||
if (sender == null || deviceKey == null || deviceKey.length < 6) return;
|
|
||||||
|
|
||||||
final missing = imageProvider.missingFragmentIndices(sessionId);
|
|
||||||
if (missing.isEmpty) {
|
|
||||||
_imageMissingRetryTimers.remove(sessionId)?.cancel();
|
|
||||||
_imageMissingRetryAttempts.remove(sessionId);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final requesterKey6 = deviceKey
|
|
||||||
.sublist(0, 6)
|
|
||||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
|
||||||
.join('');
|
|
||||||
|
|
||||||
final request = ImageFetchRequest(
|
|
||||||
sessionId: sessionId,
|
|
||||||
want: 'missing',
|
|
||||||
missingIndices: missing,
|
|
||||||
requesterKey6: requesterKey6,
|
|
||||||
timestampSec: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
|
||||||
);
|
|
||||||
|
|
||||||
final sent = await connectionProvider.sendTextMessage(
|
|
||||||
contactPublicKey: sender.publicKey,
|
|
||||||
text: request.encode(),
|
|
||||||
contact: sender,
|
|
||||||
);
|
|
||||||
if (!sent) return;
|
|
||||||
|
|
||||||
_imageMissingRetryAttempts[sessionId] = attempt + 1;
|
|
||||||
_imageMissingRetryTimers[sessionId]?.cancel();
|
|
||||||
_imageMissingRetryTimers[sessionId] = Timer(_packetRetryDelay, () {
|
|
||||||
unawaited(_requestMissingImageFragments(sessionId));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Insert or update a voice placeholder message for binary raw-data packets.
|
/// Insert or update a voice placeholder message for binary raw-data packets.
|
||||||
///
|
///
|
||||||
/// Binary voice packets arrive without a chat message, so we synthesise one
|
/// Binary voice packets arrive without a chat message, so we synthesise one
|
||||||
@@ -1300,15 +1359,9 @@ class AppProvider with ChangeNotifier {
|
|||||||
for (final timer in _voiceMissingRetryTimers.values) {
|
for (final timer in _voiceMissingRetryTimers.values) {
|
||||||
timer.cancel();
|
timer.cancel();
|
||||||
}
|
}
|
||||||
for (final timer in _imageMissingRetryTimers.values) {
|
|
||||||
timer.cancel();
|
|
||||||
}
|
|
||||||
_voiceMissingRetryTimers.clear();
|
_voiceMissingRetryTimers.clear();
|
||||||
_imageMissingRetryTimers.clear();
|
|
||||||
_voiceMissingRetryAttempts.clear();
|
_voiceMissingRetryAttempts.clear();
|
||||||
_imageMissingRetryAttempts.clear();
|
|
||||||
_voiceSessionSenderKey6.clear();
|
_voiceSessionSenderKey6.clear();
|
||||||
_imageSessionSenderKey6.clear();
|
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1328,6 +1381,8 @@ class AppProvider with ChangeNotifier {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_packetCaptureFlushTimer?.cancel();
|
||||||
|
unawaited(_flushPacketCaptureLogs());
|
||||||
// Remove connection state listener
|
// Remove connection state listener
|
||||||
connectionProvider.removeListener(_handleConnectionStateChange);
|
connectionProvider.removeListener(_handleConnectionStateChange);
|
||||||
// Clear location service callbacks
|
// Clear location service callbacks
|
||||||
@@ -1340,9 +1395,6 @@ class AppProvider with ChangeNotifier {
|
|||||||
for (final timer in _voiceMissingRetryTimers.values) {
|
for (final timer in _voiceMissingRetryTimers.values) {
|
||||||
timer.cancel();
|
timer.cancel();
|
||||||
}
|
}
|
||||||
for (final timer in _imageMissingRetryTimers.values) {
|
|
||||||
timer.cancel();
|
|
||||||
}
|
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -36,6 +36,7 @@ class ImageSession {
|
|||||||
class ImageProvider with ChangeNotifier {
|
class ImageProvider with ChangeNotifier {
|
||||||
static const String _storageKey = 'stored_image_sessions_v1';
|
static const String _storageKey = 'stored_image_sessions_v1';
|
||||||
static const Duration _outgoingTtl = Duration(minutes: 15);
|
static const Duration _outgoingTtl = Duration(minutes: 15);
|
||||||
|
static const int maxDirectPayloadHops = 3;
|
||||||
|
|
||||||
/// Incoming sessions keyed by sessionId.
|
/// Incoming sessions keyed by sessionId.
|
||||||
final Map<String, ImageSession> _sessions = {};
|
final Map<String, ImageSession> _sessions = {};
|
||||||
@@ -190,6 +191,12 @@ class ImageProvider with ChangeNotifier {
|
|||||||
debugPrint('⚠️ [ImageProvider] ${requester.advName} has no direct path');
|
debugPrint('⚠️ [ImageProvider] ${requester.advName} has no direct path');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
if (requester.outPathLen > maxDirectPayloadHops) {
|
||||||
|
debugPrint(
|
||||||
|
'⚠️ [ImageProvider] ${requester.advName} is too far: ${requester.outPathLen} hops (max $maxDirectPayloadHops)',
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
for (final fragment in cached.fragments) {
|
for (final fragment in cached.fragments) {
|
||||||
if (requestedIndices != null &&
|
if (requestedIndices != null &&
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ class VoiceSession {
|
|||||||
/// Manages incoming voice packet sessions and coordinates playback.
|
/// Manages incoming voice packet sessions and coordinates playback.
|
||||||
class VoiceProvider with ChangeNotifier {
|
class VoiceProvider with ChangeNotifier {
|
||||||
static const String _voiceSessionsStorageKey = 'stored_voice_sessions_v1';
|
static const String _voiceSessionsStorageKey = 'stored_voice_sessions_v1';
|
||||||
|
static const int maxDirectPayloadHops = 3;
|
||||||
final VoiceCodecService _codec;
|
final VoiceCodecService _codec;
|
||||||
final VoicePlayerService _player;
|
final VoicePlayerService _player;
|
||||||
late final StreamSubscription<void> _playerEventsSub;
|
late final StreamSubscription<void> _playerEventsSub;
|
||||||
@@ -161,6 +162,12 @@ class VoiceProvider with ChangeNotifier {
|
|||||||
);
|
);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
if (requester.outPathLen > maxDirectPayloadHops) {
|
||||||
|
debugPrint(
|
||||||
|
'⚠️ [VoiceProvider] Requester ${requester.advName} is too far: ${requester.outPathLen} hops (max $maxDirectPayloadHops)',
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
for (final packet in cached.packets) {
|
for (final packet in cached.packets) {
|
||||||
if (requestedIndices != null &&
|
if (requestedIndices != null &&
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import 'package:shared_preferences/shared_preferences.dart';
|
|||||||
import 'package:vibration/vibration.dart';
|
import 'package:vibration/vibration.dart';
|
||||||
import '../providers/connection_provider.dart';
|
import '../providers/connection_provider.dart';
|
||||||
import '../providers/app_provider.dart';
|
import '../providers/app_provider.dart';
|
||||||
|
import '../models/device_info.dart' show ConnectionMode;
|
||||||
import '../providers/messages_provider.dart';
|
import '../providers/messages_provider.dart';
|
||||||
import '../providers/contacts_provider.dart';
|
import '../providers/contacts_provider.dart';
|
||||||
import '../theme/app_theme.dart';
|
import '../theme/app_theme.dart';
|
||||||
@@ -298,7 +299,7 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
builder: (context, provider, child) {
|
builder: (context, provider, child) {
|
||||||
final isConnected =
|
final isConnected =
|
||||||
provider.deviceInfo.isConnected ||
|
provider.deviceInfo.isConnected ||
|
||||||
provider.isSseClientConnected;
|
provider.deviceInfo.isConnected;
|
||||||
if (isConnected) {
|
if (isConnected) {
|
||||||
return IconButton(
|
return IconButton(
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
@@ -446,9 +447,9 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
return Consumer<ConnectionProvider>(
|
return Consumer<ConnectionProvider>(
|
||||||
builder: (context, provider, child) {
|
builder: (context, provider, child) {
|
||||||
final deviceInfo = provider.deviceInfo;
|
final deviceInfo = provider.deviceInfo;
|
||||||
final isBleConnected = deviceInfo.isConnected;
|
final isConnected = deviceInfo.isConnected;
|
||||||
final isSseConnected = provider.isSseClientConnected;
|
final isTcpConnected = provider.connectionMode == ConnectionMode.tcp;
|
||||||
final isConnected = isBleConnected || isSseConnected;
|
final isBleConnected = isConnected && !isTcpConnected;
|
||||||
|
|
||||||
if (!isConnected) {
|
if (!isConnected) {
|
||||||
// Disconnected state: show connect button
|
// Disconnected state: show connect button
|
||||||
@@ -536,10 +537,10 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
Icon(
|
||||||
isSseConnected
|
isTcpConnected
|
||||||
? Icons.wifi
|
? Icons.wifi
|
||||||
: Icons.bluetooth_connected,
|
: Icons.bluetooth_connected,
|
||||||
color: isSseConnected
|
color: isTcpConnected
|
||||||
? Colors.green
|
? Colors.green
|
||||||
: (deviceInfo.signalRssi != null
|
: (deviceInfo.signalRssi != null
|
||||||
? BatteryDisplayHelper.getSignalColor(
|
? BatteryDisplayHelper.getSignalColor(
|
||||||
@@ -561,10 +562,10 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
if (isSseConnected && !isBleConnected) ...[
|
if (isTcpConnected) ...[
|
||||||
const SizedBox(width: 3),
|
const SizedBox(width: 3),
|
||||||
Text(
|
Text(
|
||||||
'SSE',
|
'WiFi',
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
color: Colors.green,
|
color: Colors.green,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:io' show Platform;
|
import 'dart:io' show Platform;
|
||||||
|
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||||
import 'dart:math' as math;
|
import 'dart:math' as math;
|
||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
@@ -67,7 +68,7 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
static const double _silenceRmsThreshold = 500.0;
|
static const double _silenceRmsThreshold = 500.0;
|
||||||
static const double _silencePeakThreshold = 1400.0;
|
static const double _silencePeakThreshold = 1400.0;
|
||||||
static const int _maxInteriorSilentChunks = 2;
|
static const int _maxInteriorSilentChunks = 2;
|
||||||
bool get _voiceSupported => Platform.isIOS || Platform.isAndroid;
|
bool get _voiceSupported => !kIsWeb && (Platform.isIOS || Platform.isAndroid);
|
||||||
StreamSubscription<Int16List>? _voiceStreamSub;
|
StreamSubscription<Int16List>? _voiceStreamSub;
|
||||||
String? _currentVoiceSessionId;
|
String? _currentVoiceSessionId;
|
||||||
final List<Int16List> _recordedChunks = [];
|
final List<Int16List> _recordedChunks = [];
|
||||||
@@ -433,6 +434,9 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
ImageSource source = ImageSource.gallery,
|
ImageSource source = ImageSource.gallery,
|
||||||
}) async {
|
}) async {
|
||||||
if (_isSendingImage) return;
|
if (_isSendingImage) return;
|
||||||
|
final shouldContinue = await _confirmPublicChannelMediaSend('image');
|
||||||
|
if (!shouldContinue) return;
|
||||||
|
if (!mounted) return;
|
||||||
final connectionProvider = context.read<ConnectionProvider>();
|
final connectionProvider = context.read<ConnectionProvider>();
|
||||||
if (!connectionProvider.deviceInfo.isConnected) {
|
if (!connectionProvider.deviceInfo.isConnected) {
|
||||||
ToastLogger.error(context, 'Not connected to device');
|
ToastLogger.error(context, 'Not connected to device');
|
||||||
@@ -534,10 +538,14 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
final isChannel =
|
final isChannel =
|
||||||
_destinationType ==
|
_destinationType ==
|
||||||
MessageDestinationPreferences.destinationTypeChannel;
|
MessageDestinationPreferences.destinationTypeChannel;
|
||||||
|
final channelIdx = isChannel
|
||||||
|
? (_selectedRecipient?.publicKey[1] ?? 0)
|
||||||
|
: null;
|
||||||
|
final recipient = _selectedRecipient;
|
||||||
final placeholder = Message(
|
final placeholder = Message(
|
||||||
id: msgId,
|
id: msgId,
|
||||||
messageType: isChannel ? MessageType.channel : MessageType.contact,
|
messageType: isChannel ? MessageType.channel : MessageType.contact,
|
||||||
channelIdx: isChannel ? 0 : null,
|
channelIdx: channelIdx,
|
||||||
senderPublicKeyPrefix: deviceKey.sublist(0, 6),
|
senderPublicKeyPrefix: deviceKey.sublist(0, 6),
|
||||||
pathLen: 0,
|
pathLen: 0,
|
||||||
textType: MessageTextType.plain,
|
textType: MessageTextType.plain,
|
||||||
@@ -545,30 +553,37 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
text: envelope.encode(),
|
text: envelope.encode(),
|
||||||
receivedAt: DateTime.now(),
|
receivedAt: DateTime.now(),
|
||||||
deliveryStatus: MessageDeliveryStatus.sending,
|
deliveryStatus: MessageDeliveryStatus.sending,
|
||||||
|
recipientPublicKey: isChannel ? null : recipient?.publicKey,
|
||||||
);
|
);
|
||||||
messagesProvider.addSentMessage(placeholder);
|
messagesProvider.addSentMessage(placeholder);
|
||||||
|
|
||||||
// Send IE1 envelope via normal message path.
|
// Send IE1 envelope via normal message path.
|
||||||
final envelopeText = envelope.encode();
|
final envelopeText = envelope.encode();
|
||||||
if (_destinationType ==
|
|
||||||
MessageDestinationPreferences.destinationTypeChannel) {
|
if (isChannel) {
|
||||||
await connectionProvider.sendChannelMessage(
|
await connectionProvider.sendChannelMessage(
|
||||||
channelIdx: 0,
|
channelIdx: channelIdx ?? 0,
|
||||||
text: envelopeText,
|
text: envelopeText,
|
||||||
messageId: msgId,
|
messageId: msgId,
|
||||||
);
|
);
|
||||||
} else if (_selectedRecipient != null) {
|
} else if (recipient != null) {
|
||||||
final sent = await connectionProvider.sendTextMessage(
|
final sent = await connectionProvider.sendTextMessage(
|
||||||
contactPublicKey: _selectedRecipient!.publicKey,
|
contactPublicKey: recipient.publicKey,
|
||||||
text: envelopeText,
|
text: envelopeText,
|
||||||
messageId: msgId,
|
messageId: msgId,
|
||||||
contact: _selectedRecipient!,
|
contact: recipient,
|
||||||
);
|
);
|
||||||
if (!sent) {
|
if (!sent) {
|
||||||
messagesProvider.markMessageFailed(msgId);
|
messagesProvider.markMessageFailed(msgId);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
ToastLogger.error(context, 'Failed to announce image');
|
ToastLogger.error(context, 'Failed to announce image');
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
messagesProvider.markMessageFailed(msgId);
|
||||||
|
if (!mounted) return;
|
||||||
|
ToastLogger.error(context, 'No recipient selected');
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
debugPrint(
|
debugPrint(
|
||||||
@@ -576,6 +591,22 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
'${fragments.length} fragments, ${compressed.length}B, '
|
'${fragments.length} fragments, ${compressed.length}B, '
|
||||||
'chunk=${imageDataBytesPerFragment}B',
|
'chunk=${imageDataBytesPerFragment}B',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Push all fragments immediately for direct contacts.
|
||||||
|
// For channels, fragments are served on demand via IR1 fetch requests.
|
||||||
|
if (!isChannel && recipient != null) {
|
||||||
|
// Small delay so the IE1 envelope can propagate before fragments arrive.
|
||||||
|
await Future.delayed(const Duration(milliseconds: 500));
|
||||||
|
if (!mounted) return;
|
||||||
|
final served = await imageProvider.serveSessionTo(
|
||||||
|
sessionId: sessionId,
|
||||||
|
requester: recipient,
|
||||||
|
);
|
||||||
|
debugPrint(
|
||||||
|
'📷 [Image] Pushed ${served ? fragments.length : 0} '
|
||||||
|
'fragments to ${recipient.advName}',
|
||||||
|
);
|
||||||
|
}
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
debugPrint('❌ [Image] _pickAndSendImage: $e\n$st');
|
debugPrint('❌ [Image] _pickAndSendImage: $e\n$st');
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
@@ -712,6 +743,17 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final shouldContinue = await _confirmPublicChannelMediaSend('voice');
|
||||||
|
if (!shouldContinue) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_isSendingVoice = false;
|
||||||
|
_currentVoiceSessionId = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await _encodeAndSendAllPackets(
|
await _encodeAndSendAllPackets(
|
||||||
chunks: chunks,
|
chunks: chunks,
|
||||||
@@ -743,8 +785,6 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
final connectionProvider = context.read<ConnectionProvider>();
|
final connectionProvider = context.read<ConnectionProvider>();
|
||||||
final messagesProvider = context.read<MessagesProvider>();
|
final messagesProvider = context.read<MessagesProvider>();
|
||||||
final voiceProvider = context.read<VoiceProvider>();
|
final voiceProvider = context.read<VoiceProvider>();
|
||||||
|
|
||||||
// Insert the chat placeholder before sending (so it appears immediately).
|
|
||||||
final msgId = 'voice_${sessionId}_sent';
|
final msgId = 'voice_${sessionId}_sent';
|
||||||
final devicePublicKey = connectionProvider.deviceInfo.publicKey;
|
final devicePublicKey = connectionProvider.deviceInfo.publicKey;
|
||||||
final senderPublicKeyPrefix =
|
final senderPublicKeyPrefix =
|
||||||
@@ -754,24 +794,8 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
final isChannel =
|
final isChannel =
|
||||||
_destinationType ==
|
_destinationType ==
|
||||||
MessageDestinationPreferences.destinationTypeChannel;
|
MessageDestinationPreferences.destinationTypeChannel;
|
||||||
final sentMsg = Message(
|
final recipient = _selectedRecipient;
|
||||||
id: msgId,
|
final channelIdx = isChannel ? (recipient?.publicKey[1] ?? 0) : null;
|
||||||
messageType: (!isChannel && _selectedRecipient != null)
|
|
||||||
? MessageType.contact
|
|
||||||
: MessageType.channel,
|
|
||||||
senderPublicKeyPrefix: senderPublicKeyPrefix,
|
|
||||||
pathLen: 0,
|
|
||||||
textType: MessageTextType.plain,
|
|
||||||
senderTimestamp: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
|
||||||
text: '',
|
|
||||||
receivedAt: DateTime.now(),
|
|
||||||
deliveryStatus: MessageDeliveryStatus.sent,
|
|
||||||
isVoice: true,
|
|
||||||
voiceId: sessionId,
|
|
||||||
channelIdx: isChannel ? (_selectedRecipient?.publicKey[1] ?? 0) : null,
|
|
||||||
recipientPublicKey: _selectedRecipient?.publicKey,
|
|
||||||
);
|
|
||||||
messagesProvider.addSentMessage(sentMsg);
|
|
||||||
|
|
||||||
final encodedPackets = <VoicePacket>[];
|
final encodedPackets = <VoicePacket>[];
|
||||||
debugPrint(
|
debugPrint(
|
||||||
@@ -831,32 +855,49 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
);
|
);
|
||||||
final envelopeText = envelope.encodeText();
|
final envelopeText = envelope.encodeText();
|
||||||
|
|
||||||
|
// Insert placeholder with real VE1 envelope text so technical details are populated.
|
||||||
|
final sentMsg = Message(
|
||||||
|
id: msgId,
|
||||||
|
messageType: (!isChannel && recipient != null)
|
||||||
|
? MessageType.contact
|
||||||
|
: MessageType.channel,
|
||||||
|
senderPublicKeyPrefix: senderPublicKeyPrefix,
|
||||||
|
pathLen: 0,
|
||||||
|
textType: MessageTextType.plain,
|
||||||
|
senderTimestamp: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||||
|
text: envelopeText,
|
||||||
|
receivedAt: DateTime.now(),
|
||||||
|
deliveryStatus: MessageDeliveryStatus.sent,
|
||||||
|
isVoice: true,
|
||||||
|
voiceId: sessionId,
|
||||||
|
channelIdx: channelIdx,
|
||||||
|
recipientPublicKey: isChannel ? null : recipient?.publicKey,
|
||||||
|
);
|
||||||
|
messagesProvider.addSentMessage(sentMsg);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (isChannel) {
|
if (isChannel) {
|
||||||
final channelIdx = _selectedRecipient?.publicKey[1] ?? 0;
|
|
||||||
await connectionProvider.sendChannelMessage(
|
await connectionProvider.sendChannelMessage(
|
||||||
channelIdx: channelIdx,
|
channelIdx: channelIdx ?? 0,
|
||||||
text: envelopeText,
|
text: envelopeText,
|
||||||
messageId: msgId,
|
messageId: msgId,
|
||||||
);
|
);
|
||||||
} else if (_selectedRecipient != null) {
|
} else if (recipient != null) {
|
||||||
final sentSuccessfully = await connectionProvider.sendTextMessage(
|
final sentSuccessfully = await connectionProvider.sendTextMessage(
|
||||||
contactPublicKey: _selectedRecipient!.publicKey,
|
contactPublicKey: recipient.publicKey,
|
||||||
text: envelopeText,
|
text: envelopeText,
|
||||||
messageId: msgId,
|
messageId: msgId,
|
||||||
contact: _selectedRecipient,
|
contact: recipient,
|
||||||
);
|
);
|
||||||
if (!sentSuccessfully) {
|
if (!sentSuccessfully) {
|
||||||
messagesProvider.markMessageFailed(msgId);
|
messagesProvider.markMessageFailed(msgId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Fallback to public channel if destination cannot be resolved.
|
messagesProvider.markMessageFailed(msgId);
|
||||||
await connectionProvider.sendChannelMessage(
|
if (!mounted) return;
|
||||||
channelIdx: 0,
|
ToastLogger.error(context, 'No recipient selected');
|
||||||
text: envelopeText,
|
return;
|
||||||
messageId: msgId,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
debugPrint('❌ [Voice] envelope send error: $e\n$st');
|
debugPrint('❌ [Voice] envelope send error: $e\n$st');
|
||||||
@@ -913,6 +954,43 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
return rms < _silenceRmsThreshold && peak < _silencePeakThreshold;
|
return rms < _silenceRmsThreshold && peak < _silencePeakThreshold;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool _isPublicChannelSelected() {
|
||||||
|
if (_destinationType !=
|
||||||
|
MessageDestinationPreferences.destinationTypeChannel) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
final channelIdx = _selectedRecipient?.publicKey[1] ?? 0;
|
||||||
|
return channelIdx == 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> _confirmPublicChannelMediaSend(String mediaType) async {
|
||||||
|
if (!_isPublicChannelSelected() || !mounted) return true;
|
||||||
|
|
||||||
|
final decision = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (dialogContext) => AlertDialog(
|
||||||
|
title: const Text('Send to Public Channel?'),
|
||||||
|
content: Text(
|
||||||
|
'You are about to send $mediaType to the Public Channel. '
|
||||||
|
'This is not advised because everyone on the mesh may receive it. '
|
||||||
|
'Choose a private or tagged channel unless this is what you want.',
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(dialogContext).pop(false),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () => Navigator.of(dialogContext).pop(true),
|
||||||
|
child: const Text('Send anyway'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return decision ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
// ── SAR dialog ─────────────────────────────────────────────────────────────
|
// ── SAR dialog ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
void _showSarDialog() {
|
void _showSarDialog() {
|
||||||
@@ -1257,13 +1335,20 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
|
|
||||||
List<Message> filteredMessages;
|
List<Message> filteredMessages;
|
||||||
|
|
||||||
// If public channel is selected, show ALL messages
|
// If channel destination is selected, filter by selected channel.
|
||||||
if (_destinationType ==
|
if (_destinationType ==
|
||||||
MessageDestinationPreferences.destinationTypeChannel &&
|
MessageDestinationPreferences.destinationTypeChannel) {
|
||||||
_selectedRecipient == null) {
|
final selectedChannelIdx = _selectedRecipient?.publicKey[1] ?? 0;
|
||||||
filteredMessages = allMessages;
|
if (selectedChannelIdx == 0) {
|
||||||
|
// Public channel view keeps showing all messages (current app behavior).
|
||||||
|
filteredMessages = allMessages;
|
||||||
|
} else {
|
||||||
|
filteredMessages = allMessages
|
||||||
|
.where((message) => message.channelIdx == selectedChannelIdx)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// If a contact or room is selected, filter by recipient
|
// If a contact or room is selected, filter by recipient/sender prefixes.
|
||||||
else if ((_destinationType ==
|
else if ((_destinationType ==
|
||||||
MessageDestinationPreferences.destinationTypeContact ||
|
MessageDestinationPreferences.destinationTypeContact ||
|
||||||
_destinationType ==
|
_destinationType ==
|
||||||
|
|||||||
@@ -661,6 +661,19 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
Consumer<AppProvider>(
|
||||||
|
builder: (context, appProvider, child) => SwitchListTile(
|
||||||
|
secondary: const Icon(Icons.person_add_alt_1),
|
||||||
|
title: const Text('Auto-add discovered contacts'),
|
||||||
|
subtitle: const Text(
|
||||||
|
'Automatically fetch and add new contacts when they are discovered',
|
||||||
|
),
|
||||||
|
value: appProvider.autoAddDiscoveredContacts,
|
||||||
|
onChanged: (value) async {
|
||||||
|
await appProvider.toggleAutoAddDiscoveredContacts(value);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
Consumer<AppProvider>(
|
Consumer<AppProvider>(
|
||||||
builder: (context, appProvider, child) => SwitchListTile(
|
builder: (context, appProvider, child) => SwitchListTile(
|
||||||
secondary: const Icon(Icons.map_outlined),
|
secondary: const Icon(Icons.map_outlined),
|
||||||
|
|||||||
68
lib/services/mesh_map_nodes_service.dart
Normal file
68
lib/services/mesh_map_nodes_service.dart
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
|
||||||
|
class MeshMapNode {
|
||||||
|
final int type;
|
||||||
|
final String name;
|
||||||
|
final String publicKey;
|
||||||
|
final double latitude;
|
||||||
|
final double longitude;
|
||||||
|
final int updatedAtMs;
|
||||||
|
|
||||||
|
const MeshMapNode({
|
||||||
|
required this.type,
|
||||||
|
required this.name,
|
||||||
|
required this.publicKey,
|
||||||
|
required this.latitude,
|
||||||
|
required this.longitude,
|
||||||
|
required this.updatedAtMs,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory MeshMapNode.fromJson(Map<String, dynamic> json) {
|
||||||
|
return MeshMapNode(
|
||||||
|
type: (json['type'] as num?)?.toInt() ?? 0,
|
||||||
|
name: (json['name'] as String?)?.trim() ?? 'Unknown',
|
||||||
|
publicKey: ((json['public_key'] as String?) ?? '').toLowerCase(),
|
||||||
|
latitude: (json['latitude'] as num?)?.toDouble() ?? 0.0,
|
||||||
|
longitude: (json['longitude'] as num?)?.toDouble() ?? 0.0,
|
||||||
|
updatedAtMs: (json['updated_at'] as num?)?.toInt() ?? 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class MeshMapNodesService {
|
||||||
|
static const String _nodesEndpoint = 'https://api.meshcore.nz/api/v1/map/nodes';
|
||||||
|
static const Duration _cacheTtl = Duration(minutes: 2);
|
||||||
|
static List<MeshMapNode>? _cachedNodes;
|
||||||
|
static DateTime? _cachedAt;
|
||||||
|
|
||||||
|
static Future<List<MeshMapNode>> fetchNodes({bool forceRefresh = false}) async {
|
||||||
|
final now = DateTime.now();
|
||||||
|
if (!forceRefresh &&
|
||||||
|
_cachedNodes != null &&
|
||||||
|
_cachedAt != null &&
|
||||||
|
now.difference(_cachedAt!) < _cacheTtl) {
|
||||||
|
return _cachedNodes!;
|
||||||
|
}
|
||||||
|
|
||||||
|
final response = await http
|
||||||
|
.get(Uri.parse(_nodesEndpoint))
|
||||||
|
.timeout(const Duration(seconds: 12));
|
||||||
|
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||||
|
throw Exception('Map nodes API returned ${response.statusCode}');
|
||||||
|
}
|
||||||
|
|
||||||
|
final decoded = jsonDecode(response.body) as Map<String, dynamic>;
|
||||||
|
final nodesRaw = decoded['nodes'] as List<dynamic>? ?? const [];
|
||||||
|
|
||||||
|
final nodes = nodesRaw
|
||||||
|
.whereType<Map<String, dynamic>>()
|
||||||
|
.map(MeshMapNode.fromJson)
|
||||||
|
.where((n) => n.publicKey.isNotEmpty && n.latitude != 0 && n.longitude != 0)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
_cachedNodes = nodes;
|
||||||
|
_cachedAt = now;
|
||||||
|
return nodes;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,213 +1,155 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:http/http.dart' as http;
|
|
||||||
import 'package:nsd/nsd.dart';
|
import 'package:nsd/nsd.dart';
|
||||||
|
|
||||||
/// Discovered SSE server on the network
|
/// Discovered MeshCore device on the network (TCP/WiFi)
|
||||||
class DiscoveredServer {
|
class DiscoveredServer {
|
||||||
final String ipAddress;
|
final String ipAddress;
|
||||||
final int port;
|
final int port;
|
||||||
final int responseTime; // in milliseconds
|
final int responseTime; // milliseconds
|
||||||
final String serverUrl;
|
|
||||||
|
|
||||||
DiscoveredServer({
|
const DiscoveredServer({
|
||||||
required this.ipAddress,
|
required this.ipAddress,
|
||||||
required this.port,
|
required this.port,
|
||||||
required this.responseTime,
|
required this.responseTime,
|
||||||
}) : serverUrl = 'http://$ipAddress:$port';
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() {
|
String toString() => 'DiscoveredServer($ipAddress:$port, ${responseTime}ms)';
|
||||||
return 'DiscoveredServer($ipAddress:$port, ${responseTime}ms)';
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool operator ==(Object other) {
|
bool operator ==(Object other) =>
|
||||||
if (identical(this, other)) return true;
|
other is DiscoveredServer &&
|
||||||
return other is DiscoveredServer &&
|
other.ipAddress == ipAddress &&
|
||||||
other.ipAddress == ipAddress &&
|
other.port == port;
|
||||||
other.port == port;
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get hashCode => Object.hash(ipAddress, port);
|
int get hashCode => Object.hash(ipAddress, port);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Network Scanner Service
|
/// Discovers MeshCore devices running the TCP/WiFi server (port 5000).
|
||||||
///
|
///
|
||||||
/// Discovers SSE servers on the local network using Bonjour/mDNS.
|
/// First tries mDNS/Bonjour (_meshcore._tcp), then falls back to a parallel
|
||||||
/// Falls back to port scanning (12929) if no services are discovered.
|
/// TCP-connect port scan of the local /24 subnet.
|
||||||
/// Uses parallel scanning (20 IPs at once) for fast discovery.
|
|
||||||
class NetworkScannerService {
|
class NetworkScannerService {
|
||||||
static const int defaultPort = 12929;
|
static const int defaultPort = 5000;
|
||||||
static const String serviceType = '_meshcore-sse._tcp';
|
static const String serviceType = '_meshcore._tcp';
|
||||||
static const int parallelScans = 20;
|
static const int parallelScans = 20;
|
||||||
static const Duration scanTimeout = Duration(seconds: 2);
|
static const Duration connectTimeout = Duration(seconds: 2);
|
||||||
static const Duration bonjourTimeout = Duration(seconds: 5);
|
static const Duration bonjourTimeout = Duration(seconds: 5);
|
||||||
|
|
||||||
Discovery? _activeDiscovery;
|
Discovery? _activeDiscovery;
|
||||||
|
|
||||||
/// Callback for when a server is discovered
|
|
||||||
Function(DiscoveredServer)? onServerDiscovered;
|
Function(DiscoveredServer)? onServerDiscovered;
|
||||||
|
|
||||||
/// Callback for scan progress updates
|
|
||||||
Function(int scanned, int total)? onProgressUpdate;
|
Function(int scanned, int total)? onProgressUpdate;
|
||||||
|
|
||||||
bool _isScanning = false;
|
bool _isScanning = false;
|
||||||
bool get isScanning => _isScanning;
|
bool get isScanning => _isScanning;
|
||||||
|
|
||||||
/// Cached discovered servers from the last scan
|
|
||||||
List<DiscoveredServer> _cachedServers = [];
|
List<DiscoveredServer> _cachedServers = [];
|
||||||
List<DiscoveredServer> get cachedServers => List.unmodifiable(_cachedServers);
|
List<DiscoveredServer> get cachedServers => List.unmodifiable(_cachedServers);
|
||||||
|
|
||||||
/// Whether we have cached results from a previous scan
|
|
||||||
bool get hasCachedResults => _cachedServers.isNotEmpty;
|
bool get hasCachedResults => _cachedServers.isNotEmpty;
|
||||||
|
|
||||||
/// Get all local IP addresses
|
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||||
Future<Set<String>> _getLocalIpAddresses() async {
|
|
||||||
final Set<String> localIps = {};
|
|
||||||
|
|
||||||
|
Future<Set<String>> _getLocalIpAddresses() async {
|
||||||
|
final ips = <String>{};
|
||||||
try {
|
try {
|
||||||
final interfaces = await NetworkInterface.list();
|
for (final iface in await NetworkInterface.list()) {
|
||||||
for (final interface in interfaces) {
|
for (final addr in iface.addresses) {
|
||||||
for (final addr in interface.addresses) {
|
if (addr.type == InternetAddressType.IPv4) ips.add(addr.address);
|
||||||
if (addr.type == InternetAddressType.IPv4) {
|
|
||||||
localIps.add(addr.address);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('❌ [NetworkScanner] Error getting local IPs: $e');
|
debugPrint('❌ [NetworkScanner] Error getting local IPs: $e');
|
||||||
}
|
}
|
||||||
|
return ips;
|
||||||
return localIps;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get local network IP range to scan
|
|
||||||
Future<List<String>> _getLocalNetworkRange() async {
|
Future<List<String>> _getLocalNetworkRange() async {
|
||||||
final List<String> ips = [];
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Get all network interfaces
|
for (final iface in await NetworkInterface.list()) {
|
||||||
final interfaces = await NetworkInterface.list();
|
for (final addr in iface.addresses) {
|
||||||
|
|
||||||
for (final interface in interfaces) {
|
|
||||||
for (final addr in interface.addresses) {
|
|
||||||
// Only scan IPv4 addresses that are not loopback
|
|
||||||
if (addr.type == InternetAddressType.IPv4 && !addr.isLoopback) {
|
if (addr.type == InternetAddressType.IPv4 && !addr.isLoopback) {
|
||||||
final ip = addr.address;
|
final parts = addr.address.split('.');
|
||||||
final parts = ip.split('.');
|
|
||||||
|
|
||||||
if (parts.length == 4) {
|
if (parts.length == 4) {
|
||||||
// Generate range for the same subnet (e.g., 192.168.1.1-254)
|
|
||||||
final subnet = '${parts[0]}.${parts[1]}.${parts[2]}';
|
final subnet = '${parts[0]}.${parts[1]}.${parts[2]}';
|
||||||
|
debugPrint('📡 [NetworkScanner] Scanning subnet $subnet.0/24');
|
||||||
// Scan from .1 to .254 (skip .0 and .255)
|
return [for (int i = 1; i <= 254; i++) '$subnet.$i'];
|
||||||
for (int i = 1; i <= 254; i++) {
|
|
||||||
ips.add('$subnet.$i');
|
|
||||||
}
|
|
||||||
|
|
||||||
debugPrint('📡 [NetworkScanner] Will scan subnet: $subnet.0/24');
|
|
||||||
// Only scan first viable subnet
|
|
||||||
return ips;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('❌ [NetworkScanner] Error getting network interfaces: $e');
|
debugPrint('❌ [NetworkScanner] Error getting network range: $e');
|
||||||
}
|
}
|
||||||
|
return [];
|
||||||
return ips;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if an IP has an SSE server running
|
/// Try a raw TCP connect to check if the MeshCore TCP server is listening.
|
||||||
Future<DiscoveredServer?> _checkServer(String ip, int port) async {
|
Future<DiscoveredServer?> _checkDevice(String ip, int port) async {
|
||||||
|
final sw = Stopwatch()..start();
|
||||||
|
Socket? socket;
|
||||||
try {
|
try {
|
||||||
final stopwatch = Stopwatch()..start();
|
socket = await Socket.connect(
|
||||||
final url = Uri.parse('http://$ip:$port/api/status');
|
ip,
|
||||||
|
port,
|
||||||
final response = await http.get(url).timeout(scanTimeout);
|
timeout: connectTimeout,
|
||||||
|
);
|
||||||
stopwatch.stop();
|
sw.stop();
|
||||||
|
debugPrint(
|
||||||
if (response.statusCode == 200) {
|
'✅ [NetworkScanner] Found device at $ip:$port (${sw.elapsedMilliseconds}ms)');
|
||||||
debugPrint('✅ [NetworkScanner] Found server at $ip:$port (${stopwatch.elapsedMilliseconds}ms)');
|
return DiscoveredServer(
|
||||||
|
ipAddress: ip,
|
||||||
return DiscoveredServer(
|
port: port,
|
||||||
ipAddress: ip,
|
responseTime: sw.elapsedMilliseconds,
|
||||||
port: port,
|
);
|
||||||
responseTime: stopwatch.elapsedMilliseconds,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} on TimeoutException {
|
|
||||||
// Timeout - server not responding, ignore
|
|
||||||
} on SocketException {
|
} on SocketException {
|
||||||
// Connection refused - no server at this IP, ignore
|
// Connection refused or timed out — no device here
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Other errors - ignore
|
debugPrint('⚠️ [NetworkScanner] Error checking $ip:$port — $e');
|
||||||
debugPrint('⚠️ [NetworkScanner] Error checking $ip:$port - $e');
|
} finally {
|
||||||
|
socket?.destroy();
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Discover servers using Bonjour/mDNS
|
// ── mDNS discovery ─────────────────────────────────────────────────────────
|
||||||
Future<List<DiscoveredServer>> _discoverViaBonjourAsync({int? port}) async {
|
|
||||||
|
Future<List<DiscoveredServer>> _discoverViaMdns({int? port}) async {
|
||||||
final scanPort = port ?? defaultPort;
|
final scanPort = port ?? defaultPort;
|
||||||
final List<DiscoveredServer> discoveredServers = [];
|
final found = <DiscoveredServer>[];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
debugPrint('🔍 [NetworkScanner] Starting Bonjour discovery for $serviceType...');
|
debugPrint('🔍 [NetworkScanner] mDNS discovery for $serviceType...');
|
||||||
|
|
||||||
// Get local IP addresses to filter out
|
|
||||||
final localIps = await _getLocalIpAddresses();
|
final localIps = await _getLocalIpAddresses();
|
||||||
debugPrint('📍 [NetworkScanner] Local IPs: ${localIps.join(", ")}');
|
|
||||||
|
|
||||||
// Start discovery with IP lookup
|
|
||||||
_activeDiscovery = await startDiscovery(
|
_activeDiscovery = await startDiscovery(
|
||||||
serviceType,
|
serviceType,
|
||||||
ipLookupType: IpLookupType.any,
|
ipLookupType: IpLookupType.any,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Wait for discovery to find services
|
|
||||||
await Future.delayed(bonjourTimeout);
|
await Future.delayed(bonjourTimeout);
|
||||||
|
|
||||||
// Process discovered services
|
for (final service in _activeDiscovery?.services ?? []) {
|
||||||
final services = _activeDiscovery?.services ?? [];
|
for (final addr in service.addresses ?? []) {
|
||||||
debugPrint('📡 [NetworkScanner] Bonjour found ${services.length} services');
|
if (localIps.contains(addr.address)) continue;
|
||||||
|
final result =
|
||||||
for (final service in services) {
|
await _checkDevice(addr.address, service.port ?? scanPort);
|
||||||
if (service.addresses != null && service.addresses!.isNotEmpty) {
|
if (result != null) {
|
||||||
for (final address in service.addresses!) {
|
found.add(result);
|
||||||
// Skip if this is a local IP address
|
onServerDiscovered?.call(result);
|
||||||
if (localIps.contains(address.address)) {
|
|
||||||
debugPrint('⏭️ [NetworkScanner] Skipping local IP: ${address.address}');
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify service is actually reachable
|
|
||||||
final result = await _checkServer(
|
|
||||||
address.address,
|
|
||||||
service.port ?? scanPort,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (result != null) {
|
|
||||||
discoveredServers.add(result);
|
|
||||||
onServerDiscovered?.call(result);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stop discovery
|
|
||||||
await stopDiscovery(_activeDiscovery!);
|
await stopDiscovery(_activeDiscovery!);
|
||||||
_activeDiscovery = null;
|
_activeDiscovery = null;
|
||||||
|
debugPrint(
|
||||||
debugPrint('✅ [NetworkScanner] Bonjour discovery complete. Found ${discoveredServers.length} servers.');
|
'✅ [NetworkScanner] mDNS done. Found ${found.length} devices.');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('⚠️ [NetworkScanner] Bonjour discovery failed: $e');
|
debugPrint('⚠️ [NetworkScanner] mDNS failed: $e');
|
||||||
if (_activeDiscovery != null) {
|
if (_activeDiscovery != null) {
|
||||||
try {
|
try {
|
||||||
await stopDiscovery(_activeDiscovery!);
|
await stopDiscovery(_activeDiscovery!);
|
||||||
@@ -216,132 +158,70 @@ class NetworkScannerService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return discoveredServers;
|
return found;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Scan the local network for SSE servers
|
// ── Port scan fallback ─────────────────────────────────────────────────────
|
||||||
/// First tries Bonjour/mDNS, then falls back to port scanning if nothing found
|
|
||||||
Future<List<DiscoveredServer>> scan({int? port}) async {
|
|
||||||
if (_isScanning) {
|
|
||||||
debugPrint('⚠️ [NetworkScanner] Scan already in progress');
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
_isScanning = true;
|
Future<List<DiscoveredServer>> _scanByPort({int? port}) async {
|
||||||
final scanPort = port ?? defaultPort;
|
final scanPort = port ?? defaultPort;
|
||||||
List<DiscoveredServer> discoveredServers = [];
|
final found = <DiscoveredServer>[];
|
||||||
|
|
||||||
try {
|
final localIps = await _getLocalIpAddresses();
|
||||||
// Try Bonjour/mDNS discovery first
|
final ips = await _getLocalNetworkRange();
|
||||||
discoveredServers = await _discoverViaBonjourAsync(port: scanPort);
|
if (ips.isEmpty) return [];
|
||||||
|
|
||||||
// Fall back to port scanning if Bonjour found nothing
|
debugPrint(
|
||||||
if (discoveredServers.isEmpty) {
|
'🔍 [NetworkScanner] Port scan: ${ips.length} IPs, port $scanPort');
|
||||||
debugPrint('🔍 [NetworkScanner] Bonjour found nothing, falling back to port scanning...');
|
|
||||||
discoveredServers = await _scanByPortAsync(port: scanPort);
|
int scanned = 0;
|
||||||
|
for (int i = 0; i < ips.length; i += parallelScans) {
|
||||||
|
final batch = ips.skip(i).take(parallelScans).toList();
|
||||||
|
final results =
|
||||||
|
await Future.wait(batch.map((ip) => _checkDevice(ip, scanPort)));
|
||||||
|
|
||||||
|
for (final result in results) {
|
||||||
|
if (result != null && !localIps.contains(result.ipAddress)) {
|
||||||
|
found.add(result);
|
||||||
|
onServerDiscovered?.call(result);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cache the results
|
scanned += batch.length;
|
||||||
_cachedServers = discoveredServers;
|
onProgressUpdate?.call(scanned, ips.length);
|
||||||
} catch (e) {
|
}
|
||||||
debugPrint('❌ [NetworkScanner] Scan error: $e');
|
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Public API ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Scan for MeshCore WiFi devices. Tries mDNS first, falls back to port scan.
|
||||||
|
Future<List<DiscoveredServer>> scan({int? port}) async {
|
||||||
|
if (_isScanning) return [];
|
||||||
|
_isScanning = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
var found = await _discoverViaMdns(port: port);
|
||||||
|
if (found.isEmpty) {
|
||||||
|
debugPrint(
|
||||||
|
'🔍 [NetworkScanner] mDNS found nothing, falling back to port scan');
|
||||||
|
found = await _scanByPort(port: port);
|
||||||
|
}
|
||||||
|
_cachedServers = found;
|
||||||
|
return found;
|
||||||
} finally {
|
} finally {
|
||||||
_isScanning = false;
|
_isScanning = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return discoveredServers;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fallback port scanning method
|
/// Verify a previously discovered device is still reachable.
|
||||||
Future<List<DiscoveredServer>> _scanByPortAsync({int? port}) async {
|
|
||||||
final scanPort = port ?? defaultPort;
|
|
||||||
final List<DiscoveredServer> discoveredServers = [];
|
|
||||||
|
|
||||||
try {
|
|
||||||
debugPrint('🔍 [NetworkScanner] Starting port scan on port $scanPort...');
|
|
||||||
|
|
||||||
// Get local IP addresses to filter out
|
|
||||||
final localIps = await _getLocalIpAddresses();
|
|
||||||
debugPrint('📍 [NetworkScanner] Local IPs: ${localIps.join(", ")}');
|
|
||||||
|
|
||||||
final ips = await _getLocalNetworkRange();
|
|
||||||
|
|
||||||
if (ips.isEmpty) {
|
|
||||||
debugPrint('⚠️ [NetworkScanner] No network interfaces found');
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
debugPrint('📊 [NetworkScanner] Scanning ${ips.length} IPs with $parallelScans parallel connections');
|
|
||||||
|
|
||||||
int scannedCount = 0;
|
|
||||||
|
|
||||||
// Scan in batches of 20 parallel connections
|
|
||||||
for (int i = 0; i < ips.length; i += parallelScans) {
|
|
||||||
final batch = ips.skip(i).take(parallelScans).toList();
|
|
||||||
|
|
||||||
// Scan batch in parallel
|
|
||||||
final futures = batch.map((ip) => _checkServer(ip, scanPort)).toList();
|
|
||||||
final results = await Future.wait(futures);
|
|
||||||
|
|
||||||
// Collect discovered servers (excluding local IPs)
|
|
||||||
for (int j = 0; j < results.length; j++) {
|
|
||||||
final result = results[j];
|
|
||||||
if (result != null) {
|
|
||||||
// Skip if this is a local IP address
|
|
||||||
if (localIps.contains(result.ipAddress)) {
|
|
||||||
debugPrint('⏭️ [NetworkScanner] Skipping local IP: ${result.ipAddress}');
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
discoveredServers.add(result);
|
|
||||||
onServerDiscovered?.call(result);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
scannedCount += batch.length;
|
|
||||||
onProgressUpdate?.call(scannedCount, ips.length);
|
|
||||||
}
|
|
||||||
|
|
||||||
debugPrint('✅ [NetworkScanner] Port scan complete. Found ${discoveredServers.length} servers.');
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('❌ [NetworkScanner] Port scan error: $e');
|
|
||||||
}
|
|
||||||
|
|
||||||
return discoveredServers;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Clear cached results (useful for forcing a fresh scan)
|
|
||||||
void clearCache() {
|
|
||||||
_cachedServers = [];
|
|
||||||
debugPrint('🗑️ [NetworkScanner] Cache cleared');
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Stop ongoing scan
|
|
||||||
void stopScan() {
|
|
||||||
if (_isScanning) {
|
|
||||||
debugPrint('🛑 [NetworkScanner] Stopping scan...');
|
|
||||||
_isScanning = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Verify that a previously discovered server is still available
|
|
||||||
/// Returns true if server is reachable, false otherwise
|
|
||||||
Future<bool> verifyServer(DiscoveredServer server) async {
|
Future<bool> verifyServer(DiscoveredServer server) async {
|
||||||
try {
|
final result = await _checkDevice(server.ipAddress, server.port);
|
||||||
debugPrint('🔍 [NetworkScanner] Verifying server at ${server.ipAddress}:${server.port}...');
|
return result != null;
|
||||||
|
|
||||||
final result = await _checkServer(server.ipAddress, server.port);
|
|
||||||
|
|
||||||
if (result != null) {
|
|
||||||
debugPrint('✅ [NetworkScanner] Server verified at ${server.ipAddress}:${server.port}');
|
|
||||||
return true;
|
|
||||||
} else {
|
|
||||||
debugPrint('❌ [NetworkScanner] Server no longer available at ${server.ipAddress}:${server.port}');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('❌ [NetworkScanner] Server verification failed: $e');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void clearCache() => _cachedServers = [];
|
||||||
|
|
||||||
|
void stopScan() => _isScanning = false;
|
||||||
}
|
}
|
||||||
|
|||||||
146
lib/services/packet_capture_storage_service.dart
Normal file
146
lib/services/packet_capture_storage_service.dart
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
import '../models/ble_packet_log.dart';
|
||||||
|
|
||||||
|
class StoredPacketCapture {
|
||||||
|
final DateTime timestamp;
|
||||||
|
final String direction;
|
||||||
|
final int? responseCode;
|
||||||
|
final String? description;
|
||||||
|
final String rawBase64;
|
||||||
|
final int rawSize;
|
||||||
|
final double? snrDb;
|
||||||
|
final int? rssiDbm;
|
||||||
|
|
||||||
|
const StoredPacketCapture({
|
||||||
|
required this.timestamp,
|
||||||
|
required this.direction,
|
||||||
|
required this.responseCode,
|
||||||
|
required this.description,
|
||||||
|
required this.rawBase64,
|
||||||
|
required this.rawSize,
|
||||||
|
required this.snrDb,
|
||||||
|
required this.rssiDbm,
|
||||||
|
});
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'ts': timestamp.millisecondsSinceEpoch,
|
||||||
|
'dir': direction,
|
||||||
|
'code': responseCode,
|
||||||
|
'desc': description,
|
||||||
|
'raw': rawBase64,
|
||||||
|
'size': rawSize,
|
||||||
|
'snr': snrDb,
|
||||||
|
'rssi': rssiDbm,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static StoredPacketCapture fromJson(Map<String, dynamic> json) {
|
||||||
|
return StoredPacketCapture(
|
||||||
|
timestamp: DateTime.fromMillisecondsSinceEpoch((json['ts'] as num).toInt()),
|
||||||
|
direction: (json['dir'] as String?) ?? 'rx',
|
||||||
|
responseCode: (json['code'] as num?)?.toInt(),
|
||||||
|
description: json['desc'] as String?,
|
||||||
|
rawBase64: (json['raw'] as String?) ?? '',
|
||||||
|
rawSize: (json['size'] as num?)?.toInt() ?? 0,
|
||||||
|
snrDb: (json['snr'] as num?)?.toDouble(),
|
||||||
|
rssiDbm: (json['rssi'] as num?)?.toInt(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Durable storage for raw BLE packets so future features can consume
|
||||||
|
/// historical packet bytes across app restarts.
|
||||||
|
class PacketCaptureStorageService {
|
||||||
|
static const String _fileName = 'packet_captures.jsonl';
|
||||||
|
static const int _maxStoredPackets = 20000;
|
||||||
|
|
||||||
|
File? _file;
|
||||||
|
|
||||||
|
Future<File> _resolveFile() async {
|
||||||
|
if (_file != null) return _file!;
|
||||||
|
final dir = await getApplicationSupportDirectory();
|
||||||
|
final file = File('${dir.path}/$_fileName');
|
||||||
|
if (!await file.exists()) {
|
||||||
|
await file.create(recursive: true);
|
||||||
|
}
|
||||||
|
_file = file;
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> appendLogs(List<BlePacketLog> logs) async {
|
||||||
|
if (logs.isEmpty) return;
|
||||||
|
try {
|
||||||
|
final file = await _resolveFile();
|
||||||
|
final sink = file.openWrite(mode: FileMode.append);
|
||||||
|
for (final log in logs) {
|
||||||
|
final row = StoredPacketCapture(
|
||||||
|
timestamp: log.timestamp,
|
||||||
|
direction: log.direction.name,
|
||||||
|
responseCode: log.responseCode,
|
||||||
|
description: log.description,
|
||||||
|
rawBase64: base64Encode(log.rawData),
|
||||||
|
rawSize: log.rawData.length,
|
||||||
|
snrDb: log.logRxDataInfo?.snrDb,
|
||||||
|
rssiDbm: log.logRxDataInfo?.rssiDbm,
|
||||||
|
);
|
||||||
|
sink.writeln(jsonEncode(row.toJson()));
|
||||||
|
}
|
||||||
|
await sink.flush();
|
||||||
|
await sink.close();
|
||||||
|
await _pruneIfNeeded(file);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('❌ [PacketCaptureStorage] Failed to append logs: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<StoredPacketCapture>> loadRecent({int limit = 500}) async {
|
||||||
|
try {
|
||||||
|
final file = await _resolveFile();
|
||||||
|
if (!await file.exists()) return const [];
|
||||||
|
final lines = await file.readAsLines();
|
||||||
|
if (lines.isEmpty) return const [];
|
||||||
|
final start = lines.length > limit ? lines.length - limit : 0;
|
||||||
|
return lines
|
||||||
|
.sublist(start)
|
||||||
|
.where((l) => l.trim().isNotEmpty)
|
||||||
|
.map((l) => StoredPacketCapture.fromJson(jsonDecode(l) as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('❌ [PacketCaptureStorage] Failed to load recent logs: $e');
|
||||||
|
return const [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<int> count() async {
|
||||||
|
try {
|
||||||
|
final file = await _resolveFile();
|
||||||
|
if (!await file.exists()) return 0;
|
||||||
|
final lines = await file.readAsLines();
|
||||||
|
return lines.where((l) => l.trim().isNotEmpty).length;
|
||||||
|
} catch (_) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> clear() async {
|
||||||
|
try {
|
||||||
|
final file = await _resolveFile();
|
||||||
|
if (await file.exists()) {
|
||||||
|
await file.writeAsString('');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('❌ [PacketCaptureStorage] Failed to clear logs: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pruneIfNeeded(File file) async {
|
||||||
|
final lines = await file.readAsLines();
|
||||||
|
if (lines.length <= _maxStoredPackets) return;
|
||||||
|
final keep = lines.sublist(lines.length - _maxStoredPackets);
|
||||||
|
await file.writeAsString('${keep.join('\n')}\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,667 +0,0 @@
|
|||||||
import 'dart:async';
|
|
||||||
import 'dart:convert';
|
|
||||||
import 'dart:io' as io;
|
|
||||||
import 'package:flutter/foundation.dart';
|
|
||||||
import 'package:http/http.dart' as http;
|
|
||||||
import 'package:http/io_client.dart' as io_client;
|
|
||||||
import '../models/message.dart';
|
|
||||||
import '../models/contact.dart';
|
|
||||||
import 'package:latlong2/latlong.dart';
|
|
||||||
|
|
||||||
/// SSE Client Service
|
|
||||||
///
|
|
||||||
/// Connects to a remote SSE server to receive messages and contacts in real-time.
|
|
||||||
/// This enables multiple app instances to share a single MeshCore BLE device
|
|
||||||
/// without direct BLE connections.
|
|
||||||
class SseClientService {
|
|
||||||
String? _serverUrl;
|
|
||||||
String? _authToken;
|
|
||||||
http.Client? _httpClient;
|
|
||||||
StreamSubscription? _messageSubscription;
|
|
||||||
StreamSubscription? _contactSubscription;
|
|
||||||
bool _isConnected = false;
|
|
||||||
bool _isConnecting = false;
|
|
||||||
bool _hasConnectedBefore =
|
|
||||||
false; // Track if we've ever successfully connected
|
|
||||||
Timer? _reconnectTimer;
|
|
||||||
Timer? _heartbeatTimer;
|
|
||||||
int _reconnectAttempts = 0;
|
|
||||||
static const int _maxReconnectAttempts = 10;
|
|
||||||
static const Duration _reconnectDelay = Duration(seconds: 5);
|
|
||||||
|
|
||||||
/// Callback for when a message is received
|
|
||||||
Function(Message)? onMessageReceived;
|
|
||||||
|
|
||||||
/// Callback for when a contact is received
|
|
||||||
Function(Contact)? onContactReceived;
|
|
||||||
|
|
||||||
/// Callback for connection state changes
|
|
||||||
Function(bool isConnected)? onConnectionStateChanged;
|
|
||||||
|
|
||||||
/// Callback for errors
|
|
||||||
Function(String error)? onError;
|
|
||||||
|
|
||||||
/// Check if client is connected
|
|
||||||
bool get isConnected => _isConnected;
|
|
||||||
|
|
||||||
/// Check if client is currently connecting
|
|
||||||
bool get isConnecting => _isConnecting;
|
|
||||||
|
|
||||||
/// Get current reconnection attempt number
|
|
||||||
int get reconnectionAttempts => _reconnectAttempts;
|
|
||||||
|
|
||||||
/// Get maximum reconnection attempts
|
|
||||||
int get maxReconnectionAttempts => _maxReconnectAttempts;
|
|
||||||
|
|
||||||
/// Get server URL
|
|
||||||
String? get serverUrl => _serverUrl;
|
|
||||||
|
|
||||||
/// Connect to SSE server
|
|
||||||
Future<void> connect({required String serverUrl, String? authToken}) async {
|
|
||||||
if (_isConnected) {
|
|
||||||
debugPrint('⚠️ [SseClient] Already connected');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_serverUrl = serverUrl;
|
|
||||||
_authToken = authToken;
|
|
||||||
_isConnecting = true;
|
|
||||||
|
|
||||||
debugPrint(
|
|
||||||
'🔌 [SseClient] Connecting to $serverUrl (attempt ${_reconnectAttempts + 1}/$_maxReconnectAttempts)',
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Create a new HTTP client with custom configuration for SSE streaming
|
|
||||||
// Using IOClient with custom HttpClient for better control over connection settings
|
|
||||||
final ioHttpClient = io.HttpClient();
|
|
||||||
ioHttpClient.connectionTimeout = const Duration(seconds: 10);
|
|
||||||
ioHttpClient.idleTimeout = const Duration(
|
|
||||||
hours: 1,
|
|
||||||
); // Keep SSE connections alive
|
|
||||||
_httpClient = io_client.IOClient(ioHttpClient);
|
|
||||||
|
|
||||||
// Test server availability
|
|
||||||
await _checkServerStatus();
|
|
||||||
|
|
||||||
// Fetch initial message history
|
|
||||||
await _fetchMessageHistory();
|
|
||||||
|
|
||||||
// Fetch initial contact list
|
|
||||||
await _fetchContacts();
|
|
||||||
|
|
||||||
// Subscribe to SSE streams
|
|
||||||
debugPrint('🔗 [SseClient] Subscribing to message stream...');
|
|
||||||
debugPrint(
|
|
||||||
'🔗 [SseClient] Using HTTP client type: ${_httpClient.runtimeType}',
|
|
||||||
);
|
|
||||||
await _subscribeToMessages();
|
|
||||||
debugPrint('🔗 [SseClient] Subscribing to contact stream...');
|
|
||||||
await _subscribeToContacts();
|
|
||||||
debugPrint('🔗 [SseClient] All subscriptions complete');
|
|
||||||
|
|
||||||
_isConnected = true;
|
|
||||||
_isConnecting = false;
|
|
||||||
_hasConnectedBefore = true; // Mark that we've successfully connected
|
|
||||||
_reconnectAttempts = 0;
|
|
||||||
debugPrint('🔔 [SseClient] Calling onConnectionStateChanged(true)');
|
|
||||||
onConnectionStateChanged?.call(true);
|
|
||||||
|
|
||||||
// Start heartbeat to detect connection loss
|
|
||||||
_startHeartbeat();
|
|
||||||
|
|
||||||
debugPrint('✅ [SseClient] Connected successfully');
|
|
||||||
} catch (e) {
|
|
||||||
_isConnecting = false;
|
|
||||||
_httpClient?.close();
|
|
||||||
_httpClient = null;
|
|
||||||
debugPrint('❌ [SseClient] Connection failed: $e');
|
|
||||||
onError?.call('Connection failed: $e');
|
|
||||||
|
|
||||||
// Only auto-reconnect if we've successfully connected before
|
|
||||||
// Initial connection failures should be handled by the user
|
|
||||||
if (_hasConnectedBefore) {
|
|
||||||
_scheduleReconnect();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Disconnect from SSE server
|
|
||||||
Future<void> disconnect() async {
|
|
||||||
debugPrint('🔌 [SseClient] Disconnecting...');
|
|
||||||
|
|
||||||
_isConnected = false;
|
|
||||||
_isConnecting = false;
|
|
||||||
_hasConnectedBefore = false; // Reset on manual disconnect
|
|
||||||
_reconnectTimer?.cancel();
|
|
||||||
_heartbeatTimer?.cancel();
|
|
||||||
await _messageSubscription?.cancel();
|
|
||||||
await _contactSubscription?.cancel();
|
|
||||||
_httpClient?.close();
|
|
||||||
|
|
||||||
_serverUrl = null;
|
|
||||||
_authToken = null;
|
|
||||||
_httpClient = null;
|
|
||||||
|
|
||||||
onConnectionStateChanged?.call(false);
|
|
||||||
|
|
||||||
debugPrint('✅ [SseClient] Disconnected');
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check server status
|
|
||||||
Future<void> _checkServerStatus() async {
|
|
||||||
final url = Uri.parse('$_serverUrl/api/status');
|
|
||||||
|
|
||||||
try {
|
|
||||||
final response = await http
|
|
||||||
.get(url, headers: _getHeaders())
|
|
||||||
.timeout(const Duration(seconds: 5));
|
|
||||||
|
|
||||||
if (response.statusCode != 200) {
|
|
||||||
throw Exception('Server returned ${response.statusCode}');
|
|
||||||
}
|
|
||||||
|
|
||||||
final data = jsonDecode(response.body);
|
|
||||||
debugPrint('📊 [SseClient] Server status: ${data['status']}');
|
|
||||||
debugPrint(' Connected clients: ${data['connectedClients']}');
|
|
||||||
debugPrint(' Messages: ${data['messageCount']}');
|
|
||||||
debugPrint(' Contacts: ${data['contactCount']}');
|
|
||||||
} catch (e) {
|
|
||||||
// Wrap the error with more user-friendly message
|
|
||||||
throw Exception(_formatConnectionError(e));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Format connection error to be more user-friendly
|
|
||||||
String _formatConnectionError(dynamic error) {
|
|
||||||
final errorStr = error.toString();
|
|
||||||
|
|
||||||
// Extract the actual server URL being connected to
|
|
||||||
final serverUri = Uri.tryParse(_serverUrl ?? '');
|
|
||||||
final host = serverUri?.host ?? 'unknown';
|
|
||||||
final port = serverUri?.port ?? 0;
|
|
||||||
|
|
||||||
if (errorStr.contains('Connection refused')) {
|
|
||||||
return 'Server not available at $host:$port. The server may be offline or not running.';
|
|
||||||
} else if (errorStr.contains('TimeoutException') ||
|
|
||||||
errorStr.contains('timed out')) {
|
|
||||||
return 'Connection to $host:$port timed out. Check your network connection.';
|
|
||||||
} else if (errorStr.contains('SocketException')) {
|
|
||||||
return 'Network error connecting to $host:$port. Check your network connection.';
|
|
||||||
} else if (errorStr.contains('Failed host lookup')) {
|
|
||||||
return 'Could not resolve hostname: $host';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return the original error if we can't make it more user-friendly
|
|
||||||
return errorStr;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Fetch message history on connect
|
|
||||||
Future<void> _fetchMessageHistory() async {
|
|
||||||
try {
|
|
||||||
final url = Uri.parse('$_serverUrl/api/messages/history');
|
|
||||||
final response = await http
|
|
||||||
.get(url, headers: _getHeaders())
|
|
||||||
.timeout(const Duration(seconds: 10));
|
|
||||||
|
|
||||||
if (response.statusCode != 200) {
|
|
||||||
throw Exception(
|
|
||||||
'Failed to fetch message history: ${response.statusCode}',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
final data = jsonDecode(response.body) as Map<String, dynamic>;
|
|
||||||
final messages = data['messages'] as List;
|
|
||||||
|
|
||||||
debugPrint(
|
|
||||||
'📥 [SseClient] Received ${messages.length} messages from history',
|
|
||||||
);
|
|
||||||
|
|
||||||
for (final msgJson in messages) {
|
|
||||||
try {
|
|
||||||
final message = _messageFromJson(msgJson);
|
|
||||||
onMessageReceived?.call(message);
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('⚠️ [SseClient] Failed to parse message: $e');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('❌ [SseClient] Error fetching message history: $e');
|
|
||||||
// Don't throw - continue with connection even if history fetch fails
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Fetch contacts on connect
|
|
||||||
Future<void> _fetchContacts() async {
|
|
||||||
try {
|
|
||||||
final url = Uri.parse('$_serverUrl/api/contacts');
|
|
||||||
final response = await http
|
|
||||||
.get(url, headers: _getHeaders())
|
|
||||||
.timeout(const Duration(seconds: 10));
|
|
||||||
|
|
||||||
if (response.statusCode != 200) {
|
|
||||||
throw Exception('Failed to fetch contacts: ${response.statusCode}');
|
|
||||||
}
|
|
||||||
|
|
||||||
final data = jsonDecode(response.body) as Map<String, dynamic>;
|
|
||||||
final contacts = data['contacts'] as List;
|
|
||||||
|
|
||||||
debugPrint('📥 [SseClient] Received ${contacts.length} contacts');
|
|
||||||
|
|
||||||
for (final contactJson in contacts) {
|
|
||||||
try {
|
|
||||||
final contact = _contactFromJson(contactJson);
|
|
||||||
onContactReceived?.call(contact);
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('⚠️ [SseClient] Failed to parse contact: $e');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('❌ [SseClient] Error fetching contacts: $e');
|
|
||||||
// Don't throw - continue with connection even if contacts fetch fails
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Subscribe to SSE message stream
|
|
||||||
Future<void> _subscribeToMessages() async {
|
|
||||||
try {
|
|
||||||
if (_httpClient == null) {
|
|
||||||
throw Exception('HTTP client not initialized');
|
|
||||||
}
|
|
||||||
|
|
||||||
debugPrint('📡 [SseClient] Creating message stream request...');
|
|
||||||
final url = Uri.parse('$_serverUrl/sse/messages');
|
|
||||||
final request = http.Request('GET', url);
|
|
||||||
request.headers.addAll(_getHeaders());
|
|
||||||
request.headers['Accept'] = 'text/event-stream';
|
|
||||||
request.headers['Cache-Control'] = 'no-cache';
|
|
||||||
|
|
||||||
debugPrint('📡 [SseClient] Sending message stream request to $url');
|
|
||||||
debugPrint('📡 [SseClient] Request headers: ${request.headers}');
|
|
||||||
|
|
||||||
final streamedResponse = await _httpClient!
|
|
||||||
.send(request)
|
|
||||||
.timeout(
|
|
||||||
const Duration(seconds: 10),
|
|
||||||
onTimeout: () {
|
|
||||||
debugPrint('❌ [SseClient] Timeout waiting for response headers');
|
|
||||||
throw TimeoutException(
|
|
||||||
'Message stream connection timed out after 10 seconds',
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
debugPrint(
|
|
||||||
'📡 [SseClient] Received response with status: ${streamedResponse.statusCode}',
|
|
||||||
);
|
|
||||||
debugPrint(
|
|
||||||
'📡 [SseClient] Response headers: ${streamedResponse.headers}',
|
|
||||||
);
|
|
||||||
debugPrint(
|
|
||||||
'📡 [SseClient] Response content length: ${streamedResponse.contentLength}',
|
|
||||||
);
|
|
||||||
debugPrint(
|
|
||||||
'📡 [SseClient] Response is redirect: ${streamedResponse.isRedirect}',
|
|
||||||
);
|
|
||||||
|
|
||||||
if (streamedResponse.statusCode != 200) {
|
|
||||||
throw Exception(
|
|
||||||
'SSE messages subscription failed: ${streamedResponse.statusCode}',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
debugPrint(
|
|
||||||
'📡 [SseClient] Message stream response received, status: ${streamedResponse.statusCode}',
|
|
||||||
);
|
|
||||||
debugPrint('📡 [SseClient] Setting up stream listener...');
|
|
||||||
|
|
||||||
_messageSubscription = streamedResponse.stream
|
|
||||||
.transform(utf8.decoder)
|
|
||||||
.transform(const LineSplitter())
|
|
||||||
.listen(
|
|
||||||
(line) {
|
|
||||||
debugPrint('📨 [SseClient] Received line: "$line"');
|
|
||||||
_handleSseLine(line, 'message');
|
|
||||||
},
|
|
||||||
onError: (error, stackTrace) {
|
|
||||||
debugPrint('❌ [SseClient] Message stream error: $error');
|
|
||||||
debugPrint(' Stack trace: $stackTrace');
|
|
||||||
_handleDisconnect();
|
|
||||||
},
|
|
||||||
onDone: () {
|
|
||||||
debugPrint(
|
|
||||||
'⚠️ [SseClient] Message stream closed (onDone called)',
|
|
||||||
);
|
|
||||||
_handleDisconnect();
|
|
||||||
},
|
|
||||||
cancelOnError: false,
|
|
||||||
);
|
|
||||||
|
|
||||||
debugPrint('✅ [SseClient] Message stream listener set up successfully');
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('❌ [SseClient] Error subscribing to message stream: $e');
|
|
||||||
rethrow;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Subscribe to SSE contact stream
|
|
||||||
Future<void> _subscribeToContacts() async {
|
|
||||||
try {
|
|
||||||
if (_httpClient == null) {
|
|
||||||
throw Exception('HTTP client not initialized');
|
|
||||||
}
|
|
||||||
|
|
||||||
debugPrint('📡 [SseClient] Creating contact stream request...');
|
|
||||||
final url = Uri.parse('$_serverUrl/sse/contacts');
|
|
||||||
final request = http.Request('GET', url);
|
|
||||||
request.headers.addAll(_getHeaders());
|
|
||||||
request.headers['Accept'] = 'text/event-stream';
|
|
||||||
request.headers['Cache-Control'] = 'no-cache';
|
|
||||||
|
|
||||||
debugPrint('📡 [SseClient] Sending contact stream request to $url');
|
|
||||||
final streamedResponse = await _httpClient!
|
|
||||||
.send(request)
|
|
||||||
.timeout(
|
|
||||||
const Duration(seconds: 10),
|
|
||||||
onTimeout: () {
|
|
||||||
throw TimeoutException(
|
|
||||||
'Contact stream connection timed out after 10 seconds',
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
if (streamedResponse.statusCode != 200) {
|
|
||||||
throw Exception(
|
|
||||||
'SSE contacts subscription failed: ${streamedResponse.statusCode}',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
debugPrint(
|
|
||||||
'📡 [SseClient] Contact stream response received, status: ${streamedResponse.statusCode}',
|
|
||||||
);
|
|
||||||
debugPrint('📡 [SseClient] Setting up contact stream listener...');
|
|
||||||
|
|
||||||
_contactSubscription = streamedResponse.stream
|
|
||||||
.transform(utf8.decoder)
|
|
||||||
.transform(const LineSplitter())
|
|
||||||
.listen(
|
|
||||||
(line) {
|
|
||||||
debugPrint('📨 [SseClient] Received contact line: "$line"');
|
|
||||||
_handleSseLine(line, 'contact');
|
|
||||||
},
|
|
||||||
onError: (error, stackTrace) {
|
|
||||||
debugPrint('❌ [SseClient] Contact stream error: $error');
|
|
||||||
debugPrint(' Stack trace: $stackTrace');
|
|
||||||
_handleDisconnect();
|
|
||||||
},
|
|
||||||
onDone: () {
|
|
||||||
debugPrint(
|
|
||||||
'⚠️ [SseClient] Contact stream closed (onDone called)',
|
|
||||||
);
|
|
||||||
_handleDisconnect();
|
|
||||||
},
|
|
||||||
cancelOnError: false,
|
|
||||||
);
|
|
||||||
|
|
||||||
debugPrint('✅ [SseClient] Contact stream listener set up successfully');
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('❌ [SseClient] Error subscribing to contact stream: $e');
|
|
||||||
rethrow;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Handle SSE line
|
|
||||||
String _eventType = '';
|
|
||||||
void _handleSseLine(String line, String streamType) {
|
|
||||||
if (line.isEmpty) {
|
|
||||||
// Event complete, reset
|
|
||||||
_eventType = '';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (line.startsWith('event:')) {
|
|
||||||
_eventType = line.substring(6).trim();
|
|
||||||
} else if (line.startsWith('data:')) {
|
|
||||||
final jsonData = line.substring(5).trim();
|
|
||||||
try {
|
|
||||||
final data = jsonDecode(jsonData) as Map<String, dynamic>;
|
|
||||||
|
|
||||||
if (streamType == 'message' && _eventType == 'message') {
|
|
||||||
final message = _messageFromJson(data);
|
|
||||||
onMessageReceived?.call(message);
|
|
||||||
} else if (streamType == 'contact' && _eventType == 'contact') {
|
|
||||||
final contact = _contactFromJson(data);
|
|
||||||
onContactReceived?.call(contact);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('⚠️ [SseClient] Failed to parse SSE data: $e');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Handle disconnect
|
|
||||||
void _handleDisconnect() {
|
|
||||||
if (!_isConnected) return;
|
|
||||||
|
|
||||||
_isConnected = false;
|
|
||||||
onConnectionStateChanged?.call(false);
|
|
||||||
|
|
||||||
_scheduleReconnect();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Schedule reconnection attempt
|
|
||||||
void _scheduleReconnect() {
|
|
||||||
if (_reconnectAttempts >= _maxReconnectAttempts) {
|
|
||||||
debugPrint('❌ [SseClient] Max reconnection attempts reached');
|
|
||||||
onError?.call('Max reconnection attempts reached');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_reconnectAttempts++;
|
|
||||||
final delay = _reconnectDelay * _reconnectAttempts;
|
|
||||||
|
|
||||||
debugPrint(
|
|
||||||
'🔄 [SseClient] Scheduling reconnect attempt $_reconnectAttempts in ${delay.inSeconds}s',
|
|
||||||
);
|
|
||||||
|
|
||||||
_reconnectTimer?.cancel();
|
|
||||||
_reconnectTimer = Timer(delay, () {
|
|
||||||
if (_serverUrl != null) {
|
|
||||||
connect(serverUrl: _serverUrl!, authToken: _authToken);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Start heartbeat to detect connection loss
|
|
||||||
void _startHeartbeat() {
|
|
||||||
_heartbeatTimer?.cancel();
|
|
||||||
_heartbeatTimer = Timer.periodic(const Duration(seconds: 30), (
|
|
||||||
timer,
|
|
||||||
) async {
|
|
||||||
try {
|
|
||||||
await _checkServerStatus();
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('⚠️ [SseClient] Heartbeat failed: $e');
|
|
||||||
_handleDisconnect();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Send message to server
|
|
||||||
Future<bool> sendMessage({
|
|
||||||
required String recipientPublicKey,
|
|
||||||
required String text,
|
|
||||||
}) async {
|
|
||||||
if (!_isConnected || _serverUrl == null) {
|
|
||||||
throw Exception('Not connected to server');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
final url = Uri.parse('$_serverUrl/api/messages');
|
|
||||||
final response = await http
|
|
||||||
.post(
|
|
||||||
url,
|
|
||||||
headers: {..._getHeaders(), 'Content-Type': 'application/json'},
|
|
||||||
body: jsonEncode({
|
|
||||||
'recipientPublicKey': recipientPublicKey,
|
|
||||||
'text': text,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.timeout(const Duration(seconds: 10));
|
|
||||||
|
|
||||||
if (response.statusCode != 200) {
|
|
||||||
throw Exception('Send message failed: ${response.statusCode}');
|
|
||||||
}
|
|
||||||
|
|
||||||
final data = jsonDecode(response.body) as Map<String, dynamic>;
|
|
||||||
return data['success'] as bool? ?? false;
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('❌ [SseClient] Error sending message: $e');
|
|
||||||
rethrow;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Send channel message to server
|
|
||||||
Future<void> sendChannelMessage({
|
|
||||||
required int channelIdx,
|
|
||||||
required String text,
|
|
||||||
}) async {
|
|
||||||
if (!_isConnected || _serverUrl == null) {
|
|
||||||
throw Exception('Not connected to server');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
final url = Uri.parse('$_serverUrl/api/messages/channel');
|
|
||||||
final response = await http
|
|
||||||
.post(
|
|
||||||
url,
|
|
||||||
headers: {..._getHeaders(), 'Content-Type': 'application/json'},
|
|
||||||
body: jsonEncode({'channelIdx': channelIdx, 'text': text}),
|
|
||||||
)
|
|
||||||
.timeout(const Duration(seconds: 10));
|
|
||||||
|
|
||||||
if (response.statusCode != 200) {
|
|
||||||
throw Exception('Send channel message failed: ${response.statusCode}');
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('❌ [SseClient] Error sending channel message: $e');
|
|
||||||
rethrow;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Request contact sync
|
|
||||||
Future<void> syncContacts() async {
|
|
||||||
if (!_isConnected || _serverUrl == null) {
|
|
||||||
throw Exception('Not connected to server');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
final url = Uri.parse('$_serverUrl/api/contacts/sync');
|
|
||||||
final response = await http
|
|
||||||
.post(url, headers: _getHeaders())
|
|
||||||
.timeout(const Duration(seconds: 10));
|
|
||||||
|
|
||||||
if (response.statusCode != 200) {
|
|
||||||
throw Exception('Contact sync failed: ${response.statusCode}');
|
|
||||||
}
|
|
||||||
|
|
||||||
debugPrint('✅ [SseClient] Contact sync requested');
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('❌ [SseClient] Error syncing contacts: $e');
|
|
||||||
rethrow;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get headers for HTTP requests
|
|
||||||
Map<String, String> _getHeaders() {
|
|
||||||
final headers = <String, String>{};
|
|
||||||
if (_authToken != null) {
|
|
||||||
headers['Authorization'] = 'Bearer $_authToken';
|
|
||||||
}
|
|
||||||
return headers;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Convert JSON to Message
|
|
||||||
Message _messageFromJson(Map<String, dynamic> json) {
|
|
||||||
return Message(
|
|
||||||
id: json['id'] as String,
|
|
||||||
messageType: MessageType.values.firstWhere(
|
|
||||||
(e) => e.name == json['messageType'],
|
|
||||||
orElse: () => MessageType.contact,
|
|
||||||
),
|
|
||||||
senderPublicKeyPrefix: json['senderPublicKeyPrefix'] != null
|
|
||||||
? Uint8List.fromList(
|
|
||||||
(json['senderPublicKeyPrefix'] as List).cast<int>(),
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
channelIdx: json['channelIdx'] as int?,
|
|
||||||
pathLen: json['pathLen'] as int,
|
|
||||||
textType: MessageTextType.fromValue(json['textType'] as int),
|
|
||||||
senderTimestamp: json['senderTimestamp'] as int,
|
|
||||||
text: json['text'] as String,
|
|
||||||
isSarMarker: json['isSarMarker'] as bool? ?? false,
|
|
||||||
sarGpsCoordinates: json['sarGpsCoordinates'] != null
|
|
||||||
? LatLng(
|
|
||||||
(json['sarGpsCoordinates']['latitude'] as num).toDouble(),
|
|
||||||
(json['sarGpsCoordinates']['longitude'] as num).toDouble(),
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
sarNotes: json['sarNotes'] as String?,
|
|
||||||
sarCustomEmoji: json['sarCustomEmoji'] as String?,
|
|
||||||
sarColorIndex: json['sarColorIndex'] as int?,
|
|
||||||
receivedAt: DateTime.parse(json['receivedAt'] as String),
|
|
||||||
senderName: json['senderName'] as String?,
|
|
||||||
deliveryStatus: MessageDeliveryStatus.values.firstWhere(
|
|
||||||
(e) => e.name == json['deliveryStatus'],
|
|
||||||
orElse: () => MessageDeliveryStatus.received,
|
|
||||||
),
|
|
||||||
expectedAckTag: json['expectedAckTag'] as int?,
|
|
||||||
suggestedTimeoutMs: json['suggestedTimeoutMs'] as int?,
|
|
||||||
roundTripTimeMs: json['roundTripTimeMs'] as int?,
|
|
||||||
deliveredAt: json['deliveredAt'] != null
|
|
||||||
? DateTime.parse(json['deliveredAt'] as String)
|
|
||||||
: null,
|
|
||||||
recipientPublicKey: json['recipientPublicKey'] != null
|
|
||||||
? Uint8List.fromList((json['recipientPublicKey'] as List).cast<int>())
|
|
||||||
: null,
|
|
||||||
retryAttempt: json['retryAttempt'] as int? ?? 0,
|
|
||||||
lastRetryAt: json['lastRetryAt'] != null
|
|
||||||
? DateTime.parse(json['lastRetryAt'] as String)
|
|
||||||
: null,
|
|
||||||
usedFloodFallback: json['usedFloodFallback'] as bool? ?? false,
|
|
||||||
isRead: json['isRead'] as bool? ?? false,
|
|
||||||
echoCount: json['echoCount'] as int? ?? 0,
|
|
||||||
firstEchoAt: json['firstEchoAt'] != null
|
|
||||||
? DateTime.parse(json['firstEchoAt'] as String)
|
|
||||||
: null,
|
|
||||||
lastEchoSnrRaw: json['lastEchoSnrRaw'] as int?,
|
|
||||||
lastEchoRssiDbm: json['lastEchoRssiDbm'] as int?,
|
|
||||||
lastEchoAt: json['lastEchoAt'] != null
|
|
||||||
? DateTime.parse(json['lastEchoAt'] as String)
|
|
||||||
: null,
|
|
||||||
isDrawing: json['isDrawing'] as bool? ?? false,
|
|
||||||
drawingId: json['drawingId'] as String?,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Convert JSON to Contact
|
|
||||||
Contact _contactFromJson(Map<String, dynamic> json) {
|
|
||||||
return Contact(
|
|
||||||
publicKey: Uint8List.fromList((json['publicKey'] as List).cast<int>()),
|
|
||||||
type: ContactType.fromValue(json['type'] as int),
|
|
||||||
flags: json['flags'] as int,
|
|
||||||
outPathLen: json['outPathLen'] as int,
|
|
||||||
outPath: Uint8List.fromList((json['outPath'] as List).cast<int>()),
|
|
||||||
advName: json['advName'] as String,
|
|
||||||
lastAdvert: json['lastAdvert'] as int,
|
|
||||||
advLat: json['advLat'] as int,
|
|
||||||
advLon: json['advLon'] as int,
|
|
||||||
lastMod: json['lastMod'] as int,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Dispose resources
|
|
||||||
void dispose() {
|
|
||||||
disconnect();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -5,16 +5,19 @@ import '../models/update_info.dart';
|
|||||||
import 'build_info_service.dart';
|
import 'build_info_service.dart';
|
||||||
|
|
||||||
/// Service for checking if a new app version is available
|
/// Service for checking if a new app version is available
|
||||||
/// Compares current build's commit hash with latest manifest from server
|
/// Compares current build's commit hash with latest GitHub release
|
||||||
class UpdateCheckerService {
|
class UpdateCheckerService {
|
||||||
static final UpdateCheckerService _instance = UpdateCheckerService._internal();
|
static final UpdateCheckerService _instance =
|
||||||
|
UpdateCheckerService._internal();
|
||||||
factory UpdateCheckerService() => _instance;
|
factory UpdateCheckerService() => _instance;
|
||||||
UpdateCheckerService._internal();
|
UpdateCheckerService._internal();
|
||||||
|
|
||||||
final BuildInfoService _buildInfoService = BuildInfoService();
|
final BuildInfoService _buildInfoService = BuildInfoService();
|
||||||
|
|
||||||
// Manifest URL for the latest unstable build
|
static const String _repoOwner = 'dz0ny';
|
||||||
static const String _manifestUrl = 'https://meshcore-sar.dz0ny.dev/unstable/latest/manifest.json';
|
static const String _repoName = 'meshcore-sar';
|
||||||
|
static const String _latestReleaseUrl =
|
||||||
|
'https://api.github.com/repos/$_repoOwner/$_repoName/releases/latest';
|
||||||
|
|
||||||
/// Check if an update is available
|
/// Check if an update is available
|
||||||
/// Returns UpdateInfo with availability status and download URL if available
|
/// Returns UpdateInfo with availability status and download URL if available
|
||||||
@@ -25,61 +28,78 @@ class UpdateCheckerService {
|
|||||||
|
|
||||||
// Skip check for dev builds (local development)
|
// Skip check for dev builds (local development)
|
||||||
if (currentCommitHash == 'dev' || currentCommitHash == 'unknown') {
|
if (currentCommitHash == 'dev' || currentCommitHash == 'unknown') {
|
||||||
debugPrint('[UpdateChecker] Skipping update check for dev/unknown build');
|
debugPrint(
|
||||||
|
'[UpdateChecker] Skipping update check for dev/unknown build',
|
||||||
|
);
|
||||||
return UpdateInfo.noUpdate(currentCommitHash);
|
return UpdateInfo.noUpdate(currentCommitHash);
|
||||||
}
|
}
|
||||||
|
|
||||||
debugPrint('[UpdateChecker] Current commit hash: $currentCommitHash');
|
debugPrint('[UpdateChecker] Current commit hash: $currentCommitHash');
|
||||||
debugPrint('[UpdateChecker] Fetching latest manifest from: $_manifestUrl');
|
debugPrint(
|
||||||
|
'[UpdateChecker] Fetching latest release from: $_latestReleaseUrl',
|
||||||
// Fetch manifest from server
|
|
||||||
final response = await http.get(
|
|
||||||
Uri.parse(_manifestUrl),
|
|
||||||
headers: {'Accept': 'application/json'},
|
|
||||||
).timeout(
|
|
||||||
const Duration(seconds: 10),
|
|
||||||
onTimeout: () {
|
|
||||||
debugPrint('[UpdateChecker] Manifest fetch timed out');
|
|
||||||
throw Exception('Manifest fetch timed out');
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Fetch latest release from GitHub
|
||||||
|
final response = await http
|
||||||
|
.get(
|
||||||
|
Uri.parse(_latestReleaseUrl),
|
||||||
|
headers: {
|
||||||
|
'Accept': 'application/vnd.github+json',
|
||||||
|
'X-GitHub-Api-Version': '2022-11-28',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.timeout(
|
||||||
|
const Duration(seconds: 10),
|
||||||
|
onTimeout: () {
|
||||||
|
debugPrint('[UpdateChecker] Latest release fetch timed out');
|
||||||
|
throw Exception('Latest release fetch timed out');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
if (response.statusCode != 200) {
|
if (response.statusCode != 200) {
|
||||||
debugPrint('[UpdateChecker] Failed to fetch manifest: ${response.statusCode}');
|
debugPrint(
|
||||||
|
'[UpdateChecker] Failed to fetch release: ${response.statusCode}',
|
||||||
|
);
|
||||||
return UpdateInfo.noUpdate(currentCommitHash);
|
return UpdateInfo.noUpdate(currentCommitHash);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse manifest JSON
|
// Parse release JSON
|
||||||
final Map<String, dynamic> manifest = json.decode(response.body);
|
final Map<String, dynamic> release = json.decode(response.body);
|
||||||
final latestCommitHash = manifest['commit'] as String?;
|
final tagName = release['tag_name'] as String?;
|
||||||
final commitShort = manifest['commit_short'] as String?;
|
final targetCommitish = release['target_commitish'] as String?;
|
||||||
final buildId = manifest['build_id'] as String?;
|
final publishedAt = release['published_at'] as String?;
|
||||||
final timestamp = manifest['timestamp'] as String?;
|
final assets = release['assets'] as List<dynamic>?;
|
||||||
final artifacts = manifest['artifacts'] as List<dynamic>?;
|
|
||||||
|
|
||||||
if (latestCommitHash == null || commitShort == null) {
|
if (tagName == null) {
|
||||||
debugPrint('[UpdateChecker] Invalid manifest: missing commit information');
|
debugPrint('[UpdateChecker] Invalid release: missing tag_name');
|
||||||
return UpdateInfo.noUpdate(currentCommitHash);
|
return UpdateInfo.noUpdate(currentCommitHash);
|
||||||
}
|
}
|
||||||
|
|
||||||
debugPrint('[UpdateChecker] Latest commit hash: $latestCommitHash');
|
debugPrint('[UpdateChecker] Latest release tag: $tagName');
|
||||||
debugPrint('[UpdateChecker] Latest commit short: $commitShort');
|
if (targetCommitish != null && targetCommitish.isNotEmpty) {
|
||||||
|
debugPrint(
|
||||||
|
'[UpdateChecker] Release target commitish: $targetCommitish',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Compare commit hashes
|
final isUpdateAvailable = await _isUpdateAvailable(
|
||||||
// Current hash might be full SHA or short (7 chars)
|
currentCommitHash: currentCommitHash,
|
||||||
// Latest from manifest is full SHA
|
releaseTag: tagName,
|
||||||
final isUpdateAvailable = !_compareCommitHashes(currentCommitHash, latestCommitHash);
|
targetCommitish: targetCommitish,
|
||||||
|
);
|
||||||
|
|
||||||
if (!isUpdateAvailable) {
|
if (!isUpdateAvailable) {
|
||||||
debugPrint('[UpdateChecker] No update available (same commit)');
|
debugPrint('[UpdateChecker] No update available');
|
||||||
return UpdateInfo.noUpdate(currentCommitHash);
|
return UpdateInfo.noUpdate(currentCommitHash);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find Android APK in artifacts
|
// Find Android APK in release assets
|
||||||
final String? apkUrl = _findAndroidApkUrl(artifacts);
|
final String? apkUrl = _findAndroidApkUrl(assets);
|
||||||
|
|
||||||
if (apkUrl == null) {
|
if (apkUrl == null) {
|
||||||
debugPrint('[UpdateChecker] Update available but no APK found in artifacts');
|
debugPrint(
|
||||||
|
'[UpdateChecker] Update available but no APK found in artifacts',
|
||||||
|
);
|
||||||
return UpdateInfo.noUpdate(currentCommitHash);
|
return UpdateInfo.noUpdate(currentCommitHash);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,10 +107,10 @@ class UpdateCheckerService {
|
|||||||
|
|
||||||
return UpdateInfo.available(
|
return UpdateInfo.available(
|
||||||
currentCommitHash: currentCommitHash,
|
currentCommitHash: currentCommitHash,
|
||||||
latestCommitHash: commitShort,
|
latestCommitHash: _formatLatestVersion(targetCommitish, tagName),
|
||||||
downloadUrl: apkUrl,
|
downloadUrl: apkUrl,
|
||||||
buildId: buildId,
|
buildId: tagName,
|
||||||
timestamp: timestamp,
|
timestamp: publishedAt,
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('[UpdateChecker] Error checking for update: $e');
|
debugPrint('[UpdateChecker] Error checking for update: $e');
|
||||||
@@ -118,15 +138,94 @@ class UpdateCheckerService {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Find Android APK URL in artifacts list
|
Future<bool> _isUpdateAvailable({
|
||||||
String? _findAndroidApkUrl(List<dynamic>? artifacts) {
|
required String currentCommitHash,
|
||||||
if (artifacts == null || artifacts.isEmpty) return null;
|
required String releaseTag,
|
||||||
|
required String? targetCommitish,
|
||||||
|
}) async {
|
||||||
|
// Prefer direct SHA compare when release target is a hash.
|
||||||
|
if (_looksLikeSha(targetCommitish)) {
|
||||||
|
return !_compareCommitHashes(currentCommitHash, targetCommitish!);
|
||||||
|
}
|
||||||
|
|
||||||
// Look for .apk file in artifacts
|
// Fallback: ask GitHub how current commit compares to the release tag.
|
||||||
for (final artifact in artifacts) {
|
final compareResult = await _compareWithReleaseTag(
|
||||||
if (artifact is String && artifact.toLowerCase().endsWith('.apk')) {
|
currentCommitHash,
|
||||||
// Construct full URL
|
releaseTag,
|
||||||
return 'https://meshcore-sar.dz0ny.dev/unstable/latest/$artifact';
|
);
|
||||||
|
if (compareResult != null) {
|
||||||
|
return compareResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we cannot compare, do not force update prompts.
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool?> _compareWithReleaseTag(
|
||||||
|
String currentCommitHash,
|
||||||
|
String releaseTag,
|
||||||
|
) async {
|
||||||
|
try {
|
||||||
|
final compareUrl =
|
||||||
|
'https://api.github.com/repos/$_repoOwner/$_repoName/compare/$currentCommitHash...$releaseTag';
|
||||||
|
final response = await http
|
||||||
|
.get(
|
||||||
|
Uri.parse(compareUrl),
|
||||||
|
headers: {
|
||||||
|
'Accept': 'application/vnd.github+json',
|
||||||
|
'X-GitHub-Api-Version': '2022-11-28',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.timeout(const Duration(seconds: 10));
|
||||||
|
|
||||||
|
if (response.statusCode != 200) {
|
||||||
|
debugPrint(
|
||||||
|
'[UpdateChecker] Compare API failed: ${response.statusCode}',
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final Map<String, dynamic> comparison = json.decode(response.body);
|
||||||
|
final status = comparison['status'] as String?;
|
||||||
|
debugPrint('[UpdateChecker] Compare status: $status');
|
||||||
|
|
||||||
|
if (status == 'behind') return true;
|
||||||
|
if (status == 'identical' || status == 'ahead') return false;
|
||||||
|
|
||||||
|
// "diverged" means the release and current commit differ.
|
||||||
|
if (status == 'diverged') return true;
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[UpdateChecker] Compare API error: $e');
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _looksLikeSha(String? value) {
|
||||||
|
if (value == null) return false;
|
||||||
|
final v = value.trim();
|
||||||
|
if (v.length < 7 || v.length > 40) return false;
|
||||||
|
return RegExp(r'^[a-fA-F0-9]+$').hasMatch(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatLatestVersion(String? targetCommitish, String tagName) {
|
||||||
|
if (_looksLikeSha(targetCommitish)) {
|
||||||
|
return targetCommitish!.substring(0, 7).toLowerCase();
|
||||||
|
}
|
||||||
|
return tagName;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find Android APK URL in release assets list
|
||||||
|
String? _findAndroidApkUrl(List<dynamic>? assets) {
|
||||||
|
if (assets == null || assets.isEmpty) return null;
|
||||||
|
|
||||||
|
for (final asset in assets) {
|
||||||
|
if (asset is! Map<String, dynamic>) continue;
|
||||||
|
final name = (asset['name'] as String?)?.toLowerCase() ?? '';
|
||||||
|
if (!name.endsWith('.apk')) continue;
|
||||||
|
|
||||||
|
final browserDownloadUrl = asset['browser_download_url'] as String?;
|
||||||
|
if (browserDownloadUrl != null && browserDownloadUrl.isNotEmpty) {
|
||||||
|
return browserDownloadUrl;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,89 +1,2 @@
|
|||||||
import 'dart:typed_data';
|
export 'voice_codec_service_stub.dart'
|
||||||
import 'package:codec2_flutter/codec2_flutter.dart';
|
if (dart.library.io) 'voice_codec_service_io.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
|
||||||
import '../utils/voice_message_parser.dart';
|
|
||||||
|
|
||||||
export 'package:codec2_flutter/codec2_flutter.dart' show Codec2Mode;
|
|
||||||
|
|
||||||
/// Maps [VoicePacketMode] to the [Codec2Mode] enum from the FFI plugin.
|
|
||||||
Codec2Mode codec2ModeFor(VoicePacketMode pktMode) {
|
|
||||||
switch (pktMode) {
|
|
||||||
case VoicePacketMode.mode3200: return Codec2Mode.mode3200;
|
|
||||||
case VoicePacketMode.mode1600: return Codec2Mode.mode1600;
|
|
||||||
case VoicePacketMode.mode1400: return Codec2Mode.mode1400;
|
|
||||||
case VoicePacketMode.mode700c: return Codec2Mode.mode700c;
|
|
||||||
case VoicePacketMode.mode1200: return Codec2Mode.mode1200;
|
|
||||||
case VoicePacketMode.mode1300: return Codec2Mode.mode1300;
|
|
||||||
case VoicePacketMode.mode2400: return Codec2Mode.mode2400;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Selects the [VoicePacketMode] best suited for a given LoRa radio bandwidth.
|
|
||||||
///
|
|
||||||
/// Call with [radioBandwidthHz] from the device's radio params
|
|
||||||
/// (e.g. 125000 for 125 kHz).
|
|
||||||
VoicePacketMode voiceModeForBandwidth(int radioBandwidthHz) {
|
|
||||||
if (radioBandwidthHz <= 62500) return VoicePacketMode.mode700c;
|
|
||||||
if (radioBandwidthHz <= 125000) return VoicePacketMode.mode1200;
|
|
||||||
return VoicePacketMode.mode1300;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// High-level codec service that provides async Codec2 encode/decode
|
|
||||||
/// executed in a background isolate so the UI thread is never blocked.
|
|
||||||
class VoiceCodecService {
|
|
||||||
void _ensureCodec2Supported() {
|
|
||||||
if (kIsWeb ||
|
|
||||||
(defaultTargetPlatform != TargetPlatform.iOS &&
|
|
||||||
defaultTargetPlatform != TargetPlatform.android)) {
|
|
||||||
throw UnsupportedError('Codec2 is enabled only on iOS and Android.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Encode [pcm] (Int16 samples, 8000 Hz mono) with [mode].
|
|
||||||
/// Returns the raw Codec2-encoded bytes.
|
|
||||||
Future<Uint8List> encode(Int16List pcm, VoicePacketMode mode) {
|
|
||||||
_ensureCodec2Supported();
|
|
||||||
return Codec2.encodeInIsolate(pcm, codec2ModeFor(mode));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Decode [codec2Bytes] back to Int16 PCM (8000 Hz mono) with [mode].
|
|
||||||
Future<Int16List> decode(Uint8List codec2Bytes, VoicePacketMode mode) {
|
|
||||||
_ensureCodec2Supported();
|
|
||||||
return Codec2.decodeInIsolate(codec2Bytes, codec2ModeFor(mode));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Decode and concatenate multiple [packets] into a single PCM Int16List.
|
|
||||||
/// Packets with null/missing entries are substituted with silence.
|
|
||||||
Future<Int16List> decodePackets(
|
|
||||||
List<VoicePacket?> packets,
|
|
||||||
VoicePacketMode mode,
|
|
||||||
) async {
|
|
||||||
_ensureCodec2Supported();
|
|
||||||
final c2Mode = codec2ModeFor(mode);
|
|
||||||
final c2 = Codec2.create(c2Mode);
|
|
||||||
final spf = c2.samplesPerFrame;
|
|
||||||
c2.destroy();
|
|
||||||
|
|
||||||
// Estimate total samples (use actual data or silence per missing packet)
|
|
||||||
final all = <Int16List>[];
|
|
||||||
for (final pkt in packets) {
|
|
||||||
if (pkt == null || pkt.codec2Data.isEmpty) {
|
|
||||||
// Silence for missing packet — duration approximated by mode
|
|
||||||
final silenceSamples = (codec2ModeFor(mode).framesPerSecond) * spf;
|
|
||||||
all.add(Int16List(silenceSamples));
|
|
||||||
} else {
|
|
||||||
final decoded = await Codec2.decodeInIsolate(pkt.codec2Data, c2Mode);
|
|
||||||
all.add(decoded);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
final total = all.fold<int>(0, (sum, l) => sum + l.length);
|
|
||||||
final result = Int16List(total);
|
|
||||||
var offset = 0;
|
|
||||||
for (final chunk in all) {
|
|
||||||
result.setRange(offset, offset + chunk.length, chunk);
|
|
||||||
offset += chunk.length;
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
89
lib/services/voice_codec_service_io.dart
Normal file
89
lib/services/voice_codec_service_io.dart
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
import 'package:codec2_flutter/codec2_flutter.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import '../utils/voice_message_parser.dart';
|
||||||
|
|
||||||
|
export 'package:codec2_flutter/codec2_flutter.dart' show Codec2Mode;
|
||||||
|
|
||||||
|
/// Maps [VoicePacketMode] to the [Codec2Mode] enum from the FFI plugin.
|
||||||
|
Codec2Mode codec2ModeFor(VoicePacketMode pktMode) {
|
||||||
|
switch (pktMode) {
|
||||||
|
case VoicePacketMode.mode3200: return Codec2Mode.mode3200;
|
||||||
|
case VoicePacketMode.mode1600: return Codec2Mode.mode1600;
|
||||||
|
case VoicePacketMode.mode1400: return Codec2Mode.mode1400;
|
||||||
|
case VoicePacketMode.mode700c: return Codec2Mode.mode700c;
|
||||||
|
case VoicePacketMode.mode1200: return Codec2Mode.mode1200;
|
||||||
|
case VoicePacketMode.mode1300: return Codec2Mode.mode1300;
|
||||||
|
case VoicePacketMode.mode2400: return Codec2Mode.mode2400;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Selects the [VoicePacketMode] best suited for a given LoRa radio bandwidth.
|
||||||
|
///
|
||||||
|
/// Call with [radioBandwidthHz] from the device's radio params
|
||||||
|
/// (e.g. 125000 for 125 kHz).
|
||||||
|
VoicePacketMode voiceModeForBandwidth(int radioBandwidthHz) {
|
||||||
|
if (radioBandwidthHz <= 62500) return VoicePacketMode.mode700c;
|
||||||
|
if (radioBandwidthHz <= 125000) return VoicePacketMode.mode1200;
|
||||||
|
return VoicePacketMode.mode1300;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// High-level codec service that provides async Codec2 encode/decode
|
||||||
|
/// executed in a background isolate so the UI thread is never blocked.
|
||||||
|
class VoiceCodecService {
|
||||||
|
void _ensureCodec2Supported() {
|
||||||
|
if (kIsWeb ||
|
||||||
|
(defaultTargetPlatform != TargetPlatform.iOS &&
|
||||||
|
defaultTargetPlatform != TargetPlatform.android)) {
|
||||||
|
throw UnsupportedError('Codec2 is enabled only on iOS and Android.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encode [pcm] (Int16 samples, 8000 Hz mono) with [mode].
|
||||||
|
/// Returns the raw Codec2-encoded bytes.
|
||||||
|
Future<Uint8List> encode(Int16List pcm, VoicePacketMode mode) {
|
||||||
|
_ensureCodec2Supported();
|
||||||
|
return Codec2.encodeInIsolate(pcm, codec2ModeFor(mode));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decode [codec2Bytes] back to Int16 PCM (8000 Hz mono) with [mode].
|
||||||
|
Future<Int16List> decode(Uint8List codec2Bytes, VoicePacketMode mode) {
|
||||||
|
_ensureCodec2Supported();
|
||||||
|
return Codec2.decodeInIsolate(codec2Bytes, codec2ModeFor(mode));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decode and concatenate multiple [packets] into a single PCM Int16List.
|
||||||
|
/// Packets with null/missing entries are substituted with silence.
|
||||||
|
Future<Int16List> decodePackets(
|
||||||
|
List<VoicePacket?> packets,
|
||||||
|
VoicePacketMode mode,
|
||||||
|
) async {
|
||||||
|
_ensureCodec2Supported();
|
||||||
|
final c2Mode = codec2ModeFor(mode);
|
||||||
|
final c2 = Codec2.create(c2Mode);
|
||||||
|
final spf = c2.samplesPerFrame;
|
||||||
|
c2.destroy();
|
||||||
|
|
||||||
|
// Estimate total samples (use actual data or silence per missing packet)
|
||||||
|
final all = <Int16List>[];
|
||||||
|
for (final pkt in packets) {
|
||||||
|
if (pkt == null || pkt.codec2Data.isEmpty) {
|
||||||
|
// Silence for missing packet — duration approximated by mode
|
||||||
|
final silenceSamples = (codec2ModeFor(mode).framesPerSecond) * spf;
|
||||||
|
all.add(Int16List(silenceSamples));
|
||||||
|
} else {
|
||||||
|
final decoded = await Codec2.decodeInIsolate(pkt.codec2Data, c2Mode);
|
||||||
|
all.add(decoded);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final total = all.fold<int>(0, (sum, l) => sum + l.length);
|
||||||
|
final result = Int16List(total);
|
||||||
|
var offset = 0;
|
||||||
|
for (final chunk in all) {
|
||||||
|
result.setRange(offset, offset + chunk.length, chunk);
|
||||||
|
offset += chunk.length;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
71
lib/services/voice_codec_service_stub.dart
Normal file
71
lib/services/voice_codec_service_stub.dart
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
import '../utils/voice_message_parser.dart';
|
||||||
|
|
||||||
|
/// Web/unsupported platform stub — Codec2 FFI is not available.
|
||||||
|
enum Codec2Mode {
|
||||||
|
mode3200(0),
|
||||||
|
mode2400(1),
|
||||||
|
mode1600(2),
|
||||||
|
mode1400(3),
|
||||||
|
mode1300(4),
|
||||||
|
mode1200(5),
|
||||||
|
mode700c(8);
|
||||||
|
|
||||||
|
const Codec2Mode(this.c2ModeId);
|
||||||
|
final int c2ModeId;
|
||||||
|
|
||||||
|
int get framesPerSecond => (this == mode3200 || this == mode2400) ? 50 : 25;
|
||||||
|
int get samplesPerFrame => 8000 ~/ framesPerSecond;
|
||||||
|
|
||||||
|
int get bytesPerSecond {
|
||||||
|
switch (this) {
|
||||||
|
case mode3200: return 400;
|
||||||
|
case mode700c: return 100;
|
||||||
|
case mode1200: return 150;
|
||||||
|
case mode1300: return 175;
|
||||||
|
case mode1400: return 175;
|
||||||
|
case mode1600: return 200;
|
||||||
|
case mode2400: return 300;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static const int _maxBytesPerPacket = 160;
|
||||||
|
|
||||||
|
int get packetDurationMs {
|
||||||
|
final bytesPerFrame = bytesPerSecond / framesPerSecond;
|
||||||
|
final framesPerPacket = (_maxBytesPerPacket / bytesPerFrame).floor();
|
||||||
|
return (framesPerPacket * 1000 ~/ framesPerSecond);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Codec2Mode codec2ModeFor(VoicePacketMode pktMode) {
|
||||||
|
switch (pktMode) {
|
||||||
|
case VoicePacketMode.mode3200: return Codec2Mode.mode3200;
|
||||||
|
case VoicePacketMode.mode1600: return Codec2Mode.mode1600;
|
||||||
|
case VoicePacketMode.mode1400: return Codec2Mode.mode1400;
|
||||||
|
case VoicePacketMode.mode700c: return Codec2Mode.mode700c;
|
||||||
|
case VoicePacketMode.mode1200: return Codec2Mode.mode1200;
|
||||||
|
case VoicePacketMode.mode1300: return Codec2Mode.mode1300;
|
||||||
|
case VoicePacketMode.mode2400: return Codec2Mode.mode2400;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
VoicePacketMode voiceModeForBandwidth(int radioBandwidthHz) {
|
||||||
|
if (radioBandwidthHz <= 62500) return VoicePacketMode.mode700c;
|
||||||
|
if (radioBandwidthHz <= 125000) return VoicePacketMode.mode1200;
|
||||||
|
return VoicePacketMode.mode1300;
|
||||||
|
}
|
||||||
|
|
||||||
|
class VoiceCodecService {
|
||||||
|
Future<Uint8List> encode(Int16List pcm, VoicePacketMode mode) =>
|
||||||
|
Future.error(UnsupportedError('Voice not supported on web'));
|
||||||
|
|
||||||
|
Future<Int16List> decode(Uint8List codec2Bytes, VoicePacketMode mode) =>
|
||||||
|
Future.error(UnsupportedError('Voice not supported on web'));
|
||||||
|
|
||||||
|
Future<Int16List> decodePackets(
|
||||||
|
List<VoicePacket?> packets,
|
||||||
|
VoicePacketMode mode,
|
||||||
|
) =>
|
||||||
|
Future.error(UnsupportedError('Voice not supported on web'));
|
||||||
|
}
|
||||||
@@ -20,7 +20,7 @@ class _ConnectionDialogState extends State<ConnectionDialog>
|
|||||||
final List<DiscoveredServer> _discoveredServers = [];
|
final List<DiscoveredServer> _discoveredServers = [];
|
||||||
int _scannedCount = 0;
|
int _scannedCount = 0;
|
||||||
int _totalToScan = 0;
|
int _totalToScan = 0;
|
||||||
String? _connectingToServerUrl; // Track which server is being connected to
|
String? _connectingToServerKey; // Track which server is being connected to (ip:port)
|
||||||
|
|
||||||
// Named listener method for proper cleanup
|
// Named listener method for proper cleanup
|
||||||
void _onTabChanged() {
|
void _onTabChanged() {
|
||||||
@@ -361,61 +361,16 @@ class _ConnectionDialogState extends State<ConnectionDialog>
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildNetworkServersTab() {
|
Widget _buildNetworkServersTab() {
|
||||||
final connectionProvider = context.watch<ConnectionProvider>();
|
|
||||||
final bool showingCachedResults =
|
final bool showingCachedResults =
|
||||||
!_networkScanner.isScanning &&
|
!_networkScanner.isScanning &&
|
||||||
_networkScanner.hasCachedResults &&
|
_networkScanner.hasCachedResults &&
|
||||||
_discoveredServers.isNotEmpty;
|
_discoveredServers.isNotEmpty;
|
||||||
final bool isConnectingToSse = connectionProvider.isSseClientConnecting;
|
|
||||||
final int sseReconnectAttempt =
|
|
||||||
connectionProvider.sseClientReconnectionAttempt;
|
|
||||||
final int sseMaxReconnects =
|
|
||||||
connectionProvider.sseClientMaxReconnectionAttempts;
|
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
// SSE Reconnection banner (show when reconnecting)
|
|
||||||
if (isConnectingToSse && sseReconnectAttempt > 0)
|
|
||||||
Container(
|
|
||||||
margin: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Theme.of(context).colorScheme.tertiaryContainer,
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
SizedBox(
|
|
||||||
width: 20,
|
|
||||||
height: 20,
|
|
||||||
child: CircularProgressIndicator(
|
|
||||||
strokeWidth: 2,
|
|
||||||
color: Theme.of(context).colorScheme.onTertiaryContainer,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
'Reconnecting to server... (Attempt $sseReconnectAttempt/$sseMaxReconnects)',
|
|
||||||
style: TextStyle(
|
|
||||||
color: Theme.of(context).colorScheme.onTertiaryContainer,
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// Info banner
|
// Info banner
|
||||||
Container(
|
Container(
|
||||||
margin: EdgeInsets.fromLTRB(
|
margin: const EdgeInsets.fromLTRB(16, 16, 16, 16),
|
||||||
16,
|
|
||||||
isConnectingToSse && sseReconnectAttempt > 0 ? 8 : 16,
|
|
||||||
16,
|
|
||||||
16,
|
|
||||||
),
|
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Theme.of(context).colorScheme.primaryContainer,
|
color: Theme.of(context).colorScheme.primaryContainer,
|
||||||
@@ -432,7 +387,7 @@ class _ConnectionDialogState extends State<ConnectionDialog>
|
|||||||
child: Text(
|
child: Text(
|
||||||
showingCachedResults
|
showingCachedResults
|
||||||
? 'Showing cached results. Tap refresh to rescan.'
|
? 'Showing cached results. Tap refresh to rescan.'
|
||||||
: 'Scanning local network for shared MeshCore devices on port 12929',
|
: 'Scanning local network for MeshCore WiFi devices on port 5000',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
@@ -505,10 +460,11 @@ class _ConnectionDialogState extends State<ConnectionDialog>
|
|||||||
itemCount: _discoveredServers.length,
|
itemCount: _discoveredServers.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final server = _discoveredServers[index];
|
final server = _discoveredServers[index];
|
||||||
|
final serverKey = '${server.ipAddress}:${server.port}';
|
||||||
final isConnectingToThisServer =
|
final isConnectingToThisServer =
|
||||||
_connectingToServerUrl == server.serverUrl;
|
_connectingToServerKey == serverKey;
|
||||||
final isAnyConnectionInProgress =
|
final isAnyConnectionInProgress =
|
||||||
isConnectingToSse || _connectingToServerUrl != null;
|
_connectingToServerKey != null;
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
margin: const EdgeInsets.symmetric(
|
margin: const EdgeInsets.symmetric(
|
||||||
@@ -593,7 +549,7 @@ class _ConnectionDialogState extends State<ConnectionDialog>
|
|||||||
|
|
||||||
// Mark this server as connecting
|
// Mark this server as connecting
|
||||||
setState(() {
|
setState(() {
|
||||||
_connectingToServerUrl = server.serverUrl;
|
_connectingToServerKey = serverKey;
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -607,8 +563,9 @@ class _ConnectionDialogState extends State<ConnectionDialog>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await connectionProvider.connectToSseServer(
|
await connectionProvider.connectTcp(
|
||||||
serverUrl: server.serverUrl,
|
server.ipAddress,
|
||||||
|
server.port,
|
||||||
);
|
);
|
||||||
await appProvider.initialize();
|
await appProvider.initialize();
|
||||||
|
|
||||||
@@ -619,7 +576,7 @@ class _ConnectionDialogState extends State<ConnectionDialog>
|
|||||||
// Clear connecting state on error
|
// Clear connecting state on error
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_connectingToServerUrl = null;
|
_connectingToServerKey = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Clean up error message (remove "Exception: " prefix)
|
// Clean up error message (remove "Exception: " prefix)
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'dart:async';
|
||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_avif/flutter_avif.dart';
|
import 'package:flutter_avif/flutter_avif.dart';
|
||||||
@@ -8,6 +9,7 @@ import '../../providers/connection_provider.dart';
|
|||||||
import '../../providers/contacts_provider.dart';
|
import '../../providers/contacts_provider.dart';
|
||||||
import '../../providers/image_provider.dart' as ip;
|
import '../../providers/image_provider.dart' as ip;
|
||||||
import '../../utils/image_message_parser.dart';
|
import '../../utils/image_message_parser.dart';
|
||||||
|
import 'transfer_timeout.dart';
|
||||||
|
|
||||||
/// A message bubble that shows a received or sent image.
|
/// A message bubble that shows a received or sent image.
|
||||||
///
|
///
|
||||||
@@ -29,8 +31,16 @@ class ImageMessageBubble extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _ImageMessageBubbleState extends State<ImageMessageBubble> {
|
class _ImageMessageBubbleState extends State<ImageMessageBubble> {
|
||||||
|
static const int _maxFetchHops = 3;
|
||||||
bool _isRequesting = false;
|
bool _isRequesting = false;
|
||||||
String? _errorText;
|
String? _errorText;
|
||||||
|
Timer? _requestTimeoutTimer;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_requestTimeoutTimer?.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -82,6 +92,9 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
|
|||||||
received: received,
|
received: received,
|
||||||
total: total,
|
total: total,
|
||||||
envelope: envelope,
|
envelope: envelope,
|
||||||
|
radioBw: radioBw,
|
||||||
|
radioSf: radioSf,
|
||||||
|
radioCr: radioCr,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
@@ -123,6 +136,9 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
|
|||||||
required int received,
|
required int received,
|
||||||
required int total,
|
required int total,
|
||||||
required ImageEnvelope envelope,
|
required ImageEnvelope envelope,
|
||||||
|
required int? radioBw,
|
||||||
|
required int? radioSf,
|
||||||
|
required int? radioCr,
|
||||||
}) {
|
}) {
|
||||||
if (isComplete && imageBytes != null) {
|
if (isComplete && imageBytes != null) {
|
||||||
return AspectRatio(
|
return AspectRatio(
|
||||||
@@ -159,7 +175,13 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
|
|||||||
] else ...[
|
] else ...[
|
||||||
// Tap-to-load icon.
|
// Tap-to-load icon.
|
||||||
IconButton(
|
IconButton(
|
||||||
onPressed: () => _requestAndFetch(envelope),
|
onPressed: () => _requestAndFetch(
|
||||||
|
envelope,
|
||||||
|
radioBw: radioBw,
|
||||||
|
radioSf: radioSf,
|
||||||
|
radioCr: radioCr,
|
||||||
|
pathLen: widget.message.pathLen,
|
||||||
|
),
|
||||||
icon: const Icon(Icons.download_rounded, size: 40),
|
icon: const Icon(Icons.download_rounded, size: 40),
|
||||||
color: Colors.white70,
|
color: Colors.white70,
|
||||||
tooltip: 'Load image',
|
tooltip: 'Load image',
|
||||||
@@ -171,18 +193,57 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _requestAndFetch(ImageEnvelope envelope) async {
|
Future<void> _requestAndFetch(
|
||||||
|
ImageEnvelope envelope, {
|
||||||
|
int? radioBw,
|
||||||
|
int? radioSf,
|
||||||
|
int? radioCr,
|
||||||
|
int pathLen = 0,
|
||||||
|
}) async {
|
||||||
if (_isRequesting) return;
|
if (_isRequesting) return;
|
||||||
final sender = _resolveSender(envelope);
|
var sender = _resolveSender(envelope);
|
||||||
if (sender == null) {
|
if (sender == null) {
|
||||||
setState(() => _errorText = 'Sender not reachable');
|
final conn = context.read<ConnectionProvider>();
|
||||||
|
await conn.getContacts();
|
||||||
|
if (!mounted) return;
|
||||||
|
sender = _resolveSender(envelope);
|
||||||
|
}
|
||||||
|
if (sender == null) {
|
||||||
|
await _showBlockingAlert(
|
||||||
|
'Cannot fetch image',
|
||||||
|
'Sender contact is unknown. Sync contacts first.',
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (sender.outPathLen < 0) {
|
||||||
|
await _showBlockingAlert(
|
||||||
|
'Cannot fetch image',
|
||||||
|
'Sender route is unknown. Sync contacts/path first.',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (sender.outPathLen > _maxFetchHops) {
|
||||||
|
await _showBlockingAlert(
|
||||||
|
'Cannot fetch image',
|
||||||
|
'Message is too far (${sender.outPathLen} hops, max $_maxFetchHops).',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (sender.outPathLen >= 2) {
|
||||||
|
_showToast(
|
||||||
|
'Image fetch over ${sender.outPathLen} hops may take a while.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() => _errorText = null);
|
||||||
final conn = context.read<ConnectionProvider>();
|
final conn = context.read<ConnectionProvider>();
|
||||||
|
final imageProvider = context.read<ip.ImageProvider>();
|
||||||
final deviceKey = conn.deviceInfo.publicKey;
|
final deviceKey = conn.deviceInfo.publicKey;
|
||||||
if (deviceKey == null || deviceKey.length < 6) {
|
if (deviceKey == null || deviceKey.length < 6) {
|
||||||
setState(() => _errorText = 'Device key unavailable');
|
await _showBlockingAlert(
|
||||||
|
'Cannot fetch image',
|
||||||
|
'Device key is unavailable.',
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,11 +251,24 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
|
|||||||
.sublist(0, 6)
|
.sublist(0, 6)
|
||||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||||
.join();
|
.join();
|
||||||
final request = ImageFetchRequest(
|
|
||||||
sessionId: envelope.sessionId,
|
// If we already have some fragments, request only what's missing.
|
||||||
requesterKey6: requesterKey6,
|
final missing = imageProvider.missingFragmentIndices(envelope.sessionId);
|
||||||
timestampSec: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
final isPartialResume =
|
||||||
);
|
missing.isNotEmpty && missing.length < envelope.total;
|
||||||
|
final request = isPartialResume
|
||||||
|
? ImageFetchRequest(
|
||||||
|
sessionId: envelope.sessionId,
|
||||||
|
want: 'missing',
|
||||||
|
missingIndices: missing,
|
||||||
|
requesterKey6: requesterKey6,
|
||||||
|
timestampSec: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||||
|
)
|
||||||
|
: ImageFetchRequest(
|
||||||
|
sessionId: envelope.sessionId,
|
||||||
|
requesterKey6: requesterKey6,
|
||||||
|
timestampSec: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||||
|
);
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_isRequesting = true;
|
_isRequesting = true;
|
||||||
@@ -211,7 +285,31 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
|
|||||||
_isRequesting = false;
|
_isRequesting = false;
|
||||||
_errorText = 'Image unavailable right now';
|
_errorText = 'Image unavailable right now';
|
||||||
});
|
});
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Timeout = 2× estimated LoRa airtime (min 30s).
|
||||||
|
final txEstimate = estimateImageTransmitDuration(
|
||||||
|
fragmentCount: missing.isEmpty ? envelope.total : missing.length,
|
||||||
|
sizeBytes: missing.isEmpty
|
||||||
|
? envelope.sizeBytes
|
||||||
|
: (envelope.sizeBytes * missing.length / envelope.total).round(),
|
||||||
|
pathLen: pathLen,
|
||||||
|
radioBw: radioBw,
|
||||||
|
radioSf: radioSf,
|
||||||
|
radioCr: radioCr,
|
||||||
|
);
|
||||||
|
_requestTimeoutTimer?.cancel();
|
||||||
|
_requestTimeoutTimer = TransferTimeout.start(
|
||||||
|
txEstimate: txEstimate,
|
||||||
|
onTimeout: () {
|
||||||
|
if (mounted &&
|
||||||
|
_isRequesting &&
|
||||||
|
!imageProvider.isComplete(envelope.sessionId)) {
|
||||||
|
setState(() => _isRequesting = false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Contact? _resolveSender(ImageEnvelope envelope) {
|
Contact? _resolveSender(ImageEnvelope envelope) {
|
||||||
@@ -223,7 +321,45 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
|
|||||||
);
|
);
|
||||||
if (c != null) return c;
|
if (c != null) return c;
|
||||||
}
|
}
|
||||||
return contactsProvider.findContactByPrefixHex(envelope.senderKey6);
|
final contact = contactsProvider.findContactByPrefixHex(
|
||||||
|
envelope.senderKey6,
|
||||||
|
);
|
||||||
|
if (contact != null) return contact;
|
||||||
|
|
||||||
|
final senderName = widget.message.senderName?.trim();
|
||||||
|
if (senderName != null && senderName.isNotEmpty) {
|
||||||
|
for (final c in contactsProvider.contacts) {
|
||||||
|
if (c.advName.trim().toLowerCase() == senderName.toLowerCase()) {
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showToast(String message) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(message), duration: const Duration(seconds: 3)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _showBlockingAlert(String title, String message) async {
|
||||||
|
if (!mounted) return;
|
||||||
|
await showDialog<void>(
|
||||||
|
context: context,
|
||||||
|
builder: (dialogContext) => AlertDialog(
|
||||||
|
title: Text(title),
|
||||||
|
content: Text(message),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(dialogContext).pop(),
|
||||||
|
child: const Text('OK'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
static String _statusText({
|
static String _statusText({
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import '../../providers/voice_provider.dart';
|
|||||||
import '../../providers/image_provider.dart' as ip;
|
import '../../providers/image_provider.dart' as ip;
|
||||||
import '../contacts/direct_message_sheet.dart';
|
import '../contacts/direct_message_sheet.dart';
|
||||||
import '../drawing_minimap_preview.dart';
|
import '../drawing_minimap_preview.dart';
|
||||||
|
import '../../models/ble_packet_log.dart';
|
||||||
import '../../services/sar_template_service.dart';
|
import '../../services/sar_template_service.dart';
|
||||||
import '../../utils/toast_logger.dart';
|
import '../../utils/toast_logger.dart';
|
||||||
import '../../utils/sar_message_parser.dart';
|
import '../../utils/sar_message_parser.dart';
|
||||||
@@ -25,6 +26,7 @@ import '../../l10n/app_localizations.dart';
|
|||||||
import '../../utils/message_extensions.dart';
|
import '../../utils/message_extensions.dart';
|
||||||
import 'voice_message_bubble.dart';
|
import 'voice_message_bubble.dart';
|
||||||
import 'image_message_bubble.dart';
|
import 'image_message_bubble.dart';
|
||||||
|
import 'message_trace_sheet.dart';
|
||||||
|
|
||||||
/// Reusable message bubble widget that displays messages with various types:
|
/// Reusable message bubble widget that displays messages with various types:
|
||||||
/// - Regular text messages (channel or direct)
|
/// - Regular text messages (channel or direct)
|
||||||
@@ -56,10 +58,17 @@ class MessageBubble extends StatefulWidget {
|
|||||||
|
|
||||||
class _MessageBubbleState extends State<MessageBubble> {
|
class _MessageBubbleState extends State<MessageBubble> {
|
||||||
bool _isExpanded = false;
|
bool _isExpanded = false;
|
||||||
|
bool _showReceivedStats = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void didUpdateWidget(MessageBubble oldWidget) {
|
void didUpdateWidget(MessageBubble oldWidget) {
|
||||||
super.didUpdateWidget(oldWidget);
|
super.didUpdateWidget(oldWidget);
|
||||||
|
if (oldWidget.message.id != widget.message.id) {
|
||||||
|
_isExpanded = false;
|
||||||
|
_showReceivedStats = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Force rebuild when message properties change (especially recipient statuses)
|
// Force rebuild when message properties change (especially recipient statuses)
|
||||||
if (oldWidget.message.id == widget.message.id) {
|
if (oldWidget.message.id == widget.message.id) {
|
||||||
// Same message, but properties might have changed
|
// Same message, but properties might have changed
|
||||||
@@ -75,6 +84,15 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _handleBubbleTap({required bool isSarMarker, required bool isDrawing}) {
|
||||||
|
if (!widget.isCompact && !isSarMarker && !isDrawing) {
|
||||||
|
setState(() {
|
||||||
|
_showReceivedStats = !_showReceivedStats;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
widget.onTap?.call();
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _retryFailedMessage(
|
Future<void> _retryFailedMessage(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
Message failedMessage,
|
Message failedMessage,
|
||||||
@@ -278,6 +296,17 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
_showTechnicalDetails(context);
|
_showTechnicalDetails(context);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
if (!isOwnMessage &&
|
||||||
|
widget.message.pathLen > 0 &&
|
||||||
|
widget.message.pathLen < 255)
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.route),
|
||||||
|
title: const Text('Trace'),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
_showTraceSheet(context);
|
||||||
|
},
|
||||||
|
),
|
||||||
// Delete message option
|
// Delete message option
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.delete, color: Colors.red),
|
leading: const Icon(Icons.delete, color: Colors.red),
|
||||||
@@ -296,6 +325,18 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _showTraceSheet(BuildContext context) {
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||||
|
shape: const RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
||||||
|
),
|
||||||
|
builder: (context) => MessageTraceSheet(message: widget.message),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
void _showTechnicalDetails(BuildContext context) {
|
void _showTechnicalDetails(BuildContext context) {
|
||||||
final connectionProvider = context.read<ConnectionProvider>();
|
final connectionProvider = context.read<ConnectionProvider>();
|
||||||
final radioBw = connectionProvider.deviceInfo.radioBw;
|
final radioBw = connectionProvider.deviceInfo.radioBw;
|
||||||
@@ -401,9 +442,21 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
?.sublist(0, recipientKey.length < 6 ? recipientKey.length : 6)
|
?.sublist(0, recipientKey.length < 6 ? recipientKey.length : 6)
|
||||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||||
.join('');
|
.join('');
|
||||||
final snrDb = widget.message.lastEchoSnrRaw != null
|
final matchedRxLog = _findBestMatchingRxLog(
|
||||||
? (widget.message.lastEchoSnrRaw!.toSigned(8) / 4.0)
|
connectionProvider.bleService.packetLogs,
|
||||||
: null;
|
widget.message,
|
||||||
|
);
|
||||||
|
final packetPathBytes = _extractPathBytesFromLog(matchedRxLog);
|
||||||
|
final packetPathHex = packetPathBytes
|
||||||
|
?.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||||
|
.join(':');
|
||||||
|
final snrDb =
|
||||||
|
matchedRxLog?.logRxDataInfo?.snrDb ??
|
||||||
|
(widget.message.lastEchoSnrRaw != null
|
||||||
|
? (widget.message.lastEchoSnrRaw!.toSigned(8) / 4.0)
|
||||||
|
: null);
|
||||||
|
final rssiDbm =
|
||||||
|
matchedRxLog?.logRxDataInfo?.rssiDbm ?? widget.message.lastEchoRssiDbm;
|
||||||
|
|
||||||
final rawLines = <String>[
|
final rawLines = <String>[
|
||||||
'Message ID: ${widget.message.id}',
|
'Message ID: ${widget.message.id}',
|
||||||
@@ -415,11 +468,14 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
'Status: ${widget.message.deliveryStatus.name}',
|
'Status: ${widget.message.deliveryStatus.name}',
|
||||||
'Path length (nodes/hops): ${widget.message.pathLen}',
|
'Path length (nodes/hops): ${widget.message.pathLen}',
|
||||||
'Sender timestamp: ${widget.message.senderTimestamp} (${widget.message.sentAt.toIso8601String()})',
|
'Sender timestamp: ${widget.message.senderTimestamp} (${widget.message.sentAt.toIso8601String()})',
|
||||||
'Received at: ${widget.message.receivedAt.toIso8601String()}',
|
'Received at (RFC3339): ${_formatRfc3339(widget.message.receivedAt)}',
|
||||||
'Channel index: ${widget.message.channelIdx ?? '-'}',
|
'Channel index: ${widget.message.channelIdx ?? '-'}',
|
||||||
'Echo count: ${widget.message.echoCount}',
|
'Echo count: ${widget.message.echoCount}',
|
||||||
'Last echo RSSI: ${widget.message.lastEchoRssiDbm ?? '-'}',
|
'Last echo RSSI: ${widget.message.lastEchoRssiDbm ?? '-'}',
|
||||||
'Last echo SNR: ${snrDb?.toStringAsFixed(2) ?? '-'}',
|
'Last echo SNR: ${snrDb?.toStringAsFixed(2) ?? '-'}',
|
||||||
|
'Matched RX RSSI: ${rssiDbm ?? '-'}',
|
||||||
|
'Matched RX SNR: ${snrDb?.toStringAsFixed(2) ?? '-'}',
|
||||||
|
'Matched path bytes: ${packetPathHex ?? '-'}',
|
||||||
'Expected ACK tag: ${widget.message.expectedAckTag ?? '-'}',
|
'Expected ACK tag: ${widget.message.expectedAckTag ?? '-'}',
|
||||||
'Suggested timeout ms: ${widget.message.suggestedTimeoutMs ?? '-'}',
|
'Suggested timeout ms: ${widget.message.suggestedTimeoutMs ?? '-'}',
|
||||||
'Round-trip ms: ${widget.message.roundTripTimeMs ?? '-'}',
|
'Round-trip ms: ${widget.message.roundTripTimeMs ?? '-'}',
|
||||||
@@ -527,304 +583,357 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
ToastLogger.success(context, l10n.textCopiedToClipboard);
|
ToastLogger.success(context, l10n.textCopiedToClipboard);
|
||||||
}
|
}
|
||||||
|
|
||||||
showDialog(
|
showModalBottomSheet(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => AlertDialog(
|
isScrollControlled: true,
|
||||||
title: Text(l10n.messageTechnicalDetails),
|
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||||
content: SizedBox(
|
shape: const RoundedRectangleBorder(
|
||||||
width: double.maxFinite,
|
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
||||||
child: SingleChildScrollView(
|
),
|
||||||
child: Column(
|
builder: (sheetContext) => SafeArea(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
child: SizedBox(
|
||||||
children: [
|
height: MediaQuery.of(sheetContext).size.height * 0.85,
|
||||||
Wrap(
|
child: Column(
|
||||||
spacing: 8,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
runSpacing: 8,
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 14, 8, 8),
|
||||||
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
_techBadge(
|
Expanded(
|
||||||
context,
|
child: Text(
|
||||||
icon: Icons.message,
|
l10n.messageTechnicalDetails,
|
||||||
label: widget.message.messageType.name.toUpperCase(),
|
style: Theme.of(sheetContext).textTheme.titleLarge,
|
||||||
),
|
|
||||||
_techBadge(
|
|
||||||
context,
|
|
||||||
icon: Icons.route,
|
|
||||||
label:
|
|
||||||
'${widget.message.pathLen} hop${widget.message.pathLen == 1 ? '' : 's'}',
|
|
||||||
),
|
|
||||||
_techBadge(
|
|
||||||
context,
|
|
||||||
icon: Icons.account_tree_outlined,
|
|
||||||
label:
|
|
||||||
'${widget.message.echoCount} node${widget.message.echoCount == 1 ? '' : 's'}',
|
|
||||||
),
|
|
||||||
if (widget.message.channelIdx != null)
|
|
||||||
_techBadge(
|
|
||||||
context,
|
|
||||||
icon: Icons.group_work,
|
|
||||||
label: 'CH ${widget.message.channelIdx}',
|
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
onPressed: () => Navigator.pop(sheetContext),
|
||||||
|
icon: const Icon(Icons.close),
|
||||||
|
tooltip: l10n.close,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
if (widget.message.lastEchoRssiDbm != null ||
|
),
|
||||||
snrDb != null) ...[
|
Expanded(
|
||||||
const SizedBox(height: 12),
|
child: SingleChildScrollView(
|
||||||
_techSection(
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
context,
|
child: Column(
|
||||||
icon: Icons.network_check,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
title: l10n.linkQuality,
|
children: [
|
||||||
child: Column(
|
Wrap(
|
||||||
children: [
|
spacing: 8,
|
||||||
if (widget.message.lastEchoRssiDbm != null)
|
runSpacing: 8,
|
||||||
_signalRow(
|
children: [
|
||||||
|
_techBadge(
|
||||||
context,
|
context,
|
||||||
label: 'RSSI',
|
icon: Icons.message,
|
||||||
valueLabel: '${widget.message.lastEchoRssiDbm} dBm',
|
label: widget.message.messageType.name
|
||||||
normalized:
|
.toUpperCase(),
|
||||||
((widget.message.lastEchoRssiDbm!.toDouble() +
|
|
||||||
120.0) /
|
|
||||||
70.0)
|
|
||||||
.clamp(0.0, 1.0),
|
|
||||||
color: widget.message.lastEchoRssiDbm! >= -80
|
|
||||||
? Colors.green
|
|
||||||
: widget.message.lastEchoRssiDbm! >= -95
|
|
||||||
? Colors.amber
|
|
||||||
: Colors.redAccent,
|
|
||||||
),
|
),
|
||||||
if (snrDb != null) ...[
|
_techBadge(
|
||||||
const SizedBox(height: 8),
|
|
||||||
_signalRow(
|
|
||||||
context,
|
context,
|
||||||
label: 'SNR',
|
icon: Icons.route,
|
||||||
valueLabel: '${snrDb.toStringAsFixed(1)} dB',
|
label:
|
||||||
normalized: ((snrDb + 20.0) / 40.0).clamp(0.0, 1.0),
|
'${widget.message.pathLen} hop${widget.message.pathLen == 1 ? '' : 's'}',
|
||||||
color: snrDb >= 10
|
),
|
||||||
? Colors.green
|
_techBadge(
|
||||||
: snrDb >= 0
|
context,
|
||||||
? Colors.amber
|
icon: Icons.account_tree_outlined,
|
||||||
: Colors.redAccent,
|
label:
|
||||||
|
'${widget.message.echoCount} node${widget.message.echoCount == 1 ? '' : 's'}',
|
||||||
|
),
|
||||||
|
if (widget.message.channelIdx != null)
|
||||||
|
_techBadge(
|
||||||
|
context,
|
||||||
|
icon: Icons.group_work,
|
||||||
|
label: 'CH ${widget.message.channelIdx}',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (widget.message.lastEchoRssiDbm != null ||
|
||||||
|
snrDb != null ||
|
||||||
|
rssiDbm != null) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_techSection(
|
||||||
|
context,
|
||||||
|
icon: Icons.network_check,
|
||||||
|
title: l10n.linkQuality,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
if (rssiDbm != null)
|
||||||
|
_signalRow(
|
||||||
|
context,
|
||||||
|
label: 'RSSI',
|
||||||
|
valueLabel: '$rssiDbm dBm',
|
||||||
|
normalized:
|
||||||
|
((rssiDbm.toDouble() + 120.0) / 70.0)
|
||||||
|
.clamp(0.0, 1.0),
|
||||||
|
color: rssiDbm >= -80
|
||||||
|
? Colors.green
|
||||||
|
: rssiDbm >= -95
|
||||||
|
? Colors.amber
|
||||||
|
: Colors.redAccent,
|
||||||
|
),
|
||||||
|
if (snrDb != null) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
_signalRow(
|
||||||
|
context,
|
||||||
|
label: 'SNR',
|
||||||
|
valueLabel: '${snrDb.toStringAsFixed(1)} dB',
|
||||||
|
normalized: ((snrDb + 20.0) / 40.0).clamp(
|
||||||
|
0.0,
|
||||||
|
1.0,
|
||||||
|
),
|
||||||
|
color: snrDb >= 10
|
||||||
|
? Colors.green
|
||||||
|
: snrDb >= 0
|
||||||
|
? Colors.amber
|
||||||
|
: Colors.redAccent,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_techSection(
|
||||||
|
context,
|
||||||
|
icon: Icons.tune,
|
||||||
|
title: l10n.delivery,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
_detailRow(
|
||||||
|
context,
|
||||||
|
label: l10n.status,
|
||||||
|
value: widget.message.deliveryStatus.name,
|
||||||
|
),
|
||||||
|
_detailRow(
|
||||||
|
context,
|
||||||
|
label: 'Received (RFC3339)',
|
||||||
|
value: _formatRfc3339(widget.message.receivedAt),
|
||||||
|
onCopy: () => copyField(
|
||||||
|
_formatRfc3339(widget.message.receivedAt),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (widget.message.expectedAckTag != null)
|
||||||
|
_detailRow(
|
||||||
|
context,
|
||||||
|
label: l10n.expectedAckTag,
|
||||||
|
value: widget.message.expectedAckTag!
|
||||||
|
.toString(),
|
||||||
|
),
|
||||||
|
if (widget.message.roundTripTimeMs != null)
|
||||||
|
_detailRow(
|
||||||
|
context,
|
||||||
|
label: l10n.roundTrip,
|
||||||
|
value: '${widget.message.roundTripTimeMs} ms',
|
||||||
|
),
|
||||||
|
if (widget.message.retryAttempt > 0)
|
||||||
|
_detailRow(
|
||||||
|
context,
|
||||||
|
label: l10n.retryAttempt,
|
||||||
|
value: widget.message.retryAttempt.toString(),
|
||||||
|
),
|
||||||
|
if (widget.message.usedFloodFallback)
|
||||||
|
_detailRow(
|
||||||
|
context,
|
||||||
|
label: l10n.floodFallback,
|
||||||
|
value: l10n.yes,
|
||||||
|
),
|
||||||
|
if (packetPathHex != null)
|
||||||
|
_detailRow(
|
||||||
|
context,
|
||||||
|
label: 'Path bytes',
|
||||||
|
value: packetPathHex,
|
||||||
|
onCopy: () => copyField(packetPathHex),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_techSection(
|
||||||
|
context,
|
||||||
|
icon: Icons.badge,
|
||||||
|
title: l10n.identity,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
_detailRow(
|
||||||
|
context,
|
||||||
|
label: l10n.messageId,
|
||||||
|
value: widget.message.id,
|
||||||
|
onCopy: () => copyField(widget.message.id),
|
||||||
|
),
|
||||||
|
_detailRow(
|
||||||
|
context,
|
||||||
|
label: l10n.sender,
|
||||||
|
value:
|
||||||
|
senderName ??
|
||||||
|
widget.message.senderName ??
|
||||||
|
'Unknown',
|
||||||
|
),
|
||||||
|
if (senderPrefixHex != null)
|
||||||
|
_detailRow(
|
||||||
|
context,
|
||||||
|
label: l10n.senderKey,
|
||||||
|
value: senderPrefixHex,
|
||||||
|
onCopy: () => copyField(senderPrefixHex),
|
||||||
|
),
|
||||||
|
if (recipientName != null)
|
||||||
|
_detailRow(
|
||||||
|
context,
|
||||||
|
label: l10n.recipient,
|
||||||
|
value: recipientName,
|
||||||
|
),
|
||||||
|
if (recipientPrefixHex != null)
|
||||||
|
_detailRow(
|
||||||
|
context,
|
||||||
|
label: l10n.recipientKey,
|
||||||
|
value: recipientPrefixHex,
|
||||||
|
onCopy: () => copyField(recipientPrefixHex),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (widget.message.isVoice) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_techSection(
|
||||||
|
context,
|
||||||
|
icon: Icons.graphic_eq,
|
||||||
|
title: l10n.voice,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
_detailRow(
|
||||||
|
context,
|
||||||
|
label: l10n.voiceId,
|
||||||
|
value: widget.message.voiceId ?? '-',
|
||||||
|
),
|
||||||
|
_detailRow(
|
||||||
|
context,
|
||||||
|
label: l10n.envelope,
|
||||||
|
value: envelope != null
|
||||||
|
? 'VE1 compact'
|
||||||
|
: legacyVoicePacket != null
|
||||||
|
? 'Legacy V packet'
|
||||||
|
: l10n.unknown,
|
||||||
|
),
|
||||||
|
if (voiceSession != null)
|
||||||
|
_detailRow(
|
||||||
|
context,
|
||||||
|
label: l10n.sessionProgress,
|
||||||
|
value:
|
||||||
|
'${voiceSession.receivedCount}/${voiceSession.total} segments',
|
||||||
|
),
|
||||||
|
if (voiceSession != null)
|
||||||
|
_detailRow(
|
||||||
|
context,
|
||||||
|
label: l10n.complete,
|
||||||
|
value: voiceSession.isComplete
|
||||||
|
? l10n.yes
|
||||||
|
: l10n.no,
|
||||||
|
),
|
||||||
|
if (voiceTxEstimate > Duration.zero)
|
||||||
|
_detailRow(
|
||||||
|
context,
|
||||||
|
label: 'Estimated tx',
|
||||||
|
value: voiceTxEstimate.inSeconds < 60
|
||||||
|
? '~${voiceTxEstimate.inSeconds}s'
|
||||||
|
: '~${voiceTxEstimate.inMinutes}m ${voiceTxEstimate.inSeconds % 60}s',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
if (imageEnvelope != null) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_techSection(
|
||||||
|
context,
|
||||||
|
icon: Icons.image_outlined,
|
||||||
|
title: 'Image',
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
_detailRow(
|
||||||
|
context,
|
||||||
|
label: l10n.envelope,
|
||||||
|
value: 'IE1',
|
||||||
|
),
|
||||||
|
_detailRow(
|
||||||
|
context,
|
||||||
|
label: 'Format',
|
||||||
|
value: imageEnvelope.format.label,
|
||||||
|
),
|
||||||
|
_detailRow(
|
||||||
|
context,
|
||||||
|
label: 'Dimensions',
|
||||||
|
value:
|
||||||
|
'${imageEnvelope.width}×${imageEnvelope.height}',
|
||||||
|
),
|
||||||
|
_detailRow(
|
||||||
|
context,
|
||||||
|
label: 'Segments',
|
||||||
|
value: imageSession != null
|
||||||
|
? '${imageSession.receivedCount}/${imageSession.total}'
|
||||||
|
: '${imageEnvelope.total}',
|
||||||
|
),
|
||||||
|
if (imageSession != null)
|
||||||
|
_detailRow(
|
||||||
|
context,
|
||||||
|
label: l10n.complete,
|
||||||
|
value: imageSession.isComplete
|
||||||
|
? l10n.yes
|
||||||
|
: l10n.no,
|
||||||
|
),
|
||||||
|
if (imageTxEstimate > Duration.zero)
|
||||||
|
_detailRow(
|
||||||
|
context,
|
||||||
|
label: 'Estimated tx',
|
||||||
|
value: imageTxEstimate.inSeconds < 60
|
||||||
|
? '~${imageTxEstimate.inSeconds}s'
|
||||||
|
: '~${imageTxEstimate.inMinutes}m ${imageTxEstimate.inSeconds % 60}s',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
ExpansionTile(
|
||||||
|
tilePadding: EdgeInsets.zero,
|
||||||
|
dense: true,
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
title: Text(
|
||||||
|
l10n.rawDump,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.all(10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.surfaceContainerHighest
|
||||||
|
.withValues(alpha: 0.35),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: SelectableText(
|
||||||
|
rawLines.join('\n'),
|
||||||
|
style: const TextStyle(
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
_techSection(
|
|
||||||
context,
|
|
||||||
icon: Icons.tune,
|
|
||||||
title: l10n.delivery,
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
_detailRow(
|
|
||||||
context,
|
|
||||||
label: l10n.status,
|
|
||||||
value: widget.message.deliveryStatus.name,
|
|
||||||
),
|
|
||||||
_detailRow(
|
|
||||||
context,
|
|
||||||
label: l10n.expectedAckTag,
|
|
||||||
value: widget.message.expectedAckTag?.toString() ?? '-',
|
|
||||||
),
|
|
||||||
_detailRow(
|
|
||||||
context,
|
|
||||||
label: l10n.roundTrip,
|
|
||||||
value: widget.message.roundTripTimeMs != null
|
|
||||||
? '${widget.message.roundTripTimeMs} ms'
|
|
||||||
: '-',
|
|
||||||
),
|
|
||||||
_detailRow(
|
|
||||||
context,
|
|
||||||
label: l10n.retryAttempt,
|
|
||||||
value: widget.message.retryAttempt.toString(),
|
|
||||||
),
|
|
||||||
_detailRow(
|
|
||||||
context,
|
|
||||||
label: l10n.floodFallback,
|
|
||||||
value: widget.message.usedFloodFallback
|
|
||||||
? l10n.yes
|
|
||||||
: l10n.no,
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
),
|
||||||
_techSection(
|
],
|
||||||
context,
|
|
||||||
icon: Icons.badge,
|
|
||||||
title: l10n.identity,
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
_detailRow(
|
|
||||||
context,
|
|
||||||
label: l10n.messageId,
|
|
||||||
value: widget.message.id,
|
|
||||||
onCopy: () => copyField(widget.message.id),
|
|
||||||
),
|
|
||||||
_detailRow(
|
|
||||||
context,
|
|
||||||
label: l10n.sender,
|
|
||||||
value: senderName ?? widget.message.senderName ?? '-',
|
|
||||||
),
|
|
||||||
_detailRow(
|
|
||||||
context,
|
|
||||||
label: l10n.senderKey,
|
|
||||||
value: senderPrefixHex ?? '-',
|
|
||||||
onCopy: senderPrefixHex != null
|
|
||||||
? () => copyField(senderPrefixHex)
|
|
||||||
: null,
|
|
||||||
),
|
|
||||||
_detailRow(
|
|
||||||
context,
|
|
||||||
label: l10n.recipient,
|
|
||||||
value: recipientName ?? '-',
|
|
||||||
),
|
|
||||||
_detailRow(
|
|
||||||
context,
|
|
||||||
label: l10n.recipientKey,
|
|
||||||
value: recipientPrefixHex ?? '-',
|
|
||||||
onCopy: recipientPrefixHex != null
|
|
||||||
? () => copyField(recipientPrefixHex)
|
|
||||||
: null,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (widget.message.isVoice) ...[
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
_techSection(
|
|
||||||
context,
|
|
||||||
icon: Icons.graphic_eq,
|
|
||||||
title: l10n.voice,
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
_detailRow(
|
|
||||||
context,
|
|
||||||
label: l10n.voiceId,
|
|
||||||
value: widget.message.voiceId ?? '-',
|
|
||||||
),
|
|
||||||
_detailRow(
|
|
||||||
context,
|
|
||||||
label: l10n.envelope,
|
|
||||||
value: envelope != null
|
|
||||||
? 'VE1 compact'
|
|
||||||
: legacyVoicePacket != null
|
|
||||||
? 'Legacy V packet'
|
|
||||||
: l10n.unknown,
|
|
||||||
),
|
|
||||||
if (voiceSession != null)
|
|
||||||
_detailRow(
|
|
||||||
context,
|
|
||||||
label: l10n.sessionProgress,
|
|
||||||
value:
|
|
||||||
'${voiceSession.receivedCount}/${voiceSession.total} segments',
|
|
||||||
),
|
|
||||||
if (voiceSession != null)
|
|
||||||
_detailRow(
|
|
||||||
context,
|
|
||||||
label: l10n.complete,
|
|
||||||
value: voiceSession.isComplete ? l10n.yes : l10n.no,
|
|
||||||
),
|
|
||||||
if (voiceTxEstimate > Duration.zero)
|
|
||||||
_detailRow(
|
|
||||||
context,
|
|
||||||
label: 'Estimated tx',
|
|
||||||
value: voiceTxEstimate.inSeconds < 60
|
|
||||||
? '~${voiceTxEstimate.inSeconds}s'
|
|
||||||
: '~${voiceTxEstimate.inMinutes}m ${voiceTxEstimate.inSeconds % 60}s',
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
if (imageEnvelope != null) ...[
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
_techSection(
|
|
||||||
context,
|
|
||||||
icon: Icons.image_outlined,
|
|
||||||
title: 'Image',
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
_detailRow(context, label: l10n.envelope, value: 'IE1'),
|
|
||||||
_detailRow(
|
|
||||||
context,
|
|
||||||
label: 'Format',
|
|
||||||
value: imageEnvelope.format.label,
|
|
||||||
),
|
|
||||||
_detailRow(
|
|
||||||
context,
|
|
||||||
label: 'Dimensions',
|
|
||||||
value:
|
|
||||||
'${imageEnvelope.width}×${imageEnvelope.height}',
|
|
||||||
),
|
|
||||||
_detailRow(
|
|
||||||
context,
|
|
||||||
label: 'Segments',
|
|
||||||
value: imageSession != null
|
|
||||||
? '${imageSession.receivedCount}/${imageSession.total}'
|
|
||||||
: '${imageEnvelope.total}',
|
|
||||||
),
|
|
||||||
if (imageSession != null)
|
|
||||||
_detailRow(
|
|
||||||
context,
|
|
||||||
label: l10n.complete,
|
|
||||||
value: imageSession.isComplete ? l10n.yes : l10n.no,
|
|
||||||
),
|
|
||||||
if (imageTxEstimate > Duration.zero)
|
|
||||||
_detailRow(
|
|
||||||
context,
|
|
||||||
label: 'Estimated tx',
|
|
||||||
value: imageTxEstimate.inSeconds < 60
|
|
||||||
? '~${imageTxEstimate.inSeconds}s'
|
|
||||||
: '~${imageTxEstimate.inMinutes}m ${imageTxEstimate.inSeconds % 60}s',
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
ExpansionTile(
|
|
||||||
tilePadding: EdgeInsets.zero,
|
|
||||||
dense: true,
|
|
||||||
visualDensity: VisualDensity.compact,
|
|
||||||
title: Text(
|
|
||||||
l10n.rawDump,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
width: double.infinity,
|
|
||||||
padding: const EdgeInsets.all(10),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Theme.of(context)
|
|
||||||
.colorScheme
|
|
||||||
.surfaceContainerHighest
|
|
||||||
.withValues(alpha: 0.35),
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
),
|
|
||||||
child: SelectableText(
|
|
||||||
rawLines.join('\n'),
|
|
||||||
style: const TextStyle(
|
|
||||||
fontFamily: 'monospace',
|
|
||||||
fontSize: 12,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
actions: [
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => Navigator.pop(context),
|
|
||||||
child: Text(AppLocalizations.of(context)!.close),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -972,6 +1081,64 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String _formatRfc3339(DateTime dateTime) {
|
||||||
|
final utc = dateTime.toUtc();
|
||||||
|
String two(int v) => v.toString().padLeft(2, '0');
|
||||||
|
String four(int v) => v.toString().padLeft(4, '0');
|
||||||
|
final fraction = utc.millisecond == 0
|
||||||
|
? ''
|
||||||
|
: '.${utc.millisecond.toString().padLeft(3, '0')}';
|
||||||
|
|
||||||
|
return '${four(utc.year)}-${two(utc.month)}-${two(utc.day)}'
|
||||||
|
'T${two(utc.hour)}:${two(utc.minute)}:${two(utc.second)}'
|
||||||
|
'${fraction}Z';
|
||||||
|
}
|
||||||
|
|
||||||
|
BlePacketLog? _findBestMatchingRxLog(
|
||||||
|
List<BlePacketLog> logs,
|
||||||
|
Message message,
|
||||||
|
) {
|
||||||
|
if (message.pathLen < 0 || message.pathLen >= 255) return null;
|
||||||
|
final expectedPayloadType = message.messageType == MessageType.channel
|
||||||
|
? 0x05
|
||||||
|
: 0x02;
|
||||||
|
BlePacketLog? bestLog;
|
||||||
|
var bestDeltaMs = 999999999;
|
||||||
|
|
||||||
|
for (final log in logs) {
|
||||||
|
if (log.responseCode != 0x88) continue; // pushLogRxData
|
||||||
|
if (log.rawData.length < 6) continue;
|
||||||
|
|
||||||
|
// Logged frame format:
|
||||||
|
// [0]=response code 0x88, [1]=snrRaw, [2]=rssi, [3]=packet header, [4]=pathLen
|
||||||
|
final raw = log.rawData;
|
||||||
|
final payloadType = (raw[3] >> 2) & 0x0F;
|
||||||
|
final pathLen = raw[4];
|
||||||
|
if (payloadType != expectedPayloadType) continue;
|
||||||
|
if (pathLen != message.pathLen) continue;
|
||||||
|
if (raw.length < 5 + pathLen) continue;
|
||||||
|
|
||||||
|
final deltaMs =
|
||||||
|
(log.timestamp.difference(message.receivedAt).inMilliseconds).abs();
|
||||||
|
if (deltaMs < bestDeltaMs) {
|
||||||
|
bestDeltaMs = deltaMs;
|
||||||
|
bestLog = log;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bestDeltaMs > 30000) return null;
|
||||||
|
return bestLog;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<int>? _extractPathBytesFromLog(BlePacketLog? log) {
|
||||||
|
if (log == null) return null;
|
||||||
|
final raw = log.rawData;
|
||||||
|
if (raw.length < 6) return null;
|
||||||
|
final pathLen = raw[4];
|
||||||
|
if (pathLen <= 0 || raw.length < 5 + pathLen) return null;
|
||||||
|
return raw.sublist(5, 5 + pathLen);
|
||||||
|
}
|
||||||
|
|
||||||
void _showReplySheet(BuildContext context) {
|
void _showReplySheet(BuildContext context) {
|
||||||
// Find the sender contact by public key prefix
|
// Find the sender contact by public key prefix
|
||||||
final contactsProvider = context.read<ContactsProvider>();
|
final contactsProvider = context.read<ContactsProvider>();
|
||||||
@@ -1352,6 +1519,55 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildReceivedSignalStatus(
|
||||||
|
BuildContext context,
|
||||||
|
Message message, {
|
||||||
|
required int? rssiDbm,
|
||||||
|
required double? snrDb,
|
||||||
|
}) {
|
||||||
|
final hopLabel = message.pathLen == 0
|
||||||
|
? 'Direct'
|
||||||
|
: '${message.pathLen} hop${message.pathLen == 1 ? '' : 's'}';
|
||||||
|
|
||||||
|
return Wrap(
|
||||||
|
spacing: 4,
|
||||||
|
runSpacing: 4,
|
||||||
|
crossAxisAlignment: WrapCrossAlignment.center,
|
||||||
|
children: [
|
||||||
|
_techChip(
|
||||||
|
context,
|
||||||
|
icon: Icons.alt_route,
|
||||||
|
label: hopLabel,
|
||||||
|
color: Colors.indigo,
|
||||||
|
),
|
||||||
|
if (rssiDbm != null || snrDb != null) ...[
|
||||||
|
_techChip(
|
||||||
|
context,
|
||||||
|
icon: Icons.bolt,
|
||||||
|
label: _linkQualityLabel(rssiDbm, snrDb),
|
||||||
|
color: _linkQualityColor(_linkQualityLabel(rssiDbm, snrDb)),
|
||||||
|
),
|
||||||
|
if (rssiDbm != null)
|
||||||
|
_signalCapsule(
|
||||||
|
context,
|
||||||
|
icon: Icons.network_cell,
|
||||||
|
label: '$rssiDbm',
|
||||||
|
filled: _rssiScore(rssiDbm),
|
||||||
|
color: Colors.blueGrey,
|
||||||
|
),
|
||||||
|
if (snrDb != null)
|
||||||
|
_signalCapsule(
|
||||||
|
context,
|
||||||
|
icon: Icons.graphic_eq,
|
||||||
|
label: snrDb.toStringAsFixed(1),
|
||||||
|
filled: _snrScore(snrDb),
|
||||||
|
color: Colors.teal,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _techChip(
|
Widget _techChip(
|
||||||
BuildContext context, {
|
BuildContext context, {
|
||||||
required IconData icon,
|
required IconData icon,
|
||||||
@@ -1472,6 +1688,19 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
final selfPublicKey = connectionProvider.deviceInfo.publicKey;
|
final selfPublicKey = connectionProvider.deviceInfo.publicKey;
|
||||||
final isOwnMessage =
|
final isOwnMessage =
|
||||||
message.isSentMessage || message.isFromSelf(selfPublicKey);
|
message.isSentMessage || message.isFromSelf(selfPublicKey);
|
||||||
|
final matchedRxLog = !isOwnMessage
|
||||||
|
? _findBestMatchingRxLog(
|
||||||
|
connectionProvider.bleService.packetLogs,
|
||||||
|
message,
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
final snrDb =
|
||||||
|
matchedRxLog?.logRxDataInfo?.snrDb ??
|
||||||
|
(message.lastEchoSnrRaw != null
|
||||||
|
? (message.lastEchoSnrRaw!.toSigned(8) / 4.0)
|
||||||
|
: null);
|
||||||
|
final rssiDbm =
|
||||||
|
matchedRxLog?.logRxDataInfo?.rssiDbm ?? message.lastEchoRssiDbm;
|
||||||
|
|
||||||
// Look up contact information for rich display name
|
// Look up contact information for rich display name
|
||||||
final contactsProvider = context.read<ContactsProvider>();
|
final contactsProvider = context.read<ContactsProvider>();
|
||||||
@@ -1547,7 +1776,10 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
: recipientDisplayName;
|
: recipientDisplayName;
|
||||||
|
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: widget.onTap,
|
onTap: () => _handleBubbleTap(
|
||||||
|
isSarMarker: isSarMarker,
|
||||||
|
isDrawing: message.isDrawing,
|
||||||
|
),
|
||||||
onLongPress: widget.isCompact ? null : () => _showMessageOptions(context),
|
onLongPress: widget.isCompact ? null : () => _showMessageOptions(context),
|
||||||
child: Container(
|
child: Container(
|
||||||
margin: const EdgeInsets.only(bottom: 8),
|
margin: const EdgeInsets.only(bottom: 8),
|
||||||
@@ -1952,6 +2184,19 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
else if (!message.isDrawing || widget.isCompact)
|
else if (!message.isDrawing || widget.isCompact)
|
||||||
Text(message.text, style: Theme.of(context).textTheme.bodyMedium),
|
Text(message.text, style: Theme.of(context).textTheme.bodyMedium),
|
||||||
|
|
||||||
|
if (!widget.isCompact &&
|
||||||
|
!isSarMarker &&
|
||||||
|
!message.isDrawing &&
|
||||||
|
_showReceivedStats) ...[
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
_buildReceivedSignalStatus(
|
||||||
|
context,
|
||||||
|
message,
|
||||||
|
rssiDbm: rssiDbm,
|
||||||
|
snrDb: snrDb,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
|
||||||
// Delivery status for sent messages (skip in compact mode)
|
// Delivery status for sent messages (skip in compact mode)
|
||||||
if (message.isSentMessage && !widget.isCompact) ...[
|
if (message.isSentMessage && !widget.isCompact) ...[
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
|
|||||||
436
lib/widgets/messages/message_trace_sheet.dart
Normal file
436
lib/widgets/messages/message_trace_sheet.dart
Normal file
@@ -0,0 +1,436 @@
|
|||||||
|
// ignore_for_file: use_null_aware_elements
|
||||||
|
|
||||||
|
import 'dart:math' as math;
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_map/flutter_map.dart' as flutter_map;
|
||||||
|
import 'package:latlong2/latlong.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import '../../models/ble_packet_log.dart';
|
||||||
|
import '../../models/message.dart';
|
||||||
|
import '../../providers/connection_provider.dart';
|
||||||
|
import '../../services/mesh_map_nodes_service.dart';
|
||||||
|
|
||||||
|
class MessageTraceSheet extends StatefulWidget {
|
||||||
|
final Message message;
|
||||||
|
|
||||||
|
const MessageTraceSheet({super.key, required this.message});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<MessageTraceSheet> createState() => _MessageTraceSheetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MessageTraceSheetState extends State<MessageTraceSheet> {
|
||||||
|
late final Future<_TraceResult> _future;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_future = _loadTrace();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<_TraceResult> _loadTrace() async {
|
||||||
|
final connectionProvider = context.read<ConnectionProvider>();
|
||||||
|
final nodes = await MeshMapNodesService.fetchNodes();
|
||||||
|
final packetPath = _extractPathFromPacketLogs(
|
||||||
|
logs: connectionProvider.bleService.packetLogs,
|
||||||
|
message: widget.message,
|
||||||
|
);
|
||||||
|
|
||||||
|
final senderPrefix = _toPrefixHex(widget.message.senderPublicKeyPrefix);
|
||||||
|
final recipientPrefix = widget.message.recipientPublicKey != null
|
||||||
|
? _toPrefixHex(widget.message.recipientPublicKey)
|
||||||
|
: _toPrefixHex(connectionProvider.deviceInfo.publicKey);
|
||||||
|
|
||||||
|
final senderNode = _bestNodeForPrefix(nodes, senderPrefix);
|
||||||
|
final recipientNode = _bestNodeForPrefix(nodes, recipientPrefix);
|
||||||
|
|
||||||
|
if (packetPath != null && packetPath.isNotEmpty) {
|
||||||
|
final matched = _matchNodesFromPathHashes(
|
||||||
|
nodes: nodes,
|
||||||
|
pathHashes: packetPath,
|
||||||
|
senderPrefix: senderPrefix,
|
||||||
|
recipientPrefix: recipientPrefix,
|
||||||
|
);
|
||||||
|
return _TraceResult(
|
||||||
|
mode: TraceMode.packetPath,
|
||||||
|
sender: senderNode,
|
||||||
|
recipient: recipientNode,
|
||||||
|
pathHashes: packetPath,
|
||||||
|
matchedPathNodes: matched,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback when packet path is unavailable.
|
||||||
|
final inferred = _inferRelaysFromHopCount(
|
||||||
|
nodes: nodes,
|
||||||
|
sender: senderNode,
|
||||||
|
recipient: recipientNode,
|
||||||
|
relayCount: math.max(0, widget.message.pathLen),
|
||||||
|
);
|
||||||
|
final matchedPathNodes = <MeshMapNode?>[
|
||||||
|
if (senderNode != null) senderNode,
|
||||||
|
...inferred,
|
||||||
|
if (recipientNode != null) recipientNode,
|
||||||
|
];
|
||||||
|
|
||||||
|
return _TraceResult(
|
||||||
|
mode: TraceMode.hopCountInference,
|
||||||
|
sender: senderNode,
|
||||||
|
recipient: recipientNode,
|
||||||
|
pathHashes: const [],
|
||||||
|
matchedPathNodes: matchedPathNodes,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return SafeArea(
|
||||||
|
child: FutureBuilder<_TraceResult>(
|
||||||
|
future: _future,
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
if (snapshot.connectionState != ConnectionState.done) {
|
||||||
|
return const SizedBox(
|
||||||
|
height: 360,
|
||||||
|
child: Center(child: CircularProgressIndicator()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (snapshot.hasError) {
|
||||||
|
return SizedBox(
|
||||||
|
height: 360,
|
||||||
|
child: Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Text('Failed to load trace: ${snapshot.error}'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final trace = snapshot.data!;
|
||||||
|
final mapPoints = trace.matchedPathNodes
|
||||||
|
.whereType<MeshMapNode>()
|
||||||
|
.map((n) => LatLng(n.latitude, n.longitude))
|
||||||
|
.toList();
|
||||||
|
final hasMapPath = mapPoints.length >= 2;
|
||||||
|
final relayNodes = _relayNodes(trace.matchedPathNodes);
|
||||||
|
|
||||||
|
return SizedBox(
|
||||||
|
height: MediaQuery.of(context).size.height * 0.75,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Center(
|
||||||
|
child: Container(
|
||||||
|
width: 40,
|
||||||
|
height: 4,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(context).dividerColor,
|
||||||
|
borderRadius: BorderRadius.circular(2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 14, 16, 8),
|
||||||
|
child: Text(
|
||||||
|
'Trace',
|
||||||
|
style: Theme.of(context).textTheme.titleLarge,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
child: Text(
|
||||||
|
trace.mode == TraceMode.packetPath
|
||||||
|
? 'Route from packet path bytes'
|
||||||
|
: 'Route inferred from hop count (${widget.message.pathLen})',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
Expanded(
|
||||||
|
child: ListView(
|
||||||
|
padding: const EdgeInsets.only(bottom: 16),
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
child: SizedBox(
|
||||||
|
height: 240,
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
child: DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: Border.all(
|
||||||
|
color: Theme.of(context).dividerColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: hasMapPath
|
||||||
|
? flutter_map.FlutterMap(
|
||||||
|
options: flutter_map.MapOptions(
|
||||||
|
initialCameraFit: flutter_map.CameraFit.bounds(
|
||||||
|
bounds: flutter_map.LatLngBounds.fromPoints(mapPoints),
|
||||||
|
padding: const EdgeInsets.all(28),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
children: [
|
||||||
|
flutter_map.TileLayer(
|
||||||
|
urlTemplate:
|
||||||
|
'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||||
|
userAgentPackageName: 'com.meshcore.sar',
|
||||||
|
),
|
||||||
|
flutter_map.PolylineLayer(
|
||||||
|
polylines: [
|
||||||
|
flutter_map.Polyline(
|
||||||
|
points: mapPoints,
|
||||||
|
strokeWidth: 4,
|
||||||
|
color: Theme.of(context).colorScheme.primary,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
flutter_map.MarkerLayer(
|
||||||
|
markers: trace.matchedPathNodes
|
||||||
|
.whereType<MeshMapNode>()
|
||||||
|
.toList()
|
||||||
|
.asMap()
|
||||||
|
.entries
|
||||||
|
.map(
|
||||||
|
(entry) => flutter_map.Marker(
|
||||||
|
point: LatLng(
|
||||||
|
entry.value.latitude,
|
||||||
|
entry.value.longitude,
|
||||||
|
),
|
||||||
|
width: 34,
|
||||||
|
height: 34,
|
||||||
|
child: CircleAvatar(
|
||||||
|
radius: 16,
|
||||||
|
backgroundColor: entry.key == 0
|
||||||
|
? Colors.green
|
||||||
|
: (entry.key ==
|
||||||
|
trace
|
||||||
|
.matchedPathNodes
|
||||||
|
.whereType<MeshMapNode>()
|
||||||
|
.length -
|
||||||
|
1
|
||||||
|
? Colors.red
|
||||||
|
: Colors.blue),
|
||||||
|
child: Text(
|
||||||
|
'${entry.key + 1}',
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: 11,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
: const Center(
|
||||||
|
child: Text('Not enough geolocated nodes to draw path'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
child: Text(
|
||||||
|
'Relays (${relayNodes.length})',
|
||||||
|
style: Theme.of(context).textTheme.titleMedium,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (relayNodes.isEmpty)
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
|
child: Text('No relay nodes could be matched for this message.'),
|
||||||
|
),
|
||||||
|
...relayNodes.map(
|
||||||
|
(node) => ListTile(
|
||||||
|
leading: const Icon(Icons.router),
|
||||||
|
title: Text(node.name),
|
||||||
|
subtitle: Text(
|
||||||
|
'${node.publicKey.substring(0, math.min(12, node.publicKey.length))} • '
|
||||||
|
'${node.latitude.toStringAsFixed(5)}, ${node.longitude.toStringAsFixed(5)}',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<MeshMapNode> _relayNodes(List<MeshMapNode?> path) {
|
||||||
|
final concrete = path.whereType<MeshMapNode>().toList();
|
||||||
|
if (concrete.length <= 2) return const [];
|
||||||
|
return concrete.sublist(1, concrete.length - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _toPrefixHex(List<int>? key) {
|
||||||
|
if (key == null || key.isEmpty) return null;
|
||||||
|
final take = key.length < 6 ? key.length : 6;
|
||||||
|
return key
|
||||||
|
.take(take)
|
||||||
|
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||||
|
.join()
|
||||||
|
.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
MeshMapNode? _bestNodeForPrefix(List<MeshMapNode> nodes, String? prefixHex) {
|
||||||
|
if (prefixHex == null || prefixHex.isEmpty) return null;
|
||||||
|
final matches = nodes
|
||||||
|
.where((n) => n.publicKey.startsWith(prefixHex))
|
||||||
|
.toList()
|
||||||
|
..sort((a, b) => b.updatedAtMs.compareTo(a.updatedAtMs));
|
||||||
|
return matches.isEmpty ? null : matches.first;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<int>? _extractPathFromPacketLogs({
|
||||||
|
required List<BlePacketLog> logs,
|
||||||
|
required Message message,
|
||||||
|
}) {
|
||||||
|
if (message.pathLen <= 0 || message.pathLen >= 255) return null;
|
||||||
|
final expectedPayloadType = message.messageType == MessageType.channel ? 0x05 : 0x02;
|
||||||
|
BlePacketLog? bestLog;
|
||||||
|
var bestDeltaMs = 999999999;
|
||||||
|
|
||||||
|
for (final log in logs) {
|
||||||
|
if (log.responseCode != 0x88) continue; // pushLogRxData
|
||||||
|
if (log.rawData.length < 6) continue;
|
||||||
|
final raw = log.rawData;
|
||||||
|
final header = raw[3];
|
||||||
|
final payloadType = (header >> 2) & 0x0F;
|
||||||
|
final pathLen = raw[4];
|
||||||
|
if (payloadType != expectedPayloadType) continue;
|
||||||
|
if (pathLen != message.pathLen) continue;
|
||||||
|
if (raw.length < 5 + pathLen) continue;
|
||||||
|
|
||||||
|
final deltaMs = (log.timestamp.difference(message.receivedAt).inMilliseconds).abs();
|
||||||
|
if (deltaMs < bestDeltaMs) {
|
||||||
|
bestDeltaMs = deltaMs;
|
||||||
|
bestLog = log;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bestLog == null || bestDeltaMs > 30000) return null;
|
||||||
|
final raw = bestLog.rawData;
|
||||||
|
final pathLen = raw[4];
|
||||||
|
return raw.sublist(5, 5 + pathLen);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<MeshMapNode?> _matchNodesFromPathHashes({
|
||||||
|
required List<MeshMapNode> nodes,
|
||||||
|
required List<int> pathHashes,
|
||||||
|
required String? senderPrefix,
|
||||||
|
required String? recipientPrefix,
|
||||||
|
}) {
|
||||||
|
final result = <MeshMapNode?>[];
|
||||||
|
for (var i = 0; i < pathHashes.length; i++) {
|
||||||
|
final hashHex = pathHashes[i].toRadixString(16).padLeft(2, '0');
|
||||||
|
final candidates = nodes.where((n) => n.publicKey.startsWith(hashHex)).toList();
|
||||||
|
if (candidates.isEmpty) {
|
||||||
|
result.add(null);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<MeshMapNode> filtered = candidates;
|
||||||
|
if (i == 0 && senderPrefix != null) {
|
||||||
|
final senderMatches = filtered.where((n) => n.publicKey.startsWith(senderPrefix)).toList();
|
||||||
|
if (senderMatches.isNotEmpty) filtered = senderMatches;
|
||||||
|
} else if (i == pathHashes.length - 1 && recipientPrefix != null) {
|
||||||
|
final recipientMatches = filtered
|
||||||
|
.where((n) => n.publicKey.startsWith(recipientPrefix))
|
||||||
|
.toList();
|
||||||
|
if (recipientMatches.isNotEmpty) filtered = recipientMatches;
|
||||||
|
}
|
||||||
|
|
||||||
|
filtered.sort((a, b) => b.updatedAtMs.compareTo(a.updatedAtMs));
|
||||||
|
result.add(filtered.first);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<MeshMapNode> _inferRelaysFromHopCount({
|
||||||
|
required List<MeshMapNode> nodes,
|
||||||
|
required MeshMapNode? sender,
|
||||||
|
required MeshMapNode? recipient,
|
||||||
|
required int relayCount,
|
||||||
|
}) {
|
||||||
|
if (relayCount <= 0 || sender == null || recipient == null) return const [];
|
||||||
|
final candidates = nodes.where((n) {
|
||||||
|
if (sender.publicKey == n.publicKey || recipient.publicKey == n.publicKey) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
final ranked = candidates
|
||||||
|
..sort((a, b) {
|
||||||
|
final da = _distanceToSegmentMeters(
|
||||||
|
p: LatLng(a.latitude, a.longitude),
|
||||||
|
a: LatLng(sender.latitude, sender.longitude),
|
||||||
|
b: LatLng(recipient.latitude, recipient.longitude),
|
||||||
|
);
|
||||||
|
final db = _distanceToSegmentMeters(
|
||||||
|
p: LatLng(b.latitude, b.longitude),
|
||||||
|
a: LatLng(sender.latitude, sender.longitude),
|
||||||
|
b: LatLng(recipient.latitude, recipient.longitude),
|
||||||
|
);
|
||||||
|
return da.compareTo(db);
|
||||||
|
});
|
||||||
|
|
||||||
|
return ranked.take(relayCount).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
double _distanceToSegmentMeters({
|
||||||
|
required LatLng p,
|
||||||
|
required LatLng a,
|
||||||
|
required LatLng b,
|
||||||
|
}) {
|
||||||
|
final ax = a.longitude;
|
||||||
|
final ay = a.latitude;
|
||||||
|
final bx = b.longitude;
|
||||||
|
final by = b.latitude;
|
||||||
|
final px = p.longitude;
|
||||||
|
final py = p.latitude;
|
||||||
|
|
||||||
|
final abx = bx - ax;
|
||||||
|
final aby = by - ay;
|
||||||
|
final apx = px - ax;
|
||||||
|
final apy = py - ay;
|
||||||
|
final ab2 = abx * abx + aby * aby;
|
||||||
|
if (ab2 == 0) {
|
||||||
|
return const Distance().as(LengthUnit.Meter, a, p);
|
||||||
|
}
|
||||||
|
var t = (apx * abx + apy * aby) / ab2;
|
||||||
|
t = t.clamp(0.0, 1.0);
|
||||||
|
final closest = LatLng(ay + aby * t, ax + abx * t);
|
||||||
|
return const Distance().as(LengthUnit.Meter, closest, p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum TraceMode { packetPath, hopCountInference }
|
||||||
|
|
||||||
|
class _TraceResult {
|
||||||
|
final TraceMode mode;
|
||||||
|
final MeshMapNode? sender;
|
||||||
|
final MeshMapNode? recipient;
|
||||||
|
final List<int> pathHashes;
|
||||||
|
final List<MeshMapNode?> matchedPathNodes;
|
||||||
|
|
||||||
|
const _TraceResult({
|
||||||
|
required this.mode,
|
||||||
|
required this.sender,
|
||||||
|
required this.recipient,
|
||||||
|
required this.pathHashes,
|
||||||
|
required this.matchedPathNodes,
|
||||||
|
});
|
||||||
|
}
|
||||||
24
lib/widgets/messages/transfer_timeout.dart
Normal file
24
lib/widgets/messages/transfer_timeout.dart
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
/// Calculates a transfer timeout as 2× the estimated LoRa airtime,
|
||||||
|
/// with a minimum of 30 seconds.
|
||||||
|
///
|
||||||
|
/// Used by [ImageMessageBubble] and [VoiceMessageBubble] to reset the
|
||||||
|
/// "loading" spinner when a transfer stalls, allowing the user to retry.
|
||||||
|
class TransferTimeout {
|
||||||
|
static const Duration _minimum = Duration(seconds: 30);
|
||||||
|
|
||||||
|
/// Start a one-shot timer based on [txEstimate] × 2 (min 30s).
|
||||||
|
///
|
||||||
|
/// [onTimeout] is called on the UI thread when the timer fires.
|
||||||
|
/// Returns the [Timer] so the caller can cancel it (e.g. on dispose or
|
||||||
|
/// when the transfer completes).
|
||||||
|
static Timer start({
|
||||||
|
required Duration txEstimate,
|
||||||
|
required void Function() onTimeout,
|
||||||
|
}) {
|
||||||
|
final timeout = txEstimate * 2;
|
||||||
|
final effective = timeout < _minimum ? _minimum : timeout;
|
||||||
|
return Timer(effective, onTimeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'dart:async';
|
||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
@@ -8,6 +9,7 @@ import '../../providers/connection_provider.dart';
|
|||||||
import '../../providers/contacts_provider.dart';
|
import '../../providers/contacts_provider.dart';
|
||||||
import '../../providers/voice_provider.dart';
|
import '../../providers/voice_provider.dart';
|
||||||
import '../../utils/voice_message_parser.dart';
|
import '../../utils/voice_message_parser.dart';
|
||||||
|
import 'transfer_timeout.dart';
|
||||||
|
|
||||||
/// A message bubble that shows a voice recording with play/stop controls.
|
/// A message bubble that shows a voice recording with play/stop controls.
|
||||||
class VoiceMessageBubble extends StatefulWidget {
|
class VoiceMessageBubble extends StatefulWidget {
|
||||||
@@ -25,9 +27,17 @@ class VoiceMessageBubble extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
|
class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
|
||||||
|
static const int _maxFetchHops = 3;
|
||||||
bool _isRequesting = false;
|
bool _isRequesting = false;
|
||||||
bool _autoPlayWhenReady = false;
|
bool _autoPlayWhenReady = false;
|
||||||
String? _errorText;
|
String? _errorText;
|
||||||
|
Timer? _requestTimeoutTimer;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_requestTimeoutTimer?.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -106,7 +116,14 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
|
|||||||
await voiceProvider.play(voiceId);
|
await voiceProvider.play(voiceId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await _requestAndPlayVoice(voiceId);
|
await _requestAndPlayVoice(
|
||||||
|
voiceId,
|
||||||
|
envelope: envelope,
|
||||||
|
radioBw: radioBw,
|
||||||
|
radioSf: radioSf,
|
||||||
|
radioCr: radioCr,
|
||||||
|
pathLen: widget.message.pathLen,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
borderRadius: BorderRadius.circular(24),
|
borderRadius: BorderRadius.circular(24),
|
||||||
child: Container(
|
child: Container(
|
||||||
@@ -174,18 +191,61 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _requestAndPlayVoice(String sessionId) async {
|
Future<void> _requestAndPlayVoice(
|
||||||
|
String sessionId, {
|
||||||
|
VoiceEnvelope? envelope,
|
||||||
|
int? radioBw,
|
||||||
|
int? radioSf,
|
||||||
|
int? radioCr,
|
||||||
|
int pathLen = 0,
|
||||||
|
}) async {
|
||||||
if (_isRequesting) return;
|
if (_isRequesting) return;
|
||||||
final sender = _resolveSenderContact();
|
var sender = _resolveSenderContact();
|
||||||
if (sender == null) {
|
if (sender == null) {
|
||||||
_setUnavailable();
|
final connectionProvider = context.read<ConnectionProvider>();
|
||||||
|
await connectionProvider.getContacts();
|
||||||
|
if (!mounted) return;
|
||||||
|
sender = _resolveSenderContact();
|
||||||
|
}
|
||||||
|
if (sender == null) {
|
||||||
|
await _showBlockingAlert(
|
||||||
|
'Cannot fetch voice',
|
||||||
|
'Sender contact is unknown. Sync contacts first.',
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (sender.outPathLen < 0) {
|
||||||
|
await _showBlockingAlert(
|
||||||
|
'Cannot fetch voice',
|
||||||
|
'Sender route is unknown. Sync contacts/path first.',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (sender.outPathLen > _maxFetchHops) {
|
||||||
|
await _showBlockingAlert(
|
||||||
|
'Cannot fetch voice',
|
||||||
|
'Message is too far (${sender.outPathLen} hops, max $_maxFetchHops).',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (sender.outPathLen >= 2) {
|
||||||
|
_showToast(
|
||||||
|
'Voice fetch over ${sender.outPathLen} hops may take a while.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_errorText = null;
|
||||||
|
});
|
||||||
|
|
||||||
final connectionProvider = context.read<ConnectionProvider>();
|
final connectionProvider = context.read<ConnectionProvider>();
|
||||||
final deviceKey = connectionProvider.deviceInfo.publicKey;
|
final deviceKey = connectionProvider.deviceInfo.publicKey;
|
||||||
if (deviceKey == null || deviceKey.length < 6) {
|
if (deviceKey == null || deviceKey.length < 6) {
|
||||||
_setUnavailable();
|
await _showBlockingAlert(
|
||||||
|
'Cannot fetch voice',
|
||||||
|
'Device key is unavailable.',
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -213,7 +273,30 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
|
|||||||
);
|
);
|
||||||
if (!sent) {
|
if (!sent) {
|
||||||
_setUnavailable();
|
_setUnavailable();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Timeout = 2× estimated LoRa airtime (min 30s).
|
||||||
|
final txEstimate = envelope != null
|
||||||
|
? estimateVoiceTransmitDuration(
|
||||||
|
packetCount: envelope.total,
|
||||||
|
mode: envelope.mode,
|
||||||
|
durationMs: envelope.durationMs,
|
||||||
|
pathLen: pathLen,
|
||||||
|
radioBw: radioBw,
|
||||||
|
radioSf: radioSf,
|
||||||
|
radioCr: radioCr,
|
||||||
|
)
|
||||||
|
: const Duration(seconds: 15);
|
||||||
|
_requestTimeoutTimer?.cancel();
|
||||||
|
_requestTimeoutTimer = TransferTimeout.start(
|
||||||
|
txEstimate: txEstimate,
|
||||||
|
onTimeout: () {
|
||||||
|
if (mounted && _isRequesting) {
|
||||||
|
_setUnavailable();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _setUnavailable() {
|
void _setUnavailable() {
|
||||||
@@ -243,9 +326,42 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
|
|||||||
if (contact != null) return contact;
|
if (contact != null) return contact;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final senderName = widget.message.senderName?.trim();
|
||||||
|
if (senderName != null && senderName.isNotEmpty) {
|
||||||
|
for (final contact in contactsProvider.contacts) {
|
||||||
|
if (contact.advName.trim().toLowerCase() == senderName.toLowerCase()) {
|
||||||
|
return contact;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _showToast(String message) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(message), duration: const Duration(seconds: 3)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _showBlockingAlert(String title, String message) async {
|
||||||
|
if (!mounted) return;
|
||||||
|
await showDialog<void>(
|
||||||
|
context: context,
|
||||||
|
builder: (dialogContext) => AlertDialog(
|
||||||
|
title: Text(title),
|
||||||
|
content: Text(message),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(dialogContext).pop(),
|
||||||
|
child: const Text('OK'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
static String _formatDuration(double seconds) {
|
static String _formatDuration(double seconds) {
|
||||||
final s = seconds.round();
|
final s = seconds.round();
|
||||||
if (s < 60) return '${s}s';
|
if (s < 60) return '${s}s';
|
||||||
|
|||||||
@@ -883,7 +883,7 @@ packages:
|
|||||||
description:
|
description:
|
||||||
path: "."
|
path: "."
|
||||||
ref: main
|
ref: main
|
||||||
resolved-ref: "624e3d3cf6ea32d8245cc85d5b599f30ca910501"
|
resolved-ref: d6f91774f19136ff71b0087feaf95fa5490524d9
|
||||||
url: "https://github.com/dz0ny/meshcore_client.git"
|
url: "https://github.com/dz0ny/meshcore_client.git"
|
||||||
source: git
|
source: git
|
||||||
version: "0.1.0"
|
version: "0.1.0"
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
|||||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||||
# In Windows, build-name is used as the major, minor, and patch parts
|
# In Windows, build-name is used as the major, minor, and patch parts
|
||||||
# of the product and file versions while build-number is used as the build suffix.
|
# of the product and file versions while build-number is used as the build suffix.
|
||||||
version: 2026.0302.1+4
|
version: 2026.0305.1+7
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.9.2
|
sdk: ^3.9.2
|
||||||
|
|||||||
Reference in New Issue
Block a user