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