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 @@ -8,6 +8,7 @@ import android.webkit.CookieManager
import android.webkit.WebChromeClient
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.activity.compose.LocalActivity
import androidx.annotation.VisibleForTesting
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
Expand Down Expand Up @@ -52,6 +53,7 @@ import io.homeassistant.companion.android.common.compose.composable.HAPlainButto
import io.homeassistant.companion.android.common.compose.theme.HADimens
import io.homeassistant.companion.android.common.compose.theme.HAThemeForPreview
import io.homeassistant.companion.android.common.compose.theme.LocalHAColorScheme
import io.homeassistant.companion.android.common.data.prefs.ScreenOrientation
import io.homeassistant.companion.android.common.util.GestureDirection
import io.homeassistant.companion.android.frontend.dialog.FrontendDialog
import io.homeassistant.companion.android.frontend.dialog.PendingDialogHandler
Expand Down Expand Up @@ -124,6 +126,7 @@ internal fun FrontendScreen(
val pendingDialog by viewModel.pendingDialog.collectAsStateWithLifecycle()
val pendingFileChooser by viewModel.pendingFileChooser.collectAsStateWithLifecycle()
val autoPlayVideoEnabled by viewModel.autoPlayVideoEnabled.collectAsStateWithLifecycle()
val screenOrientation by viewModel.screenOrientation.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 +176,7 @@ internal fun FrontendScreen(
onGesture = viewModel::onGesture,
onExoPlayerFullscreenChanged = viewModel::onExoPlayerFullscreenChanged,
autoPlayVideoEnabled = autoPlayVideoEnabled,
screenOrientation = screenOrientation,
onPipReadinessChanged = onPipReadinessChanged,
modifier = modifier,
)
Expand All @@ -198,6 +202,7 @@ internal fun FrontendScreenContent(
modifier: Modifier = Modifier,
customView: View? = null,
autoPlayVideoEnabled: Boolean = false,
screenOrientation: ScreenOrientation = ScreenOrientation.SYSTEM,
pendingPermissionRequest: PermissionRequest? = null,
pendingDialog: FrontendDialog? = null,
pendingFileChooser: FileChooserRequest? = null,
Expand Down Expand Up @@ -232,6 +237,8 @@ internal fun FrontendScreenContent(
pendingRequest = pendingFileChooser,
)

ScreenOrientationEffect(orientation = screenOrientation)

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

/**
* Applies the user's "Screen orientation" preference to the hosting activity's
* `requestedOrientation` while the frontend is composed.
*
* On dispose the previous value is restored so leaving the dashboard (e.g. navigating to
* settings) does not leak this preference to other screens that share the same activity.
*/
@Composable
private fun ScreenOrientationEffect(orientation: ScreenOrientation) {
val activity = LocalActivity.current ?: return
DisposableEffect(activity, orientation) {
val previous = activity.requestedOrientation
activity.requestedOrientation = orientation.activityInfo
onDispose {
activity.requestedOrientation = previous
}
}
}

/**
* 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 @@ -11,6 +11,7 @@ import io.homeassistant.companion.android.common.R as commonR
import io.homeassistant.companion.android.common.data.connectivity.ConnectivityCheckRepository
import io.homeassistant.companion.android.common.data.connectivity.ConnectivityCheckState
import io.homeassistant.companion.android.common.data.prefs.PrefsRepository
import io.homeassistant.companion.android.common.data.prefs.ScreenOrientation
import io.homeassistant.companion.android.common.util.GestureDirection
import io.homeassistant.companion.android.frontend.auth.HttpAuthManager
import io.homeassistant.companion.android.frontend.auth.HttpAuthResult
Expand Down Expand Up @@ -248,6 +249,17 @@ internal class FrontendViewModel @VisibleForTesting constructor(
emitAll(prefsRepository.autoPlayVideoFlow())
}.stateIn(viewModelScope, SharingStarted.Eagerly, initialValue = false)

/**
* The user's "Screen orientation" preference.
*
* Applied by the screen to the hosting activity's `requestedOrientation` so the dashboard
* obeys the user's portrait/landscape/system preference. Exposed as a [StateFlow] so the
* screen can read the current value synchronously when first attaching and react to changes.
*/
val screenOrientation: StateFlow<ScreenOrientation> = flow {
emitAll(prefsRepository.screenOrientationFlow())
}.stateIn(viewModelScope, SharingStarted.Eagerly, initialValue = ScreenOrientation.SYSTEM)

init {
viewModelScope.launch {
_viewState.collectLatest { state ->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import io.homeassistant.companion.android.common.R as commonR
import io.homeassistant.companion.android.common.data.integration.impl.entities.RateLimitResponse
import io.homeassistant.companion.android.common.data.prefs.NightModeTheme
import io.homeassistant.companion.android.common.data.prefs.PrefsRepository
import io.homeassistant.companion.android.common.data.prefs.ScreenOrientation
import io.homeassistant.companion.android.common.data.servers.ServerManager
import io.homeassistant.companion.android.database.server.Server
import io.homeassistant.companion.android.database.settings.SettingsDao
Expand Down Expand Up @@ -112,7 +113,7 @@ class SettingsPresenterImpl @Inject constructor(
"themes" -> nightModeManager.getCurrentNightMode().storageValue
"languages" -> langsManager.getCurrentLang()
"page_zoom" -> prefsRepository.getPageZoomLevel().toString()
"screen_orientation" -> prefsRepository.getScreenOrientation()
"screen_orientation" -> prefsRepository.getScreenOrientation().storageValue
else -> throw IllegalArgumentException("No string found by this key: $key")
}
}
Expand All @@ -123,7 +124,7 @@ class SettingsPresenterImpl @Inject constructor(
"themes" -> nightModeManager.saveNightMode(NightModeTheme.fromStorageValue(value))
"languages" -> langsManager.saveLang(value)
"page_zoom" -> prefsRepository.setPageZoomLevel(value?.toIntOrNull())
"screen_orientation" -> prefsRepository.saveScreenOrientation(value)
"screen_orientation" -> prefsRepository.saveScreenOrientation(ScreenOrientation.fromStorageValue(value))
else -> throw IllegalArgumentException("No string found by this key: $key")
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import android.app.PictureInPictureParams
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.content.pm.ActivityInfo
import android.content.pm.PackageManager
import android.graphics.Rect
import android.net.Uri
Expand Down Expand Up @@ -1354,19 +1353,7 @@ class WebViewActivity :
SensorWorker.start(this@WebViewActivity)
WebsocketManager.start(this@WebViewActivity)

requestedOrientation = when (presenter.getScreenOrientation()) {
getString(
R.string.screen_orientation_option_array_value_portrait,
),
-> ActivityInfo.SCREEN_ORIENTATION_PORTRAIT

getString(
R.string.screen_orientation_option_array_value_landscape,
),
-> ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE

else -> ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
}
requestedOrientation = presenter.getScreenOrientation().activityInfo

if (presenter.isKeepScreenOnEnabled()) {
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import android.content.Context
import android.content.IntentSender
import androidx.activity.result.ActivityResult
import androidx.lifecycle.Lifecycle
import io.homeassistant.companion.android.common.data.prefs.ScreenOrientation
import io.homeassistant.companion.android.common.util.GestureAction
import io.homeassistant.companion.android.common.util.GestureDirection
import io.homeassistant.companion.android.database.server.ServerConnectionInfo
Expand Down Expand Up @@ -35,7 +36,7 @@ interface WebViewPresenter {

suspend fun isFullScreen(): Boolean

suspend fun getScreenOrientation(): String?
suspend fun getScreenOrientation(): ScreenOrientation

suspend fun isKeepScreenOnEnabled(): Boolean

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import io.homeassistant.companion.android.BuildConfig
import io.homeassistant.companion.android.common.R as commonR
import io.homeassistant.companion.android.common.data.authentication.SessionState
import io.homeassistant.companion.android.common.data.prefs.PrefsRepository
import io.homeassistant.companion.android.common.data.prefs.ScreenOrientation
import io.homeassistant.companion.android.common.data.servers.ServerManager
import io.homeassistant.companion.android.common.data.servers.UrlState
import io.homeassistant.companion.android.common.util.GestureAction
Expand Down Expand Up @@ -380,7 +381,7 @@ class WebViewPresenterImpl @Inject constructor(
return prefsRepository.isFullScreenEnabled()
}

override suspend fun getScreenOrientation(): String? {
override suspend fun getScreenOrientation(): ScreenOrientation {
return prefsRepository.getScreenOrientation()
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package io.homeassistant.companion.android.frontend

import android.Manifest
import android.content.pm.ActivityInfo
import android.util.Rational
import android.view.View
import android.webkit.PermissionRequest as WebViewPermissionRequest
Expand All @@ -11,6 +12,7 @@ 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.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
Expand All @@ -27,6 +29,7 @@ import dagger.hilt.android.testing.HiltTestApplication
import io.homeassistant.companion.android.HiltComponentActivity
import io.homeassistant.companion.android.common.R as commonR
import io.homeassistant.companion.android.common.data.connectivity.ConnectivityCheckState
import io.homeassistant.companion.android.common.data.prefs.ScreenOrientation
import io.homeassistant.companion.android.common.data.servers.ServerManager
import io.homeassistant.companion.android.database.settings.SettingsDao
import io.homeassistant.companion.android.frontend.error.FrontendConnectionError
Expand Down Expand Up @@ -599,6 +602,92 @@ class FrontendScreenTest {
}
}

@Test
fun `Given screenOrientation toggles at runtime then activity requestedOrientation follows`() {
composeTestRule.activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
val orientationState = mutableStateOf(ScreenOrientation.SYSTEM)
composeTestRule.setContent {
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 = {},
screenOrientation = orientationState.value,
)
}

composeTestRule.runOnIdle {
assertEquals(ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED, composeTestRule.activity.requestedOrientation)
}

orientationState.value = ScreenOrientation.PORTRAIT
composeTestRule.runOnIdle {
assertEquals(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT, composeTestRule.activity.requestedOrientation)
}

orientationState.value = ScreenOrientation.LANDSCAPE
composeTestRule.runOnIdle {
assertEquals(ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE, composeTestRule.activity.requestedOrientation)
}

orientationState.value = ScreenOrientation.SYSTEM
composeTestRule.runOnIdle {
assertEquals(ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED, composeTestRule.activity.requestedOrientation)
}
}

@Test
fun `Given screenOrientation is PORTRAIT when content leaves composition then previous orientation is restored`() {
composeTestRule.activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
val visible = mutableStateOf(true)
composeTestRule.setContent {
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 = {},
screenOrientation = ScreenOrientation.PORTRAIT,
)
}
}

composeTestRule.runOnIdle {
assertEquals(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT, composeTestRule.activity.requestedOrientation)
}

visible.value = false
composeTestRule.runOnIdle {
assertEquals(
"requestedOrientation should be restored once the frontend leaves composition",
ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED,
composeTestRule.activity.requestedOrientation,
)
}
}

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 @@ -14,6 +14,7 @@ import io.homeassistant.companion.android.common.data.connectivity.ConnectivityC
import io.homeassistant.companion.android.common.data.connectivity.ConnectivityCheckResult
import io.homeassistant.companion.android.common.data.connectivity.ConnectivityCheckState
import io.homeassistant.companion.android.common.data.prefs.PrefsRepository
import io.homeassistant.companion.android.common.data.prefs.ScreenOrientation
import io.homeassistant.companion.android.common.data.prefs.ZoomSettings
import io.homeassistant.companion.android.common.util.GestureDirection
import io.homeassistant.companion.android.database.authentication.Authentication
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 screenOrientationFlow = MutableStateFlow(ScreenOrientation.SYSTEM)
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.screenOrientationFlow() } returns this@FrontendViewModelTest.screenOrientationFlow
}

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

@Nested
inner class ScreenOrientationSetting {

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

assertEquals(ScreenOrientation.SYSTEM, viewModel.screenOrientation.value)

screenOrientationFlow.value = ScreenOrientation.LANDSCAPE
advanceUntilIdle()

assertEquals(ScreenOrientation.LANDSCAPE, viewModel.screenOrientation.value)
}

@Test
fun `Given pref flow seeded with portrait when ViewModel constructed then exposed StateFlow has portrait`() = runTest {
screenOrientationFlow.value = ScreenOrientation.PORTRAIT

val viewModel = createViewModel()
advanceUntilIdle()

assertEquals(ScreenOrientation.PORTRAIT, viewModel.screenOrientation.value)
}
}
}
Loading