diff --git a/src/Controls/samples/Controls.Sample/Pages/Controls/HybridWebViewPage.xaml.cs b/src/Controls/samples/Controls.Sample/Pages/Controls/HybridWebViewPage.xaml.cs index 6c6a2dcb1290..cf7cab32925a 100644 --- a/src/Controls/samples/Controls.Sample/Pages/Controls/HybridWebViewPage.xaml.cs +++ b/src/Controls/samples/Controls.Sample/Pages/Controls/HybridWebViewPage.xaml.cs @@ -123,6 +123,36 @@ public SyncReturn DoSyncWorkParamsReturn(int i, string s) Value = i, }; } + + public async Task DoAsyncWork() + { + await Task.Delay(1000); + Debug.WriteLine("DoAsyncWork"); + } + + public async Task DoAsyncWorkParams(int i, string s) + { + await Task.Delay(1000); + Debug.WriteLine($"DoAsyncWorkParams: {i}, {s}"); + } + + public async Task DoAsyncWorkReturn() + { + await Task.Delay(1000); + Debug.WriteLine("DoAsyncWorkReturn"); + return "Hello from C#!"; + } + + public async Task DoAsyncWorkParamsReturn(int i, string s) + { + await Task.Delay(1000); + Debug.WriteLine($"DoAsyncWorkParamsReturn: {i}, {s}"); + return new SyncReturn + { + Message = "Hello from C#! " + s, + Value = i, + }; + } } public class SyncReturn diff --git a/src/Controls/samples/Controls.Sample/Resources/Raw/HybridSamplePage/index.html b/src/Controls/samples/Controls.Sample/Resources/Raw/HybridSamplePage/index.html index 4a178f4b555a..8f991dfac2d4 100644 --- a/src/Controls/samples/Controls.Sample/Resources/Raw/HybridSamplePage/index.html +++ b/src/Controls/samples/Controls.Sample/Resources/Raw/HybridSamplePage/index.html @@ -69,6 +69,30 @@ LogMessage("Invoked DoSyncWorkParamsReturn, return value: message=" + retValue.Message + ", value=" + retValue.Value); } + async function InvokeDoAsyncWork() { + LogMessage("Invoking DoAsyncWork"); + await window.HybridWebView.InvokeDotNet('DoAsyncWork'); + LogMessage("Invoked DoAsyncWork"); + } + + async function InvokeDoAsyncWorkParams() { + LogMessage("Invoking DoAsyncWorkParams"); + await window.HybridWebView.InvokeDotNet('DoAsyncWorkParams', [123, 'hello']); + LogMessage("Invoked DoAsyncWorkParams"); + } + + async function InvokeDoAsyncWorkReturn() { + LogMessage("Invoking DoAsyncWorkReturn"); + const retValue = await window.HybridWebView.InvokeDotNet('DoAsyncWorkReturn'); + LogMessage("Invoked DoAsyncWorkReturn, return value: " + retValue); + } + + async function InvokeDoAsyncWorkParamsReturn() { + LogMessage("Invoking DoAsyncWorkParamsReturn"); + const retValue = await window.HybridWebView.InvokeDotNet('DoAsyncWorkParamsReturn', [123, 'hello']); + LogMessage("Invoked DoAsyncWorkParamsReturn, return value: message=" + retValue.Message + ", value=" + retValue.Value); + } + @@ -84,6 +108,12 @@ +
+ + + + +
Log:
diff --git a/src/Controls/tests/DeviceTests/Elements/HybridWebView/HybridWebViewTests.cs b/src/Controls/tests/DeviceTests/Elements/HybridWebView/HybridWebViewTests.cs index c78e24bb5a44..7c9d9047d271 100644 --- a/src/Controls/tests/DeviceTests/Elements/HybridWebView/HybridWebViewTests.cs +++ b/src/Controls/tests/DeviceTests/Elements/HybridWebView/HybridWebViewTests.cs @@ -369,6 +369,33 @@ public object Invoke_NoParam_ReturnNull() return null; } + public async Task Invoke_NoParam_ReturnTask() + { + await Task.Delay(1); + UpdateLastMethodCalled(); + } + + public async Task Invoke_NoParam_ReturnTaskNull() + { + await Task.Delay(1); + UpdateLastMethodCalled(); + return null; + } + + public async Task Invoke_NoParam_ReturnTaskValueType() + { + await Task.Delay(1); + UpdateLastMethodCalled(); + return 2; + } + + public async Task Invoke_NoParam_ReturnTaskComplex() + { + await Task.Delay(1); + UpdateLastMethodCalled(); + return NewComplexResult; + } + public int Invoke_OneParam_ReturnValueType(Dictionary dict) { Assert.NotNull(dict); @@ -440,6 +467,10 @@ public IEnumerator GetEnumerator() // 3. Methods with different return values (none, simple, complex, etc.) yield return new object[] { "Invoke_NoParam_NoReturn", null }; yield return new object[] { "Invoke_NoParam_ReturnNull", null }; + yield return new object[] { "Invoke_NoParam_ReturnTask", null }; + yield return new object[] { "Invoke_NoParam_ReturnTaskNull", null }; + yield return new object[] { "Invoke_NoParam_ReturnTaskValueType", ValueTypeResult }; + yield return new object[] { "Invoke_NoParam_ReturnTaskComplex", ComplexResult }; yield return new object[] { "Invoke_OneParam_ReturnValueType", ValueTypeResult }; yield return new object[] { "Invoke_OneParam_ReturnDictionary", DictionaryResult }; yield return new object[] { "Invoke_NullParam_ReturnComplex", ComplexResult }; diff --git a/src/Core/src/Handlers/HybridWebView/HybridWebViewHandler.Windows.cs b/src/Core/src/Handlers/HybridWebView/HybridWebViewHandler.Windows.cs index fabe093bb926..f25c8f7e6434 100644 --- a/src/Core/src/Handlers/HybridWebView/HybridWebViewHandler.Windows.cs +++ b/src/Core/src/Handlers/HybridWebView/HybridWebViewHandler.Windows.cs @@ -105,77 +105,84 @@ private async void OnWebResourceRequested(CoreWebView2 sender, CoreWebView2WebRe // Get a deferral object so that WebView2 knows there's some async stuff going on. We call Complete() at the end of this method. using var deferral = eventArgs.GetDeferral(); - var requestUri = HybridWebViewQueryStringHelper.RemovePossibleQueryString(eventArgs.Request.Uri); + var (stream, contentType, statusCode, reason) = await GetResponseStreamAsync(eventArgs.Request.Uri); + var contentLength = stream?.Size ?? 0; + var headers = + $""" + Content-Type: {contentType} + Content-Length: {contentLength} + """; + + eventArgs.Response = sender.Environment!.CreateWebResourceResponse( + Content: stream, + StatusCode: statusCode, + ReasonPhrase: reason, + Headers: headers); + + // Notify WebView2 that the deferred (async) operation is complete and we set a response. + deferral.Complete(); + } + + private async Task<(IRandomAccessStream Stream, string ContentType, int StatusCode, string Reason)> GetResponseStreamAsync(string url) + { + var requestUri = HybridWebViewQueryStringHelper.RemovePossibleQueryString(url); if (new Uri(requestUri) is Uri uri && AppOriginUri.IsBaseOf(uri)) { var relativePath = AppOriginUri.MakeRelativeUri(uri).ToString().Replace('/', '\\'); - string? contentType = null; - Stream? contentStream = null; - // 1. Try special InvokeDotNet path if (relativePath == InvokeDotNetPath) { - var fullUri = new Uri(eventArgs.Request.Uri); + var fullUri = new Uri(url); var invokeQueryString = HttpUtility.ParseQueryString(fullUri.Query); - (var contentBytes, contentType) = InvokeDotNet(invokeQueryString); + var contentBytes = await InvokeDotNetAsync(invokeQueryString); if (contentBytes is not null) { - contentStream = new MemoryStream(contentBytes); + var bytesStream = new MemoryStream(contentBytes); + var ras = await CopyContentToRandomAccessStreamAsync(bytesStream); + return (Stream: ras, ContentType: "application/json", StatusCode: 200, Reason: "OK"); } } + string contentType; + // 2. If nothing found yet, try to get static content from the asset path - if (contentStream is null) + if (string.IsNullOrEmpty(relativePath)) { - if (string.IsNullOrEmpty(relativePath)) - { - relativePath = VirtualView.DefaultFile; - contentType = "text/html"; - } - else + relativePath = VirtualView.DefaultFile; + contentType = "text/html"; + } + else + { + if (!ContentTypeProvider.TryGetContentType(relativePath, out contentType!)) { - if (!ContentTypeProvider.TryGetContentType(relativePath, out contentType!)) - { - // TODO: Log this - contentType = "text/plain"; - } + // TODO: Log this + contentType = "text/plain"; } - - var assetPath = Path.Combine(VirtualView.HybridRoot!, relativePath!); - contentStream = await GetAssetStreamAsync(assetPath); } - if (contentStream is null) - { - // 3.a. If still nothing is found, return a 404 - var notFoundContent = "Resource not found (404)"; - eventArgs.Response = sender.Environment!.CreateWebResourceResponse( - Content: null, - StatusCode: 404, - ReasonPhrase: "Not Found", - Headers: GetHeaderString("text/plain", notFoundContent.Length) - ); - } - else + var assetPath = Path.Combine(VirtualView.HybridRoot!, relativePath!); + using var contentStream = await GetAssetStreamAsync(assetPath); + + if (contentStream is not null) { - // 3.b. Otherwise, return the content - eventArgs.Response = sender.Environment!.CreateWebResourceResponse( - Content: await CopyContentToRandomAccessStreamAsync(contentStream), - StatusCode: 200, - ReasonPhrase: "OK", - Headers: GetHeaderString(contentType ?? "text/plain", (int)contentStream.Length) - ); + // 3.a. If something was found, return the content + var ras = await CopyContentToRandomAccessStreamAsync(contentStream); + return (Stream: ras, ContentType: contentType, StatusCode: 200, Reason: "OK"); } + } - contentStream?.Dispose(); + // 3.b. Otherwise, return a 404 + var ras404 = new InMemoryRandomAccessStream(); + using (var writer = new StreamWriter(ras404.AsStreamForWrite())) + { + writer.WriteLine("Resource not found (404)"); } - // Notify WebView2 that the deferred (async) operation is complete and we set a response. - deferral.Complete(); + return (Stream: ras404, ContentType: "text/plain", StatusCode: 404, Reason: "Not Found"); - async Task CopyContentToRandomAccessStreamAsync(Stream content) + static async Task CopyContentToRandomAccessStreamAsync(Stream content) { using var memStream = new MemoryStream(); await content.CopyToAsync(memStream); @@ -185,9 +192,6 @@ async Task CopyContentToRandomAccessStreamAsync(Stream cont } } - private protected static string GetHeaderString(string contentType, int contentLength) => -$@"Content-Type: {contentType} -Content-Length: {contentLength}"; [RequiresUnreferencedCode(DynamicFeatures)] #if !NETSTANDARD diff --git a/src/Core/src/Handlers/HybridWebView/HybridWebViewHandler.cs b/src/Core/src/Handlers/HybridWebView/HybridWebViewHandler.cs index dbc47653beb6..e8fd784b9a2e 100644 --- a/src/Core/src/Handlers/HybridWebView/HybridWebViewHandler.cs +++ b/src/Core/src/Handlers/HybridWebView/HybridWebViewHandler.cs @@ -25,11 +25,14 @@ using System.Collections.Generic; using System.Linq; using System.Text.Json; +using System.Text; using Microsoft.Extensions.DependencyInjection; using Microsoft.Maui.Hosting; using System.Collections.Specialized; using System.Text.Json.Serialization; using System.Diagnostics.CodeAnalysis; +using System.Reflection; +using Microsoft.Extensions.Logging; namespace Microsoft.Maui.Handlers { @@ -153,89 +156,125 @@ void MessageReceived(string rawMessage) } } - internal (byte[]? ContentBytes, string? ContentType) InvokeDotNet(NameValueCollection invokeQueryString) + internal async Task InvokeDotNetAsync(NameValueCollection invokeQueryString) { try { - var invokeTarget = VirtualView.InvokeJavaScriptTarget ?? throw new NotImplementedException($"The {nameof(IHybridWebView)}.{nameof(IHybridWebView.InvokeJavaScriptTarget)} property must have a value in order to invoke a .NET method from JavaScript."); + var invokeTarget = VirtualView.InvokeJavaScriptTarget ?? throw new InvalidOperationException($"The {nameof(IHybridWebView)}.{nameof(IHybridWebView.InvokeJavaScriptTarget)} property must have a value in order to invoke a .NET method from JavaScript."); + var invokeTargetType = VirtualView.InvokeJavaScriptType ?? throw new InvalidOperationException($"The {nameof(IHybridWebView)}.{nameof(IHybridWebView.InvokeJavaScriptType)} property must have a value in order to invoke a .NET method from JavaScript."); + var invokeDataString = invokeQueryString["data"]; if (string.IsNullOrEmpty(invokeDataString)) { throw new ArgumentException("The 'data' query string parameter is required.", nameof(invokeQueryString)); } - byte[]? contentBytes = null; - string? contentType = null; - var invokeData = JsonSerializer.Deserialize(invokeDataString, HybridWebViewHandlerJsonContext.Default.JSInvokeMethodData); - - if (invokeData != null && invokeData.MethodName != null) + if (invokeData?.MethodName is null) { - var t = ((IHybridWebView)VirtualView).InvokeJavaScriptType; - var result = InvokeDotNetMethod(t!, invokeTarget, invokeData); - - contentType = "application/json"; + throw new ArgumentException("The invoke data did not provide a method name.", nameof(invokeQueryString)); + } - DotNetInvokeResult dotNetInvokeResult; + var invokeResultRaw = await InvokeDotNetMethodAsync(invokeTargetType, invokeTarget, invokeData); + var invokeResult = CreateInvokeResult(invokeResultRaw); + var json = JsonSerializer.Serialize(invokeResult); + var contentBytes = Encoding.UTF8.GetBytes(json); - if (result is not null) - { - var resultType = result.GetType(); - if (resultType.IsArray || resultType.IsClass) - { - dotNetInvokeResult = new DotNetInvokeResult() - { - Result = JsonSerializer.Serialize(result), - IsJson = true, - }; - } - else - { - dotNetInvokeResult = new DotNetInvokeResult() - { - Result = result, - }; - } - } - else - { - dotNetInvokeResult = new(); - } + return contentBytes; + } + catch (Exception ex) + { + MauiContext?.CreateLogger()?.LogError(ex, "An error occurred while invoking a .NET method from JavaScript: {ErrorMessage}", ex.Message); + } - contentBytes = System.Text.Encoding.UTF8.GetBytes(JsonSerializer.Serialize(dotNetInvokeResult)); - } + return default; + } - return (contentBytes, contentType); + private static DotNetInvokeResult CreateInvokeResult(object? result) + { + // null invoke result means an empty result + if (result is null) + { + return new(); } - catch (Exception) + + // a reference type or an array should be serialized to JSON + var resultType = result.GetType(); + if (resultType.IsArray || resultType.IsClass) { - // TODO: Log this + return new DotNetInvokeResult() + { + Result = JsonSerializer.Serialize(result), + IsJson = true, + }; } - return (null, null); + // a value type should be returned as is + return new DotNetInvokeResult() + { + Result = result, + }; } - private static object? InvokeDotNetMethod([DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] Type t, object jsInvokeTarget, JSInvokeMethodData invokeData) + private static async Task InvokeDotNetMethodAsync( + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] Type targetType, + object jsInvokeTarget, + JSInvokeMethodData invokeData) { - var invokeMethod = t.GetMethod(invokeData.MethodName!, System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.InvokeMethod); - if (invokeMethod == null) + var requestMethodName = invokeData.MethodName!; + var requestParams = invokeData.ParamValues; + + // get the method and its parameters from the .NET object instance + var dotnetMethod = targetType.GetMethod(requestMethodName, BindingFlags.Public | BindingFlags.Instance | BindingFlags.InvokeMethod); + if (dotnetMethod is null) + { + throw new InvalidOperationException($"The method {requestMethodName} couldn't be found on the {nameof(jsInvokeTarget)} of type {jsInvokeTarget.GetType().FullName}."); + } + var dotnetParams = dotnetMethod.GetParameters(); + if (requestParams is not null && dotnetParams.Length != requestParams.Length) { - throw new InvalidOperationException($"The method {invokeData.MethodName} couldn't be found on the {nameof(jsInvokeTarget)} of type {jsInvokeTarget.GetType().FullName}."); + throw new InvalidOperationException($"The number of parameters on {nameof(jsInvokeTarget)}'s method {requestMethodName} ({dotnetParams.Length}) doesn't match the number of values passed from JavaScript code ({requestParams.Length})."); } - if (invokeData.ParamValues != null && invokeMethod.GetParameters().Length != invokeData.ParamValues.Length) + // deserialize the parameters from JSON to .NET types + object?[]? invokeParamValues = null; + if (requestParams is not null) { - throw new InvalidOperationException($"The number of parameters on {nameof(jsInvokeTarget)}'s method {invokeData.MethodName} ({invokeMethod.GetParameters().Length}) doesn't match the number of values passed from JavaScript code ({invokeData.ParamValues.Length})."); + invokeParamValues = new object?[requestParams.Length]; + for (var i = 0; i < requestParams.Length; i++) + { + var reqValue = requestParams[i]; + var paramType = dotnetParams[i].ParameterType; + var deserialized = JsonSerializer.Deserialize(reqValue, paramType); + invokeParamValues[i] = deserialized; + } } - var paramObjectValues = - invokeData.ParamValues? - .Zip(invokeMethod.GetParameters(), (s, p) => s == null ? null : JsonSerializer.Deserialize(s, p.ParameterType)) - .ToArray(); + // invoke the .NET method + var dotnetReturnValue = dotnetMethod.Invoke(jsInvokeTarget, invokeParamValues); - return invokeMethod.Invoke(jsInvokeTarget, paramObjectValues); - } + if (dotnetReturnValue is null) // null result + { + return null; + } + + if (dotnetReturnValue is Task task) // Task or Task result + { + await task; + + // Task + if (dotnetMethod.ReturnType.IsGenericType) + { + var resultProperty = dotnetMethod.ReturnType.GetProperty(nameof(Task.Result)); + return resultProperty?.GetValue(task); + } + // Task + return null; + } + + return dotnetReturnValue; // regular result + } private sealed class JSInvokeMethodData { diff --git a/src/Core/src/Handlers/HybridWebView/HybridWebViewHandler.iOS.cs b/src/Core/src/Handlers/HybridWebView/HybridWebViewHandler.iOS.cs index 7145b5d9312d..83e480aef7d6 100644 --- a/src/Core/src/Handlers/HybridWebView/HybridWebViewHandler.iOS.cs +++ b/src/Core/src/Handlers/HybridWebView/HybridWebViewHandler.iOS.cs @@ -3,8 +3,10 @@ using System.Globalization; using System.IO; using System.Runtime.Versioning; +using System.Threading.Tasks; using System.Web; using Foundation; +using Microsoft.Extensions.Logging; using UIKit; using WebKit; using RectangleF = CoreGraphics.CGRect; @@ -135,37 +137,54 @@ public SchemeHandler(HybridWebViewHandler webViewHandler) private HybridWebViewHandler? Handler => _webViewHandler is not null && _webViewHandler.TryGetTarget(out var h) ? h : null; + // The `async void` is intentional here, as this is an event handler that represents the start + // of a request for some data from the webview. Once the task is complete, the `IWKUrlSchemeTask` + // object is used to send the response back to the webview. [Export("webView:startURLSchemeTask:")] [SupportedOSPlatform("ios11.0")] - public void StartUrlSchemeTask(WKWebView webView, IWKUrlSchemeTask urlSchemeTask) + public async void StartUrlSchemeTask(WKWebView webView, IWKUrlSchemeTask urlSchemeTask) { + if (Handler is null || Handler is IViewHandler ivh && ivh.VirtualView is null) + { + return; + } + var url = urlSchemeTask.Request.Url?.AbsoluteString ?? ""; - var responseData = GetResponseBytes(url); + var (bytes, contentType, statusCode) = await GetResponseBytesAsync(url); - if (responseData.StatusCode == 200) + if (statusCode == 200) { + // the method was invoked successfully, so we need to send the response back to the webview + using (var dic = new NSMutableDictionary()) { - dic.Add((NSString)"Content-Length", (NSString)(responseData.ResponseBytes.Length.ToString(CultureInfo.InvariantCulture))); - dic.Add((NSString)"Content-Type", (NSString)responseData.ContentType); + dic.Add((NSString)"Content-Length", (NSString)bytes.Length.ToString(CultureInfo.InvariantCulture)); + dic.Add((NSString)"Content-Type", (NSString)contentType); // Disable local caching. This will prevent user scripts from executing correctly. dic.Add((NSString)"Cache-Control", (NSString)"no-cache, max-age=0, must-revalidate, no-store"); + if (urlSchemeTask.Request.Url != null) { - using var response = new NSHttpUrlResponse(urlSchemeTask.Request.Url, responseData.StatusCode, "HTTP/1.1", dic); + using var response = new NSHttpUrlResponse(urlSchemeTask.Request.Url, statusCode, "HTTP/1.1", dic); urlSchemeTask.DidReceiveResponse(response); } } - urlSchemeTask.DidReceiveData(NSData.FromArray(responseData.ResponseBytes)); + urlSchemeTask.DidReceiveData(NSData.FromArray(bytes)); urlSchemeTask.DidFinish(); } + else + { + // there was an error, so we need to handle it + + Handler?.MauiContext?.CreateLogger()?.LogError("Failed to load URL: {url}", url); + } } - private (byte[] ResponseBytes, string ContentType, int StatusCode) GetResponseBytes(string? url) + private async Task<(byte[] ResponseBytes, string ContentType, int StatusCode)> GetResponseBytesAsync(string? url) { - if (Handler is null || Handler is IViewHandler ivh && ivh.VirtualView is null) + if (Handler is null) { return (Array.Empty(), ContentType: string.Empty, StatusCode: 404); } @@ -184,10 +203,10 @@ public void StartUrlSchemeTask(WKWebView webView, IWKUrlSchemeTask urlSchemeTask { var fullUri = new Uri(fullUrl!); var invokeQueryString = HttpUtility.ParseQueryString(fullUri.Query); - (var contentBytes, var bytesContentType) = Handler.InvokeDotNet(invokeQueryString); + var contentBytes = await Handler.InvokeDotNetAsync(invokeQueryString); if (contentBytes is not null) { - return (contentBytes, bytesContentType!, StatusCode: 200); + return (contentBytes, "application/json", StatusCode: 200); } } diff --git a/src/Core/src/Platform/Android/MauiHybridWebViewClient.cs b/src/Core/src/Platform/Android/MauiHybridWebViewClient.cs index c3048a09f95a..7aade3db85ce 100644 --- a/src/Core/src/Platform/Android/MauiHybridWebViewClient.cs +++ b/src/Core/src/Platform/Android/MauiHybridWebViewClient.cs @@ -5,9 +5,12 @@ using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.IO; +using System.IO.Pipelines; using System.Text; +using System.Threading.Tasks; using System.Web; using Android.Webkit; +using Microsoft.Extensions.Logging; using AWebView = Android.Webkit.WebView; namespace Microsoft.Maui.Platform @@ -29,76 +32,77 @@ public MauiHybridWebViewClient(HybridWebViewHandler handler) public override WebResourceResponse? ShouldInterceptRequest(AWebView? view, IWebResourceRequest? request) { - if (Handler is null) + var response = GetResponseStream(view, request); + + if (response is not null) { - return base.ShouldInterceptRequest(view, request); + return response; + } + + return base.ShouldInterceptRequest(view, request); + } + + private WebResourceResponse? GetResponseStream(AWebView? view, IWebResourceRequest? request) + { + if (Handler is null || Handler is IViewHandler ivh && ivh.VirtualView is null) + { + return null; } var fullUrl = request?.Url?.ToString(); var requestUri = HybridWebViewQueryStringHelper.RemovePossibleQueryString(fullUrl); - - if (new Uri(requestUri) is Uri uri && HybridWebViewHandler.AppOriginUri.IsBaseOf(uri)) + if (new Uri(requestUri) is not Uri uri || !HybridWebViewHandler.AppOriginUri.IsBaseOf(uri)) { - var relativePath = HybridWebViewHandler.AppOriginUri.MakeRelativeUri(uri).ToString().Replace('/', '\\'); + return null; + } - string? contentType = null; - Stream? contentStream = null; + var relativePath = HybridWebViewHandler.AppOriginUri.MakeRelativeUri(uri).ToString().Replace('/', '\\'); - // 1. Try special InvokeDotNet path - if (relativePath == HybridWebViewHandler.InvokeDotNetPath) - { - var fullUri = new Uri(fullUrl!); - var invokeQueryString = HttpUtility.ParseQueryString(fullUri.Query); - (var contentBytes, contentType) = Handler.InvokeDotNet(invokeQueryString); - if (contentBytes is not null) - { - contentStream = new MemoryStream(contentBytes); - } - } + // 1. Try special InvokeDotNet path + if (relativePath == HybridWebViewHandler.InvokeDotNetPath) + { + var fullUri = new Uri(fullUrl!); + var invokeQueryString = HttpUtility.ParseQueryString(fullUri.Query); + var contentBytesTask = Handler.InvokeDotNetAsync(invokeQueryString); + var responseStream = new DotNetInvokeAsyncStream(contentBytesTask, Handler); + return new WebResourceResponse("application/json", "UTF-8", 200, "OK", GetHeaders("application/json"), responseStream); + } - // 2. If nothing found yet, try to get static content from the asset path - if (contentStream is null) + // 2. If nothing found yet, try to get static content from the asset path + string? contentType; + if (string.IsNullOrEmpty(relativePath)) + { + relativePath = Handler.VirtualView.DefaultFile; + contentType = "text/html"; + } + else + { + if (!HybridWebViewHandler.ContentTypeProvider.TryGetContentType(relativePath, out contentType!)) { - if (string.IsNullOrEmpty(relativePath)) - { - relativePath = Handler.VirtualView.DefaultFile; - contentType = "text/html"; - } - else - { - if (!HybridWebViewHandler.ContentTypeProvider.TryGetContentType(relativePath, out contentType!)) - { - // TODO: Log this - contentType = "text/plain"; - } - } - - var assetPath = Path.Combine(Handler.VirtualView.HybridRoot!, relativePath!); - contentStream = PlatformOpenAppPackageFile(assetPath); + contentType = "text/plain"; + Handler.MauiContext?.CreateLogger()?.LogWarning("Could not determine content type for '{relativePath}'", relativePath); } + } - if (contentStream is null) - { - // 3.a. If still nothing is found, return a 404 - var notFoundContent = "Resource not found (404)"; + var assetPath = Path.Combine(Handler.VirtualView.HybridRoot!, relativePath!); + var contentStream = PlatformOpenAppPackageFile(assetPath); - var notFoundByteArray = Encoding.UTF8.GetBytes(notFoundContent); - var notFoundContentStream = new MemoryStream(notFoundByteArray); + if (contentStream is not null) + { + // 3.a. If something was found, return the content - return new WebResourceResponse("text/plain", "UTF-8", 404, "Not Found", GetHeaders("text/plain"), notFoundContentStream); - } - else - { - // 3.b. Otherwise, return the content + // TODO: We don't know the content length because Android doesn't tell us. Seems to work without it! - // TODO: We don't know the content length because Android doesn't tell us. Seems to work without it! - return new WebResourceResponse(contentType, "UTF-8", 200, "OK", GetHeaders(contentType ?? "text/plain"), contentStream); - } - } - else - { - return base.ShouldInterceptRequest(view, request); + return new WebResourceResponse(contentType, "UTF-8", 200, "OK", GetHeaders(contentType ?? "text/plain"), contentStream); } + + // 3.b. Otherwise, return a 404 + var notFoundContent = "Resource not found (404)"; + + var notFoundByteArray = Encoding.UTF8.GetBytes(notFoundContent); + var notFoundContentStream = new MemoryStream(notFoundByteArray); + + return new WebResourceResponse("text/plain", "UTF-8", 404, "Not Found", GetHeaders("text/plain"), notFoundContentStream); } private Stream? PlatformOpenAppPackageFile(string filename) @@ -147,5 +151,123 @@ internal void Disconnect() { _handler.SetTarget(null); } + + private class DotNetInvokeAsyncStream : Stream + { + private const int PauseThreshold = 32 * 1024; + private const int ResumeThreshold = 16 * 1024; + + private readonly Task _task; + private readonly WeakReference _handler; + private readonly Pipe _pipe; + + private bool _isDisposed; + + private HybridWebViewHandler? Handler => _handler?.GetTargetOrDefault(); + + public override bool CanRead => !_isDisposed; + + public override bool CanSeek => false; + + public override bool CanWrite => false; + + public override long Length => throw new NotSupportedException(); + + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + + public DotNetInvokeAsyncStream(Task invokeTask, HybridWebViewHandler handler) + { + _task = invokeTask; + _handler = new(handler); + + _pipe = new Pipe(new PipeOptions( + pauseWriterThreshold: PauseThreshold, + resumeWriterThreshold: ResumeThreshold, + useSynchronizationContext: false)); + + InvokeMethodAndWriteBytes(); + } + + private async void InvokeMethodAndWriteBytes() + { + try + { + var data = await _task; + + // the stream or handler may be disposed after the method completes + ObjectDisposedException.ThrowIf(_isDisposed, nameof(DotNetInvokeAsyncStream)); + ArgumentNullException.ThrowIfNull(Handler, nameof(Handler)); + + // copy the data into the pipe + if (data is not null && data.Length > 0) + { + var memory = _pipe.Writer.GetMemory(data.Length); + data.CopyTo(memory); + _pipe.Writer.Advance(data.Length); + } + + _pipe.Writer.Complete(); + } + catch (Exception ex) + { + Handler?.MauiContext?.CreateLogger()?.LogError(ex, "Error invoking .NET method from JavaScript: {ErrorMessage}", ex.Message); + + _pipe.Writer.Complete(ex); + } + } + + public override void Flush() => + throw new NotSupportedException(); + + public override int Read(byte[] buffer, int offset, int count) + { + ArgumentNullException.ThrowIfNull(buffer, nameof(buffer)); + ArgumentOutOfRangeException.ThrowIfNegative(offset, nameof(offset)); + ArgumentOutOfRangeException.ThrowIfNegative(count, nameof(count)); + ArgumentOutOfRangeException.ThrowIfGreaterThan(offset + count, buffer.Length, nameof(count)); + ObjectDisposedException.ThrowIf(_isDisposed, nameof(DotNetInvokeAsyncStream)); + + // this is a blocking read, so we need to wait for data to be available + var readResult = _pipe.Reader.ReadAsync().AsTask().ConfigureAwait(false).GetAwaiter().GetResult(); + var slice = readResult.Buffer.Slice(0, Math.Min(count, readResult.Buffer.Length)); + + var bytesRead = 0; + foreach (var span in slice) + { + var bytesToCopy = Math.Min(count, span.Length); + span.CopyTo(new Memory(buffer, offset, bytesToCopy)); + offset += bytesToCopy; + count -= bytesToCopy; + bytesRead += bytesToCopy; + } + + _pipe.Reader.AdvanceTo(slice.End); + + return bytesRead; + } + + public override long Seek(long offset, SeekOrigin origin) => + throw new NotSupportedException(); + + public override void SetLength(long value) => + throw new NotSupportedException(); + + public override void Write(byte[] buffer, int offset, int count) => + throw new NotSupportedException(); + + protected override void Dispose(bool disposing) + { + _isDisposed = true; + + _pipe.Writer.Complete(); + _pipe.Reader.Complete(); + + base.Dispose(disposing); + } + } } }