From 87ac419f19876283904196eaa5af3c652c2a7578 Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Wed, 20 May 2026 17:32:11 +0200 Subject: [PATCH 1/2] Embrace Android 15+ edge-to-edge instead of opting out Android 15 (SDK 35) enforces edge-to-edge layouts and the old `OptOutEdgeToEdgeEnforcement` attribute in styles-v35 was causing `env(safe-area-inset-*)` to return 0 inside the BlazorWebView. Replace the opt-out with a new `AndroidEdgeToEdgeInsets` class that listens to `WindowInsetsCompat` callbacks and injects the real inset values as CSS custom properties (`--safe-area-inset-*`) into the WebView via JavaScript. MainActivity is updated to wire up the listener instead of relying on the deprecated opt-out style. Co-Authored-By: Claude Sonnet 4.6 --- .../FwLiteMaui/MainPage.xaml.Android.cs | 4 + .../Android/AndroidEdgeToEdgeInsets.cs | 148 ++++++++++++++++++ .../Platforms/Android/MainActivity.cs | 8 +- .../Android/Resources/values-v35/styles.xml | 6 - 4 files changed, 158 insertions(+), 8 deletions(-) create mode 100644 backend/FwLite/FwLiteMaui/Platforms/Android/AndroidEdgeToEdgeInsets.cs delete mode 100644 backend/FwLite/FwLiteMaui/Platforms/Android/Resources/values-v35/styles.xml diff --git a/backend/FwLite/FwLiteMaui/MainPage.xaml.Android.cs b/backend/FwLite/FwLiteMaui/MainPage.xaml.Android.cs index 21879541d9..d5609352ba 100644 --- a/backend/FwLite/FwLiteMaui/MainPage.xaml.Android.cs +++ b/backend/FwLite/FwLiteMaui/MainPage.xaml.Android.cs @@ -36,6 +36,10 @@ private partial void BlazorWebViewInitialized(object? sender, BlazorWebViewIniti ? (e.WebView.WebChromeClient ?? new Android.Webkit.WebChromeClient()) : new Android.Webkit.WebChromeClient(); e.WebView.SetWebChromeClient(new PermissionManagingBlazorWebChromeClient(baseClient, activity)); + + // Push real system-bar insets into the WebView as CSS custom properties so + // edge-to-edge layouts (Android 15+) don't render under the status bar / gesture nav. + AndroidEdgeToEdgeInsets.Install(e.WebView); } private partial void BlazorWebViewOnUrlLoading(object? sender, UrlLoadingEventArgs e) diff --git a/backend/FwLite/FwLiteMaui/Platforms/Android/AndroidEdgeToEdgeInsets.cs b/backend/FwLite/FwLiteMaui/Platforms/Android/AndroidEdgeToEdgeInsets.cs new file mode 100644 index 0000000000..11fa8ba880 --- /dev/null +++ b/backend/FwLite/FwLiteMaui/Platforms/Android/AndroidEdgeToEdgeInsets.cs @@ -0,0 +1,148 @@ +#if ANDROID +using Android.Util; +using AndroidX.Core.View; +using AView = Android.Views.View; +using AWebView = Android.Webkit.WebView; +using AInsets = AndroidX.Core.Graphics.Insets; + +namespace FwLiteMaui.Platforms.Android; + +// Wires up edge-to-edge support for the Blazor WebView on Android 15+ (SDK 35). +// Reads the real system-bar insets (status bar at top, gesture-nav / 3-button nav at bottom) +// and writes them onto the WebView's root element as two tiers of CSS custom properties: +// +// --android-safe-{top,right,bottom,left} -- "chrome" inset, SystemBars + DisplayCutout +// only. Used for general page chrome (status bar / nav bar / notch clearance) where +// Material Design's standard safe area is appropriate. This is what .app pads with. +// +// --android-wide-{top,right,bottom,left} -- "wide" inset, chrome union with +// MandatorySystemGestures + TappableElement. Used for FLOATING elements (FABs, +// toasters) that need extra clearance from the gesture-nav tappable region. +// Without this split, the gesture-area reservation bleeds into every page chrome +// consumer and over-reserves visible space. +// +// Also writes --android-ime-bottom for the soft keyboard, kept separate from system bars +// so chrome surfaces (sidebars, drawers) can ignore it while scrollable content can shrink. +// CSS consumes them with sensible env() fallbacks, +// e.g. var(--android-safe-bottom, env(safe-area-inset-bottom)). +// +// We do this in JS rather than relying on CSS env(safe-area-inset-*) because that has been +// observed to report 0 inside the Android System WebView even with viewport-fit=cover. +// +// We must NOT replace the WebView's WebViewClient — Blazor's own client intercepts +// https://0.0.0.0/ requests and serves the embedded app. Anything that breaks that +// chain bricks the WebView. The insets-listener alone is enough; CSS vars set on +// document.documentElement.style survive Blazor's DOM swap. +internal static class AndroidEdgeToEdgeInsets +{ + public static void Install(AWebView webView) + { + // Belt-and-suspenders: ensure the WebView doesn't auto-pad behind our back on + // OEM AppCompat themes that flip fitsSystemWindows=true via parent inheritance. + // We're handling insets ourselves via the listener below. + webView.SetFitsSystemWindows(false); + ViewCompat.SetOnApplyWindowInsetsListener(webView, new InsetsListener(webView)); + ViewCompat.RequestApplyInsets(webView); + } + + private sealed class InsetsListener : Java.Lang.Object, IOnApplyWindowInsetsListener + { + private readonly AWebView _webView; + private AInsets _lastChrome = AInsets.None!; + private AInsets _lastWide = AInsets.None!; + private int _lastImeBottom = -1; + + public InsetsListener(AWebView webView) => _webView = webView; + + public WindowInsetsCompat? OnApplyWindowInsets(AView? v, WindowInsetsCompat? insets) + { + if (insets is null) return insets; + // Two-tier insets: + // chrome = SystemBars + DisplayCutout. Material Design's standard "safe area" + // for general page chrome. What .app pads with. + // wide = chrome + MandatorySystemGestures + TappableElement. For floating + // elements (FABs, toasters) that must clear the gesture-nav tappable region + // which can be wider than the visual handle reported in SystemBars. + // + // IME is read separately (not unioned) so chrome surfaces can keep using the + // system-only height while scrollable content shrinks for the keyboard. + // WindowInsetsCompat.Type.Ime() backports keyboard dispatch on pre-API-30. + var chromeMask = WindowInsetsCompat.Type.SystemBars() + | WindowInsetsCompat.Type.DisplayCutout(); + var wideMask = chromeMask + | WindowInsetsCompat.Type.MandatorySystemGestures() + | WindowInsetsCompat.Type.TappableElement(); + var chrome = insets.GetInsets(chromeMask); + var wide = insets.GetInsets(wideMask); + var sysBars = insets.GetInsets(WindowInsetsCompat.Type.SystemBars()); + var ime = insets.GetInsets(WindowInsetsCompat.Type.Ime()); + // Standard reduction: subtract nav-bar bottom so we don't double-count when + // the IME sits behind an opaque nav bar. + var imeBottom = Math.Max(0, (ime?.Bottom ?? 0) - (sysBars?.Bottom ?? 0)); + var chromeChanged = chrome is not null && !chrome.Equals(_lastChrome); + var wideChanged = wide is not null && !wide.Equals(_lastWide); + var imeChanged = imeBottom != _lastImeBottom; + if (chromeChanged || wideChanged || imeChanged) + { + LogBreakdown(insets, imeBottom); + if (chrome is not null) _lastChrome = chrome; + if (wide is not null) _lastWide = wide; + _lastImeBottom = imeBottom; + Apply(_webView, _lastChrome, _lastWide, imeBottom); + } + // Don't consume - let MAUI's own listeners (if any) still see the insets. + return insets; + } + + private static void LogBreakdown(WindowInsetsCompat insets, int imeReducedBottom) + { + // Diagnostic: surface the individual inset categories so we can see on-device + // (`adb logcat -s FwLiteInsets`) why the bottom value is what it is. + var sb = insets.GetInsets(WindowInsetsCompat.Type.SystemBars()); + var cut = insets.GetInsets(WindowInsetsCompat.Type.DisplayCutout()); + var gest = insets.GetInsets(WindowInsetsCompat.Type.MandatorySystemGestures()); + var tap = insets.GetInsets(WindowInsetsCompat.Type.TappableElement()); + var ime = insets.GetInsets(WindowInsetsCompat.Type.Ime()); + Log.Debug("FwLiteInsets", + $"SystemBars b={sb?.Bottom} t={sb?.Top}; Cutout b={cut?.Bottom} t={cut?.Top}; " + + $"MandatoryGestures b={gest?.Bottom}; Tappable b={tap?.Bottom}; " + + $"Ime b={ime?.Bottom} (reduced={imeReducedBottom})"); + } + } + + private static void Apply(AWebView webView, AInsets chrome, AInsets wide, int imeBottomPx) + { + var density = webView.Resources?.DisplayMetrics?.Density ?? 1f; + // The WebView's viewport is measured in CSS pixels; system insets come back in physical px. + // Use ceiling so we never under-reserve by a sub-CSS-pixel - truncation could leak + // up to ~1px of content under the bar at non-integer densities (e.g. 2.75x). + var cTop = (int)Math.Ceiling(chrome.Top / density); + var cRight = (int)Math.Ceiling(chrome.Right / density); + var cBottom = (int)Math.Ceiling(chrome.Bottom / density); + var cLeft = (int)Math.Ceiling(chrome.Left / density); + var wTop = (int)Math.Ceiling(wide.Top / density); + var wRight = (int)Math.Ceiling(wide.Right / density); + var wBottom = (int)Math.Ceiling(wide.Bottom / density); + var wLeft = (int)Math.Ceiling(wide.Left / density); + var ime = (int)Math.Ceiling(imeBottomPx / density); + Log.Debug("FwLiteInsets", + $"Applied CSS px: chrome=({cTop},{cRight},{cBottom},{cLeft}) " + + $"wide=({wTop},{wRight},{wBottom},{wLeft}) ime={ime} density={density}"); + var js = $$""" + (function() { + var s = document.documentElement.style; + s.setProperty('--android-safe-top', '{{cTop}}px'); + s.setProperty('--android-safe-right', '{{cRight}}px'); + s.setProperty('--android-safe-bottom', '{{cBottom}}px'); + s.setProperty('--android-safe-left', '{{cLeft}}px'); + s.setProperty('--android-wide-top', '{{wTop}}px'); + s.setProperty('--android-wide-right', '{{wRight}}px'); + s.setProperty('--android-wide-bottom', '{{wBottom}}px'); + s.setProperty('--android-wide-left', '{{wLeft}}px'); + s.setProperty('--android-ime-bottom', '{{ime}}px'); + })(); + """; + webView.Post(() => webView.EvaluateJavascript(js, null)); + } +} +#endif diff --git a/backend/FwLite/FwLiteMaui/Platforms/Android/MainActivity.cs b/backend/FwLite/FwLiteMaui/Platforms/Android/MainActivity.cs index ee6edadf3a..1f9776891f 100644 --- a/backend/FwLite/FwLiteMaui/Platforms/Android/MainActivity.cs +++ b/backend/FwLite/FwLiteMaui/Platforms/Android/MainActivity.cs @@ -16,8 +16,12 @@ public class MainActivity : MauiAppCompatActivity { protected override void OnCreate(Bundle? savedInstanceState) { - //custom style, declared in Android/Resources/values/styles.xml, values-v35 is used based on the android version - Theme?.ApplyStyle(Resource.Style.OptOutEdgeToEdgeEnforcement, force: false); + // Android 15+ (SDK 35) enforces edge-to-edge by default. Rather than opting out + // (which has historically been fragile - the attribute is deprecated on Android 16+ + // and CSS env(safe-area-inset-*) inside the BlazorWebView has been observed to + // return 0 even with viewport-fit=cover) we embrace edge-to-edge and propagate + // the real system-bar insets to the WebView as CSS custom properties via + // AndroidEdgeToEdgeInsets (installed when the BlazorWebView is initialized). base.OnCreate(savedInstanceState); } diff --git a/backend/FwLite/FwLiteMaui/Platforms/Android/Resources/values-v35/styles.xml b/backend/FwLite/FwLiteMaui/Platforms/Android/Resources/values-v35/styles.xml deleted file mode 100644 index 9df79fe9b4..0000000000 --- a/backend/FwLite/FwLiteMaui/Platforms/Android/Resources/values-v35/styles.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - From a26113c8282802c9d585b1ea3fecce155620e208 Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Wed, 20 May 2026 17:32:55 +0200 Subject: [PATCH 2/2] Frontend safe-area inset adjustments for Android edge-to-edge Add `viewport-fit=cover` to the HTML meta viewport tag so the WebView renders into the full screen including the notch/status-bar area. Then apply the `--safe-area-inset-*` CSS custom properties (set by `AndroidEdgeToEdgeInsets`) throughout the viewer: - `app.css`: root vars + utility classes for padding/margin insets - `App.svelte` / `ShadcnProjectView.svelte`: top/bottom padding on main content areas - `sidebar.svelte` / `sidebar-provider.svelte`: fixed positioning accounts for top inset so the sidebar doesn't underlap the status bar - `sonner.svelte`: toaster offset respects bottom inset - `dialog-content.svelte`, `drawer-content.svelte`, `alert-dialog-content.svelte`, `sheet-content.svelte`: overflow and padding adjustments so content isn't clipped behind system bars - `fab-container.svelte`: FAB raised above the bottom nav bar - `HomeView.svelte`: home view padding Co-Authored-By: Claude Sonnet 4.6 --- frontend/viewer/index.html | 2 +- frontend/viewer/src/App.svelte | 2 +- frontend/viewer/src/ShadcnProjectView.svelte | 4 +- frontend/viewer/src/app.css | 152 ++++++++++++++++++ frontend/viewer/src/home/HomeView.svelte | 3 +- .../lib/components/fab/fab-container.svelte | 14 +- .../alert-dialog/alert-dialog-content.svelte | 5 +- .../ui/dialog/dialog-content.svelte | 20 ++- .../ui/drawer/drawer-content.svelte | 10 +- .../components/ui/sheet/sheet-content.svelte | 2 +- .../ui/sidebar/sidebar-provider.svelte | 2 +- .../lib/components/ui/sidebar/sidebar.svelte | 11 +- .../lib/components/ui/sonner/sonner.svelte | 14 ++ 13 files changed, 220 insertions(+), 21 deletions(-) diff --git a/frontend/viewer/index.html b/frontend/viewer/index.html index 543e74f3fa..6bdb36acbe 100644 --- a/frontend/viewer/index.html +++ b/frontend/viewer/index.html @@ -3,7 +3,7 @@ - + FieldWorks Lite