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 a9907272681..14f1376e664 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 @@ -120,6 +120,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() // The fullscreen View handed over by the WebView is Activity-scoped. Keep it in screen // state so it does not leak across configuration changes via the ViewModel. @@ -168,6 +169,7 @@ internal fun FrontendScreen( webViewActions = viewModel.webViewActions, onGesture = viewModel::onGesture, onExoPlayerFullscreenChanged = viewModel::onExoPlayerFullscreenChanged, + autoPlayVideoEnabled = autoPlayVideoEnabled, modifier = modifier, ) } @@ -191,6 +193,7 @@ internal fun FrontendScreenContent( onWebViewCreationFailed: (Throwable) -> Unit, modifier: Modifier = Modifier, customView: View? = null, + autoPlayVideoEnabled: Boolean = false, pendingPermissionRequest: PermissionRequest? = null, pendingDialog: FrontendDialog? = null, pendingFileChooser: FileChooserRequest? = null, @@ -209,6 +212,7 @@ internal fun FrontendScreenContent( url = viewState.url, frontendJsCallback = frontendJsCallback, webViewActions = webViewActions, + autoPlayVideoEnabled = autoPlayVideoEnabled, ) PendingPermissionHandler( @@ -234,6 +238,7 @@ internal fun FrontendScreenContent( onWebViewCreationFailed = onWebViewCreationFailed, onDownloadRequested = onDownloadRequested, onGesture = onGesture, + autoPlayVideoEnabled = autoPlayVideoEnabled, ) ExoPlayerOverlay( @@ -421,6 +426,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 = { _, _ -> }, @@ -465,6 +471,7 @@ private fun SafeHAWebView( onWebViewCreated = onWebViewCreated, onDownloadRequested = onDownloadRequested, onGesture = onGesture, + autoPlayVideoEnabled = autoPlayVideoEnabled, ) }, onBackPressed = onBackClick, @@ -512,6 +519,7 @@ private fun WebView.configureForFrontend( onWebViewCreated: (WebView) -> Unit, onDownloadRequested: (url: String, contentDisposition: String, mimetype: String) -> Unit, onGesture: (GestureDirection, Int) -> Unit, + autoPlayVideoEnabled: Boolean, ) { onWebViewCreated(this) @@ -519,6 +527,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). @@ -589,7 +599,8 @@ 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). */ @Composable private fun WebViewEffects( @@ -597,6 +608,7 @@ private fun WebViewEffects( url: String, frontendJsCallback: FrontendJsCallback, webViewActions: Flow, + autoPlayVideoEnabled: Boolean, ) { if (webView != null) { LaunchedEffect(webView, url) { @@ -614,6 +626,12 @@ private fun WebViewEffects( action.run(webView) } } + 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 7dd6496d7cd..d70bfa2903c 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 @@ -54,6 +54,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 @@ -233,6 +235,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 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()) + }.stateIn(viewModelScope, SharingStarted.Eagerly, initialValue = false) + init { viewModelScope.launch { _viewState.collectLatest { state -> 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 b33f4ada2b6..01abe514276 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 @@ -74,6 +74,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) @@ -91,8 +93,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 @@ -1705,4 +1709,34 @@ class FrontendViewModelTest { } } } + + @Nested + inner class AutoPlayVideoSetting { + + @Test + fun `Given pref flow emits new value when collected then exposed StateFlow reflects it`() = runTest { + val viewModel = createViewModel() + advanceUntilIdle() + + assertEquals(false, viewModel.autoPlayVideoEnabled.value) + + autoPlayVideoFlow.value = true + advanceUntilIdle() + + assertEquals(true, viewModel.autoPlayVideoEnabled.value) + } + + @ParameterizedTest + @ValueSource(booleans = [true, false]) + 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() + advanceUntilIdle() + + assertEquals(value, viewModel.autoPlayVideoEnabled.value) + } + } } 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..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 @@ -110,16 +113,24 @@ 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) - } + } + + 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 99155f4b025..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 @@ -32,5 +32,15 @@ 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 + + /** + * 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/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..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 @@ -208,12 +205,7 @@ internal class PrefsRepositoryImpl @Inject constructor( } 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 { + return localStorage().observeChanges(PREF_FULLSCREEN_ENABLED) { isFullScreenEnabled() } } @@ -242,25 +234,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 = + localStorage().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 localStorage().observeChanges(PREF_AUTOPLAY_VIDEO) { + isAutoPlayVideoEnabled() + } + } + override suspend fun setAutoPlayVideo(enabled: Boolean) { localStorage().putBoolean(PREF_AUTOPLAY_VIDEO, enabled) } 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 5d19eb5581e..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(any()) } returns keyChangesFlow + 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,33 +210,4 @@ class PrefsRepositoryImplTest { } } } - - @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() - } - } - } }