@@ -43,11 +43,11 @@ import timber.log.Timber
4343 * reload.
4444 *
4545 * **Concurrency contract.** Entry points are safe to call from any coroutine. Var-backed internal
46- * state ([provisioningJob], [discoveredDevices]) is guarded by [mutex]; StateFlow-backed state
47- * ([scanRequested], [uiState]) is atomic by construction and not lock-guarded. The orchestrator
48- * does not own a long-lived scope: the caller passes its scope to [onConnectDevice] for
49- * provisioning, and the UI layer hosts the scan collect through [processImprovScanRequests] so
50- * navigation away from the frontend route naturally pauses BLE work.
46+ * state ([provisioningJob], [discoveredDevices], [sentDeviceNames] ) is guarded by [mutex];
47+ * StateFlow-backed state ([scanRequested], [uiState]) is atomic by construction and not
48+ * lock-guarded. The orchestrator does not own a long-lived scope: the caller passes its scope to
49+ * [onConnectDevice] for provisioning, and the UI layer hosts the scan collect through
50+ * [processImprovScanRequests] so navigation away from the frontend route naturally pauses BLE work.
5151 */
5252@ViewModelScoped
5353internal class FrontendImprovOrchestrator @Inject constructor(
@@ -83,6 +83,17 @@ internal class FrontendImprovOrchestrator @Inject constructor(
8383 */
8484 private var discoveredDevices: List <ImprovDevice > = emptyList()
8585
86+ /* *
87+ * Device names already forwarded to the frontend during the current session (from
88+ * [onStartImprovScan] until [onDismissed]). Survives `processImprovScanRequests` restarts
89+ * caused by the UI's lifecycle-bound collect tearing down on PAUSE and re-running on RESUME —
90+ * the scan flow replays its current device list to the new collector, and re-sending those
91+ * names would surface duplicates in the frontend's device list. Cleared on [onDismissed].
92+ *
93+ * Reads and writes go through [mutex].
94+ */
95+ private val sentDeviceNames: MutableSet <String > = mutableSetOf ()
96+
8697 private val _uiState = MutableStateFlow <ImprovUIState ?>(null )
8798
8899 /* *
@@ -230,6 +241,7 @@ internal class FrontendImprovOrchestrator @Inject constructor(
230241 provisioningJob = null
231242 _scanRequested .value = false
232243 discoveredDevices = emptyList()
244+ sentDeviceNames.clear()
233245 _uiState .value = null
234246 }
235247 if (domain != null ) {
@@ -275,16 +287,24 @@ internal class FrontendImprovOrchestrator @Inject constructor(
275287 }
276288 }
277289 is ProvisioningEvent .Provisioned -> {
290+ var transitioned = false
278291 _uiState .update { current ->
279- // Guard against a Provisioned event arriving after the user dismissed the
280- // sheet — overwriting `null` with `Provisioned` would briefly resurrect it.
292+ // Guard against a Provisioned event arriving after the user dismissed,
293+ // restarted, or after an Errored event overwriting any of those would
294+ // resurrect the UI or misrepresent the outcome.
281295 if (current is ImprovUIState .Provisioning ) {
296+ transitioned = true
282297 ImprovUIState .Provisioned (domain = event.domain)
283298 } else {
299+ transitioned = false
284300 current
285301 }
286302 }
287- externalBusRepository.send(ImprovDeviceSetupDoneMessage )
303+ // Gate the frontend signal on the same condition telling the frontend setup
304+ // is done while the UI is errored/closed would be inconsistent.
305+ if (transitioned) {
306+ externalBusRepository.send(ImprovDeviceSetupDoneMessage )
307+ }
288308 }
289309 }
290310 }
@@ -295,13 +315,17 @@ internal class FrontendImprovOrchestrator @Inject constructor(
295315 * emission: mirrors the list into [discoveredDevices], promotes
296316 * [ImprovUIState.SearchingDevice] to [ImprovUIState.ConfiguringDevice] when the target
297317 * appears, and forwards each newly-seen device name to the frontend via
298- * [ImprovDiscoveredDeviceMessage] — deduped per call so a single scan session never sends
299- * duplicates .
318+ * [ImprovDiscoveredDeviceMessage] — deduped against [sentDeviceNames] so the frontend sees
319+ * each name exactly once per session (across collector restarts) .
300320 */
301321 private suspend fun forwardDiscoveredDevices () {
302- val sentNames = mutableSetOf<String >()
303322 improvRepository.scanDevices().collect { devices ->
304- mutex.withLock { discoveredDevices = devices }
323+ val namesToForward = mutex.withLock {
324+ discoveredDevices = devices
325+ // Compute the diff inside the lock so it's atomic with the [sentDeviceNames]
326+ // update; emit outside the lock so the suspending [send] doesn't hold the mutex.
327+ devices.mapNotNull { it.name }.filter { sentDeviceNames.add(it) }
328+ }
305329 // Promote SearchingDevice → ConfiguringDevice as soon as the scan resolves the BLE
306330 // address. Once we leave SearchingDevice, late scan emissions must not overwrite the
307331 // user-driven transitions (Provisioning, Errored, …).
@@ -317,12 +341,8 @@ internal class FrontendImprovOrchestrator @Inject constructor(
317341 current
318342 }
319343 }
320- // Forward each newly-seen device name to the frontend exactly once per session.
321- devices.forEach { device ->
322- val name = device.name ? : return @forEach
323- if (sentNames.add(name)) {
324- externalBusRepository.send(ImprovDiscoveredDeviceMessage (name = name))
325- }
344+ namesToForward.forEach { name ->
345+ externalBusRepository.send(ImprovDiscoveredDeviceMessage (name = name))
326346 }
327347 }
328348 }
0 commit comments