Skip to content

Commit 6af41d2

Browse files
committed
Address copilot feedbacks
1 parent 3120f52 commit 6af41d2

2 files changed

Lines changed: 89 additions & 18 deletions

File tree

app/src/main/kotlin/io/homeassistant/companion/android/frontend/improv/FrontendImprovOrchestrator.kt

Lines changed: 38 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -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
5353
internal 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
}

app/src/test/kotlin/io/homeassistant/companion/android/frontend/improv/FrontendImprovOrchestratorTest.kt

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,32 @@ class FrontendImprovOrchestratorTest {
194194
job.cancel()
195195
}
196196

197+
@Test
198+
fun `Given scan collector cancelled and resubscribed while session still active then previously sent device names are not re-sent`() = runTest {
199+
val orchestrator = createOrchestrator()
200+
orchestrator.onStartImprovScan()
201+
202+
// First lifecycle-bound collect — simulates the foreground RESUMED effect.
203+
val firstJob = launch { orchestrator.processImprovScanRequests() }
204+
advanceUntilIdle()
205+
scanFlow.emit(listOf(ImprovDevice("Smart Plug", "AA:BB")))
206+
advanceUntilIdle()
207+
coVerify(exactly = 1) { externalBusRepository.send(any<OutgoingExternalBusMessage>()) }
208+
209+
// Simulate app backgrounding — lifecycle cancels processImprovScanRequests. The session
210+
// is still active (no onDismissed, _scanRequested stays true).
211+
firstJob.cancel()
212+
213+
// Foreground again: the lifecycle effect re-runs processImprovScanRequests. The repository's
214+
// scanFlow still holds the previously discovered device (replay semantics), so without
215+
// session-level dedup the orchestrator re-sends it to the frontend.
216+
val secondJob = launch { orchestrator.processImprovScanRequests() }
217+
advanceUntilIdle()
218+
219+
coVerify(exactly = 1) { externalBusRepository.send(any<OutgoingExternalBusMessage>()) }
220+
secondJob.cancel()
221+
}
222+
197223
@Test
198224
fun `Given onConfigureImprovDevice when handled then uiState is initialised to SearchingDevice`() = runTest {
199225
val orchestrator = createOrchestrator()
@@ -265,6 +291,31 @@ class FrontendImprovOrchestratorTest {
265291
provisioningScope.cancel()
266292
}
267293

294+
@Test
295+
fun `Given Errored state when late Provisioned event arrives then device_setup_done is not sent`() = runTest {
296+
val orchestrator = createOrchestrator()
297+
val scanJob = configureWithResolvedAddress(orchestrator)
298+
val provisioningScope = CoroutineScope(coroutineContext + Job())
299+
orchestrator.onConnectDevice(scope = provisioningScope, ssid = "wifi", password = "pwd")
300+
advanceUntilIdle()
301+
302+
// First an error transitions UI to Errored.
303+
provisionFlow.emit(ProvisioningEvent.ErrorOccurred(ErrorState.UNABLE_TO_CONNECT))
304+
advanceUntilIdle()
305+
assertInstanceOf(ImprovUIState.Errored::class.java, orchestrator.uiState.value)
306+
307+
// Then a late Provisioned event from the BLE stack. The UI must stay Errored AND no
308+
// setup-done message should be forwarded — otherwise the frontend would think setup
309+
// succeeded while the sheet still shows the error.
310+
provisionFlow.emit(ProvisioningEvent.Provisioned(domain = "acme"))
311+
advanceUntilIdle()
312+
313+
assertInstanceOf(ImprovUIState.Errored::class.java, orchestrator.uiState.value)
314+
coVerify(exactly = 0) { externalBusRepository.send(ImprovDeviceSetupDoneMessage) }
315+
scanJob.cancel()
316+
provisioningScope.cancel()
317+
}
318+
268319
@Test
269320
fun `Given onConnectDevice while still SearchingDevice then provisionDevice is not called`() = runTest {
270321
val orchestrator = createOrchestrator()

0 commit comments

Comments
 (0)