diff --git a/src/Core/src/Platform/Android/MauiHybridWebView.cs b/src/Core/src/Platform/Android/MauiHybridWebView.cs index def3f528697b..9d57358ab6cb 100644 --- a/src/Core/src/Platform/Android/MauiHybridWebView.cs +++ b/src/Core/src/Platform/Android/MauiHybridWebView.cs @@ -2,6 +2,7 @@ using System.Diagnostics.CodeAnalysis; using Android.Content; using Android.Graphics; +using Android.OS; using Android.Webkit; using AUri = Android.Net.Uri; using AWebView = Android.Webkit.WebView; @@ -17,6 +18,7 @@ public class MauiHybridWebView : AWebView, IHybridPlatformWebView private readonly WeakReference _handler; private static readonly AUri AndroidAppOriginUri = AUri.Parse(HybridWebViewHandler.AppOrigin)!; readonly Rect _clipRect; + volatile bool _detachPending; // True after the first layout pass where exactly one dimension is positive and the other is zero. // Auto-sizing layouts produce this intermediate state; a zero-area ClipBounds here @@ -34,6 +36,14 @@ public MauiHybridWebView(HybridWebViewHandler handler, Context context) : base(c // https://github.com/dotnet/maui/issues/31475 _clipRect = new Rect(0, 0, 0, 0); ClipBounds = _clipRect; + + // Pre-register the JS bridge BEFORE any page loads. + // Android WebView only exposes addJavascriptInterface bindings for pages that + // start loading AFTER the call is made. If Attach is deferred to + // OnAttachedToWindow, cold-start apps load their page before the view enters + // the window hierarchy, so the bridge is invisible to JS. + // Attach is idempotent, so later calls from OnAttachedToWindow are safe no-ops. + RefreshViewWebViewScrollCapture.Attach(this); } protected override void OnSizeChanged(int width, int height, int oldWidth, int oldHeight) @@ -45,6 +55,8 @@ protected override void OnSizeChanged(int width, int height, int oldWidth, int o // OnAttachedToWindow — calls Attach(this) when inside a SwipeRefreshLayout. protected override void OnAttachedToWindow() { + _detachPending = false; + base.OnAttachedToWindow(); // Re-evaluate ClipBounds when re-parented (e.g., wrapped in WrapperView for shadow) @@ -60,12 +72,33 @@ protected override void OnAttachedToWindow() RefreshViewWebViewScrollCapture.InjectObserver(this); } } + else + { + // Not inside a RefreshView — remove the bridge that was pre-registered + // in the constructor so it is not exposed to untrusted page content + // loaded in standalone HybridWebViews. + RefreshViewWebViewScrollCapture.Detach(this); + } } // OnDetachedFromWindow — calls Detach(). protected override void OnDetachedFromWindow() { - RefreshViewWebViewScrollCapture.Detach(this); + if (RefreshViewWebViewScrollCapture.IsAttached(this)) + { + _detachPending = true; +#pragma warning disable CA1422 // Validate platform compatibility + new Handler(Looper.MainLooper!).Post(() => +#pragma warning restore CA1422 // Validate platform compatibility + { + if (_detachPending) + { + _detachPending = false; + RefreshViewWebViewScrollCapture.Detach(this); + } + }); + } + base.OnDetachedFromWindow(); } @@ -122,6 +155,7 @@ protected override void Dispose(bool disposing) { if (disposing) { + _detachPending = false; RefreshViewWebViewScrollCapture.Detach(this); } diff --git a/src/Core/src/Platform/Android/MauiHybridWebViewClient.cs b/src/Core/src/Platform/Android/MauiHybridWebViewClient.cs index c202d14e2366..c7e555fcc281 100644 --- a/src/Core/src/Platform/Android/MauiHybridWebViewClient.cs +++ b/src/Core/src/Platform/Android/MauiHybridWebViewClient.cs @@ -49,7 +49,9 @@ public override void OnPageFinished(AWebView? view, string? url) // Only inject the scroll-capture observer when the WebView is hosted inside // a RefreshView – avoids unnecessary JS overhead for standalone HybridWebViews. - if (RefreshViewWebViewScrollCapture.IsAttached(view)) + if (view is not null && + RefreshViewWebViewScrollCapture.IsAttached(view) && + RefreshViewWebViewScrollCapture.IsInsideMauiSwipeRefreshLayout(view)) { RefreshViewWebViewScrollCapture.InjectObserver(view); } diff --git a/src/Core/src/Platform/Android/MauiSwipeRefreshLayout.cs b/src/Core/src/Platform/Android/MauiSwipeRefreshLayout.cs index 23cc46bd77d5..c3e406fc9b88 100644 --- a/src/Core/src/Platform/Android/MauiSwipeRefreshLayout.cs +++ b/src/Core/src/Platform/Android/MauiSwipeRefreshLayout.cs @@ -172,18 +172,21 @@ public override bool OnInterceptTouchEvent(MotionEvent? ev) _webViewOwnsGesture = false; break; case MotionEventActions.Move: - // ACTION_MOVE — reads CanScrollUp (volatile bool, zero JNI) from cached state - // instead of calling TryGetCanScrollUp every frame. - if (_touchStartedInWebView && _webViewOwnsGesture && _activeTouchScrollState is not null) + if (_touchStartedInWebView && _webViewOwnsGesture) { - if (!_activeTouchScrollState.CanScrollUp) + var shouldRetainOwnership = _activeTouchScrollState is { HasReportedState: true } + ? _activeTouchScrollState.CanScrollUp + : RefreshViewWebViewScrollCapture.TryGetCanScrollUp(_activeTouchWebView, out var canScrollUpFallback) && canScrollUpFallback; + + if (!shouldRetainOwnership) { _webViewOwnsGesture = false; } - } - if (_touchStartedInWebView && _webViewOwnsGesture) - { - return false; + + if (_webViewOwnsGesture) + { + return false; + } } break; case MotionEventActions.Cancel: diff --git a/src/Core/src/Platform/Android/MauiWebView.cs b/src/Core/src/Platform/Android/MauiWebView.cs index 93e38dbcce31..5ccc05cdfeef 100644 --- a/src/Core/src/Platform/Android/MauiWebView.cs +++ b/src/Core/src/Platform/Android/MauiWebView.cs @@ -1,6 +1,7 @@ using System; using Android.Content; using Android.Graphics; +using Android.OS; using Android.Views; using Android.Webkit; @@ -12,6 +13,7 @@ public class MauiWebView : WebView, IWebViewDelegate readonly WebViewHandler _handler; readonly Rect _clipRect; + volatile bool _detachPending; // True after the first layout pass where exactly one dimension is positive and the other is zero. // Auto-sizing layouts produce this intermediate state; a zero-area ClipBounds here @@ -28,6 +30,14 @@ public MauiWebView(WebViewHandler handler, Context context) : base(context) // https://github.com/dotnet/maui/issues/31475 _clipRect = new Rect(0, 0, 0, 0); ClipBounds = _clipRect; + + // Pre-register the JS bridge BEFORE any page loads. + // Android WebView only exposes addJavascriptInterface bindings for pages that + // start loading AFTER the call is made. If Attach is deferred to + // OnAttachedToWindow, cold-start apps (e.g. the Sandbox) load their page before + // the view enters the window hierarchy, so the bridge is invisible to JS. + // Attach is idempotent, so later calls from OnAttachedToWindow are safe no-ops. + RefreshViewWebViewScrollCapture.Attach(this); } protected override void OnSizeChanged(int width, int height, int oldWidth, int oldHeight) @@ -38,6 +48,8 @@ protected override void OnSizeChanged(int width, int height, int oldWidth, int o protected override void OnAttachedToWindow() { + _detachPending = false; + base.OnAttachedToWindow(); // Re-evaluate ClipBounds when re-parented (e.g., wrapped in WrapperView for shadow) @@ -55,11 +67,32 @@ protected override void OnAttachedToWindow() RefreshViewWebViewScrollCapture.InjectObserver(this); } } + else + { + // Not inside a RefreshView — remove the bridge that was pre-registered + // in the constructor so it is not exposed to untrusted page content + // loaded in standalone WebViews. + RefreshViewWebViewScrollCapture.Detach(this); + } } protected override void OnDetachedFromWindow() { - RefreshViewWebViewScrollCapture.Detach(this); + if (RefreshViewWebViewScrollCapture.IsAttached(this)) + { + _detachPending = true; +#pragma warning disable CA1422 // Validate platform compatibility + new Handler(Looper.MainLooper!).Post(() => +#pragma warning restore CA1422 // Validate platform compatibility + { + if (_detachPending) + { + _detachPending = false; + RefreshViewWebViewScrollCapture.Detach(this); + } + }); + } + base.OnDetachedFromWindow(); } @@ -152,6 +185,7 @@ protected override void Dispose(bool disposing) { if (disposing) { + _detachPending = false; RefreshViewWebViewScrollCapture.Detach(this); } diff --git a/src/Core/src/Platform/Android/MauiWebViewClient.cs b/src/Core/src/Platform/Android/MauiWebViewClient.cs index 89e53e36c47b..aa554dc0e2d5 100644 --- a/src/Core/src/Platform/Android/MauiWebViewClient.cs +++ b/src/Core/src/Platform/Android/MauiWebViewClient.cs @@ -73,7 +73,9 @@ public override void OnPageFinished(WebView? view, string? url) // Only inject the scroll-capture observer when the WebView is hosted inside // a RefreshView – avoids unnecessary JS overhead for standalone WebViews. - if (RefreshViewWebViewScrollCapture.IsAttached(view)) + if (view is not null && + RefreshViewWebViewScrollCapture.IsAttached(view) && + RefreshViewWebViewScrollCapture.IsInsideMauiSwipeRefreshLayout(view)) { RefreshViewWebViewScrollCapture.InjectObserver(view); } diff --git a/src/Core/src/Platform/Android/RefreshViewWebViewScrollCapture.cs b/src/Core/src/Platform/Android/RefreshViewWebViewScrollCapture.cs index ac2ab4b54da7..4922d544ca1b 100644 --- a/src/Core/src/Platform/Android/RefreshViewWebViewScrollCapture.cs +++ b/src/Core/src/Platform/Android/RefreshViewWebViewScrollCapture.cs @@ -221,8 +221,8 @@ public void SetCanScrollUp(bool canScrollUp) internal void Reset() { - _canScrollUp = false; _hasReportedState = false; + _canScrollUp = false; } } } diff --git a/src/Core/tests/DeviceTests/Handlers/WebView/WebViewHandlerTests.Android.cs b/src/Core/tests/DeviceTests/Handlers/WebView/WebViewHandlerTests.Android.cs index 49a655091c24..f8b006074618 100644 --- a/src/Core/tests/DeviceTests/Handlers/WebView/WebViewHandlerTests.Android.cs +++ b/src/Core/tests/DeviceTests/Handlers/WebView/WebViewHandlerTests.Android.cs @@ -20,6 +20,19 @@ AWebView GetNativeWebView(WebViewHandler webViewHandler) => string GetNativeSource(WebViewHandler webViewHandler) => GetNativeWebView(webViewHandler).Url; + [Fact(DisplayName = "MauiWebView has JS bridge registered at construction time")] + public async Task WebView_HasScrollCaptureBridge_AfterConstruction() + { + await InvokeOnMainThreadAsync(() => + { + var stub = new WebViewStub(); + var handler = CreateHandler(stub); + var webView = new MauiWebView(handler, handler.MauiContext!.Context!); + Assert.True(RefreshViewWebViewScrollCapture.IsAttached(webView), + "JS bridge must be registered in the constructor, before any page load."); + }); + } + [Fact(DisplayName = "DisconnectHandler Destroys Native WebView")] public async Task DisconnectHandlerDestroysNativeWebView() {