From 1d79dcb975ae6f606a783a3d0287d8dc473e1cc8 Mon Sep 17 00:00:00 2001
From: Matthias Nott <mnott@mnsoft.org>
Date: Fri, 10 Jul 2026 07:13:05 +0200
Subject: [PATCH] feat: session launcher (all-project search + switch-topic) and persistent last-host fast reconnect
---
lib/services/mqtt_service.dart | 173 +++++++++++++++++++++++++++++++++++++++++++++------------
1 files changed, 135 insertions(+), 38 deletions(-)
diff --git a/lib/services/mqtt_service.dart b/lib/services/mqtt_service.dart
index 72632aa..5cf1c62 100644
--- a/lib/services/mqtt_service.dart
+++ b/lib/services/mqtt_service.dart
@@ -20,12 +20,7 @@
import 'wol_service.dart';
/// Connection status for the MQTT client.
-enum ConnectionStatus {
- disconnected,
- connecting,
- connected,
- reconnecting,
-}
+enum ConnectionStatus { disconnected, connecting, connected, reconnecting }
// Debug log — writes to file only in debug builds, always prints via debugPrint.
// Also adds entries to TraceService so they appear in the trace log viewer.
@@ -77,10 +72,12 @@
// Callbacks
void Function(ConnectionStatus status)? onStatusChanged;
- void Function(String detail)? onStatusDetail; // "Probing local...", "Scanning network..."
+ void Function(String detail)?
+ onStatusDetail; // "Probing local...", "Scanning network..."
String? connectedHost; // The host we're currently connected to
String? connectedVia; // "Local", "VPN", "Remote", "Bonjour", "Scan"
void Function(Map<String, dynamic> message)? onMessage;
+
/// Called when the server sends a debug_state_request on pailot/control/out.
/// The handler should read current session state and call [publishDebugStateResponse].
void Function(String requestId)? onDebugStateRequest;
@@ -112,6 +109,29 @@
}
_clientId = id;
return id;
+ }
+
+ // The host that last connected successfully, persisted across app restarts so
+ // a cold start (iOS killed the backgrounded app) can reconnect fast instead of
+ // re-running the LAN race + network scan when only the VPN host is reachable.
+ static const String _kLastHostKey = 'mqtt_last_good_host';
+
+ Future<void> _saveLastGoodHost(String host) async {
+ try {
+ final prefs = await SharedPreferences.getInstance();
+ await prefs.setString(_kLastHostKey, host);
+ } catch (_) {}
+ }
+
+ Future<String?> _loadLastGoodHost() async {
+ try {
+ final h = (await SharedPreferences.getInstance()).getString(
+ _kLastHostKey,
+ );
+ return (h != null && h.isNotEmpty) ? h : null;
+ } catch (_) {
+ return null;
+ }
}
/// Force reconnect — disconnect and reconnect to last known host.
@@ -168,13 +188,41 @@
final clientId = await _getClientId();
+ // Phase 0: Fast path — try the last host that worked (persisted across app
+ // restarts) before racing all hosts or scanning. On cellular/Tailscale the
+ // LAN host and mDNS are unreachable, so this avoids the slow scan every cold
+ // start. If it's stale/unreachable it times out quickly and we fall through.
+ final lastGood = await _loadLastGoodHost();
+ if (lastGood != null && !_intentionalClose) {
+ onStatusDetail?.call('Reconnecting…');
+ _mqttLog('MQTT: fast path — trying last-good host $lastGood');
+ if (await _tryConnect(lastGood, clientId, timeout: 2500)) {
+ if (lastGood == config.localHost) {
+ connectedVia = 'Local';
+ } else if (lastGood == config.vpnHost) {
+ connectedVia = 'VPN';
+ } else if (lastGood == config.host) {
+ connectedVia = 'Remote';
+ } else {
+ connectedVia = 'Reconnected';
+ }
+ _mqttLog('MQTT: fast path connected via $connectedVia');
+ return;
+ }
+ }
+
// Phase 1: Race configured hosts (fast — just TLS probe, ~1s each)
final hosts = <String>[];
- if (config.localHost != null && config.localHost!.isNotEmpty) hosts.add(config.localHost!);
- if (_lastDiscoveredHost != null && !hosts.contains(_lastDiscoveredHost)) hosts.add(_lastDiscoveredHost!);
- if (config.vpnHost != null && config.vpnHost!.isNotEmpty) hosts.add(config.vpnHost!);
+ if (config.localHost != null && config.localHost!.isNotEmpty)
+ hosts.add(config.localHost!);
+ if (_lastDiscoveredHost != null && !hosts.contains(_lastDiscoveredHost))
+ hosts.add(_lastDiscoveredHost!);
+ if (config.vpnHost != null && config.vpnHost!.isNotEmpty)
+ hosts.add(config.vpnHost!);
if (config.host.isNotEmpty) hosts.add(config.host);
- _mqttLog('MQTT: racing ${hosts.length} configured hosts: ${hosts.join(", ")}');
+ _mqttLog(
+ 'MQTT: racing ${hosts.length} configured hosts: ${hosts.join(", ")}',
+ );
onStatusDetail?.call('Connecting...');
// Race: first probe to succeed wins, don't wait for others
@@ -250,7 +298,9 @@
/// Discover AIBroker on local network via Bonjour/mDNS.
/// Falls back to subnet scan if Bonjour fails (iOS blocks mDNS on Personal Hotspot).
/// Returns the IP address or null if not found within timeout.
- Future<String?> _discoverViaMdns({Duration timeout = const Duration(seconds: 3)}) async {
+ Future<String?> _discoverViaMdns({
+ Duration timeout = const Duration(seconds: 3),
+ }) async {
// Try Bonjour first
try {
final discovery = BonsoirDiscovery(type: '_mqtt._tcp');
@@ -263,7 +313,9 @@
switch (event) {
case BonsoirDiscoveryServiceResolvedEvent():
final ip = event.service.host;
- _mqttLog('MQTT: Bonjour resolved: ${event.service.name} at $ip:${event.service.port}');
+ _mqttLog(
+ 'MQTT: Bonjour resolved: ${event.service.name} at $ip:${event.service.port}',
+ );
if (ip != null && ip.isNotEmpty && !completer.isCompleted) {
completer.complete(ip);
}
@@ -297,7 +349,9 @@
Future<String?> _scanSubnetForMqtt() async {
try {
// Get device's own IP to determine the subnet
- final interfaces = await NetworkInterface.list(type: InternetAddressType.IPv4);
+ final interfaces = await NetworkInterface.list(
+ type: InternetAddressType.IPv4,
+ );
for (final iface in interfaces) {
for (final addr in iface.addresses) {
final parts = addr.address.split('.');
@@ -319,7 +373,10 @@
futures.add(_probeHost(probe, config.port));
}
final results = await Future.wait(futures);
- final found = results.firstWhere((r) => r != null, orElse: () => null);
+ final found = results.firstWhere(
+ (r) => r != null,
+ orElse: () => null,
+ );
if (found != null) {
_mqttLog('MQTT: subnet scan found broker at $found');
return found;
@@ -342,7 +399,9 @@
final prefs = await SharedPreferences.getInstance();
_trustedFingerprint = prefs.getString('trustedCertFingerprint');
if (_trustedFingerprint != null) {
- _mqttLog('TOFU: loaded trusted fingerprint: ${_trustedFingerprint!.substring(0, 16)}...');
+ _mqttLog(
+ 'TOFU: loaded trusted fingerprint: ${_trustedFingerprint!.substring(0, 16)}...',
+ );
}
}
@@ -365,7 +424,9 @@
SharedPreferences.getInstance().then((prefs) {
prefs.setString('trustedCertFingerprint', fingerprint);
});
- _mqttLog('TOFU: first connection, saved fingerprint: ${fingerprint.substring(0, 16)}...');
+ _mqttLog(
+ 'TOFU: first connection, saved fingerprint: ${fingerprint.substring(0, 16)}...',
+ );
return true;
}
@@ -374,7 +435,9 @@
}
// Fingerprint mismatch — possible MITM or server reinstall
- _mqttLog('TOFU: CERT MISMATCH! Expected ${_trustedFingerprint!.substring(0, 16)}... got ${fingerprint.substring(0, 16)}...');
+ _mqttLog(
+ 'TOFU: CERT MISMATCH! Expected ${_trustedFingerprint!.substring(0, 16)}... got ${fingerprint.substring(0, 16)}...',
+ );
// Reject the connection. User must reset trust in settings.
return false;
}
@@ -404,11 +467,17 @@
}
}
- Future<bool> _tryConnect(String host, String clientId, {int timeout = 5000}) async {
+ Future<bool> _tryConnect(
+ String host,
+ String clientId, {
+ int timeout = 5000,
+ }) async {
try {
final client = MqttServerClient.withPort(host, clientId, config.port);
- client.keepAlivePeriod = 120; // 2 min — iOS throttles bg network, short keepalive causes drops
- client.autoReconnect = false; // Don't auto-reconnect during trial — enable after success
+ client.keepAlivePeriod =
+ 120; // 2 min — iOS throttles bg network, short keepalive causes drops
+ client.autoReconnect =
+ false; // Don't auto-reconnect during trial — enable after success
client.connectTimeoutPeriod = timeout;
// client.maxConnectionAttempts is final — can't set it
client.logging(on: false);
@@ -440,7 +509,9 @@
// Set _client BEFORE connect() so _onConnected can subscribe
_client = client;
- _mqttLog('MQTT: connecting to $host:${config.port} as $clientId (timeout=${timeout}ms)');
+ _mqttLog(
+ 'MQTT: connecting to $host:${config.port} as $clientId (timeout=${timeout}ms)',
+ );
final result = await client.connect().timeout(
Duration(milliseconds: timeout + 1000),
onTimeout: () {
@@ -453,6 +524,8 @@
// Don't use autoReconnect — it has no backoff and causes tight reconnect loops.
// We handle reconnection manually in _onDisconnected with exponential backoff.
_reconnectAttempt = 0;
+ connectedHost = host;
+ _saveLastGoodHost(host); // remember for a fast reconnect after restart
return true;
}
_client = null;
@@ -471,12 +544,17 @@
// STABLE for 10+ seconds. This prevents flap loops where each brief connect
// resets the backoff and we hammer the server every 5s forever.
_stabilityTimer?.cancel();
- _stabilityTimer = Timer(const Duration(milliseconds: _stabilityThresholdMs), () {
- if (_status == ConnectionStatus.connected) {
- _mqttLog('MQTT: connection stable for ${_stabilityThresholdMs}ms — resetting backoff');
- _reconnectAttempt = 0;
- }
- });
+ _stabilityTimer = Timer(
+ const Duration(milliseconds: _stabilityThresholdMs),
+ () {
+ if (_status == ConnectionStatus.connected) {
+ _mqttLog(
+ 'MQTT: connection stable for ${_stabilityThresholdMs}ms — resetting backoff',
+ );
+ _reconnectAttempt = 0;
+ }
+ },
+ );
_setStatus(ConnectionStatus.connected);
_subscribe();
_listenMessages();
@@ -501,9 +579,14 @@
void _scheduleReconnect() {
_reconnectTimer?.cancel();
// Exponential backoff: 1s, 2s, 4s, 8s, 16s, 30s cap
- final delayMs = (1000 * (1 << _reconnectAttempt)).clamp(1000, _maxReconnectDelay);
+ final delayMs = (1000 * (1 << _reconnectAttempt)).clamp(
+ 1000,
+ _maxReconnectDelay,
+ );
_reconnectAttempt++;
- _mqttLog('MQTT: scheduling reconnect in ${delayMs}ms (attempt $_reconnectAttempt)');
+ _mqttLog(
+ 'MQTT: scheduling reconnect in ${delayMs}ms (attempt $_reconnectAttempt)',
+ );
_reconnectTimer = Timer(Duration(milliseconds: delayMs), () async {
if (_intentionalClose || _status == ConnectionStatus.connected) return;
final host = connectedHost ?? _lastDiscoveredHost;
@@ -675,7 +758,9 @@
/// Publish raw bytes to a topic. Used by TraceService for log streaming.
void publishRaw(String topic, Uint8Buffer payload, MqttQos qos) {
final client = _client;
- if (client == null || client.connectionStatus?.state != MqttConnectionState.connected) return;
+ if (client == null ||
+ client.connectionStatus?.state != MqttConnectionState.connected)
+ return;
try {
client.publishMessage(topic, qos, payload);
} catch (_) {}
@@ -684,7 +769,8 @@
/// Publish a JSON payload to an MQTT topic.
void _publish(String topic, Map<String, dynamic> payload, MqttQos qos) {
final client = _client;
- if (client == null || client.connectionStatus?.state != MqttConnectionState.connected) {
+ if (client == null ||
+ client.connectionStatus?.state != MqttConnectionState.connected) {
onError?.call('Not connected');
return;
}
@@ -718,7 +804,9 @@
if (platform != null) 'platform': platform,
'ts': DateTime.now().millisecondsSinceEpoch,
}, MqttQos.atLeastOnce);
- _mqttLog('debug_state_response sent for requestId=$requestId sessions=${sessions.length}');
+ _mqttLog(
+ 'debug_state_response sent for requestId=$requestId sessions=${sessions.length}',
+ );
}
/// Send a message — routes to the appropriate MQTT topic based on content.
@@ -774,8 +862,10 @@
'type': 'bundle',
'sessionId': sessionId,
'caption': message['caption'] ?? '',
- if (message['audioBase64'] != null) 'audioBase64': message['audioBase64'],
- if (message['voiceMessageId'] != null) 'voiceMessageId': message['voiceMessageId'],
+ if (message['audioBase64'] != null)
+ 'audioBase64': message['audioBase64'],
+ if (message['voiceMessageId'] != null)
+ 'voiceMessageId': message['voiceMessageId'],
'attachments': message['attachments'] ?? [],
'ts': _now(),
}, MqttQos.atLeastOnce);
@@ -830,13 +920,20 @@
/// no MQTT clients are connected (app is backgrounded or offline).
void sendDeviceToken(String token) {
final client = _client;
- if (client == null || client.connectionStatus?.state != MqttConnectionState.connected) {
+ if (client == null ||
+ client.connectionStatus?.state != MqttConnectionState.connected) {
return;
}
try {
final builder = MqttClientPayloadBuilder();
- builder.addString('{"token":"$token","ts":${DateTime.now().millisecondsSinceEpoch}}');
- client.publishMessage('pailot/device/token', MqttQos.atLeastOnce, builder.payload!);
+ builder.addString(
+ '{"token":"$token","ts":${DateTime.now().millisecondsSinceEpoch}}',
+ );
+ client.publishMessage(
+ 'pailot/device/token',
+ MqttQos.atLeastOnce,
+ builder.payload!,
+ );
_mqttLog('Push: device token published to pailot/device/token');
} catch (e) {
_mqttLog('Push: failed to publish device token: $e');
--
Gitblit v1.3.1