Skip to content
Open
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 @@ -40,6 +40,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.LayoutDirection
Expand Down Expand Up @@ -124,6 +125,7 @@ internal fun FrontendScreen(
val pendingDialog by viewModel.pendingDialog.collectAsStateWithLifecycle()
val pendingFileChooser by viewModel.pendingFileChooser.collectAsStateWithLifecycle()
val autoPlayVideoEnabled by viewModel.autoPlayVideoEnabled.collectAsStateWithLifecycle()
val keepScreenOnEnabled by viewModel.keepScreenOnEnabled.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 @@ -173,6 +175,7 @@ internal fun FrontendScreen(
onGesture = viewModel::onGesture,
onExoPlayerFullscreenChanged = viewModel::onExoPlayerFullscreenChanged,
autoPlayVideoEnabled = autoPlayVideoEnabled,
keepScreenOnEnabled = keepScreenOnEnabled,
onPipReadinessChanged = onPipReadinessChanged,
modifier = modifier,
)
Expand All @@ -198,6 +201,7 @@ internal fun FrontendScreenContent(
modifier: Modifier = Modifier,
customView: View? = null,
autoPlayVideoEnabled: Boolean = false,
keepScreenOnEnabled: Boolean = false,
pendingPermissionRequest: PermissionRequest? = null,
pendingDialog: FrontendDialog? = null,
pendingFileChooser: FileChooserRequest? = null,
Expand Down Expand Up @@ -232,6 +236,8 @@ internal fun FrontendScreenContent(
pendingRequest = pendingFileChooser,
)

KeepScreenOnEffect(enabled = keepScreenOnEnabled)

Box(modifier = modifier.fillMaxSize()) {
// Always render WebView at base layer
SafeHAWebView(
Expand Down Expand Up @@ -645,6 +651,24 @@ private fun WebViewEffects(
}
}

/**
* Toggles `View.keepScreenOn` (mapped by the platform to `WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON`)
* based on the user's "Keep screen on" preference while the frontend is composed.
*
* The flag is always cleared on dispose so it does not leak to other screens that share the
* hosting activity window.
*/
@Composable
private fun KeepScreenOnEffect(enabled: Boolean) {
val view = LocalView.current
DisposableEffect(view, enabled) {
view.keepScreenOn = enabled
onDispose {
view.keepScreenOn = false
}
}
Comment thread
TimoPtr marked this conversation as resolved.
}

/**
* Renders PiP-eligible overlays and reports their combined [PipReadiness] to the host.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,17 @@ internal class FrontendViewModel @VisibleForTesting constructor(
emitAll(prefsRepository.autoPlayVideoFlow())
}.stateIn(viewModelScope, SharingStarted.Eagerly, initialValue = false)

/**
* The user's "Keep screen on" preference.
*
* Applied by the screen to the hosting window so the device does not lock while the
* WebView is active. Exposed as a [StateFlow] so the screen can read the current value
* synchronously when first attaching to the window and react to subsequent changes.
*/
val keepScreenOnEnabled: StateFlow<Boolean> = flow {
emitAll(prefsRepository.keepScreenOnFlow())
}.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 @@ -11,7 +11,9 @@ import androidx.activity.result.ActivityResultRegistry
import androidx.activity.result.ActivityResultRegistryOwner
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
Expand Down Expand Up @@ -49,6 +51,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Rule
import org.junit.Test
Expand Down Expand Up @@ -599,6 +602,143 @@ class FrontendScreenTest {
}
}

@Test
fun `Given keepScreenOnEnabled is true then host view keepScreenOn is set`() {
var capturedView: View? = null
composeTestRule.setContent {
capturedView = LocalView.current
FrontendScreenContent(
onBackClick = {},
viewState = FrontendViewState.Content(serverId = 1, url = "https://example.com"),
webViewClient = WebViewClient(),
webChromeClient = WebChromeClient(),
frontendJsCallback = FrontendJsBridge.noOp,
onBlockInsecureRetry = {},
onOpenExternalLink = {},
onBlockInsecureHelpClick = {},
onOpenSettings = {},
onChangeSecurityLevel = {},
onOpenLocationSettings = {},
onConfigureHomeNetwork = { _ -> },
onSecurityLevelHelpClick = {},
onShowSnackbar = { _, _ -> true },
onWebViewCreationFailed = {},
keepScreenOnEnabled = true,
)
}

composeTestRule.runOnIdle {
assertTrue("host view should keep the screen on when preference is enabled", capturedView!!.keepScreenOn)
}
}

@Test
fun `Given keepScreenOnEnabled is false then host view keepScreenOn is cleared`() {
var capturedView: View? = null
composeTestRule.setContent {
capturedView = LocalView.current
FrontendScreenContent(
onBackClick = {},
viewState = FrontendViewState.Content(serverId = 1, url = "https://example.com"),
webViewClient = WebViewClient(),
webChromeClient = WebChromeClient(),
frontendJsCallback = FrontendJsBridge.noOp,
onBlockInsecureRetry = {},
onOpenExternalLink = {},
onBlockInsecureHelpClick = {},
onOpenSettings = {},
onChangeSecurityLevel = {},
onOpenLocationSettings = {},
onConfigureHomeNetwork = { _ -> },
onSecurityLevelHelpClick = {},
onShowSnackbar = { _, _ -> true },
onWebViewCreationFailed = {},
keepScreenOnEnabled = false,
)
}

composeTestRule.runOnIdle {
assertFalse(
"host view should not keep the screen on when preference is disabled",
capturedView!!.keepScreenOn,
)
}
}

@Test
fun `Given keepScreenOnEnabled toggles at runtime then host view keepScreenOn follows`() {
val enabledState = mutableStateOf(false)
var capturedView: View? = null
composeTestRule.setContent {
capturedView = LocalView.current
FrontendScreenContent(
onBackClick = {},
viewState = FrontendViewState.Content(serverId = 1, url = "https://example.com"),
webViewClient = WebViewClient(),
webChromeClient = WebChromeClient(),
frontendJsCallback = FrontendJsBridge.noOp,
onBlockInsecureRetry = {},
onOpenExternalLink = {},
onBlockInsecureHelpClick = {},
onOpenSettings = {},
onChangeSecurityLevel = {},
onOpenLocationSettings = {},
onConfigureHomeNetwork = { _ -> },
onSecurityLevelHelpClick = {},
onShowSnackbar = { _, _ -> true },
onWebViewCreationFailed = {},
keepScreenOnEnabled = enabledState.value,
)
}

composeTestRule.runOnIdle { assertFalse(capturedView!!.keepScreenOn) }

enabledState.value = true
composeTestRule.runOnIdle { assertTrue(capturedView!!.keepScreenOn) }

enabledState.value = false
composeTestRule.runOnIdle { assertFalse(capturedView!!.keepScreenOn) }
}

@Test
fun `Given keepScreenOnEnabled is true when content leaves composition then keepScreenOn is cleared`() {
val visible = mutableStateOf(true)
var capturedView: View? = null
composeTestRule.setContent {
capturedView = LocalView.current
if (visible.value) {
FrontendScreenContent(
onBackClick = {},
viewState = FrontendViewState.Content(serverId = 1, url = "https://example.com"),
webViewClient = WebViewClient(),
webChromeClient = WebChromeClient(),
frontendJsCallback = FrontendJsBridge.noOp,
onBlockInsecureRetry = {},
onOpenExternalLink = {},
onBlockInsecureHelpClick = {},
onOpenSettings = {},
onChangeSecurityLevel = {},
onOpenLocationSettings = {},
onConfigureHomeNetwork = { _ -> },
onSecurityLevelHelpClick = {},
onShowSnackbar = { _, _ -> true },
onWebViewCreationFailed = {},
keepScreenOnEnabled = true,
)
}
}

composeTestRule.runOnIdle { assertTrue(capturedView!!.keepScreenOn) }

visible.value = false
composeTestRule.runOnIdle {
assertFalse(
"keepScreenOn should be cleared once the frontend leaves composition",
capturedView!!.keepScreenOn,
)
}
}

private fun AndroidComposeTestRule<ActivityScenarioRule<HiltComponentActivity>, HiltComponentActivity>.assertIsLoading(show: Boolean) {
val node = onNodeWithContentDescription(stringResource(commonR.string.loading_content_description))
if (show) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.JsonObject
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertInstanceOf
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertNull
Expand Down Expand Up @@ -94,9 +95,11 @@ class FrontendViewModelTest {
private val gestureHandler: FrontendGestureHandler = mockk(relaxed = true)
private val zoomSettingsFlow = MutableStateFlow(ZoomSettings())
private val autoPlayVideoFlow = MutableStateFlow(false)
private val keepScreenOnFlow = 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
coEvery { this@mockk.keepScreenOnFlow() } returns this@FrontendViewModelTest.keepScreenOnFlow
}

private val serverId = 1
Expand Down Expand Up @@ -1739,4 +1742,34 @@ class FrontendViewModelTest {
assertEquals(value, viewModel.autoPlayVideoEnabled.value)
}
}

@Nested
inner class KeepScreenOnSetting {

@Test
fun `Given pref flow emits new value when collected then exposed StateFlow reflects it`() = runTest {
val viewModel = createViewModel()
advanceUntilIdle()

assertFalse(viewModel.keepScreenOnEnabled.value)

keepScreenOnFlow.value = true
advanceUntilIdle()

assertTrue(viewModel.keepScreenOnEnabled.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 {
keepScreenOnFlow.value = value

val viewModel = createViewModel()
advanceUntilIdle()

assertEquals(value, viewModel.keepScreenOnEnabled.value)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,9 @@ interface PrefsRepository {

suspend fun setKeepScreenOnEnabled(enabled: Boolean)

/** Emits the current "Keep screen on" preference immediately on collection, then on every change. */
suspend fun keepScreenOnFlow(): Flow<Boolean>

suspend fun getScreenOrientation(): String?

suspend fun saveScreenOrientation(orientation: String?)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,12 @@ internal class PrefsRepositoryImpl @Inject constructor(
localStorage().putBoolean(PREF_KEEP_SCREEN_ON_ENABLED, enabled)
}

override suspend fun keepScreenOnFlow(): Flow<Boolean> {
return localStorage().observeChanges(PREF_KEEP_SCREEN_ON_ENABLED) {
isKeepScreenOnEnabled()
}
}

override suspend fun getPageZoomLevel(): Int {
return localStorage().getInt(PREF_PAGE_ZOOM_LEVEL) ?: 100
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,21 @@ class PrefsRepositoryImplTest {
}
}

@Test
fun `Given collecting flow when keep screen on changes then updated keep screen on enabled is emitted`() = runTest {
coEvery { localStorage.getBoolean("keep_screen_on_enabled") } returns false

repository.keepScreenOnFlow().test {
assertFalse(awaitItem())

coEvery { localStorage.getBoolean("keep_screen_on_enabled") } returns true
keyChangesFlow.emit("keep_screen_on_enabled")

assertTrue(awaitItem())
cancelAndIgnoreRemainingEvents()
}
}

@Nested
inner class ZoomSettingsFlow {

Expand Down