@@ -189,6 +189,17 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver {
189189 bool _isSwitchingMode = false ;
190190 String ? _modeSwitchError; // Error message if mode switch fails
191191
192+ // Auto-reconnect state
193+ bool _userRequestedDisconnect = false ;
194+ bool _isAutoReconnecting = false ;
195+ int _reconnectAttempt = 0 ;
196+ Timer ? _reconnectTimer;
197+ Timer ? _reconnectTimeoutTimer;
198+ bool _autoPingWasEnabled = false ;
199+ AutoMode _autoModeBeforeReconnect = AutoMode .active;
200+ static const int _maxReconnectAttempts = 3 ;
201+ static const Duration _reconnectDelay = Duration (seconds: 3 );
202+
192203 // Map navigation trigger (for navigating to log entry coordinates)
193204 ({double lat, double lon})? _mapNavigationTarget;
194205 int _mapNavigationTrigger = 0 ; // Increment to trigger navigation
@@ -307,6 +318,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver {
307318 bool get isSwitchingMode => _isSwitchingMode;
308319 String ? get modeSwitchError => _modeSwitchError;
309320
321+ // Auto-reconnect getters
322+ bool get isAutoReconnecting => _isAutoReconnecting;
323+ int get reconnectAttempt => _reconnectAttempt;
324+
310325 // Repeater markers getters
311326 List <Repeater > get repeaters => List .unmodifiable (_repeaters);
312327
@@ -477,61 +492,21 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver {
477492 _bluetoothService.connectionStream.listen ((status) async {
478493 _connectionStatus = status;
479494 if (status == ConnectionStatus .disconnected) {
480- _connectionStep = ConnectionStep .disconnected;
481-
482- // Stop heartbeat immediately on BLE disconnect
483- _apiService.disableHeartbeat ();
484- debugLog ('[CONN] Heartbeat disabled due to BLE disconnect' );
485-
486- // Stop auto-ping timers
487- _autoPingTimer.stop ();
488- _rxWindowTimer.stop ();
489- _cooldownTimer.stop ();
490- if (_autoPingEnabled) {
491- _autoPingEnabled = false ;
492- debugLog ('[AUTO] Auto-ping disabled due to BLE disconnect' );
493- }
494-
495- // End noise floor session on BLE disconnect
496- await _endNoiseFloorSession ();
497-
498- // Stop RX logger
499- _rxLogger? .stopWardriving (trigger: 'ble_disconnect' );
500-
501- // Force upload any pending items BEFORE releasing session
502- // This ensures data is submitted while session is still valid
503- // Waits for TX items in hold period (up to 6 seconds) to become eligible
504- if (_apiService.hasSession) {
505- debugLog ('[CONN] Flushing API queue before session release' );
506- try {
507- await _apiQueueService.forceUploadWithHoldWait ();
508- } catch (e) {
509- debugError ('[CONN] Failed to flush API queue: $e ' );
510- }
511- }
512-
513- // Clear any remaining items and stop batch timer
514- await _apiQueueService.clearOnDisconnect ();
515-
516- // Release API session (best effort - don't block on failure)
517- if (_devicePublicKey != null && _apiService.hasSession) {
518- debugLog ('[CONN] Releasing API session due to BLE disconnect' );
519- try {
520- await _apiService.requestAuth (
521- reason: 'disconnect' ,
522- publicKey: _devicePublicKey! ,
523- );
524- debugLog ('[CONN] API session released successfully' );
525- } catch (e) {
526- debugError ('[CONN] Failed to release API session: $e ' );
527- }
495+ // Check if this is an unexpected disconnect during active wardriving
496+ final wasConnected = _connectionStep == ConnectionStep .connected;
497+ final hasRemembered = _rememberedDevice != null ;
498+ final isUnexpected = ! _userRequestedDisconnect && ! _isAutoReconnecting;
499+
500+ if (wasConnected && hasRemembered && isUnexpected && ! kIsWeb) {
501+ debugLog ('[CONN] Unexpected BLE disconnect detected - starting auto-reconnect' );
502+ await _startAutoReconnect ();
503+ } else if (! _isAutoReconnecting) {
504+ // Normal disconnect (user-requested or no remembered device)
505+ await _fullDisconnectCleanup ();
506+ } else {
507+ // Disconnected during a reconnect attempt - _attemptReconnect handles retry
508+ debugLog ('[CONN] BLE disconnect during reconnect attempt - will retry' );
528509 }
529-
530- // Existing cleanup
531- _meshCoreConnection? .dispose ();
532- _meshCoreConnection = null ;
533- _pingService? .dispose ();
534- _pingService = null ;
535510 }
536511 notifyListeners ();
537512 });
@@ -1571,8 +1546,237 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver {
15711546 debugLog ('[APP] Unified RX handler created and listening' );
15721547 }
15731548
1549+ /// Full disconnect cleanup - called on normal BLE disconnect (user-requested or no remembered device)
1550+ /// Extracted from the original BLE disconnect listener
1551+ Future <void > _fullDisconnectCleanup () async {
1552+ _connectionStep = ConnectionStep .disconnected;
1553+
1554+ // Stop heartbeat immediately on BLE disconnect
1555+ _apiService.disableHeartbeat ();
1556+ debugLog ('[CONN] Heartbeat disabled due to BLE disconnect' );
1557+
1558+ // Stop auto-ping timers
1559+ _autoPingTimer.stop ();
1560+ _rxWindowTimer.stop ();
1561+ _cooldownTimer.stop ();
1562+ if (_autoPingEnabled) {
1563+ _autoPingEnabled = false ;
1564+ debugLog ('[AUTO] Auto-ping disabled due to BLE disconnect' );
1565+ }
1566+
1567+ // End noise floor session on BLE disconnect
1568+ await _endNoiseFloorSession ();
1569+
1570+ // Stop RX logger
1571+ _rxLogger? .stopWardriving (trigger: 'ble_disconnect' );
1572+
1573+ // Force upload any pending items BEFORE releasing session
1574+ if (_apiService.hasSession) {
1575+ debugLog ('[CONN] Flushing API queue before session release' );
1576+ try {
1577+ await _apiQueueService.forceUploadWithHoldWait ();
1578+ } catch (e) {
1579+ debugError ('[CONN] Failed to flush API queue: $e ' );
1580+ }
1581+ }
1582+
1583+ // Clear any remaining items and stop batch timer
1584+ await _apiQueueService.clearOnDisconnect ();
1585+
1586+ // Release API session (best effort - don't block on failure)
1587+ if (_devicePublicKey != null && _apiService.hasSession) {
1588+ debugLog ('[CONN] Releasing API session due to BLE disconnect' );
1589+ try {
1590+ await _apiService.requestAuth (
1591+ reason: 'disconnect' ,
1592+ publicKey: _devicePublicKey! ,
1593+ );
1594+ debugLog ('[CONN] API session released successfully' );
1595+ } catch (e) {
1596+ debugError ('[CONN] Failed to release API session: $e ' );
1597+ }
1598+ }
1599+
1600+ // Existing cleanup
1601+ _meshCoreConnection? .dispose ();
1602+ _meshCoreConnection = null ;
1603+ _pingService? .dispose ();
1604+ _pingService = null ;
1605+ }
1606+
1607+ /// Start auto-reconnect after unexpected BLE disconnect
1608+ Future <void > _startAutoReconnect () async {
1609+ _isAutoReconnecting = true ;
1610+ _reconnectAttempt = 0 ;
1611+ _connectionStep = ConnectionStep .reconnecting;
1612+
1613+ // Remember auto-ping state before cleanup
1614+ _autoPingWasEnabled = _autoPingEnabled;
1615+ _autoModeBeforeReconnect = _autoMode;
1616+
1617+ // Stop auto-ping timers (don't dispose)
1618+ _autoPingTimer.stop ();
1619+ _rxWindowTimer.stop ();
1620+ _cooldownTimer.stop ();
1621+ _autoPingEnabled = false ;
1622+
1623+ // Stop heartbeat
1624+ _apiService.disableHeartbeat ();
1625+
1626+ // End noise floor session
1627+ await _endNoiseFloorSession ();
1628+
1629+ // Flush RX logger
1630+ _rxLogger? .stopWardriving (trigger: 'reconnect' );
1631+
1632+ // Stop background service
1633+ await BackgroundServiceManager .stopService ();
1634+
1635+ // Clean up dead BLE-dependent objects
1636+ _logRxDataSubscription? .cancel ();
1637+ _logRxDataSubscription = null ;
1638+ _unifiedRxHandler? .dispose ();
1639+ _unifiedRxHandler = null ;
1640+ _txTracker = null ;
1641+ _rxLogger = null ;
1642+ await _noiseFloorSubscription? .cancel ();
1643+ _noiseFloorSubscription = null ;
1644+ await _batterySubscription? .cancel ();
1645+ _batterySubscription = null ;
1646+ _meshCoreConnection? .dispose ();
1647+ _meshCoreConnection = null ;
1648+ _pingService? .dispose ();
1649+ _pingService = null ;
1650+
1651+ // Do NOT release API session or clear API queue
1652+ debugLog ('[CONN] Auto-reconnect: preserved API session, cleaned up BLE objects' );
1653+
1654+ notifyListeners ();
1655+
1656+ // Start overall timeout (30 seconds)
1657+ _reconnectTimeoutTimer = Timer (const Duration (seconds: 30 ), () {
1658+ debugLog ('[CONN] Auto-reconnect timed out after 30s' );
1659+ _abandonAutoReconnect ();
1660+ });
1661+
1662+ // Start first attempt
1663+ _attemptReconnect ();
1664+ }
1665+
1666+ /// Attempt a single reconnection
1667+ void _attemptReconnect () {
1668+ if (_reconnectAttempt >= _maxReconnectAttempts) {
1669+ debugLog ('[CONN] Auto-reconnect: max attempts reached ($_maxReconnectAttempts )' );
1670+ _abandonAutoReconnect ();
1671+ return ;
1672+ }
1673+
1674+ _reconnectAttempt++ ;
1675+ debugLog ('[CONN] Auto-reconnect attempt $_reconnectAttempt of $_maxReconnectAttempts ' );
1676+ notifyListeners ();
1677+
1678+ // Delay before attempting reconnection
1679+ _reconnectTimer = Timer (_reconnectDelay, () async {
1680+ if (! _isAutoReconnecting) return ; // Cancelled while waiting
1681+
1682+ try {
1683+ debugLog ('[CONN] Auto-reconnect: calling reconnectToRememberedDevice()' );
1684+ await reconnectToRememberedDevice ();
1685+
1686+ // If we get here and connection step is 'connected', success!
1687+ if (_connectionStep == ConnectionStep .connected) {
1688+ debugLog ('[CONN] Auto-reconnect succeeded on attempt $_reconnectAttempt ' );
1689+ _onReconnectSuccess ();
1690+ } else if (_isAutoReconnecting) {
1691+ // Connection failed but didn't throw - try again
1692+ debugLog ('[CONN] Auto-reconnect: connection did not complete, retrying...' );
1693+ _connectionStep = ConnectionStep .reconnecting;
1694+ notifyListeners ();
1695+ _attemptReconnect ();
1696+ }
1697+ } catch (e) {
1698+ debugError ('[CONN] Auto-reconnect attempt $_reconnectAttempt failed: $e ' );
1699+ if (_isAutoReconnecting) {
1700+ // Reset step back to reconnecting for UI
1701+ _connectionStep = ConnectionStep .reconnecting;
1702+ _connectionError = null ;
1703+ notifyListeners ();
1704+ _attemptReconnect ();
1705+ }
1706+ }
1707+ });
1708+ }
1709+
1710+ /// Called when auto-reconnect succeeds
1711+ void _onReconnectSuccess () {
1712+ // Cancel timers
1713+ _reconnectTimer? .cancel ();
1714+ _reconnectTimer = null ;
1715+ _reconnectTimeoutTimer? .cancel ();
1716+ _reconnectTimeoutTimer = null ;
1717+
1718+ final wasAutoPing = _autoPingWasEnabled;
1719+ final previousMode = _autoModeBeforeReconnect;
1720+
1721+ // Clear reconnect state
1722+ _isAutoReconnecting = false ;
1723+ _reconnectAttempt = 0 ;
1724+ _autoPingWasEnabled = false ;
1725+
1726+ debugLog ('[CONN] Auto-reconnect complete, restoring state (autoPing=$wasAutoPing , mode=$previousMode )' );
1727+
1728+ // Restore auto-ping if it was active
1729+ if (wasAutoPing) {
1730+ // Use a short delay to ensure connection is fully set up
1731+ Timer (const Duration (milliseconds: 500 ), () {
1732+ if (_connectionStep == ConnectionStep .connected) {
1733+ toggleAutoPing (previousMode);
1734+ debugLog ('[CONN] Auto-ping restored after reconnect (mode=$previousMode )' );
1735+ }
1736+ });
1737+ }
1738+
1739+ notifyListeners ();
1740+ }
1741+
1742+ /// Cancel auto-reconnect (called from UI cancel button)
1743+ void cancelAutoReconnect () {
1744+ debugLog ('[CONN] Auto-reconnect cancelled by user' );
1745+ _abandonAutoReconnect ();
1746+ }
1747+
1748+ /// Abandon auto-reconnect and do full cleanup
1749+ void _abandonAutoReconnect () {
1750+ // Cancel timers
1751+ _reconnectTimer? .cancel ();
1752+ _reconnectTimer = null ;
1753+ _reconnectTimeoutTimer? .cancel ();
1754+ _reconnectTimeoutTimer = null ;
1755+
1756+ // Clear reconnect state
1757+ _isAutoReconnecting = false ;
1758+ _reconnectAttempt = 0 ;
1759+ _autoPingWasEnabled = false ;
1760+
1761+ // Do full disconnect cleanup (releases API session, etc.)
1762+ _fullDisconnectCleanup ();
1763+ notifyListeners ();
1764+ }
1765+
15741766 /// Disconnect from current device
15751767 Future <void > disconnect () async {
1768+ // Mark as user-requested so BLE disconnect listener doesn't trigger auto-reconnect
1769+ _userRequestedDisconnect = true ;
1770+
1771+ // Cancel any active auto-reconnect
1772+ _reconnectTimer? .cancel ();
1773+ _reconnectTimer = null ;
1774+ _reconnectTimeoutTimer? .cancel ();
1775+ _reconnectTimeoutTimer = null ;
1776+ _isAutoReconnecting = false ;
1777+ _reconnectAttempt = 0 ;
1778+ _autoPingWasEnabled = false ;
1779+
15761780 // Disable heartbeat immediately on disconnect
15771781 _apiService.disableHeartbeat ();
15781782
@@ -1657,6 +1861,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver {
16571861 // Clear discovered devices so user must scan fresh
16581862 _discoveredDevices = [];
16591863
1864+ // Reset user-requested flag
1865+ _userRequestedDisconnect = false ;
1866+
16601867 notifyListeners ();
16611868
16621869 // Auto-exit app if preference is enabled (Android only)
0 commit comments