From c306d6bec95c4af54f9d118e146b12588714468f Mon Sep 17 00:00:00 2001 From: Timothy <6560631+TimoPtr@users.noreply.github.com> Date: Mon, 4 May 2026 14:29:28 +0200 Subject: [PATCH 1/5] Support for auto play video in FrontendScreen --- .../android/frontend/FrontendScreen.kt | 28 +++++++++- .../android/frontend/FrontendViewModel.kt | 14 +++++ .../android/frontend/FrontendViewState.kt | 1 + .../android/frontend/FrontendViewModelTest.kt | 56 +++++++++++++++++++ .../common/data/prefs/PrefsRepository.kt | 3 + .../common/data/prefs/PrefsRepositoryImpl.kt | 36 ++++++------ .../data/prefs/PrefsRepositoryImplTest.kt | 29 ++++++++++ 7 files changed, 148 insertions(+), 19 deletions(-) diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/frontend/FrontendScreen.kt b/app/src/main/kotlin/io/homeassistant/companion/android/frontend/FrontendScreen.kt index fa87e741598..e67dbeabda1 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/frontend/FrontendScreen.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/frontend/FrontendScreen.kt @@ -183,6 +183,7 @@ internal fun FrontendScreenContent( url = viewState.url, frontendJsCallback = frontendJsCallback, webViewActions = webViewActions, + autoPlayVideoEnabled = (viewState as? FrontendViewState.Content)?.autoPlayVideoEnabled, ) PendingPermissionHandler( @@ -432,6 +433,7 @@ private fun SafeHAWebView( onWebViewCreated = onWebViewCreated, onDownloadRequested = onDownloadRequested, onGesture = onGesture, + autoPlayVideoEnabled = contentState?.autoPlayVideoEnabled ?: false, ) }, onBackPressed = onBackClick, @@ -479,6 +481,7 @@ private fun WebView.configureForFrontend( onWebViewCreated: (WebView) -> Unit, onDownloadRequested: (url: String, contentDisposition: String, mimetype: String) -> Unit, onGesture: (GestureDirection, Int) -> Unit, + autoPlayVideoEnabled: Boolean, ) { onWebViewCreated(this) @@ -486,6 +489,8 @@ private fun WebView.configureForFrontend( webChromeClient?.let { this.webChromeClient = it } + settings.mediaPlaybackRequiresUserGesture = !autoPlayVideoEnabled + // Enable first-party cookies globally and third-party cookies for this WebView. // The Home Assistant frontend relies on third-party cookies for some integrations // (e.g. embedded content served from a different origin). @@ -556,7 +561,12 @@ private fun PendingPermissionHandler(pendingRequest: PermissionRequest?) { } /** - * Handles WebView side effects: URL loading and [WebViewAction] dispatch. + * Handles WebView side effects: URL loading, [WebViewAction] dispatch, and reapplying the + * "Autoplay video" preference (which requires a [WebView.reload] to take effect on the loaded page). + * + * @param autoPlayVideoEnabled Latest value from `FrontendViewState.Content`, or `null` when the + * current state is not Content. The last non-null value is remembered across non-Content + * transitions so the WebView only reloads on actual preference changes. */ @Composable private fun WebViewEffects( @@ -564,7 +574,17 @@ private fun WebViewEffects( url: String, frontendJsCallback: FrontendJsCallback, webViewActions: Flow, + autoPlayVideoEnabled: Boolean?, ) { + // Track the "Autoplay video" preference across state transitions so we only react + // to actual preference changes (and not to transient state churn like Loading → Content). + var lastAutoPlayVideoEnabled by remember { mutableStateOf(false) } + LaunchedEffect(autoPlayVideoEnabled) { + if (autoPlayVideoEnabled != null) { + lastAutoPlayVideoEnabled = autoPlayVideoEnabled + } + } + if (webView != null) { LaunchedEffect(webView, url) { frontendJsCallback.attachToWebView(webView) @@ -576,6 +596,12 @@ private fun WebViewEffects( action.run(webView) } } + LaunchedEffect(lastAutoPlayVideoEnabled, webView) { + val target = !lastAutoPlayVideoEnabled + if (webView.settings.mediaPlaybackRequiresUserGesture == target) return@LaunchedEffect + webView.settings.mediaPlaybackRequiresUserGesture = target + webView.reload() + } } } 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 dfa4b610a97..54420507844 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 @@ -274,6 +274,18 @@ internal class FrontendViewModel @VisibleForTesting constructor( } } + viewModelScope.launch { + prefsRepository.autoPlayVideoFlow().collect { enabled -> + _viewState.update { state -> + if (state is FrontendViewState.Content) { + state.copy(autoPlayVideoEnabled = enabled) + } else { + state + } + } + } + } + loadServer() } @@ -435,11 +447,13 @@ internal class FrontendViewModel @VisibleForTesting constructor( private suspend fun handleMessageResult(result: FrontendHandlerEvent) { when (result) { is FrontendHandlerEvent.Connected -> { + val isAutoPlayVideoEnabled = prefsRepository.isAutoPlayVideoEnabled() _viewState.update { currentState -> if (currentState is FrontendViewState.Loading) { FrontendViewState.Content( serverId = currentState.serverId, url = currentState.url, + autoPlayVideoEnabled = isAutoPlayVideoEnabled, ) } else { currentState diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/frontend/FrontendViewState.kt b/app/src/main/kotlin/io/homeassistant/companion/android/frontend/FrontendViewState.kt index 07c53889bee..14159c66cd2 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/frontend/FrontendViewState.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/frontend/FrontendViewState.kt @@ -54,6 +54,7 @@ sealed interface FrontendViewState { val nightModeTheme: NightModeTheme? = null, val statusBarColor: Color? = null, val backgroundColor: Color? = null, + val autoPlayVideoEnabled: Boolean = false, ) : FrontendViewState /** 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 0d568bb8166..ad2c430dba6 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 @@ -67,6 +67,8 @@ import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith import org.junit.jupiter.api.extension.RegisterExtension +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource @OptIn(ExperimentalCoroutinesApi::class) @ExtendWith(ConsoleLogExtension::class) @@ -84,8 +86,10 @@ class FrontendViewModelTest { private val downloadManager: FrontendDownloadManager = mockk(relaxed = true) private val gestureHandler: FrontendGestureHandler = mockk(relaxed = true) private val zoomSettingsFlow = MutableStateFlow(ZoomSettings()) + private val autoPlayVideoFlow = MutableStateFlow(false) private val prefsRepository: PrefsRepository = mockk(relaxed = true) { coEvery { this@mockk.zoomSettingsFlow() } returns this@FrontendViewModelTest.zoomSettingsFlow + coEvery { this@mockk.autoPlayVideoFlow() } returns this@FrontendViewModelTest.autoPlayVideoFlow } private val serverId = 1 @@ -1480,4 +1484,56 @@ class FrontendViewModelTest { } } } + + @Nested + inner class AutoPlayVideoSetting { + + @Test + fun `Given Content state when pref flow emits new value then Content reflects it`() = runTest { + val messageFlow = MutableSharedFlow() + every { frontendBusObserver.messageResults() } returns messageFlow + every { urlManager.serverUrlFlow(any(), any()) } returns flowOf( + UrlLoadResult.Success(url = testUrlWithAuth, serverId = serverId), + ) + + val viewModel = createViewModel() + advanceTimeBy(CONNECTION_TIMEOUT - 1.seconds) + + messageFlow.emit(FrontendHandlerEvent.Connected) + advanceUntilIdle() + + val initial = viewModel.viewState.value + assertTrue(initial is FrontendViewState.Content) + assertEquals(false, (initial as FrontendViewState.Content).autoPlayVideoEnabled) + + autoPlayVideoFlow.value = true + advanceUntilIdle() + + val updated = viewModel.viewState.value + assertTrue(updated is FrontendViewState.Content) + assertEquals(true, (updated as FrontendViewState.Content).autoPlayVideoEnabled) + } + + @ParameterizedTest + @ValueSource(booleans = [true, false]) + fun `Given autoplay pref value before Content when transitioning to Content then Content has autoplay reflect this`(value: Boolean) = runTest { + val messageFlow = MutableSharedFlow() + every { frontendBusObserver.messageResults() } returns messageFlow + every { urlManager.serverUrlFlow(any(), any()) } returns flowOf( + UrlLoadResult.Success(url = testUrlWithAuth, serverId = serverId), + ) + + coEvery { prefsRepository.isAutoPlayVideoEnabled() } returns value + + val viewModel = createViewModel() + advanceTimeBy(CONNECTION_TIMEOUT - 1.seconds) + + messageFlow.emit(FrontendHandlerEvent.Connected) + advanceUntilIdle() + + val state = viewModel.viewState.value + assertTrue(state is FrontendViewState.Content) + assertEquals(value, (state as FrontendViewState.Content).autoPlayVideoEnabled) + } + } } diff --git a/common/src/main/kotlin/io/homeassistant/companion/android/common/data/prefs/PrefsRepository.kt b/common/src/main/kotlin/io/homeassistant/companion/android/common/data/prefs/PrefsRepository.kt index c29b5805083..5339d6aa964 100644 --- a/common/src/main/kotlin/io/homeassistant/companion/android/common/data/prefs/PrefsRepository.kt +++ b/common/src/main/kotlin/io/homeassistant/companion/android/common/data/prefs/PrefsRepository.kt @@ -99,6 +99,9 @@ interface PrefsRepository { suspend fun isAutoPlayVideoEnabled(): Boolean + /** Emits the current "Autoplay video" preference immediately on collection, then on every change. */ + suspend fun autoPlayVideoFlow(): Flow + suspend fun setAutoPlayVideo(enabled: Boolean) suspend fun isAlwaysShowFirstViewOnAppStartEnabled(): Boolean diff --git a/common/src/main/kotlin/io/homeassistant/companion/android/common/data/prefs/PrefsRepositoryImpl.kt b/common/src/main/kotlin/io/homeassistant/companion/android/common/data/prefs/PrefsRepositoryImpl.kt index 3ab416fa38e..df04bc5134e 100644 --- a/common/src/main/kotlin/io/homeassistant/companion/android/common/data/prefs/PrefsRepositoryImpl.kt +++ b/common/src/main/kotlin/io/homeassistant/companion/android/common/data/prefs/PrefsRepositoryImpl.kt @@ -207,15 +207,8 @@ internal class PrefsRepositoryImpl @Inject constructor( localStorage().putBoolean(PREF_FULLSCREEN_ENABLED, enabled) } - override suspend fun fullScreenEnabledFlow(): Flow { - val localStorage = localStorage() - return merge( - localStorage.observeChanges(PREF_FULLSCREEN_ENABLED), - // Seed an initial emission so collectors read the current value immediately - flowOf(""), - ).map { - isFullScreenEnabled() - } + override suspend fun fullScreenEnabledFlow(): Flow = observeChanges(PREF_FULLSCREEN_ENABLED) { + isFullScreenEnabled() } override suspend fun isKeepScreenOnEnabled(): Boolean { @@ -242,25 +235,24 @@ internal class PrefsRepositoryImpl @Inject constructor( localStorage().putBoolean(PREF_PINCH_TO_ZOOM_ENABLED, enabled) } - override suspend fun zoomSettingsFlow(): Flow { - val localStorage = localStorage() - return merge( - localStorage.observeChanges(PREF_PAGE_ZOOM_LEVEL), - localStorage.observeChanges(PREF_PINCH_TO_ZOOM_ENABLED), - // Seed an initial emission so collectors read the current values immediately - flowOf(""), - ).map { + override suspend fun zoomSettingsFlow(): Flow = + observeChanges(PREF_PAGE_ZOOM_LEVEL, PREF_PINCH_TO_ZOOM_ENABLED) { ZoomSettings( zoomLevel = getPageZoomLevel(), pinchToZoomEnabled = isPinchToZoomEnabled(), ) } - } override suspend fun isAutoPlayVideoEnabled(): Boolean { return localStorage().getBoolean(PREF_AUTOPLAY_VIDEO) } + override suspend fun autoPlayVideoFlow(): Flow { + return observeChanges(PREF_AUTOPLAY_VIDEO) { + isAutoPlayVideoEnabled() + } + } + override suspend fun setAutoPlayVideo(enabled: Boolean) { localStorage().putBoolean(PREF_AUTOPLAY_VIDEO, enabled) } @@ -411,4 +403,12 @@ internal class PrefsRepositoryImpl @Inject constructor( override suspend fun setSelectedWakeWord(wakeWord: String) { localStorage().putString(PREF_SELECTED_WAKE_WORD, wakeWord) } + + private suspend fun observeChanges(vararg keys: String, mapper: suspend () -> T): Flow { + val localStorage = localStorage() + // Seed an initial emission so collectors read the current value immediately + return (keys.map { localStorage.observeChanges(it) } + flowOf("")) + .merge() + .map { mapper() } + } } diff --git a/common/src/test/kotlin/io/homeassistant/companion/android/common/data/prefs/PrefsRepositoryImplTest.kt b/common/src/test/kotlin/io/homeassistant/companion/android/common/data/prefs/PrefsRepositoryImplTest.kt index 5d19eb5581e..3c82a0a6183 100644 --- a/common/src/test/kotlin/io/homeassistant/companion/android/common/data/prefs/PrefsRepositoryImplTest.kt +++ b/common/src/test/kotlin/io/homeassistant/companion/android/common/data/prefs/PrefsRepositoryImplTest.kt @@ -184,6 +184,35 @@ class PrefsRepositoryImplTest { } } + @Nested + inner class AutoPlayVideoFlow { + + @Test + fun `Given collecting flow then current autoplay value is emitted immediately`() = runTest { + coEvery { localStorage.getBoolean("autoplay_video") } returns true + + repository.autoPlayVideoFlow().test { + assertTrue(awaitItem()) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `Given collecting flow when autoplay key changes then updated value is emitted`() = runTest { + coEvery { localStorage.getBoolean("autoplay_video") } returns false + + repository.autoPlayVideoFlow().test { + assertFalse(awaitItem()) + + coEvery { localStorage.getBoolean("autoplay_video") } returns true + keyChangesFlow.emit("autoplay_video") + + assertTrue(awaitItem()) + cancelAndIgnoreRemainingEvents() + } + } + } + @Nested inner class FullScreenEnabledFlow { From 12f8c3b2b9449f2ced0254e8ce9cb69eabd73d58 Mon Sep 17 00:00:00 2001 From: Timothy <6560631+TimoPtr@users.noreply.github.com> Date: Mon, 4 May 2026 15:02:37 +0200 Subject: [PATCH 2/5] Adjust logic to address Copilot concern --- .../android/frontend/FrontendScreen.kt | 28 +++++--------- .../android/frontend/FrontendViewModel.kt | 29 +++++++------- .../android/frontend/FrontendViewState.kt | 1 - .../android/frontend/FrontendViewModelTest.kt | 38 ++++--------------- 4 files changed, 33 insertions(+), 63 deletions(-) diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/frontend/FrontendScreen.kt b/app/src/main/kotlin/io/homeassistant/companion/android/frontend/FrontendScreen.kt index e67dbeabda1..84a2b2428c6 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/frontend/FrontendScreen.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/frontend/FrontendScreen.kt @@ -108,6 +108,7 @@ internal fun FrontendScreen( val pendingPermissionRequest by viewModel.pendingPermissionRequest.collectAsStateWithLifecycle() val pendingDialog by viewModel.pendingDialog.collectAsStateWithLifecycle() val pendingFileChooser by viewModel.pendingFileChooser.collectAsStateWithLifecycle() + val autoPlayVideoEnabled by viewModel.autoPlayVideoEnabled.collectAsStateWithLifecycle() // Create SecurityLevel ViewModel only when needed val securityLevelViewModel: LocationForSecureConnectionViewModel? = @@ -144,6 +145,7 @@ internal fun FrontendScreen( onDownloadRequested = viewModel::onDownloadRequested, webViewActions = viewModel.webViewActions, onGesture = viewModel::onGesture, + autoPlayVideoEnabled = autoPlayVideoEnabled, modifier = modifier, ) } @@ -166,6 +168,7 @@ internal fun FrontendScreenContent( onShowSnackbar: suspend (message: String, action: String?) -> Boolean, onWebViewCreationFailed: (Throwable) -> Unit, modifier: Modifier = Modifier, + autoPlayVideoEnabled: Boolean = false, pendingPermissionRequest: PermissionRequest? = null, pendingDialog: FrontendDialog? = null, pendingFileChooser: FileChooserRequest? = null, @@ -183,7 +186,7 @@ internal fun FrontendScreenContent( url = viewState.url, frontendJsCallback = frontendJsCallback, webViewActions = webViewActions, - autoPlayVideoEnabled = (viewState as? FrontendViewState.Content)?.autoPlayVideoEnabled, + autoPlayVideoEnabled = autoPlayVideoEnabled, ) PendingPermissionHandler( @@ -209,6 +212,7 @@ internal fun FrontendScreenContent( onWebViewCreationFailed = onWebViewCreationFailed, onDownloadRequested = onDownloadRequested, onGesture = onGesture, + autoPlayVideoEnabled = autoPlayVideoEnabled, ) StateOverlay( @@ -389,6 +393,7 @@ private fun SafeHAWebView( webViewClient: WebViewClient, contentState: FrontendViewState.Content?, onWebViewCreationFailed: (Throwable) -> Unit, + autoPlayVideoEnabled: Boolean, webChromeClient: WebChromeClient? = null, onDownloadRequested: (url: String, contentDisposition: String, mimetype: String) -> Unit = { _, _, _ -> }, onGesture: (GestureDirection, Int) -> Unit = { _, _ -> }, @@ -433,7 +438,7 @@ private fun SafeHAWebView( onWebViewCreated = onWebViewCreated, onDownloadRequested = onDownloadRequested, onGesture = onGesture, - autoPlayVideoEnabled = contentState?.autoPlayVideoEnabled ?: false, + autoPlayVideoEnabled = autoPlayVideoEnabled, ) }, onBackPressed = onBackClick, @@ -563,10 +568,6 @@ private fun PendingPermissionHandler(pendingRequest: PermissionRequest?) { /** * Handles WebView side effects: URL loading, [WebViewAction] dispatch, and reapplying the * "Autoplay video" preference (which requires a [WebView.reload] to take effect on the loaded page). - * - * @param autoPlayVideoEnabled Latest value from `FrontendViewState.Content`, or `null` when the - * current state is not Content. The last non-null value is remembered across non-Content - * transitions so the WebView only reloads on actual preference changes. */ @Composable private fun WebViewEffects( @@ -574,17 +575,8 @@ private fun WebViewEffects( url: String, frontendJsCallback: FrontendJsCallback, webViewActions: Flow, - autoPlayVideoEnabled: Boolean?, + autoPlayVideoEnabled: Boolean, ) { - // Track the "Autoplay video" preference across state transitions so we only react - // to actual preference changes (and not to transient state churn like Loading → Content). - var lastAutoPlayVideoEnabled by remember { mutableStateOf(false) } - LaunchedEffect(autoPlayVideoEnabled) { - if (autoPlayVideoEnabled != null) { - lastAutoPlayVideoEnabled = autoPlayVideoEnabled - } - } - if (webView != null) { LaunchedEffect(webView, url) { frontendJsCallback.attachToWebView(webView) @@ -596,8 +588,8 @@ private fun WebViewEffects( action.run(webView) } } - LaunchedEffect(lastAutoPlayVideoEnabled, webView) { - val target = !lastAutoPlayVideoEnabled + LaunchedEffect(autoPlayVideoEnabled, webView) { + val target = !autoPlayVideoEnabled if (webView.settings.mediaPlaybackRequiresUserGesture == target) return@LaunchedEffect webView.settings.mediaPlaybackRequiresUserGesture = target webView.reload() 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 54420507844..7890e598491 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 @@ -52,6 +52,8 @@ import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.stateIn @@ -248,6 +250,19 @@ internal class FrontendViewModel @VisibleForTesting constructor( /** Job tracking the zoom settings flow collection - restarted on each page load. */ private var zoomObserverJob: Job? = null + /** + * The user's "Autoplay video" preference. + * + * Lives outside [FrontendViewState] because autoplay is orthogonal to the screen state + * machine the WebView is rendered during `Loading`, `Content`, and `Error`, and all three + * need the value. Exposed as a [StateFlow] so the screen can read the current value + * synchronously when configuring the WebView at creation time (avoiding a one-shot reload + * once the persisted value lands) and react to subsequent changes via collection. + */ + val autoPlayVideoEnabled: StateFlow = flow { + emitAll(prefsRepository.autoPlayVideoFlow()) + }.stateIn(viewModelScope, SharingStarted.Eagerly, initialValue = false) + init { // Timeout watcher - cancels automatically when state changes from Loading viewModelScope.launch { @@ -274,18 +289,6 @@ internal class FrontendViewModel @VisibleForTesting constructor( } } - viewModelScope.launch { - prefsRepository.autoPlayVideoFlow().collect { enabled -> - _viewState.update { state -> - if (state is FrontendViewState.Content) { - state.copy(autoPlayVideoEnabled = enabled) - } else { - state - } - } - } - } - loadServer() } @@ -447,13 +450,11 @@ internal class FrontendViewModel @VisibleForTesting constructor( private suspend fun handleMessageResult(result: FrontendHandlerEvent) { when (result) { is FrontendHandlerEvent.Connected -> { - val isAutoPlayVideoEnabled = prefsRepository.isAutoPlayVideoEnabled() _viewState.update { currentState -> if (currentState is FrontendViewState.Loading) { FrontendViewState.Content( serverId = currentState.serverId, url = currentState.url, - autoPlayVideoEnabled = isAutoPlayVideoEnabled, ) } else { currentState diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/frontend/FrontendViewState.kt b/app/src/main/kotlin/io/homeassistant/companion/android/frontend/FrontendViewState.kt index 14159c66cd2..07c53889bee 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/frontend/FrontendViewState.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/frontend/FrontendViewState.kt @@ -54,7 +54,6 @@ sealed interface FrontendViewState { val nightModeTheme: NightModeTheme? = null, val statusBarColor: Color? = null, val backgroundColor: Color? = null, - val autoPlayVideoEnabled: Boolean = false, ) : FrontendViewState /** 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 ad2c430dba6..8fbadb7aca1 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 @@ -1489,51 +1489,29 @@ class FrontendViewModelTest { inner class AutoPlayVideoSetting { @Test - fun `Given Content state when pref flow emits new value then Content reflects it`() = runTest { - val messageFlow = MutableSharedFlow() - every { frontendBusObserver.messageResults() } returns messageFlow - every { urlManager.serverUrlFlow(any(), any()) } returns flowOf( - UrlLoadResult.Success(url = testUrlWithAuth, serverId = serverId), - ) - + fun `Given pref flow emits new value when collected then exposed StateFlow reflects it`() = runTest { val viewModel = createViewModel() - advanceTimeBy(CONNECTION_TIMEOUT - 1.seconds) - - messageFlow.emit(FrontendHandlerEvent.Connected) advanceUntilIdle() - val initial = viewModel.viewState.value - assertTrue(initial is FrontendViewState.Content) - assertEquals(false, (initial as FrontendViewState.Content).autoPlayVideoEnabled) + assertEquals(false, viewModel.autoPlayVideoEnabled.value) autoPlayVideoFlow.value = true advanceUntilIdle() - val updated = viewModel.viewState.value - assertTrue(updated is FrontendViewState.Content) - assertEquals(true, (updated as FrontendViewState.Content).autoPlayVideoEnabled) + assertEquals(true, viewModel.autoPlayVideoEnabled.value) } @ParameterizedTest @ValueSource(booleans = [true, false]) - fun `Given autoplay pref value before Content when transitioning to Content then Content has autoplay reflect this`(value: Boolean) = runTest { - val messageFlow = MutableSharedFlow() - every { frontendBusObserver.messageResults() } returns messageFlow - every { urlManager.serverUrlFlow(any(), any()) } returns flowOf( - UrlLoadResult.Success(url = testUrlWithAuth, serverId = serverId), - ) - - coEvery { prefsRepository.isAutoPlayVideoEnabled() } returns value + fun `Given pref flow seeded with value when ViewModel constructed then exposed StateFlow has that value`( + value: Boolean, + ) = runTest { + autoPlayVideoFlow.value = value val viewModel = createViewModel() - advanceTimeBy(CONNECTION_TIMEOUT - 1.seconds) - - messageFlow.emit(FrontendHandlerEvent.Connected) advanceUntilIdle() - val state = viewModel.viewState.value - assertTrue(state is FrontendViewState.Content) - assertEquals(value, (state as FrontendViewState.Content).autoPlayVideoEnabled) + assertEquals(value, viewModel.autoPlayVideoEnabled.value) } } } From 0d154a4f95b3e5c860a6a11d8bbe9b45ba39ee9d Mon Sep 17 00:00:00 2001 From: Timothy <6560631+TimoPtr@users.noreply.github.com> Date: Tue, 5 May 2026 13:38:30 +0200 Subject: [PATCH 3/5] Update app/src/main/kotlin/io/homeassistant/companion/android/frontend/FrontendViewModel.kt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Joris Pelgröm --- .../companion/android/frontend/FrontendViewModel.kt | 10 +++++----- 1 file changed, 5 insertions(+), 5 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 7890e598491..4497c82dcf6 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 @@ -253,11 +253,11 @@ internal class FrontendViewModel @VisibleForTesting constructor( /** * The user's "Autoplay video" preference. * - * Lives outside [FrontendViewState] because autoplay is orthogonal to the screen state - * machine the WebView is rendered during `Loading`, `Content`, and `Error`, and all three - * need the value. Exposed as a [StateFlow] so the screen can read the current value - * synchronously when configuring the WebView at creation time (avoiding a one-shot reload - * once the persisted value lands) and react to subsequent changes via collection. + * Lives outside [FrontendViewState] because the WebView is rendered during `Loading`, + * `Content`, and `Error`states , and all three states need the value. Exposed as a [StateFlow] + * so the screen can read the current value synchronously when configuring the WebView at + * creation time (avoiding a one-shot reload once the persisted value lands) and react to + * subsequent changes via collection. */ val autoPlayVideoEnabled: StateFlow = flow { emitAll(prefsRepository.autoPlayVideoFlow()) From cef7b39814a30550ae0d3d3692b2e7b311d9c925 Mon Sep 17 00:00:00 2001 From: Timothy <6560631+TimoPtr@users.noreply.github.com> Date: Tue, 5 May 2026 13:56:39 +0200 Subject: [PATCH 4/5] Make observeChanges support multiples keys --- .../android/common/LocalStorageImpl.kt | 18 ++++++++++-------- .../android/common/data/LocalStorage.kt | 2 +- .../common/data/prefs/PrefsRepositoryImpl.kt | 3 +-- .../data/prefs/PrefsRepositoryImplTest.kt | 2 +- 4 files changed, 13 insertions(+), 12 deletions(-) diff --git a/common/src/main/kotlin/io/homeassistant/companion/android/common/LocalStorageImpl.kt b/common/src/main/kotlin/io/homeassistant/companion/android/common/LocalStorageImpl.kt index a919c96403d..632142b0343 100644 --- a/common/src/main/kotlin/io/homeassistant/companion/android/common/LocalStorageImpl.kt +++ b/common/src/main/kotlin/io/homeassistant/companion/android/common/LocalStorageImpl.kt @@ -110,16 +110,18 @@ class LocalStorageImpl(sharedPreferences: suspend () -> SharedPreferences) : Loc withContext(Dispatchers.IO) { sharedPreferences().edit { remove(key) } } } - override fun observeChanges(key: String): Flow = callbackFlow { + override fun observeChanges(vararg keys: String): Flow = callbackFlow { val prefs = sharedPreferences() - val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, changedKey -> - if (changedKey == key) { - launch { send(key) } + keys.forEach { key -> + val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, changedKey -> + if (changedKey == key) { + launch { send(key) } + } + } + prefs.registerOnSharedPreferenceChangeListener(listener) + awaitClose { + prefs.unregisterOnSharedPreferenceChangeListener(listener) } - } - prefs.registerOnSharedPreferenceChangeListener(listener) - awaitClose { - prefs.unregisterOnSharedPreferenceChangeListener(listener) } } } diff --git a/common/src/main/kotlin/io/homeassistant/companion/android/common/data/LocalStorage.kt b/common/src/main/kotlin/io/homeassistant/companion/android/common/data/LocalStorage.kt index 99155f4b025..db08c101890 100644 --- a/common/src/main/kotlin/io/homeassistant/companion/android/common/data/LocalStorage.kt +++ b/common/src/main/kotlin/io/homeassistant/companion/android/common/data/LocalStorage.kt @@ -32,5 +32,5 @@ interface LocalStorage { * Returns a [Flow] that emits the [key] each time the value associated with it changes * and only emits for the specified [key]. */ - fun observeChanges(key: String): Flow + fun observeChanges(vararg keys: String): Flow } diff --git a/common/src/main/kotlin/io/homeassistant/companion/android/common/data/prefs/PrefsRepositoryImpl.kt b/common/src/main/kotlin/io/homeassistant/companion/android/common/data/prefs/PrefsRepositoryImpl.kt index df04bc5134e..57ecf1a563d 100644 --- a/common/src/main/kotlin/io/homeassistant/companion/android/common/data/prefs/PrefsRepositoryImpl.kt +++ b/common/src/main/kotlin/io/homeassistant/companion/android/common/data/prefs/PrefsRepositoryImpl.kt @@ -407,8 +407,7 @@ internal class PrefsRepositoryImpl @Inject constructor( private suspend fun observeChanges(vararg keys: String, mapper: suspend () -> T): Flow { val localStorage = localStorage() // Seed an initial emission so collectors read the current value immediately - return (keys.map { localStorage.observeChanges(it) } + flowOf("")) - .merge() + return merge(localStorage.observeChanges(*keys), flowOf("")) .map { mapper() } } } diff --git a/common/src/test/kotlin/io/homeassistant/companion/android/common/data/prefs/PrefsRepositoryImplTest.kt b/common/src/test/kotlin/io/homeassistant/companion/android/common/data/prefs/PrefsRepositoryImplTest.kt index 3c82a0a6183..92f183d1514 100644 --- a/common/src/test/kotlin/io/homeassistant/companion/android/common/data/prefs/PrefsRepositoryImplTest.kt +++ b/common/src/test/kotlin/io/homeassistant/companion/android/common/data/prefs/PrefsRepositoryImplTest.kt @@ -26,7 +26,7 @@ import org.junit.jupiter.params.provider.ValueSource class PrefsRepositoryImplTest { private val keyChangesFlow = MutableSharedFlow() private val localStorage = mockk { - every { observeChanges(any()) } returns keyChangesFlow + every { observeChanges(*anyVararg()) } returns keyChangesFlow } private val integrationStorage = mockk() From c2e4e3fff32124edd69d10d84f1945d8c07e7349 Mon Sep 17 00:00:00 2001 From: Timothy <6560631+TimoPtr@users.noreply.github.com> Date: Wed, 6 May 2026 09:44:26 +0200 Subject: [PATCH 5/5] Move entirely to LocalStorage --- .../android/common/LocalStorageImpl.kt | 9 ++ .../android/common/data/LocalStorage.kt | 10 ++ .../common/data/prefs/PrefsRepositoryImpl.kt | 20 +--- .../android/common/LocalStorageImplTest.kt | 8 ++ .../data/prefs/PrefsRepositoryImplTest.kt | 109 +++++++----------- 5 files changed, 72 insertions(+), 84 deletions(-) diff --git a/common/src/main/kotlin/io/homeassistant/companion/android/common/LocalStorageImpl.kt b/common/src/main/kotlin/io/homeassistant/companion/android/common/LocalStorageImpl.kt index 632142b0343..b0cf81578a0 100644 --- a/common/src/main/kotlin/io/homeassistant/companion/android/common/LocalStorageImpl.kt +++ b/common/src/main/kotlin/io/homeassistant/companion/android/common/LocalStorageImpl.kt @@ -8,6 +8,9 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.merge import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -124,4 +127,10 @@ class LocalStorageImpl(sharedPreferences: suspend () -> SharedPreferences) : Loc } } } + + override suspend fun observeChanges(vararg keys: String, mapper: suspend () -> T): Flow { + // Seed an initial emission so collectors read the current value immediately + return merge(observeChanges(*keys), flowOf("")) + .map { mapper() } + } } diff --git a/common/src/main/kotlin/io/homeassistant/companion/android/common/data/LocalStorage.kt b/common/src/main/kotlin/io/homeassistant/companion/android/common/data/LocalStorage.kt index db08c101890..78f788239cb 100644 --- a/common/src/main/kotlin/io/homeassistant/companion/android/common/data/LocalStorage.kt +++ b/common/src/main/kotlin/io/homeassistant/companion/android/common/data/LocalStorage.kt @@ -33,4 +33,14 @@ interface LocalStorage { * and only emits for the specified [key]. */ fun observeChanges(vararg keys: String): Flow + + /** + * Returns a [Flow] that emits the result of [mapper] each time the value associated with any + * of the specified [keys] changes. The current mapped value is also emitted immediately upon + * collection so collectors do not need to read the value separately before subscribing. + * + * [mapper] is invoked on every emission, including the initial one, and may suspend to read + * from storage. + */ + suspend fun observeChanges(vararg keys: String, mapper: suspend () -> T): Flow } diff --git a/common/src/main/kotlin/io/homeassistant/companion/android/common/data/prefs/PrefsRepositoryImpl.kt b/common/src/main/kotlin/io/homeassistant/companion/android/common/data/prefs/PrefsRepositoryImpl.kt index 57ecf1a563d..637b3482c6d 100644 --- a/common/src/main/kotlin/io/homeassistant/companion/android/common/data/prefs/PrefsRepositoryImpl.kt +++ b/common/src/main/kotlin/io/homeassistant/companion/android/common/data/prefs/PrefsRepositoryImpl.kt @@ -10,9 +10,6 @@ import io.homeassistant.companion.android.di.qualifiers.NamedThemesStorage import java.util.concurrent.atomic.AtomicBoolean import javax.inject.Inject import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.merge import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -207,8 +204,10 @@ internal class PrefsRepositoryImpl @Inject constructor( localStorage().putBoolean(PREF_FULLSCREEN_ENABLED, enabled) } - override suspend fun fullScreenEnabledFlow(): Flow = observeChanges(PREF_FULLSCREEN_ENABLED) { - isFullScreenEnabled() + override suspend fun fullScreenEnabledFlow(): Flow { + return localStorage().observeChanges(PREF_FULLSCREEN_ENABLED) { + isFullScreenEnabled() + } } override suspend fun isKeepScreenOnEnabled(): Boolean { @@ -236,7 +235,7 @@ internal class PrefsRepositoryImpl @Inject constructor( } override suspend fun zoomSettingsFlow(): Flow = - observeChanges(PREF_PAGE_ZOOM_LEVEL, PREF_PINCH_TO_ZOOM_ENABLED) { + localStorage().observeChanges(PREF_PAGE_ZOOM_LEVEL, PREF_PINCH_TO_ZOOM_ENABLED) { ZoomSettings( zoomLevel = getPageZoomLevel(), pinchToZoomEnabled = isPinchToZoomEnabled(), @@ -248,7 +247,7 @@ internal class PrefsRepositoryImpl @Inject constructor( } override suspend fun autoPlayVideoFlow(): Flow { - return observeChanges(PREF_AUTOPLAY_VIDEO) { + return localStorage().observeChanges(PREF_AUTOPLAY_VIDEO) { isAutoPlayVideoEnabled() } } @@ -403,11 +402,4 @@ internal class PrefsRepositoryImpl @Inject constructor( override suspend fun setSelectedWakeWord(wakeWord: String) { localStorage().putString(PREF_SELECTED_WAKE_WORD, wakeWord) } - - private suspend fun observeChanges(vararg keys: String, mapper: suspend () -> T): Flow { - val localStorage = localStorage() - // Seed an initial emission so collectors read the current value immediately - return merge(localStorage.observeChanges(*keys), flowOf("")) - .map { mapper() } - } } diff --git a/common/src/test/kotlin/io/homeassistant/companion/android/common/LocalStorageImplTest.kt b/common/src/test/kotlin/io/homeassistant/companion/android/common/LocalStorageImplTest.kt index e1dbfbb6606..d60c9d8c2d8 100644 --- a/common/src/test/kotlin/io/homeassistant/companion/android/common/LocalStorageImplTest.kt +++ b/common/src/test/kotlin/io/homeassistant/companion/android/common/LocalStorageImplTest.kt @@ -47,4 +47,12 @@ class LocalStorageImplTest { } verify { sharedPreferences.unregisterOnSharedPreferenceChangeListener(any()) } } + + @Test + fun `Given observing keys with mapper when subscribing then mapper result is emitted immediately`() = runTest { + localStorage.observeChanges("my_key") { "mapped_value" }.test { + assertEquals("mapped_value", awaitItem()) + cancelAndIgnoreRemainingEvents() + } + } } diff --git a/common/src/test/kotlin/io/homeassistant/companion/android/common/data/prefs/PrefsRepositoryImplTest.kt b/common/src/test/kotlin/io/homeassistant/companion/android/common/data/prefs/PrefsRepositoryImplTest.kt index 92f183d1514..94c2bbf5212 100644 --- a/common/src/test/kotlin/io/homeassistant/companion/android/common/data/prefs/PrefsRepositoryImplTest.kt +++ b/common/src/test/kotlin/io/homeassistant/companion/android/common/data/prefs/PrefsRepositoryImplTest.kt @@ -9,7 +9,11 @@ import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every import io.mockk.mockk +import io.mockk.slot import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.merge import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse @@ -25,8 +29,14 @@ import org.junit.jupiter.params.provider.ValueSource @ExtendWith(ConsoleLogExtension::class) class PrefsRepositoryImplTest { private val keyChangesFlow = MutableSharedFlow() + private val mapperSlot = slot Any>() private val localStorage = mockk { every { observeChanges(*anyVararg()) } returns keyChangesFlow + coEvery { + observeChanges(*anyVararg(), mapper = capture(mapperSlot)) + } answers { + merge(keyChangesFlow, flowOf("")).map { mapperSlot.captured.invoke() } + } } private val integrationStorage = mockk() @@ -131,21 +141,38 @@ class PrefsRepositoryImplTest { coVerify(exactly = 0) { integrationStorage.getString(any()) } } - @Nested - inner class ZoomSettingsFlow { + @Test + fun `Given collecting flow when autoplay key changes then updated value is emitted`() = runTest { + coEvery { localStorage.getBoolean("autoplay_video") } returns false - @Test - fun `Given collecting flow then current zoom settings are emitted immediately`() = runTest { - coEvery { localStorage.getInt("page_zoom_level") } returns 150 - coEvery { localStorage.getBoolean("pinch_to_zoom_enabled") } returns true + repository.autoPlayVideoFlow().test { + assertFalse(awaitItem()) - repository.zoomSettingsFlow().test { - val settings = awaitItem() - assertEquals(150, settings.zoomLevel) - assertTrue(settings.pinchToZoomEnabled) - cancelAndIgnoreRemainingEvents() - } + coEvery { localStorage.getBoolean("autoplay_video") } returns true + keyChangesFlow.emit("autoplay_video") + + assertTrue(awaitItem()) + cancelAndIgnoreRemainingEvents() } + } + + @Test + fun `Given collecting flow when full screen changes then updated full screen enabled is emitted`() = runTest { + coEvery { localStorage.getBoolean("fullscreen_enabled") } returns true + + repository.fullScreenEnabledFlow().test { + assertTrue(awaitItem()) + + coEvery { localStorage.getBoolean("fullscreen_enabled") } returns false + keyChangesFlow.emit("fullscreen_enabled") + + assertFalse(awaitItem()) + cancelAndIgnoreRemainingEvents() + } + } + + @Nested + inner class ZoomSettingsFlow { @Test fun `Given collecting flow when zoom key changes then updated settings are emitted`() = runTest { @@ -183,62 +210,4 @@ class PrefsRepositoryImplTest { } } } - - @Nested - inner class AutoPlayVideoFlow { - - @Test - fun `Given collecting flow then current autoplay value is emitted immediately`() = runTest { - coEvery { localStorage.getBoolean("autoplay_video") } returns true - - repository.autoPlayVideoFlow().test { - assertTrue(awaitItem()) - cancelAndIgnoreRemainingEvents() - } - } - - @Test - fun `Given collecting flow when autoplay key changes then updated value is emitted`() = runTest { - coEvery { localStorage.getBoolean("autoplay_video") } returns false - - repository.autoPlayVideoFlow().test { - assertFalse(awaitItem()) - - coEvery { localStorage.getBoolean("autoplay_video") } returns true - keyChangesFlow.emit("autoplay_video") - - assertTrue(awaitItem()) - cancelAndIgnoreRemainingEvents() - } - } - } - - @Nested - inner class FullScreenEnabledFlow { - - @Test - fun `Given collecting flow when subscribing then current full screen enabled is emitted immediately`() = runTest { - coEvery { localStorage.getBoolean("fullscreen_enabled") } returns true - - repository.fullScreenEnabledFlow().test { - assertTrue(awaitItem()) - cancelAndIgnoreRemainingEvents() - } - } - - @Test - fun `Given collecting flow when full screen changes then updated full screen enabled is emitted`() = runTest { - coEvery { localStorage.getBoolean("fullscreen_enabled") } returns true - - repository.fullScreenEnabledFlow().test { - awaitItem() // consume initial emission - - coEvery { localStorage.getBoolean("fullscreen_enabled") } returns false - keyChangesFlow.emit("fullscreen_enabled") - - assertFalse(awaitItem()) - cancelAndIgnoreRemainingEvents() - } - } - } }