diff --git a/HybridWebView/HybridWebView.cs b/HybridWebView/HybridWebView.cs index 16f6163..e655111 100644 --- a/HybridWebView/HybridWebView.cs +++ b/HybridWebView/HybridWebView.cs @@ -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"; + /// /// Specifies the file within the that should be served as the main file. The /// default value is index.html. @@ -34,6 +37,12 @@ public partial class HybridWebView : WebView public event EventHandler? RawMessageReceived; + /// + /// Async event handler that is called when a proxy request is received from the webview. + /// + + public event Func? ProxyRequestReceived; + public void Navigate(string url) { NavigateCore(url); @@ -117,7 +126,66 @@ public virtual void OnMessageReceived(string message) } - private void InvokeDotNetMethod(JSInvokeMethodData invokeData) + /// + /// Handle the proxy request message. + /// + /// + /// A Task + 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(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) { @@ -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 @@ -155,6 +223,15 @@ private sealed class WebMessageData public string? MessageContent { get; set; } } + /// + /// A simple internal class to hold the result of a .NET method invocation, and whether it should be treated as JSON. + /// + private sealed class DotNetInvokeResult + { + public object? Result { get; set; } + public bool IsJson { get; set; } + } + internal static async Task GetAssetContentAsync(string assetPath) { using var stream = await GetAssetStreamAsync(assetPath); diff --git a/HybridWebView/HybridWebViewProxyEventArgs.cs b/HybridWebView/HybridWebViewProxyEventArgs.cs new file mode 100644 index 0000000..0f6299d --- /dev/null +++ b/HybridWebView/HybridWebViewProxyEventArgs.cs @@ -0,0 +1,39 @@ +namespace HybridWebView +{ + /// + /// Event arg object for a proxy request from the . + /// + public class HybridWebViewProxyEventArgs + { + /// + /// Creates a new instance of . + /// + /// The full request URL. + public HybridWebViewProxyEventArgs(string fullUrl) + { + Url = fullUrl; + QueryParams = QueryStringHelper.GetKeyValuePairs(fullUrl); + } + + /// + /// The full request URL. + /// + public string Url { get; } + + /// + /// Query string values extracted from the request URL. + /// + public IDictionary QueryParams { get; } + + /// + /// The response content type. + /// + + public string? ResponseContentType { get; set; } = "text/plain"; + + /// + /// The response stream to be used to respond to the request. + /// + public Stream? ResponseStream { get; set; } = null; + } +} diff --git a/HybridWebView/KnownStaticFiles/HybridWebView.js b/HybridWebView/KnownStaticFiles/HybridWebView.js index 3494ba5..54e4bfc 100644 --- a/HybridWebView/KnownStaticFiles/HybridWebView.js +++ b/HybridWebView/KnownStaticFiles/HybridWebView.js @@ -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)) { @@ -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} 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 }); diff --git a/HybridWebView/Platforms/Android/AndroidHybridWebViewClient.cs b/HybridWebView/Platforms/Android/AndroidHybridWebViewClient.cs index 7c50c0a..f0f6619 100644 --- a/HybridWebView/Platforms/Android/AndroidHybridWebViewClient.cs +++ b/HybridWebView/Platforms/Android/AndroidHybridWebViewClient.cs @@ -1,4 +1,5 @@ using Android.Webkit; +using Java.Time; using Microsoft.Maui.Platform; using System.Text; using AWebView = Android.Webkit.WebView; @@ -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)) { @@ -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 @@ -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); + + // 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) { diff --git a/HybridWebView/Platforms/MacCatalyst/HybridWebViewHandler.MacCatalyst.cs b/HybridWebView/Platforms/MacCatalyst/HybridWebViewHandler.MacCatalyst.cs index 6a5e172..d31f86f 100644 --- a/HybridWebView/Platforms/MacCatalyst/HybridWebViewHandler.MacCatalyst.cs +++ b/HybridWebView/Platforms/MacCatalyst/HybridWebViewHandler.MacCatalyst.cs @@ -66,31 +66,37 @@ public SchemeHandler(HybridWebViewHandler webViewHandler) [Export("webView:startURLSchemeTask:")] [SupportedOSPlatform("ios11.0")] - public void StartUrlSchemeTask(WKWebView webView, IWKUrlSchemeTask urlSchemeTask) + public async void StartUrlSchemeTask(WKWebView webView, IWKUrlSchemeTask urlSchemeTask) { - var responseBytes = GetResponseBytes(urlSchemeTask.Request.Url?.AbsoluteString ?? "", out var contentType, statusCode: out var statusCode); - if (statusCode == 200) + var url = urlSchemeTask.Request.Url?.AbsoluteString ?? ""; + + var responseData = await GetResponseBytes(url); + + if (responseData.StatusCode == 200) { using (var dic = new NSMutableDictionary()) { - dic.Add((NSString)"Content-Length", (NSString)(responseBytes.Length.ToString(CultureInfo.InvariantCulture))); - dic.Add((NSString)"Content-Type", (NSString)contentType); + dic.Add((NSString)"Content-Length", (NSString)(responseData.ResponseBytes.Length.ToString(CultureInfo.InvariantCulture))); + dic.Add((NSString)"Content-Type", (NSString)responseData.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, statusCode, "HTTP/1.1", dic); + using var response = new NSHttpUrlResponse(urlSchemeTask.Request.Url, responseData.StatusCode, "HTTP/1.1", dic); urlSchemeTask.DidReceiveResponse(response); } - } - urlSchemeTask.DidReceiveData(NSData.FromArray(responseBytes)); + + urlSchemeTask.DidReceiveData(NSData.FromArray(responseData.ResponseBytes)); urlSchemeTask.DidFinish(); } } - private byte[] GetResponseBytes(string? url, out string contentType, out int statusCode) + private async Task<(byte[] ResponseBytes, string ContentType, int StatusCode)> GetResponseBytes(string? url) { + string contentType; + + string fullUrl = url; url = QueryStringHelper.RemovePossibleQueryString(url); if (new Uri(url) is Uri uri && HybridWebView.AppOriginUri.IsBaseOf(uri)) @@ -118,27 +124,43 @@ private byte[] GetResponseBytes(string? url, out string contentType, out int sta }; } - 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); + + await hwv.OnProxyRequestMessage(args); + + if (args.ResponseStream != null) + { + contentType = args.ResponseContentType ?? "text/plain"; + contentStream = args.ResponseStream; + } + } + + if (contentStream == null) + { + contentStream = KnownStaticFileProvider.GetKnownResourceStream(relativePath!); + } + if (contentStream is not null) { - statusCode = 200; using var ms = new MemoryStream(); contentStream.CopyTo(ms); - return ms.ToArray(); + return (ms.ToArray(), contentType, StatusCode: 200); } var assetPath = Path.Combine(bundleRootDir, relativePath); if (File.Exists(assetPath)) { - statusCode = 200; - return File.ReadAllBytes(assetPath); + return (File.ReadAllBytes(assetPath), contentType, StatusCode: 200); } } - statusCode = 404; - contentType = string.Empty; - return Array.Empty(); + return (Array.Empty(), ContentType: string.Empty, StatusCode: 404); } [Export("webView:stopURLSchemeTask:")] diff --git a/HybridWebView/Platforms/Windows/HybridWebView.Windows.cs b/HybridWebView/Platforms/Windows/HybridWebView.Windows.cs index 933c15e..dfa037e 100644 --- a/HybridWebView/Platforms/Windows/HybridWebView.Windows.cs +++ b/HybridWebView/Platforms/Windows/HybridWebView.Windows.cs @@ -1,7 +1,6 @@ using Microsoft.Web.WebView2.Core; -using System; -using System.Diagnostics.Tracing; using System.Runtime.InteropServices.WindowsRuntime; +using System.Text; using Windows.Storage.Streams; namespace HybridWebView @@ -36,7 +35,6 @@ private partial async Task InitializeHybridWebView() PlatformWebView.CoreWebView2.Settings.IsWebMessageEnabled = true; PlatformWebView.CoreWebView2.AddWebResourceRequestedFilter($"{AppOrigin}*", CoreWebView2WebResourceContext.All); PlatformWebView.CoreWebView2.WebResourceRequested += CoreWebView2_WebResourceRequested; - } private partial void NavigateCore(string url) @@ -73,7 +71,27 @@ private async void CoreWebView2_WebResourceRequested(CoreWebView2 sender, CoreWe }; } - var contentStream = KnownStaticFileProvider.GetKnownResourceStream(relativePath!); + Stream? contentStream = null; + + // Check to see if the request is a proxy request + if (relativePath == ProxyRequestPath) + { + var fullUrl = eventArgs.Request.Uri; + + var args = new HybridWebViewProxyEventArgs(fullUrl); + await OnProxyRequestMessage(args); + + if (args.ResponseStream != null) + { + contentType = args.ResponseContentType ?? "text/plain"; + contentStream = args.ResponseStream; + } + } + + if (contentStream is null) + { + contentStream = KnownStaticFileProvider.GetKnownResourceStream(relativePath!); + } if (contentStream is null) { diff --git a/HybridWebView/Platforms/iOS/HybridWebViewHandler.iOS.cs b/HybridWebView/Platforms/iOS/HybridWebViewHandler.iOS.cs index 6a5e172..a5fcf9f 100644 --- a/HybridWebView/Platforms/iOS/HybridWebViewHandler.iOS.cs +++ b/HybridWebView/Platforms/iOS/HybridWebViewHandler.iOS.cs @@ -2,6 +2,7 @@ using Microsoft.Maui.Platform; using System.Drawing; using System.Globalization; +using System.Reflection.Metadata; using System.Runtime.Versioning; using WebKit; @@ -66,31 +67,37 @@ public SchemeHandler(HybridWebViewHandler webViewHandler) [Export("webView:startURLSchemeTask:")] [SupportedOSPlatform("ios11.0")] - public void StartUrlSchemeTask(WKWebView webView, IWKUrlSchemeTask urlSchemeTask) + public async void StartUrlSchemeTask(WKWebView webView, IWKUrlSchemeTask urlSchemeTask) { - var responseBytes = GetResponseBytes(urlSchemeTask.Request.Url?.AbsoluteString ?? "", out var contentType, statusCode: out var statusCode); - if (statusCode == 200) + var url = urlSchemeTask.Request.Url?.AbsoluteString ?? ""; + + var responseData = await GetResponseBytes(url); + + if (responseData.StatusCode == 200) { using (var dic = new NSMutableDictionary()) { - dic.Add((NSString)"Content-Length", (NSString)(responseBytes.Length.ToString(CultureInfo.InvariantCulture))); - dic.Add((NSString)"Content-Type", (NSString)contentType); + dic.Add((NSString)"Content-Length", (NSString)(responseData.ResponseBytes.Length.ToString(CultureInfo.InvariantCulture))); + dic.Add((NSString)"Content-Type", (NSString)responseData.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, statusCode, "HTTP/1.1", dic); + using var response = new NSHttpUrlResponse(urlSchemeTask.Request.Url, responseData.StatusCode, "HTTP/1.1", dic); urlSchemeTask.DidReceiveResponse(response); } - } - urlSchemeTask.DidReceiveData(NSData.FromArray(responseBytes)); + + urlSchemeTask.DidReceiveData(NSData.FromArray(responseData.ResponseBytes)); urlSchemeTask.DidFinish(); } } - private byte[] GetResponseBytes(string? url, out string contentType, out int statusCode) + private async Task<(byte[] ResponseBytes, string ContentType, int StatusCode)> GetResponseBytes(string? url) { + string contentType; + + string fullUrl = url; url = QueryStringHelper.RemovePossibleQueryString(url); if (new Uri(url) is Uri uri && HybridWebView.AppOriginUri.IsBaseOf(uri)) @@ -118,27 +125,43 @@ private byte[] GetResponseBytes(string? url, out string contentType, out int sta }; } - 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); + + await hwv.OnProxyRequestMessage(args); + + if (args.ResponseStream != null) + { + contentType = args.ResponseContentType ?? "text/plain"; + contentStream = args.ResponseStream; + } + } + + if (contentStream == null) + { + contentStream = KnownStaticFileProvider.GetKnownResourceStream(relativePath!); + } + if (contentStream is not null) { - statusCode = 200; using var ms = new MemoryStream(); contentStream.CopyTo(ms); - return ms.ToArray(); + return (ms.ToArray(), contentType, StatusCode: 200); } var assetPath = Path.Combine(bundleRootDir, relativePath); if (File.Exists(assetPath)) { - statusCode = 200; - return File.ReadAllBytes(assetPath); + return (File.ReadAllBytes(assetPath), contentType, StatusCode: 200); } } - statusCode = 404; - contentType = string.Empty; - return Array.Empty(); + return (Array.Empty(), ContentType: string.Empty, StatusCode: 404); } [Export("webView:stopURLSchemeTask:")] diff --git a/HybridWebView/QueryStringHelper.cs b/HybridWebView/QueryStringHelper.cs index 8cae62f..007cc12 100644 --- a/HybridWebView/QueryStringHelper.cs +++ b/HybridWebView/QueryStringHelper.cs @@ -13,5 +13,32 @@ public static string RemovePossibleQueryString(string? url) ? url : url.Substring(0, indexOfQueryString); } + + // TODO: Replace this + + /// + /// A simple utility that takes a URL, extracts the query string and returns a dictionary of key-value pairs. + /// Note that values are unescaped. Manually created URLs in JavaScript should use encodeURIComponent to escape values. + /// + /// + /// + public static Dictionary GetKeyValuePairs(string? url) + { + var result = new Dictionary(); + if (!string.IsNullOrEmpty(url)) + { + var query = new Uri(url).Query; + if (query != null && query.Length > 1) + { + result = query + .Substring(1) + .Split('&') + .Select(p => p.Split('=')) + .ToDictionary(p => p[0], p => Uri.UnescapeDataString(p[1])); + } + } + + return result; + } } } diff --git a/MauiCSharpInteropWebView/MBTileManager.cs b/MauiCSharpInteropWebView/MBTileManager.cs new file mode 100644 index 0000000..98d2f3e --- /dev/null +++ b/MauiCSharpInteropWebView/MBTileManager.cs @@ -0,0 +1,90 @@ +using SQLite; + +namespace MauiCSharpInteropWebView +{ + /// + /// Creates an SQLite connection to an MBTiles file and retrieves tiles from it. + /// An MBTiles file is a SQLite database that contains map tiles and has a standard table format. + /// https://wiki.openstreetmap.org/wiki/MBTiles + /// + public class MBTileManager + { + private SQLiteConnection _conn; + + /// + /// Creates an SQLite connection to an MBTiles file and retrieves tiles from it. + /// An MBTiles file is a SQLite database that contains map tiles and has a standard table format. + /// https://wiki.openstreetmap.org/wiki/MBTiles + /// + /// The path to the MBTiles file in the app's assets folder. + public MBTileManager(string assetPath) + { + //Copy asset to app data storage. + var localPath = Path.Combine(FileSystem.AppDataDirectory, Path.GetFileName(assetPath)); + + //Check to see if the file exists in the app data storage already. + if (!File.Exists(localPath)) + { + //Need to copy the file to local app data storage as Sqlite can't access Raw folder. + FileSystem.OpenAppPackageFileAsync(assetPath).ContinueWith((task) => + { + using (var asset = task.Result) + { + using (var file = File.Create(localPath)) + { + asset.CopyTo(file); + } + } + + _conn = new SQLiteConnection(localPath); + }); + } + else + { + _conn = new SQLiteConnection(localPath); + } + } + + /// + /// Retrieves a tile from the MBTiles file. + /// + /// + /// + /// + /// + public byte[] GetTile(long? x, long? y, long? z) + { + if (_conn != null && x != null && y != null && z != null) + { + //Inverse Y value as mbtiles uses TMS + long inverseY = ((long)Math.Pow(2, z.Value) - 1) - y.Value; + + var tileResults = _conn.Query(String.Format("SELECT tile_data, tile_row, tile_column, zoom_level FROM tiles WHERE tile_column = {0} and tile_row = {1} and zoom_level = {2};", x, inverseY, z)); + + if (tileResults.Count > 0 && tileResults[0].tile_data != null) + { + return tileResults[0].tile_data; + } + } + + return null; + } + + /// + /// Represents a tile in an MBTiles file. + /// + public class TileResult + { + /// + /// Binary tile data. Could be a raster (jpeg/png image) or vector tile (pbf). + /// + public byte[] tile_data { get; set; } + + public long tile_row { get; set; } + + public long tile_column { get; set; } + + public long zoom_level { get; set; } + } + } +} diff --git a/MauiCSharpInteropWebView/MainPage.xaml b/MauiCSharpInteropWebView/MainPage.xaml index c21ddfb..d953610 100644 --- a/MauiCSharpInteropWebView/MainPage.xaml +++ b/MauiCSharpInteropWebView/MainPage.xaml @@ -32,7 +32,8 @@ - + diff --git a/MauiCSharpInteropWebView/MainPage.xaml.cs b/MauiCSharpInteropWebView/MainPage.xaml.cs index 893fb61..57d4e65 100644 --- a/MauiCSharpInteropWebView/MainPage.xaml.cs +++ b/MauiCSharpInteropWebView/MainPage.xaml.cs @@ -1,10 +1,16 @@ -namespace MauiCSharpInteropWebView; +using System.Globalization; +using System.IO.Compression; +using System.Text; + +namespace MauiCSharpInteropWebView; public partial class MainPage : ContentPage { private HybridAppPageID _currentPage; private int _messageCount; + private MBTileManager _mbTileManager; + public MainPage() { InitializeComponent(); @@ -15,7 +21,12 @@ public MainPage() myHybridWebView.EnableWebDevTools = true; #endif + //_mbTileManager = new MBTileManager("world_tiles.mbtiles"); + _mbTileManager = new MBTileManager("countries-raster.mbtiles"); + myHybridWebView.JSInvokeTarget = new MyJSInvokeTarget(this); + + myHybridWebView.ProxyRequestReceived += MyHybridWebView_OnProxyRequestReceived; } public string CurrentPageName => $"Current hybrid page: {_currentPage}"; @@ -52,6 +63,97 @@ private void OnHybridWebViewRawMessageReceived(object sender, HybridWebView.Hybr } } + private async Task MyHybridWebView_OnProxyRequestReceived(HybridWebView.HybridWebViewProxyEventArgs args) + { + // In an app, you might load responses from a sqlite database, zip file, or create files in memory (e.g. using SkiaSharp or System.Drawing) + + // Check to see if our custom parameter is present. + if (args.QueryParams.ContainsKey("operation")) + { + switch (args.QueryParams["operation"]) + { + case "loadImageFromZip": + // Ensure the file name parameter is present. + if (args.QueryParams.TryGetValue("fileName", out string fileName) && fileName != null) + { + // Load local zip file. + using var stream = await FileSystem.OpenAppPackageFileAsync("media/pictures.zip"); + + // Unzip file and check to see if it has the requested file name. + using var archive = new ZipArchive(stream); + + var file = archive.Entries.Where(x => x.FullName == fileName).FirstOrDefault(); + + if (file != null) + { + // Copy the file stream to a memory stream. + var ms = new MemoryStream(); + using (var fs = file.Open()) + { + await fs.CopyToAsync(ms); + } + + // Rewind stream. + ms.Position = 0; + + args.ResponseStream = ms; + args.ResponseContentType = "image/jpeg"; + } + } + break; + + case "loadImageFromWeb": + if (args.QueryParams.TryGetValue("tileId", out string tileIdString) && int.TryParse(tileIdString, out var tileId)) + { + // Apply custom logic. In this case convert into a quadkey value for Bing Maps. + var quadKey = new StringBuilder(); + for (var i = tileId; i > 0; i /= 4) + { + quadKey.Insert(0, (tileId % 4).ToString(CultureInfo.InvariantCulture)); + } + + //Create URL using the tileId parameter. + var url = $"https://ecn.t0.tiles.virtualearth.net/tiles/a{quadKey}.jpeg?g=14245"; + +#if ANDROID + var client = new HttpClient(new Xamarin.Android.Net.AndroidMessageHandler()); +#else + var client = new HttpClient(); +#endif + + var response = await client.SendAsync(new HttpRequestMessage(HttpMethod.Get, url)); + + // Copy the response stream to a memory stream. + var ms2 = new MemoryStream(); + + // TODO: Remove the Wait() + response.Content.CopyToAsync(ms2).Wait(); + ms2.Position = 0; + args.ResponseStream = ms2; + args.ResponseContentType = "image/jpeg"; + } + break; + + case "loadMapTile": + if (args.QueryParams.TryGetValue("x", out string xString) && long.TryParse(xString, out var x) && + args.QueryParams.TryGetValue("y", out string yString) && long.TryParse(yString, out var y) && + args.QueryParams.TryGetValue("z", out string zString) && long.TryParse(zString, out var z)) + { + var tileBytes = _mbTileManager.GetTile(x, y, z); + if (tileBytes != null) + { + args.ResponseStream = new MemoryStream(tileBytes); + args.ResponseContentType = "image/jpeg"; + } + } + break; + + default: + break; + } + } + } + private void WriteToLog(string message) { MessageLog += Environment.NewLine + $"{_messageCount++}: " + message; @@ -73,6 +175,69 @@ public void CallMeFromScript(string message, int value) { _mainPage.WriteToLog($"I'm a .NET method called from JavaScript with message='{message}' and value={value}"); } + + /// + /// An example of a round trip method that takes an input multiple parameters and returns a simple value type (string). + /// + /// + /// + /// + public string RoundTripCallFromScript(string message, int value) + { + _mainPage.WriteToLog($"I'm a round trip .NET method called from JavaScript with message='{message}' and value={value}"); + + return $"C# says: I got your message='{message}' and value={value}"; + } + + /// + /// An example of a round trip method that takes an input parameter and returns a simple value type (number). + /// + /// + /// + public double Fibonacci(int n) + { + _mainPage.WriteToLog($"I'm a round trip .NET method called from JavaScript calling Fibonacci with n={n}"); + + if (n == 0) return 0; + + int prev = 0; + int next = 1; + for (int i = 1; i < n; i++) + { + int sum = prev + next; + prev = next; + next = sum; + } + return next; + } + + /// + /// An example of a round trip method that returns an array of object without any input parameters. + /// + /// + public object GetObjectResponse() + { + _mainPage.WriteToLog($"I'm a round trip .NET method called from JavaScript getting an object without any input parameters"); + + return new List() + { + new { Name = "John", Age = 42 }, + new { Name = "Jane", Age = 39 }, + new { Name = "Sam", Age = 13 }, + }; + } + + /// + /// An example of a round trip method that takes an input parameter and returns an object of type class. + /// + /// + /// + public KeyValuePair GetObjectResponseWithInput(double value) + { + _mainPage.WriteToLog($"I'm a round trip .NET method called from JavaScript getting an object with input parameter value={value}"); + + return KeyValuePair.Create("value", value); + } } private enum HybridAppPageID @@ -80,5 +245,6 @@ private enum HybridAppPageID MainPage = 0, RawMessages = 1, MethodInvoke = 2, + ProxyUrls = 3, } } diff --git a/MauiCSharpInteropWebView/MauiCSharpInteropWebView.csproj b/MauiCSharpInteropWebView/MauiCSharpInteropWebView.csproj index 4386ecb..36cb1ec 100644 --- a/MauiCSharpInteropWebView/MauiCSharpInteropWebView.csproj +++ b/MauiCSharpInteropWebView/MauiCSharpInteropWebView.csproj @@ -52,6 +52,8 @@ + + diff --git a/MauiCSharpInteropWebView/Resources/Raw/countries-raster-notice.txt b/MauiCSharpInteropWebView/Resources/Raw/countries-raster-notice.txt new file mode 100644 index 0000000..eb93c43 --- /dev/null +++ b/MauiCSharpInteropWebView/Resources/Raw/countries-raster-notice.txt @@ -0,0 +1 @@ +Map content from OpenStreetMap: https://openstreetmap.org/copyright diff --git a/MauiCSharpInteropWebView/Resources/Raw/countries-raster.mbtiles b/MauiCSharpInteropWebView/Resources/Raw/countries-raster.mbtiles new file mode 100644 index 0000000..173ad5a Binary files /dev/null and b/MauiCSharpInteropWebView/Resources/Raw/countries-raster.mbtiles differ diff --git a/MauiCSharpInteropWebView/Resources/Raw/hybrid_root/hybrid_app.html b/MauiCSharpInteropWebView/Resources/Raw/hybrid_root/hybrid_app.html index 08ffbb4..6a44565 100644 --- a/MauiCSharpInteropWebView/Resources/Raw/hybrid_root/hybrid_app.html +++ b/MauiCSharpInteropWebView/Resources/Raw/hybrid_root/hybrid_app.html @@ -10,7 +10,7 @@

HybridWebView demo: Main page

Welcome to the HybridWebView demo for .NET MAUI! diff --git a/MauiCSharpInteropWebView/Resources/Raw/hybrid_root/methodinvoke.html b/MauiCSharpInteropWebView/Resources/Raw/hybrid_root/methodinvoke.html index df38628..daf249d 100644 --- a/MauiCSharpInteropWebView/Resources/Raw/hybrid_root/methodinvoke.html +++ b/MauiCSharpInteropWebView/Resources/Raw/hybrid_root/methodinvoke.html @@ -11,11 +11,35 @@ return sum; } + /* Async invoke examples */ + + function CallDotNetMethod() { Log('Calling a method in .NET with some parameters'); HybridWebView.SendInvokeMessageToDotNet("CallMeFromScript", ["msg from js", 987]); } + + async function invokeRoundTripDotNetMethod() { + var r = await HybridWebView.SendInvokeMessageToDotNetAsync('RoundTripCallFromScript', ["param1", 123]); + Log(r); + } + + async function invokeDotNetFibonacciMethod() { + var fibNumber = Math.round(Math.random() * 30) + 1; + var r = await HybridWebView.SendInvokeMessageToDotNetAsync('Fibonacci', [fibNumber]); + Log('C# Fibonacci result for ' + fibNumber + ': ' + r); + } + + async function invokeDotNetMethodJsonResult() { + var r = await HybridWebView.SendInvokeMessageToDotNetAsync('GetObjectResponse'); + Log('C# response: ' + JSON.stringify(r)); + } + + async function invokeDotNetMethodJsonResultWithInput() { + var r = await HybridWebView.SendInvokeMessageToDotNetAsync('GetObjectResponseWithInput', [Math.random() * 100]); + Log('C# response: ' + JSON.stringify(r)); + } @@ -24,18 +48,32 @@

HybridWebView demo: Method invoke

Methods can be invoked in both directions:
    -
  • JavaScript can invoke .NET methods by calling HybridWebView.SendInvokeMessageToDotNet("DotNetMethodName", ["param1", 123]);.
  • -
  • .NET can invoke JavaScript methods by calling var sum = await webView.InvokeJsMethodAsync("JsAddNumbers", 123, 456);.
  • +
  • JavaScript can invoke .NET methods without recieving a result by calling HybridWebView.SendInvokeMessageToDotNet("DotNetMethodName", ["param1", 123]);.
  • +
  • JavaScript can asynchronously invoke .NET methods and recieve a result by calling var result = await HybridWebView.SendInvokeMessageToDotNetAsync('DotNetMethodName', ["param1", 123]);.
  • +
  • .NET can invoke JavaScript methods and recieve a result by calling var sum = await webView.InvokeJsMethodAsync("JsAddNumbers", 123, 456);.
- +

+ Sync invoke - Call .NET, no response +
+ +

+ +

+ Async invoke - Call .NET, get response +
+ + + + +

JS message log: diff --git a/MauiCSharpInteropWebView/Resources/Raw/hybrid_root/proxy.html b/MauiCSharpInteropWebView/Resources/Raw/hybrid_root/proxy.html new file mode 100644 index 0000000..d663807 --- /dev/null +++ b/MauiCSharpInteropWebView/Resources/Raw/hybrid_root/proxy.html @@ -0,0 +1,105 @@ + + + + + + + + + + + + + + + + +

HybridWebView demo: Proxy

+ +
+ Requests can be proxied through .NET to allow direct access to native APIs, and allow custom response streams to be returned. + This is useful in scenarios where the request is being made from HTML elements, like an img tag, or a external library that only takes in string URLs. + Proxy request URLs can be created with the format /proxy? and appending a custom query string. Some JavaScript libraries may require the full URL to be passed in. + This can be done by calling `${window.location.origin}/proxy?` and appending the custom query string. + Proxy URLs will look something like this: https://0.0.0.0/proxy?myParameter=myValue or app://0.0.0.0/proxy?myParameter=myValue depending on the platform. +
+
+ Create proxy URLs and pass into native HTML elements +

+ The following buttons will create a proxy URL and pass it into an img tag. The image request will will be handled by a custom .NET method. +

+ + + +

+ +

+ The image below uses an img tag with a proxy URL in it's HTML like <img src="/proxy?operation=loadImageFromZip&fileName=happy.jpeg" /> +
+ +

+
+
+ + Interactive map with offline tiles. + +

+ The map tiles for this example are locally stored in a MBTiles file (a SQLite database file with a specific table schema and .mbtiles extension). + The MBTile file used in this example contains 4,096 map tiles. storing this as an MBTiles/SQLite file makes it significantly easier to manage and distribute. + The map is displayed using MapLibre GL JS. +

+
+

+ Map content from OpenStreetMap. +

+
+ + + diff --git a/MauiCSharpInteropWebView/Resources/Raw/hybrid_root/rawmessages.html b/MauiCSharpInteropWebView/Resources/Raw/hybrid_root/rawmessages.html index 517e87e..1794e63 100644 --- a/MauiCSharpInteropWebView/Resources/Raw/hybrid_root/rawmessages.html +++ b/MauiCSharpInteropWebView/Resources/Raw/hybrid_root/rawmessages.html @@ -22,7 +22,7 @@

HybridWebView demo: Raw messages

Raw messages are sent as a raw string from JavaScript to .NET with no further processing. diff --git a/MauiCSharpInteropWebView/Resources/Raw/media/pictures.zip b/MauiCSharpInteropWebView/Resources/Raw/media/pictures.zip new file mode 100644 index 0000000..7c0544e Binary files /dev/null and b/MauiCSharpInteropWebView/Resources/Raw/media/pictures.zip differ diff --git a/README.md b/README.md index 376ac0b..bb78767 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Example usage of the control: And here's how .NET code can call a JavaScript method: -```c# +```csharp var sum = await myHybridWebView.InvokeJsMethodAsync("JsAddNumbers", 123, 456); ``` @@ -23,8 +23,22 @@ And the reverse, JavaScript code calling a .NET method: HybridWebView.SendInvokeMessageToDotNet("CallMeFromScript", ["msg from js", 987]); ``` +With JavaScript you can also asynchronously call a .NET method and get a result: + +```js +HybridWebView.SendInvokeMessageToDotNetAsync("CallMeFromScriptAsync", ["msg from js", 987]) + .then(result => console.log("Got result from .NET: " + result)); +``` + +or + +```js +const result = await HybridWebView.SendInvokeMessageToDotNetAsync("CallMeFromScriptAsync", ["msg from js", 987]); +``` + In addition to method invocation, sending "raw" messages is also supported. + ## What's in this repo? Projects in this repo: @@ -47,7 +61,7 @@ Note: If you'd like to check out an already completed sample, go to https://gith 1. Ensure you have Visual Studio 2022 with the .NET MAUI workload installed 1. Create a **.NET MAUI App** project that targets .NET 8 (or use an existing one) -1. Add a reference to the `EJL.MauiHybridWebView` package: +1. Add a reference to the `EJL.MauiHybridWebView` package ([NuGet package](https://www.nuget.org/packages/EJL.MauiHybridWebView/1.0.0-preview5)): 1. Right-click on the **Dependencies** node in Solution Explorer and select **Manage NuGet Packages** 1. Select the **Browse** tab 1. Ensure the **Include prerelease** checkbox is checked @@ -61,7 +75,7 @@ Note: If you'd like to check out an already completed sample, go to https://gith 1. Add the markup `` inside the `` tag 1. Open the `MainPage.xaml.cs` file 1. Delete the `count` field, and the `OnCounterClicked` method, and replace it with the following code: - ```c# + ```csharp private async void OnHybridWebViewRawMessageReceived(object sender, HybridWebView.HybridWebViewRawMessageReceivedEventArgs e) { await Dispatcher.DispatchAsync(async () => @@ -105,6 +119,61 @@ Note: If you'd like to check out an already completed sample, go to https://gith 1. You can run it on Windows, Android, iOS, or macOS 1. When you launch the app, type text into the textbox and click the button to receive the message in C# code +## Proxy URLs + +The `HybridWebView` control can redirect URL requests to native code, and allow custom responses streams to be set. +This allows scenarios such as dynamically generating content, loading content from compressed files like ZIP or SQLite, or loading content from the internet that doesn't support CORS. + +To use this feature, handle the `ProxyRequestReceived` event in the `HybridWebView` control. +When the event handler is called set the `ResponseStream` and optionally the `ResponseContentType` of the `HybridWebViewProxyEventArgs` object received in the `OnProxyRequest` method. + +The `HybridWebViewProxyEventArgs` has the following properties: + +| Property | Type | Description | +| -------- | ---- | ----------- | +| `QueryParams` | `IDictionary` | The query string parameters of the request. Note that all values will be strings. | +| `ResponseContentType` | `string` | The content type of the response body. Default: `"text/plain"` | +| `ResponseStream` | `Stream` | The stream to use as the response body. | +| `Url` | `string` | The full URL that was requested. | + +```csharp +myWebView.ProxyRequestReceived += async (args) => +{ + //Use the query string parameters to determine what to do. + if (args.QueryParams.TryGetValue("myParameter", out var myParameter)) + { + //Add your logic to determine what to do. + if (myParameter == "myValue") + { + //Add logic to get your content (e.g. from a database, or generate it). + //Can be anything that can be turned into a stream. + + //Set the response stream and optionally the content type. + args.ResponseStream = new MemoryStream(Encoding.UTF8.GetBytes("This is the file content")); + args.ResponseContentType = "text/plain"; + } + } +}; +``` + +In your web app, you can make requests to the proxy URL by either using relative paths like `/proxy?myParameter=myValue` or an absolute path by appending the relative path tot he pages origin location `window.location.origin + '/proxy?myParameter=myValue'`. +Be sure to encode the query string parameters so that they are properly handled. Here are some ways to implement proxy URLs in your web app: + +1. Use proxy URLs with HTML tags. + ```html + + ``` +2. Use proxy URLs in JavaScript. + ```js + var request = window.location.origin + '/proxy?myParameter=' + encodeURIComponent('myValue'); + + fetch(request) + .then(response => response.text()) + .then(data => console.log(data)); + ``` + 3. Use proxy URLs with other JavaScript libraries. Some libraries only allow you to pass in string URLs. If you want to create the response in C# (for example, to generate content, or load from a compressed file), you can use a proxy URL to allow you to fulfil the response in C#. + +**NOTE:** Data from the webview can only be set in the proxy query string. POST body data is not supported as the native `WebView` in platforms do not support it. ## How to run the source code in this repo diff --git a/global.json b/global.json index f3365c4..989a69c 100644 --- a/global.json +++ b/global.json @@ -1,5 +1,6 @@ { "sdk": { - "version": "8.0.100" + "version": "8.0.100", + "rollForward": "latestMinor" } } \ No newline at end of file