Compare commits

...

46 Commits

Author SHA1 Message Date
zjs81 64d75dde45 chore: update version to 7.0.0+8 in pubspec.yaml 2026-03-14 18:46:29 -07:00
zjs81 9199aab7f7 Merge pull request #297 from zjs81/dev-improments
Improvements to path tracing and location handling
2026-03-14 18:42:58 -07:00
zjs81 60e8ee0130 fix: simplify method call for writing data in UsbSerialService 2026-03-14 18:41:57 -07:00
zjs81 6dfb7a4b69 fix: auto-add flag parsing, contact cache restore, and USB reconnect
- Fix operator precedence bug in _handleAutoAddConfig where `flags &
  flag != 0` was parsed as `flags & (flag != 0)`, always checking bit 0
  instead of the correct flag bit
- Populate _contacts from cache in loadContactCache() so contacts
  persist across app restarts
- Toggle DTR low→high on USB connect to force device to see a fresh
  connection
- Add 10ms inter-frame delay for USB sends to prevent missed responses
- Deassert DTR before closing USB port on disconnect/dispose

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-14 18:41:21 -07:00
zjs81 28a423e0a8 fix: correct location validation and clean up target contact handling
- Fix asymmetric lat/lon validation in _handleContactAdvert (was checking
  longitude != 0 for latitude; now uses (latitude != 0 || longitude != 0)
  for both)
- Remove duplicate targetGuessed assignment in path_trace_map
- Rename public target field to private _targetContact, use local variable
  to avoid unnecessary null-aware operators

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-14 18:14:39 -07:00
Winston Lowe 3593cfa843 Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-03-14 18:10:44 -07:00
Winston Lowe dc85e7a41c Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-03-14 18:10:17 -07:00
Winston Lowe 9265daaf16 Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-03-14 18:10:09 -07:00
Winston Lowe 4b744184c2 Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-03-14 18:09:54 -07:00
zjs81 64698e0be6 Merge pull request #295 from ericszimmermann/ez_group_dropdown3
squashed PR for Dropdown Group Menu
2026-03-14 18:05:22 -07:00
zjs81 3dd9037be3 Merge remote-tracking branch 'origin/main' into ez_group_dropdown3
# Conflicts:
#	lib/main.dart
2026-03-14 18:02:31 -07:00
zjs81 566e3aadf8 fix: migrate filter menus to type-safe generics and harden popup dismissal
- Move ContactSortOption/ContactTypeFilter enums to dedicated
  contact_filter_types.dart (re-exported from contact_search.dart)
- Migrate ContactsFilterMenu and DiscoveryContactsFilterMenu to use
  sealed class action types with SortFilterMenu<T> generics, replacing
  int action constants and switch statements
- Guard _closeDropdownAndRun with ModalRoute.isCurrent check to prevent
  accidental dismissal of parent routes

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-14 17:59:48 -07:00
Winston Lowe 06a906f4f7 Enhance location handling and improve path trace functionality across screens 2026-03-14 17:51:24 -07:00
zjs81 054a84031e Merge pull request #296 from zjs81/feature/ml-timeout-prediction
feat: add ML-based adaptive timeout prediction using LinearRegressor
2026-03-14 17:39:22 -07:00
zjs81 fffcff3b74 fix: cancel persist timer on dispose to prevent post-dispose writes 2026-03-14 17:39:01 -07:00
zjs81 b336aedbc5 fix: address PR #296 code review feedback
- Clamp ML predictions between physics floor (raw airtime) and ceiling
  (worst-case formula) so model can never produce unsafe timeouts
- Replace hourOfDay feature with secondsSinceLastRx for network activity
- Remove unused _ContactStats.stdDev and dead model persistence code
- Debounce observation writes (2s) instead of writing on every delivery
- Skip recording observations when pathLength is null to avoid corrupting
  training data
- Add comment explaining global (not per-contact) RX time tracking
- Remove notifyListeners from retrain to avoid unnecessary widget rebuilds
- Run dart format
2026-03-14 17:32:08 -07:00
zjs81 2ee2358ecc feat: add ML-based adaptive timeout prediction using LinearRegressor
Train a linear regression model on actual message delivery times to
predict tighter timeouts, replacing worst-case physics estimates.
Features: path length, message bytes, seconds since last RX, flood mode.
Global model with per-contact blending after 10+ observations per contact.
Falls back to existing physics formula when model has insufficient data.
2026-03-14 16:56:11 -07:00
ericz 86e9b7fe01 squashed commit of ez_group_dropdown 2026-03-15 00:34:09 +01:00
Winston Lowe 24fa78741b add TCP server address and port settings to AppSettings and update TcpScreen 2026-03-14 11:46:05 -07:00
Winston Lowe 79a45c527b Unify contact retrieval by introducing allContacts getter 2026-03-14 11:45:47 -07:00
zjs81 8b280b37be Merge pull request #293 from zjs81/map-set-location-and-connector-improvements
feat: add set-as-my-location from map long-press, connector and UI
2026-03-14 09:55:02 -07:00
zjs81 fa4da979af feat: enhance location update feedback and improve message retry error handling 2026-03-14 09:54:50 -07:00
zjs81 91608ff09e feat: improve message matching logic and update notification IDs for advertisements 2026-03-14 09:44:37 -07:00
zjs81 71f59d23df feat: add set-as-my-location from map long-press, connector and UI improvements
Add "Set as my location" option to the map long-press bottom sheet,
allowing users to set their device position directly from the map.
Includes connector, chat, contacts, and message retry service improvements.
2026-03-14 09:33:37 -07:00
zjs81 e90742be25 Merge pull request #272 from just-stuff-tm/tcp
feat: Add TCP connection support and UI integration
2026-03-13 11:04:11 -07:00
Zach db935a7454 refactor(tcp): promote MeshCoreTcpConnector, fix translations, harden UI
- Replace thin MeshCoreTcpManager facade with a proper MeshCoreTcpConnector
  that owns TcpTransportService and the frame subscription, mirroring
  MeshCoreUsbManager. The connector no longer holds a raw TcpTransportService
  or a _tcpFrameSubscription field.
- Remove hardcoded default host IP from TcpScreen (keep port 5000 hint).
- Disable connect button during scanning state, not just connecting state.
- Fix tcpPortLabel mistranslated as nautical "port/harbor" in de, it, pt,
  nl, sv, sk, sl, zh; fix corrupted Slovak tcpPortHint ("5 000" → "5000").
- Remove unused tcpStatus_connecting string from all 15 locale arb files
  and all generated app_localizations_*.dart files.
- Add extendedPadding to TCP screen FABs to match USB screen.
- Add Key to connect button; update tests to use byKey and assert
  onPressed == null when button is disabled during scanning.
2026-03-13 10:59:09 -07:00
Winston Lowe 1ad5db27ca Merge branch 'main' into tcp 2026-03-12 23:22:30 -07:00
Winston Lowe 81758adc61 Dev discovery (#291)
* Refactor contact handling: replace DiscoveryContact with Contact, update related methods and settings

* Enhance contact handling: include latitude, longitude, and last modified timestamp in contact updates; refactor path handling to accommodate discovered contacts across multiple screens

* Enhance SNRIndicator: include discovered contacts in name resolution for repeaters

* Refactor path handling: replace addReturnPath with buildPath to improve path construction logic and handle target contact types

* Update lib/screens/map_screen.dart

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Add localization for "Show Discovery Contacts" in multiple languages and refactor location plausibility check in map screen

* Enhance contact management: update discovered contacts' active status and improve contact handling with flags and raw packet data

* Refactor ChannelsScreen: pass ChannelMessageStore to buildExpandedContent and ensure messages are cleared after channel creation

* Update MapScreen: adjust label zoom threshold and refactor guessed marker building to include labels

* Refactor ChannelsScreen: change channelMessageStore to a private getter and update its usage in buildExpandedContent calls

* Enhance location plausibility check: add latitude and longitude bounds to ensure valid coordinates

* Update lib/connector/meshcore_connector.dart

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Refactor MeshCoreConnector and related stores: update discovered contacts handling, migrate legacy keys, and set public key in community store

* Refactor MeshCoreConnector and ChannelsScreen: update discovered contacts handling and set public key in community store; enhance location plausibility check in MapScreen

* Update CMD_ADD_UPDATE_CONTACT frame format to include optional latitude and longitude fields

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-12 23:08:46 -07:00
Winston Lowe c81791cf1e Migrate legacy storage keys to scoped keys in various store classes (#289) 2026-03-12 08:39:17 -07:00
Winston Lowe 1fba5312a2 Refactor storage classes to include companion's public key (#277)
* Refactor storage classes to include public key handling and improve data loading/saving logic

* Remove redundant publicKeyHex handling from ContactDiscoveryStore and fix key reference in saveContacts method

* Remove unused app_logger import from ContactDiscoveryStore

* Add warning log for empty publicKeyHex in saveChannelMessages method

* Add warning log for empty publicKeyHex in clearMessages method

* Migrate legacy storage keys to scoped keys across multiple stores

* Remove legacy unscoped keys during migration in storage classes

* Update lib/storage/contact_store.dart

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-12 00:14:48 -07:00
just-stuff-tm 2f770bbd53 fix(tcp): reset state on aborted pre-handshake connect 2026-03-10 21:38:35 -04:00
just-stuff-tm 9db79e9d40 test(tcp): harden cancel-race handling and add coverage
- tighten late TCP connect error suppression to manual-cancel disconnecting/disconnected windows
- keep TCP handshake failures surfaced outside explicit cancel flow
- allow TcpScreen connect action when connector is scanning
- add connector-level tests for late-error suppression classifier
- add TcpScreen test covering connect from scanning state
2026-03-10 20:06:05 -04:00
just-stuff-tm 1913a5aa11 fix(tcp): guard connect cancellation race and align USB screen actions
- add connectTcp cancellation guards after socket connect and connect delay so handshake does not proceed when transport/state changed
- ignore late TCP connect errors after manual cancel or transport switch to avoid spurious second disconnect paths
- keep TCP action hidden only on web and show Bluetooth action on USB screen across platforms for navigation consistency
2026-03-10 19:27:39 -04:00
just-stuff-tm 929c1c3d28 fix(tcp): cancel pending connects on disconnect and propagate remote close 2026-03-09 20:39:17 -04:00
just-stuff-tm 7a2bb20bf7 feat: Add TCP connection support and UI integration
- Implemented TCP transport service for native platforms.
- Added TCP connection screen with input fields for host and port.
- Integrated TCP connection options into the scanner and USB screens.
- Updated localization files for new TCP-related strings.
- Added tests for TCP connection flow and error handling.
- Enhanced USB screen to include TCP connection option.
- Improved layout to ensure no overflow in narrow widths for scanner and USB screens.
2026-03-07 20:07:19 -05:00
zjs81 a1b77bb29b Merge pull request #269 from zjs81/dev-latLonFix
Changed contacts latitude and longitude fields to be null until parsed and set
2026-03-07 13:53:09 -07:00
zjs81 4eecfc92dc Merge pull request #252 from just-stuff-tm/feature/usb
Feature/usb
2026-03-07 13:16:39 -07:00
zjs81 90c8cf5f3e Add TODO to switch flserial to official repo 2026-03-07 13:12:45 -07:00
zjs81 06fa176367 Narrow macOS sandbox entitlement to /dev/cu. and /dev/tty. only
The /dev/ prefix granted read/write to all device nodes. The app only
needs access to serial port devices (/dev/cu.* and /dev/tty.*) for USB
LoRa communication.
2026-03-07 13:10:42 -07:00
zjs81 e4285774a0 Merge branch 'main' into feature/usb 2026-03-07 13:03:15 -07:00
zjs81 b2da695102 Run dart format 2026-03-07 13:01:27 -07:00
zjs81 e1327a93c7 Fix contact sync fallback when channel 0 never arrives
On web BLE, contact sync is deferred until channel 0 arrives via
_handleChannelInfo. If channel 0 times out or channel sync completes
without it, _pendingInitialContactsSync stays true and contacts never
load. Add fallback in _cleanupChannelSync to trigger getContacts() if
the flag is still set when channel sync ends.
2026-03-07 13:00:23 -07:00
zjs81 421bc71bb7 Enhance USB port opening and reading logic with improved error handling and debug logging 2026-03-07 12:55:15 -07:00
Winston Lowe 84ec139ce6 Add latitude and longitude fields to contact handling in MeshCoreConnector 2026-03-07 11:02:47 -08:00
Winston Lowe b748b96237 Enhance contact handling logic in MeshCoreConnector to support conditional addition based on auto-add settings (#268) 2026-03-07 01:45:53 -08:00
Winston Lowe c2671ac2ae Refactor data handling of contacts (#267)
* Refactor data handling in MeshCoreConnector and BufferReader for improved readability and efficiency

* Update lib/connector/meshcore_connector.dart

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Fix pointer tracking in BufferReader's readCString method

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-07 01:23:46 -08:00
94 changed files with 5763 additions and 1157 deletions
+1
View File
@@ -0,0 +1 @@
6.2.4
+461 -110
View File
@@ -2,7 +2,6 @@ import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'package:crypto/crypto.dart' as crypto; import 'package:crypto/crypto.dart' as crypto;
import 'package:meshcore_open/models/discovery_contact.dart';
import 'package:pointycastle/export.dart'; import 'package:pointycastle/export.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter_blue_plus/flutter_blue_plus.dart'; import 'package:flutter_blue_plus/flutter_blue_plus.dart';
@@ -20,8 +19,10 @@ import '../services/message_retry_service.dart';
import '../services/path_history_service.dart'; import '../services/path_history_service.dart';
import '../services/app_settings_service.dart'; import '../services/app_settings_service.dart';
import '../services/background_service.dart'; import '../services/background_service.dart';
import '../services/timeout_prediction_service.dart';
import '../services/notification_service.dart'; import '../services/notification_service.dart';
import 'meshcore_connector_usb.dart'; import 'meshcore_connector_usb.dart';
import 'meshcore_connector_tcp.dart';
import '../storage/channel_message_store.dart'; import '../storage/channel_message_store.dart';
import '../storage/channel_order_store.dart'; import '../storage/channel_order_store.dart';
import '../storage/channel_settings_store.dart'; import '../storage/channel_settings_store.dart';
@@ -86,7 +87,7 @@ enum MeshCoreConnectionState {
disconnecting, disconnecting,
} }
enum MeshCoreTransportType { bluetooth, usb } enum MeshCoreTransportType { bluetooth, usb, tcp }
class RepeaterBatterySnapshot { class RepeaterBatterySnapshot {
final int millivolts; final int millivolts;
@@ -116,11 +117,12 @@ class MeshCoreConnector extends ChangeNotifier {
bool _manualDisconnect = false; bool _manualDisconnect = false;
final MeshCoreUsbManager _usbManager = MeshCoreUsbManager(); final MeshCoreUsbManager _usbManager = MeshCoreUsbManager();
StreamSubscription<Uint8List>? _usbFrameSubscription; StreamSubscription<Uint8List>? _usbFrameSubscription;
final MeshCoreTcpConnector _tcpConnector = MeshCoreTcpConnector();
MeshCoreTransportType _activeTransport = MeshCoreTransportType.bluetooth; MeshCoreTransportType _activeTransport = MeshCoreTransportType.bluetooth;
final List<ScanResult> _scanResults = []; final List<ScanResult> _scanResults = [];
final List<Contact> _contacts = []; final List<Contact> _contacts = [];
final List<DiscoveryContact> _discoveredContacts = []; final List<Contact> _discoveredContacts = [];
final List<Channel> _channels = []; final List<Channel> _channels = [];
final Map<String, List<Message>> _conversations = {}; final Map<String, List<Message>> _conversations = {};
final Map<int, List<ChannelMessage>> _channelMessages = {}; final Map<int, List<ChannelMessage>> _channelMessages = {};
@@ -165,6 +167,10 @@ class MeshCoreConnector extends ChangeNotifier {
bool _isLoadingContacts = false; bool _isLoadingContacts = false;
bool _isLoadingChannels = false; bool _isLoadingChannels = false;
bool _hasLoadedChannels = false; bool _hasLoadedChannels = false;
TimeoutPredictionService? _timeoutPredictionService;
// Intentionally global (not per-contact): tracks overall network activity.
// Frequent RX from any source indicates a busy network with more collisions.
DateTime _lastRxTime = DateTime.now();
bool _batteryRequested = false; bool _batteryRequested = false;
bool _awaitingSelfInfo = false; bool _awaitingSelfInfo = false;
bool _hasReceivedDeviceInfo = false; bool _hasReceivedDeviceInfo = false;
@@ -198,6 +204,9 @@ class MeshCoreConnector extends ChangeNotifier {
int _queueSyncRetries = 0; int _queueSyncRetries = 0;
static const int _maxQueueSyncRetries = 3; static const int _maxQueueSyncRetries = 3;
static const int _queueSyncTimeoutMs = 5000; // 5 second timeout static const int _queueSyncTimeoutMs = 5000; // 5 second timeout
// Serializes path operations (setContactPath/clearContactPath) to prevent
// interleaved async calls from leaving in-memory state inconsistent with device.
Future<void> _pathOpLock = Future.value();
Map<String, String>? _currentCustomVars; Map<String, String>? _currentCustomVars;
// Channel syncing state (sequential pattern) // Channel syncing state (sequential pattern)
@@ -255,6 +264,10 @@ class MeshCoreConnector extends ChangeNotifier {
bool get isUsbTransportConnected => bool get isUsbTransportConnected =>
_state == MeshCoreConnectionState.connected && _state == MeshCoreConnectionState.connected &&
_activeTransport == MeshCoreTransportType.usb; _activeTransport == MeshCoreTransportType.usb;
String? get activeTcpEndpoint => _tcpConnector.activeEndpoint;
bool get isTcpTransportConnected =>
_state == MeshCoreConnectionState.connected &&
_activeTransport == MeshCoreTransportType.tcp;
String get deviceDisplayName { String get deviceDisplayName {
if (_selfName != null && _selfName!.isNotEmpty) { if (_selfName != null && _selfName!.isNotEmpty) {
@@ -281,7 +294,11 @@ class MeshCoreConnector extends ChangeNotifier {
); );
} }
List<DiscoveryContact> get discoveredContacts { List<Contact> get allContacts => List.unmodifiable([
..._contacts,
..._discoveredContacts.where((c) => !c.isActive),
]);
List<Contact> get discoveredContacts {
return List.unmodifiable(_discoveredContacts); return List.unmodifiable(_discoveredContacts);
} }
@@ -291,6 +308,7 @@ class MeshCoreConnector extends ChangeNotifier {
bool get isLoadingChannels => _isLoadingChannels; bool get isLoadingChannels => _isLoadingChannels;
Stream<Uint8List> get receivedFrames => _receivedFramesController.stream; Stream<Uint8List> get receivedFrames => _receivedFramesController.stream;
Uint8List? get selfPublicKey => _selfPublicKey; Uint8List? get selfPublicKey => _selfPublicKey;
String get selfPublicKeyHex => pubKeyToHex(_selfPublicKey ?? Uint8List(0));
String? get selfName => _selfName; String? get selfName => _selfName;
double? get selfLatitude => _selfLatitude; double? get selfLatitude => _selfLatitude;
double? get selfLongitude => _selfLongitude; double? get selfLongitude => _selfLongitude;
@@ -552,6 +570,10 @@ class MeshCoreConnector extends ChangeNotifier {
_unreadStore.saveContactUnreadCount( _unreadStore.saveContactUnreadCount(
Map<String, int>.from(_contactUnreadCount), Map<String, int>.from(_contactUnreadCount),
); );
_notificationService.clearContactNotification(
contactKeyHex,
getTotalUnreadCount(),
);
notifyListeners(); notifyListeners();
} }
} }
@@ -570,6 +592,10 @@ class MeshCoreConnector extends ChangeNotifier {
_channels.isNotEmpty ? _channels : _cachedChannels, _channels.isNotEmpty ? _channels : _cachedChannels,
), ),
); );
_notificationService.clearChannelNotification(
channelIndex,
getTotalUnreadCount(),
);
notifyListeners(); notifyListeners();
} }
} }
@@ -651,6 +677,7 @@ class MeshCoreConnector extends ChangeNotifier {
BleDebugLogService? bleDebugLogService, BleDebugLogService? bleDebugLogService,
AppDebugLogService? appDebugLogService, AppDebugLogService? appDebugLogService,
BackgroundService? backgroundService, BackgroundService? backgroundService,
TimeoutPredictionService? timeoutPredictionService,
}) { }) {
_retryService = retryService; _retryService = retryService;
_pathHistoryService = pathHistoryService; _pathHistoryService = pathHistoryService;
@@ -658,7 +685,9 @@ class MeshCoreConnector extends ChangeNotifier {
_bleDebugLogService = bleDebugLogService; _bleDebugLogService = bleDebugLogService;
_appDebugLogService = appDebugLogService; _appDebugLogService = appDebugLogService;
_backgroundService = backgroundService; _backgroundService = backgroundService;
_timeoutPredictionService = timeoutPredictionService;
_usbManager.setDebugLogService(_appDebugLogService); _usbManager.setDebugLogService(_appDebugLogService);
_tcpConnector.setDebugLogService(_appDebugLogService);
// Initialize notification service // Initialize notification service
_notificationService.initialize(); _notificationService.initialize();
@@ -671,13 +700,28 @@ class MeshCoreConnector extends ChangeNotifier {
updateMessageCallback: _updateMessage, updateMessageCallback: _updateMessage,
clearContactPathCallback: clearContactPath, clearContactPathCallback: clearContactPath,
setContactPathCallback: setContactPath, setContactPathCallback: setContactPath,
calculateTimeoutCallback: (pathLength, messageBytes) => calculateTimeoutCallback:
calculateTimeout(pathLength: pathLength, messageBytes: messageBytes), (pathLength, messageBytes, {String? contactKey}) => calculateTimeout(
pathLength: pathLength,
messageBytes: messageBytes,
contactKey: contactKey,
),
getSelfPublicKeyCallback: () => _selfPublicKey, getSelfPublicKeyCallback: () => _selfPublicKey,
prepareContactOutboundTextCallback: prepareContactOutboundText, prepareContactOutboundTextCallback: prepareContactOutboundText,
appSettingsService: appSettingsService, appSettingsService: appSettingsService,
debugLogService: _appDebugLogService, debugLogService: _appDebugLogService,
recordPathResultCallback: _recordPathResult, recordPathResultCallback: _recordPathResult,
onDeliveryObservedCallback:
(contactKey, pathLength, messageBytes, tripTimeMs) {
final secSinceRx = DateTime.now().difference(_lastRxTime).inSeconds;
_timeoutPredictionService?.recordObservation(
contactKey: contactKey,
pathLength: pathLength,
messageBytes: messageBytes,
tripTimeMs: tripTimeMs,
secondsSinceLastRx: secSinceRx,
);
},
); );
} }
@@ -686,12 +730,15 @@ class MeshCoreConnector extends ChangeNotifier {
_knownContactKeys _knownContactKeys
..clear() ..clear()
..addAll(cached.map((c) => c.publicKeyHex)); ..addAll(cached.map((c) => c.publicKeyHex));
_contacts
..clear()
..addAll(cached);
for (final contact in cached) { for (final contact in cached) {
_ensureContactSmazSettingLoaded(contact.publicKeyHex); _ensureContactSmazSettingLoaded(contact.publicKeyHex);
} }
} }
Future<void> loadDiscoveredContactCache() async { Future<void> _loadDiscoveredContactCache() async {
final cached = await _discoveryContactStore.loadContacts(); final cached = await _discoveryContactStore.loadContacts();
_discoveredContacts _discoveredContacts
..clear() ..clear()
@@ -906,10 +953,7 @@ class MeshCoreConnector extends ChangeNotifier {
try { try {
await _usbFrameSubscription?.cancel(); await _usbFrameSubscription?.cancel();
_usbFrameSubscription = null; _usbFrameSubscription = null;
_appDebugLogService?.info( _appDebugLogService?.info('connectUsb: opening serial port…', tag: 'USB');
'connectUsb: opening serial port…',
tag: 'USB',
);
await _usbManager.connect(portName: portName, baudRate: baudRate); await _usbManager.connect(portName: portName, baudRate: baudRate);
_appDebugLogService?.info( _appDebugLogService?.info(
'connectUsb: serial port opened, label=${_usbManager.activePortDisplayLabel}', 'connectUsb: serial port opened, label=${_usbManager.activePortDisplayLabel}',
@@ -967,6 +1011,142 @@ class MeshCoreConnector extends ChangeNotifier {
} }
} }
Future<void> connectTcp({required String host, required int port}) async {
if (_state == MeshCoreConnectionState.connecting ||
_state == MeshCoreConnectionState.connected) {
_appDebugLogService?.warn(
'connectTcp ignored: already $_state',
tag: 'TCP',
);
return;
}
_appDebugLogService?.info('connectTcp: endpoint=$host:$port', tag: 'TCP');
await stopScan();
_cancelReconnectTimer();
_manualDisconnect = false;
_resetConnectionHandshakeState();
_activeTransport = MeshCoreTransportType.tcp;
_setState(MeshCoreConnectionState.connecting);
try {
Future<void> handleTcpConnectAbort({required String message}) async {
_appDebugLogService?.warn(message, tag: 'TCP');
final shouldResetState = shouldResetStateAfterTcpConnectAbort(
state: _state,
activeTransport: _activeTransport,
);
if (shouldResetState) {
await disconnect(manual: false);
return;
}
if (_tcpConnector.isConnected) {
await _tcpConnector.disconnect();
}
}
await _tcpConnector.cancelFrameSubscription();
await _tcpConnector.connect(host: host, port: port);
final isTcpConnectCancelled =
_activeTransport != MeshCoreTransportType.tcp ||
_state != MeshCoreConnectionState.connecting ||
!_tcpConnector.isConnected;
if (isTcpConnectCancelled) {
await handleTcpConnectAbort(
message:
'connectTcp aborted before handshake: state=$_state transport=$_activeTransport connected=${_tcpConnector.isConnected}',
);
return;
}
notifyListeners();
await Future<void>.delayed(const Duration(milliseconds: 200));
final isTcpConnectCancelledAfterDelay =
_activeTransport != MeshCoreTransportType.tcp ||
_state != MeshCoreConnectionState.connecting ||
!_tcpConnector.isConnected;
if (isTcpConnectCancelledAfterDelay) {
await handleTcpConnectAbort(
message:
'connectTcp aborted after connect delay: state=$_state transport=$_activeTransport connected=${_tcpConnector.isConnected}',
);
return;
}
_tcpConnector.listenFrames(
onFrame: _handleFrame,
onError: (error, stackTrace) {
_appDebugLogService?.error('TCP transport error: $error', tag: 'TCP');
unawaited(disconnect(manual: false));
},
onDone: () {
_appDebugLogService?.warn('TCP frame stream ended', tag: 'TCP');
unawaited(disconnect(manual: false));
},
);
_setState(MeshCoreConnectionState.connected);
_pendingInitialChannelSync = true;
await _requestDeviceInfo();
_startBatteryPolling();
var gotSelfInfo = await _waitForSelfInfo(
timeout: const Duration(seconds: 3),
);
if (!gotSelfInfo) {
await refreshDeviceInfo();
gotSelfInfo = await _waitForSelfInfo(
timeout: const Duration(seconds: 3),
);
}
if (!gotSelfInfo) {
throw StateError('Timed out waiting for SELF_INFO during TCP connect');
}
await syncTime();
} catch (error) {
_appDebugLogService?.error('TCP connection error: $error', tag: 'TCP');
final tcpConnectCancelledBeforeHandshake =
shouldIgnoreLateTcpConnectError(
manualDisconnect: _manualDisconnect,
state: _state,
activeTransport: _activeTransport,
tcpManagerConnected: _tcpConnector.isConnected,
);
if (tcpConnectCancelledBeforeHandshake) {
_appDebugLogService?.info(
'Ignoring late TCP connect error after cancellation/switch: state=$_state transport=$_activeTransport',
tag: 'TCP',
);
return;
}
await disconnect(manual: false);
rethrow;
}
}
@visibleForTesting
static bool shouldIgnoreLateTcpConnectError({
required bool manualDisconnect,
required MeshCoreConnectionState state,
required MeshCoreTransportType activeTransport,
required bool tcpManagerConnected,
}) {
return manualDisconnect &&
(state == MeshCoreConnectionState.disconnected ||
state == MeshCoreConnectionState.disconnecting) &&
(activeTransport != MeshCoreTransportType.tcp || !tcpManagerConnected);
}
@visibleForTesting
static bool shouldResetStateAfterTcpConnectAbort({
required MeshCoreConnectionState state,
required MeshCoreTransportType activeTransport,
}) {
return state == MeshCoreConnectionState.connecting &&
activeTransport == MeshCoreTransportType.tcp;
}
Future<void> connect(BluetoothDevice device, {String? displayName}) async { Future<void> connect(BluetoothDevice device, {String? displayName}) async {
if (_state == MeshCoreConnectionState.connecting || if (_state == MeshCoreConnectionState.connecting ||
_state == MeshCoreConnectionState.connected) { _state == MeshCoreConnectionState.connected) {
@@ -1196,7 +1376,6 @@ class MeshCoreConnector extends ChangeNotifier {
await _requestDeviceInfo(); await _requestDeviceInfo();
_startBatteryPolling(); _startBatteryPolling();
unawaited(loadDiscoveredContactCache());
final gotSelfInfo = await _waitForSelfInfo( final gotSelfInfo = await _waitForSelfInfo(
timeout: const Duration(seconds: 3), timeout: const Duration(seconds: 3),
@@ -1233,6 +1412,7 @@ class MeshCoreConnector extends ChangeNotifier {
bool get _shouldGateInitialChannelSync => bool get _shouldGateInitialChannelSync =>
_activeTransport == MeshCoreTransportType.usb || _activeTransport == MeshCoreTransportType.usb ||
_activeTransport == MeshCoreTransportType.tcp ||
(_activeTransport == MeshCoreTransportType.bluetooth && (_activeTransport == MeshCoreTransportType.bluetooth &&
PlatformInfo.isWeb); PlatformInfo.isWeb);
@@ -1279,9 +1459,11 @@ class MeshCoreConnector extends ChangeNotifier {
Future<void> disconnect({bool manual = true}) async { Future<void> disconnect({bool manual = true}) async {
if (_state == MeshCoreConnectionState.disconnecting) return; if (_state == MeshCoreConnectionState.disconnecting) return;
final transportAtDisconnect = _activeTransport; final transportAtDisconnect = _activeTransport;
final transportLabel = transportAtDisconnect == MeshCoreTransportType.usb final transportLabel = switch (transportAtDisconnect) {
? 'USB' MeshCoreTransportType.bluetooth => 'BLE',
: 'BLE'; MeshCoreTransportType.usb => 'USB',
MeshCoreTransportType.tcp => 'TCP',
};
_appDebugLogService?.info( _appDebugLogService?.info(
'Starting disconnect transport=$transportLabel manual=$manual', 'Starting disconnect transport=$transportLabel manual=$manual',
@@ -1301,6 +1483,7 @@ class MeshCoreConnector extends ChangeNotifier {
await _usbFrameSubscription?.cancel(); await _usbFrameSubscription?.cancel();
_usbFrameSubscription = null; _usbFrameSubscription = null;
await _usbManager.disconnect(); await _usbManager.disconnect();
await _tcpConnector.disconnect();
await _notifySubscription?.cancel(); await _notifySubscription?.cancel();
_notifySubscription = null; _notifySubscription = null;
@@ -1382,6 +1565,12 @@ class MeshCoreConnector extends ChangeNotifier {
if (_activeTransport == MeshCoreTransportType.usb) { if (_activeTransport == MeshCoreTransportType.usb) {
await _usbManager.write(data); await _usbManager.write(data);
// Brief pause so the device firmware can process each frame before the
// next arrives. Without this, rapid-fire frames over USB can cause the
// device to miss responses (especially on reconnect).
await Future<void>.delayed(const Duration(milliseconds: 10));
} else if (_activeTransport == MeshCoreTransportType.tcp) {
await _tcpConnector.write(data);
} else { } else {
if (_rxCharacteristic == null) { if (_rxCharacteristic == null) {
throw Exception("MeshCore RX characteristic not available"); throw Exception("MeshCore RX characteristic not available");
@@ -1595,18 +1784,33 @@ class MeshCoreConnector extends ChangeNotifier {
Uint8List customPath, Uint8List customPath,
int pathLen, int pathLen,
) async { ) async {
if (!isConnected) return; // Serialize path operations to prevent interleaved async calls from
// leaving in-memory state inconsistent with the device.
final prev = _pathOpLock;
final completer = Completer<void>();
_pathOpLock = completer.future;
await prev;
try {
if (!isConnected) return;
await sendFrame( await sendFrame(
buildUpdateContactPathFrame( buildUpdateContactPathFrame(
contact.publicKey, contact.publicKey,
customPath, customPath,
pathLen, pathLen,
type: contact.type, type: contact.type,
flags: contact.flags, flags: contact.flags,
name: contact.name, name: contact.name,
), ),
); );
// USB writes return instantly (no BLE flow control), so give the firmware
// time to persist the path change before subsequent commands.
if (_activeTransport == MeshCoreTransportType.usb) {
await Future<void>.delayed(const Duration(milliseconds: 100));
}
} finally {
completer.complete();
}
} }
Future<void> setContactFavorite(Contact contact, bool isFavorite) async { Future<void> setContactFavorite(Contact contact, bool isFavorite) async {
@@ -1906,7 +2110,11 @@ class MeshCoreConnector extends ChangeNotifier {
Future<void> removeContact(Contact contact) async { Future<void> removeContact(Contact contact) async {
if (!isConnected) return; if (!isConnected) return;
_handleDiscovery(contact, Uint8List(0), noNotify: true); _handleDiscovery(
contact,
contact.rawPacket ?? Uint8List(0),
noNotify: true,
);
await sendFrame(buildRemoveContactFrame(contact.publicKey)); await sendFrame(buildRemoveContactFrame(contact.publicKey));
_contacts.removeWhere((c) => c.publicKeyHex == contact.publicKeyHex); _contacts.removeWhere((c) => c.publicKeyHex == contact.publicKeyHex);
@@ -1922,7 +2130,20 @@ class MeshCoreConnector extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
Future<void> removeDiscoveredContact(DiscoveryContact contact) async { Future<void> updateKnownDiscovered() async {
if (!isConnected) return;
for (int i = 0; i < _discoveredContacts.length; i++) {
_discoveredContacts[i] = _discoveredContacts[i].copyWith(
isActive: _knownContactKeys.contains(
_discoveredContacts[i].publicKeyHex,
),
);
}
unawaited(_persistDiscoveredContacts());
notifyListeners();
}
Future<void> removeDiscoveredContact(Contact contact) async {
if (!isConnected) return; if (!isConnected) return;
_discoveredContacts.removeWhere( _discoveredContacts.removeWhere(
(c) => c.publicKeyHex == contact.publicKeyHex, (c) => c.publicKeyHex == contact.publicKeyHex,
@@ -1931,7 +2152,7 @@ class MeshCoreConnector extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
Future<void> importDiscoveredContact(DiscoveryContact contact) async { Future<void> importDiscoveredContact(Contact contact) async {
if (!isConnected) return; if (!isConnected) return;
await sendFrame( await sendFrame(
@@ -1940,11 +2161,23 @@ class MeshCoreConnector extends ChangeNotifier {
contact.path, contact.path,
contact.pathLength, contact.pathLength,
type: contact.type, type: contact.type,
flags: 0, flags: contact.flags,
name: contact.name, name: contact.name,
lat: contact.latitude,
lon: contact.longitude,
lastModified: contact.lastSeen,
), ),
); );
// Update the discovered contact to mark it as active (imported)
final discoveredIndex = _discoveredContacts.indexWhere(
(c) => c.publicKeyHex == contact.publicKeyHex,
);
if (discoveredIndex >= 0) {
_discoveredContacts[discoveredIndex] =
_discoveredContacts[discoveredIndex].copyWith(isActive: true);
}
_handleContactAdvert( _handleContactAdvert(
Contact( Contact(
publicKey: contact.publicKey, publicKey: contact.publicKey,
@@ -1952,30 +2185,44 @@ class MeshCoreConnector extends ChangeNotifier {
type: contact.type, type: contact.type,
pathLength: contact.pathLength, pathLength: contact.pathLength,
path: contact.path, path: contact.path,
latitude: contact.latitude,
longitude: contact.longitude,
lastSeen: DateTime.now(), lastSeen: DateTime.now(),
flags: contact.flags,
), ),
); );
notifyListeners(); notifyListeners();
} }
Future<void> clearContactPath(Contact contact) async { Future<void> clearContactPath(Contact contact) async {
if (!isConnected) return; // Serialize path operations to prevent interleaved async calls.
final prev = _pathOpLock;
final completer = Completer<void>();
_pathOpLock = completer.future;
await prev;
try {
if (!isConnected) return;
await sendFrame(buildResetPathFrame(contact.publicKey)); await sendFrame(buildResetPathFrame(contact.publicKey));
final existingIndex = _contacts.indexWhere( if (_activeTransport == MeshCoreTransportType.usb) {
(c) => c.publicKeyHex == contact.publicKeyHex, await Future<void>.delayed(const Duration(milliseconds: 100));
); }
if (existingIndex >= 0) { final existingIndex = _contacts.indexWhere(
final existing = _contacts[existingIndex]; (c) => c.publicKeyHex == contact.publicKeyHex,
// Use copyWith to preserve pathOverride and pathOverrideBytes
_contacts[existingIndex] = existing.copyWith(
pathLength: -1,
path: Uint8List(0),
); );
notifyListeners(); if (existingIndex >= 0) {
unawaited(_persistContacts()); final existing = _contacts[existingIndex];
// Preserve pathOverride and pathOverrideBytes — only reset device path
_contacts[existingIndex] = existing.copyWith(
pathLength: -1,
path: Uint8List(0),
);
notifyListeners();
unawaited(_persistContacts());
}
} finally {
completer.complete();
} }
// The device will send updated contact info with path_len = -1
} }
void updateContactInMemory( void updateContactInMemory(
@@ -2250,6 +2497,14 @@ class MeshCoreConnector extends ChangeNotifier {
_hasLoadedChannels = true; _hasLoadedChannels = true;
_previousChannelsCache.clear(); _previousChannelsCache.clear();
} }
// Fallback: if contact sync was deferred waiting for channel 0 but
// channel sync finished without triggering it, start contacts now.
if (_pendingInitialContactsSync && isConnected) {
_pendingInitialContactsSync = false;
unawaited(getContacts());
}
// Keep cache on failure/disconnection for future attempts // Keep cache on failure/disconnection for future attempts
} }
@@ -2276,6 +2531,7 @@ class MeshCoreConnector extends ChangeNotifier {
void _handleFrame(List<int> data) { void _handleFrame(List<int> data) {
if (data.isEmpty) return; if (data.isEmpty) return;
_lastRxTime = DateTime.now();
final frame = Uint8List.fromList(data); final frame = Uint8List.fromList(data);
_receivedFramesController.add(frame); _receivedFramesController.add(frame);
@@ -2303,10 +2559,13 @@ class MeshCoreConnector extends ChangeNotifier {
_isLoadingContacts = true; _isLoadingContacts = true;
notifyListeners(); notifyListeners();
break; break;
case pushCodeAdvert:
// Known contact was seen again - just a pub key, no action needed
break;
case pushCodeNewAdvert: case pushCodeNewAdvert:
debugPrint('Got New CONTACT'); debugPrint('Got New CONTACT');
// It's the same format as respCodeContact, so we can reuse the handler // It's the same format as respCodeContact, so we can reuse the handler
_handleContact(frame); _handleContact(frame, isContact: false);
break; break;
case respCodeContact: case respCodeContact:
debugPrint('Got CONTACT'); debugPrint('Got CONTACT');
@@ -2316,6 +2575,7 @@ class MeshCoreConnector extends ChangeNotifier {
debugPrint('Got END_OF_CONTACTS'); debugPrint('Got END_OF_CONTACTS');
_isLoadingContacts = false; _isLoadingContacts = false;
_preserveContactsOnRefresh = false; _preserveContactsOnRefresh = false;
unawaited(updateKnownDiscovered());
notifyListeners(); notifyListeners();
unawaited(_persistContacts()); unawaited(_persistContacts());
if (PlatformInfo.isWeb && if (PlatformInfo.isWeb &&
@@ -2331,7 +2591,8 @@ class MeshCoreConnector extends ChangeNotifier {
} }
if (_pendingDeferredChannelSyncAfterContacts && if (_pendingDeferredChannelSyncAfterContacts &&
(_activeTransport == MeshCoreTransportType.bluetooth || (_activeTransport == MeshCoreTransportType.bluetooth ||
_activeTransport == MeshCoreTransportType.usb)) { _activeTransport == MeshCoreTransportType.usb ||
_activeTransport == MeshCoreTransportType.tcp)) {
_pendingDeferredChannelSyncAfterContacts = false; _pendingDeferredChannelSyncAfterContacts = false;
_pendingInitialChannelSync = false; _pendingInitialChannelSync = false;
unawaited(getChannels()); unawaited(getChannels());
@@ -2482,6 +2743,28 @@ class MeshCoreConnector extends ChangeNotifier {
selfName.isNotEmpty) { selfName.isNotEmpty) {
_usbManager.updateConnectedLabel(selfName); _usbManager.updateConnectedLabel(selfName);
} }
//set all the stores' public key so they can load the correct data
_channelMessageStore.setPublicKeyHex = selfPublicKeyHex;
_messageStore.setPublicKeyHex = selfPublicKeyHex;
_channelOrderStore.setPublicKeyHex = selfPublicKeyHex;
_channelSettingsStore.setPublicKeyHex = selfPublicKeyHex;
_contactSettingsStore.setPublicKeyHex = selfPublicKeyHex;
_contactStore.setPublicKeyHex = selfPublicKeyHex;
_channelStore.setPublicKeyHex = selfPublicKeyHex;
_unreadStore.setPublicKeyHex = selfPublicKeyHex;
// Now that we have self info, we can load all the persisted data for this node
_loadChannelOrder();
loadContactCache();
loadChannelSettings();
loadCachedChannels();
// Load persisted channel messages
loadAllChannelMessages();
loadUnreadState();
_loadDiscoveredContactCache();
_awaitingSelfInfo = false; _awaitingSelfInfo = false;
_selfInfoRetryTimer?.cancel(); _selfInfoRetryTimer?.cancel();
_selfInfoRetryTimer = null; _selfInfoRetryTimer = null;
@@ -2498,14 +2781,16 @@ class MeshCoreConnector extends ChangeNotifier {
if (PlatformInfo.isWeb && if (PlatformInfo.isWeb &&
_activeTransport == MeshCoreTransportType.bluetooth) { _activeTransport == MeshCoreTransportType.bluetooth) {
_pendingInitialContactsSync = true; _pendingInitialContactsSync = true;
} else if (_activeTransport == MeshCoreTransportType.usb) { } else if (_activeTransport == MeshCoreTransportType.usb ||
_activeTransport == MeshCoreTransportType.tcp) {
_pendingDeferredChannelSyncAfterContacts = true; _pendingDeferredChannelSyncAfterContacts = true;
getContacts(); getContacts();
} else { } else {
getContacts(); getContacts();
} }
if (_shouldGateInitialChannelSync && if (_shouldGateInitialChannelSync &&
_activeTransport != MeshCoreTransportType.usb) { _activeTransport != MeshCoreTransportType.usb &&
_activeTransport != MeshCoreTransportType.tcp) {
_maybeStartInitialChannelSync(); _maybeStartInitialChannelSync();
} }
} }
@@ -2623,41 +2908,73 @@ class MeshCoreConnector extends ChangeNotifier {
} }
} }
/// Calculate timeout for a message based on radio settings and path length /// Estimate single-packet airtime in ms from radio settings, or a fallback.
/// Returns timeout in milliseconds, considering number of hops int _estimateAirtimeMs(int messageBytes) {
int calculateTimeout({required int pathLength, int messageBytes = 100}) {
// If we have radio settings, use them for accurate calculation
if (_currentFreqHz != null && if (_currentFreqHz != null &&
_currentBwHz != null && _currentBwHz != null &&
_currentSf != null && _currentSf != null &&
_currentCr != null) { _currentCr != null) {
final cr = _currentCr! <= 4 ? _currentCr! : _currentCr! - 4; final cr = _currentCr! <= 4 ? _currentCr! : _currentCr! - 4;
return calculateMessageTimeout( return calculateLoRaAirtime(
freqHz: _currentFreqHz!, payloadBytes: messageBytes,
bwHz: _currentBwHz!, spreadingFactor: _currentSf!,
sf: _currentSf!, bandwidthHz: _currentBwHz!,
cr: cr, codingRate: cr,
pathLength: pathLength, lowDataRateOptimize: _currentSf! >= 11,
messageBytes: messageBytes,
); );
} }
return 50; // fallback: ~SF7/BW125 for 100 bytes
}
// Fallback: Conservative estimates based on typical settings /// Physics-based worst-case timeout (ceiling).
// Assume SF7, BW125, which gives ~50ms airtime for 100 bytes int _physicsMaxTimeout(int pathLength, int airtime) {
const estimatedAirtime = 50;
if (pathLength < 0) { if (pathLength < 0) {
// Flood mode: Base delay + 16× airtime return 500 + (16 * airtime);
return 500 + (16 * estimatedAirtime);
} else { } else {
// Direct path: Base delay + ((airtime×6 + 250ms)×(hops+1)) return 500 + ((airtime * 6 + 250) * (pathLength + 1));
return 500 + ((estimatedAirtime * 6 + 250) * (pathLength + 1));
} }
} }
void _handleContact(Uint8List frame) { /// Physics-based minimum timeout (floor): raw traversal time.
int _physicsMinTimeout(int pathLength, int airtime) {
if (pathLength < 0) {
return airtime;
} else {
return airtime * (pathLength + 1);
}
}
/// Calculate timeout for a message based on radio settings and path length.
/// Returns timeout in milliseconds, considering number of hops.
int calculateTimeout({
required int pathLength,
int messageBytes = 100,
String? contactKey,
}) {
final airtime = _estimateAirtimeMs(messageBytes);
final physicsMin = _physicsMinTimeout(pathLength, airtime);
final physicsMax = _physicsMaxTimeout(pathLength, airtime);
// Try ML-based prediction, clamped between physics bounds
final secSinceRx = DateTime.now().difference(_lastRxTime).inSeconds;
final mlTimeout = _timeoutPredictionService?.predictTimeout(
contactKey: contactKey,
pathLength: pathLength,
messageBytes: messageBytes,
secondsSinceLastRx: secSinceRx,
);
if (mlTimeout != null) {
return mlTimeout.clamp(physicsMin, physicsMax);
}
return physicsMax;
}
void _handleContact(Uint8List frame, {bool isContact = true}) {
final contact = Contact.fromFrame(frame); final contact = Contact.fromFrame(frame);
if (contact != null) { if (contact != null) {
_handleDiscovery(contact, frame, noNotify: true, addActive: true);
if (contact.type == advTypeRepeater) { if (contact.type == advTypeRepeater) {
_contactUnreadCount.remove(contact.publicKeyHex); _contactUnreadCount.remove(contact.publicKeyHex);
_unreadStore.saveContactUnreadCount( _unreadStore.saveContactUnreadCount(
@@ -2694,11 +3011,23 @@ class MeshCoreConnector extends ChangeNotifier {
tag: 'Connector', tag: 'Connector',
); );
} else { } else {
_contacts.add(contact); if ((_autoAddUsers && contact.type == advTypeChat) ||
appLogger.info( (_autoAddRepeaters && contact.type == advTypeRepeater) ||
'Added new contact ${contact.name}: pathLen=${contact.pathLength}', (_autoAddRoomServers && contact.type == advTypeRoom) ||
tag: 'Connector', (_autoAddSensors && contact.type == advTypeSensor) ||
); isContact) {
_contacts.add(contact);
appLogger.info(
'Added new contact ${contact.name}: pathLen=${contact.pathLength}',
tag: 'Connector',
);
} else {
appLogger.info(
"Discovered contact ${contact.name} (type ${contact.typeLabel}) not added due to auto-add settings",
tag: 'Connector',
);
return;
}
} }
_knownContactKeys.add(contact.publicKeyHex); _knownContactKeys.add(contact.publicKeyHex);
_loadMessagesForContact(contact.publicKeyHex); _loadMessagesForContact(contact.publicKeyHex);
@@ -4261,6 +4590,7 @@ class MeshCoreConnector extends ChangeNotifier {
_batteryPollTimer?.cancel(); _batteryPollTimer?.cancel();
_receivedFramesController.close(); _receivedFramesController.close();
_usbManager.dispose(); _usbManager.dispose();
_tcpConnector.dispose();
// Flush pending unread writes before disposal // Flush pending unread writes before disposal
_unreadStore.flush(); _unreadStore.flush();
@@ -4270,44 +4600,40 @@ class MeshCoreConnector extends ChangeNotifier {
void _handleRxData(Uint8List frame) { void _handleRxData(Uint8List frame) {
final packet = BufferReader(frame); final packet = BufferReader(frame);
double snr = 0.0;
int routeType = 0;
int payloadType = 0;
Uint8List pathBytes = Uint8List(0);
Uint8List payload = Uint8List(0);
try { try {
packet.skipBytes(1); // Skip frame type byte packet.skipBytes(1); // Skip frame type byte
snr = packet.readInt8() / 4.0; final snr = packet.readInt8() / 4.0;
packet.skipBytes(1); // Skip RSSI byte packet.skipBytes(1); // Skip RSSI byte
//final rssi = packet.readByte(); //final rssi = packet.readByte();
final header = packet.readByte(); final header = packet.readByte();
routeType = header & 0x03; final routeType = header & 0x03;
payloadType = (header >> 2) & 0x0F; final payloadType = (header >> 2) & 0x0F;
if (routeType == _routeTransportFlood || if (routeType == _routeTransportFlood ||
routeType == _routeTransportDirect) { routeType == _routeTransportDirect) {
packet.skipBytes(4); // Skip transport-specific bytes packet.skipBytes(4); // Skip transport-specific bytes
} }
//final payloadVer = (header >> 6) & 0x03; //final payloadVer = (header >> 6) & 0x03;
final pathLen = packet.readByte(); final pathLen = packet.readByte();
pathBytes = packet.readBytes(pathLen); final pathBytes = packet.readBytes(pathLen);
payload = packet.readBytes(packet.remaining); final payload = packet.readBytes(packet.remaining);
final rawPacket = frame.sublist(3);
switch (payloadType) {
case payloadTypeADVERT:
_handlePayloadAdvertReceived(
rawPacket,
payload,
pathBytes,
routeType,
snr,
);
break;
default:
}
} catch (e) { } catch (e) {
appLogger.warn('Malformed RX frame: $e', tag: 'Connector'); appLogger.warn('Malformed RX frame: $e', tag: 'Connector');
return; return;
} }
final rawPacket = frame.sublist(3);
switch (payloadType) {
case payloadTypeADVERT:
_handlePayloadAdvertReceived(
rawPacket,
payload,
pathBytes,
routeType,
snr,
);
break;
default:
}
} }
void importContact(Uint8List frame) { void importContact(Uint8List frame) {
@@ -4332,8 +4658,8 @@ class MeshCoreConnector extends ChangeNotifier {
appLogger.warn('Malformed RX frame: $e', tag: 'Connector'); appLogger.warn('Malformed RX frame: $e', tag: 'Connector');
return; return;
} }
double latitude = 0.0; double? latitude;
double longitude = 0.0; double? longitude;
String name = ''; String name = '';
Uint8List publicKey = Uint8List(0); Uint8List publicKey = Uint8List(0);
int type = 0; int type = 0;
@@ -4369,12 +4695,12 @@ class MeshCoreConnector extends ChangeNotifier {
} }
importDiscoveredContact( importDiscoveredContact(
DiscoveryContact( Contact(
rawPacket: frame, rawPacket: frame,
publicKey: publicKey, publicKey: publicKey,
name: name, name: name,
type: type, type: type,
pathLength: pathBytes.length, pathLength: pathBytes.isEmpty ? -1 : pathBytes.length,
path: Uint8List.fromList( path: Uint8List.fromList(
pathBytes.reversed.toList(), pathBytes.reversed.toList(),
), // Store path in reverse for easier use in outgoing messages ), // Store path in reverse for easier use in outgoing messages
@@ -4393,8 +4719,8 @@ class MeshCoreConnector extends ChangeNotifier {
double snr, double snr,
) { ) {
final advert = BufferReader(payload); final advert = BufferReader(payload);
double latitude = 0.0; double? latitude;
double longitude = 0.0; double? longitude;
String name = ''; String name = '';
String contactKeyHex = ''; String contactKeyHex = '';
Uint8List publicKey = Uint8List(0); Uint8List publicKey = Uint8List(0);
@@ -4440,6 +4766,7 @@ class MeshCoreConnector extends ChangeNotifier {
if (isNewContact) { if (isNewContact) {
final newContact = Contact( final newContact = Contact(
rawPacket: rawPacket,
publicKey: publicKey, publicKey: publicKey,
name: name, name: name,
type: type, type: type,
@@ -4456,6 +4783,12 @@ class MeshCoreConnector extends ChangeNotifier {
(_autoAddRoomServers && type == advTypeRoom) || (_autoAddRoomServers && type == advTypeRoom) ||
(_autoAddSensors && type == advTypeSensor)) { (_autoAddSensors && type == advTypeSensor)) {
_handleContactAdvert(newContact); _handleContactAdvert(newContact);
_handleDiscovery(
newContact,
rawPacket,
noNotify: true,
addActive: true,
);
} else { } else {
_handleDiscovery(newContact, rawPacket); _handleDiscovery(newContact, rawPacket);
} }
@@ -4480,8 +4813,20 @@ class MeshCoreConnector extends ChangeNotifier {
// CRITICAL: Preserve user's path override when contact is refreshed from device // CRITICAL: Preserve user's path override when contact is refreshed from device
_contacts[existingIndex] = existing.copyWith( _contacts[existingIndex] = existing.copyWith(
latitude: hasLocation ? latitude : existing.latitude, latitude:
longitude: hasLocation ? longitude : existing.longitude, hasLocation &&
latitude != null &&
latitude.abs() <= 90 &&
(latitude != 0 || longitude != 0)
? latitude
: existing.latitude,
longitude:
hasLocation &&
longitude != null &&
longitude.abs() <= 180 &&
(latitude != 0 || longitude != 0)
? longitude
: existing.longitude,
name: hasName ? name : existing.name, name: hasName ? name : existing.name,
path: Uint8List.fromList(path.reversed.toList()), path: Uint8List.fromList(path.reversed.toList()),
pathLength: path.length, pathLength: path.length,
@@ -4552,11 +4897,11 @@ class MeshCoreConnector extends ChangeNotifier {
try { try {
reader.skipBytes(1); // Skip the response code byte reader.skipBytes(1); // Skip the response code byte
final flags = reader.readByte(); final flags = reader.readByte();
_autoAddUsers = flags & autoAddChatFlag != 0; _autoAddUsers = (flags & autoAddChatFlag) != 0;
_autoAddRepeaters = flags & autoAddRepeaterFlag != 0; _autoAddRepeaters = (flags & autoAddRepeaterFlag) != 0;
_autoAddRoomServers = flags & autoAddRoomServerFlag != 0; _autoAddRoomServers = (flags & autoAddRoomServerFlag) != 0;
_autoAddSensors = flags & autoAddSensorFlag != 0; _autoAddSensors = (flags & autoAddSensorFlag) != 0;
_overwriteOldest = flags & autoAddOverwriteOldestFlag != 0; _overwriteOldest = (flags & autoAddOverwriteOldestFlag) != 0;
} catch (e) { } catch (e) {
appLogger.error('Failed to parse auto-add config: $e', tag: 'Connector'); appLogger.error('Failed to parse auto-add config: $e', tag: 'Connector');
} }
@@ -4566,6 +4911,7 @@ class MeshCoreConnector extends ChangeNotifier {
Contact contact, Contact contact,
Uint8List rawPacket, { Uint8List rawPacket, {
bool noNotify = false, bool noNotify = false,
bool addActive = false,
}) { }) {
appLogger.info('Discovered new contact: ${contact.name}', tag: 'Connector'); appLogger.info('Discovered new contact: ${contact.name}', tag: 'Connector');
@@ -4585,13 +4931,15 @@ class MeshCoreConnector extends ChangeNotifier {
latitude: contact.latitude, latitude: contact.latitude,
longitude: contact.longitude, longitude: contact.longitude,
lastSeen: contact.lastSeen, lastSeen: contact.lastSeen,
flags: 0,
isActive: addActive,
); );
notifyListeners(); notifyListeners();
unawaited(_persistDiscoveredContacts()); unawaited(_persistDiscoveredContacts());
return; return;
} }
final disContact = DiscoveryContact( final disContact = Contact(
rawPacket: rawPacket, rawPacket: rawPacket,
publicKey: contact.publicKey, publicKey: contact.publicKey,
name: contact.name, name: contact.name,
@@ -4601,6 +4949,9 @@ class MeshCoreConnector extends ChangeNotifier {
latitude: contact.latitude, latitude: contact.latitude,
longitude: contact.longitude, longitude: contact.longitude,
lastSeen: contact.lastSeen, lastSeen: contact.lastSeen,
lastMessageAt: contact.lastMessageAt,
isActive: addActive,
flags: 0,
); );
_discoveredContacts.add(disContact); _discoveredContacts.add(disContact);
+70
View File
@@ -0,0 +1,70 @@
import 'dart:async';
import 'dart:typed_data';
import '../services/app_debug_log_service.dart';
import '../services/tcp_transport_service.dart';
/// Manages TCP transport for MeshCore devices.
///
/// Owns the [TcpTransportService] and TCP-specific connection state.
/// The main [MeshCoreConnector] delegates all TCP operations here.
class MeshCoreTcpConnector {
final TcpTransportService _service = TcpTransportService();
AppDebugLogService? _debugLog;
StreamSubscription<Uint8List>? _frameSubscription;
// --- Getters ---
String? get activeEndpoint => _service.activeEndpoint;
bool get isConnected => _service.isConnected;
// --- Configuration ---
void setDebugLogService(AppDebugLogService? service) {
_debugLog = service;
_service.setDebugLogService(service);
}
// --- Connection lifecycle ---
Future<void> connect({required String host, required int port}) async {
_debugLog?.info('TcpConnector.connect endpoint=$host:$port', tag: 'TCP');
await _frameSubscription?.cancel();
_frameSubscription = null;
await _service.connect(host: host, port: port);
_debugLog?.info(
'TcpConnector.connect done, endpoint=${_service.activeEndpoint}',
tag: 'TCP',
);
}
StreamSubscription<Uint8List> listenFrames({
required void Function(Uint8List) onFrame,
required void Function(Object, StackTrace?) onError,
required void Function() onDone,
}) {
_frameSubscription = _service.frameStream.listen(
onFrame,
onError: onError,
onDone: onDone,
);
return _frameSubscription!;
}
Future<void> cancelFrameSubscription() async {
await _frameSubscription?.cancel();
_frameSubscription = null;
}
Future<void> disconnect() async {
if (!_service.isConnected && _frameSubscription == null) return;
_debugLog?.info('TcpConnector.disconnect', tag: 'TCP');
await _frameSubscription?.cancel();
_frameSubscription = null;
await _service.disconnect();
}
Future<void> write(Uint8List data) => _service.write(data);
void dispose() {
_frameSubscription?.cancel();
_service.dispose();
}
}
+10 -3
View File
@@ -24,8 +24,7 @@ class MeshCoreUsbManager {
// --- Configuration --- // --- Configuration ---
Future<List<String>> listPorts() => _service.listPorts(); Future<List<String>> listPorts() => _service.listPorts();
void setRequestPortLabel(String label) => void setRequestPortLabel(String label) => _service.setRequestPortLabel(label);
_service.setRequestPortLabel(label);
void setFallbackDeviceName(String label) => void setFallbackDeviceName(String label) =>
_service.setFallbackDeviceName(label); _service.setFallbackDeviceName(label);
@@ -36,7 +35,10 @@ class MeshCoreUsbManager {
} }
// --- Connection lifecycle --- // --- Connection lifecycle ---
Future<void> connect({required String portName, int baudRate = 115200}) async { Future<void> connect({
required String portName,
int baudRate = 115200,
}) async {
_debugLog?.info( _debugLog?.info(
'UsbManager.connect: portName=$portName baud=$baudRate', 'UsbManager.connect: portName=$portName baud=$baudRate',
tag: 'USB', tag: 'USB',
@@ -51,6 +53,9 @@ class MeshCoreUsbManager {
} }
Future<void> disconnect() async { Future<void> disconnect() async {
if (!_service.isConnected && _activePortKey == null) {
return;
}
_debugLog?.info('UsbManager.disconnect', tag: 'USB'); _debugLog?.info('UsbManager.disconnect', tag: 'USB');
await _service.disconnect(); await _service.disconnect();
_activePortKey = null; _activePortKey = null;
@@ -59,6 +64,8 @@ class MeshCoreUsbManager {
Future<void> write(Uint8List data) => _service.write(data); Future<void> write(Uint8List data) => _service.write(data);
Future<void> writeRaw(Uint8List data) => _service.writeRaw(data);
// --- Label management --- // --- Label management ---
void updateConnectedLabel(String selfName) { void updateConnectedLabel(String selfName) {
_service.updateConnectedLabel(selfName); _service.updateConnectedLabel(selfName);
+67 -14
View File
@@ -4,6 +4,7 @@ import 'dart:typed_data';
// Buffer Reader - sequential binary data reader with pointer tracking // Buffer Reader - sequential binary data reader with pointer tracking
class BufferReader { class BufferReader {
int _pointer = 0; int _pointer = 0;
int _lastPointer = 0;
final Uint8List _buffer; final Uint8List _buffer;
BufferReader(Uint8List data) : _buffer = Uint8List.fromList(data); BufferReader(Uint8List data) : _buffer = Uint8List.fromList(data);
@@ -13,6 +14,7 @@ class BufferReader {
int readByte() => readBytes(1)[0]; int readByte() => readBytes(1)[0];
Uint8List readBytes(int count) { Uint8List readBytes(int count) {
_lastPointer = _pointer;
if (_pointer + count > _buffer.length) { if (_pointer + count > _buffer.length) {
throw RangeError( throw RangeError(
'Attempted to read $count bytes at offset $_pointer, but only $remaining bytes remaining in buffer of length ${_buffer.length}', 'Attempted to read $count bytes at offset $_pointer, but only $remaining bytes remaining in buffer of length ${_buffer.length}',
@@ -24,6 +26,7 @@ class BufferReader {
} }
void skipBytes(int count) { void skipBytes(int count) {
_lastPointer = _pointer;
if (_pointer + count > _buffer.length) { if (_pointer + count > _buffer.length) {
throw RangeError( throw RangeError(
'Attempted to skip $count bytes at offset $_pointer, but only $remaining bytes remaining in buffer of length ${_buffer.length}', 'Attempted to skip $count bytes at offset $_pointer, but only $remaining bytes remaining in buffer of length ${_buffer.length}',
@@ -35,6 +38,7 @@ class BufferReader {
Uint8List readRemainingBytes() => readBytes(remaining); Uint8List readRemainingBytes() => readBytes(remaining);
String readString() { String readString() {
_lastPointer = _pointer;
final value = readRemainingBytes(); final value = readRemainingBytes();
try { try {
return utf8.decode(Uint8List.fromList(value), allowMalformed: true); return utf8.decode(Uint8List.fromList(value), allowMalformed: true);
@@ -43,7 +47,8 @@ class BufferReader {
} }
} }
String readCString(int maxLength) { String readCStringGreedy(int maxLength) {
_lastPointer = _pointer;
final value = <int>[]; final value = <int>[];
final bytes = readBytes(maxLength); final bytes = readBytes(maxLength);
for (final byte in bytes) { for (final byte in bytes) {
@@ -57,6 +62,24 @@ class BufferReader {
} }
} }
String readCString(int maxLength) {
final backupPointer = _pointer;
final value = <int>[];
int counter = 0;
while (counter < maxLength) {
final byte = readByte();
if (byte == 0) break;
value.add(byte);
counter++;
}
_lastPointer = backupPointer;
try {
return utf8.decode(Uint8List.fromList(value), allowMalformed: true);
} catch (e) {
return String.fromCharCodes(value); // Latin-1 fallback
}
}
int readUInt8() => readBytes(1).buffer.asByteData().getUint8(0); int readUInt8() => readBytes(1).buffer.asByteData().getUint8(0);
int readInt8() => readBytes(1).buffer.asByteData().getInt8(0); int readInt8() => readBytes(1).buffer.asByteData().getInt8(0);
int readUInt16LE() => int readUInt16LE() =>
@@ -78,6 +101,9 @@ class BufferReader {
if ((value & 0x800000) != 0) value -= 0x1000000; if ((value & 0x800000) != 0) value -= 0x1000000;
return value; return value;
} }
void resetPointer() => _pointer = 0;
void rewind() => _pointer = _lastPointer;
} }
// Buffer Writer - accumulating binary data builder // Buffer Writer - accumulating binary data builder
@@ -122,6 +148,19 @@ class BufferWriter {
void writeHex(String hex) { void writeHex(String hex) {
writeBytes(hex2Uint8List(hex)); writeBytes(hex2Uint8List(hex));
} }
void writeBytesPadded(Uint8List bytes, int totalLength) {
// Path data (64 bytes, zero-padded)
final bytesPadded = Uint8List(totalLength);
final len = bytes.length < totalLength ? bytes.length : totalLength;
if (bytes.isNotEmpty && len > 0) {
final copyLen = bytes.length < totalLength ? bytes.length : totalLength;
for (int i = 0; i < copyLen; i++) {
bytesPadded[i] = bytes[i];
}
}
writeBytes(bytesPadded);
}
} }
Uint8List hex2Uint8List(String hex) { Uint8List hex2Uint8List(String hex) {
@@ -650,14 +689,17 @@ Uint8List buildResetPathFrame(Uint8List pubKey) {
} }
// Build CMD_ADD_UPDATE_CONTACT frame to set custom path // Build CMD_ADD_UPDATE_CONTACT frame to set custom path
// Format: [cmd][pub_key x32][type][flags][path_len][path x64][name x32][timestamp x4] // Format: [cmd][pub_key x32][type][flags][path_len][path x64][name x32][Lat? x4, Lon? x4][timestamp? x4]
Uint8List buildUpdateContactPathFrame( Uint8List buildUpdateContactPathFrame(
Uint8List pubKey, Uint8List pubKey,
Uint8List customPath, Uint8List path,
int pathLen, { int pathLen, {
int type = 1, // ADV_TYPE_CHAT int type = 1, // ADV_TYPE_CHAT
int flags = 0, int flags = 0,
String name = '', String name = '',
double? lat,
double? lon,
DateTime? lastModified,
}) { }) {
final writer = BufferWriter(); final writer = BufferWriter();
writer.writeByte(cmdAddUpdateContact); writer.writeByte(cmdAddUpdateContact);
@@ -666,17 +708,7 @@ Uint8List buildUpdateContactPathFrame(
writer.writeByte(flags); writer.writeByte(flags);
writer.writeByte(pathLen); writer.writeByte(pathLen);
// Path data (64 bytes, zero-padded) writer.writeBytesPadded(path, maxPathSize);
final pathPadded = Uint8List(maxPathSize);
if (customPath.isNotEmpty && pathLen > 0) {
final copyLen = customPath.length < maxPathSize
? customPath.length
: maxPathSize;
for (int i = 0; i < copyLen; i++) {
pathPadded[i] = customPath[i];
}
}
writer.writeBytes(pathPadded);
// Name (32 bytes, null-padded) // Name (32 bytes, null-padded)
writer.writeCString(name, maxNameSize); writer.writeCString(name, maxNameSize);
@@ -685,6 +717,27 @@ Uint8List buildUpdateContactPathFrame(
final timestamp = DateTime.now().millisecondsSinceEpoch ~/ 1000; final timestamp = DateTime.now().millisecondsSinceEpoch ~/ 1000;
writer.writeUInt32LE(timestamp); writer.writeUInt32LE(timestamp);
if ((lat == null || lon == null) && lastModified != null) {
// If lat/lon not provided, write zeros
writer.writeInt32LE(0);
writer.writeInt32LE(0);
} else {
// Latitude and Longitude are expected in degrees, convert to int by multiplying by 1e6
// Latitude
final latitude = lat ?? 0.0;
writer.writeInt32LE((latitude * 1e6).round());
// Longitude
final longitude = lon ?? 0.0;
writer.writeInt32LE((longitude * 1e6).round());
}
if (lastModified != null) {
// Last modified
final lastModifiedTimestamp = lastModified.millisecondsSinceEpoch ~/ 1000;
writer.writeUInt32LE(lastModifiedTimestamp);
}
return writer.toBytes(); return writer.toBytes();
} }
+31 -1
View File
@@ -285,6 +285,7 @@
"contacts_newGroup": "Нова група", "contacts_newGroup": "Нова група",
"contacts_groupName": "Група", "contacts_groupName": "Група",
"contacts_groupNameRequired": "Името на групата е задължително.", "contacts_groupNameRequired": "Името на групата е задължително.",
"contacts_groupNameReserved": "Това име на група е запазено",
"contacts_groupAlreadyExists": "Групата \"{name}\" вече съществува.", "contacts_groupAlreadyExists": "Групата \"{name}\" вече съществува.",
"@contacts_groupAlreadyExists": { "@contacts_groupAlreadyExists": {
"placeholders": { "placeholders": {
@@ -1859,5 +1860,34 @@
"usbConnectionFailed": "Неуспешно свързване през USB: {error}", "usbConnectionFailed": "Неуспешно свързване през USB: {error}",
"usbStatus_notConnected": "Изберете USB устройство", "usbStatus_notConnected": "Изберете USB устройство",
"usbStatus_searching": "Търсене на USB устройства...", "usbStatus_searching": "Търсене на USB устройства...",
"usbErrorConnectTimedOut": "Връзката прекъсна. Уверете се, че устройството има софтуер за USB връзка." "usbErrorConnectTimedOut": "Връзката прекъсна. Уверете се, че устройството има софтуер за USB връзка.",
"@tcpStatus_connectingTo": {
"placeholders": {
"endpoint": {
"type": "String"
}
}
},
"@tcpConnectionFailed": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"tcpHostHint": "192.168.40.10",
"tcpScreenTitle": "Свържете се чрез TCP",
"connectionChoiceTcpLabel": "TCP",
"tcpHostLabel": "IP адрес",
"tcpPortLabel": "Пристанище",
"tcpPortHint": "5000",
"tcpStatus_notConnected": "Въведете крайната точка и свържете се.",
"tcpStatus_connectingTo": "Свързване към {endpoint}...",
"tcpErrorHostRequired": "Необходим е IP адрес.",
"tcpErrorPortInvalid": "Портът трябва да бъде между 1 и 65535.",
"tcpErrorUnsupported": "Транспортът чрез TCP не се поддържа на тази платформа.",
"tcpErrorTimedOut": "Връзката TCP изтекла.",
"tcpConnectionFailed": "Неуспешно е установено TCP връзката: {error}",
"map_showDiscoveryContacts": "Покажи контакти за откриване",
"map_setAsMyLocation": "Задайте като моя местоположение"
} }
+31 -1
View File
@@ -285,6 +285,7 @@
"contacts_newGroup": "Neue Gruppe", "contacts_newGroup": "Neue Gruppe",
"contacts_groupName": "Gruppenname", "contacts_groupName": "Gruppenname",
"contacts_groupNameRequired": "Der Gruppennamen ist erforderlich.", "contacts_groupNameRequired": "Der Gruppennamen ist erforderlich.",
"contacts_groupNameReserved": "Dieser Gruppenname ist reserviert",
"contacts_groupAlreadyExists": "Die Gruppe \"{name}\" existiert bereits.", "contacts_groupAlreadyExists": "Die Gruppe \"{name}\" existiert bereits.",
"@contacts_groupAlreadyExists": { "@contacts_groupAlreadyExists": {
"placeholders": { "placeholders": {
@@ -1887,5 +1888,34 @@
"usbStatus_notConnected": "Wählen Sie ein USB-Gerät aus", "usbStatus_notConnected": "Wählen Sie ein USB-Gerät aus",
"usbStatus_connecting": "Verbindung zum USB-Gerät...", "usbStatus_connecting": "Verbindung zum USB-Gerät...",
"usbConnectionFailed": "Fehler beim USB-Verbindungsaufbau: {error}", "usbConnectionFailed": "Fehler beim USB-Verbindungsaufbau: {error}",
"usbErrorConnectTimedOut": "Verbindung konnte nicht hergestellt werden. Stellen Sie sicher, dass das Gerät die entsprechende USB-Firmware enthält." "usbErrorConnectTimedOut": "Verbindung konnte nicht hergestellt werden. Stellen Sie sicher, dass das Gerät die entsprechende USB-Firmware enthält.",
"@tcpStatus_connectingTo": {
"placeholders": {
"endpoint": {
"type": "String"
}
}
},
"@tcpConnectionFailed": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"tcpHostLabel": "IP-Adresse",
"connectionChoiceTcpLabel": "TCP",
"tcpHostHint": "192.168.40.10",
"tcpScreenTitle": "Verbinden über TCP",
"tcpPortLabel": "Port",
"tcpPortHint": "5000",
"tcpStatus_notConnected": "Geben Sie den Endpunkt ein und verbinden Sie sich.",
"tcpStatus_connectingTo": "Verbindung zu {endpoint}...",
"tcpErrorHostRequired": "Eine IP-Adresse ist erforderlich.",
"tcpErrorPortInvalid": "Die Portnummer muss zwischen 1 und 65535 liegen.",
"tcpErrorUnsupported": "Die TCP-Übertragung wird auf dieser Plattform nicht unterstützt.",
"tcpErrorTimedOut": "Die TCP-Verbindung ist abgelaufen.",
"tcpConnectionFailed": "Fehler beim TCP-Verbindungsaufbau: {error}",
"map_showDiscoveryContacts": "Entdeckungs-Kontakte anzeigen",
"map_setAsMyLocation": "Als meine aktuelle Position festlegen"
} }
+30
View File
@@ -49,6 +49,33 @@
"scanner_title": "MeshCore Open", "scanner_title": "MeshCore Open",
"connectionChoiceUsbLabel": "USB", "connectionChoiceUsbLabel": "USB",
"connectionChoiceBluetoothLabel": "Bluetooth", "connectionChoiceBluetoothLabel": "Bluetooth",
"connectionChoiceTcpLabel": "TCP",
"tcpScreenTitle": "Connect over TCP",
"tcpHostLabel": "IP Address",
"tcpHostHint": "192.168.40.10",
"tcpPortLabel": "Port",
"tcpPortHint": "5000",
"tcpStatus_notConnected": "Enter endpoint and connect",
"tcpStatus_connectingTo": "Connecting to {endpoint}...",
"@tcpStatus_connectingTo": {
"placeholders": {
"endpoint": {
"type": "String"
}
}
},
"tcpErrorHostRequired": "IP address is required.",
"tcpErrorPortInvalid": "Port must be between 1 and 65535.",
"tcpErrorUnsupported": "TCP transport is not supported on this platform.",
"tcpErrorTimedOut": "TCP connection timed out.",
"tcpConnectionFailed": "TCP connection failed: {error}",
"@tcpConnectionFailed": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"usbScreenTitle": "Connect over USB", "usbScreenTitle": "Connect over USB",
"usbScreenSubtitle": "Choose a detected serial device and connect directly to your MeshCore node.", "usbScreenSubtitle": "Choose a detected serial device and connect directly to your MeshCore node.",
"usbScreenStatus": "Select a USB device", "usbScreenStatus": "Select a USB device",
@@ -389,6 +416,7 @@
"contacts_newGroup": "New Group", "contacts_newGroup": "New Group",
"contacts_groupName": "Group name", "contacts_groupName": "Group name",
"contacts_groupNameRequired": "Group name is required", "contacts_groupNameRequired": "Group name is required",
"contacts_groupNameReserved": "This group name is reserved",
"contacts_groupAlreadyExists": "Group \"{name}\" already exists", "contacts_groupAlreadyExists": "Group \"{name}\" already exists",
"@contacts_groupAlreadyExists": { "@contacts_groupAlreadyExists": {
"placeholders": { "placeholders": {
@@ -780,6 +808,7 @@
"map_source": "Source", "map_source": "Source",
"map_flags": "Flags", "map_flags": "Flags",
"map_shareMarkerHere": "Share marker here", "map_shareMarkerHere": "Share marker here",
"map_setAsMyLocation": "Set as my location",
"map_pinLabel": "Pin label", "map_pinLabel": "Pin label",
"map_label": "Label", "map_label": "Label",
"map_pointOfInterest": "Point of interest", "map_pointOfInterest": "Point of interest",
@@ -807,6 +836,7 @@
"map_markers": "Markers", "map_markers": "Markers",
"map_showSharedMarkers": "Show shared markers", "map_showSharedMarkers": "Show shared markers",
"map_showGuessedLocations": "Show guessed node locations", "map_showGuessedLocations": "Show guessed node locations",
"map_showDiscoveryContacts": "Show Discovery Contacts",
"map_guessedLocation": "Guessed location", "map_guessedLocation": "Guessed location",
"map_lastSeenTime": "Last Seen Time", "map_lastSeenTime": "Last Seen Time",
"map_sharedPin": "Shared pin", "map_sharedPin": "Shared pin",
+31 -1
View File
@@ -285,6 +285,7 @@
"contacts_newGroup": "Nuevo Grupo", "contacts_newGroup": "Nuevo Grupo",
"contacts_groupName": "Nombre del grupo", "contacts_groupName": "Nombre del grupo",
"contacts_groupNameRequired": "El nombre del grupo es obligatorio", "contacts_groupNameRequired": "El nombre del grupo es obligatorio",
"contacts_groupNameReserved": "Este nombre de grupo está reservado",
"contacts_groupAlreadyExists": "El grupo \"{name}\" ya existe", "contacts_groupAlreadyExists": "El grupo \"{name}\" ya existe",
"@contacts_groupAlreadyExists": { "@contacts_groupAlreadyExists": {
"placeholders": { "placeholders": {
@@ -1887,5 +1888,34 @@
"usbStatus_searching": "Buscando dispositivos USB...", "usbStatus_searching": "Buscando dispositivos USB...",
"usbStatus_notConnected": "Seleccione un dispositivo USB", "usbStatus_notConnected": "Seleccione un dispositivo USB",
"usbConnectionFailed": "Error al conectar mediante USB: {error}", "usbConnectionFailed": "Error al conectar mediante USB: {error}",
"usbErrorConnectTimedOut": "La conexión ha caducado. Asegúrese de que el dispositivo tenga el firmware USB Companion." "usbErrorConnectTimedOut": "La conexión ha caducado. Asegúrese de que el dispositivo tenga el firmware USB Companion.",
"@tcpStatus_connectingTo": {
"placeholders": {
"endpoint": {
"type": "String"
}
}
},
"@tcpConnectionFailed": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"tcpScreenTitle": "Establecer conexión a través de TCP",
"connectionChoiceTcpLabel": "TCP",
"tcpHostHint": "192.168.40.10",
"tcpHostLabel": "Dirección IP",
"tcpPortLabel": "Puerto",
"tcpPortHint": "5000",
"tcpStatus_notConnected": "Ingrese la dirección final y conecte.",
"tcpStatus_connectingTo": "Conectándose a {endpoint}...",
"tcpErrorHostRequired": "Se requiere la dirección IP.",
"tcpErrorPortInvalid": "El puerto debe estar entre 1 y 65535.",
"tcpErrorUnsupported": "El protocolo de transporte TCP no está soportado en esta plataforma.",
"tcpErrorTimedOut": "La conexión TCP ha caducado.",
"tcpConnectionFailed": "Error en la conexión TCP: {error}",
"map_showDiscoveryContacts": "Mostrar Contactos de Descubrimiento",
"map_setAsMyLocation": "Establecer mi ubicación"
} }
+31 -1
View File
@@ -285,6 +285,7 @@
"contacts_newGroup": "Nouveau Groupe", "contacts_newGroup": "Nouveau Groupe",
"contacts_groupName": "Nom du groupe", "contacts_groupName": "Nom du groupe",
"contacts_groupNameRequired": "Le nom du groupe est obligatoire.", "contacts_groupNameRequired": "Le nom du groupe est obligatoire.",
"contacts_groupNameReserved": "Ce nom de groupe est réservé",
"contacts_groupAlreadyExists": "Le groupe \"{name}\" existe déjà.", "contacts_groupAlreadyExists": "Le groupe \"{name}\" existe déjà.",
"@contacts_groupAlreadyExists": { "@contacts_groupAlreadyExists": {
"placeholders": { "placeholders": {
@@ -1859,5 +1860,34 @@
"usbConnectionFailed": "Échec de la connexion USB : {error}", "usbConnectionFailed": "Échec de la connexion USB : {error}",
"usbStatus_connecting": "Connexion au périphérique USB...", "usbStatus_connecting": "Connexion au périphérique USB...",
"usbStatus_searching": "Recherche de périphériques USB...", "usbStatus_searching": "Recherche de périphériques USB...",
"usbErrorConnectTimedOut": "La connexion a expiré. Assurez-vous que l'appareil dispose du firmware USB Companion." "usbErrorConnectTimedOut": "La connexion a expiré. Assurez-vous que l'appareil dispose du firmware USB Companion.",
"@tcpStatus_connectingTo": {
"placeholders": {
"endpoint": {
"type": "String"
}
}
},
"@tcpConnectionFailed": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"tcpHostLabel": "Adresse IP",
"connectionChoiceTcpLabel": "TCP",
"tcpScreenTitle": "Établir une connexion via TCP",
"tcpHostHint": "192.168.40.10",
"tcpPortLabel": "Port",
"tcpPortHint": "5000",
"tcpStatus_notConnected": "Entrez l'adresse de destination et connectez-vous.",
"tcpStatus_connectingTo": "Connexion à {endpoint}...",
"tcpErrorHostRequired": "Une adresse IP est obligatoire.",
"tcpErrorPortInvalid": "La taille du port doit être comprise entre 1 et 65535.",
"tcpErrorUnsupported": "Le protocole TCP n'est pas pris en charge sur cette plateforme.",
"tcpErrorTimedOut": "La connexion TCP a expiré.",
"tcpConnectionFailed": "Échec de la connexion TCP : {error}",
"map_showDiscoveryContacts": "Afficher les contacts de découverte",
"map_setAsMyLocation": "Définir comme ma localisation"
} }
+31 -1
View File
@@ -285,6 +285,7 @@
"contacts_newGroup": "Nuovo Gruppo", "contacts_newGroup": "Nuovo Gruppo",
"contacts_groupName": "Nome gruppo", "contacts_groupName": "Nome gruppo",
"contacts_groupNameRequired": "Il nome del gruppo è obbligatorio.", "contacts_groupNameRequired": "Il nome del gruppo è obbligatorio.",
"contacts_groupNameReserved": "Questo nome del gruppo è riservato",
"contacts_groupAlreadyExists": "Il gruppo \"{name}\" esiste già.", "contacts_groupAlreadyExists": "Il gruppo \"{name}\" esiste già.",
"@contacts_groupAlreadyExists": { "@contacts_groupAlreadyExists": {
"placeholders": { "placeholders": {
@@ -1859,5 +1860,34 @@
"usbConnectionFailed": "Errore nella connessione USB: {error}", "usbConnectionFailed": "Errore nella connessione USB: {error}",
"usbStatus_notConnected": "Seleziona un dispositivo USB", "usbStatus_notConnected": "Seleziona un dispositivo USB",
"usbStatus_connecting": "Connessione al dispositivo USB...", "usbStatus_connecting": "Connessione al dispositivo USB...",
"usbErrorConnectTimedOut": "La connessione è scaduta. Assicurarsi che il dispositivo abbia il firmware USB Companion." "usbErrorConnectTimedOut": "La connessione è scaduta. Assicurarsi che il dispositivo abbia il firmware USB Companion.",
"@tcpStatus_connectingTo": {
"placeholders": {
"endpoint": {
"type": "String"
}
}
},
"@tcpConnectionFailed": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"tcpHostLabel": "Indirizzo IP",
"tcpHostHint": "192.168.40.10",
"connectionChoiceTcpLabel": "TCP",
"tcpScreenTitle": "Stabilire una connessione tramite TCP",
"tcpPortLabel": "Porta",
"tcpPortHint": "5000",
"tcpStatus_notConnected": "Inserisci l'endpoint e connettiti.",
"tcpStatus_connectingTo": "Connessione a {endpoint}...",
"tcpErrorHostRequired": "È necessario fornire un indirizzo IP.",
"tcpErrorPortInvalid": "La dimensione della porta deve essere compresa tra 1 e 65535.",
"tcpErrorUnsupported": "Il protocollo TCP non è supportato su questa piattaforma.",
"tcpErrorTimedOut": "La connessione TCP è scaduta.",
"tcpConnectionFailed": "Impossibile stabilire la connessione TCP: {error}",
"map_showDiscoveryContacts": "Mostra Contatti di Discovery",
"map_setAsMyLocation": "Imposta come la mia posizione"
} }
+96
View File
@@ -334,6 +334,84 @@ abstract class AppLocalizations {
/// **'Bluetooth'** /// **'Bluetooth'**
String get connectionChoiceBluetoothLabel; String get connectionChoiceBluetoothLabel;
/// No description provided for @connectionChoiceTcpLabel.
///
/// In en, this message translates to:
/// **'TCP'**
String get connectionChoiceTcpLabel;
/// No description provided for @tcpScreenTitle.
///
/// In en, this message translates to:
/// **'Connect over TCP'**
String get tcpScreenTitle;
/// No description provided for @tcpHostLabel.
///
/// In en, this message translates to:
/// **'IP Address'**
String get tcpHostLabel;
/// No description provided for @tcpHostHint.
///
/// In en, this message translates to:
/// **'192.168.40.10'**
String get tcpHostHint;
/// No description provided for @tcpPortLabel.
///
/// In en, this message translates to:
/// **'Port'**
String get tcpPortLabel;
/// No description provided for @tcpPortHint.
///
/// In en, this message translates to:
/// **'5000'**
String get tcpPortHint;
/// No description provided for @tcpStatus_notConnected.
///
/// In en, this message translates to:
/// **'Enter endpoint and connect'**
String get tcpStatus_notConnected;
/// No description provided for @tcpStatus_connectingTo.
///
/// In en, this message translates to:
/// **'Connecting to {endpoint}...'**
String tcpStatus_connectingTo(String endpoint);
/// No description provided for @tcpErrorHostRequired.
///
/// In en, this message translates to:
/// **'IP address is required.'**
String get tcpErrorHostRequired;
/// No description provided for @tcpErrorPortInvalid.
///
/// In en, this message translates to:
/// **'Port must be between 1 and 65535.'**
String get tcpErrorPortInvalid;
/// No description provided for @tcpErrorUnsupported.
///
/// In en, this message translates to:
/// **'TCP transport is not supported on this platform.'**
String get tcpErrorUnsupported;
/// No description provided for @tcpErrorTimedOut.
///
/// In en, this message translates to:
/// **'TCP connection timed out.'**
String get tcpErrorTimedOut;
/// No description provided for @tcpConnectionFailed.
///
/// In en, this message translates to:
/// **'TCP connection failed: {error}'**
String tcpConnectionFailed(String error);
/// No description provided for @usbScreenTitle. /// No description provided for @usbScreenTitle.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -1636,6 +1714,12 @@ abstract class AppLocalizations {
/// **'Group name is required'** /// **'Group name is required'**
String get contacts_groupNameRequired; String get contacts_groupNameRequired;
/// No description provided for @contacts_groupNameReserved.
///
/// In en, this message translates to:
/// **'This group name is reserved'**
String get contacts_groupNameReserved;
/// No description provided for @contacts_groupAlreadyExists. /// No description provided for @contacts_groupAlreadyExists.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -2668,6 +2752,12 @@ abstract class AppLocalizations {
/// **'Share marker here'** /// **'Share marker here'**
String get map_shareMarkerHere; String get map_shareMarkerHere;
/// No description provided for @map_setAsMyLocation.
///
/// In en, this message translates to:
/// **'Set as my location'**
String get map_setAsMyLocation;
/// No description provided for @map_pinLabel. /// No description provided for @map_pinLabel.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -2788,6 +2878,12 @@ abstract class AppLocalizations {
/// **'Show guessed node locations'** /// **'Show guessed node locations'**
String get map_showGuessedLocations; String get map_showGuessedLocations;
/// No description provided for @map_showDiscoveryContacts.
///
/// In en, this message translates to:
/// **'Show Discovery Contacts'**
String get map_showDiscoveryContacts;
/// No description provided for @map_guessedLocation. /// No description provided for @map_guessedLocation.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
+53
View File
@@ -117,6 +117,50 @@ class AppLocalizationsBg extends AppLocalizations {
@override @override
String get connectionChoiceBluetoothLabel => 'Bluetooth'; String get connectionChoiceBluetoothLabel => 'Bluetooth';
@override
String get connectionChoiceTcpLabel => 'TCP';
@override
String get tcpScreenTitle => 'Свържете се чрез TCP';
@override
String get tcpHostLabel => 'IP адрес';
@override
String get tcpHostHint => '192.168.40.10';
@override
String get tcpPortLabel => 'Пристанище';
@override
String get tcpPortHint => '5000';
@override
String get tcpStatus_notConnected => 'Въведете крайната точка и свържете се.';
@override
String tcpStatus_connectingTo(String endpoint) {
return 'Свързване към $endpoint...';
}
@override
String get tcpErrorHostRequired => 'Необходим е IP адрес.';
@override
String get tcpErrorPortInvalid => 'Портът трябва да бъде между 1 и 65535.';
@override
String get tcpErrorUnsupported =>
'Транспортът чрез TCP не се поддържа на тази платформа.';
@override
String get tcpErrorTimedOut => 'Връзката TCP изтекла.';
@override
String tcpConnectionFailed(String error) {
return 'Неуспешно е установено TCP връзката: $error';
}
@override @override
String get usbScreenTitle => 'Свържете се чрез USB'; String get usbScreenTitle => 'Свържете се чрез USB';
@@ -858,6 +902,9 @@ class AppLocalizationsBg extends AppLocalizations {
@override @override
String get contacts_groupNameRequired => 'Името на групата е задължително.'; String get contacts_groupNameRequired => 'Името на групата е задължително.';
@override
String get contacts_groupNameReserved => 'Това име на група е запазено';
@override @override
String contacts_groupAlreadyExists(String name) { String contacts_groupAlreadyExists(String name) {
return 'Групата \"$name\" вече съществува.'; return 'Групата \"$name\" вече съществува.';
@@ -1467,6 +1514,9 @@ class AppLocalizationsBg extends AppLocalizations {
@override @override
String get map_shareMarkerHere => 'Споделете маркер тук'; String get map_shareMarkerHere => 'Споделете маркер тук';
@override
String get map_setAsMyLocation => 'Задайте като моя местоположение';
@override @override
String get map_pinLabel => 'Етикетиране на пин'; String get map_pinLabel => 'Етикетиране на пин';
@@ -1531,6 +1581,9 @@ class AppLocalizationsBg extends AppLocalizations {
String get map_showGuessedLocations => String get map_showGuessedLocations =>
'Покажете местоположенията на предположените възли.'; 'Покажете местоположенията на предположените възли.';
@override
String get map_showDiscoveryContacts => 'Покажи контакти за откриване';
@override @override
String get map_guessedLocation => 'Предполагано местоположение'; String get map_guessedLocation => 'Предполагано местоположение';
+55
View File
@@ -117,6 +117,52 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get connectionChoiceBluetoothLabel => 'Bluetooth'; String get connectionChoiceBluetoothLabel => 'Bluetooth';
@override
String get connectionChoiceTcpLabel => 'TCP';
@override
String get tcpScreenTitle => 'Verbinden über TCP';
@override
String get tcpHostLabel => 'IP-Adresse';
@override
String get tcpHostHint => '192.168.40.10';
@override
String get tcpPortLabel => 'Port';
@override
String get tcpPortHint => '5000';
@override
String get tcpStatus_notConnected =>
'Geben Sie den Endpunkt ein und verbinden Sie sich.';
@override
String tcpStatus_connectingTo(String endpoint) {
return 'Verbindung zu $endpoint...';
}
@override
String get tcpErrorHostRequired => 'Eine IP-Adresse ist erforderlich.';
@override
String get tcpErrorPortInvalid =>
'Die Portnummer muss zwischen 1 und 65535 liegen.';
@override
String get tcpErrorUnsupported =>
'Die TCP-Übertragung wird auf dieser Plattform nicht unterstützt.';
@override
String get tcpErrorTimedOut => 'Die TCP-Verbindung ist abgelaufen.';
@override
String tcpConnectionFailed(String error) {
return 'Fehler beim TCP-Verbindungsaufbau: $error';
}
@override @override
String get usbScreenTitle => 'Verbinden über USB'; String get usbScreenTitle => 'Verbinden über USB';
@@ -856,6 +902,9 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get contacts_groupNameRequired => 'Der Gruppennamen ist erforderlich.'; String get contacts_groupNameRequired => 'Der Gruppennamen ist erforderlich.';
@override
String get contacts_groupNameReserved => 'Dieser Gruppenname ist reserviert';
@override @override
String contacts_groupAlreadyExists(String name) { String contacts_groupAlreadyExists(String name) {
return 'Die Gruppe \"$name\" existiert bereits.'; return 'Die Gruppe \"$name\" existiert bereits.';
@@ -1467,6 +1516,9 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get map_shareMarkerHere => 'Teilen Sie den Marker hier.'; String get map_shareMarkerHere => 'Teilen Sie den Marker hier.';
@override
String get map_setAsMyLocation => 'Als meine aktuelle Position festlegen';
@override @override
String get map_pinLabel => 'Pin Name'; String get map_pinLabel => 'Pin Name';
@@ -1531,6 +1583,9 @@ class AppLocalizationsDe extends AppLocalizations {
String get map_showGuessedLocations => String get map_showGuessedLocations =>
'Zeige die vermuteten Knotenpositionen'; 'Zeige die vermuteten Knotenpositionen';
@override
String get map_showDiscoveryContacts => 'Entdeckungs-Kontakte anzeigen';
@override @override
String get map_guessedLocation => 'Geschätzter Ort'; String get map_guessedLocation => 'Geschätzter Ort';
+53
View File
@@ -117,6 +117,50 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get connectionChoiceBluetoothLabel => 'Bluetooth'; String get connectionChoiceBluetoothLabel => 'Bluetooth';
@override
String get connectionChoiceTcpLabel => 'TCP';
@override
String get tcpScreenTitle => 'Connect over TCP';
@override
String get tcpHostLabel => 'IP Address';
@override
String get tcpHostHint => '192.168.40.10';
@override
String get tcpPortLabel => 'Port';
@override
String get tcpPortHint => '5000';
@override
String get tcpStatus_notConnected => 'Enter endpoint and connect';
@override
String tcpStatus_connectingTo(String endpoint) {
return 'Connecting to $endpoint...';
}
@override
String get tcpErrorHostRequired => 'IP address is required.';
@override
String get tcpErrorPortInvalid => 'Port must be between 1 and 65535.';
@override
String get tcpErrorUnsupported =>
'TCP transport is not supported on this platform.';
@override
String get tcpErrorTimedOut => 'TCP connection timed out.';
@override
String tcpConnectionFailed(String error) {
return 'TCP connection failed: $error';
}
@override @override
String get usbScreenTitle => 'Connect over USB'; String get usbScreenTitle => 'Connect over USB';
@@ -845,6 +889,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get contacts_groupNameRequired => 'Group name is required'; String get contacts_groupNameRequired => 'Group name is required';
@override
String get contacts_groupNameReserved => 'This group name is reserved';
@override @override
String contacts_groupAlreadyExists(String name) { String contacts_groupAlreadyExists(String name) {
return 'Group \"$name\" already exists'; return 'Group \"$name\" already exists';
@@ -1443,6 +1490,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get map_shareMarkerHere => 'Share marker here'; String get map_shareMarkerHere => 'Share marker here';
@override
String get map_setAsMyLocation => 'Set as my location';
@override @override
String get map_pinLabel => 'Pin label'; String get map_pinLabel => 'Pin label';
@@ -1506,6 +1556,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get map_showGuessedLocations => 'Show guessed node locations'; String get map_showGuessedLocations => 'Show guessed node locations';
@override
String get map_showDiscoveryContacts => 'Show Discovery Contacts';
@override @override
String get map_guessedLocation => 'Guessed location'; String get map_guessedLocation => 'Guessed location';
+54
View File
@@ -117,6 +117,50 @@ class AppLocalizationsEs extends AppLocalizations {
@override @override
String get connectionChoiceBluetoothLabel => 'Bluetooth'; String get connectionChoiceBluetoothLabel => 'Bluetooth';
@override
String get connectionChoiceTcpLabel => 'TCP';
@override
String get tcpScreenTitle => 'Establecer conexión a través de TCP';
@override
String get tcpHostLabel => 'Dirección IP';
@override
String get tcpHostHint => '192.168.40.10';
@override
String get tcpPortLabel => 'Puerto';
@override
String get tcpPortHint => '5000';
@override
String get tcpStatus_notConnected => 'Ingrese la dirección final y conecte.';
@override
String tcpStatus_connectingTo(String endpoint) {
return 'Conectándose a $endpoint...';
}
@override
String get tcpErrorHostRequired => 'Se requiere la dirección IP.';
@override
String get tcpErrorPortInvalid => 'El puerto debe estar entre 1 y 65535.';
@override
String get tcpErrorUnsupported =>
'El protocolo de transporte TCP no está soportado en esta plataforma.';
@override
String get tcpErrorTimedOut => 'La conexión TCP ha caducado.';
@override
String tcpConnectionFailed(String error) {
return 'Error en la conexión TCP: $error';
}
@override @override
String get usbScreenTitle => 'Conecte mediante USB'; String get usbScreenTitle => 'Conecte mediante USB';
@@ -857,6 +901,10 @@ class AppLocalizationsEs extends AppLocalizations {
@override @override
String get contacts_groupNameRequired => 'El nombre del grupo es obligatorio'; String get contacts_groupNameRequired => 'El nombre del grupo es obligatorio';
@override
String get contacts_groupNameReserved =>
'Este nombre de grupo está reservado';
@override @override
String contacts_groupAlreadyExists(String name) { String contacts_groupAlreadyExists(String name) {
return 'El grupo \"$name\" ya existe'; return 'El grupo \"$name\" ya existe';
@@ -1465,6 +1513,9 @@ class AppLocalizationsEs extends AppLocalizations {
@override @override
String get map_shareMarkerHere => 'Compartir marcador aquí'; String get map_shareMarkerHere => 'Compartir marcador aquí';
@override
String get map_setAsMyLocation => 'Establecer mi ubicación';
@override @override
String get map_pinLabel => 'Etiqueta de marcador'; String get map_pinLabel => 'Etiqueta de marcador';
@@ -1529,6 +1580,9 @@ class AppLocalizationsEs extends AppLocalizations {
String get map_showGuessedLocations => String get map_showGuessedLocations =>
'Mostrar las ubicaciones estimadas de los nodos.'; 'Mostrar las ubicaciones estimadas de los nodos.';
@override
String get map_showDiscoveryContacts => 'Mostrar Contactos de Descubrimiento';
@override @override
String get map_guessedLocation => 'Ubicación estimada'; String get map_guessedLocation => 'Ubicación estimada';
+55
View File
@@ -117,6 +117,52 @@ class AppLocalizationsFr extends AppLocalizations {
@override @override
String get connectionChoiceBluetoothLabel => 'Bluetooth'; String get connectionChoiceBluetoothLabel => 'Bluetooth';
@override
String get connectionChoiceTcpLabel => 'TCP';
@override
String get tcpScreenTitle => 'Établir une connexion via TCP';
@override
String get tcpHostLabel => 'Adresse IP';
@override
String get tcpHostHint => '192.168.40.10';
@override
String get tcpPortLabel => 'Port';
@override
String get tcpPortHint => '5000';
@override
String get tcpStatus_notConnected =>
'Entrez l\'adresse de destination et connectez-vous.';
@override
String tcpStatus_connectingTo(String endpoint) {
return 'Connexion à $endpoint...';
}
@override
String get tcpErrorHostRequired => 'Une adresse IP est obligatoire.';
@override
String get tcpErrorPortInvalid =>
'La taille du port doit être comprise entre 1 et 65535.';
@override
String get tcpErrorUnsupported =>
'Le protocole TCP n\'est pas pris en charge sur cette plateforme.';
@override
String get tcpErrorTimedOut => 'La connexion TCP a expiré.';
@override
String tcpConnectionFailed(String error) {
return 'Échec de la connexion TCP : $error';
}
@override @override
String get usbScreenTitle => 'Connectez via USB'; String get usbScreenTitle => 'Connectez via USB';
@@ -859,6 +905,9 @@ class AppLocalizationsFr extends AppLocalizations {
@override @override
String get contacts_groupNameRequired => 'Le nom du groupe est obligatoire.'; String get contacts_groupNameRequired => 'Le nom du groupe est obligatoire.';
@override
String get contacts_groupNameReserved => 'Ce nom de groupe est réservé';
@override @override
String contacts_groupAlreadyExists(String name) { String contacts_groupAlreadyExists(String name) {
return 'Le groupe \"$name\" existe déjà.'; return 'Le groupe \"$name\" existe déjà.';
@@ -1472,6 +1521,9 @@ class AppLocalizationsFr extends AppLocalizations {
@override @override
String get map_shareMarkerHere => 'Partager le marqueur ici'; String get map_shareMarkerHere => 'Partager le marqueur ici';
@override
String get map_setAsMyLocation => 'Définir comme ma localisation';
@override @override
String get map_pinLabel => 'Étiquete de repin'; String get map_pinLabel => 'Étiquete de repin';
@@ -1536,6 +1588,9 @@ class AppLocalizationsFr extends AppLocalizations {
String get map_showGuessedLocations => String get map_showGuessedLocations =>
'Afficher les emplacements des nœuds estimés'; 'Afficher les emplacements des nœuds estimés';
@override
String get map_showDiscoveryContacts => 'Afficher les contacts de découverte';
@override @override
String get map_guessedLocation => 'Lieu deviné'; String get map_guessedLocation => 'Lieu deviné';
+54
View File
@@ -117,6 +117,51 @@ class AppLocalizationsIt extends AppLocalizations {
@override @override
String get connectionChoiceBluetoothLabel => 'Bluetooth'; String get connectionChoiceBluetoothLabel => 'Bluetooth';
@override
String get connectionChoiceTcpLabel => 'TCP';
@override
String get tcpScreenTitle => 'Stabilire una connessione tramite TCP';
@override
String get tcpHostLabel => 'Indirizzo IP';
@override
String get tcpHostHint => '192.168.40.10';
@override
String get tcpPortLabel => 'Porta';
@override
String get tcpPortHint => '5000';
@override
String get tcpStatus_notConnected => 'Inserisci l\'endpoint e connettiti.';
@override
String tcpStatus_connectingTo(String endpoint) {
return 'Connessione a $endpoint...';
}
@override
String get tcpErrorHostRequired => 'È necessario fornire un indirizzo IP.';
@override
String get tcpErrorPortInvalid =>
'La dimensione della porta deve essere compresa tra 1 e 65535.';
@override
String get tcpErrorUnsupported =>
'Il protocollo TCP non è supportato su questa piattaforma.';
@override
String get tcpErrorTimedOut => 'La connessione TCP è scaduta.';
@override
String tcpConnectionFailed(String error) {
return 'Impossibile stabilire la connessione TCP: $error';
}
@override @override
String get usbScreenTitle => 'Connessione tramite USB'; String get usbScreenTitle => 'Connessione tramite USB';
@@ -856,6 +901,9 @@ class AppLocalizationsIt extends AppLocalizations {
@override @override
String get contacts_groupNameRequired => 'Il nome del gruppo è obbligatorio.'; String get contacts_groupNameRequired => 'Il nome del gruppo è obbligatorio.';
@override
String get contacts_groupNameReserved => 'Questo nome del gruppo è riservato';
@override @override
String contacts_groupAlreadyExists(String name) { String contacts_groupAlreadyExists(String name) {
return 'Il gruppo \"$name\" esiste già.'; return 'Il gruppo \"$name\" esiste già.';
@@ -1465,6 +1513,9 @@ class AppLocalizationsIt extends AppLocalizations {
@override @override
String get map_shareMarkerHere => 'Condividi marcatore qui'; String get map_shareMarkerHere => 'Condividi marcatore qui';
@override
String get map_setAsMyLocation => 'Imposta come la mia posizione';
@override @override
String get map_pinLabel => 'Etichetta PIN'; String get map_pinLabel => 'Etichetta PIN';
@@ -1528,6 +1579,9 @@ class AppLocalizationsIt extends AppLocalizations {
@override @override
String get map_showGuessedLocations => 'Mostra le posizioni stimate dei nodi'; String get map_showGuessedLocations => 'Mostra le posizioni stimate dei nodi';
@override
String get map_showDiscoveryContacts => 'Mostra Contatti di Discovery';
@override @override
String get map_guessedLocation => 'Località indovinata'; String get map_guessedLocation => 'Località indovinata';
+54
View File
@@ -117,6 +117,51 @@ class AppLocalizationsNl extends AppLocalizations {
@override @override
String get connectionChoiceBluetoothLabel => 'Bluetooth'; String get connectionChoiceBluetoothLabel => 'Bluetooth';
@override
String get connectionChoiceTcpLabel => 'TCP';
@override
String get tcpScreenTitle => 'Verbind via TCP';
@override
String get tcpHostLabel => 'IP-adres';
@override
String get tcpHostHint => '192.168.40.10';
@override
String get tcpPortLabel => 'Poort';
@override
String get tcpPortHint => '5000';
@override
String get tcpStatus_notConnected => 'Voer het eindpunt in en verbind';
@override
String tcpStatus_connectingTo(String endpoint) {
return 'Verbinding maken met $endpoint...';
}
@override
String get tcpErrorHostRequired => 'Een IP-adres is vereist.';
@override
String get tcpErrorPortInvalid =>
'De poortwaarde moet tussen 1 en 65535 liggen.';
@override
String get tcpErrorUnsupported =>
'TCP-transport wordt niet ondersteund op deze platform.';
@override
String get tcpErrorTimedOut => 'De TCP-verbinding is verlopen.';
@override
String tcpConnectionFailed(String error) {
return 'Verbinding met TCP mislukt: $error';
}
@override @override
String get usbScreenTitle => 'Verbind via USB'; String get usbScreenTitle => 'Verbind via USB';
@@ -850,6 +895,9 @@ class AppLocalizationsNl extends AppLocalizations {
@override @override
String get contacts_groupNameRequired => 'De groepnaam is verplicht.'; String get contacts_groupNameRequired => 'De groepnaam is verplicht.';
@override
String get contacts_groupNameReserved => 'Deze groepsnaam is gereserveerd';
@override @override
String contacts_groupAlreadyExists(String name) { String contacts_groupAlreadyExists(String name) {
return 'De groep \"$name\" bestaat al.'; return 'De groep \"$name\" bestaat al.';
@@ -1457,6 +1505,9 @@ class AppLocalizationsNl extends AppLocalizations {
@override @override
String get map_shareMarkerHere => 'Deel marker hier'; String get map_shareMarkerHere => 'Deel marker hier';
@override
String get map_setAsMyLocation => 'Stel dit in als mijn locatie';
@override @override
String get map_pinLabel => 'Label vastzetten'; String get map_pinLabel => 'Label vastzetten';
@@ -1521,6 +1572,9 @@ class AppLocalizationsNl extends AppLocalizations {
String get map_showGuessedLocations => String get map_showGuessedLocations =>
'Toon de voorspelde locaties van de knopen'; 'Toon de voorspelde locaties van de knopen';
@override
String get map_showDiscoveryContacts => 'Ontdek contacten weergeven';
@override @override
String get map_guessedLocation => 'Geroerde locatie'; String get map_guessedLocation => 'Geroerde locatie';
+55
View File
@@ -117,6 +117,52 @@ class AppLocalizationsPl extends AppLocalizations {
@override @override
String get connectionChoiceBluetoothLabel => 'Bluetooth'; String get connectionChoiceBluetoothLabel => 'Bluetooth';
@override
String get connectionChoiceTcpLabel => 'TCP';
@override
String get tcpScreenTitle => 'Połącz się za pomocą protokołu TCP';
@override
String get tcpHostLabel => 'Adres IP';
@override
String get tcpHostHint => '192.168.40.10';
@override
String get tcpPortLabel => 'Port';
@override
String get tcpPortHint => '5000';
@override
String get tcpStatus_notConnected => 'Wprowadź adres URL i połącz';
@override
String tcpStatus_connectingTo(String endpoint) {
return 'Połączenie z $endpoint...';
}
@override
String get tcpErrorHostRequired => 'Wymagana jest adresa IP.';
@override
String get tcpErrorPortInvalid =>
'Numer portu musi mieścić się w zakresie od 1 do 65535.';
@override
String get tcpErrorUnsupported =>
'Transport protokoł TCP nie jest obsługiwany na tym urządzeniu.';
@override
String get tcpErrorTimedOut =>
'Połączenie TCP zakończyło się bez powodzenia.';
@override
String tcpConnectionFailed(String error) {
return 'Błąd połączenia TCP: $error';
}
@override @override
String get usbScreenTitle => 'Połącz przez USB'; String get usbScreenTitle => 'Połącz przez USB';
@@ -858,6 +904,9 @@ class AppLocalizationsPl extends AppLocalizations {
@override @override
String get contacts_groupNameRequired => 'Nazwa grupy jest wymagana'; String get contacts_groupNameRequired => 'Nazwa grupy jest wymagana';
@override
String get contacts_groupNameReserved => 'Ta nazwa grupy jest zastrzeżona';
@override @override
String contacts_groupAlreadyExists(String name) { String contacts_groupAlreadyExists(String name) {
return 'Grupa \"$name\" już istnieje'; return 'Grupa \"$name\" już istnieje';
@@ -1466,6 +1515,9 @@ class AppLocalizationsPl extends AppLocalizations {
@override @override
String get map_shareMarkerHere => 'Udostępnij znacznik tutaj'; String get map_shareMarkerHere => 'Udostępnij znacznik tutaj';
@override
String get map_setAsMyLocation => 'Ustaw jako moje lokalizację';
@override @override
String get map_pinLabel => 'Oznacz etykietę'; String get map_pinLabel => 'Oznacz etykietę';
@@ -1530,6 +1582,9 @@ class AppLocalizationsPl extends AppLocalizations {
String get map_showGuessedLocations => String get map_showGuessedLocations =>
'Wyświetl lokalizacje zgadanych węzłów'; 'Wyświetl lokalizacje zgadanych węzłów';
@override
String get map_showDiscoveryContacts => 'Pokaż kontakty odkrywania';
@override @override
String get map_guessedLocation => 'Wydana lokalizacja'; String get map_guessedLocation => 'Wydana lokalizacja';
+54
View File
@@ -117,6 +117,51 @@ class AppLocalizationsPt extends AppLocalizations {
@override @override
String get connectionChoiceBluetoothLabel => 'Bluetooth'; String get connectionChoiceBluetoothLabel => 'Bluetooth';
@override
String get connectionChoiceTcpLabel => 'TCP';
@override
String get tcpScreenTitle => 'Estabelecer conexão via TCP';
@override
String get tcpHostLabel => 'Endereço IP';
@override
String get tcpHostHint => '192.168.40.10';
@override
String get tcpPortLabel => 'Porta';
@override
String get tcpPortHint => '5000';
@override
String get tcpStatus_notConnected => 'Insira o endereço final e conecte-se.';
@override
String tcpStatus_connectingTo(String endpoint) {
return 'Conectando a $endpoint...';
}
@override
String get tcpErrorHostRequired => 'É necessário fornecer um endereço IP.';
@override
String get tcpErrorPortInvalid =>
'O valor do porto deve estar entre 1 e 65535.';
@override
String get tcpErrorUnsupported =>
'O protocolo TCP não é suportado nesta plataforma.';
@override
String get tcpErrorTimedOut => 'A conexão TCP expirou.';
@override
String tcpConnectionFailed(String error) {
return 'Falha na conexão TCP: $error';
}
@override @override
String get usbScreenTitle => 'Conecte via USB'; String get usbScreenTitle => 'Conecte via USB';
@@ -858,6 +903,9 @@ class AppLocalizationsPt extends AppLocalizations {
@override @override
String get contacts_groupNameRequired => 'O nome do grupo é obrigatório.'; String get contacts_groupNameRequired => 'O nome do grupo é obrigatório.';
@override
String get contacts_groupNameReserved => 'Este nome de grupo está reservado';
@override @override
String contacts_groupAlreadyExists(String name) { String contacts_groupAlreadyExists(String name) {
return 'O grupo \"$name\" já existe'; return 'O grupo \"$name\" já existe';
@@ -1466,6 +1514,9 @@ class AppLocalizationsPt extends AppLocalizations {
@override @override
String get map_shareMarkerHere => 'Compartilhar marcador aqui'; String get map_shareMarkerHere => 'Compartilhar marcador aqui';
@override
String get map_setAsMyLocation => 'Defina minha localização';
@override @override
String get map_pinLabel => 'Rótulo de marcador'; String get map_pinLabel => 'Rótulo de marcador';
@@ -1530,6 +1581,9 @@ class AppLocalizationsPt extends AppLocalizations {
String get map_showGuessedLocations => String get map_showGuessedLocations =>
'Mostrar as localizações dos nós estimados'; 'Mostrar as localizações dos nós estimados';
@override
String get map_showDiscoveryContacts => 'Mostrar Contatos de Descoberta';
@override @override
String get map_guessedLocation => 'Localização estimada'; String get map_guessedLocation => 'Localização estimada';
+54
View File
@@ -117,6 +117,51 @@ class AppLocalizationsRu extends AppLocalizations {
@override @override
String get connectionChoiceBluetoothLabel => 'Bluetooth'; String get connectionChoiceBluetoothLabel => 'Bluetooth';
@override
String get connectionChoiceTcpLabel => 'TCP';
@override
String get tcpScreenTitle => 'Установить соединение по протоколу TCP';
@override
String get tcpHostLabel => 'IP-адрес';
@override
String get tcpHostHint => '192.168.40.10';
@override
String get tcpPortLabel => 'Порт';
@override
String get tcpPortHint => '5000';
@override
String get tcpStatus_notConnected => 'Введите адрес и подключитесь.';
@override
String tcpStatus_connectingTo(String endpoint) {
return 'Подключение к $endpoint...';
}
@override
String get tcpErrorHostRequired => 'Необходимо указать IP-адрес.';
@override
String get tcpErrorPortInvalid =>
'Порт должен находиться в диапазоне от 1 до 65535.';
@override
String get tcpErrorUnsupported =>
'Протокол TCP не поддерживается на этой платформе.';
@override
String get tcpErrorTimedOut => 'Соединение TCP не удалось установить.';
@override
String tcpConnectionFailed(String error) {
return 'Не удалось установить соединение TCP: $error';
}
@override @override
String get usbScreenTitle => 'Подключение через USB'; String get usbScreenTitle => 'Подключение через USB';
@@ -857,6 +902,9 @@ class AppLocalizationsRu extends AppLocalizations {
@override @override
String get contacts_groupNameRequired => 'Имя группы обязательно'; String get contacts_groupNameRequired => 'Имя группы обязательно';
@override
String get contacts_groupNameReserved => 'Это имя группы зарезервировано';
@override @override
String contacts_groupAlreadyExists(String name) { String contacts_groupAlreadyExists(String name) {
return 'Группа \"$name\" уже существует'; return 'Группа \"$name\" уже существует';
@@ -1468,6 +1516,9 @@ class AppLocalizationsRu extends AppLocalizations {
@override @override
String get map_shareMarkerHere => 'Поделиться меткой здесь'; String get map_shareMarkerHere => 'Поделиться меткой здесь';
@override
String get map_setAsMyLocation => 'Установить мое местоположение';
@override @override
String get map_pinLabel => 'Метка'; String get map_pinLabel => 'Метка';
@@ -1532,6 +1583,9 @@ class AppLocalizationsRu extends AppLocalizations {
String get map_showGuessedLocations => String get map_showGuessedLocations =>
'Отобразить предполагаемые места расположения узлов'; 'Отобразить предполагаемые места расположения узлов';
@override
String get map_showDiscoveryContacts => 'Показать контакты Discovery';
@override @override
String get map_guessedLocation => 'Угаданное место'; String get map_guessedLocation => 'Угаданное место';
+53
View File
@@ -117,6 +117,50 @@ class AppLocalizationsSk extends AppLocalizations {
@override @override
String get connectionChoiceBluetoothLabel => 'Bluetooth'; String get connectionChoiceBluetoothLabel => 'Bluetooth';
@override
String get connectionChoiceTcpLabel => 'TCP';
@override
String get tcpScreenTitle => 'Spojte sa pomocou protokolu TCP';
@override
String get tcpHostLabel => 'IP adresa';
@override
String get tcpHostHint => '192.168.40.10';
@override
String get tcpPortLabel => 'Port';
@override
String get tcpPortHint => '5000';
@override
String get tcpStatus_notConnected => 'Zadajte cieľovú adresu a pripojte sa.';
@override
String tcpStatus_connectingTo(String endpoint) {
return 'Pripojenie k $endpoint...';
}
@override
String get tcpErrorHostRequired => 'Je potrebné zadať IP adresu.';
@override
String get tcpErrorPortInvalid => 'Číslo portu musí byť medzi 1 a 65535.';
@override
String get tcpErrorUnsupported =>
'Prevoz prostredníctvom protokolu TCP nie je na tejto platforme podporovaný.';
@override
String get tcpErrorTimedOut => 'Pripojenie TCP vypršalo.';
@override
String tcpConnectionFailed(String error) {
return 'Neúspešné vytvorenie TCP spojenia: $error';
}
@override @override
String get usbScreenTitle => 'Pripojte cez USB'; String get usbScreenTitle => 'Pripojte cez USB';
@@ -850,6 +894,9 @@ class AppLocalizationsSk extends AppLocalizations {
@override @override
String get contacts_groupNameRequired => 'Skupina musí mať názov.'; String get contacts_groupNameRequired => 'Skupina musí mať názov.';
@override
String get contacts_groupNameReserved => 'Tento názov skupiny je rezervovaný';
@override @override
String contacts_groupAlreadyExists(String name) { String contacts_groupAlreadyExists(String name) {
return 'Skupina \"$name\" už existuje'; return 'Skupina \"$name\" už existuje';
@@ -1460,6 +1507,9 @@ class AppLocalizationsSk extends AppLocalizations {
@override @override
String get map_shareMarkerHere => 'Zdieľte značku tu'; String get map_shareMarkerHere => 'Zdieľte značku tu';
@override
String get map_setAsMyLocation => 'Nastavte ako moju polohu';
@override @override
String get map_pinLabel => 'Označka upozornenia'; String get map_pinLabel => 'Označka upozornenia';
@@ -1524,6 +1574,9 @@ class AppLocalizationsSk extends AppLocalizations {
String get map_showGuessedLocations => String get map_showGuessedLocations =>
'Zobraziť umiestnenia odhadnutých uzlov'; 'Zobraziť umiestnenia odhadnutých uzlov';
@override
String get map_showDiscoveryContacts => 'Zobraziť kontakty objavov';
@override @override
String get map_guessedLocation => 'Odhadnutá lokalita'; String get map_guessedLocation => 'Odhadnutá lokalita';
+53
View File
@@ -117,6 +117,50 @@ class AppLocalizationsSl extends AppLocalizations {
@override @override
String get connectionChoiceBluetoothLabel => 'Bluetooth'; String get connectionChoiceBluetoothLabel => 'Bluetooth';
@override
String get connectionChoiceTcpLabel => 'TCP';
@override
String get tcpScreenTitle => 'Komunicirajte preko protokola TCP';
@override
String get tcpHostLabel => 'IP naslov';
@override
String get tcpHostHint => '192.168.40.10';
@override
String get tcpPortLabel => 'Vrata';
@override
String get tcpPortHint => '5000';
@override
String get tcpStatus_notConnected => 'Vnesite končni naslov in se povežite';
@override
String tcpStatus_connectingTo(String endpoint) {
return 'Povezava z $endpoint...';
}
@override
String get tcpErrorHostRequired => 'Potrebna je IP-naslov.';
@override
String get tcpErrorPortInvalid => 'Port mora biti med 1 in 65535.';
@override
String get tcpErrorUnsupported =>
'Transport preko protokola TCP ni podprt na tej platformi.';
@override
String get tcpErrorTimedOut => 'Povezava TCP je presegla časovno obdobje.';
@override
String tcpConnectionFailed(String error) {
return 'Napaka pri povezavi TCP: $error';
}
@override @override
String get usbScreenTitle => 'Povežite preko USB'; String get usbScreenTitle => 'Povežite preko USB';
@@ -848,6 +892,9 @@ class AppLocalizationsSl extends AppLocalizations {
@override @override
String get contacts_groupNameRequired => 'Ime skupine je obvezno.'; String get contacts_groupNameRequired => 'Ime skupine je obvezno.';
@override
String get contacts_groupNameReserved => 'To ime skupine je rezervirano';
@override @override
String contacts_groupAlreadyExists(String name) { String contacts_groupAlreadyExists(String name) {
return 'Skupina \"$name\" že obstaja'; return 'Skupina \"$name\" že obstaja';
@@ -1454,6 +1501,9 @@ class AppLocalizationsSl extends AppLocalizations {
@override @override
String get map_shareMarkerHere => 'Delite točke tukaj.'; String get map_shareMarkerHere => 'Delite točke tukaj.';
@override
String get map_setAsMyLocation => 'Nastavite to kot mojo lokacijo';
@override @override
String get map_pinLabel => 'Oznaka za pritrditev'; String get map_pinLabel => 'Oznaka za pritrditev';
@@ -1517,6 +1567,9 @@ class AppLocalizationsSl extends AppLocalizations {
@override @override
String get map_showGuessedLocations => 'Pokaži lokacije domnevnih not.'; String get map_showGuessedLocations => 'Pokaži lokacije domnevnih not.';
@override
String get map_showDiscoveryContacts => 'Prikaži odkritja kontaktov';
@override @override
String get map_guessedLocation => 'Predpostavljena lokacija'; String get map_guessedLocation => 'Predpostavljena lokacija';
+53
View File
@@ -117,6 +117,50 @@ class AppLocalizationsSv extends AppLocalizations {
@override @override
String get connectionChoiceBluetoothLabel => 'Bluetooth'; String get connectionChoiceBluetoothLabel => 'Bluetooth';
@override
String get connectionChoiceTcpLabel => 'TCP';
@override
String get tcpScreenTitle => 'Anslut via TCP';
@override
String get tcpHostLabel => 'IP-adress';
@override
String get tcpHostHint => '192.168.40.10';
@override
String get tcpPortLabel => 'Port';
@override
String get tcpPortHint => '5000';
@override
String get tcpStatus_notConnected => 'Ange slutpunkt och anslut';
@override
String tcpStatus_connectingTo(String endpoint) {
return 'Anslutning till $endpoint...';
}
@override
String get tcpErrorHostRequired => 'IP-adress krävs.';
@override
String get tcpErrorPortInvalid => 'Porten måste vara mellan 1 och 65535.';
@override
String get tcpErrorUnsupported =>
'TCP-transport fungerar inte på denna plattform.';
@override
String get tcpErrorTimedOut => 'TCP-anslutningen har tidsut gått.';
@override
String tcpConnectionFailed(String error) {
return 'Fel vid TCP-anslutning: $error';
}
@override @override
String get usbScreenTitle => 'Anslut via USB'; String get usbScreenTitle => 'Anslut via USB';
@@ -844,6 +888,9 @@ class AppLocalizationsSv extends AppLocalizations {
@override @override
String get contacts_groupNameRequired => 'Gruppnamnet är obligatoriskt'; String get contacts_groupNameRequired => 'Gruppnamnet är obligatoriskt';
@override
String get contacts_groupNameReserved => 'Detta gruppnamn är reserverat';
@override @override
String contacts_groupAlreadyExists(String name) { String contacts_groupAlreadyExists(String name) {
return 'Gruppen \"$name\" finns redan.'; return 'Gruppen \"$name\" finns redan.';
@@ -1450,6 +1497,9 @@ class AppLocalizationsSv extends AppLocalizations {
@override @override
String get map_shareMarkerHere => 'Dela markeringen här'; String get map_shareMarkerHere => 'Dela markeringen här';
@override
String get map_setAsMyLocation => 'Ange som min plats';
@override @override
String get map_pinLabel => 'Fästetikett'; String get map_pinLabel => 'Fästetikett';
@@ -1514,6 +1564,9 @@ class AppLocalizationsSv extends AppLocalizations {
String get map_showGuessedLocations => String get map_showGuessedLocations =>
'Visa upp de antagna nodernas placeringar'; 'Visa upp de antagna nodernas placeringar';
@override
String get map_showDiscoveryContacts => 'Visa Discovery-kontakter';
@override @override
String get map_guessedLocation => 'Gissad plats'; String get map_guessedLocation => 'Gissad plats';
+54
View File
@@ -117,6 +117,51 @@ class AppLocalizationsUk extends AppLocalizations {
@override @override
String get connectionChoiceBluetoothLabel => 'Bluetooth'; String get connectionChoiceBluetoothLabel => 'Bluetooth';
@override
String get connectionChoiceTcpLabel => 'TCP';
@override
String get tcpScreenTitle => 'З\'єднатися через протокол TCP';
@override
String get tcpHostLabel => 'IP-адреса';
@override
String get tcpHostHint => '192.168.40.10';
@override
String get tcpPortLabel => 'Порт';
@override
String get tcpPortHint => '5000';
@override
String get tcpStatus_notConnected => 'Введіть кінцеву точку та підключіться';
@override
String tcpStatus_connectingTo(String endpoint) {
return 'Підключення до $endpoint...';
}
@override
String get tcpErrorHostRequired => 'Необхідно вказати IP-адресу.';
@override
String get tcpErrorPortInvalid => 'Порт повинен бути в межах від 1 до 65535.';
@override
String get tcpErrorUnsupported =>
'Транспорт TCP не підтримується на цій платформі.';
@override
String get tcpErrorTimedOut =>
'З\'єднання TCP завершилося через закінчення часу очікування.';
@override
String tcpConnectionFailed(String error) {
return 'Не вдалося встановити з\'єднання TCP: $error';
}
@override @override
String get usbScreenTitle => 'Підключити через USB'; String get usbScreenTitle => 'Підключити через USB';
@@ -853,6 +898,9 @@ class AppLocalizationsUk extends AppLocalizations {
@override @override
String get contacts_groupNameRequired => 'Назва групи обов\'язкова.'; String get contacts_groupNameRequired => 'Назва групи обов\'язкова.';
@override
String get contacts_groupNameReserved => 'Ця назва групи зарезервована';
@override @override
String contacts_groupAlreadyExists(String name) { String contacts_groupAlreadyExists(String name) {
return 'Група «$name» вже існує.'; return 'Група «$name» вже існує.';
@@ -1465,6 +1513,9 @@ class AppLocalizationsUk extends AppLocalizations {
@override @override
String get map_shareMarkerHere => 'Поділитися маркером тут'; String get map_shareMarkerHere => 'Поділитися маркером тут';
@override
String get map_setAsMyLocation => 'Встановити моє місцезнаходження';
@override @override
String get map_pinLabel => 'Мітка піна'; String get map_pinLabel => 'Мітка піна';
@@ -1529,6 +1580,9 @@ class AppLocalizationsUk extends AppLocalizations {
String get map_showGuessedLocations => String get map_showGuessedLocations =>
'Показати місцезнаходження передбачених вузлів'; 'Показати місцезнаходження передбачених вузлів';
@override
String get map_showDiscoveryContacts => 'Показати контакти Відкриття';
@override @override
String get map_guessedLocation => 'Визначено місцезнаходження'; String get map_guessedLocation => 'Визначено місцезнаходження';
+52
View File
@@ -117,6 +117,49 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String get connectionChoiceBluetoothLabel => '蓝牙'; String get connectionChoiceBluetoothLabel => '蓝牙';
@override
String get connectionChoiceTcpLabel => 'TCP';
@override
String get tcpScreenTitle => '通过 TCP 连接';
@override
String get tcpHostLabel => 'IP地址';
@override
String get tcpHostHint => '192.168.40.10';
@override
String get tcpPortLabel => '端口';
@override
String get tcpPortHint => '5000';
@override
String get tcpStatus_notConnected => '输入目标地址,然后连接';
@override
String tcpStatus_connectingTo(String endpoint) {
return '连接到 $endpoint...';
}
@override
String get tcpErrorHostRequired => '需要提供IP地址。';
@override
String get tcpErrorPortInvalid => '端口号必须在 1 到 65535 之间。';
@override
String get tcpErrorUnsupported => '此平台不支持 TCP 传输。';
@override
String get tcpErrorTimedOut => 'TCP 连接超时。';
@override
String tcpConnectionFailed(String error) {
return 'TCP 连接失败:$error';
}
@override @override
String get usbScreenTitle => '通过USB连接'; String get usbScreenTitle => '通过USB连接';
@@ -802,6 +845,9 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String get contacts_groupNameRequired => '请输入群聊名称'; String get contacts_groupNameRequired => '请输入群聊名称';
@override
String get contacts_groupNameReserved => '该群组名称已被保留';
@override @override
String contacts_groupAlreadyExists(String name) { String contacts_groupAlreadyExists(String name) {
return '名为 \"$name\" 的群聊已存在'; return '名为 \"$name\" 的群聊已存在';
@@ -1378,6 +1424,9 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String get map_shareMarkerHere => '在此分享标记'; String get map_shareMarkerHere => '在此分享标记';
@override
String get map_setAsMyLocation => '设置为我的位置';
@override @override
String get map_pinLabel => '标签'; String get map_pinLabel => '标签';
@@ -1440,6 +1489,9 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String get map_showGuessedLocations => '显示猜测的节点位置'; String get map_showGuessedLocations => '显示猜测的节点位置';
@override
String get map_showDiscoveryContacts => '显示发现联系人';
@override @override
String get map_guessedLocation => '猜测的位置'; String get map_guessedLocation => '猜测的位置';
+31 -1
View File
@@ -285,6 +285,7 @@
"contacts_newGroup": "Nieuwe Groep", "contacts_newGroup": "Nieuwe Groep",
"contacts_groupName": "Groepnaam", "contacts_groupName": "Groepnaam",
"contacts_groupNameRequired": "De groepnaam is verplicht.", "contacts_groupNameRequired": "De groepnaam is verplicht.",
"contacts_groupNameReserved": "Deze groepsnaam is gereserveerd",
"contacts_groupAlreadyExists": "De groep \"{name}\" bestaat al.", "contacts_groupAlreadyExists": "De groep \"{name}\" bestaat al.",
"@contacts_groupAlreadyExists": { "@contacts_groupAlreadyExists": {
"placeholders": { "placeholders": {
@@ -1859,5 +1860,34 @@
"usbStatus_notConnected": "Selecteer een USB-apparaat", "usbStatus_notConnected": "Selecteer een USB-apparaat",
"usbStatus_connecting": "Verbinding maken met USB-apparaat...", "usbStatus_connecting": "Verbinding maken met USB-apparaat...",
"usbStatus_searching": "Zoeken naar USB-apparaten...", "usbStatus_searching": "Zoeken naar USB-apparaten...",
"usbErrorConnectTimedOut": "Verbinding is verbroken. Zorg ervoor dat het apparaat de juiste USB-firmware heeft." "usbErrorConnectTimedOut": "Verbinding is verbroken. Zorg ervoor dat het apparaat de juiste USB-firmware heeft.",
"@tcpStatus_connectingTo": {
"placeholders": {
"endpoint": {
"type": "String"
}
}
},
"@tcpConnectionFailed": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"tcpScreenTitle": "Verbind via TCP",
"tcpHostLabel": "IP-adres",
"tcpHostHint": "192.168.40.10",
"connectionChoiceTcpLabel": "TCP",
"tcpPortLabel": "Poort",
"tcpPortHint": "5000",
"tcpStatus_notConnected": "Voer het eindpunt in en verbind",
"tcpStatus_connectingTo": "Verbinding maken met {endpoint}...",
"tcpErrorHostRequired": "Een IP-adres is vereist.",
"tcpErrorPortInvalid": "De poortwaarde moet tussen 1 en 65535 liggen.",
"tcpErrorUnsupported": "TCP-transport wordt niet ondersteund op deze platform.",
"tcpErrorTimedOut": "De TCP-verbinding is verlopen.",
"tcpConnectionFailed": "Verbinding met TCP mislukt: {error}",
"map_showDiscoveryContacts": "Ontdek contacten weergeven",
"map_setAsMyLocation": "Stel dit in als mijn locatie"
} }
+31 -1
View File
@@ -285,6 +285,7 @@
"contacts_newGroup": "Nowa Grupa", "contacts_newGroup": "Nowa Grupa",
"contacts_groupName": "Nazwa grupy", "contacts_groupName": "Nazwa grupy",
"contacts_groupNameRequired": "Nazwa grupy jest wymagana", "contacts_groupNameRequired": "Nazwa grupy jest wymagana",
"contacts_groupNameReserved": "Ta nazwa grupy jest zastrzeżona",
"contacts_groupAlreadyExists": "Grupa \"{name}\" już istnieje", "contacts_groupAlreadyExists": "Grupa \"{name}\" już istnieje",
"@contacts_groupAlreadyExists": { "@contacts_groupAlreadyExists": {
"placeholders": { "placeholders": {
@@ -1859,5 +1860,34 @@
"usbStatus_connecting": "Połączenie z urządzeniem USB...", "usbStatus_connecting": "Połączenie z urządzeniem USB...",
"usbStatus_notConnected": "Wybierz urządzenie USB", "usbStatus_notConnected": "Wybierz urządzenie USB",
"usbConnectionFailed": "Błąd połączenia USB: {error}", "usbConnectionFailed": "Błąd połączenia USB: {error}",
"usbErrorConnectTimedOut": "Połączenie nie zostało nawiązane. Upewnij się, że urządzenie posiada oprogramowanie \"USB Companion\"." "usbErrorConnectTimedOut": "Połączenie nie zostało nawiązane. Upewnij się, że urządzenie posiada oprogramowanie \"USB Companion\".",
"@tcpStatus_connectingTo": {
"placeholders": {
"endpoint": {
"type": "String"
}
}
},
"@tcpConnectionFailed": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"connectionChoiceTcpLabel": "TCP",
"tcpHostHint": "192.168.40.10",
"tcpScreenTitle": "Połącz się za pomocą protokołu TCP",
"tcpHostLabel": "Adres IP",
"tcpPortLabel": "Port",
"tcpPortHint": "5000",
"tcpStatus_notConnected": "Wprowadź adres URL i połącz",
"tcpStatus_connectingTo": "Połączenie z {endpoint}...",
"tcpErrorHostRequired": "Wymagana jest adresa IP.",
"tcpErrorPortInvalid": "Numer portu musi mieścić się w zakresie od 1 do 65535.",
"tcpErrorUnsupported": "Transport protokoł TCP nie jest obsługiwany na tym urządzeniu.",
"tcpErrorTimedOut": "Połączenie TCP zakończyło się bez powodzenia.",
"tcpConnectionFailed": "Błąd połączenia TCP: {error}",
"map_showDiscoveryContacts": "Pokaż kontakty odkrywania",
"map_setAsMyLocation": "Ustaw jako moje lokalizację"
} }
+31 -1
View File
@@ -285,6 +285,7 @@
"contacts_newGroup": "Novo Grupo", "contacts_newGroup": "Novo Grupo",
"contacts_groupName": "Nome do grupo", "contacts_groupName": "Nome do grupo",
"contacts_groupNameRequired": "O nome do grupo é obrigatório.", "contacts_groupNameRequired": "O nome do grupo é obrigatório.",
"contacts_groupNameReserved": "Este nome de grupo está reservado",
"contacts_groupAlreadyExists": "O grupo \"{name}\" já existe", "contacts_groupAlreadyExists": "O grupo \"{name}\" já existe",
"@contacts_groupAlreadyExists": { "@contacts_groupAlreadyExists": {
"placeholders": { "placeholders": {
@@ -1859,5 +1860,34 @@
"usbStatus_notConnected": "Selecione um dispositivo USB", "usbStatus_notConnected": "Selecione um dispositivo USB",
"usbConnectionFailed": "Falha na conexão USB: {error}", "usbConnectionFailed": "Falha na conexão USB: {error}",
"usbStatus_connecting": "Conectando ao dispositivo USB...", "usbStatus_connecting": "Conectando ao dispositivo USB...",
"usbErrorConnectTimedOut": "A conexão expirou. Verifique se o dispositivo possui o firmware USB Companion." "usbErrorConnectTimedOut": "A conexão expirou. Verifique se o dispositivo possui o firmware USB Companion.",
"@tcpStatus_connectingTo": {
"placeholders": {
"endpoint": {
"type": "String"
}
}
},
"@tcpConnectionFailed": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"tcpHostLabel": "Endereço IP",
"connectionChoiceTcpLabel": "TCP",
"tcpScreenTitle": "Estabelecer conexão via TCP",
"tcpHostHint": "192.168.40.10",
"tcpPortLabel": "Porta",
"tcpPortHint": "5000",
"tcpStatus_notConnected": "Insira o endereço final e conecte-se.",
"tcpStatus_connectingTo": "Conectando a {endpoint}...",
"tcpErrorHostRequired": "É necessário fornecer um endereço IP.",
"tcpErrorPortInvalid": "O valor do porto deve estar entre 1 e 65535.",
"tcpErrorUnsupported": "O protocolo TCP não é suportado nesta plataforma.",
"tcpErrorTimedOut": "A conexão TCP expirou.",
"tcpConnectionFailed": "Falha na conexão TCP: {error}",
"map_showDiscoveryContacts": "Mostrar Contatos de Descoberta",
"map_setAsMyLocation": "Defina minha localização"
} }
+31 -1
View File
@@ -212,6 +212,7 @@
"contacts_newGroup": "Новая группа", "contacts_newGroup": "Новая группа",
"contacts_groupName": "Имя группы", "contacts_groupName": "Имя группы",
"contacts_groupNameRequired": "Имя группы обязательно", "contacts_groupNameRequired": "Имя группы обязательно",
"contacts_groupNameReserved": "Это имя группы зарезервировано",
"contacts_groupAlreadyExists": "Группа \"{name}\" уже существует", "contacts_groupAlreadyExists": "Группа \"{name}\" уже существует",
"contacts_filterContacts": "Фильтр контактов...", "contacts_filterContacts": "Фильтр контактов...",
"contacts_noContactsMatchFilter": "Нет контактов, соответствующих фильтру", "contacts_noContactsMatchFilter": "Нет контактов, соответствующих фильтру",
@@ -1099,5 +1100,34 @@
"usbStatus_connecting": "Подключение к USB-устройству...", "usbStatus_connecting": "Подключение к USB-устройству...",
"usbConnectionFailed": "Не удалось установить соединение через USB: {error}", "usbConnectionFailed": "Не удалось установить соединение через USB: {error}",
"usbStatus_notConnected": "Выберите USB-устройство", "usbStatus_notConnected": "Выберите USB-устройство",
"usbErrorConnectTimedOut": "Соединение не установлено. Убедитесь, что устройство имеет установленное программное обеспечение USB Companion." "usbErrorConnectTimedOut": "Соединение не установлено. Убедитесь, что устройство имеет установленное программное обеспечение USB Companion.",
"@tcpStatus_connectingTo": {
"placeholders": {
"endpoint": {
"type": "String"
}
}
},
"@tcpConnectionFailed": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"tcpHostHint": "192.168.40.10",
"connectionChoiceTcpLabel": "TCP",
"tcpHostLabel": "IP-адрес",
"tcpScreenTitle": "Установить соединение по протоколу TCP",
"tcpPortLabel": "Порт",
"tcpPortHint": "5000",
"tcpStatus_notConnected": "Введите адрес и подключитесь.",
"tcpStatus_connectingTo": "Подключение к {endpoint}...",
"tcpErrorHostRequired": "Необходимо указать IP-адрес.",
"tcpErrorPortInvalid": "Порт должен находиться в диапазоне от 1 до 65535.",
"tcpErrorUnsupported": "Протокол TCP не поддерживается на этой платформе.",
"tcpErrorTimedOut": "Соединение TCP не удалось установить.",
"tcpConnectionFailed": "Не удалось установить соединение TCP: {error}",
"map_showDiscoveryContacts": "Показать контакты Discovery",
"map_setAsMyLocation": "Установить мое местоположение"
} }
+31 -1
View File
@@ -285,6 +285,7 @@
"contacts_newGroup": "Nová skupina", "contacts_newGroup": "Nová skupina",
"contacts_groupName": "Názov skupiny", "contacts_groupName": "Názov skupiny",
"contacts_groupNameRequired": "Skupina musí mať názov.", "contacts_groupNameRequired": "Skupina musí mať názov.",
"contacts_groupNameReserved": "Tento názov skupiny je rezervovaný",
"contacts_groupAlreadyExists": "Skupina \"{name}\" už existuje", "contacts_groupAlreadyExists": "Skupina \"{name}\" už existuje",
"@contacts_groupAlreadyExists": { "@contacts_groupAlreadyExists": {
"placeholders": { "placeholders": {
@@ -1859,5 +1860,34 @@
"usbConnectionFailed": "Neúspešné pripojenie cez USB: {error}", "usbConnectionFailed": "Neúspešné pripojenie cez USB: {error}",
"usbStatus_notConnected": "Vyberte USB zariadenie", "usbStatus_notConnected": "Vyberte USB zariadenie",
"usbStatus_connecting": "Pripojenie k USB zariadeniu...", "usbStatus_connecting": "Pripojenie k USB zariadeniu...",
"usbErrorConnectTimedOut": "Pripojenie nebolo úspešné. Uistite sa, že zariadenie má nainštalovaný firmware USB Companion." "usbErrorConnectTimedOut": "Pripojenie nebolo úspešné. Uistite sa, že zariadenie má nainštalovaný firmware USB Companion.",
"@tcpStatus_connectingTo": {
"placeholders": {
"endpoint": {
"type": "String"
}
}
},
"@tcpConnectionFailed": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"tcpHostHint": "192.168.40.10",
"tcpHostLabel": "IP adresa",
"tcpScreenTitle": "Spojte sa pomocou protokolu TCP",
"connectionChoiceTcpLabel": "TCP",
"tcpPortLabel": "Port",
"tcpPortHint": "5000",
"tcpStatus_notConnected": "Zadajte cieľovú adresu a pripojte sa.",
"tcpStatus_connectingTo": "Pripojenie k {endpoint}...",
"tcpErrorHostRequired": "Je potrebné zadať IP adresu.",
"tcpErrorPortInvalid": "Číslo portu musí byť medzi 1 a 65535.",
"tcpErrorUnsupported": "Prevoz prostredníctvom protokolu TCP nie je na tejto platforme podporovaný.",
"tcpErrorTimedOut": "Pripojenie TCP vypršalo.",
"tcpConnectionFailed": "Neúspešné vytvorenie TCP spojenia: {error}",
"map_showDiscoveryContacts": "Zobraziť kontakty objavov",
"map_setAsMyLocation": "Nastavte ako moju polohu"
} }
+31 -1
View File
@@ -285,6 +285,7 @@
"contacts_newGroup": "Nova skupina", "contacts_newGroup": "Nova skupina",
"contacts_groupName": "Ime skupine", "contacts_groupName": "Ime skupine",
"contacts_groupNameRequired": "Ime skupine je obvezno.", "contacts_groupNameRequired": "Ime skupine je obvezno.",
"contacts_groupNameReserved": "To ime skupine je rezervirano",
"contacts_groupAlreadyExists": "Skupina \"{name}\" že obstaja", "contacts_groupAlreadyExists": "Skupina \"{name}\" že obstaja",
"@contacts_groupAlreadyExists": { "@contacts_groupAlreadyExists": {
"placeholders": { "placeholders": {
@@ -1859,5 +1860,34 @@
"usbStatus_connecting": "Povezava z USB napravo...", "usbStatus_connecting": "Povezava z USB napravo...",
"usbStatus_searching": "Iskanje USB naprav...", "usbStatus_searching": "Iskanje USB naprav...",
"usbConnectionFailed": "Napaka pri povezavi preko USB: {error}", "usbConnectionFailed": "Napaka pri povezavi preko USB: {error}",
"usbErrorConnectTimedOut": "Vzpostavitve ni bilo mogo. Prosimo, da se prepričate, da ima naprave trenutno nameštan firmware USB Companion." "usbErrorConnectTimedOut": "Vzpostavitve ni bilo mogo. Prosimo, da se prepričate, da ima naprave trenutno nameštan firmware USB Companion.",
"@tcpStatus_connectingTo": {
"placeholders": {
"endpoint": {
"type": "String"
}
}
},
"@tcpConnectionFailed": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"connectionChoiceTcpLabel": "TCP",
"tcpHostLabel": "IP naslov",
"tcpHostHint": "192.168.40.10",
"tcpScreenTitle": "Komunicirajte preko protokola TCP",
"tcpPortLabel": "Vrata",
"tcpPortHint": "5000",
"tcpStatus_notConnected": "Vnesite končni naslov in se povežite",
"tcpStatus_connectingTo": "Povezava z {endpoint}...",
"tcpErrorHostRequired": "Potrebna je IP-naslov.",
"tcpErrorPortInvalid": "Port mora biti med 1 in 65535.",
"tcpErrorUnsupported": "Transport preko protokola TCP ni podprt na tej platformi.",
"tcpErrorTimedOut": "Povezava TCP je presegla časovno obdobje.",
"tcpConnectionFailed": "Napaka pri povezavi TCP: {error}",
"map_showDiscoveryContacts": "Prikaži odkritja kontaktov",
"map_setAsMyLocation": "Nastavite to kot mojo lokacijo"
} }
+31 -1
View File
@@ -285,6 +285,7 @@
"contacts_newGroup": "Ny grupp", "contacts_newGroup": "Ny grupp",
"contacts_groupName": "Gruppnamn", "contacts_groupName": "Gruppnamn",
"contacts_groupNameRequired": "Gruppnamnet är obligatoriskt", "contacts_groupNameRequired": "Gruppnamnet är obligatoriskt",
"contacts_groupNameReserved": "Detta gruppnamn är reserverat",
"contacts_groupAlreadyExists": "Gruppen \"{name}\" finns redan.", "contacts_groupAlreadyExists": "Gruppen \"{name}\" finns redan.",
"@contacts_groupAlreadyExists": { "@contacts_groupAlreadyExists": {
"placeholders": { "placeholders": {
@@ -1859,5 +1860,34 @@
"usbStatus_notConnected": "Välj en USB-enhet", "usbStatus_notConnected": "Välj en USB-enhet",
"usbConnectionFailed": "Fel vid USB-anslutning: {error}", "usbConnectionFailed": "Fel vid USB-anslutning: {error}",
"usbStatus_searching": "Söker efter USB-enheter...", "usbStatus_searching": "Söker efter USB-enheter...",
"usbErrorConnectTimedOut": "Anslutningen har tidsutgått. Se till att enheten har rätt USB-firmware." "usbErrorConnectTimedOut": "Anslutningen har tidsutgått. Se till att enheten har rätt USB-firmware.",
"@tcpStatus_connectingTo": {
"placeholders": {
"endpoint": {
"type": "String"
}
}
},
"@tcpConnectionFailed": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"tcpHostHint": "192.168.40.10",
"tcpHostLabel": "IP-adress",
"tcpScreenTitle": "Anslut via TCP",
"connectionChoiceTcpLabel": "TCP",
"tcpPortLabel": "Port",
"tcpPortHint": "5000",
"tcpStatus_notConnected": "Ange slutpunkt och anslut",
"tcpStatus_connectingTo": "Anslutning till {endpoint}...",
"tcpErrorHostRequired": "IP-adress krävs.",
"tcpErrorPortInvalid": "Porten måste vara mellan 1 och 65535.",
"tcpErrorUnsupported": "TCP-transport fungerar inte på denna plattform.",
"tcpErrorTimedOut": "TCP-anslutningen har tidsut gått.",
"tcpConnectionFailed": "Fel vid TCP-anslutning: {error}",
"map_showDiscoveryContacts": "Visa Discovery-kontakter",
"map_setAsMyLocation": "Ange som min plats"
} }
+31 -1
View File
@@ -286,6 +286,7 @@
"contacts_newGroup": "Нова група", "contacts_newGroup": "Нова група",
"contacts_groupName": "Назва групи", "contacts_groupName": "Назва групи",
"contacts_groupNameRequired": "Назва групи обов'язкова.", "contacts_groupNameRequired": "Назва групи обов'язкова.",
"contacts_groupNameReserved": "Ця назва групи зарезервована",
"contacts_groupAlreadyExists": "Група «{name}» вже існує.", "contacts_groupAlreadyExists": "Група «{name}» вже існує.",
"@contacts_groupAlreadyExists": { "@contacts_groupAlreadyExists": {
"placeholders": { "placeholders": {
@@ -1859,5 +1860,34 @@
"usbStatus_notConnected": "Виберіть пристрій USB", "usbStatus_notConnected": "Виберіть пристрій USB",
"usbConnectionFailed": "Не вдалося встановити з'єднання через USB: {error}", "usbConnectionFailed": "Не вдалося встановити з'єднання через USB: {error}",
"usbStatus_connecting": "Підключення до USB-пристрою...", "usbStatus_connecting": "Підключення до USB-пристрою...",
"usbErrorConnectTimedOut": "З'єднання не вдалося встановити. Переконайтеся, що пристрій має встановлене програмне забезпечення USB Companion." "usbErrorConnectTimedOut": "З'єднання не вдалося встановити. Переконайтеся, що пристрій має встановлене програмне забезпечення USB Companion.",
"@tcpStatus_connectingTo": {
"placeholders": {
"endpoint": {
"type": "String"
}
}
},
"@tcpConnectionFailed": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"connectionChoiceTcpLabel": "TCP",
"tcpHostHint": "192.168.40.10",
"tcpHostLabel": "IP-адреса",
"tcpScreenTitle": "З'єднатися через протокол TCP",
"tcpPortLabel": "Порт",
"tcpPortHint": "5000",
"tcpStatus_notConnected": "Введіть кінцеву точку та підключіться",
"tcpStatus_connectingTo": "Підключення до {endpoint}...",
"tcpErrorHostRequired": "Необхідно вказати IP-адресу.",
"tcpErrorPortInvalid": "Порт повинен бути в межах від 1 до 65535.",
"tcpErrorUnsupported": "Транспорт TCP не підтримується на цій платформі.",
"tcpErrorTimedOut": "З'єднання TCP завершилося через закінчення часу очікування.",
"tcpConnectionFailed": "Не вдалося встановити з'єднання TCP: {error}",
"map_showDiscoveryContacts": "Показати контакти Відкриття",
"map_setAsMyLocation": "Встановити моє місцезнаходження"
} }
+31 -1
View File
@@ -300,6 +300,7 @@
"contacts_newGroup": "新建群聊", "contacts_newGroup": "新建群聊",
"contacts_groupName": "群聊名称", "contacts_groupName": "群聊名称",
"contacts_groupNameRequired": "请输入群聊名称", "contacts_groupNameRequired": "请输入群聊名称",
"contacts_groupNameReserved": "该群组名称已被保留",
"contacts_groupAlreadyExists": "名为 \"{name}\" 的群聊已存在", "contacts_groupAlreadyExists": "名为 \"{name}\" 的群聊已存在",
"@contacts_groupAlreadyExists": { "@contacts_groupAlreadyExists": {
"placeholders": { "placeholders": {
@@ -1864,5 +1865,34 @@
"usbStatus_connecting": "连接USB设备...", "usbStatus_connecting": "连接USB设备...",
"usbStatus_notConnected": "选择一个 USB 设备", "usbStatus_notConnected": "选择一个 USB 设备",
"usbConnectionFailed": "USB 连接失败:{error}", "usbConnectionFailed": "USB 连接失败:{error}",
"usbErrorConnectTimedOut": "连接超时。请确保设备已安装 USB 伴侣固件。" "usbErrorConnectTimedOut": "连接超时。请确保设备已安装 USB 伴侣固件。",
"@tcpStatus_connectingTo": {
"placeholders": {
"endpoint": {
"type": "String"
}
}
},
"@tcpConnectionFailed": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"tcpHostLabel": "IP地址",
"tcpHostHint": "192.168.40.10",
"tcpScreenTitle": "通过 TCP 连接",
"connectionChoiceTcpLabel": "TCP",
"tcpPortLabel": "端口",
"tcpPortHint": "5000",
"tcpStatus_notConnected": "输入目标地址,然后连接",
"tcpStatus_connectingTo": "连接到 {endpoint}...",
"tcpErrorHostRequired": "需要提供IP地址。",
"tcpErrorPortInvalid": "端口号必须在 1 到 65535 之间。",
"tcpErrorUnsupported": "此平台不支持 TCP 传输。",
"tcpErrorTimedOut": "TCP 连接超时。",
"tcpConnectionFailed": "TCP 连接失败:{error}",
"map_showDiscoveryContacts": "显示发现联系人",
"map_setAsMyLocation": "设置为我的位置"
} }
+15
View File
@@ -19,6 +19,8 @@ import 'services/app_debug_log_service.dart';
import 'services/background_service.dart'; import 'services/background_service.dart';
import 'services/map_tile_cache_service.dart'; import 'services/map_tile_cache_service.dart';
import 'services/chat_text_scale_service.dart'; import 'services/chat_text_scale_service.dart';
import 'services/ui_view_state_service.dart';
import 'services/timeout_prediction_service.dart';
import 'storage/prefs_manager.dart'; import 'storage/prefs_manager.dart';
import 'utils/app_logger.dart'; import 'utils/app_logger.dart';
@@ -39,6 +41,8 @@ void main() async {
final backgroundService = BackgroundService(); final backgroundService = BackgroundService();
final mapTileCacheService = MapTileCacheService(); final mapTileCacheService = MapTileCacheService();
final chatTextScaleService = ChatTextScaleService(); final chatTextScaleService = ChatTextScaleService();
final uiViewStateService = UiViewStateService();
final timeoutPredictionService = TimeoutPredictionService(storage);
// Load settings // Load settings
await appSettingsService.loadSettings(); await appSettingsService.loadSettings();
@@ -56,6 +60,8 @@ void main() async {
_registerThirdPartyLicenses(); _registerThirdPartyLicenses();
await chatTextScaleService.initialize(); await chatTextScaleService.initialize();
await uiViewStateService.initialize();
await timeoutPredictionService.initialize();
// Wire up connector with services // Wire up connector with services
connector.initialize( connector.initialize(
@@ -65,6 +71,7 @@ void main() async {
bleDebugLogService: bleDebugLogService, bleDebugLogService: bleDebugLogService,
appDebugLogService: appDebugLogService, appDebugLogService: appDebugLogService,
backgroundService: backgroundService, backgroundService: backgroundService,
timeoutPredictionService: timeoutPredictionService,
); );
await connector.loadContactCache(); await connector.loadContactCache();
@@ -86,6 +93,8 @@ void main() async {
appDebugLogService: appDebugLogService, appDebugLogService: appDebugLogService,
mapTileCacheService: mapTileCacheService, mapTileCacheService: mapTileCacheService,
chatTextScaleService: chatTextScaleService, chatTextScaleService: chatTextScaleService,
uiViewStateService: uiViewStateService,
timeoutPredictionService: timeoutPredictionService,
), ),
); );
} }
@@ -121,6 +130,8 @@ class MeshCoreApp extends StatelessWidget {
final AppDebugLogService appDebugLogService; final AppDebugLogService appDebugLogService;
final MapTileCacheService mapTileCacheService; final MapTileCacheService mapTileCacheService;
final ChatTextScaleService chatTextScaleService; final ChatTextScaleService chatTextScaleService;
final UiViewStateService uiViewStateService;
final TimeoutPredictionService timeoutPredictionService;
const MeshCoreApp({ const MeshCoreApp({
super.key, super.key,
@@ -133,6 +144,8 @@ class MeshCoreApp extends StatelessWidget {
required this.appDebugLogService, required this.appDebugLogService,
required this.mapTileCacheService, required this.mapTileCacheService,
required this.chatTextScaleService, required this.chatTextScaleService,
required this.uiViewStateService,
required this.timeoutPredictionService,
}); });
@override @override
@@ -146,8 +159,10 @@ class MeshCoreApp extends StatelessWidget {
ChangeNotifierProvider.value(value: bleDebugLogService), ChangeNotifierProvider.value(value: bleDebugLogService),
ChangeNotifierProvider.value(value: appDebugLogService), ChangeNotifierProvider.value(value: appDebugLogService),
ChangeNotifierProvider.value(value: chatTextScaleService), ChangeNotifierProvider.value(value: chatTextScaleService),
ChangeNotifierProvider.value(value: uiViewStateService),
Provider.value(value: storage), Provider.value(value: storage),
Provider.value(value: mapTileCacheService), Provider.value(value: mapTileCacheService),
ChangeNotifierProvider.value(value: timeoutPredictionService),
], ],
child: Consumer<AppSettingsService>( child: Consumer<AppSettingsService>(
builder: (context, settingsService, child) { builder: (context, settingsService, child) {
+20
View File
@@ -39,6 +39,9 @@ class AppSettings {
final Map<String, String> batteryChemistryByRepeaterId; final Map<String, String> batteryChemistryByRepeaterId;
final UnitSystem unitSystem; final UnitSystem unitSystem;
final Set<String> mutedChannels; final Set<String> mutedChannels;
final bool mapShowDiscoveryContacts;
final String tcpServerAddress;
final int tcpServerPort;
AppSettings({ AppSettings({
this.clearPathOnMaxRetry = false, this.clearPathOnMaxRetry = false,
@@ -66,6 +69,9 @@ class AppSettings {
Map<String, String>? batteryChemistryByRepeaterId, Map<String, String>? batteryChemistryByRepeaterId,
this.unitSystem = UnitSystem.metric, this.unitSystem = UnitSystem.metric,
Set<String>? mutedChannels, Set<String>? mutedChannels,
this.mapShowDiscoveryContacts = true,
this.tcpServerAddress = '',
this.tcpServerPort = 0,
}) : batteryChemistryByDeviceId = batteryChemistryByDeviceId ?? {}, }) : batteryChemistryByDeviceId = batteryChemistryByDeviceId ?? {},
batteryChemistryByRepeaterId = batteryChemistryByRepeaterId ?? {}, batteryChemistryByRepeaterId = batteryChemistryByRepeaterId ?? {},
mutedChannels = mutedChannels ?? {}; mutedChannels = mutedChannels ?? {};
@@ -97,6 +103,9 @@ class AppSettings {
'battery_chemistry_by_repeater_id': batteryChemistryByRepeaterId, 'battery_chemistry_by_repeater_id': batteryChemistryByRepeaterId,
'unit_system': unitSystem.value, 'unit_system': unitSystem.value,
'muted_channels': mutedChannels.toList(), 'muted_channels': mutedChannels.toList(),
'map_show_discovery_contacts': mapShowDiscoveryContacts,
'tcp_server_address': tcpServerAddress,
'tcp_server_port': tcpServerPort,
}; };
} }
@@ -152,6 +161,10 @@ class AppSettings {
?.map((e) => e.toString()) ?.map((e) => e.toString())
.toSet()) ?? .toSet()) ??
{}, {},
mapShowDiscoveryContacts:
json['map_show_discovery_contacts'] as bool? ?? true,
tcpServerAddress: json['tcp_server_address'] as String? ?? '',
tcpServerPort: json['tcp_server_port'] as int? ?? 0,
); );
} }
@@ -181,6 +194,9 @@ class AppSettings {
Map<String, String>? batteryChemistryByRepeaterId, Map<String, String>? batteryChemistryByRepeaterId,
UnitSystem? unitSystem, UnitSystem? unitSystem,
Set<String>? mutedChannels, Set<String>? mutedChannels,
bool? mapShowDiscoveryContacts,
String? tcpServerAddress,
int? tcpServerPort,
}) { }) {
return AppSettings( return AppSettings(
clearPathOnMaxRetry: clearPathOnMaxRetry ?? this.clearPathOnMaxRetry, clearPathOnMaxRetry: clearPathOnMaxRetry ?? this.clearPathOnMaxRetry,
@@ -217,6 +233,10 @@ class AppSettings {
batteryChemistryByRepeaterId ?? this.batteryChemistryByRepeaterId, batteryChemistryByRepeaterId ?? this.batteryChemistryByRepeaterId,
unitSystem: unitSystem ?? this.unitSystem, unitSystem: unitSystem ?? this.unitSystem,
mutedChannels: mutedChannels ?? this.mutedChannels, mutedChannels: mutedChannels ?? this.mutedChannels,
mapShowDiscoveryContacts:
mapShowDiscoveryContacts ?? this.mapShowDiscoveryContacts,
tcpServerAddress: tcpServerAddress ?? this.tcpServerAddress,
tcpServerPort: tcpServerPort ?? this.tcpServerPort,
); );
} }
} }
+44 -58
View File
@@ -1,4 +1,6 @@
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:meshcore_open/utils/app_logger.dart';
import '../connector/meshcore_protocol.dart'; import '../connector/meshcore_protocol.dart';
class Contact { class Contact {
@@ -15,6 +17,8 @@ class Contact {
final double? longitude; final double? longitude;
final DateTime lastSeen; final DateTime lastSeen;
final DateTime lastMessageAt; final DateTime lastMessageAt;
final bool isActive;
final Uint8List? rawPacket;
Contact({ Contact({
required this.publicKey, required this.publicKey,
@@ -29,6 +33,8 @@ class Contact {
this.longitude, this.longitude,
required this.lastSeen, required this.lastSeen,
DateTime? lastMessageAt, DateTime? lastMessageAt,
this.isActive = true,
this.rawPacket,
}) : lastMessageAt = lastMessageAt ?? lastSeen; }) : lastMessageAt = lastMessageAt ?? lastSeen;
String get publicKeyHex => pubKeyToHex(publicKey); String get publicKeyHex => pubKeyToHex(publicKey);
@@ -59,7 +65,17 @@ class Contact {
return '$pathLength hops'; return '$pathLength hops';
} }
bool get hasLocation => latitude != null && longitude != null; bool get hasLocation {
const double epsilon = 1e-6;
final lat = latitude ?? 0.0;
final lon = longitude ?? 0.0;
return (lat.abs() > epsilon || lon.abs() > epsilon) &&
lat >= -90.0 &&
lat <= 90.0 &&
lon >= -180.0 &&
lon <= 180.0;
}
bool get isFavorite => (flags & contactFlagFavorite) != 0; bool get isFavorite => (flags & contactFlagFavorite) != 0;
Contact copyWith({ Contact copyWith({
@@ -76,6 +92,8 @@ class Contact {
double? longitude, double? longitude,
DateTime? lastSeen, DateTime? lastSeen,
DateTime? lastMessageAt, DateTime? lastMessageAt,
bool? isActive,
Uint8List? rawPacket,
}) { }) {
return Contact( return Contact(
publicKey: publicKey ?? this.publicKey, publicKey: publicKey ?? this.publicKey,
@@ -94,11 +112,13 @@ class Contact {
longitude: longitude ?? this.longitude, longitude: longitude ?? this.longitude,
lastSeen: lastSeen ?? this.lastSeen, lastSeen: lastSeen ?? this.lastSeen,
lastMessageAt: lastMessageAt ?? this.lastMessageAt, lastMessageAt: lastMessageAt ?? this.lastMessageAt,
isActive: isActive ?? this.isActive,
rawPacket: rawPacket ?? this.rawPacket,
); );
} }
String get pathIdList { String get pathIdList {
final pathBytes = _pathBytesForDisplay; final pathBytes = pathBytesForDisplay;
if (pathBytes.isEmpty) return ''; if (pathBytes.isEmpty) return '';
final parts = <String>[]; final parts = <String>[];
final groupSize = pathHashSize; final groupSize = pathHashSize;
@@ -120,43 +140,7 @@ class Contact {
return "<${publicKeyHex.substring(0, 8)}...${publicKeyHex.substring(publicKeyHex.length - 8)}>"; return "<${publicKeyHex.substring(0, 8)}...${publicKeyHex.substring(publicKeyHex.length - 8)}>";
} }
Uint8List? get traceRouteBytes { Uint8List get pathBytesForDisplay {
final pathBytes = _pathBytesForDisplay;
Uint8List? traceBytes;
if (pathBytes.isEmpty) {
traceBytes = Uint8List(1);
traceBytes[0] = publicKey[0];
return traceBytes;
}
if (type == advTypeRepeater || type == advTypeRoom) {
final len = (pathBytes.length + pathBytes.length + 1);
traceBytes = Uint8List(len);
traceBytes[pathBytes.length] = publicKey[0];
for (int i = 0; i < pathBytes.length; i++) {
traceBytes[i] = pathBytes[i];
if (i < pathBytes.length) {
traceBytes[len - 1 - i] = pathBytes[i];
}
}
} else {
if (pathBytes.length < 2) {
return pathBytes[0] == 0 ? null : pathBytes;
}
final len = (pathBytes.length + pathBytes.length - 1);
traceBytes = Uint8List(len);
for (int i = 0; i < pathBytes.length; i++) {
traceBytes[i] = pathBytes[i];
if (i < pathBytes.length - 1) {
traceBytes[len - 1 - i] = pathBytes[i];
}
}
}
return traceBytes;
}
Uint8List get _pathBytesForDisplay {
if (pathOverride != null) { if (pathOverride != null) {
if (pathOverride! < 0) return Uint8List(0); if (pathOverride! < 0) return Uint8List(0);
return pathOverrideBytes ?? Uint8List(0); return pathOverrideBytes ?? Uint8List(0);
@@ -166,28 +150,28 @@ class Contact {
static Contact? fromFrame(Uint8List data) { static Contact? fromFrame(Uint8List data) {
if (data.isEmpty) return null; if (data.isEmpty) return null;
if (data[0] != respCodeContact) return null; final reader = BufferReader(data);
try { try {
final pubKey = Uint8List.fromList( final respCode = reader.readByte();
data.sublist(contactPubKeyOffset, contactPubKeyOffset + pubKeySize), if (respCode != respCodeContact && respCode != pushCodeNewAdvert) {
); return null;
final type = data[contactTypeOffset]; }
final flags = data[contactFlagsOffset]; final pubKey = reader.readBytes(pubKeySize);
final pathLen = data[contactPathLenOffset].toSigned(8); final type = reader.readByte();
final flags = reader.readByte();
final pathLen = reader.readByte();
final safePathLen = pathLen > 0 final safePathLen = pathLen > 0
? (pathLen > maxPathSize ? maxPathSize : pathLen) ? (pathLen > maxPathSize ? maxPathSize : pathLen)
: 0; : 0;
final pathBytes = safePathLen > 0 final pathBytes = reader.readBytes(maxPathSize).sublist(0, safePathLen);
? Uint8List.fromList( final name = reader.readCStringGreedy(maxNameSize);
data.sublist(contactPathOffset, contactPathOffset + safePathLen),
) final lastMod = reader.readUInt32LE();
: Uint8List(0);
final name = readCString(data, contactNameOffset, maxNameSize);
final lastmod = readUint32LE(data, contactLastModOffset);
double? lat, lon; double? lat, lon;
final latRaw = readInt32LE(data, contactLatOffset); final latRaw = reader.readInt32LE();
final lonRaw = readInt32LE(data, contactLonOffset); final lonRaw = reader.readInt32LE();
if (latRaw != 0 || lonRaw != 0) { if (latRaw != 0 || lonRaw != 0) {
lat = latRaw / 1e6; lat = latRaw / 1e6;
lon = lonRaw / 1e6; lon = lonRaw / 1e6;
@@ -198,14 +182,16 @@ class Contact {
name: name.isEmpty ? 'Unknown' : name, name: name.isEmpty ? 'Unknown' : name,
type: type, type: type,
flags: flags, flags: flags,
pathLength: pathLen, pathLength: pathLen > 0 ? (pathLen > maxPathSize ? -1 : pathLen) : -1,
path: pathBytes, path: pathBytes,
latitude: lat, latitude: lat,
longitude: lon, longitude: lon,
lastSeen: DateTime.fromMillisecondsSinceEpoch(lastmod * 1000), lastSeen: DateTime.fromMillisecondsSinceEpoch(lastMod * 1000),
isActive: true,
rawPacket: null,
); );
} catch (e) { } catch (e) {
// If parsing fails, return null appLogger.error('Failed to parse contact frame: $e');
return null; return null;
} }
} }
+43
View File
@@ -0,0 +1,43 @@
class DeliveryObservation {
final String contactKey;
final int pathLength;
final int messageBytes;
final int secondsSinceLastRx;
final bool isFlood;
final int deliveryMs;
final DateTime timestamp;
DeliveryObservation({
required this.contactKey,
required this.pathLength,
required this.messageBytes,
required this.secondsSinceLastRx,
required this.isFlood,
required this.deliveryMs,
required this.timestamp,
});
Map<String, dynamic> toJson() {
return {
'contact_key': contactKey,
'path_length': pathLength,
'message_bytes': messageBytes,
'seconds_since_last_rx': secondsSinceLastRx,
'is_flood': isFlood,
'delivery_ms': deliveryMs,
'timestamp': timestamp.toIso8601String(),
};
}
factory DeliveryObservation.fromJson(Map<String, dynamic> json) {
return DeliveryObservation(
contactKey: json['contact_key'] as String,
pathLength: json['path_length'] as int,
messageBytes: json['message_bytes'] as int,
secondsSinceLastRx: json['seconds_since_last_rx'] as int? ?? 0,
isFlood: json['is_flood'] as bool,
deliveryMs: json['delivery_ms'] as int,
timestamp: DateTime.parse(json['timestamp'] as String),
);
}
}
-105
View File
@@ -1,105 +0,0 @@
import 'dart:typed_data';
import '../connector/meshcore_protocol.dart';
class DiscoveryContact {
final Uint8List rawPacket;
final Uint8List publicKey;
final String name;
final int type;
final int pathLength; // -1 = flood, 0+ = direct hops (from device)
final Uint8List path; // Path bytes from device
final double? latitude;
final double? longitude;
final DateTime lastSeen;
DiscoveryContact({
required this.rawPacket,
required this.publicKey,
required this.name,
required this.type,
required this.pathLength,
required this.path,
this.latitude,
this.longitude,
required this.lastSeen,
});
String get publicKeyHex => pubKeyToHex(publicKey);
String get typeLabel {
switch (type) {
case advTypeChat:
return 'Chat';
case advTypeRepeater:
return 'Repeater';
case advTypeRoom:
return 'Room';
case advTypeSensor:
return 'Sensor';
default:
return 'Unknown';
}
}
String get pathLabel {
if (pathLength < 0) return 'Flood';
if (pathLength == 0) return 'Direct';
return '$pathLength hops';
}
bool get hasLocation => latitude != null && longitude != null;
DiscoveryContact copyWith({
Uint8List? rawPacket,
Uint8List? publicKey,
String? name,
int? type,
int? pathLength,
Uint8List? path,
double? latitude,
double? longitude,
DateTime? lastSeen,
}) {
return DiscoveryContact(
rawPacket: rawPacket ?? this.rawPacket,
publicKey: publicKey ?? this.publicKey,
name: name ?? this.name,
type: type ?? this.type,
pathLength: pathLength ?? this.pathLength,
path: path ?? this.path,
latitude: latitude ?? this.latitude,
longitude: longitude ?? this.longitude,
lastSeen: lastSeen ?? this.lastSeen,
);
}
String get pathIdList {
final pathBytes = path;
if (pathBytes.isEmpty) return '';
final parts = <String>[];
final groupSize = pathHashSize;
for (int i = 0; i < pathBytes.length; i += groupSize) {
final end = (i + groupSize) <= pathBytes.length
? (i + groupSize)
: pathBytes.length;
final chunk = pathBytes.sublist(i, end);
parts.add(
chunk
.map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase())
.join(),
);
}
return parts.join(',');
}
String get shortPubKeyHex {
return "<${publicKeyHex.substring(0, 8)}...${publicKeyHex.substring(publicKeyHex.length - 8)}>";
}
@override
bool operator ==(Object other) =>
other is DiscoveryContact && publicKeyHex == other.publicKeyHex;
@override
int get hashCode => publicKeyHex.hashCode;
}
+13
View File
@@ -118,6 +118,19 @@ class _BleDebugLogScreenState extends State<BleDebugLogScreen> {
: Icons.download, : Icons.download,
size: 18, size: 18,
), ),
onLongPress: () async {
await Clipboard.setData(
ClipboardData(
text: entry.payload
.map(
(b) => b
.toRadixString(16)
.padLeft(2, '0'),
)
.join(''),
),
);
},
); );
} }
+7 -9
View File
@@ -40,8 +40,8 @@ class ChannelMessagePathScreen extends StatelessWidget {
final primaryPath = !channelMessage && !message.isOutgoing final primaryPath = !channelMessage && !message.isOutgoing
? Uint8List.fromList(primaryPathTmp.reversed.toList()) ? Uint8List.fromList(primaryPathTmp.reversed.toList())
: primaryPathTmp; : primaryPathTmp;
final contacts = connector.allContacts;
final hops = _buildPathHops(primaryPath, connector.contacts, l10n); final hops = _buildPathHops(primaryPath, contacts, l10n);
final hasHopDetails = primaryPath.isNotEmpty; final hasHopDetails = primaryPath.isNotEmpty;
final observedLabel = _formatObservedHops( final observedLabel = _formatObservedHops(
primaryPath.length, primaryPath.length,
@@ -62,8 +62,9 @@ class ChannelMessagePathScreen extends StatelessWidget {
builder: (context) => PathTraceMapScreen( builder: (context) => PathTraceMapScreen(
title: context.l10n.contacts_repeaterPathTrace, title: context.l10n.contacts_repeaterPathTrace,
path: primaryPath, path: primaryPath,
flipPathRound: true, flipPathAround: true,
reversePathRound: !message.isOutgoing && !channelMessage, reversePathAround:
!(!channelMessage && !message.isOutgoing),
), ),
), ),
), ),
@@ -364,11 +365,8 @@ class _ChannelMessagePathMapScreenState
: selectedPathTmp; : selectedPathTmp;
final selectedIndex = _indexForPath(selectedPath, observedPaths); final selectedIndex = _indexForPath(selectedPath, observedPaths);
final hops = _buildPathHops( final contacts = connector.allContacts;
selectedPath, final hops = _buildPathHops(selectedPath, contacts, context.l10n);
connector.contacts,
context.l10n,
);
final points = <LatLng>[]; final points = <LatLng>[];
+78 -65
View File
@@ -11,6 +11,7 @@ import 'package:uuid/uuid.dart';
import '../connector/meshcore_connector.dart'; import '../connector/meshcore_connector.dart';
import '../l10n/l10n.dart'; import '../l10n/l10n.dart';
import '../services/app_settings_service.dart'; import '../services/app_settings_service.dart';
import '../services/ui_view_state_service.dart';
import '../models/channel.dart'; import '../models/channel.dart';
import '../models/community.dart'; import '../models/community.dart';
import '../storage/community_store.dart'; import '../storage/community_store.dart';
@@ -28,8 +29,6 @@ import 'contacts_screen.dart';
import 'map_screen.dart'; import 'map_screen.dart';
import 'settings_screen.dart'; import 'settings_screen.dart';
enum ChannelSortOption { manual, name, latestMessages, unread }
class ChannelsScreen extends StatefulWidget { class ChannelsScreen extends StatefulWidget {
final bool hideBackButton; final bool hideBackButton;
@@ -43,17 +42,20 @@ class _ChannelsScreenState extends State<ChannelsScreen>
with DisconnectNavigationMixin { with DisconnectNavigationMixin {
final TextEditingController _searchController = TextEditingController(); final TextEditingController _searchController = TextEditingController();
final CommunityStore _communityStore = CommunityStore(); final CommunityStore _communityStore = CommunityStore();
String _searchQuery = '';
Timer? _searchDebounce; Timer? _searchDebounce;
ChannelSortOption _sortOption = ChannelSortOption.manual;
List<Community> _communities = []; List<Community> _communities = [];
// Cache of PSK hex -> Community for quick lookup // Cache of PSK hex -> Community for quick lookup
final Map<String, Community> _pskToCommunity = {}; final Map<String, Community> _pskToCommunity = {};
ChannelMessageStore get _channelMessageStore => ChannelMessageStore();
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_searchController.text = context
.read<UiViewStateService>()
.channelsSearchText;
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
context.read<MeshCoreConnector>().getChannels(); context.read<MeshCoreConnector>().getChannels();
_loadCommunities(); _loadCommunities();
@@ -61,6 +63,8 @@ class _ChannelsScreenState extends State<ChannelsScreen>
} }
Future<void> _loadCommunities() async { Future<void> _loadCommunities() async {
final connector = context.read<MeshCoreConnector>();
_communityStore.setPublicKeyHex = connector.selfPublicKeyHex;
final communities = await _communityStore.loadCommunities(); final communities = await _communityStore.loadCommunities();
if (mounted) { if (mounted) {
setState(() { setState(() {
@@ -106,7 +110,10 @@ class _ChannelsScreenState extends State<ChannelsScreen>
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final connector = context.watch<MeshCoreConnector>(); final connector = context.watch<MeshCoreConnector>();
final viewState = context.watch<UiViewStateService>();
final channelMessageStore = ChannelMessageStore(); final channelMessageStore = ChannelMessageStore();
channelMessageStore.setPublicKeyHex = connector.selfPublicKeyHex;
// Auto-navigate back to scanner if disconnected // Auto-navigate back to scanner if disconnected
if (!checkConnectionAndNavigate(connector)) { if (!checkConnectionAndNavigate(connector)) {
@@ -199,6 +206,7 @@ class _ChannelsScreenState extends State<ChannelsScreen>
final filteredChannels = _filterAndSortChannels( final filteredChannels = _filterAndSortChannels(
channels, channels,
connector, connector,
viewState,
); );
return Column( return Column(
@@ -213,17 +221,19 @@ class _ChannelsScreenState extends State<ChannelsScreen>
suffixIcon: Row( suffixIcon: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
if (_searchQuery.isNotEmpty) if (viewState.channelsSearchText.isNotEmpty)
IconButton( IconButton(
icon: const Icon(Icons.clear), icon: const Icon(Icons.clear),
onPressed: () { onPressed: () {
_searchDebounce?.cancel();
_searchDebounce = null;
_searchController.clear(); _searchController.clear();
setState(() { context
_searchQuery = ''; .read<UiViewStateService>()
}); .setChannelsSearchText('');
}, },
), ),
_buildFilterButton(), _buildFilterButton(viewState),
], ],
), ),
border: OutlineInputBorder( border: OutlineInputBorder(
@@ -240,9 +250,9 @@ class _ChannelsScreenState extends State<ChannelsScreen>
const Duration(milliseconds: 300), const Duration(milliseconds: 300),
() { () {
if (!mounted) return; if (!mounted) return;
setState(() { context
_searchQuery = value.toLowerCase(); .read<UiViewStateService>()
}); .setChannelsSearchText(value);
}, },
); );
}, },
@@ -277,8 +287,9 @@ class _ChannelsScreenState extends State<ChannelsScreen>
), ),
], ],
) )
: (_sortOption == ChannelSortOption.manual && : (viewState.channelsSortOption ==
_searchQuery.isEmpty) ChannelSortOption.manual &&
viewState.channelsSearchText.isEmpty)
? ReorderableListView.builder( ? ReorderableListView.builder(
padding: const EdgeInsets.only( padding: const EdgeInsets.only(
left: 16, left: 16,
@@ -578,59 +589,40 @@ class _ChannelsScreenState extends State<ChannelsScreen>
await showDisconnectDialog(context, connector); await showDisconnectDialog(context, connector);
} }
Widget _buildFilterButton() { Widget _buildFilterButton(UiViewStateService viewState) {
const actionSortManual = 0; return SortFilterMenu<ChannelSortOption>(
const actionSortName = 1;
const actionSortLatest = 2;
const actionSortUnread = 3;
return SortFilterMenu(
tooltip: context.l10n.listFilter_tooltip, tooltip: context.l10n.listFilter_tooltip,
sections: [ sections: [
SortFilterMenuSection( SortFilterMenuSection<ChannelSortOption>(
title: context.l10n.channels_sortBy, title: context.l10n.channels_sortBy,
options: [ options: [
SortFilterMenuOption( SortFilterMenuOption<ChannelSortOption>(
value: actionSortManual, value: ChannelSortOption.manual,
label: context.l10n.channels_sortManual, label: context.l10n.channels_sortManual,
checked: _sortOption == ChannelSortOption.manual, checked: viewState.channelsSortOption == ChannelSortOption.manual,
), ),
SortFilterMenuOption( SortFilterMenuOption<ChannelSortOption>(
value: actionSortName, value: ChannelSortOption.name,
label: context.l10n.channels_sortAZ, label: context.l10n.channels_sortAZ,
checked: _sortOption == ChannelSortOption.name, checked: viewState.channelsSortOption == ChannelSortOption.name,
), ),
SortFilterMenuOption( SortFilterMenuOption<ChannelSortOption>(
value: actionSortLatest, value: ChannelSortOption.latestMessages,
label: context.l10n.channels_sortLatestMessages, label: context.l10n.channels_sortLatestMessages,
checked: _sortOption == ChannelSortOption.latestMessages, checked:
viewState.channelsSortOption ==
ChannelSortOption.latestMessages,
), ),
SortFilterMenuOption( SortFilterMenuOption<ChannelSortOption>(
value: actionSortUnread, value: ChannelSortOption.unread,
label: context.l10n.channels_sortUnread, label: context.l10n.channels_sortUnread,
checked: _sortOption == ChannelSortOption.unread, checked: viewState.channelsSortOption == ChannelSortOption.unread,
), ),
], ],
), ),
], ],
onSelected: (action) { onSelected: (sortOption) {
setState(() { viewState.setChannelsSortOption(sortOption);
switch (action) {
case actionSortManual:
_sortOption = ChannelSortOption.manual;
break;
case actionSortLatest:
_sortOption = ChannelSortOption.latestMessages;
break;
case actionSortUnread:
_sortOption = ChannelSortOption.unread;
break;
case actionSortName:
default:
_sortOption = ChannelSortOption.name;
break;
}
});
}, },
); );
} }
@@ -638,11 +630,14 @@ class _ChannelsScreenState extends State<ChannelsScreen>
List<Channel> _filterAndSortChannels( List<Channel> _filterAndSortChannels(
List<Channel> channels, List<Channel> channels,
MeshCoreConnector connector, MeshCoreConnector connector,
UiViewStateService viewState,
) { ) {
var filtered = channels.where((channel) { var filtered = channels.where((channel) {
if (_searchQuery.isEmpty) return true; if (viewState.channelsSearchText.isEmpty) return true;
final label = _normalizeChannelName(channel); final label = _normalizeChannelName(channel);
return label.toLowerCase().contains(_searchQuery); return label.toLowerCase().contains(
viewState.channelsSearchText.toLowerCase(),
);
}).toList(); }).toList();
int compareByName(Channel a, Channel b) { int compareByName(Channel a, Channel b) {
@@ -651,7 +646,7 @@ class _ChannelsScreenState extends State<ChannelsScreen>
return nameA.toLowerCase().compareTo(nameB.toLowerCase()); return nameA.toLowerCase().compareTo(nameB.toLowerCase());
} }
switch (_sortOption) { switch (viewState.channelsSortOption) {
case ChannelSortOption.manual: case ChannelSortOption.manual:
break; break;
case ChannelSortOption.latestMessages: case ChannelSortOption.latestMessages:
@@ -712,6 +707,8 @@ class _ChannelsScreenState extends State<ChannelsScreen>
bool isRegularHashtag = true; bool isRegularHashtag = true;
Community? selectedCommunity; Community? selectedCommunity;
_communityStore.setPublicKeyHex = connector.selfPublicKeyHex;
showDialog( showDialog(
context: context, context: context,
builder: (dialogContext) => StatefulBuilder( builder: (dialogContext) => StatefulBuilder(
@@ -763,7 +760,9 @@ class _ChannelsScreenState extends State<ChannelsScreen>
); );
} }
Widget? buildExpandedContent() { Widget? buildExpandedContent(
ChannelMessageStore channelMessageStore,
) {
switch (selectedOption) { switch (selectedOption) {
case 0: // Create Private Channel case 0: // Create Private Channel
return Column( return Column(
@@ -788,7 +787,7 @@ class _ChannelsScreenState extends State<ChannelsScreen>
children: [ children: [
Expanded( Expanded(
child: FilledButton( child: FilledButton(
onPressed: () { onPressed: () async {
final name = nameController.text.trim(); final name = nameController.text.trim();
if (name.isEmpty) { if (name.isEmpty) {
ScaffoldMessenger.of( ScaffoldMessenger.of(
@@ -810,7 +809,14 @@ class _ChannelsScreenState extends State<ChannelsScreen>
psk[i] = random.nextInt(256); psk[i] = random.nextInt(256);
} }
Navigator.pop(dialogContext); Navigator.pop(dialogContext);
connector.setChannel(nextIndex, name, psk); await connector.setChannel(
nextIndex,
name,
psk,
);
await channelMessageStore.clearChannelMessages(
nextIndex,
);
if (context.mounted) { if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
@@ -1329,7 +1335,8 @@ class _ChannelsScreenState extends State<ChannelsScreen>
subtitle: subtitle:
dialogContext.l10n.channels_createPrivateChannelDesc, dialogContext.l10n.channels_createPrivateChannelDesc,
), ),
if (selectedOption == 0) buildExpandedContent()!, if (selectedOption == 0)
buildExpandedContent(_channelMessageStore)!,
const Divider(height: 1), const Divider(height: 1),
buildOptionTile( buildOptionTile(
optionIndex: 1, optionIndex: 1,
@@ -1338,7 +1345,8 @@ class _ChannelsScreenState extends State<ChannelsScreen>
subtitle: subtitle:
dialogContext.l10n.channels_joinPrivateChannelDesc, dialogContext.l10n.channels_joinPrivateChannelDesc,
), ),
if (selectedOption == 1) buildExpandedContent()!, if (selectedOption == 1)
buildExpandedContent(_channelMessageStore)!,
if (!hasPublicChannel) ...[ if (!hasPublicChannel) ...[
const Divider(height: 1), const Divider(height: 1),
buildOptionTile( buildOptionTile(
@@ -1348,7 +1356,8 @@ class _ChannelsScreenState extends State<ChannelsScreen>
subtitle: subtitle:
dialogContext.l10n.channels_joinPublicChannelDesc, dialogContext.l10n.channels_joinPublicChannelDesc,
), ),
if (selectedOption == 2) buildExpandedContent()!, if (selectedOption == 2)
buildExpandedContent(_channelMessageStore)!,
], ],
const Divider(height: 1), const Divider(height: 1),
buildOptionTile( buildOptionTile(
@@ -1358,7 +1367,8 @@ class _ChannelsScreenState extends State<ChannelsScreen>
subtitle: subtitle:
dialogContext.l10n.channels_joinHashtagChannelDesc, dialogContext.l10n.channels_joinHashtagChannelDesc,
), ),
if (selectedOption == 3) buildExpandedContent()!, if (selectedOption == 3)
buildExpandedContent(_channelMessageStore)!,
const Divider(height: 1), const Divider(height: 1),
buildOptionTile( buildOptionTile(
optionIndex: 4, optionIndex: 4,
@@ -1366,7 +1376,8 @@ class _ChannelsScreenState extends State<ChannelsScreen>
title: dialogContext.l10n.community_scanQr, title: dialogContext.l10n.community_scanQr,
subtitle: dialogContext.l10n.community_join, subtitle: dialogContext.l10n.community_join,
), ),
if (selectedOption == 4) buildExpandedContent()!, if (selectedOption == 4)
buildExpandedContent(_channelMessageStore)!,
const Divider(height: 1), const Divider(height: 1),
buildOptionTile( buildOptionTile(
optionIndex: 5, optionIndex: 5,
@@ -1374,7 +1385,8 @@ class _ChannelsScreenState extends State<ChannelsScreen>
title: dialogContext.l10n.community_create, title: dialogContext.l10n.community_create,
subtitle: dialogContext.l10n.community_createDesc, subtitle: dialogContext.l10n.community_createDesc,
), ),
if (selectedOption == 5) buildExpandedContent()!, if (selectedOption == 5)
buildExpandedContent(_channelMessageStore)!,
], ],
), ),
), ),
@@ -1524,7 +1536,7 @@ class _ChannelsScreenState extends State<ChannelsScreen>
try { try {
await connector.deleteChannel(channel.index); await connector.deleteChannel(channel.index);
channelMessageStore.clearChannelMessages(channel.index); await channelMessageStore.clearChannelMessages(channel.index);
if (!context.mounted) return; if (!context.mounted) return;
@@ -1749,6 +1761,7 @@ class _ChannelsScreenState extends State<ChannelsScreen>
} }
final channelCount = communityChannels.length; final channelCount = communityChannels.length;
_communityStore.setPublicKeyHex = connector.selfPublicKeyHex;
showDialog( showDialog(
context: context, context: context,
+76 -32
View File
@@ -106,10 +106,9 @@ class _ChatScreenState extends State<ChatScreen> {
final unreadLabel = context.l10n.chat_unread(unreadCount); final unreadLabel = context.l10n.chat_unread(unreadCount);
final pathLabel = _currentPathLabel(contact); final pathLabel = _currentPathLabel(contact);
// Show path details if we have path data (from device or override) // Show path details if we have non-empty path data (from device or override)
final hasPathData =
contact.path.isNotEmpty || contact.pathOverrideBytes != null;
final effectivePath = contact.pathOverrideBytes ?? contact.path; final effectivePath = contact.pathOverrideBytes ?? contact.path;
final hasPathData = effectivePath.isNotEmpty;
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -143,12 +142,25 @@ class _ChatScreenState extends State<ChatScreen> {
final contact = _resolveContact(connector); final contact = _resolveContact(connector);
final isFloodMode = contact.pathOverride == -1; final isFloodMode = contact.pathOverride == -1;
final isDirectMode = contact.pathOverride == 0;
final activeMode = isFloodMode
? 'flood'
: isDirectMode
? 'direct'
: 'auto';
return PopupMenuButton<String>( return PopupMenuButton<String>(
icon: Icon(isFloodMode ? Icons.waves : Icons.route), icon: Icon(isFloodMode ? Icons.waves : Icons.route),
tooltip: context.l10n.chat_routingMode, tooltip: context.l10n.chat_routingMode,
onSelected: (mode) async { onSelected: (mode) async {
if (mode == 'flood') { if (mode == 'flood') {
await connector.setPathOverride(contact, pathLen: -1); await connector.setPathOverride(contact, pathLen: -1);
} else if (mode == 'direct') {
await connector.setPathOverride(
contact,
pathLen: 0,
pathBytes: Uint8List(0),
);
} else { } else {
await connector.setPathOverride(contact, pathLen: null); await connector.setPathOverride(contact, pathLen: null);
} }
@@ -161,7 +173,7 @@ class _ChatScreenState extends State<ChatScreen> {
Icon( Icon(
Icons.auto_mode, Icons.auto_mode,
size: 20, size: 20,
color: !isFloodMode color: activeMode == 'auto'
? Theme.of(context).primaryColor ? Theme.of(context).primaryColor
: null, : null,
), ),
@@ -169,7 +181,30 @@ class _ChatScreenState extends State<ChatScreen> {
Text( Text(
context.l10n.chat_autoUseSavedPath, context.l10n.chat_autoUseSavedPath,
style: TextStyle( style: TextStyle(
fontWeight: !isFloodMode fontWeight: activeMode == 'auto'
? FontWeight.bold
: FontWeight.normal,
),
),
],
),
),
PopupMenuItem(
value: 'direct',
child: Row(
children: [
Icon(
Icons.near_me,
size: 20,
color: activeMode == 'direct'
? Theme.of(context).primaryColor
: null,
),
const SizedBox(width: 8),
Text(
context.l10n.chat_direct,
style: TextStyle(
fontWeight: activeMode == 'direct'
? FontWeight.bold ? FontWeight.bold
: FontWeight.normal, : FontWeight.normal,
), ),
@@ -184,7 +219,7 @@ class _ChatScreenState extends State<ChatScreen> {
Icon( Icon(
Icons.waves, Icons.waves,
size: 20, size: 20,
color: isFloodMode color: activeMode == 'flood'
? Theme.of(context).primaryColor ? Theme.of(context).primaryColor
: null, : null,
), ),
@@ -192,7 +227,7 @@ class _ChatScreenState extends State<ChatScreen> {
Text( Text(
context.l10n.chat_forceFloodMode, context.l10n.chat_forceFloodMode,
style: TextStyle( style: TextStyle(
fontWeight: isFloodMode fontWeight: activeMode == 'flood'
? FontWeight.bold ? FontWeight.bold
: FontWeight.normal, : FontWeight.normal,
), ),
@@ -251,7 +286,9 @@ class _ChatScreenState extends State<ChatScreen> {
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
context.l10n.chat_sendMessageTo(widget.contact.name), context.l10n.chat_sendMessageTo(
_resolveContact(context.read<MeshCoreConnector>()).name,
),
style: TextStyle(fontSize: 14, color: Colors.grey[500]), style: TextStyle(fontSize: 14, color: Colors.grey[500]),
), ),
], ],
@@ -269,6 +306,7 @@ class _ChatScreenState extends State<ChatScreen> {
// Auto-scroll to bottom if user is already at bottom // Auto-scroll to bottom if user is already at bottom
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
_scrollController.scrollToBottomIfAtBottom(); _scrollController.scrollToBottomIfAtBottom();
}); });
@@ -293,10 +331,10 @@ class _ChatScreenState extends State<ChatScreen> {
); );
} }
final messageIndex = index; final messageIndex = index;
Contact contact = widget.contact; Contact contact = _resolveContact(connector);
final message = reversedMessages[messageIndex]; final message = reversedMessages[messageIndex];
String fourByteHex = ''; String fourByteHex = '';
if (widget.contact.type == advTypeRoom) { if (contact.type == advTypeRoom) {
contact = _resolveContactFrom4Bytes( contact = _resolveContactFrom4Bytes(
connector, connector,
message.fourByteRoomContactKey.isEmpty message.fourByteRoomContactKey.isEmpty
@@ -314,12 +352,13 @@ class _ChatScreenState extends State<ChatScreen> {
final textScale = context.select<ChatTextScaleService, double>( final textScale = context.select<ChatTextScaleService, double>(
(service) => service.scale, (service) => service.scale,
); );
final resolvedContact = _resolveContact(connector);
return _MessageBubble( return _MessageBubble(
message: message, message: message,
senderName: widget.contact.type == advTypeRoom senderName: resolvedContact.type == advTypeRoom
? "${contact.name} [$fourByteHex]" ? "${contact.name} [$fourByteHex]"
: contact.name, : contact.name,
isRoomServer: widget.contact.type == advTypeRoom, isRoomServer: resolvedContact.type == advTypeRoom,
textScale: textScale, textScale: textScale,
onTap: () => _openMessagePath(message, contact), onTap: () => _openMessagePath(message, contact),
onLongPress: () => _showMessageActions(message, contact), onLongPress: () => _showMessageActions(message, contact),
@@ -457,7 +496,7 @@ class _ChatScreenState extends State<ChatScreen> {
return; return;
} }
connector.sendMessage(widget.contact, text); connector.sendMessage(_resolveContact(connector), text);
_textController.clear(); _textController.clear();
_textFieldFocusNode.requestFocus(); _textFieldFocusNode.requestFocus();
} }
@@ -654,7 +693,7 @@ class _ChatScreenState extends State<ChatScreen> {
// Set the path override to persist user's choice // Set the path override to persist user's choice
await connector.setPathOverride( await connector.setPathOverride(
widget.contact, _resolveContact(connector),
pathLen: pathLength, pathLen: pathLength,
pathBytes: pathBytes, pathBytes: pathBytes,
); );
@@ -663,7 +702,7 @@ class _ChatScreenState extends State<ChatScreen> {
Navigator.pop(context); Navigator.pop(context);
await _notifyPathSet( await _notifyPathSet(
connector, connector,
widget.contact, _resolveContact(connector),
pathBytes, pathBytes,
path.hopCount, path.hopCount,
); );
@@ -722,7 +761,9 @@ class _ChatScreenState extends State<ChatScreen> {
style: const TextStyle(fontSize: 11), style: const TextStyle(fontSize: 11),
), ),
onTap: () async { onTap: () async {
await connector.clearContactPath(widget.contact); await connector.clearContactPath(
_resolveContact(connector),
);
if (!context.mounted) return; if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
@@ -750,7 +791,7 @@ class _ChatScreenState extends State<ChatScreen> {
), ),
onTap: () async { onTap: () async {
await connector.setPathOverride( await connector.setPathOverride(
widget.contact, _resolveContact(connector),
pathLen: -1, pathLen: -1,
); );
if (!context.mounted) return; if (!context.mounted) return;
@@ -817,7 +858,7 @@ class _ChatScreenState extends State<ChatScreen> {
builder: (context) => PathTraceMapScreen( builder: (context) => PathTraceMapScreen(
title: context.l10n.contacts_repeaterPathTrace, title: context.l10n.contacts_repeaterPathTrace,
path: Uint8List.fromList(pathBytes), path: Uint8List.fromList(pathBytes),
flipPathRound: true, flipPathAround: true,
targetContact: widget.contact, targetContact: widget.contact,
), ),
), ),
@@ -986,7 +1027,7 @@ class _ChatScreenState extends State<ChatScreen> {
final currentPathLabel = _currentPathLabel(currentContact); final currentPathLabel = _currentPathLabel(currentContact);
// Filter out the current contact from available contacts // Filter out the current contact from available contacts
final availableContacts = connector.contacts final availableContacts = connector.allContacts
.where((c) => c != widget.contact) .where((c) => c != widget.contact)
.toList(); .toList();
@@ -1005,11 +1046,7 @@ class _ChatScreenState extends State<ChatScreen> {
); );
if (result == null) { if (result == null) {
appLogger.info( return; // Cancelled keep existing path
'PathSelectionDialog was cancelled or returned null',
tag: 'ChatScreen',
);
return;
} }
if (!mounted) { if (!mounted) {
@@ -1025,14 +1062,19 @@ class _ChatScreenState extends State<ChatScreen> {
tag: 'ChatScreen', tag: 'ChatScreen',
); );
await connector.setPathOverride( await connector.setPathOverride(
widget.contact, _resolveContact(connector),
pathLen: result.length, pathLen: result.length,
pathBytes: result, pathBytes: result,
); );
appLogger.info('setPathOverride completed', tag: 'ChatScreen'); appLogger.info('setPathOverride completed', tag: 'ChatScreen');
if (!mounted) return; if (!mounted) return;
await _notifyPathSet(connector, widget.contact, result, result.length); await _notifyPathSet(
connector,
_resolveContact(connector),
result,
result.length,
);
} }
void _openMessagePath(Message message, Contact contact) { void _openMessagePath(Message message, Contact contact) {
@@ -1044,10 +1086,10 @@ class _ChatScreenState extends State<ChatScreen> {
final String senderName; final String senderName;
if (message.isOutgoing) { if (message.isOutgoing) {
senderName = connector.selfName ?? context.l10n.chat_me; senderName = connector.selfName ?? context.l10n.chat_me;
} else if (widget.contact.type == advTypeRoom) { } else if (_resolveContact(connector).type == advTypeRoom) {
senderName = "${contact.name} [$fourByteHex]"; senderName = "${contact.name} [$fourByteHex]";
} else { } else {
senderName = widget.contact.name; senderName = _resolveContact(connector).name;
} }
final pathMessage = ChannelMessage( final pathMessage = ChannelMessage(
senderKey: null, senderKey: null,
@@ -1110,7 +1152,8 @@ class _ChatScreenState extends State<ChatScreen> {
_retryMessage(message); _retryMessage(message);
}, },
), ),
if (widget.contact.type == advTypeRoom) if (_resolveContact(context.read<MeshCoreConnector>()).type ==
advTypeRoom)
ListTile( ListTile(
leading: const Icon(Icons.chat), leading: const Icon(Icons.chat),
title: Text(context.l10n.contacts_openChat), title: Text(context.l10n.contacts_openChat),
@@ -1148,7 +1191,7 @@ class _ChatScreenState extends State<ChatScreen> {
void _retryMessage(Message message) { void _retryMessage(Message message) {
final connector = Provider.of<MeshCoreConnector>(context, listen: false); final connector = Provider.of<MeshCoreConnector>(context, listen: false);
// Retry using the contact's current path override setting // Retry using the contact's current path override setting
connector.sendMessage(widget.contact, message.text); connector.sendMessage(_resolveContact(connector), message.text);
ScaffoldMessenger.of( ScaffoldMessenger.of(
context, context,
).showSnackBar(SnackBar(content: Text(context.l10n.chat_retryingMessage))); ).showSnackBar(SnackBar(content: Text(context.l10n.chat_retryingMessage)));
@@ -1174,7 +1217,8 @@ class _ChatScreenState extends State<ChatScreen> {
// For room servers, include sender name (like channels) since multiple users // For room servers, include sender name (like channels) since multiple users
// For 1:1 chats, sender is implicit (null) // For 1:1 chats, sender is implicit (null)
final senderName = widget.contact.type == advTypeRoom final liveContact = _resolveContact(connector);
final senderName = liveContact.type == advTypeRoom
? senderContact.name ? senderContact.name
: null; : null;
final hash = ReactionHelper.computeReactionHash( final hash = ReactionHelper.computeReactionHash(
@@ -1183,7 +1227,7 @@ class _ChatScreenState extends State<ChatScreen> {
message.text, message.text,
); );
final reactionText = 'r:$hash:$emojiIndex'; final reactionText = 'r:$hash:$emojiIndex';
connector.sendMessage(widget.contact, reactionText); connector.sendMessage(_resolveContact(connector), reactionText);
} }
} }
@@ -51,6 +51,9 @@ class _CommunityQrScannerScreenState extends State<CommunityQrScannerScreen> {
_isProcessing = true; _isProcessing = true;
}); });
final connector = context.read<MeshCoreConnector>();
_communityStore.setPublicKeyHex = connector.selfPublicKeyHex;
try { try {
// Parse the community data // Parse the community data
final community = Community.fromQrData(const Uuid().v4(), data); final community = Community.fromQrData(const Uuid().v4(), data);
@@ -209,6 +212,8 @@ class _CommunityQrScannerScreenState extends State<CommunityQrScannerScreen> {
bool addPublicChannel, bool addPublicChannel,
) async { ) async {
// Save community to local storage // Save community to local storage
final connector = context.read<MeshCoreConnector>();
_communityStore.setPublicKeyHex = connector.selfPublicKeyHex;
await _communityStore.addCommunity(community); await _communityStore.addCommunity(community);
// Optionally add the community public channel to the device // Optionally add the community public channel to the device
File diff suppressed because it is too large Load Diff
+8 -7
View File
@@ -7,7 +7,7 @@ import 'package:provider/provider.dart';
import '../connector/meshcore_connector.dart'; import '../connector/meshcore_connector.dart';
import '../connector/meshcore_protocol.dart'; import '../connector/meshcore_protocol.dart';
import '../l10n/l10n.dart'; import '../l10n/l10n.dart';
import '../models/discovery_contact.dart'; import '../models/contact.dart';
import '../utils/contact_search.dart'; import '../utils/contact_search.dart';
import '../widgets/app_bar.dart'; import '../widgets/app_bar.dart';
import '../widgets/list_filter_widget.dart'; import '../widgets/list_filter_widget.dart';
@@ -129,7 +129,7 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
} }
Future<void> _showContactContextMenu( Future<void> _showContactContextMenu(
DiscoveryContact contact, Contact contact,
MeshCoreConnector connector, MeshCoreConnector connector,
) async { ) async {
final action = await showModalBottomSheet<String>( final action = await showModalBottomSheet<String>(
@@ -169,7 +169,8 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
connector.importDiscoveredContact(contact); connector.importDiscoveredContact(contact);
break; break;
case 'copy_contact': case 'copy_contact':
final hexString = pubKeyToHex(contact.rawPacket); if (contact.rawPacket == null) return;
final hexString = pubKeyToHex(contact.rawPacket!);
Clipboard.setData(ClipboardData(text: "meshcore://$hexString")); Clipboard.setData(ClipboardData(text: "meshcore://$hexString"));
if (!mounted) return; if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
@@ -207,7 +208,7 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
} }
Widget _buildFilters( Widget _buildFilters(
List<DiscoveryContact> filteredAndSorted, List<Contact> filteredAndSorted,
MeshCoreConnector connector, MeshCoreConnector connector,
) { ) {
String hintText = ""; String hintText = "";
@@ -309,8 +310,8 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
); );
} }
List<DiscoveryContact> _filterAndSortContacts( List<Contact> _filterAndSortContacts(
List<DiscoveryContact> contacts, List<Contact> contacts,
MeshCoreConnector connector, MeshCoreConnector connector,
) { ) {
var filtered = contacts.where((contact) { var filtered = contacts.where((contact) {
@@ -350,7 +351,7 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
return filtered; return filtered;
} }
bool _matchesTypeFilter(DiscoveryContact contact) { bool _matchesTypeFilter(Contact contact) {
switch (typeFilter) { switch (typeFilter) {
case ContactTypeFilter.all: case ContactTypeFilter.all:
return true; return true;
+126 -38
View File
@@ -1,6 +1,7 @@
import 'dart:math'; import 'dart:math';
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart'; import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart'; import 'package:latlong2/latlong.dart';
@@ -50,7 +51,8 @@ class MapScreen extends StatefulWidget {
} }
class _MapScreenState extends State<MapScreen> { class _MapScreenState extends State<MapScreen> {
static const double _labelZoomThreshold = 8.5; // Zoom level at which node labels start to appear
static const double _labelZoomThreshold = 12.0;
final MapController _mapController = MapController(); final MapController _mapController = MapController();
final MapMarkerService _markerService = MapMarkerService(); final MapMarkerService _markerService = MapMarkerService();
@@ -91,6 +93,15 @@ class _MapScreenState extends State<MapScreen> {
}); });
} }
bool _checkLocationPlausibility(double lat, double lon) {
const double epsilon = 1e-6;
return (lat.abs() > epsilon || lon.abs() > epsilon) &&
lat >= -90.0 &&
lat <= 90.0 &&
lon >= -180.0 &&
lon <= 180.0;
}
double _standardDeviation(List<double> values) { double _standardDeviation(List<double> values) {
if (values.length <= 1) { if (values.length <= 1) {
return 0.0; return 0.0;
@@ -126,7 +137,12 @@ class _MapScreenState extends State<MapScreen> {
builder: (context, connector, settingsService, pathHistory, child) { builder: (context, connector, settingsService, pathHistory, child) {
final tileCache = context.read<MapTileCacheService>(); final tileCache = context.read<MapTileCacheService>();
final settings = settingsService.settings; final settings = settingsService.settings;
final contacts = connector.contacts; final allContacts = connector.allContacts;
final contacts = settings.mapShowDiscoveryContacts
? allContacts
: allContacts.where((c) => c.isActive).toList();
final highlightPosition = widget.highlightPosition; final highlightPosition = widget.highlightPosition;
final sharedMarkers = settings.mapShowMarkers final sharedMarkers = settings.mapShowMarkers
? _collectSharedMarkers(connector) ? _collectSharedMarkers(connector)
@@ -159,13 +175,13 @@ class _MapScreenState extends State<MapScreen> {
: filteredByTime; : filteredByTime;
// Filter by location // Filter by location
final contactsWithLocation = filteredByKeyPrefix final contactsWithLocation = filteredByKeyPrefix.where((c) {
.where((c) => c.hasLocation) return c.hasLocation;
.toList(); }).toList();
// All contacts with a known location used as anchors regardless of // All contacts with a known location used as anchors regardless of
// time/key-prefix filters so that repeaters are always available. // time/key-prefix filters so that repeaters are always available.
final allContactsWithLocation = contacts final allContactsWithLocation = allContacts
.where((c) => c.hasLocation) .where((c) => c.hasLocation)
.toList(); .toList();
@@ -468,7 +484,10 @@ class _MapScreenState extends State<MapScreen> {
), ),
), ),
if (!_isBuildingPathTrace) if (!_isBuildingPathTrace)
...guessedLocations.map(_buildGuessedMarker), ..._buildGuessedMarker(
guessedLocations,
showLabels: _showNodeLabels,
),
..._buildMarkers( ..._buildMarkers(
contactsWithLocation, contactsWithLocation,
settings, settings,
@@ -630,6 +649,13 @@ class _MapScreenState extends State<MapScreen> {
anchors[0].latitude + offsetDeg * cos(angle), anchors[0].latitude + offsetDeg * cos(angle),
anchors[0].longitude + offsetDeg * sin(angle), anchors[0].longitude + offsetDeg * sin(angle),
); );
if (!_checkLocationPlausibility(
position.latitude,
position.longitude,
)) {
continue; // discard implausible guesses near (0, 0)
}
} else { } else {
double lat = 0, lon = 0; double lat = 0, lon = 0;
for (final a in anchors) { for (final a in anchors) {
@@ -637,6 +663,12 @@ class _MapScreenState extends State<MapScreen> {
lon += a.longitude; lon += a.longitude;
} }
position = LatLng(lat / anchors.length, lon / anchors.length); position = LatLng(lat / anchors.length, lon / anchors.length);
if (!_checkLocationPlausibility(
position.latitude,
position.longitude,
)) {
continue; // discard implausible guesses near (0, 0
}
} }
result.add( result.add(
_GuessedLocation( _GuessedLocation(
@@ -710,40 +742,61 @@ class _MapScreenState extends State<MapScreen> {
.toList(); .toList();
} }
Marker _buildGuessedMarker(_GuessedLocation guess) { List<Marker> _buildGuessedMarker(
final color = _getNodeColor(guess.contact.type); List<_GuessedLocation> guessed, {
return Marker( required bool showLabels,
point: guess.position, }) {
width: 35, final markers = <Marker>[];
height: 35,
child: GestureDetector( for (final guess in guessed) {
onTap: () => _showNodeInfo( final color = _getNodeColor(guess.contact.type);
context, final marker = Marker(
guess.contact, point: guess.position,
guessedPosition: guess.position, width: 35,
), height: 35,
child: Container( child: GestureDetector(
padding: const EdgeInsets.all(4), onTap: () => _showNodeInfo(
decoration: BoxDecoration( context,
color: color.withValues(alpha: guess.highConfidence ? 0.55 : 0.30), guess.contact,
shape: BoxShape.circle, guessedPosition: guess.position,
border: Border.all(color: Colors.white, width: 2), ),
boxShadow: [ child: Container(
BoxShadow( padding: const EdgeInsets.all(4),
color: Colors.black.withValues(alpha: 0.3), decoration: BoxDecoration(
blurRadius: 4, color: color.withValues(
offset: const Offset(0, 2), alpha: guess.highConfidence ? 0.55 : 0.30,
), ),
], shape: BoxShape.circle,
), border: Border.all(color: Colors.white, width: 2),
child: const Icon( boxShadow: [
Icons.not_listed_location, BoxShadow(
color: Colors.white, color: Colors.black.withValues(alpha: 0.3),
size: 20, blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: const Icon(
Icons.not_listed_location,
color: Colors.white,
size: 20,
),
), ),
), ),
), );
);
markers.add(marker);
if (showLabels) {
markers.add(
_buildNodeLabelMarker(
point: guess.position,
label: guess.contact.name,
),
);
}
}
return markers;
} }
List<Marker> _buildMarkers( List<Marker> _buildMarkers(
@@ -1203,6 +1256,7 @@ class _MapScreenState extends State<MapScreen> {
Contact contact, { Contact contact, {
LatLng? guessedPosition, LatLng? guessedPosition,
}) { }) {
final connector = context.read<MeshCoreConnector>();
showDialog( showDialog(
context: context, context: context,
builder: (dialogContext) => AlertDialog( builder: (dialogContext) => AlertDialog(
@@ -1248,6 +1302,9 @@ class _MapScreenState extends State<MapScreen> {
advTypeChat) // Only show chat button for chat nodes advTypeChat) // Only show chat button for chat nodes
TextButton( TextButton(
onPressed: () { onPressed: () {
if (!contact.isActive) {
connector.importDiscoveredContact(contact);
}
Navigator.pop(dialogContext); Navigator.pop(dialogContext);
Navigator.push( Navigator.push(
context, context,
@@ -1261,6 +1318,9 @@ class _MapScreenState extends State<MapScreen> {
if (contact.type == advTypeRepeater) if (contact.type == advTypeRepeater)
TextButton( TextButton(
onPressed: () { onPressed: () {
if (!contact.isActive) {
connector.importDiscoveredContact(contact);
}
Navigator.pop(dialogContext); Navigator.pop(dialogContext);
_showRepeaterLogin(context, contact); _showRepeaterLogin(context, contact);
}, },
@@ -1269,6 +1329,9 @@ class _MapScreenState extends State<MapScreen> {
if (contact.type == advTypeRoom) if (contact.type == advTypeRoom)
TextButton( TextButton(
onPressed: () { onPressed: () {
if (!contact.isActive) {
connector.importDiscoveredContact(contact);
}
Navigator.pop(dialogContext); Navigator.pop(dialogContext);
_showRoomLogin(context, contact); _showRoomLogin(context, contact);
}, },
@@ -1436,6 +1499,23 @@ class _MapScreenState extends State<MapScreen> {
); );
}, },
), ),
ListTile(
leading: const Icon(Icons.my_location),
title: Text(context.l10n.map_setAsMyLocation),
onTap: () async {
final messenger = ScaffoldMessenger.of(context);
final successMsg = context.l10n.settings_locationUpdated;
Navigator.pop(sheetContext);
if (!connector.isConnected) return;
await connector.setNodeLocation(
lat: position.latitude,
lon: position.longitude,
);
await connector.refreshDeviceInfo();
if (!mounted) return;
messenger.showSnackBar(SnackBar(content: Text(successMsg)));
},
),
ListTile( ListTile(
leading: const Icon(Icons.close), leading: const Icon(Icons.close),
title: Text(context.l10n.common_cancel), title: Text(context.l10n.common_cancel),
@@ -1745,6 +1825,14 @@ class _MapScreenState extends State<MapScreen> {
}, },
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
), ),
CheckboxListTile(
title: Text(context.l10n.map_showDiscoveryContacts),
value: settings.mapShowDiscoveryContacts,
onChanged: (value) {
service.setMapShowDiscoveryContacts(value ?? true);
},
contentPadding: EdgeInsets.zero,
),
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Text(
context.l10n.map_keyPrefix, context.l10n.map_keyPrefix,
+2 -3
View File
@@ -124,12 +124,11 @@ class _NeighborsScreenState extends State<NeighborsScreen> {
void _handleNeighborsResponse(MeshCoreConnector connector, Uint8List frame) { void _handleNeighborsResponse(MeshCoreConnector connector, Uint8List frame) {
final buffer = BufferReader(frame); final buffer = BufferReader(frame);
final contacts = connector.allContacts;
try { try {
final neighborCount = buffer.readUInt16LE(); final neighborCount = buffer.readUInt16LE();
final parsedNeighbors = parseNeighborsData(buffer, buffer.readUInt16LE()); final parsedNeighbors = parseNeighborsData(buffer, buffer.readUInt16LE());
connector.contacts.where((c) => c.type == advTypeRepeater).forEach(( contacts.where((c) => c.type == advTypeRepeater).forEach((repeater) {
repeater,
) {
for (var neighborData in parsedNeighbors) { for (var neighborData in parsedNeighbors) {
final publicKey = neighborData['publicKey']; final publicKey = neighborData['publicKey'];
if (listEquals( if (listEquals(
+115 -41
View File
@@ -52,8 +52,8 @@ class PathTraceMapScreen extends StatefulWidget {
final String title; final String title;
final Uint8List path; final Uint8List path;
final int? repeaterId; final int? repeaterId;
final bool flipPathRound; final bool flipPathAround;
final bool reversePathRound; final bool reversePathAround;
final Contact? targetContact; final Contact? targetContact;
const PathTraceMapScreen({ const PathTraceMapScreen({
@@ -61,8 +61,8 @@ class PathTraceMapScreen extends StatefulWidget {
required this.title, required this.title,
required this.path, required this.path,
this.repeaterId, this.repeaterId,
this.flipPathRound = false, this.flipPathAround = false,
this.reversePathRound = false, this.reversePathAround = false,
this.targetContact, this.targetContact,
}); });
@@ -93,6 +93,7 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
ValueKey<String> _mapKey = const ValueKey('initial'); ValueKey<String> _mapKey = const ValueKey('initial');
double _pathDistanceMeters = 0.0; double _pathDistanceMeters = 0.0;
bool _showNodeLabels = true; bool _showNodeLabels = true;
Contact? _targetContact;
String _formatPathPrefixes(Uint8List pathBytes) { String _formatPathPrefixes(Uint8List pathBytes) {
return pathBytes return pathBytes
@@ -114,14 +115,37 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
super.dispose(); super.dispose();
} }
Uint8List addReturnPath(Uint8List pathBytes) { Uint8List buildPath(Uint8List pathBytes) {
Uint8List? traceBytes; Uint8List traceBytes;
final len = (pathBytes.length + pathBytes.length - 1);
traceBytes = Uint8List(len); if (pathBytes.isEmpty) {
for (int i = 0; i < pathBytes.length; i++) { traceBytes = Uint8List(1);
traceBytes[i] = pathBytes[i]; traceBytes[0] = widget.targetContact?.publicKey[0] ?? 0;
if (i < pathBytes.length - 1) { return traceBytes;
traceBytes[len - 1 - i] = pathBytes[i]; }
if (widget.targetContact?.type == advTypeRepeater ||
widget.targetContact?.type == advTypeRoom) {
final len = (pathBytes.length + pathBytes.length + 1);
traceBytes = Uint8List(len);
traceBytes[pathBytes.length] = widget.targetContact?.publicKey[0] ?? 0;
for (int i = 0; i < pathBytes.length; i++) {
traceBytes[i] = pathBytes[i];
if (i < pathBytes.length) {
traceBytes[len - 1 - i] = pathBytes[i];
}
}
} else {
if (pathBytes.length < 2) {
return pathBytes[0] == 0 ? Uint8List(0) : pathBytes;
}
final len = (pathBytes.length + pathBytes.length - 1);
traceBytes = Uint8List(len);
for (int i = 0; i < pathBytes.length; i++) {
traceBytes[i] = pathBytes[i];
if (i < pathBytes.length - 1) {
traceBytes[len - 1 - i] = pathBytes[i];
}
} }
} }
return traceBytes; return traceBytes;
@@ -135,17 +159,17 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
}); });
} }
final Uint8List path; final pathTmp = widget.reversePathAround
Uint8List pathTmp = widget.reversePathRound
? Uint8List.fromList(widget.path.reversed.toList()) ? Uint8List.fromList(widget.path.reversed.toList())
: widget.path; : widget.path;
if (widget.flipPathRound) { final path = widget.flipPathAround ? buildPath(pathTmp) : pathTmp;
path = addReturnPath(pathTmp);
} else { appLogger.info(
path = pathTmp; 'Initiating path trace with path: ${_formatPathPrefixes(path)}',
} tag: 'PathTraceMapScreen',
noNotify: !mounted,
);
final connector = Provider.of<MeshCoreConnector>(context, listen: false); final connector = Provider.of<MeshCoreConnector>(context, listen: false);
final frame = buildTraceReq( final frame = buildTraceReq(
@@ -235,10 +259,8 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
.toList(); .toList();
Map<int, Contact> pathContacts = {}; Map<int, Contact> pathContacts = {};
final contacts = connector.allContacts;
connector.contacts.where((c) => c.type != advTypeChat).forEach(( contacts.where((c) => c.type != advTypeChat).forEach((repeater) {
repeater,
) {
for (var repeaterData in pathData) { for (var repeaterData in pathData) {
if (listEquals( if (listEquals(
repeater.publicKey.sublist(0, 1), repeater.publicKey.sublist(0, 1),
@@ -283,18 +305,21 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
// Compute endpoint position for the target contact. // Compute endpoint position for the target contact.
LatLng? targetPos; LatLng? targetPos;
bool targetGuessed = false; bool targetGuessed = false;
final target = widget.targetContact; _targetContact = widget.targetContact;
if (target != null) {
if (target.hasLocation) { if (_targetContact != null) {
targetPos = LatLng(target.latitude!, target.longitude!); final tc = _targetContact!;
} else if (pathData.isNotEmpty) { if (tc.hasLocation) {
targetPos = LatLng(tc.latitude!, tc.longitude!);
} else if (widget.path.length > 1) {
// Infer from the last hop: average GPS contacts sharing that hop. // Infer from the last hop: average GPS contacts sharing that hop.
// For a round-trip path (flipPathRound), the target-side hop sits // For a round-trip path (flipPathAround/reversePathAround), the target-side hop
// in the middle of the symmetric sequence; .last is the local side. // sits in the middle of the symmetric sequence; .last is the local side.
final lastHop = (widget.flipPathRound && pathData.length > 1) final lastHop = widget.reversePathAround
? pathData[(pathData.length - 1) ~/ 2] ? widget.path.first
: pathData.last; : widget.path.last;
final peers = connector.contacts
final peers = connector.allContacts
.where( .where(
(c) => (c) =>
c.hasLocation && c.hasLocation &&
@@ -310,12 +335,34 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
peers.map((c) => c.longitude!).reduce((a, b) => a + b) / peers.map((c) => c.longitude!).reduce((a, b) => a + b) /
peers.length; peers.length;
const offsetDeg = 0.003; const offsetDeg = 0.003;
final angle = (target.publicKey[1] / 255.0) * 2 * pi; final angle = (tc.publicKey[1] / 255.0) * 2 * pi;
targetPos = LatLng( targetPos = LatLng(
lat + offsetDeg * cos(angle), lat + offsetDeg * cos(angle),
lon + offsetDeg * sin(angle), lon + offsetDeg * sin(angle),
); );
targetGuessed = true; targetGuessed = true;
} else if (inferredPositions.containsKey(lastHop)) {
final lat = inferredPositions[lastHop]!.latitude;
final lon = inferredPositions[lastHop]!.longitude;
const offsetDeg = 0.003;
final angle = (tc.publicKey[1] / 255.0) * 2 * pi;
targetPos = LatLng(
lat + offsetDeg * cos(angle),
lon + offsetDeg * sin(angle),
);
targetGuessed = true;
} else {
// As a last resort, just place it at the same position as the last hop.
final contact = pathContacts[lastHop];
if (contact != null && contact.hasLocation) {
const offsetDeg = 0.003;
final angle = (tc.publicKey[1] / 255.0) * 2 * pi;
targetPos = LatLng(
contact.latitude! + offsetDeg * cos(angle),
contact.longitude! + offsetDeg * sin(angle),
);
targetGuessed = true;
}
} }
} }
} }
@@ -324,7 +371,12 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
_points = <LatLng>[]; _points = <LatLng>[];
_points.add(LatLng(connector.selfLatitude!, connector.selfLongitude!)); _points.add(LatLng(connector.selfLatitude!, connector.selfLongitude!));
int hopLast = 0;
int hopLastLast = 0;
for (final hop in _traceData!.pathData) { for (final hop in _traceData!.pathData) {
if (hop == hopLastLast && widget.flipPathAround) {
break; //skip duplicate hops in round-trip paths
}
final contact = _traceData!.pathContacts[hop]; final contact = _traceData!.pathContacts[hop];
if (contact != null && contact.hasLocation) { if (contact != null && contact.hasLocation) {
_points.add(LatLng(contact.latitude!, contact.longitude!)); _points.add(LatLng(contact.latitude!, contact.longitude!));
@@ -332,8 +384,14 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
final inferred = inferredPositions[hop]; final inferred = inferredPositions[hop];
if (inferred != null) _points.add(inferred); if (inferred != null) _points.add(inferred);
} }
hopLastLast = hopLast;
hopLast = hop;
}
if (targetPos != null) {
if (_targetContact != null && _targetContact!.type == advTypeChat) {
_points.add(targetPos);
}
} }
if (targetPos != null) _points.add(targetPos);
_polylines = _points.length > 1 _polylines = _points.length > 1
? [ ? [
Polyline( Polyline(
@@ -422,7 +480,8 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
], ],
), ),
), ),
if (_hasData) _buildMapPathTrace(context, tileCache), if (_hasData)
_buildMapPathTrace(context, tileCache, _targetContact),
if (_points.isEmpty && if (_points.isEmpty &&
!_hasData && !_hasData &&
!_isLoading && !_isLoading &&
@@ -451,17 +510,28 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
List<Marker> _buildHopMarkers( List<Marker> _buildHopMarkers(
List<int> pathData, { List<int> pathData, {
required bool showLabels, required bool showLabels,
required Contact? target,
}) { }) {
final markers = <Marker>[]; final markers = <Marker>[];
int hopLast = 0;
int hopLastLast = 0;
for (final hop in pathData) { for (final hop in pathData) {
final contact = _traceData!.pathContacts[hop]; final contact = _traceData!.pathContacts[hop];
final inferred = _inferredHopPositions[hop]; final inferred = _inferredHopPositions[hop];
final hasGps = contact != null && contact.hasLocation; final hasGps = contact != null && contact.hasLocation;
if (!hasGps && inferred == null) continue; if (hop == hopLastLast && widget.flipPathAround) {
continue; //skip duplicate hops in round-trip paths
}
if (!hasGps && inferred == null) {
hopLastLast = hopLast;
hopLast = hop;
continue; //skip hops with no GPS and no inferred position
}
final point = hasGps final point = hasGps
? LatLng(contact.latitude!, contact.longitude!) ? LatLng(contact.latitude!, contact.longitude!)
: inferred!; : inferred!;
final label = hop.toRadixString(16).padLeft(2, '0').toUpperCase(); final label = hop.toRadixString(16).padLeft(2, '0').toUpperCase();
markers.add( markers.add(
Marker( Marker(
point: point, point: point,
@@ -503,6 +573,8 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
), ),
); );
} }
hopLastLast = hopLast;
hopLast = hop;
} }
final selfLat = context.read<MeshCoreConnector>().selfLatitude; final selfLat = context.read<MeshCoreConnector>().selfLatitude;
@@ -552,9 +624,9 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
// Add target contact endpoint marker. // Add target contact endpoint marker.
final targetPos = _targetContactPosition; final targetPos = _targetContactPosition;
if (targetPos != null) { if (targetPos != null && target != null && target.type == advTypeChat) {
final isGuessed = _targetContactIsGuessed; final isGuessed = _targetContactIsGuessed;
final targetName = widget.targetContact?.name ?? '?'; final targetName = target.name;
markers.add( markers.add(
Marker( Marker(
point: targetPos, point: targetPos,
@@ -690,6 +762,7 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
Widget _buildMapPathTrace( Widget _buildMapPathTrace(
BuildContext context, BuildContext context,
MapTileCacheService tileCache, MapTileCacheService tileCache,
Contact? target,
) { ) {
return FlutterMap( return FlutterMap(
key: _mapKey, key: _mapKey,
@@ -728,6 +801,7 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
markers: _buildHopMarkers( markers: _buildHopMarkers(
_traceData!.pathData, _traceData!.pathData,
showLabels: _showNodeLabels, showLabels: _showNodeLabels,
target: target,
), ),
), ),
], ],
+67 -49
View File
@@ -10,6 +10,7 @@ import '../utils/app_logger.dart';
import '../widgets/adaptive_app_bar_title.dart'; import '../widgets/adaptive_app_bar_title.dart';
import '../widgets/device_tile.dart'; import '../widgets/device_tile.dart';
import 'contacts_screen.dart'; import 'contacts_screen.dart';
import 'tcp_screen.dart';
import 'usb_screen.dart'; import 'usb_screen.dart';
/// Screen for scanning and connecting to MeshCore devices /// Screen for scanning and connecting to MeshCore devices
@@ -125,61 +126,78 @@ class _ScannerScreenState extends State<ScannerScreen> {
connector.state == MeshCoreConnectionState.scanning; connector.state == MeshCoreConnectionState.scanning;
final isBluetoothOff = _bluetoothState == BluetoothAdapterState.off; final isBluetoothOff = _bluetoothState == BluetoothAdapterState.off;
final usbSupported = PlatformInfo.supportsUsbSerial; final usbSupported = PlatformInfo.supportsUsbSerial;
final tcpSupported = !PlatformInfo.isWeb;
return SafeArea( return SafeArea(
top: false, top: false,
minimum: const EdgeInsets.fromLTRB(16, 8, 16, 16), minimum: const EdgeInsets.fromLTRB(16, 8, 16, 16),
child: Row( child: FittedBox(
mainAxisAlignment: MainAxisAlignment.end, fit: BoxFit.scaleDown,
children: [ alignment: Alignment.centerRight,
if (usbSupported) child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
if (usbSupported)
FloatingActionButton.extended(
onPressed: () {
appLogger.info(
'USB selected, opening UsbScreen',
tag: 'ScannerScreen',
);
Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const UsbScreen()),
);
},
heroTag: 'scanner_usb_action',
icon: const Icon(Icons.usb),
label: Text(context.l10n.connectionChoiceUsbLabel),
),
if (usbSupported) const SizedBox(width: 12),
if (tcpSupported)
FloatingActionButton.extended(
onPressed: () {
Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const TcpScreen()),
);
},
heroTag: 'scanner_tcp_action',
icon: const Icon(Icons.lan),
label: Text(context.l10n.connectionChoiceTcpLabel),
),
if (tcpSupported) const SizedBox(width: 12),
FloatingActionButton.extended( FloatingActionButton.extended(
onPressed: () { heroTag: 'scanner_ble_action',
appLogger.info( onPressed: isBluetoothOff
'USB selected, opening UsbScreen', ? null
tag: 'ScannerScreen', : () {
); if (isScanning) {
Navigator.of(context).push( connector.stopScan();
MaterialPageRoute(builder: (_) => const UsbScreen()), } else {
); unawaited(
}, connector.startScan().catchError((e) {
heroTag: 'scanner_usb_action', appLogger.warn(
icon: const Icon(Icons.usb), 'startScan error: $e',
label: Text(context.l10n.connectionChoiceUsbLabel), tag: 'ScannerScreen',
);
}),
);
}
},
icon: isScanning
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.bluetooth_searching),
label: Text(
isScanning
? context.l10n.scanner_stop
: context.l10n.scanner_scan,
),
), ),
if (usbSupported) const SizedBox(width: 12), ],
FloatingActionButton.extended( ),
heroTag: 'scanner_ble_action',
onPressed: isBluetoothOff
? null
: () {
if (isScanning) {
connector.stopScan();
} else {
unawaited(
connector.startScan().catchError((e) {
appLogger.warn(
'startScan error: $e',
tag: 'ScannerScreen',
);
}),
);
}
},
icon: isScanning
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.bluetooth_searching),
label: Text(
isScanning
? context.l10n.scanner_stop
: context.l10n.scanner_scan,
),
),
],
), ),
); );
}, },
+295
View File
@@ -0,0 +1,295 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../connector/meshcore_connector.dart';
import '../l10n/l10n.dart';
import '../services/app_settings_service.dart';
import '../utils/platform_info.dart';
import '../widgets/adaptive_app_bar_title.dart';
import 'contacts_screen.dart';
import 'usb_screen.dart';
class TcpScreen extends StatefulWidget {
const TcpScreen({super.key});
@override
State<TcpScreen> createState() => _TcpScreenState();
}
class _TcpScreenState extends State<TcpScreen> {
late final TextEditingController _hostController;
late final TextEditingController _portController;
late final MeshCoreConnector _connector;
late final VoidCallback _connectionListener;
bool _navigatedToContacts = false;
@override
void initState() {
super.initState();
_hostController = TextEditingController(
text: context.read<AppSettingsService>().settings.tcpServerAddress,
);
_portController = TextEditingController(
text: context.read<AppSettingsService>().settings.tcpServerPort > 0
? context.read<AppSettingsService>().settings.tcpServerPort.toString()
: '',
);
_connector = context.read<MeshCoreConnector>();
_connectionListener = () {
if (!mounted) return;
if (_connector.state == MeshCoreConnectionState.disconnected) {
_navigatedToContacts = false;
}
if (_connector.state == MeshCoreConnectionState.connected &&
_connector.isTcpTransportConnected &&
!_navigatedToContacts) {
context.read<AppSettingsService>().setTcpServerAddress(
_hostController.text,
);
context.read<AppSettingsService>().setTcpServerPort(
int.tryParse(_portController.text) ?? 0,
);
_navigatedToContacts = true;
Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (_) => const ContactsScreen()),
);
}
};
_connector.addListener(_connectionListener);
}
@override
void dispose() {
_hostController.dispose();
_portController.dispose();
_connector.removeListener(_connectionListener);
if (!_navigatedToContacts &&
_connector.activeTransport == MeshCoreTransportType.tcp &&
_connector.state != MeshCoreConnectionState.disconnected) {
WidgetsBinding.instance.addPostFrameCallback((_) {
unawaited(_connector.disconnect(manual: true));
});
}
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.of(context).maybePop(),
),
title: AdaptiveAppBarTitle(context.l10n.tcpScreenTitle),
centerTitle: true,
),
body: SafeArea(
top: false,
child: Consumer<MeshCoreConnector>(
builder: (context, connector, child) {
final isConnecting =
connector.state == MeshCoreConnectionState.connecting &&
connector.activeTransport == MeshCoreTransportType.tcp;
final isButtonDisabled =
isConnecting ||
connector.state == MeshCoreConnectionState.scanning;
return Column(
children: [
_buildStatusBar(context, connector),
Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextField(
controller: _hostController,
decoration: InputDecoration(
labelText: context.l10n.tcpHostLabel,
hintText: context.l10n.tcpHostHint,
border: const OutlineInputBorder(),
),
enabled: !isConnecting,
keyboardType: TextInputType.url,
),
const SizedBox(height: 12),
TextField(
controller: _portController,
decoration: InputDecoration(
labelText: context.l10n.tcpPortLabel,
hintText: context.l10n.tcpPortHint,
border: const OutlineInputBorder(),
),
enabled: !isConnecting,
keyboardType: TextInputType.number,
),
const SizedBox(height: 16),
FilledButton.icon(
key: const Key('tcp_connect_button'),
onPressed: isButtonDisabled ? null : _connectTcp,
icon: isConnecting
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: const Icon(Icons.lan),
label: Text(
isConnecting
? context.l10n.scanner_connecting
: context.l10n.common_connect,
),
),
],
),
),
],
);
},
),
),
bottomNavigationBar: SafeArea(
top: false,
minimum: const EdgeInsets.fromLTRB(16, 8, 16, 16),
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerRight,
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
if (PlatformInfo.supportsUsbSerial)
FloatingActionButton.extended(
onPressed: () {
Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (_) => const UsbScreen()),
);
},
heroTag: 'tcp_usb_action',
extendedPadding: const EdgeInsets.symmetric(horizontal: 12),
icon: const Icon(Icons.usb),
label: Text(context.l10n.connectionChoiceUsbLabel),
),
if (PlatformInfo.supportsUsbSerial) const SizedBox(width: 12),
FloatingActionButton.extended(
onPressed: () {
Navigator.of(context).maybePop();
},
heroTag: 'tcp_ble_action',
extendedPadding: const EdgeInsets.symmetric(horizontal: 12),
icon: const Icon(Icons.bluetooth),
label: Text(context.l10n.connectionChoiceBluetoothLabel),
),
],
),
),
),
);
}
Widget _buildStatusBar(BuildContext context, MeshCoreConnector connector) {
final l10n = context.l10n;
String statusText;
Color statusColor;
if (connector.isTcpTransportConnected) {
statusText = l10n.scanner_connectedTo(
connector.activeTcpEndpoint ?? 'TCP',
);
statusColor = Colors.green;
} else if (connector.state == MeshCoreConnectionState.connecting &&
connector.activeTransport == MeshCoreTransportType.tcp) {
statusText = l10n.tcpStatus_connectingTo(
'${_hostController.text}:${_portController.text}',
);
statusColor = Colors.orange;
} else if (connector.state == MeshCoreConnectionState.disconnecting &&
connector.activeTransport == MeshCoreTransportType.tcp) {
statusText = l10n.scanner_disconnecting;
statusColor = Colors.orange;
} else {
statusText = l10n.tcpStatus_notConnected;
statusColor = Colors.grey;
}
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
color: statusColor.withValues(alpha: 0.1),
child: Row(
children: [
Icon(Icons.circle, size: 12, color: statusColor),
const SizedBox(width: 8),
Expanded(
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerLeft,
child: Text(
statusText,
style: TextStyle(
color: statusColor,
fontWeight: FontWeight.w500,
),
),
),
),
],
),
);
}
Future<void> _connectTcp() async {
if (_connector.state == MeshCoreConnectionState.connecting ||
_connector.state == MeshCoreConnectionState.connected ||
_connector.state == MeshCoreConnectionState.disconnecting) {
return;
}
final host = _hostController.text.trim();
final parsedPort = int.tryParse(_portController.text.trim());
if (host.isEmpty) {
_showError(context.l10n.tcpErrorHostRequired);
return;
}
if (parsedPort == null || parsedPort < 1 || parsedPort > 65535) {
_showError(context.l10n.tcpErrorPortInvalid);
return;
}
try {
await _connector.connectTcp(host: host, port: parsedPort);
} catch (error) {
if (!mounted) return;
_showError(_friendlyErrorMessage(error));
}
}
void _showError(String message) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message), backgroundColor: Colors.red),
);
}
String _friendlyErrorMessage(Object error) {
if (error is UnsupportedError) {
return context.l10n.tcpErrorUnsupported;
}
if (error is TimeoutException) {
return context.l10n.tcpErrorTimedOut;
}
if (error is StateError) {
return context.l10n.tcpConnectionFailed(error.message);
}
if (error is ArgumentError) {
return context.l10n.tcpConnectionFailed(
error.message?.toString() ?? error.toString(),
);
}
return context.l10n.tcpConnectionFailed(error.toString());
}
}
+78 -42
View File
@@ -12,6 +12,7 @@ import '../utils/usb_port_labels.dart';
import '../widgets/adaptive_app_bar_title.dart'; import '../widgets/adaptive_app_bar_title.dart';
import 'contacts_screen.dart'; import 'contacts_screen.dart';
import 'scanner_screen.dart'; import 'scanner_screen.dart';
import 'tcp_screen.dart';
class UsbScreen extends StatefulWidget { class UsbScreen extends StatefulWidget {
const UsbScreen({super.key}); const UsbScreen({super.key});
@@ -107,44 +108,69 @@ class _UsbScreenState extends State<UsbScreen> {
bottomNavigationBar: Consumer<MeshCoreConnector>( bottomNavigationBar: Consumer<MeshCoreConnector>(
builder: (context, connector, child) { builder: (context, connector, child) {
final isLoading = _isLoadingPorts; final isLoading = _isLoadingPorts;
final showBle = PlatformInfo.isWeb || final showBle = true;
PlatformInfo.isAndroid || final showTcp = !PlatformInfo.isWeb;
PlatformInfo.isIOS;
return SafeArea( return SafeArea(
top: false, top: false,
minimum: const EdgeInsets.fromLTRB(16, 8, 16, 16), minimum: const EdgeInsets.fromLTRB(16, 8, 16, 16),
child: Row( child: FittedBox(
mainAxisAlignment: MainAxisAlignment.end, fit: BoxFit.scaleDown,
children: [ alignment: Alignment.centerRight,
if (showBle) child: Row(
FloatingActionButton.extended( mainAxisAlignment: MainAxisAlignment.end,
onPressed: () { children: [
Navigator.of(context).pushReplacement( if (showTcp)
MaterialPageRoute( FloatingActionButton.extended(
builder: (_) => const ScannerScreen(), onPressed: () {
), Navigator.of(context).pushReplacement(
); MaterialPageRoute(builder: (_) => const TcpScreen()),
}, );
heroTag: 'usb_ble_action', },
icon: const Icon(Icons.bluetooth), heroTag: 'usb_tcp_action',
label: Text(context.l10n.connectionChoiceBluetoothLabel), extendedPadding: const EdgeInsets.symmetric(
), horizontal: 12,
if (showBle) const SizedBox(width: 12), ),
if (!_supportsHotPlug) icon: const Icon(Icons.lan),
FloatingActionButton.extended( label: Text(context.l10n.connectionChoiceTcpLabel),
onPressed: isLoading ? null : _loadPorts, ),
heroTag: 'usb_refresh_action', if (showTcp && showBle) const SizedBox(width: 12),
icon: isLoading if (showBle)
? const SizedBox( FloatingActionButton.extended(
width: 20, onPressed: () {
height: 20, Navigator.of(context).pushReplacement(
child: CircularProgressIndicator(strokeWidth: 2), MaterialPageRoute(
) builder: (_) => const ScannerScreen(),
: const Icon(Icons.refresh), ),
label: Text(context.l10n.repeater_refresh), );
), },
], heroTag: 'usb_ble_action',
extendedPadding: const EdgeInsets.symmetric(
horizontal: 12,
),
icon: const Icon(Icons.bluetooth),
label: Text(context.l10n.connectionChoiceBluetoothLabel),
),
if ((showTcp || showBle) && !_supportsHotPlug)
const SizedBox(width: 12),
if (!_supportsHotPlug)
FloatingActionButton.extended(
onPressed: isLoading ? null : _loadPorts,
heroTag: 'usb_refresh_action',
extendedPadding: const EdgeInsets.symmetric(
horizontal: 12,
),
icon: isLoading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.usb),
label: Text(context.l10n.scanner_scan),
),
],
),
), ),
); );
}, },
@@ -191,9 +217,18 @@ class _UsbScreenState extends State<UsbScreen> {
children: [ children: [
Icon(Icons.circle, size: 12, color: statusColor), Icon(Icons.circle, size: 12, color: statusColor),
const SizedBox(width: 8), const SizedBox(width: 8),
Text( Expanded(
statusText, child: FittedBox(
style: TextStyle(color: statusColor, fontWeight: FontWeight.w500), fit: BoxFit.scaleDown,
alignment: Alignment.centerLeft,
child: Text(
statusText,
style: TextStyle(
color: statusColor,
fontWeight: FontWeight.w500,
),
),
),
), ),
], ],
), ),
@@ -238,7 +273,7 @@ class _UsbScreenState extends State<UsbScreen> {
final isConnecting = final isConnecting =
connector.state == MeshCoreConnectionState.connecting && connector.state == MeshCoreConnectionState.connecting &&
connector.activeTransport == MeshCoreTransportType.usb; connector.activeTransport == MeshCoreTransportType.usb;
return ListView.separated( return ListView.separated(
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
@@ -259,8 +294,7 @@ class _UsbScreenState extends State<UsbScreen> {
), ),
subtitle: showRawName ? Text(rawName) : null, subtitle: showRawName ? Text(rawName) : null,
trailing: ElevatedButton( trailing: ElevatedButton(
onPressed: onPressed: isConnecting ? null : () => _connectPort(port),
isConnecting ? null : () => _connectPort(port),
child: Text(l10n.common_connect), child: Text(l10n.common_connect),
), ),
onTap: isConnecting ? null : () => _connectPort(port), onTap: isConnecting ? null : () => _connectPort(port),
@@ -329,8 +363,10 @@ class _UsbScreenState extends State<UsbScreen> {
if (_connector.state != MeshCoreConnectionState.disconnected) return; if (_connector.state != MeshCoreConnectionState.disconnected) return;
final rawPortName = normalizeUsbPortName(port); final rawPortName = normalizeUsbPortName(port);
appLogger.info('Connect tapped for $port (raw: $rawPortName)', appLogger.info(
tag: 'UsbScreen'); 'Connect tapped for $port (raw: $rawPortName)',
tag: 'UsbScreen',
);
try { try {
await _connector.connectUsb(portName: rawPortName); await _connector.connectUsb(portName: rawPortName);
+10 -7
View File
@@ -51,6 +51,7 @@ class AppDebugLogService extends ChangeNotifier {
String message, { String message, {
String tag = 'App', String tag = 'App',
AppDebugLogLevel level = AppDebugLogLevel.info, AppDebugLogLevel level = AppDebugLogLevel.info,
bool noNotify = false,
}) { }) {
if (!_enabled && !kDebugMode) return; if (!_enabled && !kDebugMode) return;
if (!_enabled) { if (!_enabled) {
@@ -72,22 +73,24 @@ class AppDebugLogService extends ChangeNotifier {
_entries.removeRange(0, _entries.length - maxEntries); _entries.removeRange(0, _entries.length - maxEntries);
} }
notifyListeners(); if (!noNotify) {
notifyListeners();
}
// Also print to console for development // Also print to console for development
debugPrint('[$tag] $message'); debugPrint('[$tag] $message');
} }
void info(String message, {String tag = 'App'}) { void info(String message, {String tag = 'App', bool noNotify = false}) {
log(message, tag: tag, level: AppDebugLogLevel.info); log(message, tag: tag, level: AppDebugLogLevel.info, noNotify: noNotify);
} }
void warn(String message, {String tag = 'App'}) { void warn(String message, {String tag = 'App', bool noNotify = false}) {
log(message, tag: tag, level: AppDebugLogLevel.warning); log(message, tag: tag, level: AppDebugLogLevel.warning, noNotify: noNotify);
} }
void error(String message, {String tag = 'App'}) { void error(String message, {String tag = 'App', bool noNotify = false}) {
log(message, tag: tag, level: AppDebugLogLevel.error); log(message, tag: tag, level: AppDebugLogLevel.error, noNotify: noNotify);
} }
void clear() { void clear() {
+12
View File
@@ -134,6 +134,10 @@ class AppSettingsService extends ChangeNotifier {
appLogger.setEnabled(value); appLogger.setEnabled(value);
} }
Future<void> setMapShowDiscoveryContacts(bool value) async {
await updateSettings(_settings.copyWith(mapShowDiscoveryContacts: value));
}
Future<void> setBatteryChemistryForDevice( Future<void> setBatteryChemistryForDevice(
String deviceId, String deviceId,
String chemistry, String chemistry,
@@ -178,4 +182,12 @@ class AppSettingsService extends ChangeNotifier {
..remove(channelName); ..remove(channelName);
await updateSettings(_settings.copyWith(mutedChannels: updated)); await updateSettings(_settings.copyWith(mutedChannels: updated));
} }
Future<void> setTcpServerAddress(String value) async {
await updateSettings(_settings.copyWith(tcpServerAddress: value));
}
Future<void> setTcpServerPort(int value) async {
await updateSettings(_settings.copyWith(tcpServerPort: value));
}
} }
+1 -1
View File
@@ -65,7 +65,7 @@ class ChatTextScaleService extends ChangeNotifier {
void _commitScale() { void _commitScale() {
_saveTimer?.cancel(); _saveTimer?.cancel();
PrefsManager.instance.setDouble(_prefKey, _scale); unawaited(PrefsManager.instance.setDouble(_prefKey, _scale));
} }
double _clamp(double value) => value.clamp(_minScale, _maxScale).toDouble(); double _clamp(double value) => value.clamp(_minScale, _maxScale).toDouble();
+178 -54
View File
@@ -44,6 +44,12 @@ class MessageRetryService extends ChangeNotifier {
[]; // Rolling buffer of recent ACK hashes []; // Rolling buffer of recent ACK hashes
final Map<String, List<String>> _pendingMessageQueuePerContact = final Map<String, List<String>> _pendingMessageQueuePerContact =
{}; // contactPubKeyHex FIFO queue of messageIds (DEPRECATED - will be removed) {}; // contactPubKeyHex FIFO queue of messageIds (DEPRECATED - will be removed)
final Map<String, List<String>> _sendQueue =
{}; // contactPubKeyHex ordered list of messageIds awaiting send
final Set<String> _activeMessages =
{}; // messageIds currently in-flight (sent/retrying)
final Set<String> _resolvedMessages =
{}; // messageIds already resolved (prevents double _onMessageResolved)
final Map<String, String> _expectedHashToMessageId = final Map<String, String> _expectedHashToMessageId =
{}; // expectedAckHashHex messageId (for matching RESP_CODE_SENT by hash) {}; // expectedAckHashHex messageId (for matching RESP_CODE_SENT by hash)
@@ -52,12 +58,13 @@ class MessageRetryService extends ChangeNotifier {
Function(Message)? _updateMessageCallback; Function(Message)? _updateMessageCallback;
Function(Contact)? _clearContactPathCallback; Function(Contact)? _clearContactPathCallback;
Function(Contact, Uint8List, int)? _setContactPathCallback; Function(Contact, Uint8List, int)? _setContactPathCallback;
Function(int, int)? _calculateTimeoutCallback; Function(int, int, {String? contactKey})? _calculateTimeoutCallback;
Uint8List? Function()? _getSelfPublicKeyCallback; Uint8List? Function()? _getSelfPublicKeyCallback;
String Function(Contact, String)? _prepareContactOutboundTextCallback; String Function(Contact, String)? _prepareContactOutboundTextCallback;
AppSettingsService? _appSettingsService; AppSettingsService? _appSettingsService;
AppDebugLogService? _debugLogService; AppDebugLogService? _debugLogService;
Function(String, PathSelection, bool, int?)? _recordPathResultCallback; Function(String, PathSelection, bool, int?)? _recordPathResultCallback;
Function(String, int, int, int)? _onDeliveryObservedCallback;
MessageRetryService(); MessageRetryService();
@@ -67,12 +74,20 @@ class MessageRetryService extends ChangeNotifier {
required Function(Message) updateMessageCallback, required Function(Message) updateMessageCallback,
Function(Contact)? clearContactPathCallback, Function(Contact)? clearContactPathCallback,
Function(Contact, Uint8List, int)? setContactPathCallback, Function(Contact, Uint8List, int)? setContactPathCallback,
Function(int pathLength, int messageBytes)? calculateTimeoutCallback, Function(int pathLength, int messageBytes, {String? contactKey})?
calculateTimeoutCallback,
Uint8List? Function()? getSelfPublicKeyCallback, Uint8List? Function()? getSelfPublicKeyCallback,
String Function(Contact, String)? prepareContactOutboundTextCallback, String Function(Contact, String)? prepareContactOutboundTextCallback,
AppSettingsService? appSettingsService, AppSettingsService? appSettingsService,
AppDebugLogService? debugLogService, AppDebugLogService? debugLogService,
Function(String, PathSelection, bool, int?)? recordPathResultCallback, Function(String, PathSelection, bool, int?)? recordPathResultCallback,
Function(
String contactKey,
int pathLength,
int messageBytes,
int tripTimeMs,
)?
onDeliveryObservedCallback,
}) { }) {
_sendMessageCallback = sendMessageCallback; _sendMessageCallback = sendMessageCallback;
_addMessageCallback = addMessageCallback; _addMessageCallback = addMessageCallback;
@@ -85,6 +100,7 @@ class MessageRetryService extends ChangeNotifier {
_appSettingsService = appSettingsService; _appSettingsService = appSettingsService;
_debugLogService = debugLogService; _debugLogService = debugLogService;
_recordPathResultCallback = recordPathResultCallback; _recordPathResultCallback = recordPathResultCallback;
_onDeliveryObservedCallback = onDeliveryObservedCallback;
} }
/// Compute expected ACK hash using same algorithm as firmware: /// Compute expected ACK hash using same algorithm as firmware:
@@ -156,7 +172,49 @@ class MessageRetryService extends ChangeNotifier {
_addMessageCallback!(contact.publicKeyHex, message); _addMessageCallback!(contact.publicKeyHex, message);
} }
await _attemptSend(messageId); // Queue per contact only one message in-flight at a time to avoid
// overflowing the firmware's 8-entry expected_ack_table.
final contactKey = contact.publicKeyHex;
_sendQueue[contactKey] ??= [];
_sendQueue[contactKey]!.add(messageId);
if (!_activeMessages.any(
(id) => _pendingContacts[id]?.publicKeyHex == contactKey,
)) {
_sendNextForContact(contactKey);
}
}
void _sendNextForContact(String contactKey) {
final queue = _sendQueue[contactKey];
if (queue == null) return;
// Drain stale entries iteratively instead of recursing.
while (queue.isNotEmpty) {
final messageId = queue.removeAt(0);
if (_pendingMessages.containsKey(messageId)) {
_activeMessages.add(messageId);
_attemptSend(messageId).catchError((e) {
debugPrint('_attemptSend threw for $messageId: $e');
final msg = _pendingMessages[messageId];
if (msg != null) {
final failed = msg.copyWith(status: MessageStatus.failed);
_pendingMessages[messageId] = failed;
_updateMessageCallback?.call(failed);
}
_onMessageResolved(messageId, contactKey);
});
return;
}
// Message was cancelled/cleaned up while queued try next
}
}
void _onMessageResolved(String messageId, String contactKey) {
if (_resolvedMessages.contains(messageId)) return;
_resolvedMessages.add(messageId);
_activeMessages.remove(messageId);
_sendNextForContact(contactKey);
} }
Future<void> _attemptSend(String messageId) async { Future<void> _attemptSend(String messageId) async {
@@ -169,13 +227,11 @@ class MessageRetryService extends ChangeNotifier {
// Use the path that was captured when the message was first sent // Use the path that was captured when the message was first sent
if (_setContactPathCallback != null && _clearContactPathCallback != null) { if (_setContactPathCallback != null && _clearContactPathCallback != null) {
if (message.pathLength != null && message.pathLength! < 0) { if (message.pathLength != null && message.pathLength! < 0) {
// Flood mode - clear the path
debugPrint( debugPrint(
'Setting flood mode for retry attempt ${message.retryCount}', 'Setting flood mode for retry attempt ${message.retryCount}',
); );
_clearContactPathCallback!(contact); await _clearContactPathCallback!(contact);
} else if (message.pathLength != null && message.pathLength! >= 0) { } else if (message.pathLength != null && message.pathLength! >= 0) {
// Specific path (including direct neighbor with pathLength=0)
final pathStr = message.pathBytes.isEmpty final pathStr = message.pathBytes.isEmpty
? 'direct' ? 'direct'
: message.pathBytes : message.pathBytes
@@ -192,6 +248,24 @@ class MessageRetryService extends ChangeNotifier {
} }
} }
// Re-validate after async gap a timer or ACK could have resolved/retried
// this message while we were awaiting the path callback.
final currentMessage = _pendingMessages[messageId];
if (currentMessage == null || _resolvedMessages.contains(messageId)) {
debugPrint(
'_attemptSend: message $messageId resolved during path sync, aborting',
);
return;
}
// If the message was retried by a timer during our await, the retryCount
// will have advanced. Only proceed if it still matches the attempt we started.
if (currentMessage.retryCount != message.retryCount) {
debugPrint(
'_attemptSend: message $messageId retryCount changed during path sync, aborting',
);
return;
}
final attempt = message.retryCount.clamp(0, 3); final attempt = message.retryCount.clamp(0, 3);
final timestampSeconds = message.timestamp.millisecondsSinceEpoch ~/ 1000; final timestampSeconds = message.timestamp.millisecondsSinceEpoch ~/ 1000;
@@ -231,6 +305,15 @@ class MessageRetryService extends ChangeNotifier {
if (_sendMessageCallback != null) { if (_sendMessageCallback != null) {
_sendMessageCallback!(contact, message.text, attempt, timestampSeconds); _sendMessageCallback!(contact, message.text, attempt, timestampSeconds);
} else {
// No send callback message would be stuck forever. Fail it immediately.
debugPrint(
'_attemptSend: no sendMessageCallback, failing message $messageId',
);
final failedMessage = message.copyWith(status: MessageStatus.failed);
_pendingMessages[messageId] = failedMessage;
_updateMessageCallback?.call(failedMessage);
_onMessageResolved(messageId, contact.publicKeyHex);
} }
} }
@@ -281,6 +364,7 @@ class MessageRetryService extends ChangeNotifier {
} }
// FALLBACK: Old queue-based matching (for messages sent before hash computation was added) // FALLBACK: Old queue-based matching (for messages sent before hash computation was added)
// Only match within a single contact's queue to avoid cross-contact mismatches.
if (messageId == null && allowQueueFallback) { if (messageId == null && allowQueueFallback) {
_debugLogService?.warn( _debugLogService?.warn(
'RESP_CODE_SENT: ACK hash $ackHashHex not found in hash table, falling back to queue', 'RESP_CODE_SENT: ACK hash $ackHashHex not found in hash table, falling back to queue',
@@ -290,13 +374,16 @@ class MessageRetryService extends ChangeNotifier {
'Hash-based match failed for $ackHashHex, falling back to queue-based matching', 'Hash-based match failed for $ackHashHex, falling back to queue-based matching',
); );
for (var entry in _pendingMessageQueuePerContact.entries) { // Search all contact queues so concurrent chats don't miss matches.
final queuesToSearch = _pendingMessageQueuePerContact;
for (var entry in queuesToSearch.entries) {
final contactKey = entry.key; final contactKey = entry.key;
final queue = entry.value; final queue = entry.value;
if (queue.isNotEmpty) { // Drain stale entries until we find a valid one or exhaust the queue.
while (queue.isNotEmpty) {
final candidateMessageId = queue.removeAt(0); final candidateMessageId = queue.removeAt(0);
if (_pendingMessages.containsKey(candidateMessageId)) { if (_pendingMessages.containsKey(candidateMessageId)) {
messageId = candidateMessageId; messageId = candidateMessageId;
contact = _pendingContacts[candidateMessageId]; contact = _pendingContacts[candidateMessageId];
@@ -304,21 +391,10 @@ class MessageRetryService extends ChangeNotifier {
'Queue-based match (fallback): $ackHashHex → message $messageId for $contactKey', 'Queue-based match (fallback): $ackHashHex → message $messageId for $contactKey',
); );
break; break;
} else {
debugPrint('Dequeued stale message $candidateMessageId - skipping');
if (queue.isNotEmpty) {
final nextMessageId = queue.removeAt(0);
if (_pendingMessages.containsKey(nextMessageId)) {
messageId = nextMessageId;
contact = _pendingContacts[nextMessageId];
debugPrint(
'Queue-based match (fallback): $ackHashHex → message $messageId',
);
break;
}
}
} }
debugPrint('Dequeued stale message $candidateMessageId - skipping');
} }
if (messageId != null) break;
} }
} }
@@ -357,25 +433,33 @@ class MessageRetryService extends ChangeNotifier {
); );
} }
// Use device-provided timeout, or calculate from radio settings if timeout is 0 or invalid // Calculate timeout: prefer ML prediction, then device-provided, then physics fallback
int pathLengthValue;
if (selection != null) {
pathLengthValue = selection.useFlood ? -1 : selection.hopCount;
if (pathLengthValue < 0) pathLengthValue = contact.pathLength;
} else if (message.pathLength != null) {
pathLengthValue = message.pathLength!;
} else {
pathLengthValue = contact.pathLength;
}
int actualTimeout = timeoutMs; int actualTimeout = timeoutMs;
if (timeoutMs <= 0 && _calculateTimeoutCallback != null) { if (_calculateTimeoutCallback != null) {
int pathLengthValue; final calculated = _calculateTimeoutCallback!(
if (selection != null) {
pathLengthValue = selection.useFlood ? -1 : selection.hopCount;
if (pathLengthValue < 0) pathLengthValue = contact.pathLength;
} else if (message.pathLength != null) {
pathLengthValue = message.pathLength!;
} else {
pathLengthValue = contact.pathLength;
}
actualTimeout = _calculateTimeoutCallback!(
pathLengthValue, pathLengthValue,
message.text.length, message.text.length,
contactKey: contact.publicKeyHex,
); );
debugPrint( // calculateTimeout tries ML first, falls back to physics.
'Using calculated timeout: ${actualTimeout}ms for path length $pathLengthValue', // Use calculated value if device didn't provide one, or if ML
); // produced a tighter prediction than the device's estimate.
if (timeoutMs <= 0 || calculated < timeoutMs) {
actualTimeout = calculated;
debugPrint(
'Using calculated timeout: ${actualTimeout}ms for path length $pathLengthValue',
);
}
} }
final updatedMessage = message.copyWith( final updatedMessage = message.copyWith(
@@ -463,22 +547,7 @@ class MessageRetryService extends ChangeNotifier {
} else { } else {
// Max retries reached - mark as failed // Max retries reached - mark as failed
final failedMessage = message.copyWith(status: MessageStatus.failed); final failedMessage = message.copyWith(status: MessageStatus.failed);
_pendingMessages[messageId] = failedMessage;
// Move ACK hashes to history before removing
_moveAckHashesToHistory(messageId);
_pendingMessages.remove(messageId);
_pendingContacts.remove(messageId);
_pendingPathSelections.remove(messageId);
_timeoutTimers[messageId]?.cancel();
_timeoutTimers.remove(messageId);
// Clean up the queue entry for this contact
_pendingMessageQueuePerContact[contact.publicKeyHex]?.remove(messageId);
if (_pendingMessageQueuePerContact[contact.publicKeyHex]?.isEmpty ??
false) {
_pendingMessageQueuePerContact.remove(contact.publicKeyHex);
}
// Check if we should clear the path on max retry // Check if we should clear the path on max retry
if (_appSettingsService?.settings.clearPathOnMaxRetry == true && if (_appSettingsService?.settings.clearPathOnMaxRetry == true &&
@@ -499,6 +568,30 @@ class MessageRetryService extends ChangeNotifier {
} }
notifyListeners(); notifyListeners();
// Message is done retrying send next queued message for this contact
_onMessageResolved(messageId, contact.publicKeyHex);
// Keep message in pending maps for 30s grace period so late ACKs
// can still match and update the message to delivered.
_timeoutTimers[messageId] = Timer(const Duration(seconds: 30), () {
_moveAckHashesToHistory(messageId);
// Clean up ALL hash mappings for this message
_ackHashToMessageId.removeWhere(
(_, mapping) => mapping.messageId == messageId,
);
_expectedHashToMessageId.removeWhere((_, msgId) => msgId == messageId);
_pendingMessages.remove(messageId);
_pendingContacts.remove(messageId);
_pendingPathSelections.remove(messageId);
_timeoutTimers.remove(messageId);
_resolvedMessages.remove(messageId);
final contactKey = contact.publicKeyHex;
_pendingMessageQueuePerContact[contactKey]?.remove(messageId);
if (_pendingMessageQueuePerContact[contactKey]?.isEmpty ?? false) {
_pendingMessageQueuePerContact.remove(contactKey);
}
});
} }
} }
@@ -594,7 +687,15 @@ class MessageRetryService extends ChangeNotifier {
} }
if (matchedMessageId != null) { if (matchedMessageId != null) {
final message = _pendingMessages[matchedMessageId]!; final message = _pendingMessages[matchedMessageId];
if (message == null) {
// Message was already cleaned up (e.g. grace period expired)
_ackHashToMessageId.remove(ackHashHex);
debugPrint(
'ACK matched $matchedMessageId but message already cleaned up',
);
return;
}
final contact = _pendingContacts[matchedMessageId]; final contact = _pendingContacts[matchedMessageId];
final selection = _pendingPathSelections[matchedMessageId]; final selection = _pendingPathSelections[matchedMessageId];
@@ -616,12 +717,21 @@ class MessageRetryService extends ChangeNotifier {
tripTimeMs: tripTimeMs, tripTimeMs: tripTimeMs,
); );
// Clean up ALL hash mappings for this message (from all retry attempts)
_ackHashToMessageId.removeWhere(
(_, mapping) => mapping.messageId == matchedMessageId,
);
_expectedHashToMessageId.removeWhere(
(_, msgId) => msgId == matchedMessageId,
);
// Move ACK hashes to history before removing // Move ACK hashes to history before removing
_moveAckHashesToHistory(matchedMessageId); _moveAckHashesToHistory(matchedMessageId);
_pendingMessages.remove(matchedMessageId); _pendingMessages.remove(matchedMessageId);
_pendingContacts.remove(matchedMessageId); _pendingContacts.remove(matchedMessageId);
_pendingPathSelections.remove(matchedMessageId); _pendingPathSelections.remove(matchedMessageId);
_resolvedMessages.remove(matchedMessageId);
// Clean up the queue entry for this contact (remove any remaining references to this message) // Clean up the queue entry for this contact (remove any remaining references to this message)
if (contact != null) { if (contact != null) {
@@ -646,6 +756,17 @@ class MessageRetryService extends ChangeNotifier {
true, true,
tripTimeMs, tripTimeMs,
); );
if (_onDeliveryObservedCallback != null &&
tripTimeMs > 0 &&
message.pathLength != null) {
_onDeliveryObservedCallback!(
contact.publicKeyHex,
message.pathLength!,
message.text.length,
tripTimeMs,
);
}
_onMessageResolved(matchedMessageId, contact.publicKeyHex);
} }
notifyListeners(); notifyListeners();
@@ -783,6 +904,9 @@ class MessageRetryService extends ChangeNotifier {
_ackHistory.clear(); _ackHistory.clear();
_ackHashToMessageId.clear(); _ackHashToMessageId.clear();
_pendingMessageQueuePerContact.clear(); _pendingMessageQueuePerContact.clear();
_sendQueue.clear();
_activeMessages.clear();
_resolvedMessages.clear();
super.dispose(); super.dispose();
} }
} }
+59 -3
View File
@@ -101,8 +101,7 @@ class NotificationService {
final addr = Platform.environment['DBUS_SESSION_BUS_ADDRESS']; final addr = Platform.environment['DBUS_SESSION_BUS_ADDRESS'];
if (addr != null && addr.isNotEmpty) return true; if (addr != null && addr.isNotEmpty) return true;
// Fallback: check the default socket for the current user. // Fallback: check the default socket for the current user.
final uid = Platform.environment['UID'] ?? final uid = Platform.environment['UID'] ?? Platform.environment['EUID'];
Platform.environment['EUID'];
final path = '/run/user/${uid ?? '1000'}/bus'; final path = '/run/user/${uid ?? '1000'}/bus';
return File(path).existsSync(); return File(path).existsSync();
} }
@@ -233,7 +232,9 @@ class NotificationService {
try { try {
await _notifications.show( await _notifications.show(
id: contactId?.hashCode ?? DateTime.now().millisecondsSinceEpoch, id: contactId != null
? 'advert:$contactId'.hashCode
: DateTime.now().millisecondsSinceEpoch,
title: _l10n.notification_newTypeDiscovered(contactType), title: _l10n.notification_newTypeDiscovered(contactType),
body: contactName, body: contactName,
notificationDetails: notificationDetails, notificationDetails: notificationDetails,
@@ -332,6 +333,61 @@ class NotificationService {
await _notifications.cancel(id: id); await _notifications.cancel(id: id);
} }
/// Cancel the notification for a specific contact and update the app badge.
Future<void> clearContactNotification(
String contactId,
int totalUnreadCount,
) async {
if (!await _ensureInitialized()) return;
await _notifications.cancel(id: contactId.hashCode);
await _updateBadge(totalUnreadCount);
}
/// Cancel the notification for a specific channel and update the app badge.
Future<void> clearChannelNotification(
int channelIndex,
int totalUnreadCount,
) async {
if (!await _ensureInitialized()) return;
await _notifications.cancel(id: channelIndex.hashCode);
await _updateBadge(totalUnreadCount);
}
/// Cancel advert notifications for the given contact public key hexes.
Future<void> clearAdvertNotifications(List<String> contactIds) async {
if (!await _ensureInitialized()) return;
for (final id in contactIds) {
await _notifications.cancel(id: 'advert:$id'.hashCode);
}
}
Future<void> _updateBadge(int count) async {
if (PlatformInfo.isIOS || PlatformInfo.isMacOS) {
// On Apple platforms, set the badge number directly via a silent update.
final darwinDetails = DarwinNotificationDetails(
presentAlert: false,
presentSound: false,
presentBadge: true,
badgeNumber: count,
);
final details = NotificationDetails(
iOS: darwinDetails,
macOS: darwinDetails,
);
// Use a fixed ID so each update replaces the previous one.
await _notifications.show(
id: 'badge_update'.hashCode,
title: null,
body: null,
notificationDetails: details,
);
// Immediately cancel the silent notification so it doesn't appear in tray.
await _notifications.cancel(id: 'badge_update'.hashCode);
}
// On Android, badge count is derived from active notifications,
// so cancelling the specific notification above is sufficient.
}
// //
// Public notification methods (rate limiting is enforced automatically) // Public notification methods (rate limiting is enforced automatically)
// //
+31
View File
@@ -1,4 +1,5 @@
import 'dart:convert'; import 'dart:convert';
import '../models/delivery_observation.dart';
import '../models/path_history.dart'; import '../models/path_history.dart';
import '../storage/prefs_manager.dart'; import '../storage/prefs_manager.dart';
@@ -6,6 +7,7 @@ class StorageService {
static const String _pathHistoryPrefix = 'path_history_'; static const String _pathHistoryPrefix = 'path_history_';
static const String _pendingMessagesKey = 'pending_messages'; static const String _pendingMessagesKey = 'pending_messages';
static const String _repeaterPasswordsKey = 'repeater_passwords'; static const String _repeaterPasswordsKey = 'repeater_passwords';
static const String _deliveryObservationsKey = 'delivery_observations';
Future<void> savePathHistory( Future<void> savePathHistory(
String contactPubKeyHex, String contactPubKeyHex,
@@ -122,4 +124,33 @@ class StorageService {
final prefs = PrefsManager.instance; final prefs = PrefsManager.instance;
await prefs.remove(_repeaterPasswordsKey); await prefs.remove(_repeaterPasswordsKey);
} }
Future<void> saveDeliveryObservations(
List<DeliveryObservation> observations,
) async {
final prefs = PrefsManager.instance;
final jsonStr = jsonEncode(observations.map((o) => o.toJson()).toList());
await prefs.setString(_deliveryObservationsKey, jsonStr);
}
Future<List<DeliveryObservation>> loadDeliveryObservations() async {
final prefs = PrefsManager.instance;
final jsonStr = prefs.getString(_deliveryObservationsKey);
if (jsonStr == null) return [];
try {
final list = jsonDecode(jsonStr) as List;
return list
.map((e) => DeliveryObservation.fromJson(e as Map<String, dynamic>))
.toList();
} catch (e) {
return [];
}
}
Future<void> clearDeliveryObservations() async {
final prefs = PrefsManager.instance;
await prefs.remove(_deliveryObservationsKey);
}
} }
+2
View File
@@ -0,0 +1,2 @@
export 'tcp_transport_service_native.dart'
if (dart.library.js_interop) 'tcp_transport_service_web.dart';
@@ -0,0 +1,210 @@
import 'dart:async';
import 'dart:io';
import 'dart:typed_data';
import 'app_debug_log_service.dart';
import 'usb_serial_frame_codec.dart';
class TcpTransportService {
final StreamController<Uint8List> _frameController =
StreamController<Uint8List>.broadcast();
final UsbSerialFrameDecoder _frameDecoder = UsbSerialFrameDecoder();
StreamSubscription<Uint8List>? _socketSubscription;
Socket? _socket;
AppDebugLogService? _debugLogService;
TcpTransportStatus _status = TcpTransportStatus.disconnected;
String? _activeHost;
int? _activePort;
Future<void> _pendingWrite = Future<void>.value();
int _connectGeneration = 0;
TcpTransportStatus get status => _status;
Stream<Uint8List> get frameStream => _frameController.stream;
bool get isConnected => _status == TcpTransportStatus.connected;
String? get activeEndpoint => _activeHost == null || _activePort == null
? null
: '$_activeHost:$_activePort';
void setDebugLogService(AppDebugLogService? service) {
_debugLogService = service;
}
Future<void> connect({
required String host,
required int port,
Duration timeout = const Duration(seconds: 10),
}) async {
if (_status == TcpTransportStatus.connected ||
_status == TcpTransportStatus.connecting) {
throw StateError('TCP transport is already active');
}
final trimmedHost = host.trim();
if (trimmedHost.isEmpty) {
throw ArgumentError.value(host, 'host', 'Host cannot be empty');
}
if (port < 1 || port > 65535) {
throw ArgumentError.value(port, 'port', 'Port must be in 1..65535');
}
_status = TcpTransportStatus.connecting;
final generation = ++_connectGeneration;
_frameDecoder.reset();
try {
final socket = await Socket.connect(trimmedHost, port, timeout: timeout);
if (generation != _connectGeneration ||
_status != TcpTransportStatus.connecting) {
try {
await socket.close();
} catch (_) {}
try {
socket.destroy();
} catch (_) {}
return;
}
socket.setOption(SocketOption.tcpNoDelay, true);
_socket = socket;
_activeHost = trimmedHost;
_activePort = port;
_socketSubscription = socket.listen(
_handleSocketData,
onError: _handleSocketError,
onDone: _handleSocketDone,
);
_status = TcpTransportStatus.connected;
_debugLogService?.info(
'TCP transport opened endpoint=$activeEndpoint',
tag: 'TCP',
);
} catch (error) {
await _cleanupFailedConnect();
_status = TcpTransportStatus.disconnected;
rethrow;
}
}
Future<void> write(Uint8List data) async {
if (!isConnected || _socket == null) {
throw StateError('TCP transport is not connected');
}
final packet = wrapUsbSerialTxFrame(data);
_logFrameSummary('TCP TX frame', data);
final writeTask = _pendingWrite.then((_) async {
final socket = _socket;
if (!isConnected || socket == null) {
throw StateError('TCP transport is not connected');
}
socket.add(packet);
await socket.flush();
});
_pendingWrite = writeTask.catchError((_) {});
await writeTask;
}
Future<void> disconnect() async {
_connectGeneration += 1;
if (_status == TcpTransportStatus.disconnected) return;
final endpoint = activeEndpoint;
_status = TcpTransportStatus.disconnecting;
_frameDecoder.reset();
_activeHost = null;
_activePort = null;
final subscription = _socketSubscription;
_socketSubscription = null;
await subscription?.cancel();
final socket = _socket;
_socket = null;
try {
await socket?.close();
} catch (_) {}
try {
socket?.destroy();
} catch (_) {}
_status = TcpTransportStatus.disconnected;
_debugLogService?.info(
'TCP transport closed endpoint=${endpoint ?? 'unknown'}',
tag: 'TCP',
);
}
void dispose() {
unawaited(disconnect().whenComplete(_closeFrameController));
}
Future<void> _cleanupFailedConnect() async {
final subscription = _socketSubscription;
_socketSubscription = null;
await subscription?.cancel();
final socket = _socket;
_socket = null;
try {
await socket?.close();
} catch (_) {}
try {
socket?.destroy();
} catch (_) {}
_activeHost = null;
_activePort = null;
_frameDecoder.reset();
}
void _handleSocketData(Uint8List bytes) {
for (final packet in _frameDecoder.ingest(bytes)) {
if (!packet.isRxFrame) {
_debugLogService?.info(
'TCP ignored packet start=0x${packet.frameStart.toRadixString(16).padLeft(2, '0')} len=${packet.payload.length}',
tag: 'TCP',
);
continue;
}
_addFrame(packet.payload);
}
}
void _handleSocketError(Object error, [StackTrace? stackTrace]) {
_addFrameError(error, stackTrace);
unawaited(disconnect());
}
void _handleSocketDone() {
if (_status == TcpTransportStatus.disconnecting ||
_status == TcpTransportStatus.disconnected) {
return;
}
_addFrameError(StateError('TCP socket closed by remote endpoint'));
unawaited(disconnect());
}
void _addFrame(Uint8List payload) {
if (_frameController.isClosed) return;
_frameController.add(payload);
}
void _addFrameError(Object error, [StackTrace? stackTrace]) {
if (_frameController.isClosed) return;
_frameController.addError(error, stackTrace);
}
void _logFrameSummary(String prefix, Uint8List payload) {
final code = payload.isNotEmpty ? payload.first : -1;
_debugLogService?.info(
'$prefix code=$code len=${payload.length}',
tag: 'TCP',
);
}
Future<void> _closeFrameController() async {
if (_frameController.isClosed) return;
await _frameController.close();
}
}
enum TcpTransportStatus { disconnected, connecting, connected, disconnecting }
@@ -0,0 +1,35 @@
import 'dart:typed_data';
import 'app_debug_log_service.dart';
class TcpTransportService {
AppDebugLogService? _debugLogService;
Stream<Uint8List> get frameStream => const Stream<Uint8List>.empty();
bool get isConnected => false;
String? get activeEndpoint => null;
void setDebugLogService(AppDebugLogService? service) {
_debugLogService = service;
}
Future<void> connect({
required String host,
required int port,
Duration timeout = const Duration(seconds: 10),
}) async {
_debugLogService?.warn(
'TCP transport requested on web for $host:$port',
tag: 'TCP',
);
throw UnsupportedError('TCP transport is not supported on web.');
}
Future<void> write(Uint8List data) async {
throw UnsupportedError('TCP transport is not supported on web.');
}
Future<void> disconnect() async {}
void dispose() {}
}
@@ -0,0 +1,229 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:ml_algo/ml_algo.dart';
import 'package:ml_dataframe/ml_dataframe.dart';
import '../models/delivery_observation.dart';
import 'storage_service.dart';
class _ContactStats {
int count = 0;
double _sum = 0;
void add(double ms) {
count++;
_sum += ms;
}
double get mean => _sum / count;
}
class TimeoutPredictionService extends ChangeNotifier {
final StorageService? _storage;
static const int minObservations = 10;
static const int maxObservations = 100;
static const int _retrainInterval = 5;
// 1.5x multiplier on raw prediction to account for variance in delivery
// times tight enough to improve on worst-case physics, loose enough
// to avoid premature timeouts from model noise.
static const double _safetyMargin = 1.5;
static const int _minContactObservations = 10;
List<DeliveryObservation> _observations = [];
LinearRegressor? _model;
List<String> _activeFeatures = [];
int _observationsSinceLastTrain = 0;
final Map<String, _ContactStats> _contactStats = {};
Timer? _persistTimer;
TimeoutPredictionService(StorageService storage) : _storage = storage;
TimeoutPredictionService.noStorage() : _storage = null;
int get observationCount => _observations.length;
bool get hasModel => _model != null;
Future<void> initialize() async {
_observations = await _storage?.loadDeliveryObservations() ?? [];
_rebuildContactStats();
if (_observations.length >= minObservations) {
_trainModel();
}
debugPrint(
'TimeoutPrediction: initialized with ${_observations.length} observations, '
'model=${_model != null ? "ready" : "waiting for data"}',
);
}
void recordObservation({
required String contactKey,
required int pathLength,
required int messageBytes,
required int tripTimeMs,
int secondsSinceLastRx = 0,
}) {
final observation = DeliveryObservation(
contactKey: contactKey,
pathLength: pathLength,
messageBytes: messageBytes,
secondsSinceLastRx: secondsSinceLastRx,
isFlood: pathLength < 0,
deliveryMs: tripTimeMs,
timestamp: DateTime.now(),
);
_observations.add(observation);
if (_observations.length > maxObservations) {
_observations.removeAt(0);
}
_contactStats.putIfAbsent(contactKey, () => _ContactStats());
_contactStats[contactKey]!.add(tripTimeMs.toDouble());
_observationsSinceLastTrain++;
if (_observationsSinceLastTrain >= _retrainInterval &&
_observations.length >= minObservations) {
_trainModel();
}
_persistTimer?.cancel();
_persistTimer = Timer(const Duration(seconds: 2), () {
_storage?.saveDeliveryObservations(_observations);
});
debugPrint(
'TimeoutPrediction: recorded ${tripTimeMs}ms for $pathLength hops '
'(${_observations.length} total)',
);
}
int? predictTimeout({
String? contactKey,
required int pathLength,
required int messageBytes,
int secondsSinceLastRx = 0,
}) {
if (_model == null) return null;
try {
if (_activeFeatures.isEmpty) return null;
final allFeatures = {
'pathLength': pathLength.toDouble(),
'messageBytes': messageBytes.toDouble(),
'secSinceRx': secondsSinceLastRx.toDouble(),
'isFlood': pathLength < 0 ? 1.0 : 0.0,
};
final row = _activeFeatures.map((f) => allFeatures[f]!).toList();
final features = DataFrame(
[row],
headerExists: false,
header: _activeFeatures,
);
final prediction = _model!.predict(features);
final rawValue = prediction.rows.first.first;
var predictedMs = (rawValue is double)
? rawValue
: (rawValue as num).toDouble();
debugPrint(
'TimeoutPrediction: raw prediction=$predictedMs for '
'pathLength=$pathLength, messageBytes=$messageBytes, '
'features=$_activeFeatures',
);
// Sanity check: if prediction is negative or zero, fall back
if (predictedMs <= 0) return null;
// Blend with per-contact mean if enough data
if (contactKey != null) {
final stats = _contactStats[contactKey];
if (stats != null && stats.count >= _minContactObservations) {
predictedMs = 0.5 * predictedMs + 0.5 * stats.mean;
}
}
// Connector clamps this between physics min/max bounds
final timeout = (predictedMs * _safetyMargin).ceil();
debugPrint(
'TimeoutPrediction: ML timeout ${timeout}ms '
'(raw: ${predictedMs.round()}ms, contact: $contactKey)',
);
return timeout;
} catch (e) {
debugPrint('TimeoutPrediction: prediction failed: $e');
return null;
}
}
void _trainModel() {
try {
// Build feature columns, then exclude any with zero variance
// (ml_algo's OLS produces all-zero coefficients for singular matrices)
final allNames = ['pathLength', 'messageBytes', 'secSinceRx', 'isFlood'];
final allExtractors = <double Function(DeliveryObservation)>[
(o) => o.pathLength.toDouble(),
(o) => o.messageBytes.toDouble(),
(o) => o.secondsSinceLastRx.toDouble(),
(o) => o.isFlood ? 1.0 : 0.0,
];
_activeFeatures = [];
for (var i = 0; i < allNames.length; i++) {
final values = _observations.map(allExtractors[i]).toSet();
if (values.length > 1) _activeFeatures.add(allNames[i]);
}
if (_activeFeatures.isEmpty) {
debugPrint(
'TimeoutPrediction: no features with variance, skipping training',
);
return;
}
final header = [..._activeFeatures, 'deliveryMs'];
final rows = _observations.map((o) {
final row = <double>[];
for (var i = 0; i < allNames.length; i++) {
if (_activeFeatures.contains(allNames[i])) {
row.add(allExtractors[i](o));
}
}
row.add(o.deliveryMs.toDouble());
return row;
});
final data = DataFrame([header, ...rows], headerExists: true);
_model = LinearRegressor(data, 'deliveryMs');
_observationsSinceLastTrain = 0;
// Log training summary with sample predictions
final avgMs =
_observations.map((o) => o.deliveryMs).reduce((a, b) => a + b) /
_observations.length;
debugPrint(
'TimeoutPrediction: trained on ${_observations.length} observations '
'(avg: ${avgMs.round()}ms, features: $_activeFeatures)',
);
} catch (e) {
debugPrint('TimeoutPrediction: training failed: $e');
}
}
@override
void dispose() {
_persistTimer?.cancel();
super.dispose();
}
void _rebuildContactStats() {
_contactStats.clear();
for (final obs in _observations) {
_contactStats.putIfAbsent(obs.contactKey, () => _ContactStats());
_contactStats[obs.contactKey]!.add(obs.deliveryMs.toDouble());
}
}
}
+154
View File
@@ -0,0 +1,154 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import '../storage/prefs_manager.dart';
import '../utils/contact_search.dart';
const String contactsAllGroupsValue = '__all__';
enum ChannelSortOption { manual, name, latestMessages, unread }
class UiViewStateService extends ChangeNotifier {
static const _keyContactsSelectedGroupName = 'ui_contacts_selected_group';
static const _keyContactsSortOption = 'ui_contacts_sort_option';
static const _keyContactsShowUnreadOnly = 'ui_contacts_show_unread_only';
static const _keyContactsTypeFilter = 'ui_contacts_type_filter';
static const _keyChannelsSortOption = 'ui_channels_sort_option';
static const _keyChannelsSortIndexLegacy = 'ui_channels_sort_index';
String _contactsSelectedGroupName = contactsAllGroupsValue;
String _contactsSearchText = '';
bool _contactsSearchExpanded = false;
ContactSortOption _contactsSortOption = ContactSortOption.lastSeen;
bool _contactsShowUnreadOnly = false;
ContactTypeFilter _contactsTypeFilter = ContactTypeFilter.all;
String _channelsSearchText = '';
ChannelSortOption _channelsSortOption = ChannelSortOption.manual;
String get contactsSelectedGroupName => _contactsSelectedGroupName;
String get contactsSearchText => _contactsSearchText;
bool get contactsSearchExpanded => _contactsSearchExpanded;
ContactSortOption get contactsSortOption => _contactsSortOption;
bool get contactsShowUnreadOnly => _contactsShowUnreadOnly;
ContactTypeFilter get contactsTypeFilter => _contactsTypeFilter;
String get channelsSearchText => _channelsSearchText;
ChannelSortOption get channelsSortOption => _channelsSortOption;
Future<void> initialize() async {
final prefs = PrefsManager.instance;
final selectedGroupName = prefs.getString(_keyContactsSelectedGroupName);
if (selectedGroupName != null && selectedGroupName.isNotEmpty) {
_contactsSelectedGroupName = selectedGroupName;
}
final sortStr = prefs.getString(_keyContactsSortOption);
if (sortStr != null) {
_contactsSortOption = ContactSortOption.values.firstWhere(
(e) => e.name == sortStr,
orElse: () => ContactSortOption.lastSeen,
);
}
_contactsShowUnreadOnly =
prefs.getBool(_keyContactsShowUnreadOnly) ?? false;
final typeStr = prefs.getString(_keyContactsTypeFilter);
if (typeStr != null) {
_contactsTypeFilter = ContactTypeFilter.values.firstWhere(
(e) => e.name == typeStr,
orElse: () => ContactTypeFilter.all,
);
}
final channelSortStr = prefs.getString(_keyChannelsSortOption);
if (channelSortStr != null) {
_channelsSortOption = ChannelSortOption.values.firstWhere(
(e) => e.name == channelSortStr,
orElse: () => ChannelSortOption.manual,
);
return;
}
// Backward compatibility for old persisted index format.
switch (prefs.getInt(_keyChannelsSortIndexLegacy) ?? 0) {
case 0:
_channelsSortOption = ChannelSortOption.manual;
break;
case 1:
_channelsSortOption = ChannelSortOption.name;
break;
case 2:
_channelsSortOption = ChannelSortOption.latestMessages;
break;
case 3:
_channelsSortOption = ChannelSortOption.unread;
break;
default:
_channelsSortOption = ChannelSortOption.manual;
}
}
void setContactsSelectedGroupName(String value) {
if (_contactsSelectedGroupName == value) return;
_contactsSelectedGroupName = value;
notifyListeners();
unawaited(
PrefsManager.instance.setString(_keyContactsSelectedGroupName, value),
);
}
void setContactsSearchText(String value) {
if (_contactsSearchText == value) return;
_contactsSearchText = value;
notifyListeners();
}
void setContactsSearchExpanded(bool value) {
if (_contactsSearchExpanded == value) return;
_contactsSearchExpanded = value;
notifyListeners();
}
void setContactsSortOption(ContactSortOption value) {
if (_contactsSortOption == value) return;
_contactsSortOption = value;
notifyListeners();
unawaited(
PrefsManager.instance.setString(_keyContactsSortOption, value.name),
);
}
void setContactsShowUnreadOnly(bool value) {
if (_contactsShowUnreadOnly == value) return;
_contactsShowUnreadOnly = value;
notifyListeners();
unawaited(PrefsManager.instance.setBool(_keyContactsShowUnreadOnly, value));
}
void setContactsTypeFilter(ContactTypeFilter value) {
if (_contactsTypeFilter == value) return;
_contactsTypeFilter = value;
notifyListeners();
unawaited(
PrefsManager.instance.setString(_keyContactsTypeFilter, value.name),
);
}
void setChannelsSearchText(String value) {
if (_channelsSearchText == value) return;
_channelsSearchText = value;
notifyListeners();
}
void setChannelsSortOption(ChannelSortOption value) {
if (_channelsSortOption == value) return;
_channelsSortOption = value;
notifyListeners();
unawaited(
PrefsManager.instance.setString(_keyChannelsSortOption, value.name),
);
}
}
@@ -189,6 +189,10 @@ class UsbSerialService {
serial.setStopBits1(); serial.setStopBits1();
serial.setFlowControlNone(); serial.setFlowControlNone();
serial.setRTS(false); serial.setRTS(false);
// Toggle DTR lowhigh so the device sees a fresh connection even
// if the previous disconnect didn't cleanly signal DTR drop.
serial.setDTR(false);
await Future<void>.delayed(const Duration(milliseconds: 50));
serial.setDTR(true); serial.setDTR(true);
_serial = serial; _serial = serial;
// Update the normalized port name to whichever candidate succeeded. // Update the normalized port name to whichever candidate succeeded.
@@ -249,6 +253,21 @@ class UsbSerialService {
_status = UsbSerialStatus.connected; _status = UsbSerialStatus.connected;
} }
Future<void> writeRaw(Uint8List data) async {
if (!isConnected) {
throw StateError('USB serial port is not open');
}
if (_useAndroidUsbHost) {
try {
await _androidMethodChannel.invokeMethod<void>('write', {'data': data});
} on PlatformException catch (error) {
throw StateError(error.message ?? error.code);
}
} else {
_serial!.write(data);
}
}
Future<void> write(Uint8List data) async { Future<void> write(Uint8List data) async {
if (!isConnected) { if (!isConnected) {
throw StateError('USB serial port is not open'); throw StateError('USB serial port is not open');
@@ -300,6 +319,7 @@ class UsbSerialService {
_serial = null; _serial = null;
try { try {
if (serial?.isOpen() == FlOpenStatus.open) { if (serial?.isOpen() == FlOpenStatus.open) {
serial?.setDTR(false);
serial?.closePort(); serial?.closePort();
} }
} catch (_) { } catch (_) {
@@ -350,6 +370,7 @@ class UsbSerialService {
final serial = _serial; final serial = _serial;
try { try {
if (serial?.isOpen() == FlOpenStatus.open) { if (serial?.isOpen() == FlOpenStatus.open) {
serial?.setDTR(false);
serial?.closePort(); // synchronous C call kills the SerialThread serial?.closePort(); // synchronous C call kills the SerialThread
} }
} catch (_) {} } catch (_) {}
+42 -8
View File
@@ -118,10 +118,7 @@ class UsbSerialService {
tag: 'USB Serial', tag: 'USB Serial',
); );
} catch (error) { } catch (error) {
_debugLogService?.error( _debugLogService?.error('Web connect failed: $error', tag: 'USB Serial');
'Web connect failed: $error',
tag: 'USB Serial',
);
await _cleanupFailedConnect(); await _cleanupFailedConnect();
_status = UsbSerialStatus.disconnected; _status = UsbSerialStatus.disconnected;
_connectedPortName = null; _connectedPortName = null;
@@ -130,6 +127,17 @@ class UsbSerialService {
} }
} }
Future<void> writeRaw(Uint8List data) async {
if (!isConnected || _writer == null) {
throw StateError('USB serial port is not open');
}
final promise = _writer!.callMethod<JSPromise<JSAny?>>(
'write'.toJS,
data.toJS,
);
await promise.toDart;
}
Future<void> write(Uint8List data) async { Future<void> write(Uint8List data) async {
if (!isConnected || _writer == null) { if (!isConnected || _writer == null) {
throw StateError('USB serial port is not open'); throw StateError('USB serial port is not open');
@@ -268,9 +276,23 @@ class UsbSerialService {
return null; return null;
} }
Future<void> _openPort(JSObject port, int baudRate) { Future<void> _openPort(JSObject port, int baudRate) async {
final options = JSObject()..['baudRate'] = baudRate.toJS; final options = JSObject()
return port.callMethod<JSPromise<JSAny?>>('open'.toJS, options).toDart; ..['baudRate'] = baudRate.toJS
..['flowControl'] = 'none'.toJS;
await port.callMethod<JSPromise<JSAny?>>('open'.toJS, options).toDart;
// Prevent ESP32 USB-CDC reset: hold DTR=true, RTS=false after open.
try {
final signals = JSObject()
..['dataTerminalReady'] = true.toJS
..['requestToSend'] = false.toJS;
await port
.callMethod<JSPromise<JSAny?>>('setSignals'.toJS, signals)
.toDart;
} catch (_) {
// setSignals may not be supported on all browsers/devices.
}
} }
Future<void> _cleanupFailedConnect() async { Future<void> _cleanupFailedConnect() async {
@@ -324,8 +346,12 @@ class UsbSerialService {
Future<void> _pumpReads() async { Future<void> _pumpReads() async {
final reader = _reader; final reader = _reader;
if (reader == null) return; if (reader == null) {
_debugLogService?.warn('_pumpReads: reader is null', tag: 'USB Serial');
return;
}
_debugLogService?.info('_pumpReads: started', tag: 'USB Serial');
try { try {
while (_status == UsbSerialStatus.connected && while (_status == UsbSerialStatus.connected &&
identical(reader, _reader)) { identical(reader, _reader)) {
@@ -333,6 +359,7 @@ class UsbSerialService {
.callMethod<JSPromise<JSAny?>>('read'.toJS) .callMethod<JSPromise<JSAny?>>('read'.toJS)
.toDart; .toDart;
if (result == null) { if (result == null) {
_debugLogService?.warn('_pumpReads: null result', tag: 'USB Serial');
break; break;
} }
final resultObject = result as JSObject; final resultObject = result as JSObject;
@@ -340,20 +367,27 @@ class UsbSerialService {
final doneValue = resultObject.getProperty<JSAny?>('done'.toJS); final doneValue = resultObject.getProperty<JSAny?>('done'.toJS);
final done = doneValue != null && doneValue.dartify() == true; final done = doneValue != null && doneValue.dartify() == true;
if (done) { if (done) {
_debugLogService?.info('_pumpReads: done=true', tag: 'USB Serial');
break; break;
} }
final value = resultObject.getProperty<JSAny?>('value'.toJS); final value = resultObject.getProperty<JSAny?>('value'.toJS);
final bytes = _coerceBytes(value); final bytes = _coerceBytes(value);
if (bytes != null && bytes.isNotEmpty) { if (bytes != null && bytes.isNotEmpty) {
_debugLogService?.info(
'USB RX raw: ${bytes.length} byte(s)',
tag: 'USB Serial',
);
_ingestRawBytes(bytes); _ingestRawBytes(bytes);
} }
} }
} catch (error, stackTrace) { } catch (error, stackTrace) {
_debugLogService?.error('_pumpReads error: $error', tag: 'USB Serial');
if (_status == UsbSerialStatus.connected) { if (_status == UsbSerialStatus.connected) {
_addFrameError(error, stackTrace); _addFrameError(error, stackTrace);
} }
} finally { } finally {
_debugLogService?.info('_pumpReads: ended', tag: 'USB Serial');
_releaseLock(reader); _releaseLock(reader);
if (_status == UsbSerialStatus.connected && identical(reader, _reader)) { if (_status == UsbSerialStatus.connected && identical(reader, _reader)) {
_addFrameError(StateError('USB serial connection closed')); _addFrameError(StateError('USB serial connection closed'));
+44 -7
View File
@@ -1,5 +1,7 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:meshcore_open/utils/app_logger.dart';
import '../models/channel_message.dart'; import '../models/channel_message.dart';
import '../helpers/smaz.dart'; import '../helpers/smaz.dart';
import 'prefs_manager.dart'; import 'prefs_manager.dart';
@@ -7,13 +9,25 @@ import 'prefs_manager.dart';
class ChannelMessageStore { class ChannelMessageStore {
static const String _keyPrefix = 'channel_messages_'; static const String _keyPrefix = 'channel_messages_';
String publicKeyHex = '';
set setPublicKeyHex(String value) =>
publicKeyHex = value.length > 10 ? value.substring(0, 10) : '';
String get keyFor => '$_keyPrefix$publicKeyHex';
/// Save messages for a specific channel /// Save messages for a specific channel
Future<void> saveChannelMessages( Future<void> saveChannelMessages(
int channelIndex, int channelIndex,
List<ChannelMessage> messages, List<ChannelMessage> messages,
) async { ) async {
if (publicKeyHex.isEmpty) {
appLogger.warn(
'Public key hex is not set. Cannot save channel messages.',
);
return;
}
final prefs = PrefsManager.instance; final prefs = PrefsManager.instance;
final key = '$_keyPrefix$channelIndex'; final key = '$keyFor$channelIndex';
// Convert messages to JSON // Convert messages to JSON
final jsonList = messages.map((msg) => _messageToJson(msg)).toList(); final jsonList = messages.map((msg) => _messageToJson(msg)).toList();
@@ -24,12 +38,35 @@ class ChannelMessageStore {
/// Load messages for a specific channel /// Load messages for a specific channel
Future<List<ChannelMessage>> loadChannelMessages(int channelIndex) async { Future<List<ChannelMessage>> loadChannelMessages(int channelIndex) async {
if (publicKeyHex.isEmpty) {
appLogger.warn(
'Public key hex is not set. Cannot load channel messages.',
);
return [];
}
final prefs = PrefsManager.instance; final prefs = PrefsManager.instance;
final key = '$_keyPrefix$channelIndex'; final key = '$keyFor$channelIndex';
final oldKey = '$_keyPrefix$channelIndex';
final jsonString = prefs.getString(key);
if (jsonString == null) return [];
String? jsonString = prefs.getString(key);
if (jsonString == null || jsonString.isEmpty) {
// Attempt migration from legacy unscoped key on first load
final legacyJsonString = prefs.getString(oldKey);
prefs.remove(oldKey);
if (legacyJsonString != null && legacyJsonString.isNotEmpty) {
appLogger.info(
'Migrating channel messages from legacy key $oldKey to scoped key $key',
);
await prefs.setString(key, legacyJsonString);
jsonString = legacyJsonString;
}
}
if (jsonString == null || jsonString.isEmpty) {
jsonString = prefs.getString(keyFor);
}
if (jsonString == null || jsonString.isEmpty) {
return [];
}
try { try {
final jsonList = jsonDecode(jsonString) as List<dynamic>; final jsonList = jsonDecode(jsonString) as List<dynamic>;
return jsonList.map((json) => _messageFromJson(json)).toList(); return jsonList.map((json) => _messageFromJson(json)).toList();
@@ -42,14 +79,14 @@ class ChannelMessageStore {
/// Clear messages for a specific channel /// Clear messages for a specific channel
Future<void> clearChannelMessages(int channelIndex) async { Future<void> clearChannelMessages(int channelIndex) async {
final prefs = PrefsManager.instance; final prefs = PrefsManager.instance;
final key = '$_keyPrefix$channelIndex'; final key = '$keyFor$channelIndex';
await prefs.remove(key); await prefs.remove(key);
} }
/// Clear all channel messages /// Clear all channel messages
Future<void> clearAllChannelMessages() async { Future<void> clearAllChannelMessages() async {
final prefs = PrefsManager.instance; final prefs = PrefsManager.instance;
final keys = prefs.getKeys().where((k) => k.startsWith(_keyPrefix)); final keys = prefs.getKeys().where((k) => k.startsWith(keyFor));
for (var key in keys) { for (var key in keys) {
await prefs.remove(key); await prefs.remove(key);
} }
+35 -6
View File
@@ -1,20 +1,49 @@
import 'dart:convert'; import 'dart:convert';
import '../utils/app_logger.dart';
import 'prefs_manager.dart'; import 'prefs_manager.dart';
class ChannelOrderStore { class ChannelOrderStore {
static const String _key = 'channel_order'; static const String _keyPrefix = 'channel_order_';
String publicKeyHex = '';
set setPublicKeyHex(String value) =>
publicKeyHex = value.length > 10 ? value.substring(0, 10) : '';
String get keyFor => '$_keyPrefix$publicKeyHex';
Future<void> saveChannelOrder(List<int> order) async { Future<void> saveChannelOrder(List<int> order) async {
if (publicKeyHex.isEmpty) {
appLogger.warn('Public key hex is not set. Cannot save channel order.');
return;
}
final prefs = PrefsManager.instance; final prefs = PrefsManager.instance;
await prefs.setString(_key, jsonEncode(order)); await prefs.setString(keyFor, jsonEncode(order));
} }
Future<List<int>> loadChannelOrder() async { Future<List<int>> loadChannelOrder() async {
if (publicKeyHex.isEmpty) {
appLogger.warn('Public key hex is not set. Cannot load channel order.');
return [];
}
final prefs = PrefsManager.instance; final prefs = PrefsManager.instance;
final raw = prefs.getString(_key); String? jsonString = prefs.getString(keyFor);
if (raw == null || raw.isEmpty) return []; if (jsonString == null || jsonString.isEmpty) {
// Attempt migration from legacy unscoped key on first load
final legacyJsonString = prefs.getString(_keyPrefix);
prefs.remove(_keyPrefix);
if (legacyJsonString != null && legacyJsonString.isNotEmpty) {
appLogger.info(
'Migrating channel order from legacy key $_keyPrefix to scoped key $keyFor',
);
await prefs.setString(keyFor, legacyJsonString);
jsonString = legacyJsonString;
}
}
if (jsonString == null || jsonString.isEmpty) {
return [];
}
try { try {
final decoded = jsonDecode(raw); final decoded = jsonDecode(jsonString);
if (decoded is List) { if (decoded is List) {
return decoded return decoded
.map((value) => value is int ? value : int.tryParse('$value')) .map((value) => value is int ? value : int.tryParse('$value'))
@@ -24,7 +53,7 @@ class ChannelOrderStore {
} catch (_) { } catch (_) {
// fall through to legacy parse // fall through to legacy parse
} }
return raw return jsonString
.split(',') .split(',')
.map((value) => int.tryParse(value)) .map((value) => int.tryParse(value))
.whereType<int>() .whereType<int>()
+36 -4
View File
@@ -1,17 +1,49 @@
import '../utils/app_logger.dart';
import 'prefs_manager.dart'; import 'prefs_manager.dart';
class ChannelSettingsStore { class ChannelSettingsStore {
static const String _smazKeyPrefix = 'channel_smaz_'; static const String _keyPrefix = 'channel_smaz_';
String publicKeyHex = '';
set setPublicKeyHex(String value) =>
publicKeyHex = value.length > 10 ? value.substring(0, 10) : '';
String get keyFor => '$_keyPrefix$publicKeyHex';
Future<bool> loadSmazEnabled(int channelIndex) async { Future<bool> loadSmazEnabled(int channelIndex) async {
if (publicKeyHex.isEmpty) {
appLogger.warn(
'Public key hex is not set. Cannot load channel settings.',
);
return false;
}
final prefs = PrefsManager.instance; final prefs = PrefsManager.instance;
final key = '$_smazKeyPrefix$channelIndex'; final key = '$keyFor$channelIndex';
return prefs.getBool(key) ?? false; final oldKey = '$_keyPrefix$channelIndex';
bool? enabled = prefs.getBool(oldKey);
if (enabled == null) {
// Attempt migration from legacy unscoped key on first load
enabled = prefs.getBool(oldKey);
prefs.remove(oldKey);
if (enabled != null) {
appLogger.info(
'Migrating channel settings from legacy key $oldKey to scoped key $key',
);
await prefs.setBool(key, enabled);
}
}
return enabled ?? false;
} }
Future<void> saveSmazEnabled(int channelIndex, bool enabled) async { Future<void> saveSmazEnabled(int channelIndex, bool enabled) async {
if (publicKeyHex.isEmpty) {
appLogger.warn(
'Public key hex is not set. Cannot save channel settings.',
);
return;
}
final prefs = PrefsManager.instance; final prefs = PrefsManager.instance;
final key = '$_smazKeyPrefix$channelIndex'; final key = '$keyFor$channelIndex';
await prefs.setBool(key, enabled); await prefs.setBool(key, enabled);
} }
} }
+37 -5
View File
@@ -2,18 +2,46 @@ import 'dart:convert';
import 'dart:typed_data'; import 'dart:typed_data';
import '../models/channel.dart'; import '../models/channel.dart';
import '../utils/app_logger.dart';
import 'prefs_manager.dart'; import 'prefs_manager.dart';
class ChannelStore { class ChannelStore {
static const String _key = 'channels'; static const String _keyPrefix = 'channels';
String publicKeyHex = '';
set setPublicKeyHex(String value) =>
publicKeyHex = value.length >= 10 ? value.substring(0, 10) : '';
String get keyFor => '$_keyPrefix$publicKeyHex';
Future<List<Channel>> loadChannels() async { Future<List<Channel>> loadChannels() async {
if (publicKeyHex.isEmpty) {
appLogger.warn('Public key hex is not set. Cannot load channels.');
return [];
}
final prefs = PrefsManager.instance; final prefs = PrefsManager.instance;
final jsonStr = prefs.getString(_key); String? jsonString = prefs.getString(keyFor);
if (jsonStr == null) return []; if (jsonString == null || jsonString.isEmpty) {
// Attempt migration from legacy unscoped key on first load
final legacyJsonString = prefs.getString(_keyPrefix);
prefs.remove(_keyPrefix);
if (legacyJsonString != null && legacyJsonString.isNotEmpty) {
appLogger.info(
'Migrating channel messages from legacy key $_keyPrefix to scoped key $keyFor',
);
await prefs.setString(keyFor, legacyJsonString);
jsonString = legacyJsonString;
}
}
if (jsonString == null || jsonString.isEmpty) {
jsonString = prefs.getString(keyFor);
}
if (jsonString == null || jsonString.isEmpty) {
return [];
}
try { try {
final jsonList = jsonDecode(jsonStr) as List<dynamic>; final jsonList = jsonDecode(jsonString) as List<dynamic>;
return jsonList return jsonList
.map((entry) => _fromJson(entry as Map<String, dynamic>)) .map((entry) => _fromJson(entry as Map<String, dynamic>))
.toList(); .toList();
@@ -23,9 +51,13 @@ class ChannelStore {
} }
Future<void> saveChannels(List<Channel> channels) async { Future<void> saveChannels(List<Channel> channels) async {
if (publicKeyHex.isEmpty) {
appLogger.warn('Public key hex is not set. Cannot save channels.');
return;
}
final prefs = PrefsManager.instance; final prefs = PrefsManager.instance;
final jsonList = channels.map(_toJson).toList(); final jsonList = channels.map(_toJson).toList();
await prefs.setString(_key, jsonEncode(jsonList)); await prefs.setString(keyFor, jsonEncode(jsonList));
} }
Map<String, dynamic> _toJson(Channel channel) { Map<String, dynamic> _toJson(Channel channel) {
+33 -3
View File
@@ -1,6 +1,7 @@
import 'dart:convert'; import 'dart:convert';
import '../models/community.dart'; import '../models/community.dart';
import '../utils/app_logger.dart';
import 'prefs_manager.dart'; import 'prefs_manager.dart';
/// Persists communities to local storage using SharedPreferences. /// Persists communities to local storage using SharedPreferences.
@@ -9,12 +10,37 @@ import 'prefs_manager.dart';
/// Each community contains its secret K, so this data should /// Each community contains its secret K, so this data should
/// be considered sensitive (though device encryption handles security). /// be considered sensitive (though device encryption handles security).
class CommunityStore { class CommunityStore {
static const String _communitiesKey = 'communities_v1'; static const String _keyPrefix = 'communities_v1';
String publicKeyHex = '';
set setPublicKeyHex(String value) =>
publicKeyHex = value.length > 10 ? value.substring(0, 10) : '';
String get keyFor => '$_keyPrefix$publicKeyHex';
/// Load all communities from storage /// Load all communities from storage
Future<List<Community>> loadCommunities() async { Future<List<Community>> loadCommunities() async {
if (publicKeyHex.isEmpty) {
appLogger.warn('Public key hex is not set. Cannot load communities.');
return [];
}
final prefs = PrefsManager.instance; final prefs = PrefsManager.instance;
final jsonString = prefs.getString(_communitiesKey); String? jsonString = prefs.getString(keyFor);
if (jsonString == null || jsonString.isEmpty) {
// Attempt migration from legacy unscoped key on first load
final legacyJsonString = prefs.getString(_keyPrefix);
prefs.remove(_keyPrefix);
if (legacyJsonString != null && legacyJsonString.isNotEmpty) {
appLogger.info(
'Migrating communities from legacy key $_keyPrefix to scoped key $keyFor',
);
await prefs.setString(keyFor, legacyJsonString);
jsonString = legacyJsonString;
}
}
if (jsonString == null || jsonString.isEmpty) {
jsonString = prefs.getString(keyFor);
}
if (jsonString == null || jsonString.isEmpty) { if (jsonString == null || jsonString.isEmpty) {
return []; return [];
} }
@@ -32,9 +58,13 @@ class CommunityStore {
/// Save all communities to storage /// Save all communities to storage
Future<void> saveCommunities(List<Community> communities) async { Future<void> saveCommunities(List<Community> communities) async {
if (publicKeyHex.isEmpty) {
appLogger.warn('Public key hex is not set. Cannot save communities.');
return;
}
final prefs = PrefsManager.instance; final prefs = PrefsManager.instance;
final jsonList = communities.map((c) => c.toJson()).toList(); final jsonList = communities.map((c) => c.toJson()).toList();
await prefs.setString(_communitiesKey, jsonEncode(jsonList)); await prefs.setString(keyFor, jsonEncode(jsonList));
} }
/// Add a new community /// Add a new community
+33 -11
View File
@@ -1,15 +1,15 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:typed_data'; import 'dart:typed_data';
import '../models/discovery_contact.dart'; import '../models/contact.dart';
import 'prefs_manager.dart'; import 'prefs_manager.dart';
class ContactDiscoveryStore { class ContactDiscoveryStore {
static const String _key = 'discovered_contacts'; static const String _keyPrefix = 'discovered_contacts';
Future<List<DiscoveryContact>> loadContacts() async { Future<List<Contact>> loadContacts() async {
final prefs = PrefsManager.instance; final prefs = PrefsManager.instance;
final jsonStr = prefs.getString(_key); final jsonStr = prefs.getString(_keyPrefix);
if (jsonStr == null) return []; if (jsonStr == null) return [];
try { try {
@@ -22,40 +22,62 @@ class ContactDiscoveryStore {
} }
} }
Future<void> saveContacts(List<DiscoveryContact> contacts) async { Future<void> saveContacts(List<Contact> contacts) async {
final prefs = PrefsManager.instance; final prefs = PrefsManager.instance;
final jsonList = contacts.map(_toJson).toList(); final jsonList = contacts.map(_toJson).toList();
await prefs.setString(_key, jsonEncode(jsonList)); await prefs.setString(_keyPrefix, jsonEncode(jsonList));
} }
Map<String, dynamic> _toJson(DiscoveryContact contact) { Map<String, dynamic> _toJson(Contact contact) {
return { return {
'rawPacket': base64Encode(contact.rawPacket),
'publicKey': base64Encode(contact.publicKey), 'publicKey': base64Encode(contact.publicKey),
'name': contact.name, 'name': contact.name,
'type': contact.type, 'type': contact.type,
'flags': contact.flags,
'pathLength': contact.pathLength, 'pathLength': contact.pathLength,
'path': base64Encode(contact.path), 'path': base64Encode(contact.path),
'pathOverride': contact.pathOverride,
'pathOverrideBytes': contact.pathOverrideBytes != null
? base64Encode(contact.pathOverrideBytes!)
: null,
'latitude': contact.latitude, 'latitude': contact.latitude,
'longitude': contact.longitude, 'longitude': contact.longitude,
'lastSeen': contact.lastSeen.millisecondsSinceEpoch, 'lastSeen': contact.lastSeen.millisecondsSinceEpoch,
'lastMessageAt': contact.lastMessageAt.millisecondsSinceEpoch,
'rawPacket': contact.rawPacket != null
? base64Encode(contact.rawPacket!)
: null,
}; };
} }
DiscoveryContact _fromJson(Map<String, dynamic> json) { Contact _fromJson(Map<String, dynamic> json) {
final lastSeenMs = json['lastSeen'] as int? ?? 0; final lastSeenMs = json['lastSeen'] as int? ?? 0;
return DiscoveryContact( final lastMessageMs = json['lastMessageAt'] as int?;
rawPacket: Uint8List.fromList(base64Decode(json['rawPacket'] as String)), return Contact(
publicKey: Uint8List.fromList(base64Decode(json['publicKey'] as String)), publicKey: Uint8List.fromList(base64Decode(json['publicKey'] as String)),
name: json['name'] as String? ?? 'Unknown', name: json['name'] as String? ?? 'Unknown',
type: json['type'] as int? ?? 0, type: json['type'] as int? ?? 0,
flags: json['flags'] as int? ?? 0,
pathLength: json['pathLength'] as int? ?? -1, pathLength: json['pathLength'] as int? ?? -1,
path: json['path'] != null path: json['path'] != null
? Uint8List.fromList(base64Decode(json['path'] as String)) ? Uint8List.fromList(base64Decode(json['path'] as String))
: Uint8List(0), : Uint8List(0),
pathOverride: json['pathOverride'] as int?,
pathOverrideBytes: json['pathOverrideBytes'] != null
? Uint8List.fromList(
base64Decode(json['pathOverrideBytes'] as String),
)
: null,
latitude: (json['latitude'] as num?)?.toDouble(), latitude: (json['latitude'] as num?)?.toDouble(),
longitude: (json['longitude'] as num?)?.toDouble(), longitude: (json['longitude'] as num?)?.toDouble(),
lastSeen: DateTime.fromMillisecondsSinceEpoch(lastSeenMs), lastSeen: DateTime.fromMillisecondsSinceEpoch(lastSeenMs),
lastMessageAt: DateTime.fromMillisecondsSinceEpoch(
lastMessageMs ?? lastSeenMs,
),
isActive: false,
rawPacket: json['rawPacket'] != null
? Uint8List.fromList(base64Decode(json['rawPacket'] as String))
: null,
); );
} }
} }
+37 -5
View File
@@ -1,17 +1,45 @@
import 'dart:convert'; import 'dart:convert';
import '../models/contact_group.dart'; import '../models/contact_group.dart';
import '../utils/app_logger.dart';
import 'prefs_manager.dart'; import 'prefs_manager.dart';
class ContactGroupStore { class ContactGroupStore {
static const String _key = 'contact_groups'; static const String _keyPrefix = 'contact_groups';
String publicKeyHex = '';
set setPublicKeyHex(String value) =>
publicKeyHex = value.length > 10 ? value.substring(0, 10) : '';
String get keyFor => '$_keyPrefix$publicKeyHex';
Future<List<ContactGroup>> loadGroups() async { Future<List<ContactGroup>> loadGroups() async {
if (publicKeyHex.isEmpty) {
appLogger.warn('Public key hex is not set. Cannot load contact groups.');
return [];
}
final prefs = PrefsManager.instance; final prefs = PrefsManager.instance;
final raw = prefs.getString(_key); String? jsonString = prefs.getString(keyFor);
if (raw == null || raw.isEmpty) return []; if (jsonString == null || jsonString.isEmpty) {
// Attempt migration from legacy unscoped key on first load
final legacyJsonString = prefs.getString(_keyPrefix);
prefs.remove(_keyPrefix);
if (legacyJsonString != null && legacyJsonString.isNotEmpty) {
appLogger.info(
'Migrating channel messages from legacy key $_keyPrefix to scoped key $keyFor',
);
await prefs.setString(keyFor, legacyJsonString);
jsonString = legacyJsonString;
}
}
if (jsonString == null || jsonString.isEmpty) {
jsonString = prefs.getString(keyFor);
}
if (jsonString == null || jsonString.isEmpty) {
return [];
}
try { try {
final decoded = jsonDecode(raw); final decoded = jsonDecode(jsonString);
if (decoded is List) { if (decoded is List) {
return decoded return decoded
.whereType<Map<String, dynamic>>() .whereType<Map<String, dynamic>>()
@@ -25,8 +53,12 @@ class ContactGroupStore {
} }
Future<void> saveGroups(List<ContactGroup> groups) async { Future<void> saveGroups(List<ContactGroup> groups) async {
if (publicKeyHex.isEmpty) {
appLogger.warn('Public key hex is not set. Cannot save contact groups.');
return;
}
final prefs = PrefsManager.instance; final prefs = PrefsManager.instance;
final encoded = jsonEncode(groups.map((group) => group.toJson()).toList()); final encoded = jsonEncode(groups.map((group) => group.toJson()).toList());
await prefs.setString(_key, encoded); await prefs.setString(keyFor, encoded);
} }
} }
+35 -3
View File
@@ -1,17 +1,49 @@
import '../utils/app_logger.dart';
import 'prefs_manager.dart'; import 'prefs_manager.dart';
class ContactSettingsStore { class ContactSettingsStore {
static const String _smazKeyPrefix = 'contact_smaz_'; static const String _keyPrefix = 'contact_smaz_';
String publicKeyHex = '';
set setPublicKeyHex(String value) =>
publicKeyHex = value.length > 10 ? value.substring(0, 10) : '';
String get keyFor => '$_keyPrefix$publicKeyHex';
Future<bool> loadSmazEnabled(String contactKeyHex) async { Future<bool> loadSmazEnabled(String contactKeyHex) async {
if (publicKeyHex.isEmpty) {
appLogger.warn(
'Public key hex is not set. Cannot load contact settings.',
);
return false;
}
final prefs = PrefsManager.instance; final prefs = PrefsManager.instance;
final key = '$_smazKeyPrefix$contactKeyHex'; final key = '$keyFor$contactKeyHex';
final oldKey = '$_keyPrefix$contactKeyHex';
bool? enabled = prefs.getBool(key);
if (enabled == null) {
// Attempt migration from legacy unscoped key on first load
enabled = prefs.getBool(oldKey);
prefs.remove(oldKey);
if (enabled != null) {
appLogger.info(
'Migrating contact settings from legacy key $oldKey to scoped key $key',
);
await prefs.setBool(key, enabled);
}
}
return prefs.getBool(key) ?? false; return prefs.getBool(key) ?? false;
} }
Future<void> saveSmazEnabled(String contactKeyHex, bool enabled) async { Future<void> saveSmazEnabled(String contactKeyHex, bool enabled) async {
if (publicKeyHex.isEmpty) {
appLogger.warn(
'Public key hex is not set. Cannot save contact settings.',
);
return;
}
final prefs = PrefsManager.instance; final prefs = PrefsManager.instance;
final key = '$_smazKeyPrefix$contactKeyHex'; final key = '$keyFor$contactKeyHex';
await prefs.setBool(key, enabled); await prefs.setBool(key, enabled);
} }
} }
+45 -5
View File
@@ -2,18 +2,46 @@ import 'dart:convert';
import 'dart:typed_data'; import 'dart:typed_data';
import '../models/contact.dart'; import '../models/contact.dart';
import '../utils/app_logger.dart';
import 'prefs_manager.dart'; import 'prefs_manager.dart';
class ContactStore { class ContactStore {
static const String _key = 'contacts'; static const String _keyPrefix = 'contacts';
String publicKeyHex = '';
set setPublicKeyHex(String value) =>
publicKeyHex = value.length > 10 ? value.substring(0, 10) : '';
String get keyFor => '$_keyPrefix$publicKeyHex';
Future<List<Contact>> loadContacts() async { Future<List<Contact>> loadContacts() async {
if (publicKeyHex.isEmpty) {
appLogger.warn('Public key hex is not set. Cannot load contacts.');
return [];
}
final prefs = PrefsManager.instance; final prefs = PrefsManager.instance;
final jsonStr = prefs.getString(_key); String? jsonString = prefs.getString(keyFor);
if (jsonStr == null) return []; if (jsonString == null || jsonString.isEmpty) {
// Attempt migration from legacy unscoped key on first load
final legacyJsonString = prefs.getString(_keyPrefix);
prefs.remove(_keyPrefix);
if (legacyJsonString != null && legacyJsonString.isNotEmpty) {
appLogger.info(
'Migrating contacts from legacy key $_keyPrefix to scoped key $keyFor',
);
await prefs.setString(keyFor, legacyJsonString);
jsonString = legacyJsonString;
}
}
if (jsonString == null || jsonString.isEmpty) {
jsonString = prefs.getString(keyFor);
}
if (jsonString == null || jsonString.isEmpty) {
return [];
}
try { try {
final jsonList = jsonDecode(jsonStr) as List<dynamic>; final jsonList = jsonDecode(jsonString) as List<dynamic>;
return jsonList return jsonList
.map((entry) => _fromJson(entry as Map<String, dynamic>)) .map((entry) => _fromJson(entry as Map<String, dynamic>))
.toList(); .toList();
@@ -23,9 +51,13 @@ class ContactStore {
} }
Future<void> saveContacts(List<Contact> contacts) async { Future<void> saveContacts(List<Contact> contacts) async {
if (publicKeyHex.isEmpty) {
appLogger.warn('Public key hex is not set. Cannot save contacts.');
return;
}
final prefs = PrefsManager.instance; final prefs = PrefsManager.instance;
final jsonList = contacts.map(_toJson).toList(); final jsonList = contacts.map(_toJson).toList();
await prefs.setString(_key, jsonEncode(jsonList)); await prefs.setString(keyFor, jsonEncode(jsonList));
} }
Map<String, dynamic> _toJson(Contact contact) { Map<String, dynamic> _toJson(Contact contact) {
@@ -44,6 +76,10 @@ class ContactStore {
'longitude': contact.longitude, 'longitude': contact.longitude,
'lastSeen': contact.lastSeen.millisecondsSinceEpoch, 'lastSeen': contact.lastSeen.millisecondsSinceEpoch,
'lastMessageAt': contact.lastMessageAt.millisecondsSinceEpoch, 'lastMessageAt': contact.lastMessageAt.millisecondsSinceEpoch,
'isActive': contact.isActive,
'rawPacket': contact.rawPacket != null
? base64Encode(contact.rawPacket!)
: null,
}; };
} }
@@ -71,6 +107,10 @@ class ContactStore {
lastMessageAt: DateTime.fromMillisecondsSinceEpoch( lastMessageAt: DateTime.fromMillisecondsSinceEpoch(
lastMessageMs ?? lastSeenMs, lastMessageMs ?? lastSeenMs,
), ),
isActive: json['isActive'] as bool? ?? true,
rawPacket: json['rawPacket'] != null
? Uint8List.fromList(base64Decode(json['rawPacket'] as String))
: null,
); );
} }
} }
+42 -5
View File
@@ -2,26 +2,59 @@ import 'dart:convert';
import 'dart:typed_data'; import 'dart:typed_data';
import '../models/message.dart'; import '../models/message.dart';
import '../helpers/smaz.dart'; import '../helpers/smaz.dart';
import '../utils/app_logger.dart';
import 'prefs_manager.dart'; import 'prefs_manager.dart';
class MessageStore { class MessageStore {
static const String _keyPrefix = 'messages_'; static const String _keyPrefix = 'messages_';
String publicKeyHex = '';
set setPublicKeyHex(String value) =>
publicKeyHex = value.length > 10 ? value.substring(0, 10) : '';
String get keyFor => '$_keyPrefix$publicKeyHex';
Future<void> saveMessages( Future<void> saveMessages(
String contactKeyHex, String contactKeyHex,
List<Message> messages, List<Message> messages,
) async { ) async {
if (publicKeyHex.isEmpty) {
appLogger.warn('Public key hex is not set. Cannot save messages.');
return;
}
final prefs = PrefsManager.instance; final prefs = PrefsManager.instance;
final key = '$_keyPrefix$contactKeyHex'; final key = '$keyFor$contactKeyHex';
final jsonList = messages.map(_messageToJson).toList(); final jsonList = messages.map(_messageToJson).toList();
await prefs.setString(key, jsonEncode(jsonList)); await prefs.setString(key, jsonEncode(jsonList));
} }
Future<List<Message>> loadMessages(String contactKeyHex) async { Future<List<Message>> loadMessages(String contactKeyHex) async {
if (publicKeyHex.isEmpty) {
appLogger.warn('Public key hex is not set. Cannot load messages.');
return [];
}
final prefs = PrefsManager.instance; final prefs = PrefsManager.instance;
final key = '$_keyPrefix$contactKeyHex'; final key = '$keyFor$contactKeyHex';
final jsonString = prefs.getString(key); final oldKey = '$_keyPrefix$contactKeyHex';
if (jsonString == null) return []; String? jsonString = prefs.getString(key);
if (jsonString == null || jsonString.isEmpty) {
// Attempt migration from legacy unscoped key on first load
final legacyJsonString = prefs.getString(oldKey);
prefs.remove(oldKey);
if (legacyJsonString != null && legacyJsonString.isNotEmpty) {
appLogger.info(
'Migrating messages from legacy key $oldKey to scoped key $key',
);
await prefs.setString(key, legacyJsonString);
jsonString = legacyJsonString;
}
}
if (jsonString == null || jsonString.isEmpty) {
jsonString = prefs.getString(keyFor);
}
if (jsonString == null || jsonString.isEmpty) {
return [];
}
try { try {
final jsonList = jsonDecode(jsonString) as List<dynamic>; final jsonList = jsonDecode(jsonString) as List<dynamic>;
@@ -32,8 +65,12 @@ class MessageStore {
} }
Future<void> clearMessages(String contactKeyHex) async { Future<void> clearMessages(String contactKeyHex) async {
if (publicKeyHex.isEmpty) {
appLogger.warn('Public key hex is not set. Cannot clear messages.');
return;
}
final prefs = PrefsManager.instance; final prefs = PrefsManager.instance;
final key = '$_keyPrefix$contactKeyHex'; final key = '$keyFor$contactKeyHex';
await prefs.remove(key); await prefs.remove(key);
} }
+37 -5
View File
@@ -1,11 +1,18 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import '../utils/app_logger.dart';
import 'prefs_manager.dart'; import 'prefs_manager.dart';
/// Storage for unread message tracking with debounced writes to reduce I/O. /// Storage for unread message tracking with debounced writes to reduce I/O.
class UnreadStore { class UnreadStore {
static const String _contactUnreadCountKey = 'contact_unread_count'; static const String _keyPrefix = 'contact_unread_count';
String publicKeyHex = '';
set setPublicKeyHex(String value) =>
publicKeyHex = value.length >= 10 ? value.substring(0, 10) : '';
String get keyFor => '$_keyPrefix$publicKeyHex';
// Debounce timers to batch rapid writes // Debounce timers to batch rapid writes
Timer? _contactUnreadSaveTimer; Timer? _contactUnreadSaveTimer;
@@ -20,12 +27,33 @@ class UnreadStore {
} }
Future<Map<String, int>> loadContactUnreadCount() async { Future<Map<String, int>> loadContactUnreadCount() async {
if (publicKeyHex.isEmpty) {
appLogger.warn('Public key hex is not set. Cannot load unread counts.');
return {};
}
final prefs = PrefsManager.instance; final prefs = PrefsManager.instance;
final jsonStr = prefs.getString(_contactUnreadCountKey); String? jsonString = prefs.getString(keyFor);
if (jsonStr == null) return {}; if (jsonString == null || jsonString.isEmpty) {
// Attempt migration from legacy unscoped key on first load
final legacyJsonString = prefs.getString(_keyPrefix);
prefs.remove(_keyPrefix);
if (legacyJsonString != null && legacyJsonString.isNotEmpty) {
appLogger.info(
'Migrating channel messages from legacy key $_keyPrefix to scoped key $keyFor',
);
await prefs.setString(keyFor, legacyJsonString);
jsonString = legacyJsonString;
}
}
if (jsonString == null || jsonString.isEmpty) {
jsonString = prefs.getString(keyFor);
}
if (jsonString == null || jsonString.isEmpty) {
return {};
}
try { try {
final json = jsonDecode(jsonStr) as Map<String, dynamic>; final json = jsonDecode(jsonString) as Map<String, dynamic>;
return json.map((key, value) => MapEntry(key, value as int)); return json.map((key, value) => MapEntry(key, value as int));
} catch (_) { } catch (_) {
return {}; return {};
@@ -33,6 +61,10 @@ class UnreadStore {
} }
void saveContactUnreadCount(Map<String, int> counts) { void saveContactUnreadCount(Map<String, int> counts) {
if (publicKeyHex.isEmpty) {
appLogger.warn('Public key hex is not set. Cannot save unread counts.');
return;
}
_pendingContactUnreadCount = counts; _pendingContactUnreadCount = counts;
_contactUnreadSaveTimer?.cancel(); _contactUnreadSaveTimer?.cancel();
@@ -49,7 +81,7 @@ class UnreadStore {
final prefs = PrefsManager.instance; final prefs = PrefsManager.instance;
final jsonStr = jsonEncode(_pendingContactUnreadCount); final jsonStr = jsonEncode(_pendingContactUnreadCount);
await prefs.setString(_contactUnreadCountKey, jsonStr); await prefs.setString(keyFor, jsonStr);
_pendingContactUnreadCount = null; _pendingContactUnreadCount = null;
} }
+8 -7
View File
@@ -23,23 +23,23 @@ class AppLogger {
bool get isEnabled => _enabled; bool get isEnabled => _enabled;
/// Log an info message /// Log an info message
void info(String message, {String tag = 'App'}) { void info(String message, {String tag = 'App', bool noNotify = false}) {
if (_enabled && _service != null) { if (_enabled && _service != null) {
_service!.info(message, tag: tag); _service!.info(message, tag: tag, noNotify: noNotify);
} }
} }
/// Log a warning message /// Log a warning message
void warn(String message, {String tag = 'App'}) { void warn(String message, {String tag = 'App', bool noNotify = false}) {
if (_enabled && _service != null) { if (_enabled && _service != null) {
_service!.warn(message, tag: tag); _service!.warn(message, tag: tag, noNotify: noNotify);
} }
} }
/// Log an error message /// Log an error message
void error(String message, {String tag = 'App'}) { void error(String message, {String tag = 'App', bool noNotify = false}) {
if (_enabled && _service != null) { if (_enabled && _service != null) {
_service!.error(message, tag: tag); _service!.error(message, tag: tag, noNotify: noNotify);
} }
} }
@@ -48,9 +48,10 @@ class AppLogger {
String message, { String message, {
String tag = 'App', String tag = 'App',
AppDebugLogLevel level = AppDebugLogLevel.info, AppDebugLogLevel level = AppDebugLogLevel.info,
bool noNotify = false,
}) { }) {
if (_enabled && _service != null) { if (_enabled && _service != null) {
_service!.log(message, tag: tag, level: level); _service!.log(message, tag: tag, level: level, noNotify: noNotify);
} }
} }
} }
+3
View File
@@ -0,0 +1,3 @@
enum ContactSortOption { lastSeen, recentMessages, name }
enum ContactTypeFilter { all, favorites, users, repeaters, rooms }
+3 -3
View File
@@ -1,7 +1,7 @@
import 'package:meshcore_open/models/discovery_contact.dart';
import '../models/contact.dart'; import '../models/contact.dart';
export 'contact_filter_types.dart';
bool matchesContactQuery(Contact contact, String query) { bool matchesContactQuery(Contact contact, String query) {
final normalizedQuery = query.trim().toLowerCase(); final normalizedQuery = query.trim().toLowerCase();
if (normalizedQuery.isEmpty) return true; if (normalizedQuery.isEmpty) return true;
@@ -16,7 +16,7 @@ bool matchesContactQuery(Contact contact, String query) {
return contact.publicKeyHex.toLowerCase().startsWith(hexPrefix); return contact.publicKeyHex.toLowerCase().startsWith(hexPrefix);
} }
bool matchesDiscoveryContactQuery(DiscoveryContact contact, String query) { bool matchesDiscoveryContactQuery(Contact contact, String query) {
final normalizedQuery = query.trim().toLowerCase(); final normalizedQuery = query.trim().toLowerCase();
if (normalizedQuery.isEmpty) return true; if (normalizedQuery.isEmpty) return true;
+70 -99
View File
@@ -1,12 +1,9 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../l10n/l10n.dart'; import '../l10n/l10n.dart';
import '../utils/contact_search.dart';
enum ContactSortOption { lastSeen, recentMessages, name } class SortFilterMenuOption<T> {
final T value;
enum ContactTypeFilter { all, favorites, users, repeaters, rooms }
class SortFilterMenuOption {
final int value;
final String label; final String label;
final bool? checked; final bool? checked;
@@ -17,16 +14,16 @@ class SortFilterMenuOption {
}); });
} }
class SortFilterMenuSection { class SortFilterMenuSection<T> {
final String title; final String title;
final List<SortFilterMenuOption> options; final List<SortFilterMenuOption<T>> options;
const SortFilterMenuSection({required this.title, required this.options}); const SortFilterMenuSection({required this.title, required this.options});
} }
class SortFilterMenu extends StatelessWidget { class SortFilterMenu<T> extends StatelessWidget {
final List<SortFilterMenuSection> sections; final List<SortFilterMenuSection<T>> sections;
final ValueChanged<int> onSelected; final ValueChanged<T> onSelected;
final String tooltip; final String tooltip;
final Widget icon; final Widget icon;
@@ -40,7 +37,7 @@ class SortFilterMenu extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return PopupMenuButton<int>( return PopupMenuButton<T>(
icon: icon, icon: icon,
tooltip: tooltip, tooltip: tooltip,
onSelected: onSelected, onSelected: onSelected,
@@ -53,11 +50,11 @@ class SortFilterMenu extends StatelessWidget {
final visibleSections = sections final visibleSections = sections
.where((section) => section.options.isNotEmpty) .where((section) => section.options.isNotEmpty)
.toList(); .toList();
final entries = <PopupMenuEntry<int>>[]; final entries = <PopupMenuEntry<T>>[];
for (int i = 0; i < visibleSections.length; i++) { for (int i = 0; i < visibleSections.length; i++) {
final section = visibleSections[i]; final section = visibleSections[i];
entries.add( entries.add(
PopupMenuItem<int>( PopupMenuItem<T>(
enabled: false, enabled: false,
child: Text(section.title, style: labelStyle), child: Text(section.title, style: labelStyle),
), ),
@@ -65,14 +62,14 @@ class SortFilterMenu extends StatelessWidget {
for (final option in section.options) { for (final option in section.options) {
if (option.checked == null) { if (option.checked == null) {
entries.add( entries.add(
PopupMenuItem<int>( PopupMenuItem<T>(
value: option.value, value: option.value,
child: Text(option.label), child: Text(option.label),
), ),
); );
} else { } else {
entries.add( entries.add(
CheckedPopupMenuItem<int>( CheckedPopupMenuItem<T>(
value: option.value, value: option.value,
checked: option.checked ?? false, checked: option.checked ?? false,
child: Text(option.label), child: Text(option.label),
@@ -90,16 +87,23 @@ class SortFilterMenu extends StatelessWidget {
} }
} }
const int _actionSortRecentMessages = 1; sealed class _ContactsFilterAction {
const int _actionSortName = 2; const _ContactsFilterAction();
const int _actionSortLastSeen = 3; }
const int _actionFilterAll = 4;
const int _actionFilterFavorites = 5; class _SortAction extends _ContactsFilterAction {
const int _actionFilterUsers = 6; final ContactSortOption option;
const int _actionFilterRepeaters = 7; const _SortAction(this.option);
const int _actionFilterRooms = 8; }
const int _actionToggleUnreadOnly = 9;
const int _actionNewGroup = 10; class _TypeFilterAction extends _ContactsFilterAction {
final ContactTypeFilter filter;
const _TypeFilterAction(this.filter);
}
class _ToggleUnreadAction extends _ContactsFilterAction {
const _ToggleUnreadAction();
}
class ContactsFilterMenu extends StatelessWidget { class ContactsFilterMenu extends StatelessWidget {
final ContactSortOption sortOption; final ContactSortOption sortOption;
@@ -108,7 +112,6 @@ class ContactsFilterMenu extends StatelessWidget {
final ValueChanged<ContactSortOption> onSortChanged; final ValueChanged<ContactSortOption> onSortChanged;
final ValueChanged<ContactTypeFilter> onTypeFilterChanged; final ValueChanged<ContactTypeFilter> onTypeFilterChanged;
final ValueChanged<bool> onUnreadOnlyChanged; final ValueChanged<bool> onUnreadOnlyChanged;
final VoidCallback onNewGroup;
const ContactsFilterMenu({ const ContactsFilterMenu({
super.key, super.key,
@@ -118,30 +121,29 @@ class ContactsFilterMenu extends StatelessWidget {
required this.onSortChanged, required this.onSortChanged,
required this.onTypeFilterChanged, required this.onTypeFilterChanged,
required this.onUnreadOnlyChanged, required this.onUnreadOnlyChanged,
required this.onNewGroup,
}); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = context.l10n; final l10n = context.l10n;
return SortFilterMenu( return SortFilterMenu<_ContactsFilterAction>(
tooltip: l10n.listFilter_tooltip, tooltip: l10n.listFilter_tooltip,
sections: [ sections: [
SortFilterMenuSection( SortFilterMenuSection(
title: l10n.listFilter_sortBy, title: l10n.listFilter_sortBy,
options: [ options: [
SortFilterMenuOption( SortFilterMenuOption(
value: _actionSortRecentMessages, value: _SortAction(ContactSortOption.recentMessages),
label: l10n.listFilter_latestMessages, label: l10n.listFilter_latestMessages,
checked: sortOption == ContactSortOption.recentMessages, checked: sortOption == ContactSortOption.recentMessages,
), ),
SortFilterMenuOption( SortFilterMenuOption(
value: _actionSortLastSeen, value: _SortAction(ContactSortOption.lastSeen),
label: l10n.listFilter_heardRecently, label: l10n.listFilter_heardRecently,
checked: sortOption == ContactSortOption.lastSeen, checked: sortOption == ContactSortOption.lastSeen,
), ),
SortFilterMenuOption( SortFilterMenuOption(
value: _actionSortName, value: _SortAction(ContactSortOption.name),
label: l10n.listFilter_az, label: l10n.listFilter_az,
checked: sortOption == ContactSortOption.name, checked: sortOption == ContactSortOption.name,
), ),
@@ -151,80 +153,66 @@ class ContactsFilterMenu extends StatelessWidget {
title: l10n.listFilter_filters, title: l10n.listFilter_filters,
options: [ options: [
SortFilterMenuOption( SortFilterMenuOption(
value: _actionFilterAll, value: _TypeFilterAction(ContactTypeFilter.all),
label: l10n.listFilter_all, label: l10n.listFilter_all,
checked: typeFilter == ContactTypeFilter.all, checked: typeFilter == ContactTypeFilter.all,
), ),
SortFilterMenuOption( SortFilterMenuOption(
value: _actionFilterFavorites, value: _TypeFilterAction(ContactTypeFilter.favorites),
label: l10n.listFilter_favorites, label: l10n.listFilter_favorites,
checked: typeFilter == ContactTypeFilter.favorites, checked: typeFilter == ContactTypeFilter.favorites,
), ),
SortFilterMenuOption( SortFilterMenuOption(
value: _actionFilterUsers, value: _TypeFilterAction(ContactTypeFilter.users),
label: l10n.listFilter_users, label: l10n.listFilter_users,
checked: typeFilter == ContactTypeFilter.users, checked: typeFilter == ContactTypeFilter.users,
), ),
SortFilterMenuOption( SortFilterMenuOption(
value: _actionFilterRepeaters, value: _TypeFilterAction(ContactTypeFilter.repeaters),
label: l10n.listFilter_repeaters, label: l10n.listFilter_repeaters,
checked: typeFilter == ContactTypeFilter.repeaters, checked: typeFilter == ContactTypeFilter.repeaters,
), ),
SortFilterMenuOption( SortFilterMenuOption(
value: _actionFilterRooms, value: _TypeFilterAction(ContactTypeFilter.rooms),
label: l10n.listFilter_roomServers, label: l10n.listFilter_roomServers,
checked: typeFilter == ContactTypeFilter.rooms, checked: typeFilter == ContactTypeFilter.rooms,
), ),
SortFilterMenuOption( SortFilterMenuOption(
value: _actionToggleUnreadOnly, value: const _ToggleUnreadAction(),
label: l10n.listFilter_unreadOnly, label: l10n.listFilter_unreadOnly,
checked: showUnreadOnly, checked: showUnreadOnly,
), ),
SortFilterMenuOption(
value: _actionNewGroup,
label: l10n.listFilter_newGroup,
),
], ],
), ),
], ],
onSelected: (action) { onSelected: (action) {
switch (action) { switch (action) {
case _actionSortRecentMessages: case _SortAction(:final option):
onSortChanged(ContactSortOption.recentMessages); onSortChanged(option);
break; case _TypeFilterAction(:final filter):
case _actionSortName: onTypeFilterChanged(filter);
onSortChanged(ContactSortOption.name); case _ToggleUnreadAction():
break;
case _actionSortLastSeen:
onSortChanged(ContactSortOption.lastSeen);
break;
case _actionFilterAll:
onTypeFilterChanged(ContactTypeFilter.all);
break;
case _actionFilterUsers:
onTypeFilterChanged(ContactTypeFilter.users);
break;
case _actionFilterFavorites:
onTypeFilterChanged(ContactTypeFilter.favorites);
break;
case _actionFilterRepeaters:
onTypeFilterChanged(ContactTypeFilter.repeaters);
break;
case _actionFilterRooms:
onTypeFilterChanged(ContactTypeFilter.rooms);
break;
case _actionToggleUnreadOnly:
onUnreadOnlyChanged(!showUnreadOnly); onUnreadOnlyChanged(!showUnreadOnly);
break;
case _actionNewGroup:
onNewGroup();
break;
} }
}, },
); );
} }
} }
sealed class _DiscoveryFilterAction {
const _DiscoveryFilterAction();
}
class _DiscoverySortAction extends _DiscoveryFilterAction {
final ContactSortOption option;
const _DiscoverySortAction(this.option);
}
class _DiscoveryTypeFilterAction extends _DiscoveryFilterAction {
final ContactTypeFilter filter;
const _DiscoveryTypeFilterAction(this.filter);
}
class DiscoveryContactsFilterMenu extends StatelessWidget { class DiscoveryContactsFilterMenu extends StatelessWidget {
final ContactSortOption sortOption; final ContactSortOption sortOption;
final ContactTypeFilter typeFilter; final ContactTypeFilter typeFilter;
@@ -242,19 +230,19 @@ class DiscoveryContactsFilterMenu extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = context.l10n; final l10n = context.l10n;
return SortFilterMenu( return SortFilterMenu<_DiscoveryFilterAction>(
tooltip: l10n.listFilter_tooltip, tooltip: l10n.listFilter_tooltip,
sections: [ sections: [
SortFilterMenuSection( SortFilterMenuSection(
title: l10n.listFilter_sortBy, title: l10n.listFilter_sortBy,
options: [ options: [
SortFilterMenuOption( SortFilterMenuOption(
value: _actionSortLastSeen, value: _DiscoverySortAction(ContactSortOption.lastSeen),
label: l10n.listFilter_heardRecently, label: l10n.listFilter_heardRecently,
checked: sortOption == ContactSortOption.lastSeen, checked: sortOption == ContactSortOption.lastSeen,
), ),
SortFilterMenuOption( SortFilterMenuOption(
value: _actionSortName, value: _DiscoverySortAction(ContactSortOption.name),
label: l10n.listFilter_az, label: l10n.listFilter_az,
checked: sortOption == ContactSortOption.name, checked: sortOption == ContactSortOption.name,
), ),
@@ -264,22 +252,22 @@ class DiscoveryContactsFilterMenu extends StatelessWidget {
title: l10n.listFilter_filters, title: l10n.listFilter_filters,
options: [ options: [
SortFilterMenuOption( SortFilterMenuOption(
value: _actionFilterAll, value: _DiscoveryTypeFilterAction(ContactTypeFilter.all),
label: l10n.listFilter_all, label: l10n.listFilter_all,
checked: typeFilter == ContactTypeFilter.all, checked: typeFilter == ContactTypeFilter.all,
), ),
SortFilterMenuOption( SortFilterMenuOption(
value: _actionFilterUsers, value: _DiscoveryTypeFilterAction(ContactTypeFilter.users),
label: l10n.listFilter_users, label: l10n.listFilter_users,
checked: typeFilter == ContactTypeFilter.users, checked: typeFilter == ContactTypeFilter.users,
), ),
SortFilterMenuOption( SortFilterMenuOption(
value: _actionFilterRepeaters, value: _DiscoveryTypeFilterAction(ContactTypeFilter.repeaters),
label: l10n.listFilter_repeaters, label: l10n.listFilter_repeaters,
checked: typeFilter == ContactTypeFilter.repeaters, checked: typeFilter == ContactTypeFilter.repeaters,
), ),
SortFilterMenuOption( SortFilterMenuOption(
value: _actionFilterRooms, value: _DiscoveryTypeFilterAction(ContactTypeFilter.rooms),
label: l10n.listFilter_roomServers, label: l10n.listFilter_roomServers,
checked: typeFilter == ContactTypeFilter.rooms, checked: typeFilter == ContactTypeFilter.rooms,
), ),
@@ -288,27 +276,10 @@ class DiscoveryContactsFilterMenu extends StatelessWidget {
], ],
onSelected: (action) { onSelected: (action) {
switch (action) { switch (action) {
case _actionSortName: case _DiscoverySortAction(:final option):
onSortChanged(ContactSortOption.name); onSortChanged(option);
break; case _DiscoveryTypeFilterAction(:final filter):
case _actionSortLastSeen: onTypeFilterChanged(filter);
onSortChanged(ContactSortOption.lastSeen);
break;
case _actionFilterAll:
onTypeFilterChanged(ContactTypeFilter.all);
break;
case _actionFilterUsers:
onTypeFilterChanged(ContactTypeFilter.users);
break;
case _actionFilterFavorites:
onTypeFilterChanged(ContactTypeFilter.favorites);
break;
case _actionFilterRepeaters:
onTypeFilterChanged(ContactTypeFilter.repeaters);
break;
case _actionFilterRooms:
onTypeFilterChanged(ContactTypeFilter.rooms);
break;
} }
}, },
); );
+2 -2
View File
@@ -78,7 +78,7 @@ class _PathManagementDialogState extends State<_PathManagementDialog> {
builder: (context) => PathTraceMapScreen( builder: (context) => PathTraceMapScreen(
title: context.l10n.contacts_repeaterPathTrace, title: context.l10n.contacts_repeaterPathTrace,
path: Uint8List.fromList(pathBytes), path: Uint8List.fromList(pathBytes),
flipPathRound: true, flipPathAround: true,
targetContact: widget.contact, targetContact: widget.contact,
), ),
), ),
@@ -107,7 +107,7 @@ class _PathManagementDialogState extends State<_PathManagementDialog> {
} }
final pathForInput = currentContact.pathIdList; final pathForInput = currentContact.pathIdList;
final availableContacts = connector.contacts final availableContacts = connector.allContacts
.where((c) => c.publicKeyHex != currentContact.publicKeyHex) .where((c) => c.publicKeyHex != currentContact.publicKeyHex)
.toList(); .toList();
+2 -1
View File
@@ -1,5 +1,6 @@
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:meshcore_open/connector/meshcore_protocol.dart';
import '../l10n/l10n.dart'; import '../l10n/l10n.dart';
import '../models/contact.dart'; import '../models/contact.dart';
@@ -65,7 +66,7 @@ class _PathSelectionDialogState extends State<PathSelectionDialog> {
void _filterValidContacts() { void _filterValidContacts() {
_validContacts = widget.availableContacts _validContacts = widget.availableContacts
.where((c) => c.type == 2 || c.type == 3) .where((c) => c.type == advTypeRepeater || c.type == advTypeRoom)
.toList(); .toList();
} }
+2 -2
View File
@@ -157,8 +157,8 @@ class _SNRIndicatorState extends State<SNRIndicator> {
repeater.snr, repeater.snr,
widget.connector.currentSf, widget.connector.currentSf,
); );
final allContacts = widget.connector.allContacts;
final name = widget.connector.contacts final name = allContacts
.where((c) => c.publicKey.first == repeater.pubkeyFirstByte) .where((c) => c.publicKey.first == repeater.pubkeyFirstByte)
.map((c) => c.name) .map((c) => c.name)
.firstOrNull; .firstOrNull;
+3 -1
View File
@@ -14,9 +14,11 @@
<true/> <true/>
<key>com.apple.security.device.usb</key> <key>com.apple.security.device.usb</key>
<true/> <true/>
<!-- USB serial ports (/dev/cu.* and /dev/tty.*) for LoRa device communication -->
<key>com.apple.security.temporary-exception.files.absolute-path.read-write</key> <key>com.apple.security.temporary-exception.files.absolute-path.read-write</key>
<array> <array>
<string>/dev/</string> <string>/dev/cu.</string>
<string>/dev/tty.</string>
</array> </array>
<key>com.apple.security.device.camera</key> <key>com.apple.security.device.camera</key>
<true/> <true/>
+3 -1
View File
@@ -10,9 +10,11 @@
<true/> <true/>
<key>com.apple.security.device.usb</key> <key>com.apple.security.device.usb</key>
<true/> <true/>
<!-- USB serial ports (/dev/cu.* and /dev/tty.*) for LoRa device communication -->
<key>com.apple.security.temporary-exception.files.absolute-path.read-write</key> <key>com.apple.security.temporary-exception.files.absolute-path.read-write</key>
<array> <array>
<string>/dev/</string> <string>/dev/cu.</string>
<string>/dev/tty.</string>
</array> </array>
<key>com.apple.security.device.camera</key> <key>com.apple.security.device.camera</key>
<true/> <true/>
+4 -1
View File
@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts # In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix. # of the product and file versions while build-number is used as the build suffix.
version: 6.0.0+7 version: 7.0.0+8
environment: environment:
sdk: ^3.9.2 sdk: ^3.9.2
@@ -38,6 +38,7 @@ dependencies:
# Use with the CupertinoIcons class for iOS style icons. # Use with the CupertinoIcons class for iOS style icons.
cupertino_icons: ^1.0.8 cupertino_icons: ^1.0.8
flutter_blue_plus: ^2.1.0 flutter_blue_plus: ^2.1.0
# TODO: Switch to official flserial repo once changes are upstreamed
flserial: flserial:
git: git:
url: https://github.com/MeshEnvy/flserial.git url: https://github.com/MeshEnvy/flserial.git
@@ -68,6 +69,8 @@ dependencies:
material_symbols_icons: ^4.2906.0 material_symbols_icons: ^4.2906.0
web: ^1.1.1 web: ^1.1.1
flutter_svg: ^2.0.10+1 flutter_svg: ^2.0.10+1
ml_algo: ^16.0.0
ml_dataframe: ^1.0.0
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
@@ -0,0 +1,93 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_open/connector/meshcore_connector.dart';
void main() {
group('shouldIgnoreLateTcpConnectError', () {
test('returns true for manual cancel during disconnecting state', () {
final result = MeshCoreConnector.shouldIgnoreLateTcpConnectError(
manualDisconnect: true,
state: MeshCoreConnectionState.disconnecting,
activeTransport: MeshCoreTransportType.bluetooth,
tcpManagerConnected: false,
);
expect(result, isTrue);
});
test(
'returns true for manual cancel after reaching disconnected state',
() {
final result = MeshCoreConnector.shouldIgnoreLateTcpConnectError(
manualDisconnect: true,
state: MeshCoreConnectionState.disconnected,
activeTransport: MeshCoreTransportType.bluetooth,
tcpManagerConnected: false,
);
expect(result, isTrue);
},
);
test('returns false when not a manual disconnect', () {
final result = MeshCoreConnector.shouldIgnoreLateTcpConnectError(
manualDisconnect: false,
state: MeshCoreConnectionState.disconnecting,
activeTransport: MeshCoreTransportType.bluetooth,
tcpManagerConnected: false,
);
expect(result, isFalse);
});
test('returns false for connected state handshake failures', () {
final result = MeshCoreConnector.shouldIgnoreLateTcpConnectError(
manualDisconnect: true,
state: MeshCoreConnectionState.connected,
activeTransport: MeshCoreTransportType.tcp,
tcpManagerConnected: true,
);
expect(result, isFalse);
});
test('returns false when TCP is still active while disconnecting', () {
final result = MeshCoreConnector.shouldIgnoreLateTcpConnectError(
manualDisconnect: true,
state: MeshCoreConnectionState.disconnecting,
activeTransport: MeshCoreTransportType.tcp,
tcpManagerConnected: true,
);
expect(result, isFalse);
});
});
group('shouldResetStateAfterTcpConnectAbort', () {
test('returns true when TCP connect is still in connecting state', () {
final result = MeshCoreConnector.shouldResetStateAfterTcpConnectAbort(
state: MeshCoreConnectionState.connecting,
activeTransport: MeshCoreTransportType.tcp,
);
expect(result, isTrue);
});
test('returns false when state is already disconnected', () {
final result = MeshCoreConnector.shouldResetStateAfterTcpConnectAbort(
state: MeshCoreConnectionState.disconnected,
activeTransport: MeshCoreTransportType.tcp,
);
expect(result, isFalse);
});
test('returns false when transport switched away from TCP', () {
final result = MeshCoreConnector.shouldResetStateAfterTcpConnectAbort(
state: MeshCoreConnectionState.connecting,
activeTransport: MeshCoreTransportType.bluetooth,
);
expect(result, isFalse);
});
});
}
+192
View File
@@ -0,0 +1,192 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:provider/provider.dart';
import 'package:meshcore_open/connector/meshcore_connector.dart';
import 'package:meshcore_open/l10n/app_localizations.dart';
import 'package:meshcore_open/screens/scanner_screen.dart';
import 'package:meshcore_open/screens/tcp_screen.dart';
class _FakeMeshCoreConnector extends MeshCoreConnector {
_FakeMeshCoreConnector();
MeshCoreConnectionState initialState = MeshCoreConnectionState.disconnected;
MeshCoreTransportType initialTransport = MeshCoreTransportType.bluetooth;
String? initialEndpoint;
int connectTcpCalls = 0;
String? lastHost;
int? lastPort;
@override
MeshCoreConnectionState get state => initialState;
@override
MeshCoreTransportType get activeTransport => initialTransport;
@override
bool get isTcpTransportConnected =>
initialState == MeshCoreConnectionState.connected &&
initialTransport == MeshCoreTransportType.tcp;
@override
String? get activeTcpEndpoint => initialEndpoint;
@override
Future<void> connectTcp({required String host, required int port}) async {
connectTcpCalls += 1;
lastHost = host;
lastPort = port;
}
}
Widget _buildTestApp({
required MeshCoreConnector connector,
required Widget child,
Locale? locale,
}) {
return ChangeNotifierProvider<MeshCoreConnector>.value(
value: connector,
child: MaterialApp(
locale: locale,
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: child,
),
);
}
void main() {
testWidgets('TcpScreen uses localized TCP copy', (tester) async {
final connector = _FakeMeshCoreConnector();
await tester.pumpWidget(
_buildTestApp(
connector: connector,
child: const TcpScreen(),
locale: const Locale('en'),
),
);
await tester.pumpAndSettle();
final context = tester.element(find.byType(TcpScreen));
final l10n = AppLocalizations.of(context);
expect(find.text(l10n.tcpScreenTitle), findsOneWidget);
expect(find.text(l10n.tcpHostLabel), findsOneWidget);
expect(find.text(l10n.tcpPortLabel), findsOneWidget);
expect(find.text(l10n.tcpStatus_notConnected), findsOneWidget);
});
testWidgets('TcpScreen validation errors are localized', (tester) async {
final connector = _FakeMeshCoreConnector();
await tester.pumpWidget(
_buildTestApp(
connector: connector,
child: const TcpScreen(),
locale: const Locale('en'),
),
);
await tester.pumpAndSettle();
final context = tester.element(find.byType(TcpScreen));
final l10n = AppLocalizations.of(context);
await tester.enterText(find.byType(TextField).first, '');
await tester.tap(find.byKey(const Key('tcp_connect_button')));
await tester.pumpAndSettle();
expect(find.text(l10n.tcpErrorHostRequired), findsOneWidget);
expect(connector.connectTcpCalls, 0);
await tester.enterText(find.byType(TextField).first, '192.168.1.50');
await tester.enterText(find.byType(TextField).at(1), '99999');
await tester.tap(find.byKey(const Key('tcp_connect_button')));
await tester.pumpAndSettle();
expect(connector.connectTcpCalls, 0);
});
testWidgets('TCP Bluetooth action returns to existing scanner route', (
tester,
) async {
final connector = _FakeMeshCoreConnector();
await tester.pumpWidget(
_buildTestApp(connector: connector, child: const ScannerScreen()),
);
await tester.pumpAndSettle();
await tester.tap(find.widgetWithText(FloatingActionButton, 'TCP'));
await tester.pumpAndSettle();
expect(find.byType(TcpScreen), findsOneWidget);
await tester.tap(find.widgetWithText(FloatingActionButton, 'Bluetooth'));
await tester.pumpAndSettle();
expect(find.byType(TcpScreen), findsNothing);
expect(find.byType(ScannerScreen), findsOneWidget);
final navigatorState = tester.state<NavigatorState>(find.byType(Navigator));
expect(navigatorState.canPop(), isFalse);
// ScannerScreen.dispose() schedules disconnect work that debounces notify.
// Drain that debounce timer before test teardown.
await tester.pumpWidget(const SizedBox.shrink());
await tester.pump(const Duration(milliseconds: 60));
});
testWidgets('TcpScreen disables connect button while connector is scanning', (
tester,
) async {
final connector = _FakeMeshCoreConnector()
..initialState = MeshCoreConnectionState.scanning;
await tester.pumpWidget(
_buildTestApp(
connector: connector,
child: const TcpScreen(),
locale: const Locale('en'),
),
);
await tester.pumpAndSettle();
final button = tester.widget<ButtonStyleButton>(
find.byKey(const Key('tcp_connect_button')),
);
expect(button.onPressed, isNull);
expect(connector.connectTcpCalls, 0);
});
testWidgets('TcpScreen narrow width long status text does not overflow', (
tester,
) async {
await tester.binding.setSurfaceSize(const Size(320, 700));
addTearDown(() => tester.binding.setSurfaceSize(null));
final connector = _FakeMeshCoreConnector()
..initialState = MeshCoreConnectionState.connected
..initialTransport = MeshCoreTransportType.tcp
..initialEndpoint = 'meshcore-room-server-very-long-hostname.local:5000';
await tester.pumpWidget(
_buildTestApp(
connector: connector,
child: const TcpScreen(),
locale: const Locale('en'),
),
);
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
final context = tester.element(find.byType(TcpScreen));
final l10n = AppLocalizations.of(context);
expect(
find.text(l10n.scanner_connectedTo(connector.initialEndpoint!)),
findsOneWidget,
);
await tester.pumpWidget(const SizedBox.shrink());
await tester.pump(const Duration(milliseconds: 60));
});
}
+83 -32
View File
@@ -116,10 +116,7 @@ void main() {
); );
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.ancestor( await tester.tap(find.byType(ListTile).first);
of: find.text('Connect'),
matching: find.bySubtype<ElevatedButton>(),
));
await tester.pump(); await tester.pump();
expect(connector.connectUsbCalls, 0); expect(connector.connectUsbCalls, 0);
@@ -131,28 +128,24 @@ void main() {
}, },
); );
testWidgets( testWidgets('UsbScreen sends raw port name when tapping Connect', (
'UsbScreen sends raw port name when tapping Connect', tester,
(tester) async { ) async {
final connector = _FakeMeshCoreConnector( final connector = _FakeMeshCoreConnector(
ports: <String>['COM6 - USB Serial Device (COM6)'], ports: <String>['COM6 - USB Serial Device (COM6)'],
); );
await tester.pumpWidget( await tester.pumpWidget(
_buildTestApp(connector: connector, child: const UsbScreen()), _buildTestApp(connector: connector, child: const UsbScreen()),
); );
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.ancestor( await tester.tap(find.byType(ListTile).first);
of: find.text('Connect'), await tester.pump();
matching: find.bySubtype<ElevatedButton>(),
));
await tester.pump();
expect(connector.connectUsbCalls, 1); expect(connector.connectUsbCalls, 1);
expect(connector.lastConnectPortName, 'COM6'); expect(connector.lastConnectPortName, 'COM6');
}, });
);
testWidgets('ScannerScreen USB action reflects platform support', ( testWidgets('ScannerScreen USB action reflects platform support', (
tester, tester,
@@ -176,9 +169,72 @@ void main() {
await tester.pump(const Duration(milliseconds: 60)); await tester.pump(const Duration(milliseconds: 60));
}); });
testWidgets('ScannerScreen narrow width keeps actions without overflow', (
tester,
) async {
await tester.binding.setSurfaceSize(const Size(320, 700));
addTearDown(() => tester.binding.setSurfaceSize(null));
final connector = _FakeMeshCoreConnector();
await tester.pumpWidget(
_buildTestApp(connector: connector, child: const ScannerScreen()),
);
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
final context = tester.element(find.byType(ScannerScreen));
final l10n = AppLocalizations.of(context);
expect(find.text(l10n.scanner_scan), findsOneWidget);
if (PlatformInfo.supportsUsbSerial) {
expect(find.text(l10n.connectionChoiceUsbLabel), findsOneWidget);
}
if (!PlatformInfo.isWeb) {
expect(find.text(l10n.connectionChoiceTcpLabel), findsOneWidget);
}
await tester.pumpWidget(const SizedBox.shrink());
await tester.pump(const Duration(milliseconds: 60));
});
testWidgets('UsbScreen narrow width long status text does not overflow', (
tester,
) async {
await tester.binding.setSurfaceSize(const Size(320, 700));
addTearDown(() => tester.binding.setSurfaceSize(null));
final connector =
_FakeMeshCoreConnector(initialState: MeshCoreConnectionState.connected)
..fakeUsbTransportConnected = true
..fakeActiveUsbPortDisplayLabel =
'/dev/bus/usb/001/002 - KD3CGK mesh-utility.org very long label';
await tester.pumpWidget(
_buildTestApp(connector: connector, child: const UsbScreen()),
);
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
final context = tester.element(find.byType(UsbScreen));
final l10n = AppLocalizations.of(context);
expect(
find.text(
l10n.scanner_connectedTo(connector.fakeActiveUsbPortDisplayLabel!),
),
findsOneWidget,
);
await tester.pumpWidget(const SizedBox.shrink());
await tester.pump(const Duration(milliseconds: 60));
});
group('Error Handling', () { group('Error Handling', () {
testWidgets('shows error SnackBar when listing ports fails', testWidgets('shows error SnackBar when listing ports fails', (
(tester) async { tester,
) async {
final connector = _FakeMeshCoreConnector(); final connector = _FakeMeshCoreConnector();
connector.listUsbPortsImpl = () async { connector.listUsbPortsImpl = () async {
throw PlatformException( throw PlatformException(
@@ -195,9 +251,7 @@ void main() {
expect(find.text('USB permission was denied.'), findsOneWidget); expect(find.text('USB permission was denied.'), findsOneWidget);
}); });
testWidgets('connection failure shows SnackBar error', ( testWidgets('connection failure shows SnackBar error', (tester) async {
tester,
) async {
final connector = _FakeMeshCoreConnector(ports: <String>['COM1']); final connector = _FakeMeshCoreConnector(ports: <String>['COM1']);
var connectAttempted = false; var connectAttempted = false;
connector.connectUsbImpl = ({required String portName}) async { connector.connectUsbImpl = ({required String portName}) async {
@@ -210,10 +264,7 @@ void main() {
); );
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.ancestor( await tester.tap(find.byType(ListTile).first);
of: find.text('Connect'),
matching: find.bySubtype<ElevatedButton>(),
));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(connectAttempted, isTrue); expect(connectAttempted, isTrue);
+156
View File
@@ -0,0 +1,156 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:ml_algo/ml_algo.dart';
import 'package:ml_dataframe/ml_dataframe.dart';
void main() {
test('LinearRegressor basic sanity check', () {
// Simple: y = 2x + 100
final data = DataFrame(
[
[1.0, 102.0],
[2.0, 104.0],
[3.0, 106.0],
[4.0, 108.0],
[5.0, 110.0],
[10.0, 120.0],
[20.0, 140.0],
[50.0, 200.0],
[0.0, 100.0],
[100.0, 300.0],
],
headerExists: false,
header: ['x', 'y'],
);
debugPrint('Training data columns: ${data.header}');
debugPrint('Training data rows: ${data.rows.length}');
final model = LinearRegressor(data, 'y');
final testDf = DataFrame(
[
[25.0],
],
headerExists: false,
header: ['x'],
);
final prediction = model.predict(testDf);
final value = prediction.rows.first.first;
debugPrint('Predict x=25 → y=$value (expected ~150)');
expect((value as num).toDouble(), closeTo(150, 5));
});
test('LinearRegressor multi-feature with constant column produces zeros', () {
// isFlood=0 for all rows zero-variance column singular matrix
final data = DataFrame(
[
[0.0, 50.0, 14.0, 0.0, 1900.0],
[0.0, 80.0, 14.0, 0.0, 2200.0],
[2.0, 50.0, 14.0, 0.0, 5000.0],
[4.0, 50.0, 14.0, 0.0, 9500.0],
],
headerExists: false,
header: [
'pathLength',
'messageBytes',
'hourOfDay',
'isFlood',
'deliveryMs',
],
);
final model = LinearRegressor(data, 'deliveryMs');
final testDf = DataFrame(
[
[2.0, 50.0, 14.0, 0.0],
],
headerExists: false,
header: ['pathLength', 'messageBytes', 'hourOfDay', 'isFlood'],
);
final pred = model.predict(testDf).rows.first.first;
debugPrint(
'With constant isFlood column: hops=2 → ${(pred as num).round()}ms (likely 0)',
);
});
test('LinearRegressor 2-feature works correctly', () {
// Just pathLength + messageBytes deliveryMs
final data = DataFrame(
[
[0.0, 50.0, 1900.0],
[0.0, 80.0, 2200.0],
[2.0, 50.0, 5000.0],
[2.0, 80.0, 5500.0],
[4.0, 50.0, 9500.0],
[4.0, 80.0, 10000.0],
[0.0, 30.0, 1800.0],
[2.0, 30.0, 4800.0],
[4.0, 30.0, 9000.0],
[0.0, 60.0, 2000.0],
],
headerExists: false,
header: ['pathLength', 'messageBytes', 'deliveryMs'],
);
final model = LinearRegressor(data, 'deliveryMs');
for (final hops in [0.0, 2.0, 4.0]) {
final testDf = DataFrame(
[
[hops, 50.0],
],
headerExists: false,
header: ['pathLength', 'messageBytes'],
);
final pred = model.predict(testDf).rows.first.first;
debugPrint('2-feature: hops=$hops${(pred as num).round()}ms');
}
});
test('LinearRegressor multi-feature with variance in all columns', () {
// Mix flood and direct so isFlood has variance
final data = DataFrame(
[
[0.0, 50.0, 14.0, 0.0, 1900.0],
[0.0, 80.0, 10.0, 0.0, 2200.0],
[2.0, 50.0, 16.0, 0.0, 5000.0],
[2.0, 80.0, 20.0, 0.0, 5500.0],
[4.0, 50.0, 8.0, 0.0, 9500.0],
[4.0, 80.0, 12.0, 0.0, 10000.0],
[-1.0, 40.0, 14.0, 1.0, 5000.0],
[-1.0, 60.0, 18.0, 1.0, 6500.0],
[-1.0, 30.0, 10.0, 1.0, 4000.0],
[-1.0, 80.0, 22.0, 1.0, 7000.0],
],
headerExists: false,
header: [
'pathLength',
'messageBytes',
'hourOfDay',
'isFlood',
'deliveryMs',
],
);
final model = LinearRegressor(data, 'deliveryMs');
for (final tc in [
[0.0, 50.0, 14.0, 0.0],
[2.0, 50.0, 14.0, 0.0],
[4.0, 50.0, 14.0, 0.0],
[-1.0, 50.0, 14.0, 1.0],
]) {
final testDf = DataFrame(
[tc],
headerExists: false,
header: ['pathLength', 'messageBytes', 'hourOfDay', 'isFlood'],
);
final pred = model.predict(testDf).rows.first.first;
debugPrint(
'4-feature: hops=${tc[0]} flood=${tc[3]}${(pred as num).round()}ms',
);
}
});
}
@@ -0,0 +1,136 @@
import 'dart:async';
import 'dart:io';
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_open/services/tcp_transport_service_native.dart';
import 'package:meshcore_open/services/usb_serial_frame_codec.dart';
final class _DelayedConnectOverrides extends IOOverrides {
_DelayedConnectOverrides(this.delay);
final Duration delay;
@override
Future<Socket> socketConnect(
host,
int port, {
sourceAddress,
int sourcePort = 0,
Duration? timeout,
}) async {
await Future<void>.delayed(delay);
return super.socketConnect(
host,
port,
sourceAddress: sourceAddress,
sourcePort: sourcePort,
timeout: timeout,
);
}
}
void main() {
test('connect/disconnect updates TCP transport state', () async {
final server = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0);
final service = TcpTransportService();
try {
await service.connect(
host: InternetAddress.loopbackIPv4.address,
port: server.port,
);
expect(service.isConnected, isTrue);
expect(
service.activeEndpoint,
'${InternetAddress.loopbackIPv4.address}:${server.port}',
);
await service.disconnect();
expect(service.isConnected, isFalse);
expect(service.activeEndpoint, isNull);
} finally {
await service.disconnect();
await server.close();
}
});
test('disconnect is safe when already disconnected', () async {
final service = TcpTransportService();
await service.disconnect();
await service.disconnect();
expect(service.isConnected, isFalse);
expect(service.activeEndpoint, isNull);
});
test('emits only RX frames from socket stream', () async {
final server = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0);
final acceptedSocket = Completer<Socket>();
final service = TcpTransportService();
final receivedFrames = <Uint8List>[];
final serverSub = server.listen((socket) {
if (!acceptedSocket.isCompleted) {
acceptedSocket.complete(socket);
} else {
socket.destroy();
}
});
final frameSub = service.frameStream.listen(receivedFrames.add);
try {
await service.connect(
host: InternetAddress.loopbackIPv4.address,
port: server.port,
);
final socket = await acceptedSocket.future.timeout(
const Duration(seconds: 2),
);
socket.add(<int>[usbSerialTxFrameStart, 0x01, 0x00, 0x11]);
socket.add(<int>[usbSerialRxFrameStart, 0x02, 0x00, 0x33, 0x44]);
await socket.flush();
await Future<void>.delayed(const Duration(milliseconds: 20));
expect(receivedFrames, hasLength(1));
expect(receivedFrames.single, orderedEquals(<int>[0x33, 0x44]));
} finally {
await service.disconnect();
await frameSub.cancel();
await serverSub.cancel();
await server.close();
}
});
test(
'disconnect during in-flight connect keeps transport disconnected',
() async {
final server = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0);
final service = TcpTransportService();
final host = InternetAddress.loopbackIPv4.address;
try {
await IOOverrides.runWithIOOverrides(() async {
final connectFuture = service.connect(host: host, port: server.port);
await Future<void>.delayed(const Duration(milliseconds: 10));
await service.disconnect();
await connectFuture;
expect(service.isConnected, isFalse);
expect(service.status, TcpTransportStatus.disconnected);
expect(service.activeEndpoint, isNull);
}, _DelayedConnectOverrides(const Duration(milliseconds: 120)));
} finally {
await service.disconnect();
await server.close();
}
},
);
}
@@ -0,0 +1,164 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_open/models/delivery_observation.dart';
import 'package:meshcore_open/services/timeout_prediction_service.dart';
void main() {
late TimeoutPredictionService service;
setUp(() {
service = TimeoutPredictionService.noStorage();
});
test('trains on sample data and predicts sensible timeouts', () {
// Simulate realistic delivery data:
// Direct 0-hop messages: ~1500-2500ms
// 2-hop messages: ~4000-6000ms
// 4-hop messages: ~8000-12000ms
// Flood messages: ~3000-8000ms
final sampleData = [
// 0-hop direct
_obs(pathLength: 0, messageBytes: 20, deliveryMs: 1800),
_obs(pathLength: 0, messageBytes: 50, deliveryMs: 2100),
_obs(pathLength: 0, messageBytes: 80, deliveryMs: 2400),
_obs(pathLength: 0, messageBytes: 30, deliveryMs: 1925),
// 2-hop direct
_obs(pathLength: 2, messageBytes: 40, deliveryMs: 4500),
_obs(pathLength: 2, messageBytes: 60, deliveryMs: 5200),
_obs(pathLength: 2, messageBytes: 25, deliveryMs: 4100),
// 4-hop direct
_obs(pathLength: 4, messageBytes: 50, deliveryMs: 9800),
_obs(pathLength: 4, messageBytes: 30, deliveryMs: 8500),
_obs(pathLength: 4, messageBytes: 70, deliveryMs: 10570),
// Flood
_obs(pathLength: -1, messageBytes: 40, deliveryMs: 5000),
_obs(pathLength: -1, messageBytes: 60, deliveryMs: 6500),
];
// Feed all observations
for (final obs in sampleData) {
service.recordObservation(
contactKey: obs.contactKey,
pathLength: obs.pathLength,
messageBytes: obs.messageBytes,
tripTimeMs: obs.deliveryMs,
);
}
expect(service.hasModel, isTrue);
expect(service.observationCount, equals(12));
// Predict for different scenarios
final direct0 = service.predictTimeout(pathLength: 0, messageBytes: 50);
final direct2 = service.predictTimeout(pathLength: 2, messageBytes: 50);
final direct4 = service.predictTimeout(pathLength: 4, messageBytes: 50);
final flood = service.predictTimeout(pathLength: -1, messageBytes: 50);
// All should return non-null (model is trained)
expect(direct0, isNotNull);
expect(direct2, isNotNull);
expect(direct4, isNotNull);
expect(flood, isNotNull);
// More hops should predict longer timeouts
expect(direct4!, greaterThan(direct2!));
expect(direct2, greaterThan(direct0!));
// All should be positive
expect(direct0, greaterThan(0));
expect(direct4, greaterThan(0));
// Print predictions for visibility
debugPrint('Predictions (with 1.5x safety margin):');
debugPrint(' 0-hop direct: ${direct0}ms');
debugPrint(' 2-hop direct: ${direct2}ms');
debugPrint(' 4-hop direct: ${direct4}ms');
debugPrint(' flood: ${flood}ms');
});
test('returns null before minimum observations', () {
for (var i = 0; i < TimeoutPredictionService.minObservations - 1; i++) {
service.recordObservation(
contactKey: 'abc',
pathLength: 0,
messageBytes: 50,
tripTimeMs: 2000,
);
}
expect(service.hasModel, isFalse);
expect(service.predictTimeout(pathLength: 0, messageBytes: 50), isNull);
});
test('caps observations at maxObservations', () {
for (var i = 0; i < TimeoutPredictionService.maxObservations + 20; i++) {
service.recordObservation(
contactKey: 'abc',
pathLength: 0,
messageBytes: 50,
tripTimeMs: 2000 + i,
);
}
expect(
service.observationCount,
equals(TimeoutPredictionService.maxObservations),
);
});
test('blends per-contact stats after enough observations', () {
// Train with mixed contacts and varied features:
// contactA is fast (0-hop), contactB is slow (2-hop)
for (var i = 0; i < 12; i++) {
service.recordObservation(
contactKey: 'contactA',
pathLength: 0,
messageBytes: 30 + i,
tripTimeMs: 1500,
);
service.recordObservation(
contactKey: 'contactB',
pathLength: 2,
messageBytes: 30 + i,
tripTimeMs: 8000,
);
}
final predA = service.predictTimeout(
contactKey: 'contactA',
pathLength: 0,
messageBytes: 50,
);
final predB = service.predictTimeout(
contactKey: 'contactB',
pathLength: 0,
messageBytes: 50,
);
expect(predA, isNotNull);
expect(predB, isNotNull);
// Contact B (slow) should have a higher predicted timeout than A (fast)
expect(predB!, greaterThan(predA!));
debugPrint('Per-contact blending:');
debugPrint(' contactA (fast): ${predA}ms');
debugPrint(' contactB (slow): ${predB}ms');
});
}
DeliveryObservation _obs({
required int pathLength,
required int messageBytes,
required int deliveryMs,
String contactKey = 'test_contact',
}) {
return DeliveryObservation(
contactKey: contactKey,
pathLength: pathLength,
messageBytes: messageBytes,
secondsSinceLastRx: 5,
isFlood: pathLength < 0,
deliveryMs: deliveryMs,
timestamp: DateTime.now(),
);
}