From 7c2c56e2018913277d49b4be454b61238b510791 Mon Sep 17 00:00:00 2001 From: Timothy <6560631+TimoPtr@users.noreply.github.com> Date: Wed, 8 Apr 2026 13:25:13 +0200 Subject: [PATCH 1/2] UrlFlow and errorFlow are eagerly started with viewState value --- .../android/frontend/FrontendViewModel.kt | 9 +--- .../android/frontend/FrontendViewModelTest.kt | 51 +++++++++++++------ 2 files changed, 37 insertions(+), 23 deletions(-) diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/frontend/FrontendViewModel.kt b/app/src/main/kotlin/io/homeassistant/companion/android/frontend/FrontendViewModel.kt index 9f5ebf4ef34..b51df39e16d 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/frontend/FrontendViewModel.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/frontend/FrontendViewModel.kt @@ -24,7 +24,6 @@ import io.homeassistant.companion.android.util.HAWebChromeClient import io.homeassistant.companion.android.util.HAWebViewClient import io.homeassistant.companion.android.util.HAWebViewClientFactory import javax.inject.Inject -import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds import kotlinx.coroutines.ExperimentalForInheritanceCoroutinesApi import kotlinx.coroutines.Job @@ -35,7 +34,6 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.WhileSubscribed import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.collectLatest @@ -50,9 +48,6 @@ import timber.log.Timber @VisibleForTesting val CONNECTION_TIMEOUT = 10.seconds -/** Delay before stopping shared flows after the last subscriber disconnects. */ -private val SUBSCRIPTION_STOP_DELAY = 500.milliseconds - /** * ViewModel for frontend screen. * @@ -144,12 +139,12 @@ internal class FrontendViewModel @VisibleForTesting constructor( override val urlFlow: StateFlow = _viewState.map { it.url } .distinctUntilChanged() - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(SUBSCRIPTION_STOP_DELAY), null) + .stateIn(viewModelScope, SharingStarted.Eagerly, _viewState.value.url) override val errorFlow: StateFlow = _viewState.map { state -> (state as? FrontendViewState.Error)?.error } .distinctUntilChanged() - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(SUBSCRIPTION_STOP_DELAY), null) + .stateIn(viewModelScope, SharingStarted.Eagerly, (_viewState.value as? FrontendViewState.Error)?.error) /** Flow of scripts to be evaluated in the WebView. */ val scriptsToEvaluate: Flow = frontendMessageHandler.scriptsToEvaluate() diff --git a/app/src/test/kotlin/io/homeassistant/companion/android/frontend/FrontendViewModelTest.kt b/app/src/test/kotlin/io/homeassistant/companion/android/frontend/FrontendViewModelTest.kt index ebe2340cd24..12025c5c64e 100644 --- a/app/src/test/kotlin/io/homeassistant/companion/android/frontend/FrontendViewModelTest.kt +++ b/app/src/test/kotlin/io/homeassistant/companion/android/frontend/FrontendViewModelTest.kt @@ -275,24 +275,49 @@ class FrontendViewModelTest { } @Nested - inner class ErrorFlow { + inner class DerivedFlows { @Test - fun `Given loading state when error occurs then error flow is updated`() = runTest { + fun `Given url manager returns success when urlFlow is subscribed then urlFlow value matches viewState url`() = runTest { + every { urlManager.serverUrlFlow(any(), any()) } returns flowOf( + UrlLoadResult.Success(url = testUrlWithAuth, serverId = serverId), + ) + + val viewModel = createViewModel() + + val job = backgroundScope.launch { viewModel.urlFlow.collect {} } + advanceTimeBy(CONNECTION_TIMEOUT - 1.seconds) + + assertEquals(testUrlWithAuth, viewModel.urlFlow.value) + job.cancel() + } + + @Test + fun `Given error state then errorFlow value matches viewState error`() = runTest { every { urlManager.serverUrlFlow(any(), any()) } returns flowOf( UrlLoadResult.ServerNotFound(serverId), ) val viewModel = createViewModel() + advanceUntilIdle() - // Subscribe to errorFlow to activate the stateIn with WhileSubscribed - val errors = mutableListOf() - val job = backgroundScope.launch { viewModel.errorFlow.collect { errors.add(it) } } + assertTrue(viewModel.errorFlow.value is FrontendConnectionError.UnreachableError) + } + } + + @Nested + inner class ErrorFlow { + @Test + fun `Given loading state when error occurs then error flow is updated`() = runTest { + every { urlManager.serverUrlFlow(any(), any()) } returns flowOf( + UrlLoadResult.ServerNotFound(serverId), + ) + + val viewModel = createViewModel() advanceUntilIdle() - assertTrue(errors.any { it is FrontendConnectionError.UnreachableError }) - job.cancel() + assertTrue(viewModel.errorFlow.value is FrontendConnectionError.UnreachableError) } @Test @@ -301,15 +326,10 @@ class FrontendViewModelTest { every { urlManager.serverUrlFlow(any(), any()) } returns urlResults val viewModel = createViewModel() - - // Subscribe to errorFlow to activate the stateIn with WhileSubscribed - val errors = mutableListOf() - val job = backgroundScope.launch { viewModel.errorFlow.collect { errors.add(it) } } - advanceUntilIdle() // Verify error exists - assertTrue(errors.any { it != null }) + assertTrue(viewModel.errorFlow.value != null) // Setup successful response for retry urlResults.value = UrlLoadResult.Success(url = testUrlWithAuth, serverId = serverId) @@ -317,9 +337,8 @@ class FrontendViewModelTest { viewModel.onRetry() advanceTimeBy(CONNECTION_TIMEOUT - 1.seconds) - // Error should be cleared - the last emitted error should be null - assertTrue(errors.last() == null) - job.cancel() + // Error should be cleared + assertEquals(null, viewModel.errorFlow.value) } @Test From 9c2bd880f2cdfb0686158875588730fb1dbb7a40 Mon Sep 17 00:00:00 2001 From: Timothy <6560631+TimoPtr@users.noreply.github.com> Date: Wed, 8 Apr 2026 14:19:50 +0200 Subject: [PATCH 2/2] Apply suggestion --- .../companion/android/frontend/FrontendViewModelTest.kt | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/app/src/test/kotlin/io/homeassistant/companion/android/frontend/FrontendViewModelTest.kt b/app/src/test/kotlin/io/homeassistant/companion/android/frontend/FrontendViewModelTest.kt index 12025c5c64e..26457edafeb 100644 --- a/app/src/test/kotlin/io/homeassistant/companion/android/frontend/FrontendViewModelTest.kt +++ b/app/src/test/kotlin/io/homeassistant/companion/android/frontend/FrontendViewModelTest.kt @@ -278,22 +278,20 @@ class FrontendViewModelTest { inner class DerivedFlows { @Test - fun `Given url manager returns success when urlFlow is subscribed then urlFlow value matches viewState url`() = runTest { + fun `Given url manager returns success then urlFlow value matches viewState url without subscribers`() = runTest { every { urlManager.serverUrlFlow(any(), any()) } returns flowOf( UrlLoadResult.Success(url = testUrlWithAuth, serverId = serverId), ) val viewModel = createViewModel() - - val job = backgroundScope.launch { viewModel.urlFlow.collect {} } advanceTimeBy(CONNECTION_TIMEOUT - 1.seconds) + // No collector on urlFlow — value must still be up to date (SharingStarted.Eagerly) assertEquals(testUrlWithAuth, viewModel.urlFlow.value) - job.cancel() } @Test - fun `Given error state then errorFlow value matches viewState error`() = runTest { + fun `Given error state then errorFlow value matches viewState error without subscribers`() = runTest { every { urlManager.serverUrlFlow(any(), any()) } returns flowOf( UrlLoadResult.ServerNotFound(serverId), )