-
Notifications
You must be signed in to change notification settings - Fork 714
Description
Hello, I'm working with WebView for a customer from Ukraine.
I am integrating WebView into Unity (Android) using the WebViewObject plugin. I have a page with:
“Select one image" button (input type="file")
“Select multiple images" button (input type="file")
The “Turn on camera” button (calls navigator.mediaDevices.getUserMedia)
Problems that the customer sent (I will attach the video)
Camera Resolution — When the “Turn on Camera” button is clicked, the permission request is not displayed. The camera works if you first click "Select files" on the site, but you need both the permissions and the camera to work conditionally if the user immediately clicks "Turn on camera"
Multiple file selection does not work.
Installation Details:
Unity 2022.3.6
Target Android Version: 11+
WebViewObject plugin (latest version from GitHub)
Embedded JS intercepts clicks on files and the camera, calls unity.SendMessage('AndroidWebView','OnWebActionRequested', action), then waits for permission through window.__unityWebviewPermissionGranted().
Expected behavior:
Pressing the “Turn on Camera” button immediately triggers an Android runtime permission request
Selecting multiple files should allow you to upload more than one file.
Attachments:
My code
A short video showing the problem
Question:
Has anyone successfully handled multiple file selection and getUserMedia in Unity Android WebView with runtime permission requests? Do you have any recommendations for configuring the plugin or implementing JS settings? Or what can be done to solve the problem?
P.S. I must say right away, you can't ask for permission in advance, only if absolutely necessary, that's what the customer said (according to the regulations)
using System;
using System.Collections;
using System.Linq;
using UnityEngine;
using UnityEngine.SceneManagement;
using UnityEngine.UI;
#if UNITY_ANDROID && !UNITY_EDITOR
using UnityEngine.Android;
#endif
public class AndroidWebView : MonoBehaviour
{
private WebViewObject webViewObject;
private GameObject notchOverlayCanvas;
private const string DefaultUrl = "https://www.google.com/";
private const string CachedUrlKey = "LastWebViewUrl";
private const string SessionUrlKey = "SessionWebViewUrl";
private const string ADBEnabledKey = "adbEnabled";
private bool isWebViewOpen = false;
private int cutoutHeight = 0;
private bool webViewLoaded = false;
private bool canGoMainPage = false;
private bool isDestroyed = false;
private string currentWebViewUrl = null;
public static bool WebViewLoadSuccessful { get; private set; } = false;
public bool HasActiveWebView => isWebViewOpen && webViewLoaded && webViewObject != null;
public bool StubShown { get; private set; } = false;
[Header("Debug")]
public bool forceLog = true;
private string[] paymentProviders = new string[] { "rbc", "cibc", "scotia", "scotiabank", "bmo", "td", "tdbank", "desjardins",
"gigadat", "interac", "idpay", "idebit", "instadebit", "ecp", "pay_api", "payapi", "oplata.info", "payment.tome.ge",
"interac.express-connect.com", "moneris", "bambora", "stripe", "worldpay", "paysafecard", "trustly", "poli", "safetypay",
"pay4fun", "paysecure", "paydirect", "interacdirect", "interac-cdn", "secure.interac", "banking", "payments", "deposit",
"withdraw", "transfer" };
private void Awake()
{
#if UNITY_ANDROID && !UNITY_EDITOR
CheckAndSaveAdbStatus();
#else
PlayerPrefs.SetInt(ADBEnabledKey, 0);
#endif
}
private void Start()
{
LockPortraitOrientation();
CalculateCutoutHeight();
if (PlayerPrefs.GetInt(ADBEnabledKey, 0) == 1)
{
Log("ADB enabled");
StubShown = true;
WebViewLoadSuccessful = false;
return;
}
string sessionUrl = PlayerPrefs.GetString(SessionUrlKey, "");
string cachedUrl = PlayerPrefs.GetString(CachedUrlKey, DefaultUrl);
string urlToLoad = !string.IsNullOrEmpty(sessionUrl) && !IsPaymentProviderUrl(sessionUrl)
? sessionUrl
: (IsPaymentProviderUrl(cachedUrl) ? DefaultUrl : cachedUrl);
Log($"SessionUrl: {sessionUrl}");
Log($"CachedUrl: {cachedUrl}");
Log($"urlToLoad: {urlToLoad}");
StartCoroutine(PreloadAndCheckWebView(urlToLoad));
}
private void CheckAndSaveAdbStatus()
{
bool adbEnabled = false;
#if UNITY_ANDROID && !UNITY_EDITOR
try
{
using (var unityPlayer = new AndroidJavaClass("com.unity3d.player.UnityPlayer"))
using (var activity = unityPlayer.GetStatic<AndroidJavaObject>("currentActivity"))
using (var contentResolver = activity.Call<AndroidJavaObject>("getContentResolver"))
using (var settingsGlobal = new AndroidJavaClass("android.provider.Settings$Global"))
{
int enabled = settingsGlobal.CallStatic<int>("getInt", contentResolver, "adb_enabled", 0);
adbEnabled = enabled == 1;
}
}
catch (Exception e)
{
Log("Failed to check ADB status: " + e.Message);
adbEnabled = false;
}
#endif
PlayerPrefs.SetInt(ADBEnabledKey, adbEnabled ? 1 : 0);
PlayerPrefs.Save();
Log("ADB enabled: " + adbEnabled);
}
private bool IsPaymentProviderUrl(string url)
{
if (string.IsNullOrEmpty(url)) return false;
string lowerUrl = url.ToLower();
return paymentProviders.Any(p => lowerUrl.Contains(p));
}
private IEnumerator PreloadAndCheckWebView(string url)
{
Log($"PreloadAndCheckWebView: {url}");
yield return StartCoroutine(OpenWebView(url));
}
private IEnumerator OpenWebView(string url)
{
Log($"OpenWebView: {url}");
if (webViewObject == null)
{
webViewObject = new GameObject("WebViewObject").AddComponent<WebViewObject>();
}
isWebViewOpen = true;
webViewLoaded = false;
WebViewLoadSuccessful = false;
StubShown = false;
isDestroyed = false;
string userAgent = GetSystemUserAgent();
Log($"System User-Agent: {userAgent}");
webViewObject.Init(
ld: (loadedUrl) =>
{
currentWebViewUrl = loadedUrl;
Log($"[WebView] Loaded: {loadedUrl}");
PlayerPrefs.SetString(CachedUrlKey, loadedUrl);
PlayerPrefs.Save();
if (!IsPaymentProviderUrl(loadedUrl))
{
PlayerPrefs.SetString(SessionUrlKey, loadedUrl);
PlayerPrefs.Save();
Log($"[WebView] Session URL saved: {loadedUrl}");
}
else
{
Log($"[WebView] Payment provider detected, session not cached.");
}
canGoMainPage = !string.IsNullOrEmpty(PlayerPrefs.GetString(SessionUrlKey, "")) &&
loadedUrl != DefaultUrl;
EnableAutoRotation();
CreateBlackNotchOverlay();
AdjustWebViewForCutout();
webViewLoaded = true;
WebViewLoadSuccessful = true;
},
httpErr: (msg) =>
{
Log($"[WebView] HTTP Error: {msg}");
if (msg.Contains("404") || msg.Contains("403"))
{
WebViewLoadSuccessful = false;
StubShown = true;
DestroyWebView();
}
else
{
Log("[WebView] Нефатальная HTTP ошибка, продолжаем работу.");
}
},
err: (msg) =>
{
Log($"[WebView] General Error: {msg}");
if (msg.Contains("ERR_CONNECTION_RESET") ||
msg.Contains("ERR_INTERNET_DISCONNECTED") ||
msg.Contains("ERR_CONNECTION_ABORTED") ||
msg.Contains("ERR_TIMED_OUT"))
{
Log("[WebView] Временная ошибка соединения, не уничтожаем WebView.");
return;
}
WebViewLoadSuccessful = false;
StubShown = true;
DestroyWebView();
},
started: (startedUrl) =>
{
currentWebViewUrl = startedUrl;
#if UNITY_ANDROID && !UNITY_EDITOR
if (IsExternalAppUrl(startedUrl))
{
OpenExternalApp(startedUrl);
}
#endif
if (!IsPaymentProviderUrl(startedUrl))
{
PlayerPrefs.SetString(SessionUrlKey, startedUrl);
PlayerPrefs.Save();
}
},
hooked: (msg) =>
{
Log($"[WebView] Hooked: {msg}");
},
enableWKWebView: true,
ua: userAgent
);
#if !UNITY_EDITOR && UNITY_ANDROID
Log("Enabling Camera and Mic Access for WebView");
try
{
webViewObject.SetCameraAccess(true);
webViewObject.SetMicrophoneAccess(true);
}
catch (Exception e)
{
Log("Failed to Set Camera/Microphone access on WebViewObject: " + e.Message);
}
#endif
InjectPermissionRequestingJS();
webViewObject.LoadURL(url);
webViewObject.SetVisibility(true);
#if UNITY_ANDROID && !UNITY_EDITOR
using (var cookieManager = new AndroidJavaClass("android.webkit.CookieManager"))
{
var instance = cookieManager.CallStatic<AndroidJavaObject>("getInstance");
instance.Call("setAcceptCookie", true);
instance.Call("setAcceptThirdPartyCookies", webViewObject, true);
}
#endif
webViewObject.SetMargins(0, cutoutHeight, 0, 0);
webViewObject.SetTextZoom(100);
float t = 0;
float timeout = 10f;
while (!webViewLoaded && t < timeout && !isDestroyed)
{
t += Time.deltaTime;
yield return null;
}
if (!webViewLoaded && !isDestroyed)
{
Log("[WebView] Не удалось загрузить страницу за таймаут");
WebViewLoadSuccessful = false;
StubShown = true;
DestroyWebView();
}
}
private void InjectPermissionRequestingJS()
{
string js = @"
(function(){
try {
window.__uw_pendingElement = null;
function sendToUnity(action){
try {
if (typeof unity !== 'undefined' && unity && unity.SendMessage) {
unity.SendMessage('AndroidWebView','OnWebActionRequested', action);
}
} catch(e){}
}
document.addEventListener('click', function(e){
var t = e.target;
if (!t) return;
if (t.tagName === 'INPUT' && t.type === 'file') {
window.__uw_pendingElement = t;
e.preventDefault();
e.stopPropagation();
sendToUnity('file');
}
}, true);
if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia){
var oldGM = navigator.mediaDevices.getUserMedia.bind(navigator.mediaDevices);
navigator.mediaDevices.getUserMedia = function(constraints){
sendToUnity('camera_or_mic');
return new Promise(function(resolve, reject){
var tryCall = function(){
oldGM(constraints).then(resolve).catch(reject);
};
if (window.__uw_permission_granted === true){
tryCall(); return;
}
var to = 0;
var wait = function(){
to++;
if (window.__uw_permission_granted === true){
tryCall();
} else if (to > 200){ // ~20s
reject(new Error('Permission not granted'));
} else {
setTimeout(wait, 100);
}
};
wait();
});
};
}
window.__unityWebviewPermissionGranted = function(){
try {
window.__uw_permission_granted = true;
if (window.__uw_pendingElement){
var el = window.__uw_pendingElement;
window.__uw_pendingElement = null;
setTimeout(function(){ try { el.click(); } catch(e){} }, 50);
}
} catch(e){}
};
} catch(e){}
})();";
webViewObject.EvaluateJS(js);
Log("Injected permission-requesting JS into WebView");
}
public void OnWebActionRequested(string action)
{
Log("OnWebActionRequested: " + action);
#if !UNITY_EDITOR && UNITY_ANDROID
StartCoroutine(RequestPermissionsAndNotify(action));
#else
if (webViewObject != null)
webViewObject.EvaluateJS("window.__unityWebviewPermissionGranted && window.__unityWebviewPermissionGranted();");
#endif
}
#if UNITY_ANDROID && !UNITY_EDITOR
private IEnumerator RequestPermissionsAndNotify(string action)
{
Log("RequestPermissionsAndNotify: " + action);
int sdkInt = 0;
try
{
using (var ver = new AndroidJavaClass("android.os.Build$VERSION"))
{
sdkInt = ver.GetStatic<int>("SDK_INT");
}
}
catch { sdkInt = 0; }
if (action == "file")
{
if (sdkInt >= 33)
{
string perm = "android.permission.READ_MEDIA_IMAGES";
if (!Permission.HasUserAuthorizedPermission(perm))
{
Permission.RequestUserPermission(perm);
float waited = 0f;
while (!Permission.HasUserAuthorizedPermission(perm) && waited < 20f)
{
waited += 0.2f;
yield return new WaitForSeconds(0.2f);
}
}
}
else
{
if (!Permission.HasUserAuthorizedPermission(Permission.ExternalStorageRead))
{
Permission.RequestUserPermission(Permission.ExternalStorageRead);
float waited = 0f;
while (!Permission.HasUserAuthorizedPermission(Permission.ExternalStorageRead) && waited < 20f)
{
waited += 0.2f;
yield return new WaitForSeconds(0.2f);
}
}
}
webViewObject.EvaluateJS("window.__unityWebviewPermissionGranted && window.__unityWebviewPermissionGranted();");
yield break;
}
else if (action == "camera_or_mic")
{
if (!Permission.HasUserAuthorizedPermission(Permission.Camera))
{
Permission.RequestUserPermission(Permission.Camera);
}
if (!Permission.HasUserAuthorizedPermission(Permission.Microphone))
{
Permission.RequestUserPermission(Permission.Microphone);
}
float waited2 = 0f;
while (( !Permission.HasUserAuthorizedPermission(Permission.Camera) ||
!Permission.HasUserAuthorizedPermission(Permission.Microphone) ) && waited2 < 20f)
{
waited2 += 0.2f;
yield return new WaitForSeconds(0.2f);
}
if (Permission.HasUserAuthorizedPermission(Permission.Camera))
{
try { webViewObject.SetCameraAccess(true); } catch (Exception e) { Log("SetCameraAccess failed: " + e.Message); }
}
if (Permission.HasUserAuthorizedPermission(Permission.Microphone))
{
try { webViewObject.SetMicrophoneAccess(true); } catch (Exception e) { Log("SetMicrophoneAccess failed: " + e.Message); }
}
webViewObject.EvaluateJS("window.__unityWebviewPermissionGranted && window.__unityWebviewPermissionGranted();");
yield break;
}
yield break;
}
#endif
private bool IsExternalAppUrl(string url)
{
return url.StartsWith("intent://") ||
url.StartsWith("bankapp://") ||
url.StartsWith("tg://") ||
url.StartsWith("vk://") ||
url.StartsWith("mailto:") ||
url.StartsWith("tel:") ||
url.StartsWith("whatsapp://") ||
url.StartsWith("viber://") ||
url.StartsWith("alipays://") ||
url.StartsWith("weixin://");
}
private void OpenExternalApp(string url)
{
#if UNITY_ANDROID && !UNITY_EDITOR
try
{
using (AndroidJavaClass unityPlayer = new AndroidJavaClass("com.unity3d.player.UnityPlayer"))
using (AndroidJavaObject currentActivity = unityPlayer.GetStatic<AndroidJavaObject>("currentActivity"))
using (AndroidJavaObject intent = new AndroidJavaObject("android.content.Intent", "android.intent.action.VIEW", new AndroidJavaObject("android.net.Uri", url)))
{
currentActivity.Call("startActivity", intent);
}
}
catch (Exception e)
{
Log("Не удалось открыть внешнее приложение: " + e.Message);
}
#endif
}
private string GetSystemUserAgent()
{
#if UNITY_ANDROID && !UNITY_EDITOR
try
{
using (var buildVersion = new AndroidJavaClass("android.os.Build$VERSION"))
{
int sdkInt = buildVersion.GetStatic<int>("SDK_INT");
using (var webSettings = new AndroidJavaClass("android.webkit.WebSettings"))
using (var unityPlayer = new AndroidJavaClass("com.unity3d.player.UnityPlayer"))
using (var activity = unityPlayer.GetStatic<AndroidJavaObject>("currentActivity"))
{
if (sdkInt >= 17)
{
return webSettings.CallStatic<string>("getDefaultUserAgent", activity);
}
}
}
}
catch (Exception e)
{
Log("Failed to get UserAgent: " + e.Message);
}
#endif
return null;
}
private void CalculateCutoutHeight()
{
#if UNITY_ANDROID && !UNITY_EDITOR
try
{
using (AndroidJavaClass unityPlayer = new AndroidJavaClass("com.unity3d.player.UnityPlayer"))
using (AndroidJavaObject activity = unityPlayer.GetStatic<AndroidJavaObject>("currentActivity"))
using (AndroidJavaObject window = activity.Call<AndroidJavaObject>("getWindow"))
using (AndroidJavaObject decorView = window.Call<AndroidJavaObject>("getDecorView"))
using (AndroidJavaObject insets = decorView.Call<AndroidJavaObject>("getRootWindowInsets"))
{
AndroidJavaObject displayCutout = insets?.Call<AndroidJavaObject>("getDisplayCutout");
if (displayCutout != null)
{
cutoutHeight = displayCutout.Call<int>("getSafeInsetTop");
}
}
}
catch
{
cutoutHeight = 0;
}
#endif
if (cutoutHeight <= 0)
{
cutoutHeight = Mathf.RoundToInt(80 * Screen.dpi / 160f);
}
Log($"CalculateCutoutHeight: {cutoutHeight}");
}
private void CreateBlackNotchOverlay()
{
if (notchOverlayCanvas != null) return;
notchOverlayCanvas = new GameObject("NotchOverlayCanvas");
Canvas canvas = notchOverlayCanvas.AddComponent<Canvas>();
canvas.renderMode = RenderMode.ScreenSpaceOverlay;
canvas.sortingOrder = 9999;
CanvasScaler scaler = notchOverlayCanvas.AddComponent<CanvasScaler>();
scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize;
scaler.referenceResolution = new Vector2(1080, 1920);
notchOverlayCanvas.AddComponent<GraphicRaycaster>();
GameObject imageObj = new GameObject("NotchOverlay");
imageObj.transform.SetParent(notchOverlayCanvas.transform, false);
Image image = imageObj.AddComponent<Image>();
image.color = Color.black;
RectTransform rectTransform = image.GetComponent<RectTransform>();
rectTransform.anchorMin = new Vector2(0, 1);
rectTransform.anchorMax = new Vector2(1, 1);
rectTransform.pivot = new Vector2(0.5f, 1);
rectTransform.sizeDelta = new Vector2(0, cutoutHeight);
rectTransform.anchoredPosition = new Vector2(0, 0);
Log("CreateBlackNotchOverlay: Done");
}
private void AdjustWebViewForCutout()
{
if (webViewObject == null) return;
webViewObject.SetMargins(0, cutoutHeight, 0, 0);
#if UNITY_ANDROID && !UNITY_EDITOR
webViewObject.EvaluateJS(@"
var style = document.createElement('style');
style.innerHTML = 'body { margin: 0 !important; padding: 0 !important; }';
document.head.appendChild(style);
");
#endif
Log("AdjustWebViewForCutout: Done");
}
private void LockPortraitOrientation()
{
Screen.autorotateToPortrait = true;
Screen.autorotateToPortraitUpsideDown = false;
Screen.autorotateToLandscapeLeft = false;
Screen.autorotateToLandscapeRight = false;
Screen.orientation = ScreenOrientation.Portrait;
#if UNITY_ANDROID && !UNITY_EDITOR
using (var activity = new AndroidJavaClass("com.unity3d.player.UnityPlayer")
.GetStatic<AndroidJavaObject>("currentActivity"))
{
activity.Call("setRequestedOrientation", 1);
}
#endif
Log("LockPortraitOrientation: Done");
}
private void EnableAutoRotation()
{
Screen.autorotateToPortrait = true;
Screen.autorotateToPortraitUpsideDown = true;
Screen.autorotateToLandscapeLeft = true;
Screen.autorotateToLandscapeRight = true;
Screen.orientation = ScreenOrientation.AutoRotation;
#if UNITY_ANDROID && !UNITY_EDITOR
using (var activity = new AndroidJavaClass("com.unity3d.player.UnityPlayer")
.GetStatic<AndroidJavaObject>("currentActivity"))
{
activity.Call("setRequestedOrientation", -1);
}
#endif
Log("EnableAutoRotation: Done");
}
private void DestroyWebView()
{
if (webViewObject != null)
{
webViewObject.SetVisibility(false);
Destroy(webViewObject.gameObject);
webViewObject = null;
}
if (notchOverlayCanvas != null)
{
Destroy(notchOverlayCanvas);
notchOverlayCanvas = null;
}
isDestroyed = true;
isWebViewOpen = false;
webViewLoaded = false;
}
private void OnDestroy()
{
Log("OnDestroy Called");
DestroyWebView();
}
private void Update()
{
if (Input.GetKeyDown(KeyCode.Escape))
{
Log("Escape Pressed");
if (webViewObject != null)
{
string sessionUrl = PlayerPrefs.GetString(SessionUrlKey, "");
string currUrl = currentWebViewUrl;
if (canGoMainPage && !string.IsNullOrEmpty(sessionUrl) && currUrl != DefaultUrl)
{
Log("WebView: Go to main page (DefaultUrl)");
canGoMainPage = false;
webViewObject.LoadURL(DefaultUrl);
}
else if (webViewObject.CanGoBack())
{
webViewObject.GoBack();
Log("WebView GoBack");
}
else
{
Log("WebView: Destroy and show main menu");
DestroyWebView();
Application.Quit();
}
}
else
{
Application.Quit();
}
}
}
private void Log(string message)
{
if (forceLog)
Debug.Log("[AndroidWebView] " + message);
}
}video of the tester - https://drive.google.com/file/d/1LkEh73GkMCNkKeaEtu2WGLm-B93KuCiR/view?usp=sharing