From 12b37757e11401772fb1d2ef71081cd2d0ef0443 Mon Sep 17 00:00:00 2001 From: Timothy <6560631+TimoPtr@users.noreply.github.com> Date: Wed, 20 May 2026 10:17:08 +0200 Subject: [PATCH 1/2] Introduce screen orientation to FrontendScreen and expose flow from pref --- .../android/frontend/FrontendScreen.kt | 26 ++++++ .../android/frontend/FrontendViewModel.kt | 12 +++ .../android/settings/SettingsPresenterImpl.kt | 5 +- .../android/webview/WebViewActivity.kt | 15 +--- .../android/webview/WebViewPresenter.kt | 3 +- .../android/webview/WebViewPresenterImpl.kt | 3 +- .../android/frontend/FrontendScreenTest.kt | 89 +++++++++++++++++++ .../android/frontend/FrontendViewModelTest.kt | 30 +++++++ .../common/data/prefs/PrefsRepository.kt | 32 ++++++- .../common/data/prefs/PrefsRepositoryImpl.kt | 14 ++- .../data/prefs/PrefsRepositoryImplTest.kt | 79 ++++++++++++++++ 11 files changed, 284 insertions(+), 24 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 c577b569636..aa810d3a9bf 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 @@ -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 @@ -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 @@ -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. @@ -173,6 +176,7 @@ internal fun FrontendScreen( onGesture = viewModel::onGesture, onExoPlayerFullscreenChanged = viewModel::onExoPlayerFullscreenChanged, autoPlayVideoEnabled = autoPlayVideoEnabled, + screenOrientation = screenOrientation, onPipReadinessChanged = onPipReadinessChanged, modifier = modifier, ) @@ -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, @@ -232,6 +237,8 @@ internal fun FrontendScreenContent( pendingRequest = pendingFileChooser, ) + ScreenOrientationEffect(orientation = screenOrientation) + Box(modifier = modifier.fillMaxSize()) { // Always render WebView at base layer SafeHAWebView( @@ -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. */ 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 41bc6a360f9..5e231f3dd92 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 @@ -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 @@ -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 = flow { + emitAll(prefsRepository.screenOrientationFlow()) + }.stateIn(viewModelScope, SharingStarted.Eagerly, initialValue = ScreenOrientation.SYSTEM) + init { viewModelScope.launch { _viewState.collectLatest { state -> diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/settings/SettingsPresenterImpl.kt b/app/src/main/kotlin/io/homeassistant/companion/android/settings/SettingsPresenterImpl.kt index 0740d0a86e9..8bf77922ebe 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/settings/SettingsPresenterImpl.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/settings/SettingsPresenterImpl.kt @@ -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 @@ -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") } } @@ -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") } } diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/webview/WebViewActivity.kt b/app/src/main/kotlin/io/homeassistant/companion/android/webview/WebViewActivity.kt index a67325e2117..51246515736 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/webview/WebViewActivity.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/webview/WebViewActivity.kt @@ -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 @@ -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) diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/webview/WebViewPresenter.kt b/app/src/main/kotlin/io/homeassistant/companion/android/webview/WebViewPresenter.kt index 550d8c0c462..592a5441f30 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/webview/WebViewPresenter.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/webview/WebViewPresenter.kt @@ -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 @@ -35,7 +36,7 @@ interface WebViewPresenter { suspend fun isFullScreen(): Boolean - suspend fun getScreenOrientation(): String? + suspend fun getScreenOrientation(): ScreenOrientation suspend fun isKeepScreenOnEnabled(): Boolean diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/webview/WebViewPresenterImpl.kt b/app/src/main/kotlin/io/homeassistant/companion/android/webview/WebViewPresenterImpl.kt index d0d11174f49..4dd2acbe14c 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/webview/WebViewPresenterImpl.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/webview/WebViewPresenterImpl.kt @@ -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 @@ -380,7 +381,7 @@ class WebViewPresenterImpl @Inject constructor( return prefsRepository.isFullScreenEnabled() } - override suspend fun getScreenOrientation(): String? { + override suspend fun getScreenOrientation(): ScreenOrientation { return prefsRepository.getScreenOrientation() } diff --git a/app/src/test/kotlin/io/homeassistant/companion/android/frontend/FrontendScreenTest.kt b/app/src/test/kotlin/io/homeassistant/companion/android/frontend/FrontendScreenTest.kt index 1596e0a8bd5..296b778dcf0 100644 --- a/app/src/test/kotlin/io/homeassistant/companion/android/frontend/FrontendScreenTest.kt +++ b/app/src/test/kotlin/io/homeassistant/companion/android/frontend/FrontendScreenTest.kt @@ -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 @@ -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 @@ -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 @@ -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, HiltComponentActivity>.assertIsLoading(show: Boolean) { val node = onNodeWithContentDescription(stringResource(commonR.string.loading_content_description)) if (show) { 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 01abe514276..b35adf29331 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 @@ -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 @@ -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 @@ -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) + } + } } 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 0f69ea7a42c..e256f3df71c 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 @@ -1,5 +1,6 @@ package io.homeassistant.companion.android.common.data.prefs +import android.content.pm.ActivityInfo import android.os.Parcelable import io.homeassistant.companion.android.common.data.integration.ControlsAuthRequiredSetting import io.homeassistant.companion.android.common.util.GestureAction @@ -7,6 +8,26 @@ import io.homeassistant.companion.android.common.util.HAGesture import kotlinx.coroutines.flow.Flow import kotlinx.parcelize.Parcelize +/** + * Screen orientation preference applied to the dashboard host activity. + * + * The [storageValue]s match the entries declared in the `pref_screen_orientation_option_values` + * string-array used by the settings ListPreference, so values written by the legacy settings UI + * still resolve to a typed enum here. + */ +enum class ScreenOrientation(val storageValue: String, val activityInfo: Int) { + SYSTEM("system", ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED), + PORTRAIT("portrait", ActivityInfo.SCREEN_ORIENTATION_PORTRAIT), + LANDSCAPE("landscape", ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE), + ; + + companion object { + /** Returns the matching entry or [SYSTEM] when [value] is null or unknown. */ + fun fromStorageValue(value: String?): ScreenOrientation = + entries.firstOrNull { it.storageValue == value } ?: SYSTEM + } +} + enum class NightModeTheme(val storageValue: String) { LIGHT("light"), DARK("dark"), @@ -82,9 +103,16 @@ interface PrefsRepository { suspend fun setKeepScreenOnEnabled(enabled: Boolean) - suspend fun getScreenOrientation(): String? + /** + * Returns the user's current screen orientation preference. Falls back to + * [ScreenOrientation.SYSTEM] when no value is stored or the stored value cannot be resolved. + */ + suspend fun getScreenOrientation(): ScreenOrientation + + suspend fun saveScreenOrientation(orientation: ScreenOrientation) - suspend fun saveScreenOrientation(orientation: String?) + /** Emits the current [ScreenOrientation] preference immediately on collection, then on every change. */ + suspend fun screenOrientationFlow(): Flow suspend fun getPageZoomLevel(): Int 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 ef0bf61ed61..a6a916d593b 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 @@ -142,12 +142,18 @@ internal class PrefsRepositoryImpl @Inject constructor( localStorage().putString(PREF_LOCALES, lang) } - override suspend fun getScreenOrientation(): String? { - return localStorage().getString(PREF_SCREEN_ORIENTATION) + override suspend fun getScreenOrientation(): ScreenOrientation { + return ScreenOrientation.fromStorageValue(localStorage().getString(PREF_SCREEN_ORIENTATION)) } - override suspend fun saveScreenOrientation(orientation: String?) { - localStorage().putString(PREF_SCREEN_ORIENTATION, orientation) + override suspend fun saveScreenOrientation(orientation: ScreenOrientation) { + localStorage().putString(PREF_SCREEN_ORIENTATION, orientation.storageValue) + } + + override suspend fun screenOrientationFlow(): Flow { + return localStorage().observeChanges(PREF_SCREEN_ORIENTATION) { + getScreenOrientation() + } } override suspend fun getControlsAuthRequired(): ControlsAuthRequiredSetting { 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 6fc6dfc7292..d4e3a29039e 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 @@ -171,6 +171,85 @@ class PrefsRepositoryImplTest { } } + @Test + fun `Given collecting flow when screen orientation changes then typed value is emitted`() = runTest { + coEvery { localStorage.getString("screen_orientation") } returns null + + repository.screenOrientationFlow().test { + // Null storage value falls back to SYSTEM + assertEquals(ScreenOrientation.SYSTEM, awaitItem()) + + coEvery { localStorage.getString("screen_orientation") } returns "portrait" + keyChangesFlow.emit("screen_orientation") + assertEquals(ScreenOrientation.PORTRAIT, awaitItem()) + + coEvery { localStorage.getString("screen_orientation") } returns "landscape" + keyChangesFlow.emit("screen_orientation") + assertEquals(ScreenOrientation.LANDSCAPE, awaitItem()) + + // Unknown value falls back to SYSTEM + coEvery { localStorage.getString("screen_orientation") } returns "garbage" + keyChangesFlow.emit("screen_orientation") + assertEquals(ScreenOrientation.SYSTEM, awaitItem()) + + cancelAndIgnoreRemainingEvents() + } + } + + @Nested + inner class GetScreenOrientation { + + @Test + fun `Given null storage value when get then returns SYSTEM`() = runTest { + coEvery { localStorage.getString("screen_orientation") } returns null + + assertEquals(ScreenOrientation.SYSTEM, repository.getScreenOrientation()) + } + + @ParameterizedTest + @CsvSource( + "system, SYSTEM", + "portrait, PORTRAIT", + "landscape, LANDSCAPE", + ) + fun `Given storage value when get then returns matching enum`( + storedValue: String, + expected: ScreenOrientation, + ) = runTest { + coEvery { localStorage.getString("screen_orientation") } returns storedValue + + assertEquals(expected, repository.getScreenOrientation()) + } + + @Test + fun `Given unknown storage value when get then returns SYSTEM`() = runTest { + coEvery { localStorage.getString("screen_orientation") } returns "unknown-value" + + assertEquals(ScreenOrientation.SYSTEM, repository.getScreenOrientation()) + } + } + + @Nested + inner class SaveScreenOrientation { + + @ParameterizedTest + @CsvSource( + "SYSTEM, system", + "PORTRAIT, portrait", + "LANDSCAPE, landscape", + ) + fun `Given enum when save then storage value is the storageValue string`( + orientation: ScreenOrientation, + expectedStored: String, + ) = runTest { + coEvery { localStorage.putString(any(), any()) } returns Unit + + repository.saveScreenOrientation(orientation) + + coVerify { localStorage.putString("screen_orientation", expectedStored) } + } + } + @Nested inner class ZoomSettingsFlow { From cfe1fb1bc88492147e8ef6791cd4edec1e155b30 Mon Sep 17 00:00:00 2001 From: Timothy <6560631+TimoPtr@users.noreply.github.com> Date: Thu, 21 May 2026 11:41:11 +0200 Subject: [PATCH 2/2] Apply suggestion --- .../companion/android/settings/SettingsPresenterImpl.kt | 2 +- .../companion/android/common/data/prefs/PrefsRepository.kt | 2 +- .../companion/android/common/data/prefs/PrefsRepositoryImpl.kt | 2 +- .../android/common/data/prefs/PrefsRepositoryImplTest.kt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/settings/SettingsPresenterImpl.kt b/app/src/main/kotlin/io/homeassistant/companion/android/settings/SettingsPresenterImpl.kt index 8bf77922ebe..c2347fac559 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/settings/SettingsPresenterImpl.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/settings/SettingsPresenterImpl.kt @@ -124,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(ScreenOrientation.fromStorageValue(value)) + "screen_orientation" -> prefsRepository.setScreenOrientation(ScreenOrientation.fromStorageValue(value)) else -> throw IllegalArgumentException("No string found by this key: $key") } } 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 e256f3df71c..f55c98245b1 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 @@ -109,7 +109,7 @@ interface PrefsRepository { */ suspend fun getScreenOrientation(): ScreenOrientation - suspend fun saveScreenOrientation(orientation: ScreenOrientation) + suspend fun setScreenOrientation(orientation: ScreenOrientation) /** Emits the current [ScreenOrientation] preference immediately on collection, then on every change. */ suspend fun screenOrientationFlow(): 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 a6a916d593b..25c1178036a 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 @@ -146,7 +146,7 @@ internal class PrefsRepositoryImpl @Inject constructor( return ScreenOrientation.fromStorageValue(localStorage().getString(PREF_SCREEN_ORIENTATION)) } - override suspend fun saveScreenOrientation(orientation: ScreenOrientation) { + override suspend fun setScreenOrientation(orientation: ScreenOrientation) { localStorage().putString(PREF_SCREEN_ORIENTATION, orientation.storageValue) } 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 d4e3a29039e..ccefaf2a50c 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 @@ -244,7 +244,7 @@ class PrefsRepositoryImplTest { ) = runTest { coEvery { localStorage.putString(any(), any()) } returns Unit - repository.saveScreenOrientation(orientation) + repository.setScreenOrientation(orientation) coVerify { localStorage.putString("screen_orientation", expectedStored) } }