diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index e52257f3228..27c79118cc2 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -112,6 +112,7 @@ 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 14f1376e664..c577b569636 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 @@ -12,6 +12,7 @@ import androidx.annotation.VisibleForTesting import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -65,6 +66,7 @@ import io.homeassistant.companion.android.frontend.permissions.MultiplePermissio import io.homeassistant.companion.android.frontend.permissions.NotificationPermissionPrompt import io.homeassistant.companion.android.frontend.permissions.PermissionRequest import io.homeassistant.companion.android.frontend.permissions.SinglePermissionEffect +import io.homeassistant.companion.android.launch.PipReadiness import io.homeassistant.companion.android.loading.LoadingScreen import io.homeassistant.companion.android.onboarding.locationforsecureconnection.LocationForSecureConnectionScreen import io.homeassistant.companion.android.onboarding.locationforsecureconnection.LocationForSecureConnectionViewModel @@ -115,6 +117,7 @@ internal fun FrontendScreen( onSecurityLevelHelpClick: suspend () -> Unit, onShowSnackbar: suspend (message: String, action: String?) -> Boolean, modifier: Modifier = Modifier, + onPipReadinessChanged: (PipReadiness?) -> Unit = {}, ) { val viewState by viewModel.viewState.collectAsStateWithLifecycle() val pendingPermissionRequest by viewModel.pendingPermissionRequest.collectAsStateWithLifecycle() @@ -170,6 +173,7 @@ internal fun FrontendScreen( onGesture = viewModel::onGesture, onExoPlayerFullscreenChanged = viewModel::onExoPlayerFullscreenChanged, autoPlayVideoEnabled = autoPlayVideoEnabled, + onPipReadinessChanged = onPipReadinessChanged, modifier = modifier, ) } @@ -204,6 +208,7 @@ internal fun FrontendScreenContent( webViewActions: Flow = emptyFlow(), onGesture: (GestureDirection, Int) -> Unit = { _, _ -> }, onExoPlayerFullscreenChanged: (Boolean) -> Unit = {}, + onPipReadinessChanged: (PipReadiness?) -> Unit = {}, ) { var webView by remember { mutableStateOf(null) } @@ -241,13 +246,13 @@ internal fun FrontendScreenContent( autoPlayVideoEnabled = autoPlayVideoEnabled, ) - ExoPlayerOverlay( + PipEligibleOverlays( contentState = viewState as? FrontendViewState.Content, - onFullscreenChanged = onExoPlayerFullscreenChanged, + customView = customView, + onExoPlayerFullscreenChanged = onExoPlayerFullscreenChanged, + onPipReadinessChanged = onPipReadinessChanged, ) - CustomViewOverlay(customView = customView) - StateOverlay( viewState = viewState, errorStateProvider = errorStateProvider, @@ -582,19 +587,24 @@ private fun PendingPermissionHandler(pendingRequest: PermissionRequest?) { onDismiss = pendingRequest.onDismiss, ) } + is PermissionRequest.MultiplePermissions -> { MultiplePermissionsEffect( pendingRequest = pendingRequest, onPermissionResult = pendingRequest.onResult, ) } + is PermissionRequest.SinglePermission -> { SinglePermissionEffect( pendingRequest = pendingRequest, onPermissionResult = pendingRequest.onResult, ) } - null -> { /* No pending permission */ } + + null -> { + /* No pending permission */ + } } } @@ -635,6 +645,33 @@ private fun WebViewEffects( } } +/** + * Renders PiP-eligible overlays and reports their combined [PipReadiness] to the host. + */ +@Composable +private fun BoxScope.PipEligibleOverlays( + contentState: FrontendViewState.Content?, + customView: View?, + onExoPlayerFullscreenChanged: (Boolean) -> Unit, + onPipReadinessChanged: (PipReadiness?) -> Unit, +) { + val exoState = contentState?.exoPlayerState + val readiness = remember(customView, exoState?.isFullScreen, exoState?.videoAspectRatio) { + PipReadiness.from(customViewShown = customView != null, exoState = exoState) + } + + LaunchedEffect(readiness) { onPipReadinessChanged(readiness) } + DisposableEffect(Unit) { + onDispose { onPipReadinessChanged(null) } + } + + ExoPlayerOverlay( + contentState = contentState, + onFullscreenChanged = onExoPlayerFullscreenChanged, + ) + CustomViewOverlay(customView = customView) +} + @Composable private fun CustomViewOverlay(customView: View?) { val view: View = customView ?: return diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/frontend/navigation/FrontendNavigation.kt b/app/src/main/kotlin/io/homeassistant/companion/android/frontend/navigation/FrontendNavigation.kt index efed3e71bfb..01d59c3aab5 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/frontend/navigation/FrontendNavigation.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/frontend/navigation/FrontendNavigation.kt @@ -19,6 +19,7 @@ import io.homeassistant.companion.android.common.data.servers.ServerManager.Comp import io.homeassistant.companion.android.frontend.FrontendScreen import io.homeassistant.companion.android.frontend.FrontendViewModel import io.homeassistant.companion.android.launch.HAStartDestinationRoute +import io.homeassistant.companion.android.launch.PipReadiness import io.homeassistant.companion.android.nfc.WriteNfcTag import io.homeassistant.companion.android.settings.SettingsActivity import io.homeassistant.companion.android.util.getActivity @@ -72,6 +73,7 @@ internal fun NavGraphBuilder.frontendScreen( onShowSnackbar: suspend (message: String, action: String?) -> Boolean, onShowServerSwitcher: (onServerSelected: (Int) -> Unit) -> Unit, onRequestFullscreen: (Boolean) -> Unit = {}, + onPipReadinessChanged: (PipReadiness?) -> Unit = {}, ) { if (WIPFeature.USE_FRONTEND_V2) { composable { @@ -113,6 +115,7 @@ internal fun NavGraphBuilder.frontendScreen( onConfigureHomeNetwork = onConfigureHomeNetwork, onSecurityLevelHelpClick = onSecurityLevelHelpClick, onShowSnackbar = onShowSnackbar, + onPipReadinessChanged = onPipReadinessChanged, ) } } else { diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/launch/LaunchActivity.kt b/app/src/main/kotlin/io/homeassistant/companion/android/launch/LaunchActivity.kt index 6c98ff8fdf6..b3be19c9499 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/launch/LaunchActivity.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/launch/LaunchActivity.kt @@ -1,7 +1,10 @@ package io.homeassistant.companion.android.launch +import android.app.PictureInPictureParams import android.content.Context import android.content.Intent +import android.content.pm.PackageManager +import android.os.Build import android.os.Bundle import android.os.Parcelable import androidx.activity.compose.LocalActivity @@ -159,6 +162,7 @@ class LaunchActivity : AppCompatActivity() { startDestination = (uiState as? LaunchUiState.Ready)?.startDestination, snackbarHostState = snackbarHostState, onRequestFullscreen = viewModel::onFullscreenRequested, + onPipReadinessChanged = viewModel::onPipReadinessChanged, modifier = Modifier.hazeSource(hazeState), ) @@ -203,6 +207,22 @@ class LaunchActivity : AppCompatActivity() { if (!isFinishing && WIPFeature.USE_FRONTEND_V2) SensorReceiver.updateAllSensors(this) } + override fun onUserLeaveHint() { + super.onUserLeaveHint() + if (WIPFeature.USE_FRONTEND_V2) { + viewModel.onAppPaused() + + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return + if (!packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE)) return + val readiness = viewModel.pipReadiness.value ?: return + val params = PictureInPictureParams.Builder() + .setAspectRatio(readiness.aspectRatio) + .apply { readiness.sourceRect?.let(::setSourceRectHint) } + .build() + enterPictureInPictureMode(params) + } + } + override fun onStop() { super.onStop() if (WIPFeature.USE_FRONTEND_V2) { diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/launch/LaunchViewModel.kt b/app/src/main/kotlin/io/homeassistant/companion/android/launch/LaunchViewModel.kt index 210d20df81c..861c7cb4c60 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/launch/LaunchViewModel.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/launch/LaunchViewModel.kt @@ -134,6 +134,17 @@ internal class LaunchViewModel @VisibleForTesting constructor( private val _isAppLocked = MutableStateFlow(false) val isAppLocked: StateFlow = _isAppLocked.asStateFlow() + private val _pipReadiness = MutableStateFlow(null) + + /** + * Latest [PipReadiness] reported by the active screen, or `null` if no screen is currently + * displaying PiP-eligible content. + * + * Read by [LaunchActivity] to build [android.app.PictureInPictureParams] when the user + * backgrounds the app or when `setAutoEnterEnabled` is honored by the OS (API 31+). + */ + val pipReadiness: StateFlow = _pipReadiness.asStateFlow() + init { viewModelScope.launch { cleanupServers() @@ -185,6 +196,13 @@ internal class LaunchViewModel @VisibleForTesting constructor( fullscreenRequested.value = fullscreen } + /** + * Updates [pipReadiness] from the screen layer. `null` indicates no PiP-eligible content. + */ + fun onPipReadinessChanged(readiness: PipReadiness?) { + _pipReadiness.value = readiness + } + private suspend fun handleInitialState(initialDeepLink: LaunchActivity.DeepLink?) { when (initialDeepLink) { is LaunchActivity.DeepLink.OpenOnboarding -> navigateToOnboarding( diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/launch/PipReadiness.kt b/app/src/main/kotlin/io/homeassistant/companion/android/launch/PipReadiness.kt new file mode 100644 index 00000000000..582f79a4f45 --- /dev/null +++ b/app/src/main/kotlin/io/homeassistant/companion/android/launch/PipReadiness.kt @@ -0,0 +1,84 @@ +package io.homeassistant.companion.android.launch + +import android.graphics.Rect +import android.util.Rational +import io.homeassistant.companion.android.frontend.exoplayer.ExoPlayerUiState + +/** + * Narrowest aspect ratio (width:height) accepted by + * [android.app.PictureInPictureParams.Builder.setAspectRatio], per its documented range of + * 1:2.39 to 2.39:1 inclusive. We clamp to it so the system does not reject the params later + * when entering PiP. + */ +private val PIP_MIN_ASPECT = Rational(100, 239) + +/** + * Widest aspect ratio (width:height) accepted by + * [android.app.PictureInPictureParams.Builder.setAspectRatio], per its documented range of + * 1:2.39 to 2.39:1 inclusive. We clamp to it so the system does not reject the params later + * when entering PiP. + */ +private val PIP_MAX_ASPECT = Rational(239, 100) + +/** + * Fallback PiP aspect ratio used when the source content does not advertise its own — + * e.g. a WebView custom view, or a video that has not yet reported its dimensions. 16:9 + * matches the most common video framing and sits comfortably inside the PiP allowed range. + */ +private val PIP_DEFAULT_ASPECT = Rational(16, 9) + +/** + * Snapshot of "the screen has PiP-eligible content" along with the parameters needed to enter + * Picture-in-Picture mode. + * + * `null` upstream of this type means the host activity must not enter PiP. Both fields are + * value types — keeping `android.app.PictureInPictureParams` (API 26+) out of the ViewModel + * avoids leaking an Android system type into shared Compose plumbing. + * + * @param aspectRatio Pre-clamped to Android's allowed PiP range `[1:2.39, 2.39:1]`. + * @param sourceRect Optional layout hint used for the launch animation. `null` lets Android + * infer it from the activity's content. + */ +data class PipReadiness(val aspectRatio: Rational, val sourceRect: Rect? = null) { + companion object { + /** + * Computes the [PipReadiness] snapshot for the current screen state. + * + * Returns `null` when no PiP-eligible content is showing. When both an ExoPlayer fullscreen + * stream and a custom view are simultaneously present (theoretically possible — different + * frontend paths choose between them), the player is the more specific signal and wins. + * + * The returned aspect ratio is clamped to Android's allowed PiP range `[1:2.39, 2.39:1]` so + * `PictureInPictureParams.Builder.setAspectRatio` cannot throw. + */ + fun from(customViewShown: Boolean, exoState: ExoPlayerUiState?): PipReadiness? { + val playerFullScreen = exoState?.isFullScreen == true + if (!playerFullScreen && !customViewShown) return null + + val aspect = if (playerFullScreen) { + exoState.videoAspectRatio?.let(::aspectFromHeightOverWidth) ?: PIP_DEFAULT_ASPECT + } else { + PIP_DEFAULT_ASPECT + } + return PipReadiness(aspectRatio = aspect.coerceWithinPipRange()) + } + + private fun Rational.coerceWithinPipRange(): Rational = when { + toFloat() < PIP_MIN_ASPECT.toFloat() -> PIP_MIN_ASPECT + toFloat() > PIP_MAX_ASPECT.toFloat() -> PIP_MAX_ASPECT + else -> this + } + + private fun aspectFromHeightOverWidth(heightOverWidth: Double): Rational { + // ExoPlayer stores ratio as height/width; PictureInPictureParams wants width:height. + // Multiply by 1_000 before truncating to integers so we keep three significant digits + // before `Rational`'s built-in reduction collapses common cases like 16:9 or 4:3. + val widthScaled = 1_000.0 + val heightScaled = heightOverWidth * 1_000.0 + return Rational( + widthScaled.toInt().coerceAtLeast(1), + heightScaled.toInt().coerceAtLeast(1), + ) + } + } +} diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/util/compose/HAApp.kt b/app/src/main/kotlin/io/homeassistant/companion/android/util/compose/HAApp.kt index bdc8d4822ad..90234126fec 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/util/compose/HAApp.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/util/compose/HAApp.kt @@ -19,6 +19,7 @@ import androidx.compose.ui.Modifier import androidx.navigation.NavHostController import io.homeassistant.companion.android.common.compose.theme.LocalHAColorScheme import io.homeassistant.companion.android.launch.HAStartDestinationRoute +import io.homeassistant.companion.android.launch.PipReadiness import io.homeassistant.companion.android.loading.LoadingScreen /** @@ -43,6 +44,7 @@ internal fun HAApp( snackbarHostState: SnackbarHostState, modifier: Modifier = Modifier, onRequestFullscreen: (Boolean) -> Unit = {}, + onPipReadinessChanged: (PipReadiness?) -> Unit = {}, ) { Scaffold( modifier = modifier, @@ -71,6 +73,7 @@ internal fun HAApp( navController = navController, startDestination = startDestination, onRequestFullscreen = onRequestFullscreen, + onPipReadinessChanged = onPipReadinessChanged, onShowSnackbar = { message, action -> snackbarHostState.showSnackbar( message, diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/util/compose/HANavHost.kt b/app/src/main/kotlin/io/homeassistant/companion/android/util/compose/HANavHost.kt index 273f9db05b0..615c71bac89 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/util/compose/HANavHost.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/util/compose/HANavHost.kt @@ -16,6 +16,7 @@ import io.homeassistant.companion.android.frontend.navigation.FrontendRoute import io.homeassistant.companion.android.frontend.navigation.frontendScreen import io.homeassistant.companion.android.frontend.navigation.navigateToFrontend import io.homeassistant.companion.android.launch.HAStartDestinationRoute +import io.homeassistant.companion.android.launch.PipReadiness import io.homeassistant.companion.android.loading.LoadingScreen import io.homeassistant.companion.android.loading.navigation.LoadingRoute import io.homeassistant.companion.android.loading.navigation.loadingScreen @@ -51,6 +52,7 @@ internal fun HANavHost( startDestination: HAStartDestinationRoute?, onShowSnackbar: suspend (message: String, action: String?) -> Boolean, onRequestFullscreen: (Boolean) -> Unit = {}, + onPipReadinessChanged: (PipReadiness?) -> Unit = {}, ) { val activity = LocalActivity.current val isAutomotive = activity?.isAutomotive() == true @@ -123,6 +125,7 @@ internal fun HANavHost( onShowSnackbar = onShowSnackbar, onShowServerSwitcher = { onServerSelected -> showServerSwitcher(activity, onServerSelected) }, onRequestFullscreen = onRequestFullscreen, + onPipReadinessChanged = onPipReadinessChanged, ) setHomeNetworkScreen( onGotoNextScreen = { 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 2946d4d19f7..1596e0a8bd5 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.util.Rational import android.view.View import android.webkit.PermissionRequest as WebViewPermissionRequest import android.webkit.WebChromeClient @@ -33,6 +34,7 @@ import io.homeassistant.companion.android.frontend.error.FrontendConnectionError import io.homeassistant.companion.android.frontend.js.FrontendJsBridge import io.homeassistant.companion.android.frontend.permissions.PermissionManager import io.homeassistant.companion.android.frontend.permissions.PermissionRequest +import io.homeassistant.companion.android.launch.PipReadiness import io.homeassistant.companion.android.testing.unit.ConsoleLogRule import io.homeassistant.companion.android.testing.unit.stringResource import io.homeassistant.companion.android.util.FakePermissionResultRegistry @@ -46,6 +48,8 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -527,6 +531,74 @@ class FrontendScreenTest { } } + @Test + fun `Given Content with customView when reporter runs then PipReadiness is published with default aspect`() { + val captured = mutableListOf() + + composeTestRule.setContent { + val context = androidx.compose.ui.platform.LocalContext.current + FrontendScreenContent( + onBackClick = {}, + viewState = FrontendViewState.Content( + serverId = 1, + url = "https://example.com", + ), + customView = View(context), + webViewClient = WebViewClient(), + webChromeClient = WebChromeClient(), + frontendJsCallback = FrontendJsBridge.noOp, + onBlockInsecureRetry = {}, + onOpenExternalLink = {}, + onBlockInsecureHelpClick = {}, + onOpenSettings = {}, + onChangeSecurityLevel = {}, + onOpenLocationSettings = {}, + onConfigureHomeNetwork = { _ -> }, + onSecurityLevelHelpClick = {}, + onShowSnackbar = { _, _ -> true }, + onWebViewCreationFailed = {}, + onPipReadinessChanged = { captured += it }, + ) + } + + composeTestRule.runOnIdle { + assertEquals(Rational(16, 9), captured.lastOrNull()?.aspectRatio) + } + } + + @Test + fun `Given no customView and no fullscreen player when reporter runs then PipReadiness is null`() { + val captured = mutableListOf() + + 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 = {}, + onPipReadinessChanged = { captured += it }, + ) + } + + composeTestRule.runOnIdle { + assertNull(captured.lastOrNull()) + } + } + 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/PipReadinessTest.kt b/app/src/test/kotlin/io/homeassistant/companion/android/frontend/PipReadinessTest.kt new file mode 100644 index 00000000000..8c638b7deae --- /dev/null +++ b/app/src/test/kotlin/io/homeassistant/companion/android/frontend/PipReadinessTest.kt @@ -0,0 +1,102 @@ +package io.homeassistant.companion.android.frontend + +import android.util.Rational +import androidx.media3.common.Player +import dagger.hilt.android.testing.HiltTestApplication +import io.homeassistant.companion.android.frontend.exoplayer.ExoPlayerUiState +import io.homeassistant.companion.android.launch.PipReadiness +import io.mockk.mockk +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * Robolectric is required because `android.util.Rational#equals` is stubbed in pure JVM unit + * tests — comparing two distinct `Rational` instances always returns false without it. + */ +@RunWith(RobolectricTestRunner::class) +@Config(application = HiltTestApplication::class) +class PipReadinessTest { + + private val player: Player = mockk(relaxed = true) + + @Test + fun `Given neither customView nor fullscreen player when computed then result is null`() { + val result = PipReadiness.from(customViewShown = false, exoState = null) + + assertNull(result) + } + + @Test + fun `Given non-fullscreen player without customView when computed then result is null`() { + val state = ExoPlayerUiState(player = player, isFullScreen = false, videoAspectRatio = 9.0 / 16.0) + + val result = PipReadiness.from(customViewShown = false, exoState = state) + + assertNull(result) + } + + @Test + fun `Given customView shown when computed then result uses 16 to 9 default`() { + val result = PipReadiness.from(customViewShown = true, exoState = null) + + assertEquals(Rational(16, 9), result?.aspectRatio) + assertNull(result?.sourceRect) + } + + @Test + fun `Given fullscreen player without aspect ratio when computed then result uses 16 to 9 default`() { + val state = ExoPlayerUiState(player = player, isFullScreen = true, videoAspectRatio = null) + + val result = PipReadiness.from(customViewShown = false, exoState = state) + + assertEquals(Rational(16, 9), result?.aspectRatio) + } + + @Test + fun `Given fullscreen player and customView when computed then player aspect wins`() { + val state = ExoPlayerUiState(player = player, isFullScreen = true, videoAspectRatio = 3.0 / 4.0) + + val result = PipReadiness.from(customViewShown = true, exoState = state) + + // 4:3 -> width 1000, height 750 -> Rational(4, 3) after reduction + assertEquals(Rational(4, 3), result?.aspectRatio) + } + + @Test + fun `Given fullscreen player with 16 to 9 aspect when computed then aspect is preserved`() { + // 9.0 / 16.0 = 0.5625; widthScaled=1000, heightScaled=562 -> Rational(500, 281) + val result = computeFor(heightOverWidth = 9.0 / 16.0) + + assertEquals(Rational(500, 281), result?.aspectRatio) + } + + @Test + fun `Given fullscreen player with 4 to 3 aspect when computed then aspect is preserved`() { + val result = computeFor(heightOverWidth = 3.0 / 4.0) + + assertEquals(Rational(4, 3), result?.aspectRatio) + } + + @Test + fun `Given fullscreen player with 3 to 1 ultra-wide aspect when computed then aspect is clamped to max`() { + val result = computeFor(heightOverWidth = 1.0 / 3.0) + + assertEquals(Rational(239, 100), result?.aspectRatio) + } + + @Test + fun `Given fullscreen player with 1 to 3 ultra-tall aspect when computed then aspect is clamped to min`() { + val result = computeFor(heightOverWidth = 3.0) + + assertEquals(Rational(100, 239), result?.aspectRatio) + } + + private fun computeFor(heightOverWidth: Double) = PipReadiness.from( + customViewShown = false, + exoState = ExoPlayerUiState(player = player, isFullScreen = true, videoAspectRatio = heightOverWidth), + ) +} diff --git a/app/src/test/kotlin/io/homeassistant/companion/android/launch/LaunchActivityTest.kt b/app/src/test/kotlin/io/homeassistant/companion/android/launch/LaunchActivityTest.kt index 50268bc0940..81f031e8433 100644 --- a/app/src/test/kotlin/io/homeassistant/companion/android/launch/LaunchActivityTest.kt +++ b/app/src/test/kotlin/io/homeassistant/companion/android/launch/LaunchActivityTest.kt @@ -1,6 +1,11 @@ package io.homeassistant.companion.android.launch +import android.app.Activity +import android.content.Context +import android.content.pm.PackageManager +import android.util.Rational import androidx.lifecycle.Lifecycle +import androidx.lifecycle.ViewModelProvider import androidx.test.core.app.ActivityScenario import androidx.test.core.app.ApplicationProvider import androidx.work.testing.WorkManagerTestInitHelper @@ -29,11 +34,14 @@ import io.mockk.unmockkConstructor import io.mockk.unmockkObject import io.mockk.verify import org.junit.After +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf import org.robolectric.annotation.Config @RunWith(RobolectricTestRunner::class) @@ -95,4 +103,67 @@ class LaunchActivityTest { verify { SensorReceiver.updateAllSensors(any()) } } } + + @Test + fun `Given PIP feature available and readiness reported when user leaves then PIP is entered`() { + setPipFeatureAvailable(available = true) + + ActivityScenario.launch(LaunchActivity::class.java).use { scenario -> + scenario.onActivity { activity -> + pipViewModelOf(activity).onPipReadinessChanged( + PipReadiness(aspectRatio = Rational(16, 9)), + ) + + invokeOnUserLeaveHint(activity) + + assertTrue(activity.isInPictureInPictureMode) + } + } + } + + @Test + fun `Given PIP feature available but no readiness when user leaves then PIP is not entered`() { + setPipFeatureAvailable(available = true) + + ActivityScenario.launch(LaunchActivity::class.java).use { scenario -> + scenario.onActivity { activity -> + invokeOnUserLeaveHint(activity) + + assertFalse(activity.isInPictureInPictureMode) + } + } + } + + @Test + fun `Given device without PIP feature when user leaves then PIP is not entered`() { + setPipFeatureAvailable(available = false) + + ActivityScenario.launch(LaunchActivity::class.java).use { scenario -> + scenario.onActivity { activity -> + pipViewModelOf(activity).onPipReadinessChanged( + PipReadiness(aspectRatio = Rational(16, 9)), + ) + + invokeOnUserLeaveHint(activity) + + assertFalse(activity.isInPictureInPictureMode) + } + } + } + + private fun setPipFeatureAvailable(available: Boolean) { + val context = ApplicationProvider.getApplicationContext() + shadowOf(context.packageManager) + .setSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE, available) + } + + private fun pipViewModelOf(activity: LaunchActivity): LaunchViewModel = ViewModelProvider(activity)[LaunchViewModel::class.java] + + // `Activity.onUserLeaveHint` is `protected`, so reflect into Activity to dispatch through + // the override. ActivityScenario does not expose a "user leaving" lifecycle helper. + private fun invokeOnUserLeaveHint(activity: Activity) { + val method = Activity::class.java.getDeclaredMethod("onUserLeaveHint") + method.isAccessible = true + method.invoke(activity) + } } diff --git a/app/src/test/kotlin/io/homeassistant/companion/android/launch/LaunchViewModelTest.kt b/app/src/test/kotlin/io/homeassistant/companion/android/launch/LaunchViewModelTest.kt index e4c5df67605..941a8467a5e 100644 --- a/app/src/test/kotlin/io/homeassistant/companion/android/launch/LaunchViewModelTest.kt +++ b/app/src/test/kotlin/io/homeassistant/companion/android/launch/LaunchViewModelTest.kt @@ -1,5 +1,7 @@ package io.homeassistant.companion.android.launch +import android.graphics.Rect +import android.util.Rational import androidx.work.OneTimeWorkRequest import androidx.work.WorkManager import io.homeassistant.companion.android.applock.AppLockStateManager @@ -29,6 +31,7 @@ import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith @@ -676,4 +679,39 @@ class LaunchViewModelTest { assertTrue(viewModel.isFullScreen.value) } + + @Test + fun `Given no pip readiness reported when initialized then pipReadiness is null`() = runTest { + createViewModel() + advanceUntilIdle() + + assertNull(viewModel.pipReadiness.value) + } + + @Test + fun `Given onPipReadinessChanged with non-null when called then pipReadiness reflects it`() = runTest { + createViewModel() + advanceUntilIdle() + + val readiness = PipReadiness( + aspectRatio = Rational(16, 9), + sourceRect = Rect(0, 0, 1920, 1080), + ) + viewModel.onPipReadinessChanged(readiness) + advanceUntilIdle() + + assertEquals(readiness, viewModel.pipReadiness.value) + } + + @Test + fun `Given non-null then null when reported then pipReadiness round-trips`() = runTest { + createViewModel() + advanceUntilIdle() + + viewModel.onPipReadinessChanged(PipReadiness(aspectRatio = Rational(16, 9))) + viewModel.onPipReadinessChanged(null) + advanceUntilIdle() + + assertNull(viewModel.pipReadiness.value) + } } diff --git a/automotive/lint-baseline.xml b/automotive/lint-baseline.xml index 7b9fe6a152c..f13bfd77a7b 100644 --- a/automotive/lint-baseline.xml +++ b/automotive/lint-baseline.xml @@ -1,5 +1,5 @@ - + @@ -107,7 +107,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -338,7 +338,7 @@ errorLine2=" ~~~~~~~~~~~~~~"> @@ -382,7 +382,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -1068,6 +1068,17 @@ column="1"/> + + + + @@ -2230,7 +2241,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -2241,7 +2252,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -2252,7 +2263,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> diff --git a/automotive/src/main/AndroidManifest.xml b/automotive/src/main/AndroidManifest.xml index cf8aaf6abf9..077c6975b78 100644 --- a/automotive/src/main/AndroidManifest.xml +++ b/automotive/src/main/AndroidManifest.xml @@ -267,6 +267,7 @@