Skip to content
Merged
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
36 changes: 35 additions & 1 deletion src/Core/src/Platform/Android/MauiHybridWebView.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -17,6 +18,7 @@ public class MauiHybridWebView : AWebView, IHybridPlatformWebView
private readonly WeakReference<HybridWebViewHandler> _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
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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();
}

Expand Down Expand Up @@ -122,6 +155,7 @@ protected override void Dispose(bool disposing)
{
if (disposing)
{
_detachPending = false;
RefreshViewWebViewScrollCapture.Detach(this);
}

Expand Down
4 changes: 3 additions & 1 deletion src/Core/src/Platform/Android/MauiHybridWebViewClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
19 changes: 11 additions & 8 deletions src/Core/src/Platform/Android/MauiSwipeRefreshLayout.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
36 changes: 35 additions & 1 deletion src/Core/src/Platform/Android/MauiWebView.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using System;
using Android.Content;
using Android.Graphics;
using Android.OS;
using Android.Views;
using Android.Webkit;

Expand All @@ -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
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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();
}

Expand Down Expand Up @@ -152,6 +185,7 @@ protected override void Dispose(bool disposing)
{
if (disposing)
{
_detachPending = false;
RefreshViewWebViewScrollCapture.Detach(this);
}

Expand Down
4 changes: 3 additions & 1 deletion src/Core/src/Platform/Android/MauiWebViewClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -221,8 +221,8 @@ public void SetCanScrollUp(bool canScrollUp)

internal void Reset()
{
_canScrollUp = false;
_hasReportedState = false;
_canScrollUp = false;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<WebViewHandler>(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()
{
Expand Down
Loading