Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -168,6 +169,7 @@ internal fun FrontendScreen(
webViewActions = viewModel.webViewActions,
onGesture = viewModel::onGesture,
onExoPlayerFullscreenChanged = viewModel::onExoPlayerFullscreenChanged,
autoPlayVideoEnabled = autoPlayVideoEnabled,
modifier = modifier,
)
}
Expand All @@ -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,
Expand All @@ -209,6 +212,7 @@ internal fun FrontendScreenContent(
url = viewState.url,
frontendJsCallback = frontendJsCallback,
webViewActions = webViewActions,
autoPlayVideoEnabled = autoPlayVideoEnabled,
)

PendingPermissionHandler(
Expand All @@ -234,6 +238,7 @@ internal fun FrontendScreenContent(
onWebViewCreationFailed = onWebViewCreationFailed,
onDownloadRequested = onDownloadRequested,
onGesture = onGesture,
autoPlayVideoEnabled = autoPlayVideoEnabled,
)

ExoPlayerOverlay(
Expand Down Expand Up @@ -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 = { _, _ -> },
Expand Down Expand Up @@ -465,6 +471,7 @@ private fun SafeHAWebView(
onWebViewCreated = onWebViewCreated,
onDownloadRequested = onDownloadRequested,
onGesture = onGesture,
autoPlayVideoEnabled = autoPlayVideoEnabled,
)
},
onBackPressed = onBackClick,
Expand Down Expand Up @@ -512,13 +519,16 @@ private fun WebView.configureForFrontend(
onWebViewCreated: (WebView) -> Unit,
onDownloadRequested: (url: String, contentDisposition: String, mimetype: String) -> Unit,
onGesture: (GestureDirection, Int) -> Unit,
autoPlayVideoEnabled: Boolean,
) {
onWebViewCreated(this)

this.webViewClient = webViewClient

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).
Expand Down Expand Up @@ -589,14 +599,16 @@ 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(
webView: WebView?,
url: String,
frontendJsCallback: FrontendJsCallback,
webViewActions: Flow<WebViewAction>,
autoPlayVideoEnabled: Boolean,
) {
if (webView != null) {
LaunchedEffect(webView, url) {
Expand All @@ -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()
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<Boolean> = flow {
emitAll(prefsRepository.autoPlayVideoFlow())
}.stateIn(viewModelScope, SharingStarted.Eagerly, initialValue = false)

init {
viewModelScope.launch {
_viewState.collectLatest { state ->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -110,16 +113,24 @@ class LocalStorageImpl(sharedPreferences: suspend () -> SharedPreferences) : Loc
withContext(Dispatchers.IO) { sharedPreferences().edit { remove(key) } }
}

override fun observeChanges(key: String): Flow<String> = callbackFlow {
override fun observeChanges(vararg keys: String): Flow<String> = 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 <T> observeChanges(vararg keys: String, mapper: suspend () -> T): Flow<T> {
// Seed an initial emission so collectors read the current value immediately
return merge(observeChanges(*keys), flowOf(""))
.map { mapper() }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>
fun observeChanges(vararg keys: String): Flow<String>

/**
* 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 <T> observeChanges(vararg keys: String, mapper: suspend () -> T): Flow<T>
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<Boolean>

suspend fun setAutoPlayVideo(enabled: Boolean)

suspend fun isAlwaysShowFirstViewOnAppStartEnabled(): Boolean
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -208,12 +205,7 @@ internal class PrefsRepositoryImpl @Inject constructor(
}

override suspend fun fullScreenEnabledFlow(): Flow<Boolean> {
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()
}
}
Expand Down Expand Up @@ -242,25 +234,24 @@ internal class PrefsRepositoryImpl @Inject constructor(
localStorage().putBoolean(PREF_PINCH_TO_ZOOM_ENABLED, enabled)
}

override suspend fun zoomSettingsFlow(): Flow<ZoomSettings> {
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<ZoomSettings> =
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<Boolean> {
return localStorage().observeChanges(PREF_AUTOPLAY_VIDEO) {
isAutoPlayVideoEnabled()
}
}

override suspend fun setAutoPlayVideo(enabled: Boolean) {
localStorage().putBoolean(PREF_AUTOPLAY_VIDEO, enabled)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
}
}
Loading
Loading