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
153 changes: 153 additions & 0 deletions src/Controls/tests/TestCases.HostApp/Issues/Issue34392.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
#if ANDROID
using Android.Webkit;
using Microsoft.Maui.Handlers;
using Microsoft.Maui.Platform;
using AWebView = Android.Webkit.WebView;
#endif

namespace Maui.Controls.Sample.Issues;

[Issue(IssueTracker.Github, 34392, "MAUI Handler not working with Custom WebView on Android (ShouldOverrideUrlLoading behavior)", PlatformAffected.Android)]
public class Issue34392 : TestContentPage
{
public static Label StatusIndicator { get; private set; }
protected override void Init()
{
var grid = new Grid
{
RowDefinitions = new RowDefinitionCollection
{
new RowDefinition { Height = GridLength.Auto },
new RowDefinition { Height = GridLength.Auto },
new RowDefinition { Height = GridLength.Star }
}
};

var titleLabel = new Label
{
Text = "Custom WebViewClient Test",
FontSize = 18,
FontAttributes = FontAttributes.Bold,
HorizontalOptions = LayoutOptions.Center,
Padding = new Thickness(5)
};
grid.Add(titleLabel, 0, 0);

var statusLabel = new Label
{
Text = "FAILED",
AutomationId = "StatusLabel",
FontSize = 14,
FontAttributes = FontAttributes.Bold,
HorizontalOptions = LayoutOptions.Center,
Padding = new Thickness(5)
};
Grid.SetRow(statusLabel, 1);
grid.Add(statusLabel);

var customWebView = new Issue34392_CustomWebView();
Grid.SetRow(customWebView, 2);
grid.Add(customWebView);

Content = grid;

StatusIndicator = statusLabel;

customWebView.CustomHeaders = new Dictionary<string, string>
{
{ "X-Custom-Header", "MyHeaderValue" },
{ "Authorization", "Bearer sample-token-12345" }
};

customWebView.Source = new HtmlWebViewSource
{
Html = "<html><body><h1>Custom WebView Test</h1><script>setTimeout(function(){ window.location.href = 'https://example.com/test'; }, 500);</script></body></html>"
};
}
}

public class Issue34392_CustomWebView : Microsoft.Maui.Controls.WebView
{
public static readonly BindableProperty CustomHeadersProperty =
BindableProperty.Create(nameof(CustomHeaders), typeof(Dictionary<string, string>), typeof(Issue34392_CustomWebView), new Dictionary<string, string>());
Comment on lines +71 to +72

public Dictionary<string, string> CustomHeaders
{
get => (Dictionary<string, string>)GetValue(CustomHeadersProperty);
set => SetValue(CustomHeadersProperty, value);
}
}

#if ANDROID
#nullable enable
public class Issue34392_CustomWebViewHandler : WebViewHandler
{
protected override void ConnectHandler(AWebView platformView)
{
base.ConnectHandler(platformView);

var customWebView = VirtualView as Issue34392_CustomWebView;
var headers = customWebView?.CustomHeaders ?? new Dictionary<string, string>();

platformView.SetWebViewClient(new Issue34392_CustomWebViewClient(this, headers));
}
}

public class Issue34392_CustomWebViewClient : MauiWebViewClient
{
private readonly Dictionary<string, string> _headerParams;
private bool _alreadyRedirected;

public Issue34392_CustomWebViewClient(WebViewHandler handler, Dictionary<string, string> headers)
: base(handler)
{
_headerParams = headers;
}

public override bool ShouldOverrideUrlLoading(AWebView? view, IWebResourceRequest? request)
{
var url = request?.Url?.ToString() ?? string.Empty;

// Update UI to show this method was called
MainThread.BeginInvokeOnMainThread(() =>
{
if (Issue34392.StatusIndicator != null)
{
Issue34392.StatusIndicator.Text = "SUCCESS";
}
});

// Prevent infinite redirect loop — only load with custom headers once
if (_alreadyRedirected)
return true;

_alreadyRedirected = true;

if (_headerParams.Count > 0)
{
view?.LoadUrl(url, _headerParams);
}
else
{
view?.LoadUrl(url);
}

return true;
}
}
#endif

public static class Issue34392Extensions
{
public static MauiAppBuilder Issue34392AddHandlers(this MauiAppBuilder builder)
{
builder.ConfigureMauiHandlers(handlers =>
{
#if ANDROID
handlers.AddHandler<Issue34392_CustomWebView, Issue34392_CustomWebViewHandler>();
#endif
});

return builder;
}
}
3 changes: 2 additions & 1 deletion src/Controls/tests/TestCases.HostApp/MauiProgram.cs
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,8 @@ public static MauiApp CreateMauiApp()
.Issue18720DatePickerAddMappers()
.Issue18720TimePickerAddMappers()
.Issue28945AddMappers()
.Issue25436RegisterNavigationService();
.Issue25436RegisterNavigationService()
.Issue34392AddHandlers();

#if IOS || MACCATALYST
appBuilder.ConfigureCollectionViewHandlers();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
#if ANDROID //WebViewClient is only used in Android
using NUnit.Framework;
using UITest.Appium;
using UITest.Core;

namespace Microsoft.Maui.TestCases.Tests.Issues
{
public class Issue34392 : _IssuesUITest
{
public Issue34392(TestDevice testDevice) : base(testDevice)
{
}
public override string Issue => "MAUI Handler not working with Custom WebView on Android (ShouldOverrideUrlLoading behavior)";

[Test]
[Category(UITestCategories.WebView)]
public void ShouldOverrideUrlLoading_Called()
{
// The label starts as "FAILED" and updates asynchronously after ShouldOverrideUrlLoading fires.
// Poll until the text changes to "SUCCESS" instead of reading a stale initial value.
var result = App.WaitForTextToBePresentInElement("StatusLabel", "SUCCESS", timeout: TimeSpan.FromSeconds(10));

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 AI-Generated Review (multi-model)

[moderate] Regression Prevention and Test Coverage — This test stops after the custom client receives one navigation, so it never exercises the DisconnectHandler path changed by this PR. As written it would still pass even if a custom/shared WebViewClient is disposed during page teardown and then fails when reused. Please add a lifecycle assertion (for example navigate away/recreate the page or reuse the same custom client across two handler lifetimes) so the ownership/disposal regression is covered.

Assert.That(result, Is.True, "Expected ShouldOverrideUrlLoading to be called and update the status label text to 'SUCCESS', but it was not updated within the timeout.");
}
Comment thread
NirmalKumarYuvaraj marked this conversation as resolved.
}
}
#endif
72 changes: 52 additions & 20 deletions src/Core/src/Handlers/WebView/WebViewHandler.Android.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ public partial class WebViewHandler : ViewHandler<IWebView, AWebView>

protected internal string? UrlCanceled { get; set; }

MauiWebViewClient? _webViewClient;
MauiWebChromeClient? _webChromeClient;

protected override AWebView CreatePlatformView()
{
var platformView = new MauiWebView(this, Context!)
Expand All @@ -34,6 +37,13 @@ protected override AWebView CreatePlatformView()
platformView.SetLayerType(global::Android.Views.LayerType.Software, null);
}

// Create web clients once and store references
_webViewClient = new MauiWebViewClient(this);
platformView.SetWebViewClient(_webViewClient);

_webChromeClient = new MauiWebChromeClient(this);
platformView.SetWebChromeClient(_webChromeClient);

return platformView;
}

Expand All @@ -54,20 +64,55 @@ public override void SetVirtualView(IView view)

protected override void DisconnectHandler(AWebView platformView)
{
// WebView.WebViewClient / WebView.WebChromeClient getters are only available on API 26+.
// On older API levels we fall back to the cached references we set in CreatePlatformView.
global::Android.Webkit.WebViewClient? currentWebViewClient = null;
WebChromeClient? currentWebChromeClient = null;

if (OperatingSystem.IsAndroidVersionAtLeast(26))
{
if (platformView.WebViewClient is MauiWebViewClient webViewClient)
webViewClient.Disconnect();

if (platformView.WebChromeClient is MauiWebChromeClient webChromeClient)
webChromeClient.Disconnect();
currentWebViewClient = platformView.WebViewClient;
currentWebChromeClient = platformView.WebChromeClient;
}

platformView.SetWebViewClient(null!);
platformView.SetWebChromeClient(null);

platformView.StopLoading();

// Disconnect/dispose the clients that were actually active
if (OperatingSystem.IsAndroidVersionAtLeast(26))
{
(currentWebViewClient as MauiWebViewClient)?.Disconnect();
(currentWebChromeClient as MauiWebChromeClient)?.Disconnect();
}

// Also clean up originals if they were replaced by a custom handler
if (!ReferenceEquals(currentWebViewClient, _webViewClient))
{
if (OperatingSystem.IsAndroidVersionAtLeast(26))
{
_webViewClient?.Disconnect();
}

_webViewClient?.Dispose();
}

if (!ReferenceEquals(currentWebChromeClient, _webChromeClient))
{
if (OperatingSystem.IsAndroidVersionAtLeast(26))
{
_webChromeClient?.Disconnect();
}

_webChromeClient?.Dispose();
}

(currentWebViewClient as IDisposable)?.Dispose();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 AI-Generated Review (multi-model)

[major] Android WebViewHandler lifecycle — This disposes whichever WebViewClient/WebChromeClient is currently installed, including clients supplied by an app/custom handler. SetWebViewClient/SetWebChromeClient does not transfer ownership to MAUI; a shared or otherwise externally-owned client will be disposed when one handler disconnects and can then throw ObjectDisposedException when reused. Please only detach the platform view and disconnect/dispose clients owned by this handler, not arbitrary current clients.

(currentWebChromeClient as IDisposable)?.Dispose();

_webViewClient = null;
_webChromeClient = null;

base.DisconnectHandler(platformView);
}

Expand All @@ -81,18 +126,6 @@ public static void MapUserAgent(IWebViewHandler handler, IWebView webView)
handler.PlatformView.UpdateUserAgent(webView);
}

public static void MapWebViewClient(IWebViewHandler handler, IWebView webView)
{
if (handler is WebViewHandler platformHandler)
handler.PlatformView.SetWebViewClient(new MauiWebViewClient(platformHandler));
}

public static void MapWebChromeClient(IWebViewHandler handler, IWebView webView)
{
if (handler is WebViewHandler platformHandler)
handler.PlatformView.SetWebChromeClient(new MauiWebChromeClient(platformHandler));
}

public static void MapWebViewSettings(IWebViewHandler handler, IWebView webView)
{
handler.PlatformView.UpdateSettings(webView, true, true);
Expand Down Expand Up @@ -167,8 +200,7 @@ protected internal bool NavigatingCanceled(string? url)

static void ProcessSourceWhenReady(IWebViewHandler handler, IWebView webView)
{
//We want to load the source after making sure the mapper for webclients
//and settings were called already
//We want to load the source after making sure the mapper for settings was called already
var platformHandler = handler as WebViewHandler;
if (platformHandler == null || platformHandler._firstRun)
return;
Expand Down
2 changes: 0 additions & 2 deletions src/Core/src/Handlers/WebView/WebViewHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,6 @@ public partial class WebViewHandler : IWebViewHandler
[nameof(IView.Background)] = MapBackground,
#endif
#if __ANDROID__
[nameof(WebViewClient)] = MapWebViewClient,
[nameof(WebChromeClient)] = MapWebChromeClient,
[nameof(WebView.Settings)] = MapWebViewSettings
#elif __IOS__ || MACCATALYST
[nameof(IWebView.FlowDirection)] = MapFlowDirection,
Expand Down
2 changes: 2 additions & 0 deletions src/Core/src/PublicAPI/net-android/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,8 @@ static Microsoft.Maui.Platform.TimePickerExtensions.UpdateTime(this Microsoft.Ma
static Microsoft.Maui.SafeAreaEdges.Container.get -> Microsoft.Maui.SafeAreaEdges
virtual Microsoft.Maui.Handlers.DatePickerHandler2.CreateDatePickerDialog(int year, int month, int day) -> Google.Android.Material.DatePicker.MaterialDatePicker?
virtual Microsoft.Maui.Handlers.TimePickerHandler2.CreateTimePickerDialog(int hour, int minute) -> Google.Android.Material.TimePicker.MaterialTimePicker?
*REMOVED*static Microsoft.Maui.Handlers.WebViewHandler.MapWebChromeClient(Microsoft.Maui.Handlers.IWebViewHandler! handler, Microsoft.Maui.IWebView! webView) -> void

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[major] Public API Surface — These methods are already shipped public Android APIs (they are still listed in PublicAPI.Shipped.txt), so marking them REMOVED and deleting the methods is a source-breaking change for apps or libraries that call/customize them. The mapper entries can be removed without breaking callers by keeping MapWebViewClient/MapWebChromeClient as compatibility shims, for example idempotent no-ops or default-client initializers.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 AI-Generated Review (multi-model)

[major] Public API Surface — MapWebViewClient and MapWebChromeClient are shipped Android APIs (they remain listed in PublicAPI.Shipped.txt), so removing them is a source/binary break for apps that call these helpers from custom WebView mapper code. The lifecycle fix can still move default client creation earlier while keeping these methods as compatibility shims that preserve the old behavior instead of removing the shipped members.

*REMOVED*static Microsoft.Maui.Handlers.WebViewHandler.MapWebViewClient(Microsoft.Maui.Handlers.IWebViewHandler! handler, Microsoft.Maui.IWebView! webView) -> void
Comment thread
NirmalKumarYuvaraj marked this conversation as resolved.
Comment on lines +287 to +288
Microsoft.Maui.ITab
Microsoft.Maui.ITab.Icon.get -> Microsoft.Maui.IImageSource?
Microsoft.Maui.ITab.IsEnabled.get -> bool
Expand Down
Loading