package com.getcapacitor;

import android.annotation.SuppressLint;
import android.app.Activity;
import android.content.ActivityNotFoundException;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.content.pm.ApplicationInfo;
import android.content.pm.PackageInfo;
import android.content.pm.PackageManager;
import android.content.res.Configuration;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.os.Handler;
import android.os.HandlerThread;
import android.webkit.ValueCallback;
import android.webkit.WebSettings;
import android.webkit.WebView;
import androidx.activity.result.ActivityResultCallback;
import androidx.activity.result.ActivityResultLauncher;
import androidx.activity.result.contract.ActivityResultContract;
import androidx.annotation.NonNull;
import androidx.appcompat.app.AppCompatActivity;
import androidx.core.app.ActivityCompat;
import androidx.core.content.pm.PackageInfoCompat;
import androidx.fragment.app.Fragment;
import com.getcapacitor.android.R;
import com.getcapacitor.annotation.CapacitorPlugin;
import com.getcapacitor.annotation.Permission;
import com.getcapacitor.cordova.MockCordovaInterfaceImpl;
import com.getcapacitor.cordova.MockCordovaWebViewImpl;
import com.getcapacitor.util.HostMask;
import com.getcapacitor.util.InternalUtils;
import com.getcapacitor.util.PermissionHelper;
import com.getcapacitor.util.WebColor;
import java.io.File;
import java.net.SocketTimeoutException;
import java.net.URL;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.apache.cordova.ConfigXmlParser;
import org.apache.cordova.CordovaPreferences;
import org.apache.cordova.CordovaWebView;
import org.apache.cordova.PluginEntry;
import org.apache.cordova.PluginManager;
import org.json.JSONException;

/**
 * The Bridge class is the main engine of Capacitor. It manages
 * loading and communicating with all Plugins,
 * proxying Native events to Plugins, executing Plugin methods,
 * communicating with the WebView, and a whole lot more.
 *
 * Generally, you'll not use Bridge directly, instead, extend from BridgeActivity
 * to get a WebView instance and proxy native events automatically.
 *
 * If you want to use this Bridge in an existing Android app, please
 * see the source for BridgeActivity for the methods you'll need to
 * pass through to Bridge:
 * <a href="https://github.com/ionic-team/capacitor/blob/HEAD/android/capacitor/src/main/java/com/getcapacitor/BridgeActivity.java">
 *   BridgeActivity.java</a>
 */
public class Bridge {

    private static final String PREFS_NAME = "CapacitorSettings";
    private static final String PERMISSION_PREFS_NAME = "PluginPermStates";
    private static final String BUNDLE_LAST_PLUGIN_ID_KEY = "capacitorLastActivityPluginId";
    private static final String BUNDLE_LAST_PLUGIN_CALL_METHOD_NAME_KEY = "capacitorLastActivityPluginMethod";
    private static final String BUNDLE_PLUGIN_CALL_OPTIONS_SAVED_KEY = "capacitorLastPluginCallOptions";
    private static final String BUNDLE_PLUGIN_CALL_BUNDLE_KEY = "capacitorLastPluginCallBundle";
    private static final String LAST_BINARY_VERSION_CODE = "lastBinaryVersionCode";
    private static final String LAST_BINARY_VERSION_NAME = "lastBinaryVersionName";
    private static final String MINIMUM_ANDROID_WEBVIEW_ERROR = "System WebView is not supported";

    // The name of the directory we use to look for index.html and the rest of our web assets
    public static final String DEFAULT_WEB_ASSET_DIR = "public";
    public static final String CAPACITOR_HTTP_SCHEME = "http";
    public static final String CAPACITOR_HTTPS_SCHEME = "https";
    public static final String CAPACITOR_FILE_START = "/_capacitor_file_";
    public static final String CAPACITOR_CONTENT_START = "/_capacitor_content_";
    public static final int DEFAULT_ANDROID_WEBVIEW_VERSION = 60;
    public static final int MINIMUM_ANDROID_WEBVIEW_VERSION = 55;
    public static final int DEFAULT_HUAWEI_WEBVIEW_VERSION = 10;
    public static final int MINIMUM_HUAWEI_WEBVIEW_VERSION = 10;

    // Loaded Capacitor config
    private CapConfig config;

    // A reference to the main activity for the app
    private final AppCompatActivity context;
    // A reference to the containing Fragment if used
    private final Fragment fragment;
    private WebViewLocalServer localServer;
    private String localUrl;
    private String appUrl;
    private String appUrlConfig;
    private HostMask appAllowNavigationMask;
    private Set<String> allowedOriginRules = new HashSet<String>();
    private ArrayList<String> authorities = new ArrayList<>();
    // A reference to the main WebView for the app
    private final WebView webView;
    public final MockCordovaInterfaceImpl cordovaInterface;
    private CordovaWebView cordovaWebView;
    private CordovaPreferences preferences;
    private BridgeWebViewClient webViewClient;
    private App app;

    // Our MessageHandler for sending and receiving data to the WebView
    private final MessageHandler msgHandler;

    // The ThreadHandler for executing plugin calls
    private final HandlerThread handlerThread = new HandlerThread("CapacitorPlugins");

    // Our Handler for posting plugin calls. Created from the ThreadHandler
    private Handler taskHandler = null;

    private final List<Class<? extends Plugin>> initialPlugins;

    private final List<Plugin> pluginInstances;

    // A map of Plugin Id's to PluginHandle's
    private Map<String, PluginHandle> plugins = new HashMap<>();

    // Stored plugin calls that we're keeping around to call again someday
    private Map<String, PluginCall> savedCalls = new HashMap<>();

    // The call IDs of saved plugin calls with associated plugin id for handling permissions
    private Map<String, LinkedList<String>> savedPermissionCallIds = new HashMap<>();

    // Store a plugin that started a new activity, in case we need to resume
    // the app and return that data back
    private PluginCall pluginCallForLastActivity;

    // Any URI that was passed to the app on start
    private Uri intentUri;

    // A list of listeners that trigger when webView events occur
    private List<WebViewListener> webViewListeners = new ArrayList<>();

    // An interface to manipulate route resolving
    private RouteProcessor routeProcessor;

    // A pre-determined path to load the bridge
    private ServerPath serverPath;

    /**
     * Create the Bridge with a reference to the main {@link Activity} for the
     * app, and a reference to the {@link WebView} our app will use.
     * @param context
     * @param webView
     * @deprecated Use {@link Bridge.Builder} to create Bridge instances
     */
    @Deprecated
    public Bridge(
        AppCompatActivity context,
        WebView webView,
        List<Class<? extends Plugin>> initialPlugins,
        MockCordovaInterfaceImpl cordovaInterface,
        PluginManager pluginManager,
        CordovaPreferences preferences,
        CapConfig config
    ) {
        this(context, null, null, webView, initialPlugins, new ArrayList<>(), cordovaInterface, pluginManager, preferences, config);
    }

    private Bridge(
        AppCompatActivity context,
        ServerPath serverPath,
        Fragment fragment,
        WebView webView,
        List<Class<? extends Plugin>> initialPlugins,
        List<Plugin> pluginInstances,
        MockCordovaInterfaceImpl cordovaInterface,
        PluginManager pluginManager,
        CordovaPreferences preferences,
        CapConfig config
    ) {
        this.app = new App();
        this.serverPath = serverPath;
        this.context = context;
        this.fragment = fragment;
        this.webView = webView;
        this.webViewClient = new BridgeWebViewClient(this);
        this.initialPlugins = initialPlugins;
        this.pluginInstances = pluginInstances;
        this.cordovaInterface = cordovaInterface;
        this.preferences = preferences;

        // Start our plugin execution threads and handlers
        handlerThread.start();
        taskHandler = new Handler(handlerThread.getLooper());

        this.config = config != null ? config : CapConfig.loadDefault(getActivity());
        Logger.init(this.config);

        // Initialize web view and message handler for it
        this.initWebView();
        this.setAllowedOriginRules();
        this.msgHandler = new MessageHandler(this, webView, pluginManager);

        // Grab any intent info that our app was launched with
        Intent intent = context.getIntent();
        this.intentUri = intent.getData();
        // Register our core plugins
        this.registerAllPlugins();

        this.loadWebView();
    }

    private void setAllowedOriginRules() {
        String[] appAllowNavigationConfig = this.config.getAllowNavigation();
        String authority = this.getHost();
        String scheme = this.getScheme();
        allowedOriginRules.add(scheme + "://" + authority);
        if (this.getServerUrl() != null) {
            allowedOriginRules.add(this.getServerUrl());
        }
        if (appAllowNavigationConfig != null) {
            for (String allowNavigation : appAllowNavigationConfig) {
                if (!allowNavigation.startsWith("http")) {
                    allowedOriginRules.add("https://" + allowNavigation);
                } else {
                    allowedOriginRules.add(allowNavigation);
                }
            }
            authorities.addAll(Arrays.asList(appAllowNavigationConfig));
        }
        this.appAllowNavigationMask = HostMask.Parser.parse(appAllowNavigationConfig);
    }

    public App getApp() {
        return app;
    }

    private void loadWebView() {
        final boolean html5mode = this.config.isHTML5Mode();

        // Start the local web server
        localServer = new WebViewLocalServer(context, this, getJSInjector(), authorities, html5mode);
        localServer.hostAssets(DEFAULT_WEB_ASSET_DIR);

        Logger.debug("Loading app at " + appUrl);

        webView.setWebChromeClient(new BridgeWebChromeClient(this));
        webView.setWebViewClient(this.webViewClient);

        if (!isDeployDisabled() && !isNewBinary()) {
            SharedPreferences prefs = getContext()
                .getSharedPreferences(com.getcapacitor.plugin.WebView.WEBVIEW_PREFS_NAME, Activity.MODE_PRIVATE);
            String path = prefs.getString(com.getcapacitor.plugin.WebView.CAP_SERVER_PATH, null);
            if (path != null && !path.isEmpty() && new File(path).exists()) {
                setServerBasePath(path);
            }
        }
        if (!this.isMinimumWebViewInstalled()) {
            String errorUrl = this.getErrorUrl();
            if (errorUrl != null) {
                webView.loadUrl(errorUrl);
                return;
            } else {
                Logger.error(MINIMUM_ANDROID_WEBVIEW_ERROR);
            }
        }

        // If serverPath configured, start server based on provided path
        if (serverPath != null) {
            if (serverPath.getType() == ServerPath.PathType.ASSET_PATH) {
                setServerAssetPath(serverPath.getPath());
            } else {
                setServerBasePath(serverPath.getPath());
            }
        } else {
            // Get to work
            webView.loadUrl(appUrl);
        }
    }

    @SuppressLint("WebViewApiAvailability")
    public boolean isMinimumWebViewInstalled() {
        PackageManager pm = getContext().getPackageManager();

        // Check getCurrentWebViewPackage() directly if above Android 8
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
            PackageInfo info = WebView.getCurrentWebViewPackage();
            if (info.packageName.equals("com.huawei.webview")) {
                String majorVersionStr = info.versionName.split("\\.")[0];
                int majorVersion = Integer.parseInt(majorVersionStr);
                return majorVersion >= config.getMinHuaweiWebViewVersion();
            }
            String majorVersionStr = info.versionName.split("\\.")[0];
            int majorVersion = Integer.parseInt(majorVersionStr);
            return majorVersion >= config.getMinWebViewVersion();
        }

        // Otherwise manually check WebView versions
        try {
            String webViewPackage = "com.google.android.webview";
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
                webViewPackage = "com.android.chrome";
            }
            PackageInfo info = InternalUtils.getPackageInfo(pm, webViewPackage);
            String majorVersionStr = info.versionName.split("\\.")[0];
            int majorVersion = Integer.parseInt(majorVersionStr);
            return majorVersion >= config.getMinWebViewVersion();
        } catch (Exception ex) {
            Logger.warn("Unable to get package info for 'com.google.android.webview'" + ex.toString());
        }

        try {
            PackageInfo info = InternalUtils.getPackageInfo(pm, "com.android.webview");
            String majorVersionStr = info.versionName.split("\\.")[0];
            int majorVersion = Integer.parseInt(majorVersionStr);
            return majorVersion >= config.getMinWebViewVersion();
        } catch (Exception ex) {
            Logger.warn("Unable to get package info for 'com.android.webview'" + ex.toString());
        }

        // Could not detect any webview, return false
        return false;
    }

    public boolean launchIntent(Uri url) {
        /*
         * Give plugins the chance to handle the url
         */
        for (Map.Entry<String, PluginHandle> entry : plugins.entrySet()) {
            Plugin plugin = entry.getValue().getInstance();
            if (plugin != null) {
                Boolean shouldOverrideLoad = plugin.shouldOverrideLoad(url);
                if (shouldOverrideLoad != null) {
                    return shouldOverrideLoad;
                }
            }
        }

        if (url.getScheme().equals("data")) {
            return false;
        }

        Uri appUri = Uri.parse(appUrl);
        if (
            !(appUri.getHost().equals(url.getHost()) && url.getScheme().equals(appUri.getScheme())) &&
            !appAllowNavigationMask.matches(url.getHost())
        ) {
            try {
                Intent openIntent = new Intent(Intent.ACTION_VIEW, url);
                getContext().startActivity(openIntent);
            } catch (ActivityNotFoundException e) {
                // TODO - trigger an event
            }
            return true;
        }
        return false;
    }

    private boolean isNewBinary() {
        String versionCode = "";
        String versionName = "";
        SharedPreferences prefs = getContext()
            .getSharedPreferences(com.getcapacitor.plugin.WebView.WEBVIEW_PREFS_NAME, Activity.MODE_PRIVATE);
        String lastVersionCode = prefs.getString(LAST_BINARY_VERSION_CODE, null);
        String lastVersionName = prefs.getString(LAST_BINARY_VERSION_NAME, null);

        try {
            PackageManager pm = getContext().getPackageManager();
            PackageInfo pInfo = InternalUtils.getPackageInfo(pm, getContext().getPackageName());
            versionCode = Integer.toString((int) PackageInfoCompat.getLongVersionCode(pInfo));
            versionName = pInfo.versionName;
        } catch (Exception ex) {
            Logger.error("Unable to get package info", ex);
        }

        if (!versionCode.equals(lastVersionCode) || !versionName.equals(lastVersionName)) {
            SharedPreferences.Editor editor = prefs.edit();
            editor.putString(LAST_BINARY_VERSION_CODE, versionCode);
            editor.putString(LAST_BINARY_VERSION_NAME, versionName);
            editor.putString(com.getcapacitor.plugin.WebView.CAP_SERVER_PATH, "");
            editor.apply();
            return true;
        }
        return false;
    }

    public boolean isDeployDisabled() {
        return preferences.getBoolean("DisableDeploy", false);
    }

    public boolean shouldKeepRunning() {
        return preferences.getBoolean("KeepRunning", true);
    }

    public void handleAppUrlLoadError(Exception ex) {
        if (ex instanceof SocketTimeoutException) {
            Logger.error(
                "Unable to load app. Ensure the server is running at " +
                appUrl +
                ", or modify the " +
                "appUrl setting in capacitor.config.json (make sure to npx cap copy after to commit changes).",
                ex
            );
        }
    }

    public boolean isDevMode() {
        return (getActivity().getApplicationInfo().flags & ApplicationInfo.FLAG_DEBUGGABLE) != 0;
    }

    protected void setCordovaWebView(CordovaWebView cordovaWebView) {
        this.cordovaWebView = cordovaWebView;
    }

    /**
     * Get the Context for the App
     * @return
     */
    public Context getContext() {
        return this.context;
    }

    /**
     * Get the activity for the app
     * @return
     */
    public AppCompatActivity getActivity() {
        return this.context;
    }

    /**
     * Get the fragment for the app, if applicable. This will likely be null unless Capacitor
     * is being used embedded in a Native Android app.
     *
     * @return The fragment containing the Capacitor WebView.
     */
    public Fragment getFragment() {
        return this.fragment;
    }

    /**
     * Get the core WebView under Capacitor's control
     * @return
     */
    public WebView getWebView() {
        return this.webView;
    }

    /**
     * Get the URI that was used to launch the app (if any)
     * @return
     */
    public Uri getIntentUri() {
        return intentUri;
    }

    /**
     * Get scheme that is used to serve content
     * @return
     */
    public String getScheme() {
        return this.config.getAndroidScheme();
    }

    /**
     * Get host name that is used to serve content
     * @return
     */
    public String getHost() {
        return this.config.getHostname();
    }

    /**
     * Get the server url that is used to serve content
     * @return
     */
    public String getServerUrl() {
        return this.config.getServerUrl();
    }

    public String getErrorUrl() {
        String errorPath = this.config.getErrorPath();

        if (errorPath != null && !errorPath.trim().isEmpty()) {
            String authority = this.getHost();
            String scheme = this.getScheme();

            String localUrl = scheme + "://" + authority;

            return localUrl + "/" + errorPath;
        }

        return null;
    }

    public String getAppUrl() {
        return appUrl;
    }

    public CapConfig getConfig() {
        return this.config;
    }

    public void reset() {
        savedCalls = new HashMap<>();
    }

    /**
     * Initialize the WebView, setting required flags
     */
    @SuppressLint("SetJavaScriptEnabled")
    private void initWebView() {
        WebSettings settings = webView.getSettings();
        settings.setJavaScriptEnabled(true);
        settings.setDomStorageEnabled(true);
        settings.setGeolocationEnabled(true);
        settings.setDatabaseEnabled(true);
        settings.setMediaPlaybackRequiresUserGesture(false);
        settings.setJavaScriptCanOpenWindowsAutomatically(true);
        if (this.config.isMixedContentAllowed()) {
            settings.setMixedContentMode(WebSettings.MIXED_CONTENT_ALWAYS_ALLOW);
        }

        String appendUserAgent = this.config.getAppendedUserAgentString();
        if (appendUserAgent != null) {
            String defaultUserAgent = settings.getUserAgentString();
            settings.setUserAgentString(defaultUserAgent + " " + appendUserAgent);
        }
        String overrideUserAgent = this.config.getOverriddenUserAgentString();
        if (overrideUserAgent != null) {
            settings.setUserAgentString(overrideUserAgent);
        }

        String backgroundColor = this.config.getBackgroundColor();
        try {
            if (backgroundColor != null) {
                webView.setBackgroundColor(WebColor.parseColor(backgroundColor));
            }
        } catch (IllegalArgumentException ex) {
            Logger.debug("WebView background color not applied");
        }

        if (config.isInitialFocus()) {
            webView.requestFocusFromTouch();
        }

        WebView.setWebContentsDebuggingEnabled(this.config.isWebContentsDebuggingEnabled());

        appUrlConfig = this.getServerUrl();
        String authority = this.getHost();
        authorities.add(authority);
        String scheme = this.getScheme();

        localUrl = scheme + "://" + authority;

        if (appUrlConfig != null) {
            try {
                URL appUrlObject = new URL(appUrlConfig);
                authorities.add(appUrlObject.getAuthority());
            } catch (Exception ex) {
                Logger.error("Provided server url is invalid: " + ex.getMessage());
                return;
            }
            localUrl = appUrlConfig;
            appUrl = appUrlConfig;
        } else {
            appUrl = localUrl;
            // custom URL schemes requires path ending with /
            if (!scheme.equals(Bridge.CAPACITOR_HTTP_SCHEME) && !scheme.equals(CAPACITOR_HTTPS_SCHEME)) {
                appUrl += "/";
            }
        }

        String appUrlPath = this.config.getStartPath();
        if (appUrlPath != null && !appUrlPath.trim().isEmpty()) {
            appUrl += appUrlPath;
        }
    }

    /**
     * Register our core Plugin APIs
     */
    private void registerAllPlugins() {
        this.registerPlugin(com.getcapacitor.plugin.CapacitorCookies.class);
        this.registerPlugin(com.getcapacitor.plugin.WebView.class);
        this.registerPlugin(com.getcapacitor.plugin.CapacitorHttp.class);

        for (Class<? extends Plugin> pluginClass : this.initialPlugins) {
            this.registerPlugin(pluginClass);
        }

        for (Plugin plugin : pluginInstances) {
            registerPluginInstance(plugin);
        }
    }

    /**
     * Register additional plugins
     * @param pluginClasses the plugins to register
     */
    public void registerPlugins(Class<? extends Plugin>[] pluginClasses) {
        for (Class<? extends Plugin> plugin : pluginClasses) {
            this.registerPlugin(plugin);
        }
    }

    public void registerPluginInstances(Plugin[] pluginInstances) {
        for (Plugin plugin : pluginInstances) {
            this.registerPluginInstance(plugin);
        }
    }

    @SuppressWarnings("deprecation")
    private String getLegacyPluginName(Class<? extends Plugin> pluginClass) {
        NativePlugin legacyPluginAnnotation = pluginClass.getAnnotation(NativePlugin.class);
        if (legacyPluginAnnotation == null) {
            Logger.error("Plugin doesn't have the @CapacitorPlugin annotation. Please add it");
            return null;
        }

        return legacyPluginAnnotation.name();
    }

    /**
     * Register a plugin class
     * @param pluginClass a class inheriting from Plugin
     */
    public void registerPlugin(Class<? extends Plugin> pluginClass) {
        String pluginId = pluginId(pluginClass);
        if (pluginId == null) return;

        try {
            this.plugins.put(pluginId, new PluginHandle(this, pluginClass));
        } catch (InvalidPluginException ex) {
            logInvalidPluginException(pluginClass);
        } catch (PluginLoadException ex) {
            logPluginLoadException(pluginClass, ex);
        }
    }

    public void registerPluginInstance(Plugin plugin) {
        Class<? extends Plugin> clazz = plugin.getClass();
        String pluginId = pluginId(clazz);
        if (pluginId == null) return;

        try {
            this.plugins.put(pluginId, new PluginHandle(this, plugin));
        } catch (InvalidPluginException ex) {
            logInvalidPluginException(clazz);
        }
    }

    private String pluginId(Class<? extends Plugin> clazz) {
        String pluginName = pluginName(clazz);
        String pluginId = clazz.getSimpleName();
        if (pluginName == null) return null;

        if (!pluginName.equals("")) {
            pluginId = pluginName;
        }
        Logger.debug("Registering plugin instance: " + pluginId);
        return pluginId;
    }

    private String pluginName(Class<? extends Plugin> clazz) {
        String pluginName;
        CapacitorPlugin pluginAnnotation = clazz.getAnnotation(CapacitorPlugin.class);
        if (pluginAnnotation == null) {
            pluginName = this.getLegacyPluginName(clazz);
        } else {
            pluginName = pluginAnnotation.name();
        }

        return pluginName;
    }

    private void logInvalidPluginException(Class<? extends Plugin> clazz) {
        Logger.error(
            "NativePlugin " +
            clazz.getName() +
            " is invalid. Ensure the @CapacitorPlugin annotation exists on the plugin class and" +
            " the class extends Plugin"
        );
    }

    private void logPluginLoadException(Class<? extends Plugin> clazz, Exception ex) {
        Logger.error("NativePlugin " + clazz.getName() + " failed to load", ex);
    }

    public PluginHandle getPlugin(String pluginId) {
        return this.plugins.get(pluginId);
    }

    /**
     * Find the plugin handle that responds to the given request code. This will
     * fire after certain Android OS intent results/permission checks/etc.
     * @param requestCode
     * @return
     */
    @Deprecated
    @SuppressWarnings("deprecation")
    public PluginHandle getPluginWithRequestCode(int requestCode) {
        for (PluginHandle handle : this.plugins.values()) {
            int[] requestCodes;

            CapacitorPlugin pluginAnnotation = handle.getPluginAnnotation();
            if (pluginAnnotation == null) {
                // Check for legacy plugin annotation, @NativePlugin
                NativePlugin legacyPluginAnnotation = handle.getLegacyPluginAnnotation();
                if (legacyPluginAnnotation == null) {
                    continue;
                }

                if (legacyPluginAnnotation.permissionRequestCode() == requestCode) {
                    return handle;
                }

                requestCodes = legacyPluginAnnotation.requestCodes();

                for (int rc : requestCodes) {
                    if (rc == requestCode) {
                        return handle;
                    }
                }
            } else {
                requestCodes = pluginAnnotation.requestCodes();

                for (int rc : requestCodes) {
                    if (rc == requestCode) {
                        return handle;
                    }
                }
            }
        }
        return null;
    }

    /**
     * Call a method on a plugin.
     * @param pluginId the plugin id to use to lookup the plugin handle
     * @param methodName the name of the method to call
     * @param call the call object to pass to the method
     */
    public void callPluginMethod(String pluginId, final String methodName, final PluginCall call) {
        try {
            final PluginHandle plugin = this.getPlugin(pluginId);

            if (plugin == null) {
                Logger.error("unable to find plugin : " + pluginId);
                call.errorCallback("unable to find plugin : " + pluginId);
                return;
            }

            if (Logger.shouldLog()) {
                Logger.verbose(
                    "callback: " +
                    call.getCallbackId() +
                    ", pluginId: " +
                    plugin.getId() +
                    ", methodName: " +
                    methodName +
                    ", methodData: " +
                    call.getData().toString()
                );
            }

            Runnable currentThreadTask = () -> {
                try {
                    plugin.invoke(methodName, call);

                    if (call.isKeptAlive()) {
                        saveCall(call);
                    }
                } catch (PluginLoadException | InvalidPluginMethodException ex) {
                    Logger.error("Unable to execute plugin method", ex);
                } catch (Exception ex) {
                    Logger.error("Serious error executing plugin", ex);
                    throw new RuntimeException(ex);
                }
            };

            taskHandler.post(currentThreadTask);
        } catch (Exception ex) {
            Logger.error(Logger.tags("callPluginMethod"), "error : " + ex, null);
            call.errorCallback(ex.toString());
        }
    }

    /**
     * Evaluate JavaScript in the web view. This method
     * executes on the main thread automatically.
     * @param js the JS to execute
     * @param callback an optional ValueCallback that will synchronously receive a value
     *                 after calling the JS
     */
    public void eval(final String js, final ValueCallback<String> callback) {
        Handler mainHandler = new Handler(context.getMainLooper());
        mainHandler.post(() -> webView.evaluateJavascript(js, callback));
    }

    public void logToJs(final String message, final String level) {
        eval("window.Capacitor.logJs(\"" + message + "\", \"" + level + "\")", null);
    }

    public void logToJs(final String message) {
        logToJs(message, "log");
    }

    public void triggerJSEvent(final String eventName, final String target) {
        eval("window.Capacitor.triggerEvent(\"" + eventName + "\", \"" + target + "\")", s -> {});
    }

    public void triggerJSEvent(final String eventName, final String target, final String data) {
        eval("window.Capacitor.triggerEvent(\"" + eventName + "\", \"" + target + "\", " + data + ")", s -> {});
    }

    public void triggerWindowJSEvent(final String eventName) {
        this.triggerJSEvent(eventName, "window");
    }

    public void triggerWindowJSEvent(final String eventName, final String data) {
        this.triggerJSEvent(eventName, "window", data);
    }

    public void triggerDocumentJSEvent(final String eventName) {
        this.triggerJSEvent(eventName, "document");
    }

    public void triggerDocumentJSEvent(final String eventName, final String data) {
        this.triggerJSEvent(eventName, "document", data);
    }

    public void execute(Runnable runnable) {
        taskHandler.post(runnable);
    }

    public void executeOnMainThread(Runnable runnable) {
        Handler mainHandler = new Handler(context.getMainLooper());

        mainHandler.post(runnable);
    }

    /**
     * Retain a call between plugin invocations
     * @param call
     */
    public void saveCall(PluginCall call) {
        this.savedCalls.put(call.getCallbackId(), call);
    }

    /**
     * Get a retained plugin call
     * @param callbackId the callbackId to use to lookup the call with
     * @return the stored call
     */
    public PluginCall getSavedCall(String callbackId) {
        if (callbackId == null) {
            return null;
        }

        return this.savedCalls.get(callbackId);
    }

    PluginCall getPluginCallForLastActivity() {
        PluginCall pluginCallForLastActivity = this.pluginCallForLastActivity;
        this.pluginCallForLastActivity = null;
        return pluginCallForLastActivity;
    }

    void setPluginCallForLastActivity(PluginCall pluginCallForLastActivity) {
        this.pluginCallForLastActivity = pluginCallForLastActivity;
    }

    /**
     * Release a retained call
     * @param call a call to release
     */
    public void releaseCall(PluginCall call) {
        releaseCall(call.getCallbackId());
    }

    /**
     * Release a retained call by its ID
     * @param callbackId an ID of a callback to release
     */
    public void releaseCall(String callbackId) {
        this.savedCalls.remove(callbackId);
    }

    /**
     * Removes the earliest saved call prior to a permissions request for a given plugin and
     * returns it.
     *
     * @return The saved plugin call
     */
    protected PluginCall getPermissionCall(String pluginId) {
        LinkedList<String> permissionCallIds = this.savedPermissionCallIds.get(pluginId);
        String savedCallId = null;
        if (permissionCallIds != null) {
            savedCallId = permissionCallIds.poll();
        }

        return getSavedCall(savedCallId);
    }

    /**
     * Save a call to be retrieved after requesting permissions. Calls are saved in order.
     *
     * @param call The plugin call to save.
     */
    protected void savePermissionCall(PluginCall call) {
        if (call != null) {
            if (!savedPermissionCallIds.containsKey(call.getPluginId())) {
                savedPermissionCallIds.put(call.getPluginId(), new LinkedList<>());
            }

            savedPermissionCallIds.get(call.getPluginId()).add(call.getCallbackId());
            saveCall(call);
        }
    }

    /**
     * Register an Activity Result Launcher to the containing Fragment or Activity.
     *
     * @param contract A contract specifying that an activity can be called with an input of
     *                 type I and produce an output of type O.
     * @param callback The callback run on Activity Result.
     * @return A registered Activity Result Launcher.
     */
    public <I, O> ActivityResultLauncher<I> registerForActivityResult(
        @NonNull final ActivityResultContract<I, O> contract,
        @NonNull final ActivityResultCallback<O> callback
    ) {
        if (fragment != null) {
            return fragment.registerForActivityResult(contract, callback);
        } else {
            return context.registerForActivityResult(contract, callback);
        }
    }

    /**
     * Build the JSInjector that will be used to inject JS into files served to the app,
     * to ensure that Capacitor's JS and the JS for all the plugins is loaded each time.
     */
    private JSInjector getJSInjector() {
        try {
            String globalJS = JSExport.getGlobalJS(context, config.isLoggingEnabled(), isDevMode());
            String bridgeJS = JSExport.getBridgeJS(context);
            String pluginJS = JSExport.getPluginJS(plugins.values());
            String cordovaJS = JSExport.getCordovaJS(context);
            String cordovaPluginsJS = JSExport.getCordovaPluginJS(context);
            String cordovaPluginsFileJS = JSExport.getCordovaPluginsFileJS(context);
            String localUrlJS = "window.WEBVIEW_SERVER_URL = '" + localUrl + "';";

            return new JSInjector(globalJS, bridgeJS, pluginJS, cordovaJS, cordovaPluginsJS, cordovaPluginsFileJS, localUrlJS);
        } catch (Exception ex) {
            Logger.error("Unable to export Capacitor JS. App will not function!", ex);
        }
        return null;
    }

    /**
     * Restore any saved bundle state data
     * @param savedInstanceState
     */
    public void restoreInstanceState(Bundle savedInstanceState) {
        String lastPluginId = savedInstanceState.getString(BUNDLE_LAST_PLUGIN_ID_KEY);
        String lastPluginCallMethod = savedInstanceState.getString(BUNDLE_LAST_PLUGIN_CALL_METHOD_NAME_KEY);
        String lastOptionsJson = savedInstanceState.getString(BUNDLE_PLUGIN_CALL_OPTIONS_SAVED_KEY);

        if (lastPluginId != null) {
            // If we have JSON blob saved, create a new plugin call with the original options
            if (lastOptionsJson != null) {
                try {
                    JSObject options = new JSObject(lastOptionsJson);

                    pluginCallForLastActivity =
                        new PluginCall(msgHandler, lastPluginId, PluginCall.CALLBACK_ID_DANGLING, lastPluginCallMethod, options);
                } catch (JSONException ex) {
                    Logger.error("Unable to restore plugin call, unable to parse persisted JSON object", ex);
                }
            }

            // Let the plugin restore any state it needs
            Bundle bundleData = savedInstanceState.getBundle(BUNDLE_PLUGIN_CALL_BUNDLE_KEY);
            PluginHandle lastPlugin = getPlugin(lastPluginId);
            if (bundleData != null && lastPlugin != null) {
                lastPlugin.getInstance().restoreState(bundleData);
            } else {
                Logger.error("Unable to restore last plugin call");
            }
        }
    }

    public void saveInstanceState(Bundle outState) {
        Logger.debug("Saving instance state!");

        // If there was a last PluginCall for a started activity, we need to
        // persist it so we can load it again in case our app gets terminated
        if (pluginCallForLastActivity != null) {
            PluginCall call = pluginCallForLastActivity;
            PluginHandle handle = getPlugin(call.getPluginId());

            if (handle != null) {
                Bundle bundle = handle.getInstance().saveInstanceState();
                if (bundle != null) {
                    outState.putString(BUNDLE_LAST_PLUGIN_ID_KEY, call.getPluginId());
                    outState.putString(BUNDLE_LAST_PLUGIN_CALL_METHOD_NAME_KEY, call.getMethodName());
                    outState.putString(BUNDLE_PLUGIN_CALL_OPTIONS_SAVED_KEY, call.getData().toString());
                    outState.putBundle(BUNDLE_PLUGIN_CALL_BUNDLE_KEY, bundle);
                } else {
                    Logger.error("Couldn't save last " + call.getPluginId() + "'s Plugin " + call.getMethodName() + " call");
                }
            }
        }
    }

    @Deprecated
    @SuppressWarnings("deprecation")
    public void startActivityForPluginWithResult(PluginCall call, Intent intent, int requestCode) {
        Logger.debug("Starting activity for result");

        pluginCallForLastActivity = call;

        getActivity().startActivityForResult(intent, requestCode);
    }

    /**
     * Check for legacy Capacitor or Cordova plugins that may have registered to handle a permission
     * request, and handle them if so. If not handled, false is returned.
     *
     * @param requestCode the code that was requested
     * @param permissions the permissions requested
     * @param grantResults the set of granted/denied permissions
     * @return true if permission code was handled by a plugin explicitly, false if not
     */
    @SuppressWarnings("deprecation")
    boolean onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) {
        PluginHandle plugin = getPluginWithRequestCode(requestCode);

        if (plugin == null) {
            boolean permissionHandled = false;
            Logger.debug("Unable to find a Capacitor plugin to handle permission requestCode, trying Cordova plugins " + requestCode);
            try {
                permissionHandled = cordovaInterface.handlePermissionResult(requestCode, permissions, grantResults);
            } catch (JSONException e) {
                Logger.debug("Error on Cordova plugin permissions request " + e.getMessage());
            }
            return permissionHandled;
        }

        // Call deprecated method if using deprecated NativePlugin annotation
        if (plugin.getPluginAnnotation() == null) {
            plugin.getInstance().handleRequestPermissionsResult(requestCode, permissions, grantResults);
            return true;
        }

        return false;
    }

    /**
     * Saves permission states and rejects if permissions were not correctly defined in
     * the AndroidManifest.xml file.
     *
     * @param plugin
     * @param savedCall
     * @param permissions
     * @return true if permissions were saved and defined correctly, false if not
     */
    protected boolean validatePermissions(Plugin plugin, PluginCall savedCall, Map<String, Boolean> permissions) {
        SharedPreferences prefs = getContext().getSharedPreferences(PERMISSION_PREFS_NAME, Activity.MODE_PRIVATE);

        for (Map.Entry<String, Boolean> permission : permissions.entrySet()) {
            String permString = permission.getKey();
            boolean isGranted = permission.getValue();

            if (isGranted) {
                // Permission granted. If previously denied, remove cached state
                String state = prefs.getString(permString, null);

                if (state != null) {
                    SharedPreferences.Editor editor = prefs.edit();
                    editor.remove(permString);
                    editor.apply();
                }
            } else {
                SharedPreferences.Editor editor = prefs.edit();

                if (ActivityCompat.shouldShowRequestPermissionRationale(getActivity(), permString)) {
                    // Permission denied, can prompt again with rationale
                    editor.putString(permString, PermissionState.PROMPT_WITH_RATIONALE.toString());
                } else {
                    // Permission denied permanently, store this state for future reference
                    editor.putString(permString, PermissionState.DENIED.toString());
                }

                editor.apply();
            }
        }

        String[] permStrings = permissions.keySet().toArray(new String[0]);

        if (!PermissionHelper.hasDefinedPermissions(getContext(), permStrings)) {
            StringBuilder builder = new StringBuilder();
            builder.append("Missing the following permissions in AndroidManifest.xml:\n");
            String[] missing = PermissionHelper.getUndefinedPermissions(getContext(), permStrings);
            for (String perm : missing) {
                builder.append(perm + "\n");
            }
            savedCall.reject(builder.toString());
            return false;
        }

        return true;
    }

    /**
     * Helper to check all permissions and see the current states of each permission.
     *
     * @since 3.0.0
     * @return A mapping of permission aliases to the associated granted status.
     */
    protected Map<String, PermissionState> getPermissionStates(Plugin plugin) {
        Map<String, PermissionState> permissionsResults = new HashMap<>();
        CapacitorPlugin annotation = plugin.getPluginHandle().getPluginAnnotation();
        for (Permission perm : annotation.permissions()) {
            // If a permission is defined with no permission constants, return GRANTED for it.
            // Otherwise, get its true state.
            if (perm.strings().length == 0 || (perm.strings().length == 1 && perm.strings()[0].isEmpty())) {
                String key = perm.alias();
                if (!key.isEmpty()) {
                    PermissionState existingResult = permissionsResults.get(key);

                    // auto set permission state to GRANTED if the alias is empty.
                    if (existingResult == null) {
                        permissionsResults.put(key, PermissionState.GRANTED);
                    }
                }
            } else {
                for (String permString : perm.strings()) {
                    String key = perm.alias().isEmpty() ? permString : perm.alias();
                    PermissionState permissionStatus;
                    if (ActivityCompat.checkSelfPermission(this.getContext(), permString) == PackageManager.PERMISSION_GRANTED) {
                        permissionStatus = PermissionState.GRANTED;
                    } else {
                        permissionStatus = PermissionState.PROMPT;

                        // Check if there is a cached permission state for the "Never ask again" state
                        SharedPreferences prefs = getContext().getSharedPreferences(PERMISSION_PREFS_NAME, Activity.MODE_PRIVATE);
                        String state = prefs.getString(permString, null);

                        if (state != null) {
                            permissionStatus = PermissionState.byState(state);
                        }
                    }

                    PermissionState existingResult = permissionsResults.get(key);

                    // multiple permissions with the same alias must all be true, otherwise all false.
                    if (existingResult == null || existingResult == PermissionState.GRANTED) {
                        permissionsResults.put(key, permissionStatus);
                    }
                }
            }
        }

        return permissionsResults;
    }

    /**
     * Handle an activity result and pass it to a plugin that has indicated it wants to
     * handle the result.
     * @param requestCode
     * @param resultCode
     * @param data
     */
    @SuppressWarnings("deprecation")
    boolean onActivityResult(int requestCode, int resultCode, Intent data) {
        PluginHandle plugin = getPluginWithRequestCode(requestCode);

        if (plugin == null || plugin.getInstance() == null) {
            Logger.debug("Unable to find a Capacitor plugin to handle requestCode, trying Cordova plugins " + requestCode);
            return cordovaInterface.onActivityResult(requestCode, resultCode, data);
        }

        // deprecated, to be removed
        PluginCall lastCall = plugin.getInstance().getSavedCall();

        // If we don't have a saved last call (because our app was killed and restarted, for example),
        // Then we should see if we have any saved plugin call information and generate a new,
        // "dangling" plugin call (a plugin call that doesn't have a corresponding web callback)
        // and then send that to the plugin
        if (lastCall == null && pluginCallForLastActivity != null) {
            plugin.getInstance().saveCall(pluginCallForLastActivity);
        }

        plugin.getInstance().handleOnActivityResult(requestCode, resultCode, data);

        // Clear the plugin call we may have re-hydrated on app launch
        pluginCallForLastActivity = null;

        return true;
    }

    /**
     * Handle an onNewIntent lifecycle event and notify the plugins
     * @param intent
     */
    public void onNewIntent(Intent intent) {
        for (PluginHandle plugin : plugins.values()) {
            plugin.getInstance().handleOnNewIntent(intent);
        }

        if (cordovaWebView != null) {
            cordovaWebView.onNewIntent(intent);
        }
    }

    /**
     * Handle an onConfigurationChanged event and notify the plugins
     * @param newConfig
     */
    public void onConfigurationChanged(Configuration newConfig) {
        for (PluginHandle plugin : plugins.values()) {
            plugin.getInstance().handleOnConfigurationChanged(newConfig);
        }
    }

    /**
     * Handle onRestart lifecycle event and notify the plugins
     */
    public void onRestart() {
        for (PluginHandle plugin : plugins.values()) {
            plugin.getInstance().handleOnRestart();
        }
    }

    /**
     * Handle onStart lifecycle event and notify the plugins
     */
    public void onStart() {
        for (PluginHandle plugin : plugins.values()) {
            plugin.getInstance().handleOnStart();
        }

        if (cordovaWebView != null) {
            cordovaWebView.handleStart();
        }
    }

    /**
     * Handle onResume lifecycle event and notify the plugins
     */
    public void onResume() {
        for (PluginHandle plugin : plugins.values()) {
            plugin.getInstance().handleOnResume();
        }

        if (cordovaWebView != null) {
            cordovaWebView.handleResume(this.shouldKeepRunning());
        }
    }

    /**
     * Handle onPause lifecycle event and notify the plugins
     */
    public void onPause() {
        for (PluginHandle plugin : plugins.values()) {
            plugin.getInstance().handleOnPause();
        }

        if (cordovaWebView != null) {
            boolean keepRunning = this.shouldKeepRunning() || cordovaInterface.getActivityResultCallback() != null;
            cordovaWebView.handlePause(keepRunning);
        }
    }

    /**
     * Handle onStop lifecycle event and notify the plugins
     */
    public void onStop() {
        for (PluginHandle plugin : plugins.values()) {
            plugin.getInstance().handleOnStop();
        }

        if (cordovaWebView != null) {
            cordovaWebView.handleStop();
        }
    }

    /**
     * Handle onDestroy lifecycle event and notify the plugins
     */
    public void onDestroy() {
        for (PluginHandle plugin : plugins.values()) {
            plugin.getInstance().handleOnDestroy();
        }

        handlerThread.quitSafely();

        if (cordovaWebView != null) {
            cordovaWebView.handleDestroy();
        }
    }

    /**
     * Handle onDetachedFromWindow lifecycle event
     */
    public void onDetachedFromWindow() {
        webView.removeAllViews();
        webView.destroy();
    }

    public String getServerBasePath() {
        return this.localServer.getBasePath();
    }

    /**
     * Tell the local server to load files from the given
     * file path instead of the assets path.
     * @param path
     */
    public void setServerBasePath(String path) {
        localServer.hostFiles(path);
        webView.post(() -> webView.loadUrl(appUrl));
    }

    /**
     * Tell the local server to load files from the given
     * asset path.
     * @param path
     */
    public void setServerAssetPath(String path) {
        localServer.hostAssets(path);
        webView.post(() -> webView.loadUrl(appUrl));
    }

    /**
     * Reload the WebView
     */
    public void reload() {
        webView.post(() -> webView.loadUrl(appUrl));
    }

    public String getLocalUrl() {
        return localUrl;
    }

    public WebViewLocalServer getLocalServer() {
        return localServer;
    }

    public HostMask getAppAllowNavigationMask() {
        return appAllowNavigationMask;
    }

    public Set<String> getAllowedOriginRules() {
        return allowedOriginRules;
    }

    public BridgeWebViewClient getWebViewClient() {
        return this.webViewClient;
    }

    public void setWebViewClient(BridgeWebViewClient client) {
        this.webViewClient = client;
        webView.setWebViewClient(client);
    }

    List<WebViewListener> getWebViewListeners() {
        return webViewListeners;
    }

    void setWebViewListeners(List<WebViewListener> webViewListeners) {
        this.webViewListeners = webViewListeners;
    }

    RouteProcessor getRouteProcessor() {
        return routeProcessor;
    }

    void setRouteProcessor(RouteProcessor routeProcessor) {
        this.routeProcessor = routeProcessor;
    }

    ServerPath getServerPath() {
        return serverPath;
    }

    /**
     * Add a listener that the WebViewClient can trigger on certain events.
     * @param webViewListener A {@link WebViewListener} to add.
     */
    public void addWebViewListener(WebViewListener webViewListener) {
        webViewListeners.add(webViewListener);
    }

    /**
     * Remove a listener that the WebViewClient triggers on certain events.
     * @param webViewListener A {@link WebViewListener} to remove.
     */
    public void removeWebViewListener(WebViewListener webViewListener) {
        webViewListeners.remove(webViewListener);
    }

    public static class Builder {

        private Bundle instanceState = null;
        private CapConfig config = null;
        private List<Class<? extends Plugin>> plugins = new ArrayList<>();
        private List<Plugin> pluginInstances = new ArrayList<>();
        private AppCompatActivity activity;
        private Fragment fragment;
        private RouteProcessor routeProcessor;
        private final List<WebViewListener> webViewListeners = new ArrayList<>();
        private ServerPath serverPath;

        public Builder(AppCompatActivity activity) {
            this.activity = activity;
        }

        public Builder(Fragment fragment) {
            this.activity = (AppCompatActivity) fragment.getActivity();
            this.fragment = fragment;
        }

        public Builder setInstanceState(Bundle instanceState) {
            this.instanceState = instanceState;
            return this;
        }

        public Builder setConfig(CapConfig config) {
            this.config = config;
            return this;
        }

        public Builder setPlugins(List<Class<? extends Plugin>> plugins) {
            this.plugins = plugins;
            return this;
        }

        public Builder addPlugin(Class<? extends Plugin> plugin) {
            this.plugins.add(plugin);
            return this;
        }

        public Builder addPlugins(List<Class<? extends Plugin>> plugins) {
            for (Class<? extends Plugin> cls : plugins) {
                this.addPlugin(cls);
            }

            return this;
        }

        public Builder addPluginInstance(Plugin plugin) {
            this.pluginInstances.add(plugin);
            return this;
        }

        public Builder addPluginInstances(List<Plugin> plugins) {
            this.pluginInstances.addAll(plugins);
            return this;
        }

        public Builder addWebViewListener(WebViewListener webViewListener) {
            webViewListeners.add(webViewListener);
            return this;
        }

        public Builder addWebViewListeners(List<WebViewListener> webViewListeners) {
            for (WebViewListener listener : webViewListeners) {
                this.addWebViewListener(listener);
            }

            return this;
        }

        public Builder setRouteProcessor(RouteProcessor routeProcessor) {
            this.routeProcessor = routeProcessor;
            return this;
        }

        public Builder setServerPath(ServerPath serverPath) {
            this.serverPath = serverPath;
            return this;
        }

        public Bridge create() {
            // Cordova initialization
            ConfigXmlParser parser = new ConfigXmlParser();
            parser.parse(activity.getApplicationContext());
            CordovaPreferences preferences = parser.getPreferences();
            preferences.setPreferencesBundle(activity.getIntent().getExtras());
            List<PluginEntry> pluginEntries = parser.getPluginEntries();

            MockCordovaInterfaceImpl cordovaInterface = new MockCordovaInterfaceImpl(activity);
            if (instanceState != null) {
                cordovaInterface.restoreInstanceState(instanceState);
            }

            WebView webView = this.fragment != null ? fragment.getView().findViewById(R.id.webview) : activity.findViewById(R.id.webview);
            MockCordovaWebViewImpl mockWebView = new MockCordovaWebViewImpl(activity.getApplicationContext());
            mockWebView.init(cordovaInterface, pluginEntries, preferences, webView);
            PluginManager pluginManager = mockWebView.getPluginManager();
            cordovaInterface.onCordovaInit(pluginManager);

            // Bridge initialization
            Bridge bridge = new Bridge(
                activity,
                serverPath,
                fragment,
                webView,
                plugins,
                pluginInstances,
                cordovaInterface,
                pluginManager,
                preferences,
                config
            );

            if (webView instanceof CapacitorWebView) {
                CapacitorWebView capacitorWebView = (CapacitorWebView) webView;
                capacitorWebView.setBridge(bridge);
            }

            bridge.setCordovaWebView(mockWebView);
            bridge.setWebViewListeners(webViewListeners);
            bridge.setRouteProcessor(routeProcessor);

            if (instanceState != null) {
                bridge.restoreInstanceState(instanceState);
            }

            return bridge;
        }
    }
}
