From cda5ac96c4802f7c33f8f0099a8c9c34423dde4a Mon Sep 17 00:00:00 2001
From: Matthias Nott <mnott@mnsoft.org>
Date: Sat, 11 Apr 2026 09:27:07 +0200
Subject: [PATCH] feat: PDF and document file viewing support

---
 lib/services/mqtt_service.dart |   72 ++++++++++++++++++++++++++++++++----
 1 files changed, 64 insertions(+), 8 deletions(-)

diff --git a/lib/services/mqtt_service.dart b/lib/services/mqtt_service.dart
index 0c0502e..0718063 100644
--- a/lib/services/mqtt_service.dart
+++ b/lib/services/mqtt_service.dart
@@ -68,6 +68,13 @@
   // (Per-session subscriptions removed — single pailot/out topic now)
   static const int _maxSeenIds = 500;
 
+  // Reconnect backoff
+  Timer? _reconnectTimer;
+  Timer? _stabilityTimer;
+  int _reconnectAttempt = 0;
+  static const int _maxReconnectDelay = 30000; // 30s cap
+  static const int _stabilityThresholdMs = 10000; // 10s stable = reset backoff
+
   // Callbacks
   void Function(ConnectionStatus status)? onStatusChanged;
   void Function(String detail)? onStatusDetail; // "Probing local...", "Scanning network..."
@@ -120,16 +127,16 @@
   }
 
   /// Fast reconnect to a known host — skips discovery, short timeout.
-  Future<void> _fastReconnect(String host) async {
+  /// Returns true if connected, false if failed.
+  Future<bool> _fastReconnect(String host) async {
     _mqttLog('MQTT: fast reconnect to $host');
     final clientId = await _getClientId();
     if (await _tryConnect(host, clientId, timeout: 2000)) {
       connectedHost = host;
-      return;
+      return true;
     }
-    // Fast path failed — fall back to full connect
-    _mqttLog('MQTT: fast reconnect failed, full connect...');
-    connect();
+    _mqttLog('MQTT: fast reconnect failed');
+    return false;
   }
 
   /// Connect to the MQTT broker.
@@ -440,7 +447,9 @@
       );
       _mqttLog('MQTT: connect result=${result?.state}');
       if (result?.state == MqttConnectionState.connected) {
-        client.autoReconnect = true;
+        // Don't use autoReconnect — it has no backoff and causes tight reconnect loops.
+        // We handle reconnection manually in _onDisconnected with exponential backoff.
+        _reconnectAttempt = 0;
         return true;
       }
       _client = null;
@@ -454,6 +463,17 @@
 
   void _onConnected() {
     _mqttLog('MQTT: _onConnected fired');
+    _reconnectTimer?.cancel();
+    // Don't reset _reconnectAttempt here — only after the connection has been
+    // 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;
+      }
+    });
     _setStatus(ConnectionStatus.connected);
     _subscribe();
     _listenMessages();
@@ -461,6 +481,7 @@
   }
 
   void _onDisconnected() {
+    _stabilityTimer?.cancel();
     _updatesSub?.cancel();
     _updatesSub = null;
 
@@ -470,15 +491,41 @@
     } else {
       _setStatus(ConnectionStatus.reconnecting);
       onReconnecting?.call();
+      _scheduleReconnect();
     }
   }
 
+  void _scheduleReconnect() {
+    _reconnectTimer?.cancel();
+    // Exponential backoff: 1s, 2s, 4s, 8s, 16s, 30s cap
+    final delayMs = (1000 * (1 << _reconnectAttempt)).clamp(1000, _maxReconnectDelay);
+    _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;
+      if (host != null) {
+        _mqttLog('MQTT: reconnect attempt $_reconnectAttempt to $host');
+        final ok = await _fastReconnect(host);
+        if (!ok && !_intentionalClose) {
+          _scheduleReconnect(); // Try again with increased backoff
+        }
+      } else {
+        _mqttLog('MQTT: no known host, running full connect');
+        await connect();
+      }
+    });
+  }
+
   void _onAutoReconnect() {
+    // Unused — autoReconnect is disabled, but keep callback for safety
     _setStatus(ConnectionStatus.reconnecting);
     onReconnecting?.call();
   }
 
   void _onAutoReconnected() {
+    // Unused — autoReconnect is disabled, but keep callback for safety
+    _reconnectAttempt = 0;
     _setStatus(ConnectionStatus.connected);
     _subscribe();
     _listenMessages();
@@ -764,6 +811,11 @@
   /// Disconnect intentionally.
   void disconnect() {
     _intentionalClose = true;
+    _reconnectTimer?.cancel();
+    _reconnectTimer = null;
+    _stabilityTimer?.cancel();
+    _stabilityTimer = null;
+    _reconnectAttempt = 0;
     _updatesSub?.cancel();
     _updatesSub = null;
     _connectivitySub?.cancel();
@@ -799,8 +851,12 @@
       case AppLifecycleState.resumed:
         if (_intentionalClose) break;
         _mqttLog('MQTT: app resumed');
-        // Let autoReconnect handle dead connections (keepalive timeout).
-        // Just trigger catch_up to fetch missed messages and rebuild UI.
+        // If disconnected, trigger immediate reconnect (reset backoff).
+        if (_status != ConnectionStatus.connected) {
+          _reconnectAttempt = 0;
+          _scheduleReconnect();
+        }
+        // Trigger catch_up to fetch missed messages and rebuild UI.
         onResume?.call();
       case AppLifecycleState.paused:
         break;

--
Gitblit v1.3.1