@@ -66,6 +66,15 @@ class ExternalGpsRepository @Inject constructor(
6666 @Volatile private var mapY: String = " Y"
6767 @Volatile private var mapZ: String = " Z"
6868
69+ /* *
70+ * Tracks whether the user has explicitly disconnected this session. While
71+ * set, the auto-reconnect loop stops attempting to re-establish a
72+ * connection so the rider's deliberate action sticks until they actively
73+ * reconnect (from the Integration settings screen). Resets to false on
74+ * every app launch so a paired device naturally reconnects on boot.
75+ */
76+ @Volatile private var explicitlyDisconnected: Boolean = false
77+
6978 init {
7079 // Bridge the connection manager's sample stream into a StateFlow so the
7180 // dashboard can render the latest fix without subscribing to a hot
@@ -102,6 +111,47 @@ class ExternalGpsRepository @Inject constructor(
102111 }
103112 }
104113
114+ // Auto-reconnect loop. Watches the underlying GATT connection state
115+ // and re-issues a connect whenever we transition to DISCONNECTED with
116+ // a paired device, unless the rider explicitly disconnected this
117+ // session. Doubles as the "connect on boot" path: on app start the
118+ // state begins as DISCONNECTED, so a paired device gets a single
119+ // attempt with no backoff almost immediately.
120+ scope.launch {
121+ var failedAttempts = 0
122+ connectionManager.connectionState.collect { state ->
123+ when (state) {
124+ ConnectionState .CONNECTED -> {
125+ failedAttempts = 0
126+ }
127+ ConnectionState .DISCONNECTED -> {
128+ val paired = settingsRepository.get().externalGpsAddress != null
129+ if (! paired || explicitlyDisconnected) return @collect
130+ // Backoff: 2 / 5 / 10 / 30 s, then 30 s forever. Keeps
131+ // the radio quiet when the box is out of range while
132+ // still recovering quickly from a single-frame drop.
133+ val delayMs = when (failedAttempts) {
134+ 0 -> 1_500L
135+ 1 -> 5_000L
136+ 2 -> 10_000L
137+ else -> 30_000L
138+ }
139+ Log .i(TAG , " Auto-reconnect attempt ${failedAttempts + 1 } after ${delayMs} ms" )
140+ kotlinx.coroutines.delay(delayMs)
141+ // Re-check after the backoff: rider might have
142+ // disconnected/unpaired during the wait.
143+ val recheck = settingsRepository.get().externalGpsAddress != null
144+ if (! recheck || explicitlyDisconnected) return @collect
145+ if (connectionState.value == ConnectionState .DISCONNECTED ) {
146+ failedAttempts + = 1
147+ connectPaired()
148+ }
149+ }
150+ else -> { /* CONNECTING / INITIALIZING — let it complete */ }
151+ }
152+ }
153+ }
154+
105155 /*
106156 // TEMP DEMO: synthesize a RaceBox session so the UI can be reviewed
107157 // without hardware. Auto-pairs a fake device, marks the connection
@@ -219,11 +269,15 @@ class ExternalGpsRepository @Inject constructor(
219269 Log .w(TAG , " No adapter for paired source $source — skipping connect" )
220270 return false
221271 }
272+ // Any explicit connect call clears the user-disconnect veto so the
273+ // auto-reconnect loop resumes.
274+ explicitlyDisconnected = false
222275 connectionManager.connect(address, adapter, buildInitWrites(adapter))
223276 return true
224277 }
225278
226279 fun connect (address : String , adapter : ExternalGpsAdapter ) {
280+ explicitlyDisconnected = false
227281 connectionManager.connect(address, adapter, buildInitWrites(adapter))
228282 }
229283
@@ -248,6 +302,10 @@ class ExternalGpsRepository @Inject constructor(
248302 }
249303
250304 fun disconnect () {
305+ // User-initiated disconnect: veto the auto-reconnect loop so the
306+ // rider's tap on "Disconnect" actually sticks. They reset it by
307+ // tapping Reconnect / pairing.
308+ explicitlyDisconnected = true
251309 _currentSample .value = null
252310 connectionManager.disconnect()
253311 }
0 commit comments