Skip to content

Commit

Permalink
Proxy requests and round trip JavaScript invocations (#43)
Browse files Browse the repository at this point in the history
* Add support for proxying requests sent by HybridWebView: Proxying web requests from the browser through native code to allow for modifying the request, and creating custom responses.
* Add round trip invoke JS-.NET-JS: Add round trip invoke JS-.NET-JS by leveraging the proxy framework that was created.
  • Loading branch information
rbrundritt authored Mar 11, 2024
1 parent e962a50 commit 2c777e2
Show file tree
Hide file tree
Showing 21 changed files with 826 additions and 57 deletions.
83 changes: 80 additions & 3 deletions HybridWebView/HybridWebView.cs
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
using System.Text.Json;
using System.Diagnostics;
using System.Text.Json;

namespace HybridWebView
{
public partial class HybridWebView : WebView
{
internal const string ProxyRequestPath = "proxy";

/// <summary>
/// Specifies the file within the <see cref="HybridAssetRoot"/> that should be served as the main file. The
/// default value is <c>index.html</c>.
Expand Down Expand Up @@ -34,6 +37,12 @@ public partial class HybridWebView : WebView

public event EventHandler<HybridWebViewRawMessageReceivedEventArgs>? RawMessageReceived;

/// <summary>
/// Async event handler that is called when a proxy request is received from the webview.
/// </summary>

public event Func<HybridWebViewProxyEventArgs, Task>? ProxyRequestReceived;

public void Navigate(string url)
{
NavigateCore(url);
Expand Down Expand Up @@ -117,7 +126,66 @@ public virtual void OnMessageReceived(string message)

}

private void InvokeDotNetMethod(JSInvokeMethodData invokeData)
/// <summary>
/// Handle the proxy request message.
/// </summary>
/// <param name="args"></param>
/// <returns>A Task</returns>
public virtual async Task OnProxyRequestMessage(HybridWebViewProxyEventArgs args)
{
// Don't let failed proxy requests crash the app.
try
{
// When no query parameters are passed, the SendRoundTripMessageToDotNet JavaScript method is expected to have been called.
if (args.QueryParams != null && args.QueryParams.TryGetValue("__ajax", out string? jsonQueryString))
{
if (jsonQueryString != null)
{
var invokeData = JsonSerializer.Deserialize<JSInvokeMethodData>(jsonQueryString);

if (invokeData != null && invokeData.MethodName != null)
{
object? result = InvokeDotNetMethod(invokeData);

if (result != null)
{
args.ResponseContentType = "application/json";

DotNetInvokeResult dotNetInvokeResult;

var resultType = result.GetType();
if (resultType.IsArray || resultType.IsClass)
{
dotNetInvokeResult = new DotNetInvokeResult()
{
Result = JsonSerializer.Serialize(result),
IsJson = true,
};
}
else
{
dotNetInvokeResult = new DotNetInvokeResult()
{
Result = result,
};
}
args.ResponseStream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(JsonSerializer.Serialize(dotNetInvokeResult)));
}
}
}
}
else if (ProxyRequestReceived != null) //Check to see if user has subscribed to the event.
{
await ProxyRequestReceived(args);
}
}
catch (Exception ex)
{
Debug.WriteLine($"An exception occurred while handling the proxy request: {ex.Message}");
}
}

private object? InvokeDotNetMethod(JSInvokeMethodData invokeData)
{
if (JSInvokeTarget is null)
{
Expand All @@ -140,7 +208,7 @@ private void InvokeDotNetMethod(JSInvokeMethodData invokeData)
.Zip(invokeMethod.GetParameters(), (s, p) => JsonSerializer.Deserialize(s, p.ParameterType))
.ToArray();

var returnValue = invokeMethod.Invoke(JSInvokeTarget, paramObjectValues);
return invokeMethod.Invoke(JSInvokeTarget, paramObjectValues);
}

private sealed class JSInvokeMethodData
Expand All @@ -155,6 +223,15 @@ private sealed class WebMessageData
public string? MessageContent { get; set; }
}

/// <summary>
/// A simple internal class to hold the result of a .NET method invocation, and whether it should be treated as JSON.
/// </summary>
private sealed class DotNetInvokeResult
{
public object? Result { get; set; }
public bool IsJson { get; set; }
}

internal static async Task<string?> GetAssetContentAsync(string assetPath)
{
using var stream = await GetAssetStreamAsync(assetPath);
Expand Down
39 changes: 39 additions & 0 deletions HybridWebView/HybridWebViewProxyEventArgs.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
namespace HybridWebView
{
/// <summary>
/// Event arg object for a proxy request from the <see cref="HybridWebView"/>.
/// </summary>
public class HybridWebViewProxyEventArgs
{
/// <summary>
/// Creates a new instance of <see cref="HybridWebViewProxyEventArgs"/>.
/// </summary>
/// <param name="fullUrl">The full request URL.</param>
public HybridWebViewProxyEventArgs(string fullUrl)
{
Url = fullUrl;
QueryParams = QueryStringHelper.GetKeyValuePairs(fullUrl);
}

/// <summary>
/// The full request URL.
/// </summary>
public string Url { get; }

/// <summary>
/// Query string values extracted from the request URL.
/// </summary>
public IDictionary<string, string> QueryParams { get; }

/// <summary>
/// The response content type.
/// </summary>

public string? ResponseContentType { get; set; } = "text/plain";

/// <summary>
/// The response stream to be used to respond to the request.
/// </summary>
public Stream? ResponseStream { get; set; } = null;
}
}
67 changes: 67 additions & 0 deletions HybridWebView/KnownStaticFiles/HybridWebView.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,19 @@
// Standard methods for HybridWebView

window.HybridWebView = {
/**
* Sends a message to .NET using the built in
* @param {string} message Message to send.
*/
"SendRawMessageToDotNet": function SendRawMessageToDotNet(message) {
window.HybridWebView.SendMessageToDotNet(0, message);
},

/**
* Invoke a .NET method. No result is expected.
* @param {string} methodName Name of .NET method to invoke.
* @param {any[]} paramValues Parameters to pass to the method.
*/
"SendInvokeMessageToDotNet": function SendInvokeMessageToDotNet(methodName, paramValues) {
if (typeof paramValues !== 'undefined') {
if (!Array.isArray(paramValues)) {
Expand All @@ -18,6 +27,64 @@ window.HybridWebView = {
window.HybridWebView.SendMessageToDotNet(1, JSON.stringify({ "MethodName": methodName, "ParamValues": paramValues }));
},

/**
* Asynchronously invoke .NET method and get a result.
* Leverages the proxy to send the message to .NET.
* @param {string} methodName Name of .NET method to invoke.
* @param {any[]} paramValues Parameters to pass to the method.
* @returns {Promise<any>} Result of the .NET method.
*/
"SendInvokeMessageToDotNetAsync": async function SendInvokeMessageToDotNetAsync(methodName, paramValues) {
const body = {
MethodName: methodName
};

if (typeof paramValues !== 'undefined') {
if (!Array.isArray(paramValues)) {
paramValues = [paramValues];
}

for (var i = 0; i < paramValues.length; i++) {
paramValues[i] = JSON.stringify(paramValues[i]);
}

if (paramValues.length > 0) {
body.ParamValues = paramValues;
}
}

const message = JSON.stringify(body);

try {
// Android web view doesn't support getting the body of a POST request, so we use a GET request instead and pass the body as a query string parameter.
var requestUrl = `${window.location.origin}/proxy?__ajax=${encodeURIComponent(message)}`;

const rawResponse = await fetch(requestUrl, {
method: 'GET',
headers: {
'Accept': 'application/json'
}
});
const response = await rawResponse.json();

if (response) {
if (response.IsJson) {
return JSON.parse(response.Result);
}

return response.Result;
}
} catch (e) { }

return null;
},

/**
* Sends a message to .NET using the built in
* @private
* @param {number} messageType The type of message to send.
* @param {string} messageContent The message content.
*/
"SendMessageToDotNet": function SendMessageToDotNet(messageType, messageContent) {
var message = JSON.stringify({ "MessageType": messageType, "MessageContent": messageContent });

Expand Down
31 changes: 27 additions & 4 deletions HybridWebView/Platforms/Android/AndroidHybridWebViewClient.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using Android.Webkit;
using Java.Time;
using Microsoft.Maui.Platform;
using System.Text;
using AWebView = Android.Webkit.WebView;
Expand All @@ -15,8 +16,10 @@ public AndroidHybridWebViewClient(HybridWebViewHandler handler) : base(handler)
}
public override WebResourceResponse? ShouldInterceptRequest(AWebView? view, IWebResourceRequest? request)
{
var requestUri = request?.Url?.ToString();
requestUri = QueryStringHelper.RemovePossibleQueryString(requestUri);
var fullUrl = request?.Url?.ToString();
var requestUri = QueryStringHelper.RemovePossibleQueryString(fullUrl);

var webView = (HybridWebView)_handler.VirtualView;

if (new Uri(requestUri) is Uri uri && HybridWebView.AppOriginUri.IsBaseOf(uri))
{
Expand All @@ -25,7 +28,7 @@ public AndroidHybridWebViewClient(HybridWebViewHandler handler) : base(handler)
string contentType;
if (string.IsNullOrEmpty(relativePath))
{
relativePath = ((HybridWebView)_handler.VirtualView).MainFile;
relativePath = webView.MainFile;
contentType = "text/html";
}
else
Expand All @@ -40,7 +43,27 @@ public AndroidHybridWebViewClient(HybridWebViewHandler handler) : base(handler)
};
}

var contentStream = KnownStaticFileProvider.GetKnownResourceStream(relativePath!);
Stream? contentStream = null;

// Check to see if the request is a proxy request.
if (relativePath == HybridWebView.ProxyRequestPath)
{
var args = new HybridWebViewProxyEventArgs(fullUrl);

Check warning on line 51 in HybridWebView/Platforms/Android/AndroidHybridWebViewClient.cs

View workflow job for this annotation

GitHub Actions / call-build-workflow / Build (windows-latest)

Possible null reference argument for parameter 'fullUrl' in 'HybridWebViewProxyEventArgs.HybridWebViewProxyEventArgs(string fullUrl)'.

Check warning on line 51 in HybridWebView/Platforms/Android/AndroidHybridWebViewClient.cs

View workflow job for this annotation

GitHub Actions / call-build-workflow / Build (macos-14)

Possible null reference argument for parameter 'fullUrl' in 'HybridWebViewProxyEventArgs.HybridWebViewProxyEventArgs(string fullUrl)'.

// TODO: Don't block async. Consider making this an async call, and then calling DidFinish when done
webView.OnProxyRequestMessage(args).Wait();

if (args.ResponseStream != null)
{
contentType = args.ResponseContentType ?? "text/plain";
contentStream = args.ResponseStream;
}
}

if (contentStream == null)
{
contentStream = KnownStaticFileProvider.GetKnownResourceStream(relativePath!);
}

if (contentStream is null)
{
Expand Down
Loading

0 comments on commit 2c777e2

Please sign in to comment.