From 6c2a702d0541805fd1e81939f59a73bc81ddc930 Mon Sep 17 00:00:00 2001 From: BagavathiPerumal Date: Wed, 27 May 2026 11:58:32 +0530 Subject: [PATCH 1/4] fix-35572-made code changes to fix cold-start RefreshView scroll capture for MauiWebView and MauiHybridWebView by moving Attach to constructor, detaching bridge for standalone WebViews, adding IsInsideMauiSwipeRefreshLayout guard in OnPageFinished, and adding constructor bridge attachment device test. --- .../src/Platform/Android/MauiHybridWebView.cs | 15 +++++++++++++++ .../Platform/Android/MauiHybridWebViewClient.cs | 4 +++- src/Core/src/Platform/Android/MauiWebView.cs | 15 +++++++++++++++ .../src/Platform/Android/MauiWebViewClient.cs | 4 +++- .../WebView/WebViewHandlerTests.Android.cs | 13 +++++++++++++ 5 files changed, 49 insertions(+), 2 deletions(-) diff --git a/src/Core/src/Platform/Android/MauiHybridWebView.cs b/src/Core/src/Platform/Android/MauiHybridWebView.cs index def3f528697b..90cf99a0648a 100644 --- a/src/Core/src/Platform/Android/MauiHybridWebView.cs +++ b/src/Core/src/Platform/Android/MauiHybridWebView.cs @@ -34,6 +34,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) @@ -60,6 +68,13 @@ 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(). 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/MauiWebView.cs b/src/Core/src/Platform/Android/MauiWebView.cs index 93e38dbcce31..a320c540466c 100644 --- a/src/Core/src/Platform/Android/MauiWebView.cs +++ b/src/Core/src/Platform/Android/MauiWebView.cs @@ -28,6 +28,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) @@ -55,6 +63,13 @@ 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() 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/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() { From addbc7912344973906f6197d131a862582ed0064 Mon Sep 17 00:00:00 2001 From: BagavathiPerumal Date: Fri, 5 Jun 2026 15:09:26 +0530 Subject: [PATCH 2/4] fix-35572-Removed the unwanted testcase code changes. --- .../WebView/WebViewHandlerTests.Android.cs | 57 ------------------- 1 file changed, 57 deletions(-) diff --git a/src/Core/tests/DeviceTests/Handlers/WebView/WebViewHandlerTests.Android.cs b/src/Core/tests/DeviceTests/Handlers/WebView/WebViewHandlerTests.Android.cs index f8b006074618..002384ffaf45 100644 --- a/src/Core/tests/DeviceTests/Handlers/WebView/WebViewHandlerTests.Android.cs +++ b/src/Core/tests/DeviceTests/Handlers/WebView/WebViewHandlerTests.Android.cs @@ -32,62 +32,5 @@ await InvokeOnMainThreadAsync(() => "JS bridge must be registered in the constructor, before any page load."); }); } - - [Fact(DisplayName = "DisconnectHandler Destroys Native WebView")] - public async Task DisconnectHandlerDestroysNativeWebView() - { - var originalFactory = WebViewHandler.PlatformViewFactory; - - try - { - await InvokeOnMainThreadAsync(() => - { - DestroyTrackingMauiWebView platformView = null; - - WebViewHandler.PlatformViewFactory = handler => - { - platformView = new DestroyTrackingMauiWebView((WebViewHandler)handler, handler.MauiContext!.Context!); - return platformView; - }; - - var webView = new WebViewStub(); - var handler = CreateHandler(webView); - var parent = new FrameLayout(handler.MauiContext!.Context!); - parent.AddView(handler.PlatformView); - - Assert.Same(parent, handler.PlatformView.Parent); - - ((IElementHandler)handler).DisconnectHandler(); - - var destroyTrackingWebView = platformView ?? throw new InvalidOperationException("Expected the WebView factory to create a platform view."); - Assert.True(destroyTrackingWebView.DestroyCalled); - Assert.Null(destroyTrackingWebView.ParentWhenDestroyed); - Assert.Equal(0, parent.ChildCount); - }); - } - finally - { - WebViewHandler.PlatformViewFactory = originalFactory; - } - } - - class DestroyTrackingMauiWebView : MauiWebView - { - public DestroyTrackingMauiWebView(WebViewHandler handler, Context context) - : base(handler, context) - { - } - - public bool DestroyCalled { get; private set; } - - public IViewParent ParentWhenDestroyed { get; private set; } - - public override void Destroy() - { - DestroyCalled = true; - ParentWhenDestroyed = Parent; - base.Destroy(); - } - } } } \ No newline at end of file From a30bebb844e4a6b262b180dc337fd12599af8948 Mon Sep 17 00:00:00 2001 From: BagavathiPerumal Date: Tue, 9 Jun 2026 13:01:52 +0530 Subject: [PATCH 3/4] fix-35572-Changes updated based on suggestion. --- .../src/Platform/Android/MauiHybridWebView.cs | 21 ++++++++++++++++++- .../Android/MauiSwipeRefreshLayout.cs | 19 ++++++++++------- src/Core/src/Platform/Android/MauiWebView.cs | 21 ++++++++++++++++++- .../RefreshViewWebViewScrollCapture.cs | 2 +- 4 files changed, 52 insertions(+), 11 deletions(-) diff --git a/src/Core/src/Platform/Android/MauiHybridWebView.cs b/src/Core/src/Platform/Android/MauiHybridWebView.cs index 90cf99a0648a..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 @@ -53,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) @@ -80,7 +84,21 @@ protected override void OnAttachedToWindow() // 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(); } @@ -137,6 +155,7 @@ protected override void Dispose(bool disposing) { if (disposing) { + _detachPending = false; RefreshViewWebViewScrollCapture.Detach(this); } 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 a320c540466c..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 @@ -46,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) @@ -74,7 +78,21 @@ protected override void OnAttachedToWindow() 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(); } @@ -167,6 +185,7 @@ protected override void Dispose(bool disposing) { if (disposing) { + _detachPending = false; RefreshViewWebViewScrollCapture.Detach(this); } 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; } } } From fce0f0120484e24a48c239c7e5631731cbaeeec9 Mon Sep 17 00:00:00 2001 From: BagavathiPerumal Date: Fri, 12 Jun 2026 11:45:51 +0530 Subject: [PATCH 4/4] fix-35572-Changes updated. --- .../WebView/WebViewHandlerTests.Android.cs | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/src/Core/tests/DeviceTests/Handlers/WebView/WebViewHandlerTests.Android.cs b/src/Core/tests/DeviceTests/Handlers/WebView/WebViewHandlerTests.Android.cs index 002384ffaf45..f8b006074618 100644 --- a/src/Core/tests/DeviceTests/Handlers/WebView/WebViewHandlerTests.Android.cs +++ b/src/Core/tests/DeviceTests/Handlers/WebView/WebViewHandlerTests.Android.cs @@ -32,5 +32,62 @@ await InvokeOnMainThreadAsync(() => "JS bridge must be registered in the constructor, before any page load."); }); } + + [Fact(DisplayName = "DisconnectHandler Destroys Native WebView")] + public async Task DisconnectHandlerDestroysNativeWebView() + { + var originalFactory = WebViewHandler.PlatformViewFactory; + + try + { + await InvokeOnMainThreadAsync(() => + { + DestroyTrackingMauiWebView platformView = null; + + WebViewHandler.PlatformViewFactory = handler => + { + platformView = new DestroyTrackingMauiWebView((WebViewHandler)handler, handler.MauiContext!.Context!); + return platformView; + }; + + var webView = new WebViewStub(); + var handler = CreateHandler(webView); + var parent = new FrameLayout(handler.MauiContext!.Context!); + parent.AddView(handler.PlatformView); + + Assert.Same(parent, handler.PlatformView.Parent); + + ((IElementHandler)handler).DisconnectHandler(); + + var destroyTrackingWebView = platformView ?? throw new InvalidOperationException("Expected the WebView factory to create a platform view."); + Assert.True(destroyTrackingWebView.DestroyCalled); + Assert.Null(destroyTrackingWebView.ParentWhenDestroyed); + Assert.Equal(0, parent.ChildCount); + }); + } + finally + { + WebViewHandler.PlatformViewFactory = originalFactory; + } + } + + class DestroyTrackingMauiWebView : MauiWebView + { + public DestroyTrackingMauiWebView(WebViewHandler handler, Context context) + : base(handler, context) + { + } + + public bool DestroyCalled { get; private set; } + + public IViewParent ParentWhenDestroyed { get; private set; } + + public override void Destroy() + { + DestroyCalled = true; + ParentWhenDestroyed = Parent; + base.Destroy(); + } + } } } \ No newline at end of file