Skip to content

Unity Android WebView — Multiple file input does not work, the problem is with the permission requirements. #1179

@eddi73932-tech

Description

@eddi73932-tech

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions