Merge remote-tracking branch 'origin/dev' into test-regions Also added fixes

This commit is contained in:
zjs81
2026-06-15 22:46:59 -07:00
133 changed files with 34463 additions and 19330 deletions
+65 -8
View File
@@ -39,7 +39,12 @@ class RetryServiceConfig {
final void Function(Message) updateMessage;
final Function(Contact)? clearContactPath;
final Function(Contact, Uint8List, int)? setContactPath;
final int Function(int pathLength, int messageBytes, {String? contactKey})?
final int Function(
int pathLength,
int messageBytes, {
String? contactKey,
int? deviceTimeoutMs,
})?
calculateTimeout;
final Uint8List? Function()? getSelfPublicKey;
final String Function(Contact, String)? prepareContactOutboundText;
@@ -74,6 +79,12 @@ class RetryServiceConfig {
class MessageRetryService extends ChangeNotifier {
static const int maxAckHistorySize = 100;
/// Global cap on concurrent in-flight messages across ALL contacts.
/// The firmware's expected_ack_table is a single 8-entry circular buffer
/// shared globally; cap at 6 to leave two slots of headroom.
static const int _maxGlobalInFlight = 6;
int _maxRetries = 5;
int get maxRetries => _maxRetries;
@@ -170,8 +181,9 @@ class MessageRetryService extends ChangeNotifier {
_config?.addMessage(contact.publicKeyHex, message);
// Queue per contact — only one message in-flight at a time to avoid
// overflowing the firmware's 8-entry expected_ack_table.
// Queue per contact — one message in-flight per contact at a time, and
// bounded globally by _maxGlobalInFlight across all contacts so we never
// overflow the firmware's 8-entry global expected_ack_table.
final contactKey = contact.publicKeyHex;
_sendQueue[contactKey] ??= [];
_sendQueue[contactKey]!.add(messageId);
@@ -184,6 +196,11 @@ class MessageRetryService extends ChangeNotifier {
}
void _sendNextForContact(String contactKey) {
// Enforce the global in-flight cap before starting a new send.
// The firmware's expected_ack_table is a single 8-entry circular buffer
// shared across all contacts; exceeding it silently evicts an older slot.
if (_activeMessages.length >= _maxGlobalInFlight) return;
final queue = _sendQueue[contactKey];
if (queue == null) return;
@@ -210,8 +227,23 @@ class MessageRetryService extends ChangeNotifier {
void _onMessageResolved(String messageId, String contactKey) {
if (_resolvedMessages.contains(messageId)) return;
_resolvedMessages.add(messageId);
_activeMessages.remove(messageId);
// If cleanup already removed this message from the active set, it has
// already pumped the queues; avoid double-pumping.
if (!_activeMessages.remove(messageId)) return;
_pumpQueues(contactKey);
}
void _pumpQueues(String contactKey) {
// Pump this contact's queue first, then any other contacts that are waiting.
_sendNextForContact(contactKey);
for (final key in _sendQueue.keys) {
if (key == contactKey) continue;
if (_activeMessages.length >= _maxGlobalInFlight) break;
final queue = _sendQueue[key];
if (queue != null && queue.isNotEmpty) {
_sendNextForContact(key);
}
}
}
PathSelection? _selectPathForAttempt(Message message, Contact contact) {
@@ -352,6 +384,10 @@ class MessageRetryService extends ChangeNotifier {
}
bool updateMessageFromSent(int ackHash, int timeoutMs) {
// Firmware sets expected_ack = 0 for CLI/command sends (TXT_TYPE_CLI_DATA).
// No ACK will ever be issued for these, so arming a retry timer is wrong.
if (ackHash == 0) return false;
final config = _config;
if (config == null) return false;
@@ -404,13 +440,18 @@ class MessageRetryService extends ChangeNotifier {
// Calculate timeout: prefer ML prediction, then device-provided, then physics fallback
final pathLengthValue = message.pathLength ?? contact.pathLength;
final outboundTextForTimeout =
config.prepareContactOutboundText?.call(contact, message.text) ??
message.text;
final messageBytesForTimeout = utf8.encode(outboundTextForTimeout).length;
int actualTimeout = timeoutMs;
if (config.calculateTimeout != null) {
actualTimeout = config.calculateTimeout!(
pathLengthValue,
message.text.length,
messageBytesForTimeout,
contactKey: contact.publicKeyHex,
deviceTimeoutMs: timeoutMs > 0 ? timeoutMs : null,
);
}
@@ -449,17 +490,28 @@ class MessageRetryService extends ChangeNotifier {
});
}
void untrack(String messageId) {
_timeoutTimers[messageId]?.cancel();
_cleanupMessage(messageId);
}
void _cleanupMessage(String messageId) {
_moveAckHashesToHistory(messageId);
_ackHashToMessageId.removeWhere(
(_, mapping) => mapping.messageId == messageId,
);
_expectedHashToMessageId.removeWhere((_, msgId) => msgId == messageId);
final contactKey = _pendingContacts[messageId]?.publicKeyHex;
_pendingMessages.remove(messageId);
_pendingContacts.remove(messageId);
_attemptPathHistory.remove(messageId);
_timeoutTimers.remove(messageId);
_resolvedMessages.remove(messageId);
// Cancellation (and other cleanup paths) must release the active in-flight
// slot and pump waiting queues so the global cap does not stall forever.
if (_activeMessages.remove(messageId) && contactKey != null) {
_pumpQueues(contactKey);
}
}
void _handleTimeout(String messageId) {
@@ -612,7 +664,6 @@ class MessageRetryService extends ChangeNotifier {
for (final expectedHash in expectedHashes) {
if (expectedHash == ackHash) {
matchedMessageId = messageId;
matchedAttemptIndex = expectedHashes.indexOf(expectedHash);
break;
}
}
@@ -664,10 +715,16 @@ class MessageRetryService extends ChangeNotifier {
if (config?.onDeliveryObserved != null &&
tripTimeMs > 0 &&
message.pathLength != null) {
config!.onDeliveryObserved!(
final outboundTextForObserved =
config!.prepareContactOutboundText?.call(contact, message.text) ??
message.text;
final messageBytesForObserved = utf8
.encode(outboundTextForObserved)
.length;
config.onDeliveryObserved!(
contact.publicKeyHex,
message.pathLength!,
message.text.length,
messageBytesForObserved,
tripTimeMs,
);
}
+42 -8
View File
@@ -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',
@@ -248,7 +280,7 @@ class NotificationService {
await _notifications.show(
id: contactId != null
? 'advert:$contactId'.hashCode
: DateTime.now().millisecondsSinceEpoch,
: DateTime.now().millisecondsSinceEpoch & 0x7FFFFFFF,
title: _l10n.notification_newTypeDiscovered(contactType),
body: contactName,
notificationDetails: notificationDetails,
@@ -265,7 +297,7 @@ class NotificationService {
int? channelIndex,
int? badgeCount,
}) async {
if (!await _ensureInitialized()) return;
if (!await _ensureCanNotify()) return;
final androidDetails = AndroidNotificationDetails(
'channel_messages',
@@ -304,7 +336,9 @@ class NotificationService {
try {
await _notifications.show(
id: channelIndex?.hashCode ?? DateTime.now().millisecondsSinceEpoch,
id:
channelIndex?.hashCode ??
DateTime.now().millisecondsSinceEpoch & 0x7FFFFFFF,
title: channelName,
body: body,
notificationDetails: notificationDetails,
@@ -543,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
+3 -1
View File
@@ -134,10 +134,12 @@ class PathHistoryService extends ChangeNotifier {
newWeight = (currentWeight + successIncrement).clamp(0.0, maxWeight);
} else {
newWeight = currentWeight - failureDecrement;
if (newWeight <= 0) {
if (newWeight <= 0 && failureCount >= 3) {
removePathRecord(contactPubKeyHex, selection.pathBytes);
return;
}
// Keep the record with a small floor weight until we have enough evidence
newWeight = newWeight.clamp(0.1, maxWeight);
}
_addPathRecord(
+20 -8
View File
@@ -63,12 +63,15 @@ class TimeoutPredictionService extends ChangeNotifier {
required int tripTimeMs,
int secondsSinceLastRx = 0,
}) {
final isFlood = pathLength < 0;
final observation = DeliveryObservation(
contactKey: contactKey,
pathLength: pathLength,
// Clamp to 0 for flood so the hop-count slope is learned from direct paths
// only; isFlood carries the flood signal as a separate feature.
pathLength: isFlood ? 0 : pathLength,
messageBytes: messageBytes,
secondsSinceLastRx: secondsSinceLastRx,
isFlood: pathLength < 0,
isFlood: isFlood,
deliveryMs: tripTimeMs,
timestamp: DateTime.now(),
);
@@ -76,11 +79,12 @@ class TimeoutPredictionService extends ChangeNotifier {
_observations.add(observation);
if (_observations.length > maxObservations) {
_observations.removeAt(0);
_rebuildContactStats();
} else {
_contactStats.putIfAbsent(contactKey, () => _ContactStats());
_contactStats[contactKey]!.add(tripTimeMs.toDouble());
}
_contactStats.putIfAbsent(contactKey, () => _ContactStats());
_contactStats[contactKey]!.add(tripTimeMs.toDouble());
_observationsSinceLastTrain++;
if (_observationsSinceLastTrain >= _retrainInterval &&
_observations.length >= minObservations) {
@@ -108,11 +112,14 @@ class TimeoutPredictionService extends ChangeNotifier {
try {
if (_activeFeatures.isEmpty) return null;
final flood = pathLength < 0;
final allFeatures = {
'pathLength': pathLength.toDouble(),
// Clamp to 0 for flood — mirrors recordObservation so training and
// prediction see the same pathLength values; isFlood carries the signal.
'pathLength': flood ? 0.0 : pathLength.toDouble(),
'messageBytes': messageBytes.toDouble(),
'secSinceRx': secondsSinceLastRx.toDouble(),
'isFlood': pathLength < 0 ? 1.0 : 0.0,
'isFlood': flood ? 1.0 : 0.0,
};
final row = _activeFeatures.map((f) => allFeatures[f]!).toList();
@@ -164,7 +171,9 @@ class TimeoutPredictionService extends ChangeNotifier {
// (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(),
// pathLength is already clamped to >=0 in recordObservation, but guard
// here as well for any observations loaded from older persisted data.
(o) => o.pathLength < 0 ? 0.0 : o.pathLength.toDouble(),
(o) => o.messageBytes.toDouble(),
(o) => o.secondsSinceLastRx.toDouble(),
(o) => o.isFlood ? 1.0 : 0.0,
@@ -215,6 +224,9 @@ class TimeoutPredictionService extends ChangeNotifier {
@override
void dispose() {
if (_persistTimer?.isActive == true) {
_storage?.saveDeliveryObservations(_observations);
}
_persistTimer?.cancel();
super.dispose();
}
+22 -9
View File
@@ -47,6 +47,7 @@ class TranslationService extends ChangeNotifier {
_langDetectInit = initLangDetect();
}
bool _disposed = false;
bool _isBusy = false;
bool _isDownloading = false;
bool _cancelDownloadRequested = false;
@@ -215,7 +216,7 @@ class TranslationService extends ChangeNotifier {
}
_downloadTotalBytes = totalSize;
notifyListeners();
_notify();
DownloadedModelFile downloaded;
if (supportsRange &&
@@ -268,7 +269,7 @@ class TranslationService extends ChangeNotifier {
throw StateError('Model download failed: HTTP ${response.statusCode}');
}
_downloadTotalBytes ??= response.contentLength;
notifyListeners();
_notify();
final trackedStream = _trackDownloadProgress(response.stream);
return await _fileStore.writeModelBytes(
fileName: fileName,
@@ -313,7 +314,7 @@ class TranslationService extends ChangeNotifier {
throw const TranslationDownloadCancelled();
}
_downloadFileName = 'Merging chunks...';
notifyListeners();
_notify();
combineReached = true;
return await _fileStore.combineChunks(
fileName: fileName,
@@ -361,7 +362,7 @@ class TranslationService extends ChangeNotifier {
}
_cancelDownloadRequested = true;
_lastError = 'Download stopped.';
notifyListeners();
_notify();
}
Future<void> removeModel(TranslationModelRecord model) async {
@@ -469,7 +470,7 @@ class TranslationService extends ChangeNotifier {
} catch (error) {
_lastError = error.toString();
appLogger.warn('Language detection failed: $error');
notifyListeners();
_notify();
return null;
}
}
@@ -538,7 +539,7 @@ class TranslationService extends ChangeNotifier {
} catch (error) {
_lastError = error.toString();
appLogger.warn('Translation request failed: $error');
notifyListeners();
_notify();
return null;
}
}
@@ -631,6 +632,10 @@ class TranslationService extends ChangeNotifier {
final completer = Completer<T>();
_setBusy(true);
_queue = _queue.then((_) async {
if (_disposed) {
completer.completeError(StateError('TranslationService disposed.'));
return;
}
try {
completer.complete(await action());
} catch (error, stackTrace) {
@@ -648,17 +653,24 @@ class TranslationService extends ChangeNotifier {
throw const TranslationDownloadCancelled();
}
_downloadedBytes += chunk.length;
notifyListeners();
_notify();
yield chunk;
}
}
void _notify() {
if (_disposed) {
return;
}
notifyListeners();
}
void _setBusy(bool value) {
if (_isBusy == value) {
return;
}
_isBusy = value;
notifyListeners();
_notify();
}
void _setDownloading(bool value) {
@@ -669,11 +681,12 @@ class TranslationService extends ChangeNotifier {
_downloadTotalBytes = null;
_downloadFileName = null;
}
notifyListeners();
_notify();
}
@override
void dispose() {
_disposed = true;
final engine = _engine;
_engine = null;
_loadedModelPath = null;
@@ -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;
}
+48 -11
View File
@@ -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);
}
}
}