mirror of
https://github.com/zjs81/meshcore-open.git
synced 2026-06-27 20:57:31 +10:00
Refactor code structure and remove redundant sections for improved readability and maintainability
This commit is contained in:
@@ -328,6 +328,20 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
String? get deviceId => _deviceId;
|
||||
String get deviceIdLabel => _deviceId ?? 'Unknown';
|
||||
|
||||
/// Stable per-radio key for transport-agnostic per-device settings such as
|
||||
/// battery chemistry. On BLE this is the existing remoteId (so previously
|
||||
/// saved settings are preserved); on USB/TCP — where there is no BLE
|
||||
/// remoteId — it falls back to the node's public key, which identifies the
|
||||
/// same physical radio across transports. Null until a device identity is
|
||||
/// known.
|
||||
String? get batteryDeviceKey {
|
||||
if (_deviceId != null) return _deviceId;
|
||||
if (_selfPublicKey != null && _selfPublicKey!.isNotEmpty) {
|
||||
return selfPublicKeyHex;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
MeshCoreTransportType get activeTransport => _activeTransport;
|
||||
String? get activeUsbPort => _usbManager.activePortKey;
|
||||
String? get activeUsbPortDisplayLabel => _usbManager.activePortDisplayLabel;
|
||||
@@ -493,7 +507,7 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
}
|
||||
|
||||
String _batteryChemistryForDevice() {
|
||||
final deviceId = _device?.remoteId.toString();
|
||||
final deviceId = batteryDeviceKey;
|
||||
if (deviceId == null || _appSettingsService == null) return 'nmc';
|
||||
return _appSettingsService!.batteryChemistryForDevice(deviceId);
|
||||
}
|
||||
@@ -1607,6 +1621,20 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
await stopScan();
|
||||
}
|
||||
await Future<void>.delayed(const Duration(milliseconds: 200));
|
||||
|
||||
// The read pump can fail the instant the port opens (e.g. a device that
|
||||
// re-enumerates on open). That error is emitted on a broadcast stream
|
||||
// before the listener below attaches, so it would otherwise be lost and
|
||||
// the connect would stall until the SELF_INFO timeout. Check transport
|
||||
// liveness directly and abort fast with the real cause.
|
||||
if (!_usbManager.isConnected) {
|
||||
final cause = _usbManager.lastError;
|
||||
throw StateError(
|
||||
'USB device disconnected during connect'
|
||||
'${cause == null ? '' : ': $cause'}',
|
||||
);
|
||||
}
|
||||
|
||||
_usbFrameSubscription = _usbManager.frameStream.listen(
|
||||
_handleFrame,
|
||||
onError: (error, stackTrace) {
|
||||
@@ -5737,7 +5765,9 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
) {
|
||||
if (!isRoomServer) return null;
|
||||
if (!msg.isOutgoing) {
|
||||
final senderContact = _contacts.cast<Contact?>().firstWhere(
|
||||
// Saved contacts first, then discovery-only nodes, so reaction matching
|
||||
// resolves the author's name even when they haven't been saved.
|
||||
final senderContact = allContactsUnfiltered.cast<Contact?>().firstWhere(
|
||||
(c) =>
|
||||
c != null &&
|
||||
_matchesPrefix(c.publicKey, msg.fourByteRoomContactKey),
|
||||
|
||||
@@ -19,6 +19,7 @@ class MeshCoreUsbManager {
|
||||
String? get activePortKey => _activePortKey;
|
||||
String? get activePortDisplayLabel => _activePortLabel ?? _activePortKey;
|
||||
bool get isConnected => _service.isConnected;
|
||||
Object? get lastError => _service.lastError;
|
||||
Stream<Uint8List> get frameStream => _service.frameStream;
|
||||
|
||||
// --- Configuration ---
|
||||
|
||||
@@ -143,6 +143,7 @@
|
||||
"scanner_chromeRequired": "Chrome Browser Required",
|
||||
"scanner_chromeRequiredMessage": "This web application requires Google Chrome or a Chromium-based browser for Bluetooth support.",
|
||||
"scanner_enableBluetooth": "Enable Bluetooth",
|
||||
"scanner_bluetoothWebUnsupported": "Bluetooth isn't available in the browser. Connect over USB instead.",
|
||||
"device_quickSwitch": "Quick switch",
|
||||
"device_meshcore": "MeshCore",
|
||||
"settings_title": "Settings",
|
||||
|
||||
@@ -694,6 +694,12 @@ abstract class AppLocalizations {
|
||||
/// **'Enable Bluetooth'**
|
||||
String get scanner_enableBluetooth;
|
||||
|
||||
/// No description provided for @scanner_bluetoothWebUnsupported.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Bluetooth isn\'t available in the browser. Connect over USB instead.'**
|
||||
String get scanner_bluetoothWebUnsupported;
|
||||
|
||||
/// No description provided for @device_quickSwitch.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
||||
@@ -318,6 +318,10 @@ class AppLocalizationsBg extends AppLocalizations {
|
||||
@override
|
||||
String get scanner_enableBluetooth => 'Активирайте Bluetooth';
|
||||
|
||||
@override
|
||||
String get scanner_bluetoothWebUnsupported =>
|
||||
'Bluetooth isn\'t available in the browser. Connect over USB instead.';
|
||||
|
||||
@override
|
||||
String get device_quickSwitch => 'Бързо превключване';
|
||||
|
||||
|
||||
@@ -321,6 +321,10 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
@override
|
||||
String get scanner_enableBluetooth => 'Bluetooth aktivieren';
|
||||
|
||||
@override
|
||||
String get scanner_bluetoothWebUnsupported =>
|
||||
'Bluetooth isn\'t available in the browser. Connect over USB instead.';
|
||||
|
||||
@override
|
||||
String get device_quickSwitch => 'Schnelles Umschalten';
|
||||
|
||||
|
||||
@@ -315,6 +315,10 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get scanner_enableBluetooth => 'Enable Bluetooth';
|
||||
|
||||
@override
|
||||
String get scanner_bluetoothWebUnsupported =>
|
||||
'Bluetooth isn\'t available in the browser. Connect over USB instead.';
|
||||
|
||||
@override
|
||||
String get device_quickSwitch => 'Quick switch';
|
||||
|
||||
|
||||
@@ -319,6 +319,10 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
@override
|
||||
String get scanner_enableBluetooth => 'Habilitar Bluetooth';
|
||||
|
||||
@override
|
||||
String get scanner_bluetoothWebUnsupported =>
|
||||
'Bluetooth isn\'t available in the browser. Connect over USB instead.';
|
||||
|
||||
@override
|
||||
String get device_quickSwitch => 'Cambiar rápidamente';
|
||||
|
||||
|
||||
@@ -321,6 +321,10 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
@override
|
||||
String get scanner_enableBluetooth => 'Activer le Bluetooth';
|
||||
|
||||
@override
|
||||
String get scanner_bluetoothWebUnsupported =>
|
||||
'Bluetooth isn\'t available in the browser. Connect over USB instead.';
|
||||
|
||||
@override
|
||||
String get device_quickSwitch => 'Basculement rapide';
|
||||
|
||||
|
||||
@@ -318,6 +318,10 @@ class AppLocalizationsHu extends AppLocalizations {
|
||||
@override
|
||||
String get scanner_enableBluetooth => 'Engedje be a Bluetooth funkciót';
|
||||
|
||||
@override
|
||||
String get scanner_bluetoothWebUnsupported =>
|
||||
'Bluetooth isn\'t available in the browser. Connect over USB instead.';
|
||||
|
||||
@override
|
||||
String get device_quickSwitch => 'Gyors váltás';
|
||||
|
||||
|
||||
@@ -321,6 +321,10 @@ class AppLocalizationsIt extends AppLocalizations {
|
||||
@override
|
||||
String get scanner_enableBluetooth => 'Abilita il Bluetooth';
|
||||
|
||||
@override
|
||||
String get scanner_bluetoothWebUnsupported =>
|
||||
'Bluetooth isn\'t available in the browser. Connect over USB instead.';
|
||||
|
||||
@override
|
||||
String get device_quickSwitch => 'Passa velocemente';
|
||||
|
||||
|
||||
@@ -308,6 +308,10 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
@override
|
||||
String get scanner_enableBluetooth => 'Bluetoothを有効にする';
|
||||
|
||||
@override
|
||||
String get scanner_bluetoothWebUnsupported =>
|
||||
'Bluetooth isn\'t available in the browser. Connect over USB instead.';
|
||||
|
||||
@override
|
||||
String get device_quickSwitch => '素早い切り替え';
|
||||
|
||||
|
||||
@@ -307,6 +307,10 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
@override
|
||||
String get scanner_enableBluetooth => '블루투스 활성화';
|
||||
|
||||
@override
|
||||
String get scanner_bluetoothWebUnsupported =>
|
||||
'Bluetooth isn\'t available in the browser. Connect over USB instead.';
|
||||
|
||||
@override
|
||||
String get device_quickSwitch => '빠른 전환';
|
||||
|
||||
|
||||
@@ -317,6 +317,10 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
@override
|
||||
String get scanner_enableBluetooth => 'Activeer Bluetooth';
|
||||
|
||||
@override
|
||||
String get scanner_bluetoothWebUnsupported =>
|
||||
'Bluetooth isn\'t available in the browser. Connect over USB instead.';
|
||||
|
||||
@override
|
||||
String get device_quickSwitch => 'Snelle overschakeling';
|
||||
|
||||
|
||||
@@ -322,6 +322,10 @@ class AppLocalizationsPl extends AppLocalizations {
|
||||
@override
|
||||
String get scanner_enableBluetooth => 'Włącz Bluetooth';
|
||||
|
||||
@override
|
||||
String get scanner_bluetoothWebUnsupported =>
|
||||
'Bluetooth isn\'t available in the browser. Connect over USB instead.';
|
||||
|
||||
@override
|
||||
String get device_quickSwitch => 'Szybka zmiana';
|
||||
|
||||
|
||||
@@ -320,6 +320,10 @@ class AppLocalizationsPt extends AppLocalizations {
|
||||
@override
|
||||
String get scanner_enableBluetooth => 'Ative o Bluetooth';
|
||||
|
||||
@override
|
||||
String get scanner_bluetoothWebUnsupported =>
|
||||
'Bluetooth isn\'t available in the browser. Connect over USB instead.';
|
||||
|
||||
@override
|
||||
String get device_quickSwitch => 'Mudar rapidamente';
|
||||
|
||||
|
||||
@@ -320,6 +320,10 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
@override
|
||||
String get scanner_enableBluetooth => 'Включите Bluetooth';
|
||||
|
||||
@override
|
||||
String get scanner_bluetoothWebUnsupported =>
|
||||
'Bluetooth isn\'t available in the browser. Connect over USB instead.';
|
||||
|
||||
@override
|
||||
String get device_quickSwitch => 'Быстрое переключение';
|
||||
|
||||
|
||||
@@ -319,6 +319,10 @@ class AppLocalizationsSk extends AppLocalizations {
|
||||
@override
|
||||
String get scanner_enableBluetooth => 'Povolte Bluetooth';
|
||||
|
||||
@override
|
||||
String get scanner_bluetoothWebUnsupported =>
|
||||
'Bluetooth isn\'t available in the browser. Connect over USB instead.';
|
||||
|
||||
@override
|
||||
String get device_quickSwitch => 'Rýchle prepínač';
|
||||
|
||||
|
||||
@@ -318,6 +318,10 @@ class AppLocalizationsSl extends AppLocalizations {
|
||||
@override
|
||||
String get scanner_enableBluetooth => 'Omogočite Bluetooth';
|
||||
|
||||
@override
|
||||
String get scanner_bluetoothWebUnsupported =>
|
||||
'Bluetooth isn\'t available in the browser. Connect over USB instead.';
|
||||
|
||||
@override
|
||||
String get device_quickSwitch => 'Hitro preklop';
|
||||
|
||||
|
||||
@@ -316,6 +316,10 @@ class AppLocalizationsSv extends AppLocalizations {
|
||||
@override
|
||||
String get scanner_enableBluetooth => 'Aktivera Bluetooth';
|
||||
|
||||
@override
|
||||
String get scanner_bluetoothWebUnsupported =>
|
||||
'Bluetooth isn\'t available in the browser. Connect over USB instead.';
|
||||
|
||||
@override
|
||||
String get device_quickSwitch => 'Snabb växling';
|
||||
|
||||
|
||||
@@ -319,6 +319,10 @@ class AppLocalizationsUk extends AppLocalizations {
|
||||
@override
|
||||
String get scanner_enableBluetooth => 'Увімкніть Bluetooth';
|
||||
|
||||
@override
|
||||
String get scanner_bluetoothWebUnsupported =>
|
||||
'Bluetooth isn\'t available in the browser. Connect over USB instead.';
|
||||
|
||||
@override
|
||||
String get device_quickSwitch => 'Швидке перемикання';
|
||||
|
||||
|
||||
@@ -305,6 +305,10 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
@override
|
||||
String get scanner_enableBluetooth => '启用蓝牙';
|
||||
|
||||
@override
|
||||
String get scanner_bluetoothWebUnsupported =>
|
||||
'Bluetooth isn\'t available in the browser. Connect over USB instead.';
|
||||
|
||||
@override
|
||||
String get device_quickSwitch => '快速切换';
|
||||
|
||||
|
||||
@@ -141,7 +141,7 @@ class AppSettings {
|
||||
this.mapKeyPrefix = '',
|
||||
this.mapShowMarkers = true,
|
||||
this.mapShowGuessedLocations = true,
|
||||
this.enableMessageTracing = false,
|
||||
this.enableMessageTracing = true,
|
||||
this.mapCacheBounds,
|
||||
this.mapCacheMinZoom = 10,
|
||||
this.mapCacheMaxZoom = 15,
|
||||
@@ -149,7 +149,7 @@ class AppSettings {
|
||||
this.notifyOnNewMessage = true,
|
||||
this.notifyOnNewChannelMessage = true,
|
||||
this.notifyOnNewAdvert = true,
|
||||
this.autoRouteRotationEnabled = false,
|
||||
this.autoRouteRotationEnabled = true,
|
||||
this.maxRouteWeight = 5.0,
|
||||
this.initialRouteWeight = 3.0,
|
||||
this.routeWeightSuccessIncrement = 0.5,
|
||||
@@ -264,7 +264,7 @@ class AppSettings {
|
||||
mapShowMarkers: json['map_show_markers'] as bool? ?? true,
|
||||
mapShowGuessedLocations:
|
||||
json['map_show_guessed_locations'] as bool? ?? true,
|
||||
enableMessageTracing: json['enable_message_tracing'] as bool? ?? false,
|
||||
enableMessageTracing: json['enable_message_tracing'] as bool? ?? true,
|
||||
mapCacheBounds: (json['map_cache_bounds'] as Map?)?.map(
|
||||
(key, value) => MapEntry(key.toString(), (value as num).toDouble()),
|
||||
),
|
||||
@@ -276,7 +276,7 @@ class AppSettings {
|
||||
json['notify_on_new_channel_message'] as bool? ?? true,
|
||||
notifyOnNewAdvert: json['notify_on_new_advert'] as bool? ?? true,
|
||||
autoRouteRotationEnabled:
|
||||
json['auto_route_rotation_enabled'] as bool? ?? false,
|
||||
json['auto_route_rotation_enabled'] as bool? ?? true,
|
||||
maxRouteWeight: (json['max_route_weight'] as num?)?.toDouble() ?? 5.0,
|
||||
initialRouteWeight:
|
||||
(json['initial_route_weight'] as num?)?.toDouble() ?? 3.0,
|
||||
|
||||
@@ -607,7 +607,7 @@ class AppSettingsScreen extends StatelessWidget {
|
||||
AppSettingsService settingsService,
|
||||
MeshCoreConnector connector,
|
||||
) {
|
||||
final deviceId = connector.deviceId;
|
||||
final deviceId = connector.batteryDeviceKey;
|
||||
final isConnected = connector.isConnected && deviceId != null;
|
||||
final selection = isConnected
|
||||
? settingsService.batteryChemistryForDevice(deviceId)
|
||||
|
||||
@@ -381,12 +381,14 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
}
|
||||
final messageIndex = index;
|
||||
Contact contact = _resolveContact(connector);
|
||||
final bool isRoom = contact.type == advTypeRoom;
|
||||
final message = reversedMessages[messageIndex];
|
||||
String fourByteHex = '';
|
||||
if (contact.type == advTypeRoom) {
|
||||
Contact? roomAuthor;
|
||||
if (isRoom) {
|
||||
// Room-server messages carry the original author's 4-byte prefix
|
||||
// separately from message.text; use it only for resolving the name.
|
||||
contact = _resolveContactFrom4Bytes(
|
||||
roomAuthor = _resolveContactFrom4Bytes(
|
||||
connector,
|
||||
message.fourByteRoomContactKey.isEmpty
|
||||
? Uint8List.fromList([0, 0, 0, 0])
|
||||
@@ -396,6 +398,9 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||
.join()
|
||||
.toUpperCase();
|
||||
// Only adopt the author identity when we actually know them; never
|
||||
// fall back to the room server's own name as the sender.
|
||||
if (roomAuthor != null) contact = roomAuthor;
|
||||
}
|
||||
|
||||
return Builder(
|
||||
@@ -403,11 +408,12 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
final textScale = context.select<ChatTextScaleService, double>(
|
||||
(service) => service.scale,
|
||||
);
|
||||
final resolvedContact = _resolveContact(connector);
|
||||
final bubble = _MessageBubble(
|
||||
message: message,
|
||||
senderName: resolvedContact.type == advTypeRoom
|
||||
? "${contact.name} [$fourByteHex]"
|
||||
senderName: isRoom
|
||||
? (roomAuthor != null
|
||||
? "${roomAuthor.name} [$fourByteHex]"
|
||||
: "[$fourByteHex]")
|
||||
: contact.name,
|
||||
sourceId: widget.contact.publicKeyHex,
|
||||
textScale: textScale,
|
||||
@@ -755,13 +761,17 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
return connector.contacts[_resolveContactIndex];
|
||||
}
|
||||
|
||||
Contact _resolveContactFrom4Bytes(
|
||||
Contact? _resolveContactFrom4Bytes(
|
||||
MeshCoreConnector connector,
|
||||
Uint8List key4Bytes,
|
||||
) {
|
||||
return connector.contacts.firstWhere(
|
||||
(c) => listEquals(c.publicKey.sublist(0, 4), key4Bytes.sublist(0, 4)),
|
||||
orElse: () => widget.contact,
|
||||
// Match against saved contacts first, then nodes only seen via discovery —
|
||||
// a room poster you haven't saved may still be in the discovered list.
|
||||
return connector.allContactsUnfiltered.cast<Contact?>().firstWhere(
|
||||
(c) =>
|
||||
c != null &&
|
||||
listEquals(c.publicKey.sublist(0, 4), key4Bytes.sublist(0, 4)),
|
||||
orElse: () => null,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1049,7 +1059,11 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
if (message.isOutgoing) {
|
||||
senderName = connector.selfName ?? context.l10n.chat_me;
|
||||
} else if (_resolveContact(connector).type == advTypeRoom) {
|
||||
senderName = "${contact.name} [$fourByteHex]";
|
||||
// An unresolved author leaves `contact` as the room server itself; show
|
||||
// only the prefix rather than mislabeling the post with the room's name.
|
||||
senderName = contact.type == advTypeRoom
|
||||
? "[$fourByteHex]"
|
||||
: "${contact.name} [$fourByteHex]";
|
||||
} else {
|
||||
senderName = _resolveContact(connector).name;
|
||||
}
|
||||
|
||||
+358
-40
@@ -1,4 +1,3 @@
|
||||
import 'dart:collection';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
@@ -94,6 +93,11 @@ class _MapScreenState extends State<MapScreen> {
|
||||
String _searchQuery = '';
|
||||
List<_GuessedLocation> _cachedGuessedLocations = [];
|
||||
String _guessedLocationsCacheKey = '';
|
||||
int? _sharedMarkersCacheSignature;
|
||||
Locale? _sharedMarkersCacheLocale;
|
||||
List<_SharedMarker> _cachedSharedMarkers = const [];
|
||||
_NodeMarkersCacheKey? _nodeMarkersCacheKey;
|
||||
List<Marker> _cachedNodeMarkers = const [];
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
@@ -283,11 +287,23 @@ class _MapScreenState extends State<MapScreen> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Consumer3<MeshCoreConnector, AppSettingsService, PathHistoryService>(
|
||||
builder: (context, connector, settingsService, pathHistory, child) {
|
||||
return Builder(
|
||||
builder: (context) {
|
||||
final connectorSnapshot = context
|
||||
.select<MeshCoreConnector, _MapConnectorSnapshot>(
|
||||
_MapConnectorSnapshot.fromConnector,
|
||||
);
|
||||
final connector = connectorSnapshot.connector;
|
||||
final settings = context.select<AppSettingsService, AppSettings>(
|
||||
(service) => service.settings,
|
||||
);
|
||||
final pathHistoryVersion = context.select<PathHistoryService, int>(
|
||||
(service) => service.version,
|
||||
);
|
||||
final settingsService = context.read<AppSettingsService>();
|
||||
final pathHistory = context.read<PathHistoryService>();
|
||||
final tileCache = context.read<MapTileCacheService>();
|
||||
final isDesktop = _isDesktopPlatform(defaultTargetPlatform);
|
||||
final settings = settingsService.settings;
|
||||
final allContacts = connector.allContacts;
|
||||
|
||||
final contacts = settings.mapShowDiscoveryContacts
|
||||
@@ -296,7 +312,10 @@ class _MapScreenState extends State<MapScreen> {
|
||||
|
||||
final highlightPosition = widget.highlightPosition;
|
||||
final sharedMarkers = settings.mapShowMarkers
|
||||
? _collectSharedMarkers(connector)
|
||||
? _collectSharedMarkers(
|
||||
connector,
|
||||
connectorSnapshot.markerSignature,
|
||||
)
|
||||
.where(
|
||||
(marker) =>
|
||||
!_hiddenMarkerIds.contains(marker.id) &&
|
||||
@@ -347,9 +366,17 @@ class _MapScreenState extends State<MapScreen> {
|
||||
.where((c) => c.hasLocation)
|
||||
.toList();
|
||||
|
||||
// Guessed markers represent the same node types as known-location
|
||||
// markers, so apply the node-type filters before estimating positions.
|
||||
final guessCandidates = _filterContactsBySettings(
|
||||
filteredByKeyPrefix,
|
||||
settings,
|
||||
noLocations: true,
|
||||
);
|
||||
|
||||
// Compute guessed locations with caching
|
||||
final maxRangeKm = _estimateLoRaRangeKm(connector);
|
||||
final filteredKeys = filteredByKeyPrefix
|
||||
final filteredKeys = guessCandidates
|
||||
.map((c) => '${c.publicKeyHex}:${c.path.join("-")}')
|
||||
.join(',');
|
||||
final anchorKeys = allContactsWithLocation
|
||||
@@ -359,12 +386,12 @@ class _MapScreenState extends State<MapScreen> {
|
||||
)
|
||||
.join(',');
|
||||
final cacheKey =
|
||||
'$filteredKeys|$anchorKeys|${pathHistory.version}:${connector.currentSf}:${connector.currentBwHz}:${connector.currentTxPower}:${settings.mapShowGuessedLocations}';
|
||||
'$filteredKeys|$anchorKeys|$pathHistoryVersion:${connector.currentFreqHz}:${connector.currentSf}:${connector.currentBwHz}:${connector.currentTxPower}:${settings.mapShowGuessedLocations}';
|
||||
if (cacheKey != _guessedLocationsCacheKey) {
|
||||
_guessedLocationsCacheKey = cacheKey;
|
||||
_cachedGuessedLocations = settings.mapShowGuessedLocations
|
||||
? _computeGuessedLocations(
|
||||
filteredByKeyPrefix,
|
||||
guessCandidates,
|
||||
allContactsWithLocation,
|
||||
pathHistory,
|
||||
maxRangeKm,
|
||||
@@ -759,9 +786,21 @@ class _MapScreenState extends State<MapScreen> {
|
||||
guessedLocations,
|
||||
showLabels: _showNodeLabels,
|
||||
),
|
||||
..._buildNodeMarkers(
|
||||
..._buildNodeMarkersCached(
|
||||
visibleContacts,
|
||||
settings,
|
||||
connectorSnapshot.contactsSignature,
|
||||
connectorSnapshot.batterySignature,
|
||||
_freshness,
|
||||
settings.mapTimeFilterHours,
|
||||
settings.mapKeyPrefixEnabled,
|
||||
settings.mapKeyPrefix,
|
||||
settings.mapShowDiscoveryContacts,
|
||||
Object.hashAllUnordered(
|
||||
settings.batteryChemistryByRepeaterId.entries.map(
|
||||
(entry) => Object.hash(entry.key, entry.value),
|
||||
),
|
||||
),
|
||||
showLabels: _showNodeLabels,
|
||||
selectedContact: selectedContact,
|
||||
),
|
||||
@@ -873,6 +912,59 @@ class _MapScreenState extends State<MapScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
List<Marker> _buildNodeMarkersCached(
|
||||
List<Contact> contacts,
|
||||
AppSettings settings,
|
||||
int contactsSignature,
|
||||
int batterySignature,
|
||||
_Freshness freshness,
|
||||
double timeFilterHours,
|
||||
bool keyPrefixEnabled,
|
||||
String keyPrefix,
|
||||
bool showDiscoveryContacts,
|
||||
int batteryChemistrySignature, {
|
||||
required bool showLabels,
|
||||
Contact? selectedContact,
|
||||
}) {
|
||||
final visibleContactsSignature = Object.hashAll(
|
||||
contacts.map(
|
||||
(contact) =>
|
||||
Object.hash(_mapContactSignature(contact), _ageOf(contact)),
|
||||
),
|
||||
);
|
||||
final key = _NodeMarkersCacheKey(
|
||||
contactsSignature: contactsSignature,
|
||||
visibleContactsSignature: visibleContactsSignature,
|
||||
batterySignature: batterySignature,
|
||||
freshness: freshness,
|
||||
timeFilterHours: timeFilterHours,
|
||||
keyPrefixEnabled: keyPrefixEnabled,
|
||||
keyPrefix: keyPrefix,
|
||||
showDiscoveryContacts: showDiscoveryContacts,
|
||||
batteryChemistrySignature: batteryChemistrySignature,
|
||||
showLabels: showLabels,
|
||||
selectedKey: selectedContact?.publicKeyHex,
|
||||
zoom: _zoom,
|
||||
overlapsMode: settings.mapShowOverlaps,
|
||||
showRepeaters: settings.mapShowRepeaters,
|
||||
showChatNodes: settings.mapShowChatNodes,
|
||||
showOtherNodes: settings.mapShowOtherNodes,
|
||||
isBuildingPathTrace: _isBuildingPathTrace,
|
||||
);
|
||||
if (key != _nodeMarkersCacheKey) {
|
||||
_nodeMarkersCacheKey = key;
|
||||
_cachedNodeMarkers = List.unmodifiable(
|
||||
_buildNodeMarkers(
|
||||
contacts,
|
||||
settings,
|
||||
showLabels: showLabels,
|
||||
selectedContact: selectedContact,
|
||||
),
|
||||
);
|
||||
}
|
||||
return _cachedNodeMarkers;
|
||||
}
|
||||
|
||||
List<_GuessedLocation> _computeGuessedLocations(
|
||||
List<Contact> allContacts,
|
||||
List<Contact> withLocation,
|
||||
@@ -1146,17 +1238,18 @@ class _MapScreenState extends State<MapScreen> {
|
||||
}) {
|
||||
List<Contact> filtered = [];
|
||||
bool addContact = false;
|
||||
|
||||
for (final contact in contacts) {
|
||||
addContact = false;
|
||||
if (!contact.hasLocation && !noLocations) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Apply node type filters
|
||||
// Apply node type filters. The overlaps toggle is purely a visual
|
||||
// highlight (applied in _buildNodeMarkers) and no longer affects which
|
||||
// nodes are shown.
|
||||
if (contact.type == advTypeRepeater &&
|
||||
(settings.mapShowRepeaters ||
|
||||
_isBuildingPathTrace ||
|
||||
settings.mapShowOverlaps)) {
|
||||
(settings.mapShowRepeaters || _isBuildingPathTrace)) {
|
||||
addContact = true;
|
||||
}
|
||||
if (contact.type == advTypeChat &&
|
||||
@@ -1165,9 +1258,7 @@ class _MapScreenState extends State<MapScreen> {
|
||||
}
|
||||
if (contact.type != advTypeChat &&
|
||||
contact.type != advTypeRepeater &&
|
||||
(settings.mapShowOtherNodes ||
|
||||
_isBuildingPathTrace ||
|
||||
settings.mapShowOverlaps)) {
|
||||
(settings.mapShowOtherNodes || _isBuildingPathTrace)) {
|
||||
addContact = true;
|
||||
}
|
||||
|
||||
@@ -1175,25 +1266,6 @@ class _MapScreenState extends State<MapScreen> {
|
||||
addContact = false;
|
||||
}
|
||||
|
||||
if (settings.mapShowOverlaps) {
|
||||
final hasOverlap = contacts
|
||||
.where(
|
||||
(c) =>
|
||||
c.publicKeyHex != contact.publicKeyHex &&
|
||||
c.publicKey.first == contact.publicKey.first &&
|
||||
(c.type == advTypeRepeater || c.type == advTypeRoom) &&
|
||||
(contact.type == advTypeRepeater ||
|
||||
contact.type == advTypeRoom),
|
||||
)
|
||||
.firstOrNull;
|
||||
|
||||
if (hasOverlap == null &&
|
||||
settings.mapShowOverlaps &&
|
||||
!_isBuildingPathTrace) {
|
||||
addContact = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (addContact) {
|
||||
filtered.add(contact);
|
||||
}
|
||||
@@ -1212,13 +1284,34 @@ class _MapScreenState extends State<MapScreen> {
|
||||
final selectedKey = selectedContact?.publicKeyHex;
|
||||
final items = contacts.where((c) => c.publicKeyHex != selectedKey).toList();
|
||||
|
||||
// Key-prefix overlaps are a visual highlight only: flag the repeaters/rooms
|
||||
// whose first key byte collides with another repeater/room on the map.
|
||||
final overlapPrefixes = <int>{};
|
||||
if (overlapsMode) {
|
||||
final counts = <int, int>{};
|
||||
for (final contact in contacts) {
|
||||
if (contact.type == advTypeRepeater || contact.type == advTypeRoom) {
|
||||
final prefix = contact.publicKey.first;
|
||||
counts[prefix] = (counts[prefix] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
counts.forEach((prefix, count) {
|
||||
if (count > 1) overlapPrefixes.add(prefix);
|
||||
});
|
||||
}
|
||||
bool isOverlap(Contact contact) =>
|
||||
overlapsMode &&
|
||||
(contact.type == advTypeRepeater || contact.type == advTypeRoom) &&
|
||||
overlapPrefixes.contains(contact.publicKey.first);
|
||||
|
||||
void addNode(Contact contact, {bool dot = false}) {
|
||||
markers.add(_nodeMarker(contact, overlapsMode: overlapsMode, dot: dot));
|
||||
final overlap = isOverlap(contact);
|
||||
markers.add(_nodeMarker(contact, overlapsMode: overlap, dot: dot));
|
||||
if (showLabels) {
|
||||
markers.add(
|
||||
_buildNodeLabelMarker(
|
||||
point: LatLng(contact.latitude!, contact.longitude!),
|
||||
label: overlapsMode
|
||||
label: overlap
|
||||
? "${contact.publicKeyHex.substring(0, 2)}:${contact.name}"
|
||||
: contact.name,
|
||||
),
|
||||
@@ -1255,7 +1348,7 @@ class _MapScreenState extends State<MapScreen> {
|
||||
markers.add(
|
||||
_nodeMarker(
|
||||
selectedContact,
|
||||
overlapsMode: overlapsMode,
|
||||
overlapsMode: isOverlap(selectedContact),
|
||||
selected: true,
|
||||
),
|
||||
);
|
||||
@@ -2371,7 +2464,16 @@ class _MapScreenState extends State<MapScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
List<_SharedMarker> _collectSharedMarkers(MeshCoreConnector connector) {
|
||||
List<_SharedMarker> _collectSharedMarkers(
|
||||
MeshCoreConnector connector,
|
||||
int markerSignature,
|
||||
) {
|
||||
final locale = Localizations.localeOf(context);
|
||||
if (_sharedMarkersCacheSignature == markerSignature &&
|
||||
_sharedMarkersCacheLocale == locale) {
|
||||
return _cachedSharedMarkers;
|
||||
}
|
||||
|
||||
// Build a _SharedMarker per message (history empty), grouped by dedupe key.
|
||||
// Afterwards pick the latest per key and fill its history from older ones.
|
||||
final updatesByKey = <String, List<_SharedMarker>>{};
|
||||
@@ -2463,7 +2565,10 @@ class _MapScreenState extends State<MapScreen> {
|
||||
});
|
||||
|
||||
markers.sort((a, b) => b.timestamp.compareTo(a.timestamp));
|
||||
return markers;
|
||||
_sharedMarkersCacheSignature = markerSignature;
|
||||
_sharedMarkersCacheLocale = locale;
|
||||
_cachedSharedMarkers = List.unmodifiable(markers);
|
||||
return _cachedSharedMarkers;
|
||||
}
|
||||
|
||||
Marker _buildSharedMarker(_SharedMarker marker) {
|
||||
@@ -3564,6 +3669,219 @@ enum _NodeAge { online, recent, stale }
|
||||
|
||||
enum _Freshness { all, online, recent, stale }
|
||||
|
||||
int _bytesSignature(Iterable<int>? bytes) {
|
||||
if (bytes == null) return 0;
|
||||
return Object.hashAll(bytes);
|
||||
}
|
||||
|
||||
int _mapContactSignature(Contact contact) {
|
||||
return Object.hash(
|
||||
contact.publicKeyHex,
|
||||
contact.name,
|
||||
contact.type,
|
||||
contact.flags,
|
||||
contact.pathLength,
|
||||
_bytesSignature(contact.path),
|
||||
contact.pathOverride,
|
||||
_bytesSignature(contact.pathOverrideBytes),
|
||||
contact.latitude,
|
||||
contact.longitude,
|
||||
contact.lastSeen.millisecondsSinceEpoch,
|
||||
contact.lastMessageAt.millisecondsSinceEpoch,
|
||||
contact.isActive,
|
||||
contact.wasPulled,
|
||||
);
|
||||
}
|
||||
|
||||
class _MapConnectorSnapshot {
|
||||
final MeshCoreConnector connector;
|
||||
final int contactsSignature;
|
||||
final int markerSignature;
|
||||
final int batterySignature;
|
||||
final int uiSignature;
|
||||
|
||||
const _MapConnectorSnapshot({
|
||||
required this.connector,
|
||||
required this.contactsSignature,
|
||||
required this.markerSignature,
|
||||
required this.batterySignature,
|
||||
required this.uiSignature,
|
||||
});
|
||||
|
||||
factory _MapConnectorSnapshot.fromConnector(MeshCoreConnector connector) {
|
||||
final allContacts = connector.allContacts;
|
||||
final contactsSignature = Object.hashAll(
|
||||
allContacts.map(_mapContactSignature),
|
||||
);
|
||||
final batterySignature = Object.hashAll(
|
||||
allContacts
|
||||
.where((contact) => contact.type == advTypeRepeater)
|
||||
.map(
|
||||
(contact) => Object.hash(
|
||||
contact.publicKeyHex,
|
||||
connector.getRepeaterBatteryMillivolts(contact.publicKeyHex),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final markerParts = <Object?>[connector.selfName];
|
||||
for (final contact in connector.contacts) {
|
||||
markerParts.add(contact.publicKeyHex);
|
||||
markerParts.add(contact.name);
|
||||
for (final message in connector.getMessages(contact)) {
|
||||
if (!message.text.trimLeft().startsWith('m:')) continue;
|
||||
markerParts.add(
|
||||
Object.hash(
|
||||
message.messageId,
|
||||
message.text,
|
||||
message.timestamp.millisecondsSinceEpoch,
|
||||
message.isOutgoing,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
for (final channel in connector.channels) {
|
||||
markerParts.add(
|
||||
Object.hash(
|
||||
channel.index,
|
||||
channel.name,
|
||||
channel.isPublicChannel,
|
||||
channel.isEmpty,
|
||||
),
|
||||
);
|
||||
for (final message in connector.getChannelMessages(channel)) {
|
||||
if (!message.text.trimLeft().startsWith('m:')) continue;
|
||||
markerParts.add(
|
||||
Object.hash(
|
||||
message.messageId,
|
||||
message.text,
|
||||
message.senderName,
|
||||
message.timestamp.millisecondsSinceEpoch,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return _MapConnectorSnapshot(
|
||||
connector: connector,
|
||||
contactsSignature: contactsSignature,
|
||||
markerSignature: Object.hashAll(markerParts),
|
||||
batterySignature: batterySignature,
|
||||
uiSignature: Object.hash(
|
||||
connector.isConnected,
|
||||
connector.selfLatitude,
|
||||
connector.selfLongitude,
|
||||
connector.currentFreqHz,
|
||||
connector.currentBwHz,
|
||||
connector.currentSf,
|
||||
connector.currentTxPower,
|
||||
connector.getTotalContactsUnreadCount(),
|
||||
connector.getTotalChannelsUnreadCount(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return other is _MapConnectorSnapshot &&
|
||||
contactsSignature == other.contactsSignature &&
|
||||
markerSignature == other.markerSignature &&
|
||||
batterySignature == other.batterySignature &&
|
||||
uiSignature == other.uiSignature;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(
|
||||
contactsSignature,
|
||||
markerSignature,
|
||||
batterySignature,
|
||||
uiSignature,
|
||||
);
|
||||
}
|
||||
|
||||
class _NodeMarkersCacheKey {
|
||||
final int contactsSignature;
|
||||
final int visibleContactsSignature;
|
||||
final int batterySignature;
|
||||
final _Freshness freshness;
|
||||
final double timeFilterHours;
|
||||
final bool keyPrefixEnabled;
|
||||
final String keyPrefix;
|
||||
final bool showDiscoveryContacts;
|
||||
final int batteryChemistrySignature;
|
||||
final bool showLabels;
|
||||
final String? selectedKey;
|
||||
final double zoom;
|
||||
final bool overlapsMode;
|
||||
final bool showRepeaters;
|
||||
final bool showChatNodes;
|
||||
final bool showOtherNodes;
|
||||
final bool isBuildingPathTrace;
|
||||
|
||||
const _NodeMarkersCacheKey({
|
||||
required this.contactsSignature,
|
||||
required this.visibleContactsSignature,
|
||||
required this.batterySignature,
|
||||
required this.freshness,
|
||||
required this.timeFilterHours,
|
||||
required this.keyPrefixEnabled,
|
||||
required this.keyPrefix,
|
||||
required this.showDiscoveryContacts,
|
||||
required this.batteryChemistrySignature,
|
||||
required this.showLabels,
|
||||
required this.selectedKey,
|
||||
required this.zoom,
|
||||
required this.overlapsMode,
|
||||
required this.showRepeaters,
|
||||
required this.showChatNodes,
|
||||
required this.showOtherNodes,
|
||||
required this.isBuildingPathTrace,
|
||||
});
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return other is _NodeMarkersCacheKey &&
|
||||
contactsSignature == other.contactsSignature &&
|
||||
visibleContactsSignature == other.visibleContactsSignature &&
|
||||
batterySignature == other.batterySignature &&
|
||||
freshness == other.freshness &&
|
||||
timeFilterHours == other.timeFilterHours &&
|
||||
keyPrefixEnabled == other.keyPrefixEnabled &&
|
||||
keyPrefix == other.keyPrefix &&
|
||||
showDiscoveryContacts == other.showDiscoveryContacts &&
|
||||
batteryChemistrySignature == other.batteryChemistrySignature &&
|
||||
showLabels == other.showLabels &&
|
||||
selectedKey == other.selectedKey &&
|
||||
zoom == other.zoom &&
|
||||
overlapsMode == other.overlapsMode &&
|
||||
showRepeaters == other.showRepeaters &&
|
||||
showChatNodes == other.showChatNodes &&
|
||||
showOtherNodes == other.showOtherNodes &&
|
||||
isBuildingPathTrace == other.isBuildingPathTrace;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(
|
||||
contactsSignature,
|
||||
visibleContactsSignature,
|
||||
batterySignature,
|
||||
freshness,
|
||||
timeFilterHours,
|
||||
keyPrefixEnabled,
|
||||
keyPrefix,
|
||||
showDiscoveryContacts,
|
||||
batteryChemistrySignature,
|
||||
showLabels,
|
||||
selectedKey,
|
||||
zoom,
|
||||
overlapsMode,
|
||||
showRepeaters,
|
||||
showChatNodes,
|
||||
showOtherNodes,
|
||||
isBuildingPathTrace,
|
||||
);
|
||||
}
|
||||
|
||||
class _GuessedLocation {
|
||||
final Contact contact;
|
||||
final LatLng position;
|
||||
|
||||
@@ -207,6 +207,15 @@ class _ScannerScreenState extends State<ScannerScreen> {
|
||||
}
|
||||
|
||||
void _toggleScan(MeshCoreConnector connector) {
|
||||
if (PlatformInfo.isWeb) {
|
||||
// flutter_blue_plus has no web backend, so a BLE scan silently no-ops in
|
||||
// the browser. Tell the user instead of leaving them staring at a button.
|
||||
showDismissibleSnackBar(
|
||||
context,
|
||||
content: Text(context.l10n.scanner_bluetoothWebUnsupported),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (connector.state == MeshCoreConnectionState.scanning) {
|
||||
connector.stopScan();
|
||||
} else {
|
||||
|
||||
@@ -725,18 +725,26 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text(l10n.common_cancel),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
Navigator.pop(context);
|
||||
await connector.setNodeName(controller.text);
|
||||
await connector.refreshDeviceInfo();
|
||||
if (!context.mounted) return;
|
||||
showDismissibleSnackBar(
|
||||
context,
|
||||
content: Text(l10n.settings_nodeNameUpdated),
|
||||
ListenableBuilder(
|
||||
listenable: controller,
|
||||
builder: (context, _) {
|
||||
final name = controller.text.trim();
|
||||
return TextButton(
|
||||
onPressed: name.isEmpty
|
||||
? null
|
||||
: () async {
|
||||
Navigator.pop(context);
|
||||
await connector.setNodeName(name);
|
||||
await connector.refreshDeviceInfo();
|
||||
if (!context.mounted) return;
|
||||
showDismissibleSnackBar(
|
||||
context,
|
||||
content: Text(l10n.settings_nodeNameUpdated),
|
||||
);
|
||||
},
|
||||
child: Text(l10n.common_save),
|
||||
);
|
||||
},
|
||||
child: Text(l10n.common_save),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -386,6 +386,10 @@ class _UsbScreenState extends State<UsbScreen> {
|
||||
|
||||
void _showError(Object error) {
|
||||
if (!mounted) return;
|
||||
// Cancelling the browser's serial port picker is a normal user action, not
|
||||
// an error — don't show a scary red toast (and never leak the raw
|
||||
// DOMException text).
|
||||
if (_isUserCancelledPortPicker(error)) return;
|
||||
showDismissibleSnackBar(
|
||||
context,
|
||||
content: Text(_friendlyErrorMessage(error)),
|
||||
@@ -393,6 +397,16 @@ class _UsbScreenState extends State<UsbScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
bool _isUserCancelledPortPicker(Object error) {
|
||||
if (error is StateError &&
|
||||
error.message.contains('No USB serial device selected')) {
|
||||
return true;
|
||||
}
|
||||
final text = error.toString();
|
||||
return text.contains('No port selected by the user') ||
|
||||
text.contains("Failed to execute 'requestPort'");
|
||||
}
|
||||
|
||||
String _friendlyErrorMessage(Object error) {
|
||||
final l10n = context.l10n;
|
||||
|
||||
|
||||
@@ -114,6 +114,36 @@ class NotificationService {
|
||||
return _isInitialized;
|
||||
}
|
||||
|
||||
// Cached "are we allowed to post notifications" result. Null = not yet
|
||||
// determined. Avoids calling _notifications.show() when it would only throw
|
||||
// "You must request notifications permissions first" (every web build, and
|
||||
// Android 13+ before the user grants the permission).
|
||||
bool? _canNotify;
|
||||
|
||||
Future<bool> _ensureCanNotify() async {
|
||||
if (!await _ensureInitialized()) return false;
|
||||
final cached = _canNotify;
|
||||
if (cached != null) return cached;
|
||||
|
||||
// flutter_local_notifications has no web backend, so show() always throws.
|
||||
// Skip silently instead of logging an error per incoming message.
|
||||
if (kIsWeb) return _canNotify = false;
|
||||
|
||||
// On Android 13+ notifications require an explicit grant; reflect the real
|
||||
// OS state so we don't spam failed show() calls when denied.
|
||||
final androidPlugin = _notifications
|
||||
.resolvePlatformSpecificImplementation<
|
||||
AndroidFlutterLocalNotificationsPlugin
|
||||
>();
|
||||
if (androidPlugin != null) {
|
||||
final enabled = await androidPlugin.areNotificationsEnabled();
|
||||
return _canNotify = enabled ?? false;
|
||||
}
|
||||
|
||||
// iOS/macOS request permission during initialize(); desktop has no gate.
|
||||
return _canNotify = true;
|
||||
}
|
||||
|
||||
Future<bool> requestPermissions() async {
|
||||
if (!_isInitialized) {
|
||||
await initialize();
|
||||
@@ -126,7 +156,8 @@ class NotificationService {
|
||||
>();
|
||||
if (androidPlugin != null) {
|
||||
final granted = await androidPlugin.requestNotificationsPermission();
|
||||
return granted ?? false;
|
||||
_canNotify = granted ?? false;
|
||||
return _canNotify!;
|
||||
}
|
||||
|
||||
// iOS permissions are requested during initialization
|
||||
@@ -140,7 +171,8 @@ class NotificationService {
|
||||
badge: true,
|
||||
sound: true,
|
||||
);
|
||||
return granted ?? false;
|
||||
_canNotify = granted ?? false;
|
||||
return _canNotify!;
|
||||
}
|
||||
|
||||
return true;
|
||||
@@ -165,7 +197,7 @@ class NotificationService {
|
||||
String? contactId,
|
||||
int? badgeCount,
|
||||
}) async {
|
||||
if (!await _ensureInitialized()) return;
|
||||
if (!await _ensureCanNotify()) return;
|
||||
|
||||
final androidDetails = AndroidNotificationDetails(
|
||||
'messages',
|
||||
@@ -215,7 +247,7 @@ class NotificationService {
|
||||
required String contactType,
|
||||
String? contactId,
|
||||
}) async {
|
||||
if (!await _ensureInitialized()) return;
|
||||
if (!await _ensureCanNotify()) return;
|
||||
|
||||
const androidDetails = AndroidNotificationDetails(
|
||||
'adverts',
|
||||
@@ -265,7 +297,7 @@ class NotificationService {
|
||||
int? channelIndex,
|
||||
int? badgeCount,
|
||||
}) async {
|
||||
if (!await _ensureInitialized()) return;
|
||||
if (!await _ensureCanNotify()) return;
|
||||
|
||||
final androidDetails = AndroidNotificationDetails(
|
||||
'channel_messages',
|
||||
@@ -545,7 +577,7 @@ class NotificationService {
|
||||
}
|
||||
|
||||
Future<void> _showBatchSummary(List<_PendingNotification> batch) async {
|
||||
if (!await _ensureInitialized()) return;
|
||||
if (!await _ensureCanNotify()) return;
|
||||
|
||||
// Group by type
|
||||
final messages = batch
|
||||
|
||||
@@ -33,12 +33,14 @@ class UsbSerialService {
|
||||
String? _connectedPortLabel;
|
||||
FlSerial? _serial;
|
||||
AppDebugLogService? _debugLogService;
|
||||
Object? _lastError;
|
||||
|
||||
UsbSerialStatus get status => _status;
|
||||
String? get activePortKey => _connectedPortKey;
|
||||
String? get activePortDisplayLabel =>
|
||||
_connectedPortLabel ?? _connectedPortKey;
|
||||
Stream<Uint8List> get frameStream => _frameController.stream;
|
||||
Object? get lastError => _lastError;
|
||||
bool get _useAndroidUsbHost =>
|
||||
!kIsWeb && defaultTargetPlatform == TargetPlatform.android;
|
||||
bool get _useDesktopFlSerial =>
|
||||
@@ -434,6 +436,7 @@ class UsbSerialService {
|
||||
}
|
||||
|
||||
void _addFrameError(Object error, [StackTrace? stackTrace]) {
|
||||
_lastError = error;
|
||||
if (_frameController.isClosed) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -15,6 +15,18 @@ class UsbSerialService {
|
||||
static const Map<String, String> _knownUsbNames = <String, String>{
|
||||
'2886:1667': 'Seeed Wio Tracker L1',
|
||||
};
|
||||
|
||||
/// USB-to-UART bridge chips whose hardware auto-reset circuit requires DTR
|
||||
/// to be held asserted after open (otherwise the MCU resets). Native-USB-CDC
|
||||
/// boards (nRF52840/Adafruit 0x239A, Espressif native 0x303A, Seeed 0x2886)
|
||||
/// tie DTR to the bootloader/reset line, so asserting it re-enumerates and
|
||||
/// drops the device ("The device has been lost"); they must be left alone.
|
||||
static const Set<int> _uartBridgeVendorIds = <int>{
|
||||
0x10C4, // Silicon Labs CP210x
|
||||
0x1A86, // QinHeng CH340 / CH9102
|
||||
0x0403, // FTDI
|
||||
0x067B, // Prolific PL2303
|
||||
};
|
||||
static final Map<String, String> _deviceNamesByPortKey = <String, String>{};
|
||||
static final Map<String, String> _baseLabelsByPortKey = <String, String>{};
|
||||
static final Map<String, JSObject> _authorizedPortsByKey =
|
||||
@@ -34,12 +46,14 @@ class UsbSerialService {
|
||||
String _requestPortLabel = 'Choose USB Device';
|
||||
String _fallbackDeviceName = 'Web Serial Device';
|
||||
AppDebugLogService? _debugLogService;
|
||||
Object? _lastError;
|
||||
|
||||
UsbSerialStatus get status => _status;
|
||||
String? get activePortKey => _connectedPortKey;
|
||||
String? get activePortDisplayLabel => _connectedPortName ?? _connectedPortKey;
|
||||
Stream<Uint8List> get frameStream => _frameController.stream;
|
||||
bool get isConnected => _status == UsbSerialStatus.connected;
|
||||
Object? get lastError => _lastError;
|
||||
|
||||
JSObject get _navigator => JSObject.fromInteropObject(web.window.navigator);
|
||||
bool get _isSupported => _navigator.has('serial');
|
||||
@@ -74,6 +88,7 @@ class UsbSerialService {
|
||||
}
|
||||
|
||||
_status = UsbSerialStatus.connecting;
|
||||
_lastError = null;
|
||||
_frameDecoder.reset();
|
||||
|
||||
try {
|
||||
@@ -282,16 +297,30 @@ class UsbSerialService {
|
||||
..['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.
|
||||
// Only UART-bridge chips (CP210x/CH340/FTDI/PL2303) need DTR held high to
|
||||
// avoid the auto-reset circuit firing on open. Native-USB-CDC boards
|
||||
// (e.g. nRF52840/Adafruit) tie DTR to the reset line — toggling it there
|
||||
// re-enumerates the device and Web Serial reports "The device has been
|
||||
// lost". Leave their signals untouched.
|
||||
final vendorId = _portInfo(port)?.usbVendorId;
|
||||
final isUartBridge =
|
||||
vendorId != null && _uartBridgeVendorIds.contains(vendorId);
|
||||
_debugLogService?.info(
|
||||
'Open: vendorId=${vendorId == null ? 'unknown' : '0x${vendorId.toRadixString(16)}'} '
|
||||
'uartBridge=$isUartBridge (DTR ${isUartBridge ? 'asserted' : 'left default'})',
|
||||
tag: 'USB Serial',
|
||||
);
|
||||
if (isUartBridge) {
|
||||
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.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -384,13 +413,21 @@ class UsbSerialService {
|
||||
} catch (error, stackTrace) {
|
||||
_debugLogService?.error('_pumpReads error: $error', tag: 'USB Serial');
|
||||
if (_status == UsbSerialStatus.connected) {
|
||||
// The transport is dead — reflect that in status immediately so a
|
||||
// concurrent connect handshake fails fast instead of waiting for a
|
||||
// SELF_INFO that can never arrive.
|
||||
_status = UsbSerialStatus.disconnected;
|
||||
_lastError = error;
|
||||
_addFrameError(error, stackTrace);
|
||||
}
|
||||
} finally {
|
||||
_debugLogService?.info('_pumpReads: ended', tag: 'USB Serial');
|
||||
_releaseLock(reader);
|
||||
if (_status == UsbSerialStatus.connected && identical(reader, _reader)) {
|
||||
_addFrameError(StateError('USB serial connection closed'));
|
||||
_status = UsbSerialStatus.disconnected;
|
||||
final closedError = StateError('USB serial connection closed');
|
||||
_lastError = closedError;
|
||||
_addFrameError(closedError);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user