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
1 change: 1 addition & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@
<activity
android:name="io.homeassistant.companion.android.launch.LaunchActivity"
android:exported="true"
android:supportsPictureInPicture="true"
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboard|keyboardHidden|navigation|uiMode"
android:windowSoftInputMode="adjustResize"
android:theme="@style/Theme.LaunchScreen">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -170,6 +173,7 @@ internal fun FrontendScreen(
onGesture = viewModel::onGesture,
onExoPlayerFullscreenChanged = viewModel::onExoPlayerFullscreenChanged,
autoPlayVideoEnabled = autoPlayVideoEnabled,
onPipReadinessChanged = onPipReadinessChanged,
modifier = modifier,
)
}
Expand Down Expand Up @@ -204,6 +208,7 @@ internal fun FrontendScreenContent(
webViewActions: Flow<WebViewAction> = emptyFlow(),
onGesture: (GestureDirection, Int) -> Unit = { _, _ -> },
onExoPlayerFullscreenChanged: (Boolean) -> Unit = {},
onPipReadinessChanged: (PipReadiness?) -> Unit = {},
) {
var webView by remember { mutableStateOf<WebView?>(null) }

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 */
}
}
}

Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<FrontendRoute> {
Expand Down Expand Up @@ -113,6 +115,7 @@ internal fun NavGraphBuilder.frontendScreen(
onConfigureHomeNetwork = onConfigureHomeNetwork,
onSecurityLevelHelpClick = onSecurityLevelHelpClick,
onShowSnackbar = onShowSnackbar,
onPipReadinessChanged = onPipReadinessChanged,
)
}
} else {
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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),
)

Expand Down Expand Up @@ -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
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
if (!packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE)) return
val readiness = viewModel.pipReadiness.value ?: return
Comment on lines +215 to +217
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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,17 @@ internal class LaunchViewModel @VisibleForTesting constructor(
private val _isAppLocked = MutableStateFlow(false)
val isAppLocked: StateFlow<Boolean> = _isAppLocked.asStateFlow()

private val _pipReadiness = MutableStateFlow<PipReadiness?>(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?> = _pipReadiness.asStateFlow()

init {
viewModelScope.launch {
cleanupServers()
Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
@@ -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),
)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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

/**
Expand All @@ -43,6 +44,7 @@ internal fun HAApp(
snackbarHostState: SnackbarHostState,
modifier: Modifier = Modifier,
onRequestFullscreen: (Boolean) -> Unit = {},
onPipReadinessChanged: (PipReadiness?) -> Unit = {},
) {
Scaffold(
modifier = modifier,
Expand Down Expand Up @@ -71,6 +73,7 @@ internal fun HAApp(
navController = navController,
startDestination = startDestination,
onRequestFullscreen = onRequestFullscreen,
onPipReadinessChanged = onPipReadinessChanged,
onShowSnackbar = { message, action ->
snackbarHostState.showSnackbar(
message,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -123,6 +125,7 @@ internal fun HANavHost(
onShowSnackbar = onShowSnackbar,
onShowServerSwitcher = { onServerSelected -> showServerSwitcher(activity, onServerSelected) },
onRequestFullscreen = onRequestFullscreen,
onPipReadinessChanged = onPipReadinessChanged,
)
setHomeNetworkScreen(
onGotoNextScreen = {
Expand Down
Loading
Loading