File: Handlers\HybridWebView\HybridWebViewHandler.cs
Web Access
Project: src\src\Core\src\Core.csproj (Microsoft.Maui)
#if __IOS__ || MACCATALYST
using PlatformView = WebKit.WKWebView;
#elif MONOANDROID
using PlatformView = Android.Webkit.WebView;
#elif WINDOWS
using PlatformView = Microsoft.UI.Xaml.Controls.WebView2;
#elif TIZEN
using PlatformView = Tizen.NUI.BaseComponents.View;
#elif (NETSTANDARD || !PLATFORM) || (NET6_0_OR_GREATER && !IOS && !ANDROID && !TIZEN)
using PlatformView = System.Object;
#endif
#if __ANDROID__
using Android.Webkit;
#elif __IOS__
using WebKit;
#endif
using System.IO;
using System.Threading.Tasks;
using Microsoft.Maui.Storage;
using System;
using System.Collections.Concurrent;
using System.Threading;
using Microsoft.Maui.Devices;
using System.Text.RegularExpressions;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Maui.Hosting;
using System.Collections.Specialized;
using System.Text.Json.Serialization;
using System.Diagnostics.CodeAnalysis;
 
namespace Microsoft.Maui.Handlers
{
	[RequiresUnreferencedCode(DynamicFeatures)]
#if !NETSTANDARD
	[RequiresDynamicCode(DynamicFeatures)]
#endif
	public partial class HybridWebViewHandler : IHybridWebViewHandler, IHybridWebViewTaskManager
	{
		internal const string DynamicFeatures = "HybridWebView uses dynamic System.Text.Json serialization features.";
		internal const string NotSupportedMessage = DynamicFeatures + " Enable the $(MauiHybridWebViewSupported) property in your .csproj file to use in a trimming unsafe manner.";
 
		// Using an IP address means that the web view doesn't wait for any DNS resolution,
		// making it substantially faster. Note that this isn't real HTTP traffic, since
		// we intercept all the requests within this origin.
		private static readonly string AppHostAddress = "0.0.0.1";
 
		private static readonly string AppHostScheme =
#if IOS || MACCATALYST
			"app";
#else
			"https";
#endif
 
		/// <summary>
		/// Gets the application's base URI. Defaults to <c>https://0.0.0.1/</c> on Windows and Android,
		/// and <c>app://0.0.0.1/</c> on iOS and MacCatalyst (because <c>https</c> is reserved).
		/// </summary>
		internal static readonly string AppOrigin = $"{AppHostScheme}://{AppHostAddress}/";
 
		internal static readonly Uri AppOriginUri = new(AppOrigin);
 
		internal const string InvokeDotNetPath = "__hwvInvokeDotNet";
 
		public static IPropertyMapper<IHybridWebView, IHybridWebViewHandler> Mapper = new PropertyMapper<IHybridWebView, IHybridWebViewHandler>(ViewHandler.ViewMapper)
		{
		};
 
		public static CommandMapper<IHybridWebView, IHybridWebViewHandler> CommandMapper = new(ViewCommandMapper)
		{
			[nameof(IHybridWebView.EvaluateJavaScriptAsync)] = MapEvaluateJavaScriptAsync,
			[nameof(IHybridWebView.InvokeJavaScriptAsync)] = MapInvokeJavaScriptAsync,
			[nameof(IHybridWebView.SendRawMessage)] = MapSendRawMessage,
		};
 
		public HybridWebViewHandler() : base(Mapper, CommandMapper)
		{
		}
 
		public HybridWebViewHandler(IPropertyMapper? mapper = null, CommandMapper? commandMapper = null)
			: base(mapper ?? Mapper, commandMapper ?? CommandMapper)
		{
		}
 
		IHybridWebView IHybridWebViewHandler.VirtualView => VirtualView;
 
		PlatformView IHybridWebViewHandler.PlatformView => PlatformView;
 
		internal HybridWebViewDeveloperTools DeveloperTools => MauiContext?.Services.GetService<HybridWebViewDeveloperTools>() ?? new HybridWebViewDeveloperTools();
 
 
		/// <summary>
		/// Handler for when the an Async JavaScript task has completed and needs to notify .NET.
		/// </summary>
		private void AsyncTaskCompleted(string taskId, string result)
		{
			// Look for the callback in the list of pending callbacks
			if (!string.IsNullOrEmpty(taskId) && _asyncTaskCallbacks.TryGetValue(taskId, out var callback))
			{
				// Get the callback and remove it from the list
				callback.SetResult(result);
 
				// Remove the callback
				_asyncTaskCallbacks.TryRemove(taskId, out var _);
			}
		}
 
		void MessageReceived(string rawMessage)
		{
			if (string.IsNullOrEmpty(rawMessage))
			{
				throw new ArgumentException($"The raw message cannot be null or empty.", nameof(rawMessage));
			}
#if !NETSTANDARD2_0
			var indexOfPipe = rawMessage.IndexOf('|', StringComparison.Ordinal);
#else
			var indexOfPipe = rawMessage.IndexOf("|", StringComparison.Ordinal);
#endif
			if (indexOfPipe == -1)
			{
				throw new ArgumentException($"The raw message must contain a pipe character ('|').", nameof(rawMessage));
			}
 
			var messageType = rawMessage.Substring(0, indexOfPipe);
			var messageContent = rawMessage.Substring(indexOfPipe + 1);
 
			switch (messageType)
			{
				case "__InvokeJavaScriptCompleted":
					{
#if !NETSTANDARD2_0
						var indexOfPipeInContent = messageContent.IndexOf('|', StringComparison.Ordinal);
#else
						var indexOfPipeInContent = messageContent.IndexOf("|", StringComparison.Ordinal);
#endif
						if (indexOfPipeInContent == -1)
						{
							throw new ArgumentException($"The '{messageType}' message content must contain a pipe character ('|').", nameof(rawMessage));
						}
 
						var taskId = messageContent.Substring(0, indexOfPipeInContent);
						var result = messageContent.Substring(indexOfPipeInContent + 1);
						AsyncTaskCompleted(taskId, result);
					}
					break;
				case "__RawMessage":
					VirtualView?.RawMessageReceived(messageContent);
					break;
				default:
					throw new ArgumentException($"The message type '{messageType}' is not recognized.", nameof(rawMessage));
			}
		}
 
		internal (byte[]? ContentBytes, string? ContentType) InvokeDotNet(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 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<JSInvokeMethodData>(invokeDataString, HybridWebViewHandlerJsonContext.Default.JSInvokeMethodData);
 
				if (invokeData != null && invokeData.MethodName != null)
				{
					var t = ((IHybridWebView)VirtualView).InvokeJavaScriptType;
					var result = InvokeDotNetMethod(t!, invokeTarget, invokeData);
 
					contentType = "application/json";
 
					DotNetInvokeResult dotNetInvokeResult;
 
					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();
					}
 
					contentBytes = System.Text.Encoding.UTF8.GetBytes(JsonSerializer.Serialize(dotNetInvokeResult));
				}
 
				return (contentBytes, contentType);
			}
			catch (Exception)
			{
				// TODO: Log this
			}
 
			return (null, null);
		}
 
		private static object? InvokeDotNetMethod([DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] Type t, 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)
			{
				throw new InvalidOperationException($"The method {invokeData.MethodName} couldn't be found on the {nameof(jsInvokeTarget)} of type {jsInvokeTarget.GetType().FullName}.");
			}
 
			if (invokeData.ParamValues != null && invokeMethod.GetParameters().Length != invokeData.ParamValues.Length)
			{
				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}).");
			}
 
			var paramObjectValues =
				invokeData.ParamValues?
					.Zip(invokeMethod.GetParameters(), (s, p) => s == null ? null : JsonSerializer.Deserialize(s, p.ParameterType))
					.ToArray();
 
			return invokeMethod.Invoke(jsInvokeTarget, paramObjectValues);
		}
 
 
		private sealed class JSInvokeMethodData
		{
			public string? MethodName { get; set; }
			public string[]? ParamValues { get; set; }
		}
 
		private sealed class DotNetInvokeResult
		{
			public object? Result { get; set; }
			public bool IsJson { get; set; }
		}
 
		[JsonSourceGenerationOptions()]
		[JsonSerializable(typeof(JSInvokeMethodData))]
		private partial class HybridWebViewHandlerJsonContext : JsonSerializerContext
		{
		}
 
 
 
#if PLATFORM && !TIZEN
		public static async void MapEvaluateJavaScriptAsync(IHybridWebViewHandler handler, IHybridWebView hybridWebView, object? arg)
		{
			if (arg is not EvaluateJavaScriptAsyncRequest request ||
				handler.PlatformView is not MauiHybridWebView hybridPlatformWebView)
			{
				return;
			}
 
			if (handler.PlatformView is null)
			{
				request.SetCanceled();
				return;
			}
 
			var script = request.Script;
			// Make all the platforms mimic Android's implementation, which is by far the most complete.
			if (!OperatingSystem.IsAndroid())
			{
				script = EscapeJsString(script);
 
				if (!OperatingSystem.IsWindows())
				{
					// Use JSON.stringify() method to converts a JavaScript value to a JSON string
					script = "try{JSON.stringify(eval('" + script + "'))}catch(e){'null'};";
				}
				else
				{
					script = "try{eval('" + script + "')}catch(e){'null'};";
				}
			}
 
			// Use the handler command to evaluate the JS
			var innerRequest = new EvaluateJavaScriptAsyncRequest(script);
			EvaluateJavaScript(handler, hybridWebView, innerRequest);
 
			var result = await innerRequest.Task;
 
			//if the js function errored or returned null/undefined treat it as null
			if (result == "null")
			{
				result = null;
			}
			//JSON.stringify wraps the result in literal quotes, we just want the actual returned result
			//note that if the js function returns the string "null" we will get here and not above
			else if (result != null)
			{
				result = result.Trim('"');
			}
 
			request.SetResult(result!);
 
		}
#endif
 
		public static async void MapInvokeJavaScriptAsync(IHybridWebViewHandler handler, IHybridWebView hybridWebView, object? arg)
		{
#if PLATFORM && !TIZEN
			if (arg is not HybridWebViewInvokeJavaScriptRequest invokeJavaScriptRequest ||
				handler.PlatformView is not MauiHybridWebView hybridPlatformWebView ||
				handler is not IHybridWebViewTaskManager taskManager)
			{
				return;
			}
 
			// Create a callback for async JavaScript methods to invoke when they are done
			var callback = new TaskCompletionSource<string>();
			var currentInvokeTaskId = $"{taskManager.GetNextInvokeTaskId()}";
			taskManager.AsyncTaskCallbacks.TryAdd(currentInvokeTaskId, callback);
 
			var paramsValuesStringArray =
				invokeJavaScriptRequest.ParamValues == null
				? string.Empty
				: string.Join(
					", ",
					invokeJavaScriptRequest.ParamValues.Select((v, i) => (v == null ? "null" : JsonSerializer.Serialize(v, invokeJavaScriptRequest.ParamJsonTypeInfos![i]!))));
 
			await handler.InvokeAsync(nameof(IHybridWebView.EvaluateJavaScriptAsync),
				new EvaluateJavaScriptAsyncRequest($"window.HybridWebView.__InvokeJavaScript({currentInvokeTaskId}, {invokeJavaScriptRequest.MethodName}, [{paramsValuesStringArray}])"));
 
			var stringResult = await callback.Task;
 
			if (stringResult is null)
			{
				invokeJavaScriptRequest.SetResult(null);
			}
			else
			{
				var typedResult = JsonSerializer.Deserialize(stringResult, invokeJavaScriptRequest.ReturnTypeJsonTypeInfo);
				invokeJavaScriptRequest.SetResult(typedResult);
			}
#else
			await Task.CompletedTask;
#endif
		}
 
#if PLATFORM && !TIZEN
		// Copied from WebView.cs
		internal static string? EscapeJsString(string js)
		{
			if (js == null)
				return null;
 
			if (!js.Contains('\'', StringComparison.Ordinal))
				return js;
 
			//get every quote in the string along with all the backslashes preceding it
			var singleQuotes = Regex.Matches(js, @"(\\*?)'");
 
			var uniqueMatches = new List<string>();
 
			for (var i = 0; i < singleQuotes.Count; i++)
			{
				var matchedString = singleQuotes[i].Value;
				if (!uniqueMatches.Contains(matchedString))
				{
					uniqueMatches.Add(matchedString);
				}
			}
 
			uniqueMatches.Sort((x, y) => y.Length.CompareTo(x.Length));
 
			//escape all quotes from the script as well as add additional escaping to all quotes that were already escaped
			for (var i = 0; i < uniqueMatches.Count; i++)
			{
				var match = uniqueMatches[i];
				var numberOfBackslashes = match.Length - 1;
				var slashesToAdd = (numberOfBackslashes * 2) + 1;
				var replacementStr = "'".PadLeft(slashesToAdd + 1, '\\');
				js = Regex.Replace(js, @"(?<=[^\\])" + Regex.Escape(match), replacementStr);
			}
 
			return js;
		}
#endif
 
		internal static async Task<string?> GetAssetContentAsync(string assetPath)
		{
			using var stream = await GetAssetStreamAsync(assetPath);
			if (stream == null)
			{
				return null;
			}
			using var reader = new StreamReader(stream);
 
			var contents = reader.ReadToEnd();
 
			return contents;
		}
 
		internal static async Task<Stream?> GetAssetStreamAsync(string assetPath)
		{
			if (!await FileSystem.AppPackageFileExistsAsync(assetPath))
			{
				return null;
			}
			return await FileSystem.OpenAppPackageFileAsync(assetPath);
		}
 
#if !NETSTANDARD
		internal static readonly FileExtensionContentTypeProvider ContentTypeProvider = new();
#endif
 
		// IHybridWebViewTaskManager implementation
		ConcurrentDictionary<string, TaskCompletionSource<string>> _asyncTaskCallbacks = new ConcurrentDictionary<string, TaskCompletionSource<string>>();
		int _asyncInvokeTaskId;
 
		int IHybridWebViewTaskManager.GetNextInvokeTaskId()
		{
			return Interlocked.Increment(ref _asyncInvokeTaskId);
		}
		ConcurrentDictionary<string, TaskCompletionSource<string>> IHybridWebViewTaskManager.AsyncTaskCallbacks => _asyncTaskCallbacks;
	}
}