Skip to content

Commit 962fcbf

Browse files
authored
Edge to edge support for webview (#5346)
1 parent 984d7ed commit 962fcbf

File tree

7 files changed

+158
-29
lines changed

7 files changed

+158
-29
lines changed

app/src/main/kotlin/io/homeassistant/companion/android/util/InsetsUtil.kt

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,27 @@
11
package io.homeassistant.companion.android.util
22

3+
import android.util.DisplayMetrics
34
import android.view.View
5+
import android.webkit.WebView
46
import androidx.compose.foundation.layout.PaddingValues
57
import androidx.compose.foundation.layout.WindowInsets
68
import androidx.compose.foundation.layout.WindowInsetsSides
79
import androidx.compose.foundation.layout.asPaddingValues
810
import androidx.compose.foundation.layout.only
911
import androidx.compose.foundation.layout.safeDrawing
1012
import androidx.compose.runtime.Composable
13+
import androidx.compose.ui.unit.Density
1114
import androidx.compose.ui.unit.Dp
1215
import androidx.compose.ui.unit.LayoutDirection
16+
import androidx.core.util.TypedValueCompat.pxToDp
1317
import androidx.core.view.ViewCompat
1418
import androidx.core.view.WindowInsetsCompat
1519
import androidx.core.view.WindowInsetsCompat.Type.displayCutout
1620
import androidx.core.view.WindowInsetsCompat.Type.ime
1721
import androidx.core.view.WindowInsetsCompat.Type.systemBars
1822
import androidx.core.view.updatePadding
1923
import androidx.preference.PreferenceFragmentCompat
24+
import timber.log.Timber
2025

2126
operator fun PaddingValues.plus(that: PaddingValues): PaddingValues = object : PaddingValues {
2227
override fun calculateBottomPadding(): Dp = this@plus.calculateBottomPadding() + that.calculateBottomPadding()
@@ -101,3 +106,28 @@ fun View.applySafeDrawingInsets(
101106
if (consumeInsets) WindowInsetsCompat.CONSUMED else windowInsets
102107
}
103108
}
109+
110+
/**
111+
* Applies safe area insets to the WebView by setting CSS custom properties.
112+
* These properties are used by the Home Assistant frontend for edge-to-edge display.
113+
*/
114+
fun WebView.applyInsets(
115+
insets: WindowInsets,
116+
density: Density,
117+
displayMetrics: DisplayMetrics,
118+
layoutDirection: LayoutDirection,
119+
) {
120+
val safeInsetTop = pxToDp(insets.getTop(density).toFloat(), displayMetrics)
121+
val safeInsetRight = pxToDp(insets.getRight(density, layoutDirection).toFloat(), displayMetrics)
122+
val safeInsetBottom = pxToDp(insets.getBottom(density).toFloat(), displayMetrics)
123+
val safeInsetLeft = pxToDp(insets.getLeft(density, layoutDirection).toFloat(), displayMetrics)
124+
val safeAreaJs = """
125+
document.documentElement.style.setProperty('--app-safe-area-inset-top', '${safeInsetTop}px');
126+
document.documentElement.style.setProperty('--app-safe-area-inset-bottom', '${safeInsetBottom}px');
127+
document.documentElement.style.setProperty('--app-safe-area-inset-left', '${safeInsetLeft}px');
128+
document.documentElement.style.setProperty('--app-safe-area-inset-right', '${safeInsetRight}px');
129+
""".trimIndent()
130+
Timber.d("Safe area is $safeAreaJs")
131+
132+
evaluateJavascript(safeAreaJs, null)
133+
}

app/src/main/kotlin/io/homeassistant/companion/android/webview/WebView.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,9 @@ interface WebView {
3131
* @param keepHistory if `true`, preserves navigation history; if `false`, clears history after
3232
* loading
3333
* @param openInApp if `true`, loads in the WebView; if `false`, opens in external browser
34+
* @param serverHandleInsets if `true`, the server handles window insets for edge-to-edge display
3435
*/
35-
fun loadUrl(url: Uri, keepHistory: Boolean, openInApp: Boolean)
36+
fun loadUrl(url: Uri, keepHistory: Boolean, openInApp: Boolean, serverHandleInsets: Boolean)
3637

3738
fun setStatusBarAndBackgroundColor(statusBarColor: Int, backgroundColor: Int)
3839

app/src/main/kotlin/io/homeassistant/companion/android/webview/WebViewActivity.kt

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import android.os.VibrationEffect
2020
import android.os.Vibrator
2121
import android.text.method.HideReturnsTransformationMethod
2222
import android.text.method.PasswordTransformationMethod
23+
import android.util.DisplayMetrics
2324
import android.util.Rational
2425
import android.view.HapticFeedbackConstants
2526
import android.view.KeyEvent
@@ -47,6 +48,10 @@ import androidx.activity.result.IntentSenderRequest
4748
import androidx.activity.result.contract.ActivityResultContracts
4849
import androidx.annotation.OptIn
4950
import androidx.appcompat.app.AlertDialog
51+
import androidx.compose.foundation.layout.WindowInsets
52+
import androidx.compose.foundation.layout.displayCutout
53+
import androidx.compose.foundation.layout.systemBars
54+
import androidx.compose.foundation.layout.union
5055
import androidx.compose.material3.SnackbarDuration
5156
import androidx.compose.material3.SnackbarHostState
5257
import androidx.compose.material3.SnackbarResult
@@ -57,7 +62,13 @@ import androidx.compose.runtime.remember
5762
import androidx.compose.runtime.rememberCoroutineScope
5863
import androidx.compose.runtime.setValue
5964
import androidx.compose.ui.graphics.Color
65+
import androidx.compose.ui.platform.LocalConfiguration
66+
import androidx.compose.ui.platform.LocalDensity
67+
import androidx.compose.ui.platform.LocalLayoutDirection
68+
import androidx.compose.ui.platform.LocalResources
69+
import androidx.compose.ui.unit.Density
6070
import androidx.compose.ui.unit.DpSize
71+
import androidx.compose.ui.unit.LayoutDirection
6172
import androidx.compose.ui.unit.dp
6273
import androidx.core.app.ActivityCompat
6374
import androidx.core.content.getSystemService
@@ -125,6 +136,7 @@ import io.homeassistant.companion.android.util.DataUriDownloadManager
125136
import io.homeassistant.companion.android.util.LifecycleHandler
126137
import io.homeassistant.companion.android.util.OnSwipeListener
127138
import io.homeassistant.companion.android.util.TLSWebViewClient
139+
import io.homeassistant.companion.android.util.applyInsets
128140
import io.homeassistant.companion.android.util.hasNonRootPath
129141
import io.homeassistant.companion.android.util.hasSameOrigin
130142
import io.homeassistant.companion.android.util.isStarted
@@ -301,9 +313,23 @@ class WebViewActivity :
301313
private var downloadFileContentDisposition = ""
302314
private var downloadFileMimetype = ""
303315
private val javascriptInterface = "externalApp"
316+
private var serverHandleInsets = mutableStateOf(false)
304317

305318
private val snackbarHostState = SnackbarHostState()
306319

320+
private data class InsetsContext(
321+
val windowInsets: WindowInsets,
322+
val density: Density,
323+
val displayMetrics: DisplayMetrics,
324+
val layoutDirection: LayoutDirection,
325+
) {
326+
fun applyInsets(webView: WebView) {
327+
webView.applyInsets(windowInsets, density, displayMetrics, layoutDirection)
328+
}
329+
}
330+
331+
private var insetsContext: InsetsContext? = null
332+
307333
@SuppressLint("SetJavaScriptEnabled")
308334
override fun onCreate(savedInstanceState: Bundle?) {
309335
if (
@@ -351,11 +377,28 @@ class WebViewActivity :
351377
val customViewFromWebView by remember { customViewFromWebView }
352378
val statusBarColor by remember { statusBarColor }
353379
val backgroundColor by remember { backgroundColor }
380+
val serverHandleInsets by remember { serverHandleInsets }
354381
var nightModeTheme by remember { mutableStateOf<NightModeTheme?>(null) }
355382
val snackbarHostState = remember { snackbarHostState }
356383
var webViewInitialized by remember { webViewInitialized }
357384
var shouldAskNotificationPermission by remember { mutableStateOf(false) }
358385

386+
val configuration = LocalConfiguration.current
387+
val currentInsetsContext = InsetsContext(
388+
windowInsets = WindowInsets.systemBars.union(WindowInsets.displayCutout),
389+
density = LocalDensity.current,
390+
displayMetrics = LocalResources.current.displayMetrics,
391+
layoutDirection = LocalLayoutDirection.current,
392+
)
393+
insetsContext = currentInsetsContext
394+
395+
// Apply insets when configuration changes (e.g., screen rotation)
396+
LaunchedEffect(configuration, serverHandleInsets) {
397+
if (serverHandleInsets) {
398+
currentInsetsContext.applyInsets(webView)
399+
}
400+
}
401+
359402
LaunchedEffect(Unit) {
360403
nightModeTheme = nightModeManager.getCurrentNightMode()
361404
shouldAskNotificationPermission = presenter.shouldAskNotificationPermission()
@@ -372,6 +415,7 @@ class WebViewActivity :
372415
customViewFromWebView,
373416
shouldAskNotificationPermission = shouldAskNotificationPermission,
374417
webViewInitialized = webViewInitialized,
418+
serverHandleInsets = serverHandleInsets,
375419
nightModeTheme = nightModeTheme,
376420
statusBarColor = statusBarColor,
377421
backgroundColor = backgroundColor,
@@ -452,6 +496,11 @@ class WebViewActivity :
452496
webView.clearHistory()
453497
clearHistory = false
454498
}
499+
500+
if (serverHandleInsets.value) {
501+
insetsContext?.applyInsets(webView)
502+
}
503+
455504
setWebViewZoom()
456505
if (moreInfoEntity != "" && view?.progress == 100 && isConnected) {
457506
ioScope.launch {
@@ -1436,8 +1485,11 @@ class WebViewActivity :
14361485
finish()
14371486
}
14381487

1439-
override fun loadUrl(url: Uri, keepHistory: Boolean, openInApp: Boolean) {
1440-
Timber.d("Loading $url (keepHistory $keepHistory, openInApp $openInApp)")
1488+
override fun loadUrl(url: Uri, keepHistory: Boolean, openInApp: Boolean, serverHandleInsets: Boolean) {
1489+
Timber.d(
1490+
"Loading $url (keepHistory $keepHistory, openInApp $openInApp, serverHandleInsets $serverHandleInsets)",
1491+
)
1492+
this.serverHandleInsets.value = serverHandleInsets
14411493
if (openInApp) {
14421494
runFragmentTransactionIfStateSafe {
14431495
// Remove any displayed fragments (e.g., BlockInsecureFragment, ConnectionSecurityLevelFragment)

app/src/main/kotlin/io/homeassistant/companion/android/webview/WebViewContentScreen.kt

Lines changed: 50 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,6 @@ import io.homeassistant.companion.android.util.compose.webview.HAWebView
7070
import kotlinx.coroutines.launch
7171

7272
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
73-
@OptIn(ExperimentalHazeMaterialsApi::class)
7473
@Composable
7574
internal fun WebViewContentScreen(
7675
webView: WebView?,
@@ -85,6 +84,7 @@ internal fun WebViewContentScreen(
8584
webViewInitialized: Boolean,
8685
onFullscreenClicked: (isFullscreen: Boolean) -> Unit,
8786
onNotificationPermissionResult: (Boolean) -> Unit,
87+
serverHandleInsets: Boolean,
8888
nightModeTheme: NightModeTheme? = null,
8989
statusBarColor: Color? = null,
9090
backgroundColor: Color? = null,
@@ -106,7 +106,14 @@ internal fun WebViewContentScreen(
106106
.fillMaxSize()
107107
.background(colorResource(commonR.color.colorLaunchScreenBackground)),
108108
) {
109-
SafeHAWebView(webView, nightModeTheme, currentAppLocked, statusBarColor, backgroundColor)
109+
SafeHAWebView(
110+
webView,
111+
nightModeTheme,
112+
currentAppLocked = currentAppLocked,
113+
statusBarColor = statusBarColor,
114+
backgroundColor = backgroundColor,
115+
serverHandleInsets = serverHandleInsets,
116+
)
110117

111118
player?.let { player ->
112119
playerSize?.let { playerSize ->
@@ -140,22 +147,57 @@ internal fun WebViewContentScreen(
140147
}
141148
}
142149

150+
@OptIn(ExperimentalHazeMaterialsApi::class)
143151
@Composable
144152
private fun SafeHAWebView(
145153
webView: WebView?,
146154
nightModeTheme: NightModeTheme?,
147155
currentAppLocked: Boolean,
148156
statusBarColor: Color?,
149157
backgroundColor: Color?,
158+
serverHandleInsets: Boolean,
150159
) {
151-
// We add colored small spacer all around the WebView based on the `safeDrawing` insets.
152-
// TODO This should be disable when the frontend supports edge to edge
153-
// https://github.com/home-assistant/frontend/pull/25566
160+
val hazeModifier = if (currentAppLocked) Modifier.hazeEffect(style = HazeMaterials.thin()) else Modifier
161+
162+
if (serverHandleInsets) {
163+
Box(modifier = hazeModifier) {
164+
HAWebView(
165+
nightModeTheme = nightModeTheme,
166+
factory = { webView },
167+
modifier = Modifier
168+
.fillMaxSize()
169+
.background(Color.Transparent),
170+
)
171+
}
172+
} else {
173+
HAWebViewWithInsets(
174+
webView = webView,
175+
nightModeTheme = nightModeTheme,
176+
statusBarColor = statusBarColor,
177+
backgroundColor = backgroundColor,
178+
modifier = hazeModifier,
179+
)
180+
}
181+
}
154182

183+
/**
184+
* Wraps the WebView with colored overlays matching the safe area insets.
185+
*
186+
* Used when the Home Assistant frontend does not handle edge-to-edge insets
187+
* version prior 2025.12.x
188+
*/
189+
@Composable
190+
private fun HAWebViewWithInsets(
191+
webView: WebView?,
192+
nightModeTheme: NightModeTheme?,
193+
statusBarColor: Color?,
194+
backgroundColor: Color?,
195+
modifier: Modifier = Modifier,
196+
) {
155197
val insets = WindowInsets.safeDrawing
156198
val insetsPaddingValues = insets.asPaddingValues()
157199

158-
Column(modifier = if (currentAppLocked) Modifier.hazeEffect(style = HazeMaterials.thin()) else Modifier) {
200+
Column(modifier = modifier) {
159201
statusBarColor?.Overlay(
160202
modifier = Modifier
161203
.height(insetsPaddingValues.calculateTopPadding())
@@ -173,9 +215,7 @@ private fun SafeHAWebView(
173215
)
174216
HAWebView(
175217
nightModeTheme = nightModeTheme,
176-
factory = {
177-
webView
178-
},
218+
factory = { webView },
179219
modifier = Modifier
180220
.weight(1f)
181221
.background(Color.Transparent),
@@ -302,5 +342,6 @@ private fun WebViewContentScreenPreview() {
302342
customViewFromWebView = null,
303343
onFullscreenClicked = {},
304344
onNotificationPermissionResult = {},
345+
serverHandleInsets = false,
305346
)
306347
}

app/src/main/kotlin/io/homeassistant/companion/android/webview/WebViewPresenterImpl.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,7 @@ class WebViewPresenterImpl @Inject constructor(
226226
url = urlWithAuth,
227227
keepHistory = !isNewServer,
228228
openInApp = it.baseIsEqual(baseUrl),
229+
serverHandleInsets = serverManager.getServer(serverId)?.version?.isAtLeast(2025, 12) == true,
229230
)
230231
}
231232
}

app/src/screenshotTest/kotlin/io/homeassistant/companion/android/webview/WebViewContentScreenScreenshotTest.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ class WebViewContentScreenScreenshotTest {
2727
customViewFromWebView = null,
2828
onFullscreenClicked = {},
2929
onNotificationPermissionResult = {},
30+
serverHandleInsets = false,
3031
)
3132
}
3233

@@ -47,6 +48,7 @@ class WebViewContentScreenScreenshotTest {
4748
customViewFromWebView = null,
4849
onFullscreenClicked = {},
4950
onNotificationPermissionResult = {},
51+
serverHandleInsets = false,
5052
)
5153
}
5254

@@ -67,6 +69,7 @@ class WebViewContentScreenScreenshotTest {
6769
customViewFromWebView = null,
6870
onFullscreenClicked = {},
6971
onNotificationPermissionResult = {},
72+
serverHandleInsets = false,
7073
)
7174
}
7275

@@ -88,6 +91,7 @@ class WebViewContentScreenScreenshotTest {
8891
onFullscreenClicked = {},
8992
onNotificationPermissionResult = {},
9093
supportsNotificationPermission = true,
94+
serverHandleInsets = false,
9195
)
9296
}
9397
}

0 commit comments

Comments
 (0)