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
@@ -1,22 +1,27 @@
package io.homeassistant.companion.android.util

import android.util.DisplayMetrics
import android.view.View
import android.webkit.WebView
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.runtime.Composable
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.LayoutDirection
import androidx.core.util.TypedValueCompat.pxToDp
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsCompat.Type.displayCutout
import androidx.core.view.WindowInsetsCompat.Type.ime
import androidx.core.view.WindowInsetsCompat.Type.systemBars
import androidx.core.view.updatePadding
import androidx.preference.PreferenceFragmentCompat
import timber.log.Timber

operator fun PaddingValues.plus(that: PaddingValues): PaddingValues = object : PaddingValues {
override fun calculateBottomPadding(): Dp = [email protected]() + that.calculateBottomPadding()
Expand Down Expand Up @@ -101,3 +106,28 @@ fun View.applySafeDrawingInsets(
if (consumeInsets) WindowInsetsCompat.CONSUMED else windowInsets
}
}

/**
* Applies safe area insets to the WebView by setting CSS custom properties.
* These properties are used by the Home Assistant frontend for edge-to-edge display.
*/
fun WebView.applyInsets(
insets: WindowInsets,
density: Density,
displayMetrics: DisplayMetrics,
layoutDirection: LayoutDirection,
) {
val safeInsetTop = pxToDp(insets.getTop(density).toFloat(), displayMetrics)
val safeInsetRight = pxToDp(insets.getRight(density, layoutDirection).toFloat(), displayMetrics)
val safeInsetBottom = pxToDp(insets.getBottom(density).toFloat(), displayMetrics)
val safeInsetLeft = pxToDp(insets.getLeft(density, layoutDirection).toFloat(), displayMetrics)
val safeAreaJs = """
document.documentElement.style.setProperty('--app-safe-area-inset-top', '${safeInsetTop}px');
document.documentElement.style.setProperty('--app-safe-area-inset-bottom', '${safeInsetBottom}px');
document.documentElement.style.setProperty('--app-safe-area-inset-left', '${safeInsetLeft}px');
document.documentElement.style.setProperty('--app-safe-area-inset-right', '${safeInsetRight}px');
""".trimIndent()
Timber.d("Safe area is $safeAreaJs")

evaluateJavascript(safeAreaJs, null)
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ interface WebView {
* loading
* @param openInApp if `true`, loads in the WebView; if `false`, opens in external browser
*/
fun loadUrl(url: Uri, keepHistory: Boolean, openInApp: Boolean)
fun loadUrl(url: Uri, keepHistory: Boolean, openInApp: Boolean, serverHandleInsets: Boolean)

fun setStatusBarAndBackgroundColor(statusBarColor: Int, backgroundColor: Int)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import android.os.VibrationEffect
import android.os.Vibrator
import android.text.method.HideReturnsTransformationMethod
import android.text.method.PasswordTransformationMethod
import android.util.DisplayMetrics
import android.util.Rational
import android.view.HapticFeedbackConstants
import android.view.KeyEvent
Expand Down Expand Up @@ -47,6 +48,10 @@ import androidx.activity.result.IntentSenderRequest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.OptIn
import androidx.appcompat.app.AlertDialog
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.displayCutout
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.layout.union
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
Expand All @@ -57,7 +62,13 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.core.app.ActivityCompat
import androidx.core.content.getSystemService
Expand Down Expand Up @@ -123,6 +134,7 @@ import io.homeassistant.companion.android.util.DataUriDownloadManager
import io.homeassistant.companion.android.util.LifecycleHandler
import io.homeassistant.companion.android.util.OnSwipeListener
import io.homeassistant.companion.android.util.TLSWebViewClient
import io.homeassistant.companion.android.util.applyInsets
import io.homeassistant.companion.android.util.compose.initializePlayer
import io.homeassistant.companion.android.util.hasNonRootPath
import io.homeassistant.companion.android.util.hasSameOrigin
Expand Down Expand Up @@ -300,9 +312,23 @@ class WebViewActivity :
private var downloadFileContentDisposition = ""
private var downloadFileMimetype = ""
private val javascriptInterface = "externalApp"
private var serverHandleInsets = mutableStateOf(false)

private val snackbarHostState = SnackbarHostState()

private data class InsetsContext(
val windowInsets: WindowInsets,
val density: Density,
val displayMetrics: DisplayMetrics,
val layoutDirection: LayoutDirection,
) {
fun applyInsets(webView: WebView) {
webView.applyInsets(windowInsets, density, displayMetrics, layoutDirection)
}
}

private var insetsContext: InsetsContext? = null

@SuppressLint("SetJavaScriptEnabled")
override fun onCreate(savedInstanceState: Bundle?) {
if (
Expand Down Expand Up @@ -350,11 +376,28 @@ class WebViewActivity :
val customViewFromWebView by remember { customViewFromWebView }
val statusBarColor by remember { statusBarColor }
val backgroundColor by remember { backgroundColor }
val serverHandleInsets by remember { serverHandleInsets }
var nightModeTheme by remember { mutableStateOf<NightModeTheme?>(null) }
val snackbarHostState = remember { snackbarHostState }
var webViewInitialized by remember { webViewInitialized }
var shouldAskNotificationPermission by remember { mutableStateOf(false) }

val configuration = LocalConfiguration.current
val currentInsetsContext = InsetsContext(
windowInsets = WindowInsets.systemBars.union(WindowInsets.displayCutout),
density = LocalDensity.current,
displayMetrics = LocalResources.current.displayMetrics,
layoutDirection = LocalLayoutDirection.current,
)
insetsContext = currentInsetsContext

// Apply insets when configuration changes (e.g., screen rotation)
LaunchedEffect(configuration, serverHandleInsets) {
if (serverHandleInsets) {
currentInsetsContext.applyInsets(webView)
}
}

LaunchedEffect(Unit) {
nightModeTheme = nightModeManager.getCurrentNightMode()
shouldAskNotificationPermission = presenter.shouldAskNotificationPermission()
Expand All @@ -371,6 +414,7 @@ class WebViewActivity :
customViewFromWebView,
shouldAskNotificationPermission = shouldAskNotificationPermission,
webViewInitialized = webViewInitialized,
serverHandleInsets = serverHandleInsets,
nightModeTheme = nightModeTheme,
statusBarColor = statusBarColor,
backgroundColor = backgroundColor,
Expand Down Expand Up @@ -451,6 +495,11 @@ class WebViewActivity :
webView.clearHistory()
clearHistory = false
}

if (serverHandleInsets.value) {
insetsContext?.applyInsets(webView)
}

setWebViewZoom()
if (moreInfoEntity != "" && view?.progress == 100 && isConnected) {
ioScope.launch {
Expand Down Expand Up @@ -1435,8 +1484,11 @@ class WebViewActivity :
finish()
}

override fun loadUrl(url: Uri, keepHistory: Boolean, openInApp: Boolean) {
Timber.d("Loading $url (keepHistory $keepHistory, openInApp $openInApp)")
override fun loadUrl(url: Uri, keepHistory: Boolean, openInApp: Boolean, serverHandleInsets: Boolean) {
Timber.d(
"Loading $url (keepHistory $keepHistory, openInApp $openInApp, serverHandleInsets $serverHandleInsets)",
)
this.serverHandleInsets.value = serverHandleInsets
if (openInApp) {
// Remove any displayed fragments (e.g., BlockInsecureFragment, ConnectionSecurityLevelFragment)
supportFragmentManager.popBackStack(null, FragmentManager.POP_BACK_STACK_INCLUSIVE)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,6 @@ import io.homeassistant.companion.android.util.compose.webview.HAWebView
import kotlinx.coroutines.launch

@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
@OptIn(ExperimentalHazeMaterialsApi::class)
@Composable
internal fun WebViewContentScreen(
webView: WebView?,
Expand All @@ -85,6 +84,7 @@ internal fun WebViewContentScreen(
webViewInitialized: Boolean,
onFullscreenClicked: (isFullscreen: Boolean) -> Unit,
onNotificationPermissionResult: (Boolean) -> Unit,
serverHandleInsets: Boolean,
nightModeTheme: NightModeTheme? = null,
statusBarColor: Color? = null,
backgroundColor: Color? = null,
Expand All @@ -106,7 +106,14 @@ internal fun WebViewContentScreen(
.fillMaxSize()
.background(colorResource(commonR.color.colorLaunchScreenBackground)),
) {
SafeHAWebView(webView, nightModeTheme, currentAppLocked, statusBarColor, backgroundColor)
SafeHAWebView(
webView,
nightModeTheme,
currentAppLocked = currentAppLocked,
statusBarColor = statusBarColor,
backgroundColor = backgroundColor,
serverHandleInsets = serverHandleInsets,
)

player?.let { player ->
playerSize?.let { playerSize ->
Expand Down Expand Up @@ -140,22 +147,57 @@ internal fun WebViewContentScreen(
}
}

@OptIn(ExperimentalHazeMaterialsApi::class)
@Composable
private fun SafeHAWebView(
webView: WebView?,
nightModeTheme: NightModeTheme?,
currentAppLocked: Boolean,
statusBarColor: Color?,
backgroundColor: Color?,
serverHandleInsets: Boolean,
) {
// We add colored small spacer all around the WebView based on the `safeDrawing` insets.
// TODO This should be disable when the frontend supports edge to edge
// https://github.com/home-assistant/frontend/pull/25566
val hazeModifier = if (currentAppLocked) Modifier.hazeEffect(style = HazeMaterials.thin()) else Modifier

if (serverHandleInsets) {
Box(modifier = hazeModifier) {
HAWebView(
nightModeTheme = nightModeTheme,
factory = { webView },
modifier = Modifier
.fillMaxSize()
.background(Color.Transparent),
)
}
} else {
HAWebViewWithInsets(
webView = webView,
nightModeTheme = nightModeTheme,
statusBarColor = statusBarColor,
backgroundColor = backgroundColor,
modifier = hazeModifier,
)
}
}

/**
* Wraps the WebView with colored overlays matching the safe area insets.
*
* Used when the Home Assistant frontend does not handle edge-to-edge insets
* version prior 2025.12.x
*/
@Composable
private fun HAWebViewWithInsets(
webView: WebView?,
nightModeTheme: NightModeTheme?,
statusBarColor: Color?,
backgroundColor: Color?,
modifier: Modifier = Modifier,
) {
val insets = WindowInsets.safeDrawing
val insetsPaddingValues = insets.asPaddingValues()

Column(modifier = if (currentAppLocked) Modifier.hazeEffect(style = HazeMaterials.thin()) else Modifier) {
Column(modifier = modifier) {
statusBarColor?.Overlay(
modifier = Modifier
.height(insetsPaddingValues.calculateTopPadding())
Expand All @@ -173,9 +215,7 @@ private fun SafeHAWebView(
)
HAWebView(
nightModeTheme = nightModeTheme,
factory = {
webView
},
factory = { webView },
modifier = Modifier
.weight(1f)
.background(Color.Transparent),
Expand Down Expand Up @@ -302,5 +342,6 @@ private fun WebViewContentScreenPreview() {
customViewFromWebView = null,
onFullscreenClicked = {},
onNotificationPermissionResult = {},
serverHandleInsets = false,
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,7 @@ class WebViewPresenterImpl @Inject constructor(
url = urlWithAuth,
keepHistory = !isNewServer,
openInApp = it.baseIsEqual(baseUrl),
serverHandleInsets = serverManager.getServer(serverId)?.version?.isAtLeast(2025, 12) == true,
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,28 @@ class WebViewContentScreenScreenshotTest {
customViewFromWebView = null,
onFullscreenClicked = {},
onNotificationPermissionResult = {},
serverHandleInsets = false,
)
}

@PreviewTest
@Preview
@Composable
fun `WebView with app unlocked server handle insets`() {
WebViewContentScreen(
webView = null,
player = null,
snackbarHostState = SnackbarHostState(),
playerSize = null,
playerTop = 0.dp,
playerLeft = 0.dp,
currentAppLocked = false,
shouldAskNotificationPermission = false,
webViewInitialized = true,
customViewFromWebView = null,
onFullscreenClicked = {},
onNotificationPermissionResult = {},
serverHandleInsets = true,
)
}

Expand All @@ -47,6 +69,7 @@ class WebViewContentScreenScreenshotTest {
customViewFromWebView = null,
onFullscreenClicked = {},
onNotificationPermissionResult = {},
serverHandleInsets = false,
)
}

Expand All @@ -67,6 +90,7 @@ class WebViewContentScreenScreenshotTest {
customViewFromWebView = null,
onFullscreenClicked = {},
onNotificationPermissionResult = {},
serverHandleInsets = false,
)
}

Expand All @@ -88,6 +112,7 @@ class WebViewContentScreenScreenshotTest {
onFullscreenClicked = {},
onNotificationPermissionResult = {},
supportsNotificationPermission = true,
serverHandleInsets = false,
)
}
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading