From b3ee9950223bc4428318e8fe5b22daa37dd7e317 Mon Sep 17 00:00:00 2001 From: Tachibana Shin Date: Mon, 5 Aug 2024 08:00:20 +0000 Subject: [PATCH] Add initial implementation of Capacitor Android bridge and plugin system - Introduced `Bridge` class to manage loading and communication with plugins, proxying native events, and executing plugin methods. - Added `Plugin` base class for creating new plugins with convenient features for interacting with the `Bridge`. - Implemented `WebViewLocalServer` to host assets and resources on virtual URLs compatible with the Same-Origin policy. - Created `BridgeFragment` to facilitate the use of the `Bridge` within a fragment. - Added various utility classes and methods to support the bridge and plugin functionalities. - Removed deprecated Java files and replaced them with Kotlin implementations. --- @capacitor/android/capacitor/build.gradle | 5 + .../main/java/com/getcapacitor/Bridge.java | 1568 ----------------- .../src/main/java/com/getcapacitor/Bridge.kt | 1561 ++++++++++++++++ .../java/com/getcapacitor/BridgeActivity.java | 197 --- .../java/com/getcapacitor/BridgeActivity.kt | 185 ++ .../java/com/getcapacitor/BridgeFragment.java | 134 -- .../java/com/getcapacitor/BridgeFragment.kt | 124 ++ .../getcapacitor/BridgeWebChromeClient.java | 510 ------ .../com/getcapacitor/BridgeWebChromeClient.kt | 534 ++++++ .../com/getcapacitor/BridgeWebViewClient.java | 111 -- .../com/getcapacitor/BridgeWebViewClient.kt | 106 ++ .../main/java/com/getcapacitor/CapConfig.java | 670 ------- .../main/java/com/getcapacitor/CapConfig.kt | 650 +++++++ .../com/getcapacitor/CapacitorWebView.java | 52 - .../java/com/getcapacitor/CapacitorWebView.kt | 49 + .../main/java/com/getcapacitor/FileUtils.java | 292 --- .../main/java/com/getcapacitor/FileUtils.kt | 287 +++ .../getcapacitor/InvalidPluginException.java | 8 - .../getcapacitor/InvalidPluginException.kt | 3 + .../InvalidPluginMethodException.java | 16 - .../InvalidPluginMethodException.kt | 9 + .../main/java/com/getcapacitor/JSArray.java | 51 - .../src/main/java/com/getcapacitor/JSArray.kt | 42 + .../main/java/com/getcapacitor/JSExport.java | 193 -- .../main/java/com/getcapacitor/JSExport.kt | 183 ++ .../com/getcapacitor/JSExportException.java | 16 - .../com/getcapacitor/JSExportException.kt | 9 + .../java/com/getcapacitor/JSInjector.java | 107 -- .../main/java/com/getcapacitor/JSInjector.kt | 100 ++ .../main/java/com/getcapacitor/JSObject.java | 164 -- .../main/java/com/getcapacitor/JSObject.kt | 155 ++ .../main/java/com/getcapacitor/JSValue.java | 65 - .../src/main/java/com/getcapacitor/JSValue.kt | 62 + .../main/java/com/getcapacitor/Logger.java | 103 -- .../src/main/java/com/getcapacitor/Logger.kt | 109 ++ .../java/com/getcapacitor/MessageHandler.java | 159 -- .../java/com/getcapacitor/MessageHandler.kt | 176 ++ .../{NativePlugin.java => NativePlugin.kt} | 27 +- .../com/getcapacitor/PermissionState.java | 31 - .../java/com/getcapacitor/PermissionState.kt | 25 + .../main/java/com/getcapacitor/Plugin.java | 1046 ----------- .../src/main/java/com/getcapacitor/Plugin.kt | 1077 +++++++++++ .../java/com/getcapacitor/PluginCall.java | 440 ----- .../main/java/com/getcapacitor/PluginCall.kt | 333 ++++ .../{PluginConfig.java => PluginConfig.kt} | 78 +- .../java/com/getcapacitor/PluginHandle.java | 160 -- .../java/com/getcapacitor/PluginHandle.kt | 137 ++ .../PluginInvocationException.java | 16 - .../getcapacitor/PluginInvocationException.kt | 9 + .../com/getcapacitor/PluginLoadException.java | 19 - .../com/getcapacitor/PluginLoadException.kt | 12 + .../java/com/getcapacitor/PluginManager.java | 56 - .../java/com/getcapacitor/PluginManager.kt | 54 + .../java/com/getcapacitor/PluginMethod.java | 15 - .../java/com/getcapacitor/PluginMethod.kt | 12 + .../com/getcapacitor/PluginMethodHandle.java | 33 - .../com/getcapacitor/PluginMethodHandle.kt | 15 + .../java/com/getcapacitor/PluginResult.java | 84 - .../java/com/getcapacitor/PluginResult.kt | 74 + .../java/com/getcapacitor/ProcessedRoute.java | 37 - .../java/com/getcapacitor/ProcessedRoute.kt | 12 + .../java/com/getcapacitor/RouteProcessor.java | 8 - .../java/com/getcapacitor/RouteProcessor.kt | 8 + .../java/com/getcapacitor/ServerPath.java | 25 - .../main/java/com/getcapacitor/ServerPath.kt | 8 + .../java/com/getcapacitor/UriMatcher.java | 180 -- .../main/java/com/getcapacitor/UriMatcher.kt | 169 ++ ...ebViewListener.java => WebViewListener.kt} | 23 +- .../com/getcapacitor/WebViewLocalServer.java | 878 --------- .../com/getcapacitor/WebViewLocalServer.kt | 852 +++++++++ .../getcapacitor/plugin/CapacitorCookies.java | 4 +- .../getcapacitor/plugin/CapacitorHttp.java | 13 +- .../plugin/util/HttpRequestHandler.java | 6 +- bun.lockb | Bin 659194 -> 659586 bytes src-capacitor/android/app/build.gradle | 7 + .../java/git/shin/animevsub/MainActivity.java | 24 - .../java/git/shin/animevsub/MainActivity.kt | 19 + .../git/shin/animevsub/ResolvePlugin.java | 112 -- .../java/git/shin/animevsub/ResolvePlugin.kt | 105 ++ src-capacitor/android/build.gradle | 6 +- .../android/capacitor.settings.gradle | 24 +- src-capacitor/bun.lockb | Bin 42070 -> 42040 bytes 82 files changed, 7360 insertions(+), 7678 deletions(-) delete mode 100644 @capacitor/android/capacitor/src/main/java/com/getcapacitor/Bridge.java create mode 100644 @capacitor/android/capacitor/src/main/java/com/getcapacitor/Bridge.kt delete mode 100644 @capacitor/android/capacitor/src/main/java/com/getcapacitor/BridgeActivity.java create mode 100644 @capacitor/android/capacitor/src/main/java/com/getcapacitor/BridgeActivity.kt delete mode 100644 @capacitor/android/capacitor/src/main/java/com/getcapacitor/BridgeFragment.java create mode 100644 @capacitor/android/capacitor/src/main/java/com/getcapacitor/BridgeFragment.kt delete mode 100644 @capacitor/android/capacitor/src/main/java/com/getcapacitor/BridgeWebChromeClient.java create mode 100644 @capacitor/android/capacitor/src/main/java/com/getcapacitor/BridgeWebChromeClient.kt delete mode 100644 @capacitor/android/capacitor/src/main/java/com/getcapacitor/BridgeWebViewClient.java create mode 100644 @capacitor/android/capacitor/src/main/java/com/getcapacitor/BridgeWebViewClient.kt delete mode 100644 @capacitor/android/capacitor/src/main/java/com/getcapacitor/CapConfig.java create mode 100644 @capacitor/android/capacitor/src/main/java/com/getcapacitor/CapConfig.kt delete mode 100644 @capacitor/android/capacitor/src/main/java/com/getcapacitor/CapacitorWebView.java create mode 100644 @capacitor/android/capacitor/src/main/java/com/getcapacitor/CapacitorWebView.kt delete mode 100644 @capacitor/android/capacitor/src/main/java/com/getcapacitor/FileUtils.java create mode 100644 @capacitor/android/capacitor/src/main/java/com/getcapacitor/FileUtils.kt delete mode 100644 @capacitor/android/capacitor/src/main/java/com/getcapacitor/InvalidPluginException.java create mode 100644 @capacitor/android/capacitor/src/main/java/com/getcapacitor/InvalidPluginException.kt delete mode 100644 @capacitor/android/capacitor/src/main/java/com/getcapacitor/InvalidPluginMethodException.java create mode 100644 @capacitor/android/capacitor/src/main/java/com/getcapacitor/InvalidPluginMethodException.kt delete mode 100644 @capacitor/android/capacitor/src/main/java/com/getcapacitor/JSArray.java create mode 100644 @capacitor/android/capacitor/src/main/java/com/getcapacitor/JSArray.kt delete mode 100644 @capacitor/android/capacitor/src/main/java/com/getcapacitor/JSExport.java create mode 100644 @capacitor/android/capacitor/src/main/java/com/getcapacitor/JSExport.kt delete mode 100644 @capacitor/android/capacitor/src/main/java/com/getcapacitor/JSExportException.java create mode 100644 @capacitor/android/capacitor/src/main/java/com/getcapacitor/JSExportException.kt delete mode 100644 @capacitor/android/capacitor/src/main/java/com/getcapacitor/JSInjector.java create mode 100644 @capacitor/android/capacitor/src/main/java/com/getcapacitor/JSInjector.kt delete mode 100644 @capacitor/android/capacitor/src/main/java/com/getcapacitor/JSObject.java create mode 100644 @capacitor/android/capacitor/src/main/java/com/getcapacitor/JSObject.kt delete mode 100644 @capacitor/android/capacitor/src/main/java/com/getcapacitor/JSValue.java create mode 100644 @capacitor/android/capacitor/src/main/java/com/getcapacitor/JSValue.kt delete mode 100644 @capacitor/android/capacitor/src/main/java/com/getcapacitor/Logger.java create mode 100644 @capacitor/android/capacitor/src/main/java/com/getcapacitor/Logger.kt delete mode 100644 @capacitor/android/capacitor/src/main/java/com/getcapacitor/MessageHandler.java create mode 100644 @capacitor/android/capacitor/src/main/java/com/getcapacitor/MessageHandler.kt rename @capacitor/android/capacitor/src/main/java/com/getcapacitor/{NativePlugin.java => NativePlugin.kt} (53%) delete mode 100644 @capacitor/android/capacitor/src/main/java/com/getcapacitor/PermissionState.java create mode 100644 @capacitor/android/capacitor/src/main/java/com/getcapacitor/PermissionState.kt delete mode 100644 @capacitor/android/capacitor/src/main/java/com/getcapacitor/Plugin.java create mode 100644 @capacitor/android/capacitor/src/main/java/com/getcapacitor/Plugin.kt delete mode 100644 @capacitor/android/capacitor/src/main/java/com/getcapacitor/PluginCall.java create mode 100644 @capacitor/android/capacitor/src/main/java/com/getcapacitor/PluginCall.kt rename @capacitor/android/capacitor/src/main/java/com/getcapacitor/{PluginConfig.java => PluginConfig.kt} (60%) delete mode 100644 @capacitor/android/capacitor/src/main/java/com/getcapacitor/PluginHandle.java create mode 100644 @capacitor/android/capacitor/src/main/java/com/getcapacitor/PluginHandle.kt delete mode 100644 @capacitor/android/capacitor/src/main/java/com/getcapacitor/PluginInvocationException.java create mode 100644 @capacitor/android/capacitor/src/main/java/com/getcapacitor/PluginInvocationException.kt delete mode 100644 @capacitor/android/capacitor/src/main/java/com/getcapacitor/PluginLoadException.java create mode 100644 @capacitor/android/capacitor/src/main/java/com/getcapacitor/PluginLoadException.kt delete mode 100644 @capacitor/android/capacitor/src/main/java/com/getcapacitor/PluginManager.java create mode 100644 @capacitor/android/capacitor/src/main/java/com/getcapacitor/PluginManager.kt delete mode 100644 @capacitor/android/capacitor/src/main/java/com/getcapacitor/PluginMethod.java create mode 100644 @capacitor/android/capacitor/src/main/java/com/getcapacitor/PluginMethod.kt delete mode 100644 @capacitor/android/capacitor/src/main/java/com/getcapacitor/PluginMethodHandle.java create mode 100644 @capacitor/android/capacitor/src/main/java/com/getcapacitor/PluginMethodHandle.kt delete mode 100644 @capacitor/android/capacitor/src/main/java/com/getcapacitor/PluginResult.java create mode 100644 @capacitor/android/capacitor/src/main/java/com/getcapacitor/PluginResult.kt delete mode 100644 @capacitor/android/capacitor/src/main/java/com/getcapacitor/ProcessedRoute.java create mode 100644 @capacitor/android/capacitor/src/main/java/com/getcapacitor/ProcessedRoute.kt delete mode 100644 @capacitor/android/capacitor/src/main/java/com/getcapacitor/RouteProcessor.java create mode 100644 @capacitor/android/capacitor/src/main/java/com/getcapacitor/RouteProcessor.kt delete mode 100644 @capacitor/android/capacitor/src/main/java/com/getcapacitor/ServerPath.java create mode 100644 @capacitor/android/capacitor/src/main/java/com/getcapacitor/ServerPath.kt delete mode 100644 @capacitor/android/capacitor/src/main/java/com/getcapacitor/UriMatcher.java create mode 100644 @capacitor/android/capacitor/src/main/java/com/getcapacitor/UriMatcher.kt rename @capacitor/android/capacitor/src/main/java/com/getcapacitor/{WebViewListener.java => WebViewListener.kt} (67%) delete mode 100644 @capacitor/android/capacitor/src/main/java/com/getcapacitor/WebViewLocalServer.java create mode 100644 @capacitor/android/capacitor/src/main/java/com/getcapacitor/WebViewLocalServer.kt delete mode 100644 src-capacitor/android/app/src/main/java/git/shin/animevsub/MainActivity.java create mode 100644 src-capacitor/android/app/src/main/java/git/shin/animevsub/MainActivity.kt delete mode 100644 src-capacitor/android/app/src/main/java/git/shin/animevsub/ResolvePlugin.java create mode 100644 src-capacitor/android/app/src/main/java/git/shin/animevsub/ResolvePlugin.kt diff --git a/@capacitor/android/capacitor/build.gradle b/@capacitor/android/capacitor/build.gradle index 5e2a4011..d50aa275 100644 --- a/@capacitor/android/capacitor/build.gradle +++ b/@capacitor/android/capacitor/build.gradle @@ -33,6 +33,7 @@ buildscript { tasks.withType(Javadoc).all { enabled = false } apply plugin: 'com.android.library' +apply plugin: 'org.jetbrains.kotlin.android' if (System.getenv("CAP_PUBLISH") == "true") { apply plugin: 'io.github.gradle-nexus.publish-plugin' @@ -70,6 +71,9 @@ android { publishing { singleVariant("release") } + kotlinOptions { + jvmTarget = '17' + } } repositories { @@ -86,6 +90,7 @@ dependencies { implementation "androidx.fragment:fragment:$androidxFragmentVersion" implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion" implementation "androidx.webkit:webkit:$androidxWebkitVersion" + implementation 'androidx.core:core-ktx:1.13.1' testImplementation "junit:junit:$junitVersion" androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion" androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion" diff --git a/@capacitor/android/capacitor/src/main/java/com/getcapacitor/Bridge.java b/@capacitor/android/capacitor/src/main/java/com/getcapacitor/Bridge.java deleted file mode 100644 index 1b01134c..00000000 --- a/@capacitor/android/capacitor/src/main/java/com/getcapacitor/Bridge.java +++ /dev/null @@ -1,1568 +0,0 @@ -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 java.util.regex.Matcher; -import java.util.regex.Pattern; -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: - * - * BridgeActivity.java - */ -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 String CAPACITOR_HTTP_INTERCEPTOR_START = "/_capacitor_http_interceptor_"; - public static final String CAPACITOR_HTTPS_INTERCEPTOR_START = "/_capacitor_https_interceptor_"; - - 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 allowedOriginRules = new HashSet(); - private ArrayList 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> initialPlugins; - - private final List pluginInstances; - - // A map of Plugin Id's to PluginHandle's - private Map plugins = new HashMap<>(); - - // Stored plugin calls that we're keeping around to call again someday - private Map savedCalls = new HashMap<>(); - - // The call IDs of saved plugin calls with associated plugin id for handling permissions - private Map> 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 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> 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> initialPlugins, - List 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(); - Pattern pattern = Pattern.compile("(\\d+)"); - Matcher matcher = pattern.matcher(info.versionName); - if (matcher.find()) { - String majorVersionStr = matcher.group(0); - int majorVersion = Integer.parseInt(majorVersionStr); - if (info.packageName.equals("com.huawei.webview")) { - return majorVersion >= config.getMinHuaweiWebViewVersion(); - } - return majorVersion >= config.getMinWebViewVersion(); - } else { - return false; - } - } - - // 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()); - } - - final int amazonFireMajorWebViewVersion = extractWebViewMajorVersion(pm, "com.amazon.webview.chromium"); - if (amazonFireMajorWebViewVersion >= config.getMinWebViewVersion()) { - return true; - } - - // Could not detect any webview, return false - return false; - } - - private int extractWebViewMajorVersion(final PackageManager pm, final String webViewPackageName) { - try { - final PackageInfo info = InternalUtils.getPackageInfo(pm, webViewPackageName); - final String majorVersionStr = info.versionName.split("\\.")[0]; - final int majorVersion = Integer.parseInt(majorVersionStr); - return majorVersion; - } catch (Exception ex) { - Logger.warn(String.format("Unable to get package info for '%s' with err '%s'", webViewPackageName, ex)); - } - return 0; - } - - public boolean launchIntent(Uri url) { - /* - * Give plugins the chance to handle the url - */ - for (Map.Entry 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") || url.getScheme().equals("blob")) { - 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 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[] pluginClasses) { - for (Class plugin : pluginClasses) { - this.registerPlugin(plugin); - } - } - - public void registerPluginInstances(Plugin[] pluginInstances) { - for (Plugin plugin : pluginInstances) { - this.registerPluginInstance(plugin); - } - } - - @SuppressWarnings("deprecation") - private String getLegacyPluginName(Class 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 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 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 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 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 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 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 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 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 ActivityResultLauncher registerForActivityResult( - @NonNull final ActivityResultContract contract, - @NonNull final ActivityResultCallback 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 permissions) { - SharedPreferences prefs = getContext().getSharedPreferences(PERMISSION_PREFS_NAME, Activity.MODE_PRIVATE); - - for (Map.Entry 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 getPermissionStates(Plugin plugin) { - Map 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 getAllowedOriginRules() { - return allowedOriginRules; - } - - public BridgeWebViewClient getWebViewClient() { - return this.webViewClient; - } - - public void setWebViewClient(BridgeWebViewClient client) { - this.webViewClient = client; - webView.setWebViewClient(client); - } - - List getWebViewListeners() { - return webViewListeners; - } - - void setWebViewListeners(List 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> plugins = new ArrayList<>(); - private List pluginInstances = new ArrayList<>(); - private AppCompatActivity activity; - private Fragment fragment; - private RouteProcessor routeProcessor; - private final List 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> plugins) { - this.plugins = plugins; - return this; - } - - public Builder addPlugin(Class plugin) { - this.plugins.add(plugin); - return this; - } - - public Builder addPlugins(List> plugins) { - for (Class cls : plugins) { - this.addPlugin(cls); - } - - return this; - } - - public Builder addPluginInstance(Plugin plugin) { - this.pluginInstances.add(plugin); - return this; - } - - public Builder addPluginInstances(List plugins) { - this.pluginInstances.addAll(plugins); - return this; - } - - public Builder addWebViewListener(WebViewListener webViewListener) { - webViewListeners.add(webViewListener); - return this; - } - - public Builder addWebViewListeners(List 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 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; - } - } -} diff --git a/@capacitor/android/capacitor/src/main/java/com/getcapacitor/Bridge.kt b/@capacitor/android/capacitor/src/main/java/com/getcapacitor/Bridge.kt new file mode 100644 index 00000000..93b463ef --- /dev/null +++ b/@capacitor/android/capacitor/src/main/java/com/getcapacitor/Bridge.kt @@ -0,0 +1,1561 @@ +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.pm.ApplicationInfo +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.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.cordova.MockCordovaInterfaceImpl +import com.getcapacitor.cordova.MockCordovaWebViewImpl +import com.getcapacitor.plugin.CapacitorCookies +import com.getcapacitor.plugin.CapacitorHttp +import com.getcapacitor.util.HostMask +import com.getcapacitor.util.InternalUtils +import com.getcapacitor.util.PermissionHelper +import com.getcapacitor.util.WebColor +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 +import java.io.File +import java.net.SocketTimeoutException +import java.net.URL +import java.util.Arrays +import java.util.LinkedList +import java.util.regex.Pattern + +/** + * 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: + * [ + * BridgeActivity.java](https://github.com/ionic-team/capacitor/blob/HEAD/android/capacitor/src/main/java/com/getcapacitor/BridgeActivity.java) + */ +class Bridge private constructor( + context: AppCompatActivity?, + serverPath: ServerPath?, + fragment: Fragment?, + webView: WebView, + initialPlugins: List>, + pluginInstances: List, + cordovaInterface: MockCordovaInterfaceImpl, + pluginManager: PluginManager, + preferences: CordovaPreferences, + config: CapConfig? +) { + // Loaded Capacitor config + val config: CapConfig + + /** + * Get the activity for the app + * @return + */ + // A reference to the main activity for the app + val activity: AppCompatActivity? + + /** + * 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. + */ + // A reference to the containing Fragment if used + val fragment: Fragment? + var localServer: WebViewLocalServer? = null + private set + var localUrl: String? = null + private set + var appUrl: String? = null + private set + private var appUrlConfig: String? = null + var appAllowNavigationMask: HostMask? = null + private set + private val allowedOriginRules: MutableSet = HashSet() + private val authorities = ArrayList() + + /** + * Get the core WebView under Capacitor's control + * @return + */ + // A reference to the main WebView for the app + val webView: WebView + val cordovaInterface: MockCordovaInterfaceImpl + private var cordovaWebView: CordovaWebView? = null + private val preferences: CordovaPreferences + private var webViewClient: BridgeWebViewClient + val app: App = App() + + // Our MessageHandler for sending and receiving data to the WebView + private val msgHandler: MessageHandler + + // The ThreadHandler for executing plugin calls + private val handlerThread = HandlerThread("CapacitorPlugins") + + // Our Handler for posting plugin calls. Created from the ThreadHandler + private var taskHandler: Handler? = null + + private val initialPlugins: List> + + private val pluginInstances: List + + // A map of Plugin Id's to PluginHandle's + private val plugins: MutableMap = HashMap() + + // Stored plugin calls that we're keeping around to call again someday + private var savedCalls: MutableMap = HashMap() + + // The call IDs of saved plugin calls with associated plugin id for handling permissions + private val savedPermissionCallIds: MutableMap> = HashMap() + + // Store a plugin that started a new activity, in case we need to resume + // the app and return that data back + private var pluginCallForLastActivity: PluginCall? = null + + /** + * Get the URI that was used to launch the app (if any) + * @return + */ + // Any URI that was passed to the app on start + val intentUri: Uri? + + // A list of listeners that trigger when webView events occur + private var webViewListeners: MutableList = ArrayList() + + // An interface to manipulate route resolving + var routeProcessor: RouteProcessor? = null + + // A pre-determined path to load the bridge + val serverPath: ServerPath? + + /** + * Create the Bridge with a reference to the main [Activity] for the + * app, and a reference to the [WebView] our app will use. + * @param context + * @param webView + */ + @Deprecated("Use {@link Bridge.Builder} to create Bridge instances") + constructor( + context: AppCompatActivity?, + webView: WebView, + initialPlugins: List>, + cordovaInterface: MockCordovaInterfaceImpl, + pluginManager: PluginManager, + preferences: CordovaPreferences, + config: CapConfig? + ) : this( + context, + null, + null, + webView, + initialPlugins, + ArrayList(), + cordovaInterface, + pluginManager, + preferences, + config + ) + + init { + this.serverPath = serverPath + this.activity = context + this.fragment = fragment + this.webView = webView + this.webViewClient = BridgeWebViewClient(this) + this.initialPlugins = initialPlugins + this.pluginInstances = pluginInstances + this.cordovaInterface = cordovaInterface + this.preferences = preferences + + // Start our plugin execution threads and handlers + handlerThread.start() + taskHandler = Handler(handlerThread.looper) + + this.config = config ?: CapConfig.loadDefault(activity) + Logger.init(this.config) + + // Initialize web view and message handler for it + this.initWebView() + this.setAllowedOriginRules() + this.msgHandler = MessageHandler(this, webView, pluginManager) + + // Grab any intent info that our app was launched with + val intent = context!!.intent + this.intentUri = intent.data + // Register our core plugins + this.registerAllPlugins() + + this.loadWebView() + } + + private fun setAllowedOriginRules() { + val appAllowNavigationConfig = config.allowNavigation + val authority = this.host + val scheme = this.scheme + allowedOriginRules.add("$scheme://$authority") + if (this.serverUrl != null) { + allowedOriginRules.add(this.serverUrl) + } + if (appAllowNavigationConfig != null) { + for (allowNavigation in appAllowNavigationConfig) { + if (!allowNavigation.startsWith("http")) { + allowedOriginRules.add("https://$allowNavigation") + } else { + allowedOriginRules.add(allowNavigation) + } + } + authorities.addAll(Arrays.asList(*appAllowNavigationConfig)) + } + this.appAllowNavigationMask = HostMask.Parser.parse(appAllowNavigationConfig) + } + + private fun loadWebView() { + val html5mode = config.isHTML5Mode + + // Start the local web server + localServer = WebViewLocalServer(activity, this, jSInjector, authorities, html5mode) + localServer!!.hostAssets(DEFAULT_WEB_ASSET_DIR) + + Logger.debug("Loading app at $appUrl") + + webView.webChromeClient = BridgeWebChromeClient(this) + webView.webViewClient = webViewClient + + if (!isDeployDisabled && !isNewBinary) { + val prefs = getContext() + .getSharedPreferences( + com.getcapacitor.plugin.WebView.WEBVIEW_PREFS_NAME, + Activity.MODE_PRIVATE + ) + val path = prefs.getString(com.getcapacitor.plugin.WebView.CAP_SERVER_PATH, null) + if (path != null && !path.isEmpty() && File(path).exists()) { + serverBasePath = path + } + } + if (!this.isMinimumWebViewInstalled) { + val errorUrl = this.errorUrl + 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.type == ServerPath.PathType.ASSET_PATH) { + setServerAssetPath(serverPath.path) + } else { + serverBasePath = serverPath.path + } + } else { + // Get to work + webView.loadUrl(appUrl!!) + } + } + + @get:SuppressLint("WebViewApiAvailability") + val isMinimumWebViewInstalled: Boolean + get() { + val pm = getContext()!!.packageManager + + // Check getCurrentWebViewPackage() directly if above Android 8 + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val info = WebView.getCurrentWebViewPackage() + val pattern = Pattern.compile("(\\d+)") + val matcher = pattern.matcher(info!!.versionName) + if (matcher.find()) { + val majorVersionStr = matcher.group(0) + val majorVersion = majorVersionStr.toInt() + if (info.packageName == "com.huawei.webview") { + return majorVersion >= config.minHuaweiWebViewVersion + } + return majorVersion >= config.minWebViewVersion + } else { + return false + } + } + + // Otherwise manually check WebView versions + try { + var webViewPackage = "com.google.android.webview" + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + webViewPackage = "com.android.chrome" + } + val info = InternalUtils.getPackageInfo(pm, webViewPackage) + val majorVersionStr = + info.versionName.split("\\.".toRegex()).dropLastWhile { it.isEmpty() } + .toTypedArray()[0] + val majorVersion = majorVersionStr.toInt() + return majorVersion >= config.minWebViewVersion + } catch (ex: Exception) { + Logger.warn("Unable to get package info for 'com.google.android.webview'$ex") + } + + try { + val info = InternalUtils.getPackageInfo(pm, "com.android.webview") + val majorVersionStr = + info.versionName.split("\\.".toRegex()).dropLastWhile { it.isEmpty() } + .toTypedArray()[0] + val majorVersion = majorVersionStr.toInt() + return majorVersion >= config.minWebViewVersion + } catch (ex: Exception) { + Logger.warn("Unable to get package info for 'com.android.webview'$ex") + } + + val amazonFireMajorWebViewVersion = + extractWebViewMajorVersion(pm, "com.amazon.webview.chromium") + if (amazonFireMajorWebViewVersion >= config.minWebViewVersion) { + return true + } + + // Could not detect any webview, return false + return false + } + + private fun extractWebViewMajorVersion(pm: PackageManager, webViewPackageName: String): Int { + try { + val info = InternalUtils.getPackageInfo(pm, webViewPackageName) + val majorVersionStr = + info.versionName.split("\\.".toRegex()).dropLastWhile { it.isEmpty() } + .toTypedArray()[0] + val majorVersion = majorVersionStr.toInt() + return majorVersion + } catch (ex: Exception) { + Logger.warn( + String.format( + "Unable to get package info for '%s' with err '%s'", + webViewPackageName, + ex + ) + ) + } + return 0 + } + + fun launchIntent(url: Uri): Boolean { + /* + * Give plugins the chance to handle the url + */ + for ((_, value) in plugins) { + val plugin = value.instance + if (plugin != null) { + val shouldOverrideLoad = plugin.shouldOverrideLoad(url) + if (shouldOverrideLoad != null) { + return shouldOverrideLoad + } + } + } + + if (url.scheme == "data" || url.scheme == "blob") { + return false + } + + val appUri = Uri.parse(appUrl) + if (!(appUri.host == url.host && url.scheme == appUri.scheme) && + !appAllowNavigationMask!!.matches(url.host) + ) { + try { + val openIntent = Intent(Intent.ACTION_VIEW, url) + getContext()!!.startActivity(openIntent) + } catch (e: ActivityNotFoundException) { + // TODO - trigger an event + } + return true + } + return false + } + + private val isNewBinary: Boolean + get() { + var versionCode = "" + var versionName = "" + val prefs = getContext() + .getSharedPreferences( + com.getcapacitor.plugin.WebView.WEBVIEW_PREFS_NAME, + Activity.MODE_PRIVATE + ) + val lastVersionCode = prefs.getString(LAST_BINARY_VERSION_CODE, null) + val lastVersionName = prefs.getString(LAST_BINARY_VERSION_NAME, null) + + try { + val pm = getContext()!!.packageManager + val pInfo = InternalUtils.getPackageInfo(pm, getContext()!!.packageName) + versionCode = PackageInfoCompat.getLongVersionCode(pInfo).toInt() + .toString() + versionName = pInfo.versionName + } catch (ex: Exception) { + Logger.error("Unable to get package info", ex) + } + + if (versionCode != lastVersionCode || versionName != lastVersionName) { + val 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 + } + + val isDeployDisabled: Boolean + get() = preferences.getBoolean("DisableDeploy", false) + + fun shouldKeepRunning(): Boolean { + return preferences.getBoolean("KeepRunning", true) + } + + fun handleAppUrlLoadError(ex: Exception?) { + if (ex is 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 + ) + } + } + + val isDevMode: Boolean + get() = (activity!!.applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE) != 0 + + protected fun setCordovaWebView(cordovaWebView: CordovaWebView?) { + this.cordovaWebView = cordovaWebView + } + + /** + * Get the Context for the App + * @return + */ + fun getContext(): Context? { + return this.activity + } + + val scheme: String + /** + * Get scheme that is used to serve content + * @return + */ + get() = config.androidScheme + + val host: String + /** + * Get host name that is used to serve content + * @return + */ + get() = config.hostname + + val serverUrl: String? + /** + * Get the server url that is used to serve content + * @return + */ + get() = config.serverUrl + + val errorUrl: String? + get() { + val errorPath = config.errorPath + + if (errorPath != null && !errorPath.trim { it <= ' ' }.isEmpty()) { + val authority = this.host + val scheme = this.scheme + + val localUrl = "$scheme://$authority" + + return "$localUrl/$errorPath" + } + + return null + } + + fun reset() { + savedCalls = HashMap() + } + + /** + * Initialize the WebView, setting required flags + */ + @SuppressLint("SetJavaScriptEnabled") + private fun initWebView() { + val settings = webView.settings + settings.javaScriptEnabled = true + settings.domStorageEnabled = true + settings.setGeolocationEnabled(true) + settings.databaseEnabled = true + settings.mediaPlaybackRequiresUserGesture = false + settings.javaScriptCanOpenWindowsAutomatically = true + if (config.isMixedContentAllowed) { + settings.mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW + } + + val appendUserAgent = config.appendedUserAgentString + if (appendUserAgent != null) { + val defaultUserAgent = settings.userAgentString + settings.userAgentString = "$defaultUserAgent $appendUserAgent" + } + val overrideUserAgent = config.overriddenUserAgentString + if (overrideUserAgent != null) { + settings.userAgentString = overrideUserAgent + } + + val backgroundColor = config.backgroundColor + try { + if (backgroundColor != null) { + webView.setBackgroundColor(WebColor.parseColor(backgroundColor)) + } + } catch (ex: IllegalArgumentException) { + Logger.debug("WebView background color not applied") + } + + if (config.isInitialFocus) { + webView.requestFocusFromTouch() + } + + WebView.setWebContentsDebuggingEnabled(config.isWebContentsDebuggingEnabled) + + appUrlConfig = this.serverUrl + val authority = this.host + authorities.add(authority) + val scheme = this.scheme + + localUrl = "$scheme://$authority" + + if (appUrlConfig != null) { + try { + val appUrlObject = URL(appUrlConfig) + authorities.add(appUrlObject.authority) + } catch (ex: Exception) { + Logger.error("Provided server url is invalid: " + ex.message) + return + } + localUrl = appUrlConfig + appUrl = appUrlConfig + } else { + appUrl = localUrl + // custom URL schemes requires path ending with / + if (scheme != CAPACITOR_HTTP_SCHEME && scheme != CAPACITOR_HTTPS_SCHEME) { + appUrl += "/" + } + } + + val appUrlPath = config.startPath + if (appUrlPath != null && !appUrlPath.trim { it <= ' ' }.isEmpty()) { + appUrl += appUrlPath + } + } + + /** + * Register our core Plugin APIs + */ + private fun registerAllPlugins() { + this.registerPlugin(CapacitorCookies::class.java) + this.registerPlugin(com.getcapacitor.plugin.WebView::class.java) + this.registerPlugin(CapacitorHttp::class.java) + + for (pluginClass in this.initialPlugins) { + this.registerPlugin(pluginClass) + } + + for (plugin in pluginInstances) { + registerPluginInstance(plugin) + } + } + + /** + * Register additional plugins + * @param pluginClasses the plugins to register + */ + fun registerPlugins(pluginClasses: Array>) { + for (plugin in pluginClasses) { + this.registerPlugin(plugin) + } + } + + fun registerPluginInstances(pluginInstances: Array) { + for (plugin in pluginInstances) { + this.registerPluginInstance(plugin) + } + } + + @Suppress("deprecation") + private fun getLegacyPluginName(pluginClass: Class): String? { + val legacyPluginAnnotation = pluginClass.getAnnotation( + NativePlugin::class.java + ) + 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 + */ + fun registerPlugin(pluginClass: Class) { + val pluginId = pluginId(pluginClass) ?: return + + try { + plugins[pluginId] = PluginHandle(this, pluginClass) + } catch (ex: InvalidPluginException) { + logInvalidPluginException(pluginClass) + } catch (ex: PluginLoadException) { + logPluginLoadException(pluginClass, ex) + } + } + + fun registerPluginInstance(plugin: Plugin) { + val clazz: Class = plugin.javaClass + val pluginId = pluginId(clazz) ?: return + + try { + plugins[pluginId] = PluginHandle(this, plugin) + } catch (ex: InvalidPluginException) { + logInvalidPluginException(clazz) + } + } + + private fun pluginId(clazz: Class): String? { + val pluginName = pluginName(clazz) + var pluginId = clazz.simpleName + if (pluginName == null) return null + + if (pluginName != "") { + pluginId = pluginName + } + Logger.debug("Registering plugin instance: $pluginId") + return pluginId + } + + private fun pluginName(clazz: Class): String? { + val pluginName: String? + val pluginAnnotation = clazz.getAnnotation( + CapacitorPlugin::class.java + ) + pluginName = pluginAnnotation?.name ?: getLegacyPluginName(clazz) + + return pluginName + } + + private fun logInvalidPluginException(clazz: Class) { + Logger.error( + "NativePlugin " + + clazz.name + + " is invalid. Ensure the @CapacitorPlugin annotation exists on the plugin class and" + + " the class extends Plugin" + ) + } + + private fun logPluginLoadException(clazz: Class, ex: Exception) { + Logger.error("NativePlugin " + clazz.name + " failed to load", ex) + } + + fun getPlugin(pluginId: String): PluginHandle? { + return plugins[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("") + @Suppress("deprecation") + fun getPluginWithRequestCode(requestCode: Int): PluginHandle? { + for (handle in plugins.values) { + var requestCodes: IntArray + + val pluginAnnotation = handle.pluginAnnotation + if (pluginAnnotation == null) { + // Check for legacy plugin annotation, @NativePlugin + val legacyPluginAnnotation = handle.legacyPluginAnnotation ?: continue + + if (legacyPluginAnnotation.permissionRequestCode == requestCode) { + return handle + } + + requestCodes = legacyPluginAnnotation.requestCodes + + for (rc in requestCodes) { + if (rc == requestCode) { + return handle + } + } + } else { + requestCodes = pluginAnnotation.requestCodes + + for (rc in 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 + */ + fun callPluginMethod(pluginId: String, methodName: String, call: PluginCall) { + try { + val 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.callbackId + + ", pluginId: " + + plugin.id + + ", methodName: " + + methodName + + ", methodData: " + + call.data.toString() + ) + } + + val currentThreadTask = Runnable { + try { + plugin.invoke(methodName, call) + + if (call.isKeptAlive) { + saveCall(call) + } + } catch (ex: PluginLoadException) { + Logger.error("Unable to execute plugin method", ex) + } catch (ex: InvalidPluginMethodException) { + Logger.error("Unable to execute plugin method", ex) + } catch (ex: Exception) { + Logger.error("Serious error executing plugin", ex) + throw RuntimeException(ex) + } + } + + taskHandler!!.post(currentThreadTask) + } catch (ex: Exception) { + 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 + */ + fun eval(js: String?, callback: ValueCallback?) { + val mainHandler = Handler(activity!!.mainLooper) + mainHandler.post { webView.evaluateJavascript(js!!, callback) } + } + + @JvmOverloads + fun logToJs(message: String, level: String = "log") { + eval("window.Capacitor.logJs(\"$message\", \"$level\")", null) + } + + fun triggerJSEvent(eventName: String, target: String) { + eval("window.Capacitor.triggerEvent(\"$eventName\", \"$target\")") { s: String? -> } + } + + fun triggerJSEvent(eventName: String, target: String, data: String) { + eval("window.Capacitor.triggerEvent(\"$eventName\", \"$target\", $data)") { s: String? -> } + } + + fun triggerWindowJSEvent(eventName: String) { + this.triggerJSEvent(eventName, "window") + } + + fun triggerWindowJSEvent(eventName: String, data: String) { + this.triggerJSEvent(eventName, "window", data) + } + + fun triggerDocumentJSEvent(eventName: String) { + this.triggerJSEvent(eventName, "document") + } + + fun triggerDocumentJSEvent(eventName: String, data: String) { + this.triggerJSEvent(eventName, "document", data) + } + + fun execute(runnable: Runnable?) { + taskHandler!!.post(runnable!!) + } + + fun executeOnMainThread(runnable: Runnable?) { + val mainHandler = Handler(activity!!.mainLooper) + + mainHandler.post(runnable!!) + } + + /** + * Retain a call between plugin invocations + * @param call + */ + fun saveCall(call: PluginCall) { + savedCalls[call.callbackId] = call + } + + /** + * Get a retained plugin call + * @param callbackId the callbackId to use to lookup the call with + * @return the stored call + */ + fun getSavedCall(callbackId: String?): PluginCall? { + if (callbackId == null) { + return null + } + + return savedCalls[callbackId] + } + + fun getPluginCallForLastActivity(): PluginCall? { + val pluginCallForLastActivity = this.pluginCallForLastActivity + this.pluginCallForLastActivity = null + return pluginCallForLastActivity + } + + fun setPluginCallForLastActivity(pluginCallForLastActivity: PluginCall?) { + this.pluginCallForLastActivity = pluginCallForLastActivity + } + + /** + * Release a retained call + * @param call a call to release + */ + fun releaseCall(call: PluginCall) { + releaseCall(call.callbackId) + } + + /** + * Release a retained call by its ID + * @param callbackId an ID of a callback to release + */ + fun releaseCall(callbackId: String) { + 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 + */ + fun getPermissionCall(pluginId: String): PluginCall? { + val permissionCallIds = savedPermissionCallIds[pluginId] + var savedCallId: String? = 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. + */ + fun savePermissionCall(call: PluginCall?) { + if (call != null) { + if (!savedPermissionCallIds.containsKey(call.pluginId)) { + savedPermissionCallIds[call.pluginId] = LinkedList() + } + + savedPermissionCallIds[call.pluginId]!!.add(call.callbackId) + 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. + */ + fun registerForActivityResult( + contract: ActivityResultContract, + callback: ActivityResultCallback + ): ActivityResultLauncher { + return fragment?.registerForActivityResult(contract, callback) + ?: activity!!.registerForActivityResult(contract, callback) + } + + private val jSInjector: JSInjector? + /** + * 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. + */ + get() { + try { + val globalJS = JSExport.getGlobalJS(activity, config.isLoggingEnabled, isDevMode) + val bridgeJS = JSExport.getBridgeJS(activity) + val pluginJS = JSExport.getPluginJS(plugins.values) + val cordovaJS = JSExport.getCordovaJS(activity) + val cordovaPluginsJS = JSExport.getCordovaPluginJS(activity) + val cordovaPluginsFileJS = JSExport.getCordovaPluginsFileJS(activity) + val localUrlJS = "window.WEBVIEW_SERVER_URL = '$localUrl';" + + return JSInjector( + globalJS, + bridgeJS, + pluginJS, + cordovaJS, + cordovaPluginsJS, + cordovaPluginsFileJS, + localUrlJS + ) + } catch (ex: Exception) { + Logger.error("Unable to export Capacitor JS. App will not function!", ex) + } + return null + } + + /** + * Restore any saved bundle state data + * @param savedInstanceState + */ + fun restoreInstanceState(savedInstanceState: Bundle) { + val lastPluginId = savedInstanceState.getString(BUNDLE_LAST_PLUGIN_ID_KEY) + val lastPluginCallMethod = savedInstanceState.getString( + BUNDLE_LAST_PLUGIN_CALL_METHOD_NAME_KEY + ) + val 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 { + val options = JSObject(lastOptionsJson) + + pluginCallForLastActivity = + PluginCall( + msgHandler, + lastPluginId, + PluginCall.CALLBACK_ID_DANGLING, + lastPluginCallMethod, + options + ) + } catch (ex: JSONException) { + Logger.error( + "Unable to restore plugin call, unable to parse persisted JSON object", + ex + ) + } + } + + // Let the plugin restore any state it needs + val bundleData = savedInstanceState.getBundle(BUNDLE_PLUGIN_CALL_BUNDLE_KEY) + val lastPlugin = getPlugin(lastPluginId) + if (bundleData != null && lastPlugin != null) { + lastPlugin.instance.restoreState(bundleData) + } else { + Logger.error("Unable to restore last plugin call") + } + } + } + + fun saveInstanceState(outState: Bundle) { + 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) { + val call: PluginCall = pluginCallForLastActivity + val handle = getPlugin(call.pluginId) + + if (handle != null) { + val bundle = handle.instance.saveInstanceState() + if (bundle != null) { + outState.putString(BUNDLE_LAST_PLUGIN_ID_KEY, call.pluginId) + outState.putString(BUNDLE_LAST_PLUGIN_CALL_METHOD_NAME_KEY, call.methodName) + outState.putString(BUNDLE_PLUGIN_CALL_OPTIONS_SAVED_KEY, call.data.toString()) + outState.putBundle(BUNDLE_PLUGIN_CALL_BUNDLE_KEY, bundle) + } else { + Logger.error("Couldn't save last " + call.pluginId + "'s Plugin " + call.methodName + " call") + } + } + } + } + + @Deprecated("") + @Suppress("deprecation") + fun startActivityForPluginWithResult(call: PluginCall?, intent: Intent?, requestCode: Int) { + Logger.debug("Starting activity for result") + + pluginCallForLastActivity = call + + activity!!.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 + */ + @Suppress("deprecation") + fun onRequestPermissionsResult( + requestCode: Int, + permissions: Array?, + grantResults: IntArray? + ): Boolean { + val plugin = getPluginWithRequestCode(requestCode) + + if (plugin == null) { + var 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 (e: JSONException) { + Logger.debug("Error on Cordova plugin permissions request " + e.message) + } + return permissionHandled + } + + // Call deprecated method if using deprecated NativePlugin annotation + if (plugin.pluginAnnotation == null) { + plugin.instance.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 + */ + fun validatePermissions( + plugin: Plugin?, + savedCall: PluginCall, + permissions: Map + ): Boolean { + val prefs = + getContext()!!.getSharedPreferences(PERMISSION_PREFS_NAME, Activity.MODE_PRIVATE) + + for ((permString, isGranted) in permissions) { + if (isGranted) { + // Permission granted. If previously denied, remove cached state + val state = prefs.getString(permString, null) + + if (state != null) { + val editor = prefs.edit() + editor.remove(permString) + editor.apply() + } + } else { + val editor = prefs.edit() + + if (ActivityCompat.shouldShowRequestPermissionRationale(activity!!, 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() + } + } + + val permStrings = permissions.keys.toTypedArray() + + if (!PermissionHelper.hasDefinedPermissions(getContext(), permStrings)) { + val builder = StringBuilder() + builder.append("Missing the following permissions in AndroidManifest.xml:\n") + val missing = PermissionHelper.getUndefinedPermissions(getContext(), permStrings) + for (perm in 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. + */ + fun getPermissionStates(plugin: Plugin): Map { + val permissionsResults: MutableMap = HashMap() + val annotation = plugin.pluginHandle.pluginAnnotation + for (perm in annotation.permissions) { + // If a permission is defined with no permission constants, return GRANTED for it. + // Otherwise, get its true state. + if (perm.strings.size == 0 || (perm.strings.size == 1 && perm.strings[0].isEmpty())) { + val key = perm.alias + if (!key.isEmpty()) { + val existingResult = permissionsResults[key] + + // auto set permission state to GRANTED if the alias is empty. + if (existingResult == null) { + permissionsResults[key] = PermissionState.GRANTED + } + } + } else { + for (permString in perm.strings) { + val key = if (perm.alias.isEmpty()) permString else perm.alias + var permissionStatus: PermissionState + if (ActivityCompat.checkSelfPermission( + 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 + val prefs = getContext()!!.getSharedPreferences( + PERMISSION_PREFS_NAME, + Activity.MODE_PRIVATE + ) + val state = prefs.getString(permString, null) + + if (state != null) { + permissionStatus = PermissionState.byState(state) + } + } + + val existingResult = permissionsResults[key] + + // multiple permissions with the same alias must all be true, otherwise all false. + if (existingResult == null || existingResult == PermissionState.GRANTED) { + permissionsResults[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 + */ + @Suppress("deprecation") + fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean { + val plugin = getPluginWithRequestCode(requestCode) + + if (plugin == null || plugin.instance == 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 + val lastCall = plugin.instance.savedCall + + // 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.instance.saveCall(pluginCallForLastActivity) + } + + plugin.instance.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 + */ + fun onNewIntent(intent: Intent?) { + for (plugin in plugins.values) { + plugin.instance.handleOnNewIntent(intent) + } + + if (cordovaWebView != null) { + cordovaWebView!!.onNewIntent(intent) + } + } + + /** + * Handle an onConfigurationChanged event and notify the plugins + * @param newConfig + */ + fun onConfigurationChanged(newConfig: Configuration?) { + for (plugin in plugins.values) { + plugin.instance.handleOnConfigurationChanged(newConfig) + } + } + + /** + * Handle onRestart lifecycle event and notify the plugins + */ + fun onRestart() { + for (plugin in plugins.values) { + plugin.instance.handleOnRestart() + } + } + + /** + * Handle onStart lifecycle event and notify the plugins + */ + fun onStart() { + for (plugin in plugins.values) { + plugin.instance.handleOnStart() + } + + if (cordovaWebView != null) { + cordovaWebView!!.handleStart() + } + } + + /** + * Handle onResume lifecycle event and notify the plugins + */ + fun onResume() { + for (plugin in plugins.values) { + plugin.instance.handleOnResume() + } + + if (cordovaWebView != null) { + cordovaWebView!!.handleResume(this.shouldKeepRunning()) + } + } + + /** + * Handle onPause lifecycle event and notify the plugins + */ + fun onPause() { + for (plugin in plugins.values) { + plugin.instance.handleOnPause() + } + + if (cordovaWebView != null) { + val keepRunning = + this.shouldKeepRunning() || cordovaInterface.activityResultCallback != null + cordovaWebView!!.handlePause(keepRunning) + } + } + + /** + * Handle onStop lifecycle event and notify the plugins + */ + fun onStop() { + for (plugin in plugins.values) { + plugin.instance.handleOnStop() + } + + if (cordovaWebView != null) { + cordovaWebView!!.handleStop() + } + } + + /** + * Handle onDestroy lifecycle event and notify the plugins + */ + fun onDestroy() { + for (plugin in plugins.values) { + plugin.instance.handleOnDestroy() + } + + handlerThread.quitSafely() + + if (cordovaWebView != null) { + cordovaWebView!!.handleDestroy() + } + } + + /** + * Handle onDetachedFromWindow lifecycle event + */ + fun onDetachedFromWindow() { + webView.removeAllViews() + webView.destroy() + } + + var serverBasePath: String? + get() = localServer!!.basePath + /** + * Tell the local server to load files from the given + * file path instead of the assets path. + * @param path + */ + set(path) { + localServer!!.hostFiles(path) + webView.post { webView.loadUrl(appUrl!!) } + } + + /** + * Tell the local server to load files from the given + * asset path. + * @param path + */ + fun setServerAssetPath(path: String?) { + localServer!!.hostAssets(path) + webView.post { webView.loadUrl(appUrl!!) } + } + + /** + * Reload the WebView + */ + fun reload() { + webView.post { webView.loadUrl(appUrl!!) } + } + + fun getAllowedOriginRules(): Set { + return allowedOriginRules + } + + fun getWebViewClient(): BridgeWebViewClient { + return this.webViewClient + } + + fun setWebViewClient(client: BridgeWebViewClient) { + this.webViewClient = client + webView.webViewClient = client + } + + fun getWebViewListeners(): List { + return webViewListeners + } + + fun setWebViewListeners(webViewListeners: MutableList) { + this.webViewListeners = webViewListeners + } + + /** + * Add a listener that the WebViewClient can trigger on certain events. + * @param webViewListener A [WebViewListener] to add. + */ + fun addWebViewListener(webViewListener: WebViewListener) { + webViewListeners.add(webViewListener) + } + + /** + * Remove a listener that the WebViewClient triggers on certain events. + * @param webViewListener A [WebViewListener] to remove. + */ + fun removeWebViewListener(webViewListener: WebViewListener) { + webViewListeners.remove(webViewListener) + } + + class Builder { + private var instanceState: Bundle? = null + private var config: CapConfig? = null + private var plugins: MutableList> = ArrayList() + private val pluginInstances: MutableList = ArrayList() + private var activity: AppCompatActivity? + private var fragment: Fragment? = null + private var routeProcessor: RouteProcessor? = null + private val webViewListeners: MutableList = ArrayList() + private var serverPath: ServerPath? = null + + constructor(activity: AppCompatActivity?) { + this.activity = activity + } + + constructor(fragment: Fragment) { + this.activity = fragment.activity as AppCompatActivity? + this.fragment = fragment + } + + fun setInstanceState(instanceState: Bundle?): Builder { + this.instanceState = instanceState + return this + } + + fun setConfig(config: CapConfig?): Builder { + this.config = config + return this + } + + fun setPlugins(plugins: MutableList>): Builder { + this.plugins = plugins + return this + } + + fun addPlugin(plugin: Class): Builder { + plugins.add(plugin) + return this + } + + fun addPlugins(plugins: List>): Builder { + for (cls in plugins) { + this.addPlugin(cls) + } + + return this + } + + fun addPluginInstance(plugin: Plugin): Builder { + pluginInstances.add(plugin) + return this + } + + fun addPluginInstances(plugins: List?): Builder { + pluginInstances.addAll(plugins!!) + return this + } + + fun addWebViewListener(webViewListener: WebViewListener): Builder { + webViewListeners.add(webViewListener) + return this + } + + fun addWebViewListeners(webViewListeners: List): Builder { + for (listener in webViewListeners) { + this.addWebViewListener(listener) + } + + return this + } + + fun setRouteProcessor(routeProcessor: RouteProcessor?): Builder { + this.routeProcessor = routeProcessor + return this + } + + fun setServerPath(serverPath: ServerPath?): Builder { + this.serverPath = serverPath + return this + } + + fun create(): Bridge { + // Cordova initialization + val parser = ConfigXmlParser() + parser.parse(activity!!.applicationContext) + val preferences = parser.preferences + preferences.setPreferencesBundle(activity!!.intent.extras) + val pluginEntries: List = parser.pluginEntries + + val cordovaInterface = MockCordovaInterfaceImpl(activity) + if (instanceState != null) { + cordovaInterface.restoreInstanceState(instanceState) + } + + val webView = if (this.fragment != null) fragment!!.view!! + .findViewById(R.id.webview) else activity!!.findViewById(R.id.webview) + val mockWebView = MockCordovaWebViewImpl(activity!!.applicationContext) + mockWebView.init(cordovaInterface, pluginEntries, preferences, webView) + val pluginManager = mockWebView.pluginManager + cordovaInterface.onCordovaInit(pluginManager) + + // Bridge initialization + val bridge = Bridge( + activity, + serverPath, + fragment, + webView, + plugins, + pluginInstances, + cordovaInterface, + pluginManager, + preferences, + config + ) + + if (webView is CapacitorWebView) { + webView.setBridge(bridge) + } + + bridge.setCordovaWebView(mockWebView) + bridge.setWebViewListeners(webViewListeners) + bridge.routeProcessor = routeProcessor + + if (instanceState != null) { + bridge.restoreInstanceState(instanceState!!) + } + + return bridge + } + } + + companion object { + private const val PREFS_NAME = "CapacitorSettings" + private const val PERMISSION_PREFS_NAME = "PluginPermStates" + private const val BUNDLE_LAST_PLUGIN_ID_KEY = "capacitorLastActivityPluginId" + private const val BUNDLE_LAST_PLUGIN_CALL_METHOD_NAME_KEY = + "capacitorLastActivityPluginMethod" + private const val BUNDLE_PLUGIN_CALL_OPTIONS_SAVED_KEY = "capacitorLastPluginCallOptions" + private const val BUNDLE_PLUGIN_CALL_BUNDLE_KEY = "capacitorLastPluginCallBundle" + private const val LAST_BINARY_VERSION_CODE = "lastBinaryVersionCode" + private const val LAST_BINARY_VERSION_NAME = "lastBinaryVersionName" + private const val 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 + const val DEFAULT_WEB_ASSET_DIR: String = "public" + const val CAPACITOR_HTTP_SCHEME: String = "http" + const val CAPACITOR_HTTPS_SCHEME: String = "https" + const val CAPACITOR_FILE_START: String = "/_capacitor_file_" + const val CAPACITOR_CONTENT_START: String = "/_capacitor_content_" + const val CAPACITOR_HTTP_INTERCEPTOR_START: String = "/_capacitor_http_interceptor_" + const val CAPACITOR_HTTPS_INTERCEPTOR_START: String = "/_capacitor_https_interceptor_" + + const val DEFAULT_ANDROID_WEBVIEW_VERSION: Int = 60 + const val MINIMUM_ANDROID_WEBVIEW_VERSION: Int = 55 + const val DEFAULT_HUAWEI_WEBVIEW_VERSION: Int = 10 + const val MINIMUM_HUAWEI_WEBVIEW_VERSION: Int = 10 + } +} diff --git a/@capacitor/android/capacitor/src/main/java/com/getcapacitor/BridgeActivity.java b/@capacitor/android/capacitor/src/main/java/com/getcapacitor/BridgeActivity.java deleted file mode 100644 index c3660265..00000000 --- a/@capacitor/android/capacitor/src/main/java/com/getcapacitor/BridgeActivity.java +++ /dev/null @@ -1,197 +0,0 @@ -package com.getcapacitor; - -import android.content.Intent; -import android.content.res.Configuration; -import android.os.Bundle; -import androidx.appcompat.app.AppCompatActivity; -import com.getcapacitor.android.R; -import java.util.ArrayList; -import java.util.List; - -public class BridgeActivity extends AppCompatActivity { - - protected Bridge bridge; - protected boolean keepRunning = true; - protected CapConfig config; - - protected int activityDepth = 0; - protected List> initialPlugins = new ArrayList<>(); - protected final Bridge.Builder bridgeBuilder = new Bridge.Builder(this); - - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - bridgeBuilder.setInstanceState(savedInstanceState); - getApplication().setTheme(R.style.AppTheme_NoActionBar); - setTheme(R.style.AppTheme_NoActionBar); - setContentView(R.layout.bridge_layout_main); - PluginManager loader = new PluginManager(getAssets()); - - try { - bridgeBuilder.addPlugins(loader.loadPluginClasses()); - } catch (PluginLoadException ex) { - Logger.error("Error loading plugins.", ex); - } - - this.load(); - } - - protected void load() { - Logger.debug("Starting BridgeActivity"); - - bridge = bridgeBuilder.addPlugins(initialPlugins).setConfig(config).create(); - - this.keepRunning = bridge.shouldKeepRunning(); - this.onNewIntent(getIntent()); - } - - public void registerPlugin(Class plugin) { - bridgeBuilder.addPlugin(plugin); - } - - public void registerPlugins(List> plugins) { - bridgeBuilder.addPlugins(plugins); - } - - public Bridge getBridge() { - return this.bridge; - } - - @Override - public void onSaveInstanceState(Bundle outState) { - super.onSaveInstanceState(outState); - bridge.saveInstanceState(outState); - } - - @Override - public void onStart() { - super.onStart(); - activityDepth++; - this.bridge.onStart(); - Logger.debug("App started"); - } - - @Override - public void onRestart() { - super.onRestart(); - this.bridge.onRestart(); - Logger.debug("App restarted"); - } - - @Override - public void onResume() { - super.onResume(); - bridge.getApp().fireStatusChange(true); - this.bridge.onResume(); - Logger.debug("App resumed"); - } - - @Override - public void onPause() { - super.onPause(); - this.bridge.onPause(); - Logger.debug("App paused"); - } - - @Override - public void onStop() { - super.onStop(); - - activityDepth = Math.max(0, activityDepth - 1); - if (activityDepth == 0) { - bridge.getApp().fireStatusChange(false); - } - - this.bridge.onStop(); - Logger.debug("App stopped"); - } - - @Override - public void onDestroy() { - super.onDestroy(); - this.bridge.onDestroy(); - Logger.debug("App destroyed"); - } - - @Override - public void onDetachedFromWindow() { - super.onDetachedFromWindow(); - this.bridge.onDetachedFromWindow(); - } - - /** - * Handles permission request results. - * - * Capacitor is backwards compatible such that plugins using legacy permission request codes - * may coexist with plugins using the AndroidX Activity v1.2 permission callback flow introduced - * in Capacitor 3.0. - * - * In this method, plugins are checked first for ownership of the legacy permission request code. - * If the {@link Bridge#onRequestPermissionsResult(int, String[], int[])} method indicates it has - * handled the permission, then the permission callback will be considered complete. Otherwise, - * the permission will be handled using the AndroidX Activity flow. - * - * @param requestCode the request code associated with the permission request - * @param permissions the Android permission strings requested - * @param grantResults the status result of the permission request - */ - @Override - public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) { - if (this.bridge == null) { - return; - } - - if (!bridge.onRequestPermissionsResult(requestCode, permissions, grantResults)) { - super.onRequestPermissionsResult(requestCode, permissions, grantResults); - } - } - - /** - * Handles activity results. - * - * Capacitor is backwards compatible such that plugins using legacy activity result codes - * may coexist with plugins using the AndroidX Activity v1.2 activity callback flow introduced - * in Capacitor 3.0. - * - * In this method, plugins are checked first for ownership of the legacy request code. If the - * {@link Bridge#onActivityResult(int, int, Intent)} method indicates it has handled the activity - * result, then the callback will be considered complete. Otherwise, the result will be handled - * using the AndroidX Activiy flow. - * - * @param requestCode the request code associated with the activity result - * @param resultCode the result code - * @param data any data included with the activity result - */ - @Override - protected void onActivityResult(int requestCode, int resultCode, Intent data) { - if (this.bridge == null) { - return; - } - - if (!bridge.onActivityResult(requestCode, resultCode, data)) { - super.onActivityResult(requestCode, resultCode, data); - } - } - - @Override - protected void onNewIntent(Intent intent) { - super.onNewIntent(intent); - - if (this.bridge == null || intent == null) { - return; - } - - this.bridge.onNewIntent(intent); - } - - @Override - public void onConfigurationChanged(Configuration newConfig) { - super.onConfigurationChanged(newConfig); - - if (this.bridge == null) { - return; - } - - this.bridge.onConfigurationChanged(newConfig); - } -} diff --git a/@capacitor/android/capacitor/src/main/java/com/getcapacitor/BridgeActivity.kt b/@capacitor/android/capacitor/src/main/java/com/getcapacitor/BridgeActivity.kt new file mode 100644 index 00000000..b85a22d0 --- /dev/null +++ b/@capacitor/android/capacitor/src/main/java/com/getcapacitor/BridgeActivity.kt @@ -0,0 +1,185 @@ +package com.getcapacitor + +import android.content.Intent +import android.content.res.Configuration +import android.os.Bundle +import androidx.appcompat.app.AppCompatActivity +import com.getcapacitor.android.R +import kotlin.math.max + +open class BridgeActivity : AppCompatActivity() { + var bridge: Bridge? = null + protected set + protected var keepRunning: Boolean = true + protected var config: CapConfig? = null + + protected var activityDepth: Int = 0 + protected var initialPlugins: List> = ArrayList() + protected val bridgeBuilder: Bridge.Builder = Bridge.Builder( + this + ) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + bridgeBuilder.setInstanceState(savedInstanceState) + application.setTheme(R.style.AppTheme_NoActionBar) + setTheme(R.style.AppTheme_NoActionBar) + setContentView(R.layout.bridge_layout_main) + val loader = PluginManager(assets) + + try { + bridgeBuilder.addPlugins(loader.loadPluginClasses()) + } catch (ex: PluginLoadException) { + Logger.Companion.error("Error loading plugins.", ex) + } + + this.load() + } + + protected fun load() { + Logger.Companion.debug("Starting BridgeActivity") + + bridge = bridgeBuilder.addPlugins(initialPlugins).setConfig(config).create() + + this.keepRunning = bridge!!.shouldKeepRunning() + this.onNewIntent(intent) + } + + fun registerPlugin(plugin: Class?) { + bridgeBuilder.addPlugin(plugin!!) + } + + fun registerPlugins(plugins: List?>?) { + bridgeBuilder.addPlugins(plugins) + } + + public override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + bridge!!.saveInstanceState(outState) + } + + public override fun onStart() { + super.onStart() + activityDepth++ + bridge!!.onStart() + Logger.Companion.debug("App started") + } + + public override fun onRestart() { + super.onRestart() + bridge!!.onRestart() + Logger.Companion.debug("App restarted") + } + + public override fun onResume() { + super.onResume() + bridge!!.app.fireStatusChange(true) + bridge!!.onResume() + Logger.Companion.debug("App resumed") + } + + public override fun onPause() { + super.onPause() + bridge!!.onPause() + Logger.Companion.debug("App paused") + } + + public override fun onStop() { + super.onStop() + + activityDepth = max(0.0, (activityDepth - 1).toDouble()).toInt() + if (activityDepth == 0) { + bridge!!.app.fireStatusChange(false) + } + + bridge!!.onStop() + Logger.Companion.debug("App stopped") + } + + public override fun onDestroy() { + super.onDestroy() + bridge!!.onDestroy() + Logger.Companion.debug("App destroyed") + } + + override fun onDetachedFromWindow() { + super.onDetachedFromWindow() + bridge!!.onDetachedFromWindow() + } + + /** + * Handles permission request results. + * + * Capacitor is backwards compatible such that plugins using legacy permission request codes + * may coexist with plugins using the AndroidX Activity v1.2 permission callback flow introduced + * in Capacitor 3.0. + * + * In this method, plugins are checked first for ownership of the legacy permission request code. + * If the [Bridge.onRequestPermissionsResult] method indicates it has + * handled the permission, then the permission callback will be considered complete. Otherwise, + * the permission will be handled using the AndroidX Activity flow. + * + * @param requestCode the request code associated with the permission request + * @param permissions the Android permission strings requested + * @param grantResults the status result of the permission request + */ + override fun onRequestPermissionsResult( + requestCode: Int, + permissions: Array, + grantResults: IntArray + ) { + if (this.bridge == null) { + return + } + + if (!bridge!!.onRequestPermissionsResult(requestCode, permissions, grantResults)) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + } + } + + /** + * Handles activity results. + * + * Capacitor is backwards compatible such that plugins using legacy activity result codes + * may coexist with plugins using the AndroidX Activity v1.2 activity callback flow introduced + * in Capacitor 3.0. + * + * In this method, plugins are checked first for ownership of the legacy request code. If the + * [Bridge.onActivityResult] method indicates it has handled the activity + * result, then the callback will be considered complete. Otherwise, the result will be handled + * using the AndroidX Activiy flow. + * + * @param requestCode the request code associated with the activity result + * @param resultCode the result code + * @param data any data included with the activity result + */ + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + if (this.bridge == null) { + return + } + + if (!bridge!!.onActivityResult(requestCode, resultCode, data)) { + super.onActivityResult(requestCode, resultCode, data) + } + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + + if (this.bridge == null || intent == null) { + return + } + + bridge!!.onNewIntent(intent) + } + + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + + if (this.bridge == null) { + return + } + + bridge!!.onConfigurationChanged(newConfig) + } +} diff --git a/@capacitor/android/capacitor/src/main/java/com/getcapacitor/BridgeFragment.java b/@capacitor/android/capacitor/src/main/java/com/getcapacitor/BridgeFragment.java deleted file mode 100644 index f269bd56..00000000 --- a/@capacitor/android/capacitor/src/main/java/com/getcapacitor/BridgeFragment.java +++ /dev/null @@ -1,134 +0,0 @@ -package com.getcapacitor; - -import android.content.Context; -import android.content.res.TypedArray; -import android.os.Bundle; -import android.util.AttributeSet; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import androidx.fragment.app.Fragment; -import com.getcapacitor.android.R; -import java.util.ArrayList; -import java.util.List; - -/** - * A simple {@link Fragment} subclass. - * Use the {@link BridgeFragment#newInstance} factory method to - * create an instance of this fragment. - */ -public class BridgeFragment extends Fragment { - - private static final String ARG_START_DIR = "startDir"; - - protected Bridge bridge; - protected boolean keepRunning = true; - - private final List> initialPlugins = new ArrayList<>(); - private CapConfig config = null; - - private final List webViewListeners = new ArrayList<>(); - - public BridgeFragment() { - // Required empty public constructor - } - - /** - * Use this factory method to create a new instance of - * this fragment using the provided parameters. - * - * @param startDir the directory to serve content from - * @return A new instance of fragment BridgeFragment. - */ - public static BridgeFragment newInstance(String startDir) { - BridgeFragment fragment = new BridgeFragment(); - Bundle args = new Bundle(); - args.putString(ARG_START_DIR, startDir); - fragment.setArguments(args); - return fragment; - } - - public void addPlugin(Class plugin) { - this.initialPlugins.add(plugin); - } - - public void setConfig(CapConfig config) { - this.config = config; - } - - public Bridge getBridge() { - return bridge; - } - - public void addWebViewListener(WebViewListener webViewListener) { - webViewListeners.add(webViewListener); - } - - /** - * Load the WebView and create the Bridge - */ - protected void load(Bundle savedInstanceState) { - Logger.debug("Loading Bridge with BridgeFragment"); - - Bundle args = getArguments(); - String startDir = null; - - if (args != null) { - startDir = getArguments().getString(ARG_START_DIR); - } - - bridge = - new Bridge.Builder(this) - .setInstanceState(savedInstanceState) - .setPlugins(initialPlugins) - .setConfig(config) - .addWebViewListeners(webViewListeners) - .create(); - - if (startDir != null) { - bridge.setServerAssetPath(startDir); - } - - this.keepRunning = bridge.shouldKeepRunning(); - } - - @Override - public void onInflate(Context context, AttributeSet attrs, Bundle savedInstanceState) { - super.onInflate(context, attrs, savedInstanceState); - - TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.bridge_fragment); - CharSequence c = a.getString(R.styleable.bridge_fragment_start_dir); - - if (c != null) { - String startDir = c.toString(); - Bundle args = new Bundle(); - args.putString(ARG_START_DIR, startDir); - setArguments(args); - } - } - - @Override - public void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - } - - @Override - public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { - // Inflate the layout for this fragment - return inflater.inflate(R.layout.fragment_bridge, container, false); - } - - @Override - public void onViewCreated(View view, Bundle savedInstanceState) { - super.onViewCreated(view, savedInstanceState); - this.load(savedInstanceState); - } - - @Override - public void onDestroy() { - super.onDestroy(); - if (this.bridge != null) { - this.bridge.onDestroy(); - } - } -} diff --git a/@capacitor/android/capacitor/src/main/java/com/getcapacitor/BridgeFragment.kt b/@capacitor/android/capacitor/src/main/java/com/getcapacitor/BridgeFragment.kt new file mode 100644 index 00000000..40a248da --- /dev/null +++ b/@capacitor/android/capacitor/src/main/java/com/getcapacitor/BridgeFragment.kt @@ -0,0 +1,124 @@ +package com.getcapacitor + +import android.content.Context +import android.os.Bundle +import android.util.AttributeSet +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import com.getcapacitor.android.R + +/** + * A simple [Fragment] subclass. + * Use the [BridgeFragment.newInstance] factory method to + * create an instance of this fragment. + */ +class BridgeFragment : Fragment() { + var bridge: Bridge? = null + protected set + protected var keepRunning: Boolean = true + + private val initialPlugins: MutableList> = ArrayList() + private var config: CapConfig? = null + + private val webViewListeners: MutableList = ArrayList() + + fun addPlugin(plugin: Class) { + initialPlugins.add(plugin) + } + + fun setConfig(config: CapConfig?) { + this.config = config + } + + fun addWebViewListener(webViewListener: WebViewListener) { + webViewListeners.add(webViewListener) + } + + /** + * Load the WebView and create the Bridge + */ + protected fun load(savedInstanceState: Bundle?) { + Logger.Companion.debug("Loading Bridge with BridgeFragment") + + val args = arguments + var startDir: String? = null + + if (args != null) { + startDir = arguments!!.getString(ARG_START_DIR) + } + + bridge = + Bridge.Builder(this) + .setInstanceState(savedInstanceState) + .setPlugins(initialPlugins) + .setConfig(config) + .addWebViewListeners(webViewListeners) + .create() + + if (startDir != null) { + bridge!!.setServerAssetPath(startDir) + } + + this.keepRunning = bridge!!.shouldKeepRunning() + } + + override fun onInflate(context: Context, attrs: AttributeSet, savedInstanceState: Bundle?) { + super.onInflate(context, attrs, savedInstanceState) + + val a = context.obtainStyledAttributes(attrs, R.styleable.bridge_fragment) + val c: CharSequence? = a.getString(R.styleable.bridge_fragment_start_dir) + + if (c != null) { + val startDir = c.toString() + val args = Bundle() + args.putString(ARG_START_DIR, startDir) + arguments = args + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + // Inflate the layout for this fragment + return inflater.inflate(R.layout.fragment_bridge, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + this.load(savedInstanceState) + } + + override fun onDestroy() { + super.onDestroy() + if (this.bridge != null) { + bridge!!.onDestroy() + } + } + + companion object { + private const val ARG_START_DIR = "startDir" + + /** + * Use this factory method to create a new instance of + * this fragment using the provided parameters. + * + * @param startDir the directory to serve content from + * @return A new instance of fragment BridgeFragment. + */ + fun newInstance(startDir: String?): BridgeFragment { + val fragment = BridgeFragment() + val args = Bundle() + args.putString(ARG_START_DIR, startDir) + fragment.arguments = args + return fragment + } + } +} diff --git a/@capacitor/android/capacitor/src/main/java/com/getcapacitor/BridgeWebChromeClient.java b/@capacitor/android/capacitor/src/main/java/com/getcapacitor/BridgeWebChromeClient.java deleted file mode 100644 index 400b65a0..00000000 --- a/@capacitor/android/capacitor/src/main/java/com/getcapacitor/BridgeWebChromeClient.java +++ /dev/null @@ -1,510 +0,0 @@ -package com.getcapacitor; - -import android.Manifest; -import android.annotation.SuppressLint; -import android.app.Activity; -import android.app.AlertDialog; -import android.content.ActivityNotFoundException; -import android.content.Intent; -import android.net.Uri; -import android.os.Build; -import android.os.Environment; -import android.provider.MediaStore; -import android.view.View; -import android.webkit.ConsoleMessage; -import android.webkit.GeolocationPermissions; -import android.webkit.JsPromptResult; -import android.webkit.JsResult; -import android.webkit.MimeTypeMap; -import android.webkit.PermissionRequest; -import android.webkit.ValueCallback; -import android.webkit.WebChromeClient; -import android.webkit.WebView; -import android.widget.EditText; -import androidx.activity.result.ActivityResult; -import androidx.activity.result.ActivityResultCallback; -import androidx.activity.result.ActivityResultLauncher; -import androidx.activity.result.contract.ActivityResultContracts; -import androidx.core.content.FileProvider; -import com.getcapacitor.util.PermissionHelper; -import java.io.File; -import java.io.IOException; -import java.text.SimpleDateFormat; -import java.util.*; - -/** - * Custom WebChromeClient handler, required for showing dialogs, confirms, etc. in our - * WebView instance. - */ -public class BridgeWebChromeClient extends WebChromeClient { - - private interface PermissionListener { - void onPermissionSelect(Boolean isGranted); - } - - private interface ActivityResultListener { - void onActivityResult(ActivityResult result); - } - - private ActivityResultLauncher permissionLauncher; - private ActivityResultLauncher activityLauncher; - private PermissionListener permissionListener; - private ActivityResultListener activityListener; - - private Bridge bridge; - - public BridgeWebChromeClient(Bridge bridge) { - this.bridge = bridge; - - ActivityResultCallback> permissionCallback = (Map isGranted) -> { - if (permissionListener != null) { - boolean granted = true; - for (Map.Entry permission : isGranted.entrySet()) { - if (!permission.getValue()) granted = false; - } - permissionListener.onPermissionSelect(granted); - } - }; - - permissionLauncher = bridge.registerForActivityResult(new ActivityResultContracts.RequestMultiplePermissions(), permissionCallback); - activityLauncher = - bridge.registerForActivityResult( - new ActivityResultContracts.StartActivityForResult(), - result -> { - if (activityListener != null) { - activityListener.onActivityResult(result); - } - } - ); - } - - /** - * Render web content in `view`. - * - * Both this method and {@link #onHideCustomView()} are required for - * rendering web content in full screen. - * - * @see onShowCustomView() docs - */ - @Override - public void onShowCustomView(View view, CustomViewCallback callback) { - callback.onCustomViewHidden(); - super.onShowCustomView(view, callback); - } - - /** - * Render web content in the original Web View again. - * - * Do not remove this method--@see #onShowCustomView(View, CustomViewCallback). - */ - @Override - public void onHideCustomView() { - super.onHideCustomView(); - } - - @Override - public void onPermissionRequest(final PermissionRequest request) { - boolean isRequestPermissionRequired = android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.M; - - List permissionList = new ArrayList<>(); - if (Arrays.asList(request.getResources()).contains("android.webkit.resource.VIDEO_CAPTURE")) { - permissionList.add(Manifest.permission.CAMERA); - } - if (Arrays.asList(request.getResources()).contains("android.webkit.resource.AUDIO_CAPTURE")) { - permissionList.add(Manifest.permission.MODIFY_AUDIO_SETTINGS); - permissionList.add(Manifest.permission.RECORD_AUDIO); - } - if (!permissionList.isEmpty() && isRequestPermissionRequired) { - String[] permissions = permissionList.toArray(new String[0]); - permissionListener = - isGranted -> { - if (isGranted) { - request.grant(request.getResources()); - } else { - request.deny(); - } - }; - permissionLauncher.launch(permissions); - } else { - request.grant(request.getResources()); - } - } - - /** - * Show the browser alert modal - * @param view - * @param url - * @param message - * @param result - * @return - */ - @Override - public boolean onJsAlert(WebView view, String url, String message, final JsResult result) { - if (bridge.getActivity().isFinishing()) { - return true; - } - - AlertDialog.Builder builder = new AlertDialog.Builder(view.getContext()); - builder - .setMessage(message) - .setPositiveButton( - "OK", - (dialog, buttonIndex) -> { - dialog.dismiss(); - result.confirm(); - } - ) - .setOnCancelListener( - dialog -> { - dialog.dismiss(); - result.cancel(); - } - ); - - AlertDialog dialog = builder.create(); - - dialog.show(); - - return true; - } - - /** - * Show the browser confirm modal - * @param view - * @param url - * @param message - * @param result - * @return - */ - @Override - public boolean onJsConfirm(WebView view, String url, String message, final JsResult result) { - if (bridge.getActivity().isFinishing()) { - return true; - } - - final AlertDialog.Builder builder = new AlertDialog.Builder(view.getContext()); - - builder - .setMessage(message) - .setPositiveButton( - "OK", - (dialog, buttonIndex) -> { - dialog.dismiss(); - result.confirm(); - } - ) - .setNegativeButton( - "Cancel", - (dialog, buttonIndex) -> { - dialog.dismiss(); - result.cancel(); - } - ) - .setOnCancelListener( - dialog -> { - dialog.dismiss(); - result.cancel(); - } - ); - - AlertDialog dialog = builder.create(); - - dialog.show(); - - return true; - } - - /** - * Show the browser prompt modal - * @param view - * @param url - * @param message - * @param defaultValue - * @param result - * @return - */ - @Override - public boolean onJsPrompt(WebView view, String url, String message, String defaultValue, final JsPromptResult result) { - if (bridge.getActivity().isFinishing()) { - return true; - } - - final AlertDialog.Builder builder = new AlertDialog.Builder(view.getContext()); - final EditText input = new EditText(view.getContext()); - - builder - .setMessage(message) - .setView(input) - .setPositiveButton( - "OK", - (dialog, buttonIndex) -> { - dialog.dismiss(); - - String inputText1 = input.getText().toString().trim(); - result.confirm(inputText1); - } - ) - .setNegativeButton( - "Cancel", - (dialog, buttonIndex) -> { - dialog.dismiss(); - result.cancel(); - } - ) - .setOnCancelListener( - dialog -> { - dialog.dismiss(); - result.cancel(); - } - ); - - AlertDialog dialog = builder.create(); - - dialog.show(); - - return true; - } - - /** - * Handle the browser geolocation permission prompt - * @param origin - * @param callback - */ - @Override - public void onGeolocationPermissionsShowPrompt(String origin, GeolocationPermissions.Callback callback) { - super.onGeolocationPermissionsShowPrompt(origin, callback); - Logger.debug("onGeolocationPermissionsShowPrompt: DOING IT HERE FOR ORIGIN: " + origin); - final String[] geoPermissions = { Manifest.permission.ACCESS_COARSE_LOCATION, Manifest.permission.ACCESS_FINE_LOCATION }; - - if (!PermissionHelper.hasPermissions(bridge.getContext(), geoPermissions)) { - permissionListener = - isGranted -> { - if (isGranted) { - callback.invoke(origin, true, false); - } else { - final String[] coarsePermission = { Manifest.permission.ACCESS_COARSE_LOCATION }; - if ( - Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && - PermissionHelper.hasPermissions(bridge.getContext(), coarsePermission) - ) { - callback.invoke(origin, true, false); - } else { - callback.invoke(origin, false, false); - } - } - }; - permissionLauncher.launch(geoPermissions); - } else { - // permission is already granted - callback.invoke(origin, true, false); - Logger.debug("onGeolocationPermissionsShowPrompt: has required permission"); - } - } - - @Override - public boolean onShowFileChooser( - WebView webView, - final ValueCallback filePathCallback, - final FileChooserParams fileChooserParams - ) { - List acceptTypes = Arrays.asList(fileChooserParams.getAcceptTypes()); - boolean captureEnabled = fileChooserParams.isCaptureEnabled(); - boolean capturePhoto = captureEnabled && acceptTypes.contains("image/*"); - final boolean captureVideo = captureEnabled && acceptTypes.contains("video/*"); - if ((capturePhoto || captureVideo)) { - if (isMediaCaptureSupported()) { - showMediaCaptureOrFilePicker(filePathCallback, fileChooserParams, captureVideo); - } else { - permissionListener = - isGranted -> { - if (isGranted) { - showMediaCaptureOrFilePicker(filePathCallback, fileChooserParams, captureVideo); - } else { - Logger.warn(Logger.tags("FileChooser"), "Camera permission not granted"); - filePathCallback.onReceiveValue(null); - } - }; - final String[] camPermission = { Manifest.permission.CAMERA }; - permissionLauncher.launch(camPermission); - } - } else { - showFilePicker(filePathCallback, fileChooserParams); - } - - return true; - } - - private boolean isMediaCaptureSupported() { - String[] permissions = { Manifest.permission.CAMERA }; - return ( - PermissionHelper.hasPermissions(bridge.getContext(), permissions) || - !PermissionHelper.hasDefinedPermission(bridge.getContext(), Manifest.permission.CAMERA) - ); - } - - private void showMediaCaptureOrFilePicker(ValueCallback filePathCallback, FileChooserParams fileChooserParams, boolean isVideo) { - // TODO: add support for video capture on Android M and older - // On Android M and lower the VIDEO_CAPTURE_INTENT (e.g.: intent.getData()) - // returns a file:// URI instead of the expected content:// URI. - // So we disable it for now because it requires a bit more work - boolean isVideoCaptureSupported = android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.N; - boolean shown = false; - if (isVideo && isVideoCaptureSupported) { - shown = showVideoCapturePicker(filePathCallback); - } else { - shown = showImageCapturePicker(filePathCallback); - } - if (!shown) { - Logger.warn(Logger.tags("FileChooser"), "Media capture intent could not be launched. Falling back to default file picker."); - showFilePicker(filePathCallback, fileChooserParams); - } - } - - @SuppressLint("QueryPermissionsNeeded") - private boolean showImageCapturePicker(final ValueCallback filePathCallback) { - Intent takePictureIntent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE); - if (takePictureIntent.resolveActivity(bridge.getActivity().getPackageManager()) == null) { - return false; - } - - final Uri imageFileUri; - try { - imageFileUri = createImageFileUri(); - } catch (Exception ex) { - Logger.error("Unable to create temporary media capture file: " + ex.getMessage()); - return false; - } - takePictureIntent.putExtra(MediaStore.EXTRA_OUTPUT, imageFileUri); - activityListener = - activityResult -> { - Uri[] result = null; - if (activityResult.getResultCode() == Activity.RESULT_OK) { - result = new Uri[] { imageFileUri }; - } - filePathCallback.onReceiveValue(result); - }; - activityLauncher.launch(takePictureIntent); - - return true; - } - - @SuppressLint("QueryPermissionsNeeded") - private boolean showVideoCapturePicker(final ValueCallback filePathCallback) { - Intent takeVideoIntent = new Intent(MediaStore.ACTION_VIDEO_CAPTURE); - if (takeVideoIntent.resolveActivity(bridge.getActivity().getPackageManager()) == null) { - return false; - } - - activityListener = - activityResult -> { - Uri[] result = null; - if (activityResult.getResultCode() == Activity.RESULT_OK) { - result = new Uri[] { activityResult.getData().getData() }; - } - filePathCallback.onReceiveValue(result); - }; - activityLauncher.launch(takeVideoIntent); - - return true; - } - - private void showFilePicker(final ValueCallback filePathCallback, FileChooserParams fileChooserParams) { - Intent intent = fileChooserParams.createIntent(); - if (fileChooserParams.getMode() == FileChooserParams.MODE_OPEN_MULTIPLE) { - intent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true); - } - if (fileChooserParams.getAcceptTypes().length > 1 || intent.getType().startsWith(".")) { - String[] validTypes = getValidTypes(fileChooserParams.getAcceptTypes()); - intent.putExtra(Intent.EXTRA_MIME_TYPES, validTypes); - if (intent.getType().startsWith(".")) { - intent.setType(validTypes[0]); - } - } - try { - activityListener = - activityResult -> { - Uri[] result; - Intent resultIntent = activityResult.getData(); - if (activityResult.getResultCode() == Activity.RESULT_OK && resultIntent.getClipData() != null) { - final int numFiles = resultIntent.getClipData().getItemCount(); - result = new Uri[numFiles]; - for (int i = 0; i < numFiles; i++) { - result[i] = resultIntent.getClipData().getItemAt(i).getUri(); - } - } else { - result = WebChromeClient.FileChooserParams.parseResult(activityResult.getResultCode(), resultIntent); - } - filePathCallback.onReceiveValue(result); - }; - activityLauncher.launch(intent); - } catch (ActivityNotFoundException e) { - filePathCallback.onReceiveValue(null); - } - } - - private String[] getValidTypes(String[] currentTypes) { - List validTypes = new ArrayList<>(); - MimeTypeMap mtm = MimeTypeMap.getSingleton(); - for (String mime : currentTypes) { - if (mime.startsWith(".")) { - String extension = mime.substring(1); - String extensionMime = mtm.getMimeTypeFromExtension(extension); - if (extensionMime != null && !validTypes.contains(extensionMime)) { - validTypes.add(extensionMime); - } - } else if (!validTypes.contains(mime)) { - validTypes.add(mime); - } - } - Object[] validObj = validTypes.toArray(); - return Arrays.copyOf(validObj, validObj.length, String[].class); - } - - @Override - public boolean onConsoleMessage(ConsoleMessage consoleMessage) { - String tag = Logger.tags("Console"); - if (consoleMessage.message() != null && isValidMsg(consoleMessage.message())) { - String msg = String.format( - "File: %s - Line %d - Msg: %s", - consoleMessage.sourceId(), - consoleMessage.lineNumber(), - consoleMessage.message() - ); - String level = consoleMessage.messageLevel().name(); - if ("ERROR".equalsIgnoreCase(level)) { - Logger.error(tag, msg, null); - } else if ("WARNING".equalsIgnoreCase(level)) { - Logger.warn(tag, msg); - } else if ("TIP".equalsIgnoreCase(level)) { - Logger.debug(tag, msg); - } else { - Logger.info(tag, msg); - } - } - return true; - } - - public boolean isValidMsg(String msg) { - return !( - msg.contains("%cresult %c") || - (msg.contains("%cnative %c")) || - msg.equalsIgnoreCase("[object Object]") || - msg.equalsIgnoreCase("console.groupEnd") - ); - } - - private Uri createImageFileUri() throws IOException { - Activity activity = bridge.getActivity(); - File photoFile = createImageFile(activity); - return FileProvider.getUriForFile(activity, bridge.getContext().getPackageName() + ".fileprovider", photoFile); - } - - private File createImageFile(Activity activity) throws IOException { - // Create an image file name - String timeStamp = new SimpleDateFormat("yyyyMMdd_HHmmss").format(new Date()); - String imageFileName = "JPEG_" + timeStamp + "_"; - File storageDir = activity.getExternalFilesDir(Environment.DIRECTORY_PICTURES); - - return File.createTempFile(imageFileName, ".jpg", storageDir); - } -} diff --git a/@capacitor/android/capacitor/src/main/java/com/getcapacitor/BridgeWebChromeClient.kt b/@capacitor/android/capacitor/src/main/java/com/getcapacitor/BridgeWebChromeClient.kt new file mode 100644 index 00000000..894bcb57 --- /dev/null +++ b/@capacitor/android/capacitor/src/main/java/com/getcapacitor/BridgeWebChromeClient.kt @@ -0,0 +1,534 @@ +package com.getcapacitor + +import android.Manifest +import android.annotation.SuppressLint +import android.app.Activity +import android.app.AlertDialog +import android.content.ActivityNotFoundException +import android.content.DialogInterface +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.os.Environment +import android.provider.MediaStore +import android.view.View +import android.webkit.ConsoleMessage +import android.webkit.GeolocationPermissions +import android.webkit.JsPromptResult +import android.webkit.JsResult +import android.webkit.MimeTypeMap +import android.webkit.PermissionRequest +import android.webkit.ValueCallback +import android.webkit.WebChromeClient +import android.webkit.WebView +import android.widget.EditText +import androidx.activity.result.ActivityResult +import androidx.activity.result.ActivityResultCallback +import androidx.activity.result.ActivityResultLauncher +import androidx.core.content.FileProvider +import com.getcapacitor.util.PermissionHelper +import java.io.File +import java.io.IOException +import java.text.SimpleDateFormat +import java.util.Arrays +import java.util.Date + +/** + * Custom WebChromeClient handler, required for showing dialogs, confirms, etc. in our + * WebView instance. + */ +class BridgeWebChromeClient(private val bridge: Bridge) : WebChromeClient() { + private interface PermissionListener { + fun onPermissionSelect(isGranted: Boolean?) + } + + private interface ActivityResultListener { + fun onActivityResult(result: ActivityResult?) + } + + private val permissionLauncher: ActivityResultLauncher<*> + private val activityLauncher: ActivityResultLauncher<*> + private var permissionListener: PermissionListener? = null + private var activityListener: ActivityResultListener? = null + + init { + val permissionCallback = ActivityResultCallback { isGranted: Map -> + if (permissionListener != null) { + var granted = true + for ((_, value) in isGranted) { + if (!value!!) granted = false + } + permissionListener!!.onPermissionSelect(granted) + } + } + + permissionLauncher = + bridge.registerForActivityResult, Map>( + RequestMultiplePermissions(), + permissionCallback + ) + activityLauncher = + bridge.registerForActivityResult( + StartActivityForResult() + ) { result: ActivityResult? -> + if (activityListener != null) { + activityListener!!.onActivityResult(result) + } + } + } + + /** + * Render web content in `view`. + * + * Both this method and [.onHideCustomView] are required for + * rendering web content in full screen. + * + * @see [](https://developer.android.com/reference/android/webkit/WebChromeClient.onShowCustomView + ) */ + override fun onShowCustomView(view: View, callback: CustomViewCallback) { + callback.onCustomViewHidden() + super.onShowCustomView(view, callback) + } + + /** + * Render web content in the original Web View again. + * + * Do not remove this method--@see #onShowCustomView(View, CustomViewCallback). + */ + override fun onHideCustomView() { + super.onHideCustomView() + } + + override fun onPermissionRequest(request: PermissionRequest) { + val isRequestPermissionRequired = Build.VERSION.SDK_INT >= Build.VERSION_CODES.M + + val permissionList: MutableList = ArrayList() + if (Arrays.asList(*request.resources) + .contains("android.webkit.resource.VIDEO_CAPTURE") + ) { + permissionList.add(Manifest.permission.CAMERA) + } + if (Arrays.asList(*request.resources) + .contains("android.webkit.resource.AUDIO_CAPTURE") + ) { + permissionList.add(Manifest.permission.MODIFY_AUDIO_SETTINGS) + permissionList.add(Manifest.permission.RECORD_AUDIO) + } + if (!permissionList.isEmpty() && isRequestPermissionRequired) { + val permissions = permissionList.toTypedArray() + permissionListener = + PermissionListener { isGranted: Boolean -> + if (isGranted) { + request.grant(request.resources) + } else { + request.deny() + } + } + permissionLauncher.launch(permissions) + } else { + request.grant(request.resources) + } + } + + /** + * Show the browser alert modal + * @param view + * @param url + * @param message + * @param result + * @return + */ + override fun onJsAlert(view: WebView, url: String, message: String, result: JsResult): Boolean { + if (bridge.activity!!.isFinishing) { + return true + } + + val builder = AlertDialog.Builder(view.context) + builder + .setMessage(message) + .setPositiveButton( + "OK" + ) { dialog: DialogInterface, buttonIndex: Int -> + dialog.dismiss() + result.confirm() + } + .setOnCancelListener { dialog: DialogInterface -> + dialog.dismiss() + result.cancel() + } + + val dialog = builder.create() + + dialog.show() + + return true + } + + /** + * Show the browser confirm modal + * @param view + * @param url + * @param message + * @param result + * @return + */ + override fun onJsConfirm( + view: WebView, + url: String, + message: String, + result: JsResult + ): Boolean { + if (bridge.activity!!.isFinishing) { + return true + } + + val builder = AlertDialog.Builder(view.context) + + builder + .setMessage(message) + .setPositiveButton( + "OK" + ) { dialog: DialogInterface, buttonIndex: Int -> + dialog.dismiss() + result.confirm() + } + .setNegativeButton( + "Cancel" + ) { dialog: DialogInterface, buttonIndex: Int -> + dialog.dismiss() + result.cancel() + } + .setOnCancelListener { dialog: DialogInterface -> + dialog.dismiss() + result.cancel() + } + + val dialog = builder.create() + + dialog.show() + + return true + } + + /** + * Show the browser prompt modal + * @param view + * @param url + * @param message + * @param defaultValue + * @param result + * @return + */ + override fun onJsPrompt( + view: WebView, + url: String, + message: String, + defaultValue: String, + result: JsPromptResult + ): Boolean { + if (bridge.activity!!.isFinishing) { + return true + } + + val builder = AlertDialog.Builder(view.context) + val input = EditText(view.context) + + builder + .setMessage(message) + .setView(input) + .setPositiveButton( + "OK" + ) { dialog: DialogInterface, buttonIndex: Int -> + dialog.dismiss() + val inputText1 = input.text.toString().trim { it <= ' ' } + result.confirm(inputText1) + } + .setNegativeButton( + "Cancel" + ) { dialog: DialogInterface, buttonIndex: Int -> + dialog.dismiss() + result.cancel() + } + .setOnCancelListener { dialog: DialogInterface -> + dialog.dismiss() + result.cancel() + } + + val dialog = builder.create() + + dialog.show() + + return true + } + + /** + * Handle the browser geolocation permission prompt + * @param origin + * @param callback + */ + override fun onGeolocationPermissionsShowPrompt( + origin: String, + callback: GeolocationPermissions.Callback + ) { + super.onGeolocationPermissionsShowPrompt(origin, callback) + Logger.Companion.debug("onGeolocationPermissionsShowPrompt: DOING IT HERE FOR ORIGIN: $origin") + val geoPermissions = arrayOf( + Manifest.permission.ACCESS_COARSE_LOCATION, + Manifest.permission.ACCESS_FINE_LOCATION + ) + + if (!PermissionHelper.hasPermissions(bridge.getContext(), geoPermissions)) { + permissionListener = + PermissionListener { isGranted: Boolean -> + if (isGranted) { + callback.invoke(origin, true, false) + } else { + val coarsePermission = arrayOf(Manifest.permission.ACCESS_COARSE_LOCATION) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && + PermissionHelper.hasPermissions(bridge.getContext(), coarsePermission) + ) { + callback.invoke(origin, true, false) + } else { + callback.invoke(origin, false, false) + } + } + } + permissionLauncher.launch(geoPermissions) + } else { + // permission is already granted + callback.invoke(origin, true, false) + Logger.Companion.debug("onGeolocationPermissionsShowPrompt: has required permission") + } + } + + override fun onShowFileChooser( + webView: WebView, + filePathCallback: ValueCallback>, + fileChooserParams: FileChooserParams + ): Boolean { + val acceptTypes = Arrays.asList(*fileChooserParams.acceptTypes) + val captureEnabled = fileChooserParams.isCaptureEnabled + val capturePhoto = captureEnabled && acceptTypes.contains("image/*") + val captureVideo = captureEnabled && acceptTypes.contains("video/*") + if ((capturePhoto || captureVideo)) { + if (isMediaCaptureSupported) { + showMediaCaptureOrFilePicker(filePathCallback, fileChooserParams, captureVideo) + } else { + permissionListener = + PermissionListener { isGranted: Boolean -> + if (isGranted) { + showMediaCaptureOrFilePicker( + filePathCallback, + fileChooserParams, + captureVideo + ) + } else { + Logger.Companion.warn( + Logger.Companion.tags("FileChooser"), + "Camera permission not granted" + ) + filePathCallback.onReceiveValue(null) + } + } + val camPermission = arrayOf(Manifest.permission.CAMERA) + permissionLauncher.launch(camPermission) + } + } else { + showFilePicker(filePathCallback, fileChooserParams) + } + + return true + } + + private val isMediaCaptureSupported: Boolean + get() { + val permissions = arrayOf(Manifest.permission.CAMERA) + return (PermissionHelper.hasPermissions(bridge.getContext(), permissions) || + !PermissionHelper.hasDefinedPermission( + bridge.getContext(), + Manifest.permission.CAMERA + ) + ) + } + + private fun showMediaCaptureOrFilePicker( + filePathCallback: ValueCallback>, + fileChooserParams: FileChooserParams, + isVideo: Boolean + ) { + // TODO: add support for video capture on Android M and older + // On Android M and lower the VIDEO_CAPTURE_INTENT (e.g.: intent.getData()) + // returns a file:// URI instead of the expected content:// URI. + // So we disable it for now because it requires a bit more work + val isVideoCaptureSupported = Build.VERSION.SDK_INT >= Build.VERSION_CODES.N + var shown = false + shown = if (isVideo && isVideoCaptureSupported) { + showVideoCapturePicker(filePathCallback) + } else { + showImageCapturePicker(filePathCallback) + } + if (!shown) { + Logger.Companion.warn( + Logger.Companion.tags("FileChooser"), + "Media capture intent could not be launched. Falling back to default file picker." + ) + showFilePicker(filePathCallback, fileChooserParams) + } + } + + @SuppressLint("QueryPermissionsNeeded") + private fun showImageCapturePicker(filePathCallback: ValueCallback?>): Boolean { + val takePictureIntent = Intent(MediaStore.ACTION_IMAGE_CAPTURE) + if (takePictureIntent.resolveActivity(bridge.activity!!.packageManager) == null) { + return false + } + + val imageFileUri: Uri + try { + imageFileUri = createImageFileUri() + } catch (ex: Exception) { + Logger.Companion.error("Unable to create temporary media capture file: " + ex.message) + return false + } + takePictureIntent.putExtra(MediaStore.EXTRA_OUTPUT, imageFileUri) + activityListener = + ActivityResultListener { activityResult: ActivityResult -> + var result: Array? = null + if (activityResult.resultCode == Activity.RESULT_OK) { + result = arrayOf(imageFileUri) + } + filePathCallback.onReceiveValue(result) + } + activityLauncher.launch(takePictureIntent) + + return true + } + + @SuppressLint("QueryPermissionsNeeded") + private fun showVideoCapturePicker(filePathCallback: ValueCallback?>): Boolean { + val takeVideoIntent = Intent(MediaStore.ACTION_VIDEO_CAPTURE) + if (takeVideoIntent.resolveActivity(bridge.activity!!.packageManager) == null) { + return false + } + + activityListener = + ActivityResultListener { activityResult: ActivityResult -> + var result: Array? = null + if (activityResult.resultCode == Activity.RESULT_OK) { + result = arrayOf(activityResult.data!!.data) + } + filePathCallback.onReceiveValue(result) + } + activityLauncher.launch(takeVideoIntent) + + return true + } + + private fun showFilePicker( + filePathCallback: ValueCallback?>, + fileChooserParams: FileChooserParams + ) { + val intent = fileChooserParams.createIntent() + if (fileChooserParams.mode == FileChooserParams.MODE_OPEN_MULTIPLE) { + intent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true) + } + if (fileChooserParams.acceptTypes.size > 1 || intent.type!!.startsWith(".")) { + val validTypes = getValidTypes(fileChooserParams.acceptTypes) + intent.putExtra(Intent.EXTRA_MIME_TYPES, validTypes) + if (intent.type!!.startsWith(".")) { + intent.setType(validTypes[0]) + } + } + try { + activityListener = + ActivityResultListener { activityResult: ActivityResult -> + val result: Array? + val resultIntent = activityResult.data + if (activityResult.resultCode == Activity.RESULT_OK && resultIntent!!.clipData != null) { + val numFiles = resultIntent.clipData!!.itemCount + result = arrayOfNulls(numFiles) + for (i in 0 until numFiles) { + result[i] = resultIntent.clipData!!.getItemAt(i).uri + } + } else { + result = + FileChooserParams.parseResult(activityResult.resultCode, resultIntent) + } + filePathCallback.onReceiveValue(result) + } + activityLauncher.launch(intent) + } catch (e: ActivityNotFoundException) { + filePathCallback.onReceiveValue(null) + } + } + + private fun getValidTypes(currentTypes: Array): Array { + val validTypes: MutableList = ArrayList() + val mtm = MimeTypeMap.getSingleton() + for (mime in currentTypes) { + if (mime.startsWith(".")) { + val extension = mime.substring(1) + val extensionMime = mtm.getMimeTypeFromExtension(extension) + if (extensionMime != null && !validTypes.contains(extensionMime)) { + validTypes.add(extensionMime) + } + } else if (!validTypes.contains(mime)) { + validTypes.add(mime) + } + } + val validObj: Array = validTypes.toTypedArray() + return Arrays.copyOf(validObj, validObj.size, Array::class.java) + } + + override fun onConsoleMessage(consoleMessage: ConsoleMessage): Boolean { + val tag: String = Logger.Companion.tags("Console") + if (consoleMessage.message() != null && isValidMsg(consoleMessage.message())) { + val msg = String.format( + "File: %s - Line %d - Msg: %s", + consoleMessage.sourceId(), + consoleMessage.lineNumber(), + consoleMessage.message() + ) + val level = consoleMessage.messageLevel().name + if ("ERROR".equals(level, ignoreCase = true)) { + Logger.Companion.error(tag, msg, null) + } else if ("WARNING".equals(level, ignoreCase = true)) { + Logger.Companion.warn(tag, msg) + } else if ("TIP".equals(level, ignoreCase = true)) { + Logger.Companion.debug(tag, msg) + } else { + Logger.Companion.info(tag, msg) + } + } + return true + } + + fun isValidMsg(msg: String): Boolean { + return !(msg.contains("%cresult %c") || + (msg.contains("%cnative %c")) || + msg.equals("[object Object]", ignoreCase = true) || + msg.equals("console.groupEnd", ignoreCase = true) + ) + } + + @Throws(IOException::class) + private fun createImageFileUri(): Uri { + val activity: Activity? = bridge.activity + val photoFile = createImageFile(activity) + return FileProvider.getUriForFile( + activity!!, + bridge.getContext()!!.packageName + ".fileprovider", + photoFile + ) + } + + @Throws(IOException::class) + private fun createImageFile(activity: Activity?): File { + // Create an image file name + val timeStamp = SimpleDateFormat("yyyyMMdd_HHmmss").format(Date()) + val imageFileName = "JPEG_" + timeStamp + "_" + val storageDir = activity!!.getExternalFilesDir(Environment.DIRECTORY_PICTURES) + + return File.createTempFile(imageFileName, ".jpg", storageDir) + } +} diff --git a/@capacitor/android/capacitor/src/main/java/com/getcapacitor/BridgeWebViewClient.java b/@capacitor/android/capacitor/src/main/java/com/getcapacitor/BridgeWebViewClient.java deleted file mode 100644 index c434247a..00000000 --- a/@capacitor/android/capacitor/src/main/java/com/getcapacitor/BridgeWebViewClient.java +++ /dev/null @@ -1,111 +0,0 @@ -package com.getcapacitor; - -import android.graphics.Bitmap; -import android.net.Uri; -import android.webkit.RenderProcessGoneDetail; -import android.webkit.WebResourceError; -import android.webkit.WebResourceRequest; -import android.webkit.WebResourceResponse; -import android.webkit.WebView; -import android.webkit.WebViewClient; -import java.util.List; - -public class BridgeWebViewClient extends WebViewClient { - - private Bridge bridge; - - public BridgeWebViewClient(Bridge bridge) { - this.bridge = bridge; - } - - @Override - public WebResourceResponse shouldInterceptRequest(WebView view, WebResourceRequest request) { - return bridge.getLocalServer().shouldInterceptRequest(request); - } - - @Override - public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) { - Uri url = request.getUrl(); - return bridge.launchIntent(url); - } - - @Deprecated - @Override - public boolean shouldOverrideUrlLoading(WebView view, String url) { - return bridge.launchIntent(Uri.parse(url)); - } - - @Override - public void onPageFinished(WebView view, String url) { - super.onPageFinished(view, url); - List webViewListeners = bridge.getWebViewListeners(); - - if (webViewListeners != null && view.getProgress() == 100) { - for (WebViewListener listener : bridge.getWebViewListeners()) { - listener.onPageLoaded(view); - } - } - } - - @Override - public void onReceivedError(WebView view, WebResourceRequest request, WebResourceError error) { - super.onReceivedError(view, request, error); - - List webViewListeners = bridge.getWebViewListeners(); - if (webViewListeners != null) { - for (WebViewListener listener : bridge.getWebViewListeners()) { - listener.onReceivedError(view); - } - } - - String errorPath = bridge.getErrorUrl(); - if (errorPath != null && request.isForMainFrame()) { - view.loadUrl(errorPath); - } - } - - @Override - public void onPageStarted(WebView view, String url, Bitmap favicon) { - super.onPageStarted(view, url, favicon); - bridge.reset(); - List webViewListeners = bridge.getWebViewListeners(); - - if (webViewListeners != null) { - for (WebViewListener listener : bridge.getWebViewListeners()) { - listener.onPageStarted(view); - } - } - } - - @Override - public void onReceivedHttpError(WebView view, WebResourceRequest request, WebResourceResponse errorResponse) { - super.onReceivedHttpError(view, request, errorResponse); - - List webViewListeners = bridge.getWebViewListeners(); - if (webViewListeners != null) { - for (WebViewListener listener : bridge.getWebViewListeners()) { - listener.onReceivedHttpError(view); - } - } - - String errorPath = bridge.getErrorUrl(); - if (errorPath != null && request.isForMainFrame()) { - view.loadUrl(errorPath); - } - } - - @Override - public boolean onRenderProcessGone(WebView view, RenderProcessGoneDetail detail) { - super.onRenderProcessGone(view, detail); - boolean result = false; - - List webViewListeners = bridge.getWebViewListeners(); - if (webViewListeners != null) { - for (WebViewListener listener : bridge.getWebViewListeners()) { - result = listener.onRenderProcessGone(view, detail) || result; - } - } - - return result; - } -} diff --git a/@capacitor/android/capacitor/src/main/java/com/getcapacitor/BridgeWebViewClient.kt b/@capacitor/android/capacitor/src/main/java/com/getcapacitor/BridgeWebViewClient.kt new file mode 100644 index 00000000..bfec7991 --- /dev/null +++ b/@capacitor/android/capacitor/src/main/java/com/getcapacitor/BridgeWebViewClient.kt @@ -0,0 +1,106 @@ +package com.getcapacitor + +import android.graphics.Bitmap +import android.net.Uri +import android.webkit.RenderProcessGoneDetail +import android.webkit.WebResourceError +import android.webkit.WebResourceRequest +import android.webkit.WebResourceResponse +import android.webkit.WebView +import android.webkit.WebViewClient + +open class BridgeWebViewClient(private val bridge: Bridge) : WebViewClient() { + override fun shouldInterceptRequest( + view: WebView, + request: WebResourceRequest + ): WebResourceResponse? { + return bridge.localServer!!.shouldInterceptRequest(request) + } + + override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean { + val url = request.url + return bridge.launchIntent(url) + } + + @Deprecated("") + override fun shouldOverrideUrlLoading(view: WebView, url: String): Boolean { + return bridge.launchIntent(Uri.parse(url)) + } + + override fun onPageFinished(view: WebView, url: String) { + super.onPageFinished(view, url) + val webViewListeners = bridge.getWebViewListeners() + + if (webViewListeners != null && view.progress == 100) { + for (listener in bridge.getWebViewListeners()) { + listener.onPageLoaded(view) + } + } + } + + override fun onReceivedError( + view: WebView, + request: WebResourceRequest, + error: WebResourceError + ) { + super.onReceivedError(view, request, error) + + val webViewListeners = bridge.getWebViewListeners() + if (webViewListeners != null) { + for (listener in bridge.getWebViewListeners()) { + listener.onReceivedError(view) + } + } + + val errorPath = bridge.errorUrl + if (errorPath != null && request.isForMainFrame) { + view.loadUrl(errorPath) + } + } + + override fun onPageStarted(view: WebView, url: String, favicon: Bitmap) { + super.onPageStarted(view, url, favicon) + bridge.reset() + val webViewListeners = bridge.getWebViewListeners() + + if (webViewListeners != null) { + for (listener in bridge.getWebViewListeners()) { + listener.onPageStarted(view) + } + } + } + + override fun onReceivedHttpError( + view: WebView, + request: WebResourceRequest, + errorResponse: WebResourceResponse + ) { + super.onReceivedHttpError(view, request, errorResponse) + + val webViewListeners = bridge.getWebViewListeners() + if (webViewListeners != null) { + for (listener in bridge.getWebViewListeners()) { + listener.onReceivedHttpError(view) + } + } + + val errorPath = bridge.errorUrl + if (errorPath != null && request.isForMainFrame) { + view.loadUrl(errorPath) + } + } + + override fun onRenderProcessGone(view: WebView, detail: RenderProcessGoneDetail): Boolean { + super.onRenderProcessGone(view, detail) + var result = false + + val webViewListeners = bridge.getWebViewListeners() + if (webViewListeners != null) { + for (listener in bridge.getWebViewListeners()) { + result = listener.onRenderProcessGone(view, detail) || result + } + } + + return result + } +} diff --git a/@capacitor/android/capacitor/src/main/java/com/getcapacitor/CapConfig.java b/@capacitor/android/capacitor/src/main/java/com/getcapacitor/CapConfig.java deleted file mode 100644 index 63db2a47..00000000 --- a/@capacitor/android/capacitor/src/main/java/com/getcapacitor/CapConfig.java +++ /dev/null @@ -1,670 +0,0 @@ -package com.getcapacitor; - -import static com.getcapacitor.Bridge.CAPACITOR_HTTP_SCHEME; -import static com.getcapacitor.Bridge.DEFAULT_ANDROID_WEBVIEW_VERSION; -import static com.getcapacitor.Bridge.DEFAULT_HUAWEI_WEBVIEW_VERSION; -import static com.getcapacitor.Bridge.MINIMUM_ANDROID_WEBVIEW_VERSION; -import static com.getcapacitor.Bridge.MINIMUM_HUAWEI_WEBVIEW_VERSION; -import static com.getcapacitor.FileUtils.readFileFromAssets; - -import android.content.Context; -import android.content.pm.ApplicationInfo; -import android.content.res.AssetManager; -import androidx.annotation.Nullable; -import com.getcapacitor.util.JSONUtils; -import java.io.File; -import java.io.IOException; -import java.util.Arrays; -import java.util.HashMap; -import java.util.Iterator; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import org.json.JSONException; -import org.json.JSONObject; - -/** - * Represents the configuration options for Capacitor - */ -public class CapConfig { - - private static final String LOG_BEHAVIOR_NONE = "none"; - private static final String LOG_BEHAVIOR_DEBUG = "debug"; - private static final String LOG_BEHAVIOR_PRODUCTION = "production"; - - // Server Config - private boolean html5mode = true; - private String serverUrl; - private String hostname = "localhost"; - private String androidScheme = CAPACITOR_HTTP_SCHEME; - private String[] allowNavigation; - - // Android Config - private String overriddenUserAgentString; - private String appendedUserAgentString; - private String backgroundColor; - private boolean allowMixedContent = false; - private boolean captureInput = false; - private boolean webContentsDebuggingEnabled = false; - private boolean loggingEnabled = true; - private boolean initialFocus = true; - private boolean useLegacyBridge = false; - private int minWebViewVersion = DEFAULT_ANDROID_WEBVIEW_VERSION; - private int minHuaweiWebViewVersion = DEFAULT_HUAWEI_WEBVIEW_VERSION; - private String errorPath; - - // Embedded - private String startPath; - - // Plugins - private Map pluginsConfiguration = null; - - // Config Object JSON (legacy) - private JSONObject configJSON = new JSONObject(); - - /** - * Constructs an empty config file. - */ - private CapConfig() {} - - /** - * Get an instance of the Config file object. - * @deprecated use {@link #loadDefault(Context)} to load an instance of the Config object - * from the capacitor.config.json file, or use the {@link CapConfig.Builder} to construct - * a CapConfig for embedded use. - * - * @param assetManager The AssetManager used to load the config file - * @param config JSON describing a configuration to use - */ - @Deprecated - public CapConfig(AssetManager assetManager, JSONObject config) { - if (config != null) { - this.configJSON = config; - } else { - // Load the capacitor.config.json - loadConfigFromAssets(assetManager, null); - } - - deserializeConfig(null); - } - - /** - * Constructs a Capacitor Configuration from config.json file. - * - * @param context The context. - * @return A loaded config file, if successful. - */ - public static CapConfig loadDefault(Context context) { - CapConfig config = new CapConfig(); - - if (context == null) { - Logger.error("Capacitor Config could not be created from file. Context must not be null."); - return config; - } - - config.loadConfigFromAssets(context.getAssets(), null); - config.deserializeConfig(context); - return config; - } - - /** - * Constructs a Capacitor Configuration from config.json file within the app assets. - * - * @param context The context. - * @param path A path relative to the root assets directory. - * @return A loaded config file, if successful. - */ - public static CapConfig loadFromAssets(Context context, String path) { - CapConfig config = new CapConfig(); - - if (context == null) { - Logger.error("Capacitor Config could not be created from file. Context must not be null."); - return config; - } - - config.loadConfigFromAssets(context.getAssets(), path); - config.deserializeConfig(context); - return config; - } - - /** - * Constructs a Capacitor Configuration from config.json file within the app file-space. - * - * @param context The context. - * @param path A path relative to the root of the app file-space. - * @return A loaded config file, if successful. - */ - public static CapConfig loadFromFile(Context context, String path) { - CapConfig config = new CapConfig(); - - if (context == null) { - Logger.error("Capacitor Config could not be created from file. Context must not be null."); - return config; - } - - config.loadConfigFromFile(path); - config.deserializeConfig(context); - return config; - } - - /** - * Constructs a Capacitor Configuration using ConfigBuilder. - * - * @param builder A config builder initialized with values - */ - private CapConfig(Builder builder) { - // Server Config - this.html5mode = builder.html5mode; - this.serverUrl = builder.serverUrl; - this.hostname = builder.hostname; - - if (this.validateScheme(builder.androidScheme)) { - this.androidScheme = builder.androidScheme; - } - - this.allowNavigation = builder.allowNavigation; - - // Android Config - this.overriddenUserAgentString = builder.overriddenUserAgentString; - this.appendedUserAgentString = builder.appendedUserAgentString; - this.backgroundColor = builder.backgroundColor; - this.allowMixedContent = builder.allowMixedContent; - this.captureInput = builder.captureInput; - this.webContentsDebuggingEnabled = builder.webContentsDebuggingEnabled; - this.loggingEnabled = builder.loggingEnabled; - this.initialFocus = builder.initialFocus; - this.useLegacyBridge = builder.useLegacyBridge; - this.minWebViewVersion = builder.minWebViewVersion; - this.minHuaweiWebViewVersion = builder.minHuaweiWebViewVersion; - this.errorPath = builder.errorPath; - - // Embedded - this.startPath = builder.startPath; - - // Plugins Config - this.pluginsConfiguration = builder.pluginsConfiguration; - } - - /** - * Loads a Capacitor Configuration JSON file into a Capacitor Configuration object. - * An optional path string can be provided to look for the config in a subdirectory path. - */ - private void loadConfigFromAssets(AssetManager assetManager, String path) { - if (path == null) { - path = ""; - } else { - // Add slash at the end to form a proper file path if going deeper in assets dir - if (path.charAt(path.length() - 1) != '/') { - path = path + "/"; - } - } - - try { - String jsonString = readFileFromAssets(assetManager, path + "capacitor.config.json"); - configJSON = new JSONObject(jsonString); - } catch (IOException ex) { - Logger.error("Unable to load capacitor.config.json. Run npx cap copy first", ex); - } catch (JSONException ex) { - Logger.error("Unable to parse capacitor.config.json. Make sure it's valid json", ex); - } - } - - /** - * Loads a Capacitor Configuration JSON file into a Capacitor Configuration object. - * An optional path string can be provided to look for the config in a subdirectory path. - */ - private void loadConfigFromFile(String path) { - if (path == null) { - path = ""; - } else { - // Add slash at the end to form a proper file path if going deeper in assets dir - if (path.charAt(path.length() - 1) != '/') { - path = path + "/"; - } - } - - try { - File configFile = new File(path + "capacitor.config.json"); - String jsonString = FileUtils.readFileFromDisk(configFile); - configJSON = new JSONObject(jsonString); - } catch (JSONException ex) { - Logger.error("Unable to parse capacitor.config.json. Make sure it's valid json", ex); - } catch (IOException ex) { - Logger.error("Unable to load capacitor.config.json.", ex); - } - } - - /** - * Deserializes the config from JSON into a Capacitor Configuration object. - */ - private void deserializeConfig(@Nullable Context context) { - boolean isDebug = context != null && (context.getApplicationInfo().flags & ApplicationInfo.FLAG_DEBUGGABLE) != 0; - - // Server - html5mode = JSONUtils.getBoolean(configJSON, "server.html5mode", html5mode); - serverUrl = JSONUtils.getString(configJSON, "server.url", null); - hostname = JSONUtils.getString(configJSON, "server.hostname", hostname); - errorPath = JSONUtils.getString(configJSON, "server.errorPath", null); - - String configSchema = JSONUtils.getString(configJSON, "server.androidScheme", androidScheme); - if (this.validateScheme(configSchema)) { - androidScheme = configSchema; - } - - allowNavigation = JSONUtils.getArray(configJSON, "server.allowNavigation", null); - - // Android - overriddenUserAgentString = - JSONUtils.getString(configJSON, "android.overrideUserAgent", JSONUtils.getString(configJSON, "overrideUserAgent", null)); - appendedUserAgentString = - JSONUtils.getString(configJSON, "android.appendUserAgent", JSONUtils.getString(configJSON, "appendUserAgent", null)); - backgroundColor = - JSONUtils.getString(configJSON, "android.backgroundColor", JSONUtils.getString(configJSON, "backgroundColor", null)); - allowMixedContent = - JSONUtils.getBoolean( - configJSON, - "android.allowMixedContent", - JSONUtils.getBoolean(configJSON, "allowMixedContent", allowMixedContent) - ); - minWebViewVersion = JSONUtils.getInt(configJSON, "android.minWebViewVersion", DEFAULT_ANDROID_WEBVIEW_VERSION); - minHuaweiWebViewVersion = JSONUtils.getInt(configJSON, "android.minHuaweiWebViewVersion", DEFAULT_HUAWEI_WEBVIEW_VERSION); - captureInput = JSONUtils.getBoolean(configJSON, "android.captureInput", captureInput); - useLegacyBridge = JSONUtils.getBoolean(configJSON, "android.useLegacyBridge", useLegacyBridge); - webContentsDebuggingEnabled = JSONUtils.getBoolean(configJSON, "android.webContentsDebuggingEnabled", isDebug); - - String logBehavior = JSONUtils.getString( - configJSON, - "android.loggingBehavior", - JSONUtils.getString(configJSON, "loggingBehavior", LOG_BEHAVIOR_DEBUG) - ); - switch (logBehavior.toLowerCase(Locale.ROOT)) { - case LOG_BEHAVIOR_PRODUCTION: - loggingEnabled = true; - break; - case LOG_BEHAVIOR_NONE: - loggingEnabled = false; - break; - default: // LOG_BEHAVIOR_DEBUG - loggingEnabled = isDebug; - } - - initialFocus = JSONUtils.getBoolean(configJSON, "android.initialFocus", initialFocus); - - // Plugins - pluginsConfiguration = deserializePluginsConfig(JSONUtils.getObject(configJSON, "plugins")); - } - - private boolean validateScheme(String scheme) { - List invalidSchemes = Arrays.asList("file", "ftp", "ftps", "ws", "wss", "about", "blob", "data"); - if (invalidSchemes.contains(scheme)) { - Logger.warn(scheme + " is not an allowed scheme. Defaulting to http."); - return false; - } - - // Non-http(s) schemes are not allowed to modify the URL path as of Android Webview 117 - if (!scheme.equals("http") && !scheme.equals("https")) { - Logger.warn( - "Using a non-standard scheme: " + scheme + " for Android. This is known to cause issues as of Android Webview 117." - ); - } - - return true; - } - - public boolean isHTML5Mode() { - return html5mode; - } - - public String getServerUrl() { - return serverUrl; - } - - public String getErrorPath() { - return errorPath; - } - - public String getHostname() { - return hostname; - } - - public String getStartPath() { - return startPath; - } - - public String getAndroidScheme() { - return androidScheme; - } - - public String[] getAllowNavigation() { - return allowNavigation; - } - - public String getOverriddenUserAgentString() { - return overriddenUserAgentString; - } - - public String getAppendedUserAgentString() { - return appendedUserAgentString; - } - - public String getBackgroundColor() { - return backgroundColor; - } - - public boolean isMixedContentAllowed() { - return allowMixedContent; - } - - public boolean isInputCaptured() { - return captureInput; - } - - public boolean isWebContentsDebuggingEnabled() { - return webContentsDebuggingEnabled; - } - - public boolean isLoggingEnabled() { - return loggingEnabled; - } - - public boolean isInitialFocus() { - return initialFocus; - } - - public boolean isUsingLegacyBridge() { - return useLegacyBridge; - } - - public int getMinWebViewVersion() { - if (minWebViewVersion < MINIMUM_ANDROID_WEBVIEW_VERSION) { - Logger.warn("Specified minimum webview version is too low, defaulting to " + MINIMUM_ANDROID_WEBVIEW_VERSION); - return MINIMUM_ANDROID_WEBVIEW_VERSION; - } - - return minWebViewVersion; - } - - public int getMinHuaweiWebViewVersion() { - if (minHuaweiWebViewVersion < MINIMUM_HUAWEI_WEBVIEW_VERSION) { - Logger.warn("Specified minimum Huawei webview version is too low, defaulting to " + MINIMUM_HUAWEI_WEBVIEW_VERSION); - return MINIMUM_HUAWEI_WEBVIEW_VERSION; - } - - return minHuaweiWebViewVersion; - } - - public PluginConfig getPluginConfiguration(String pluginId) { - PluginConfig pluginConfig = pluginsConfiguration.get(pluginId); - if (pluginConfig == null) { - pluginConfig = new PluginConfig(new JSONObject()); - } - - return pluginConfig; - } - - /** - * Get a JSON object value from the Capacitor config. - * @deprecated use {@link PluginConfig#getObject(String)} to access plugin config values. - * For main Capacitor config values, use the appropriate getter. - * - * @param key A key to fetch from the config - * @return The value from the config, if exists. Null if not - */ - @Deprecated - public JSONObject getObject(String key) { - try { - return configJSON.getJSONObject(key); - } catch (Exception ex) {} - return null; - } - - /** - * Get a string value from the Capacitor config. - * @deprecated use {@link PluginConfig#getString(String, String)} to access plugin config - * values. For main Capacitor config values, use the appropriate getter. - * - * @param key A key to fetch from the config - * @return The value from the config, if exists. Null if not - */ - @Deprecated - public String getString(String key) { - return JSONUtils.getString(configJSON, key, null); - } - - /** - * Get a string value from the Capacitor config. - * @deprecated use {@link PluginConfig#getString(String, String)} to access plugin config - * values. For main Capacitor config values, use the appropriate getter. - * - * @param key A key to fetch from the config - * @param defaultValue A default value to return if the key does not exist in the config - * @return The value from the config, if key exists. Default value returned if not - */ - @Deprecated - public String getString(String key, String defaultValue) { - return JSONUtils.getString(configJSON, key, defaultValue); - } - - /** - * Get a boolean value from the Capacitor config. - * @deprecated use {@link PluginConfig#getBoolean(String, boolean)} to access plugin config - * values. For main Capacitor config values, use the appropriate getter. - * - * @param key A key to fetch from the config - * @param defaultValue A default value to return if the key does not exist in the config - * @return The value from the config, if key exists. Default value returned if not - */ - @Deprecated - public boolean getBoolean(String key, boolean defaultValue) { - return JSONUtils.getBoolean(configJSON, key, defaultValue); - } - - /** - * Get an integer value from the Capacitor config. - * @deprecated use {@link PluginConfig#getInt(String, int)} to access the plugin config - * values. For main Capacitor config values, use the appropriate getter. - * - * @param key A key to fetch from the config - * @param defaultValue A default value to return if the key does not exist in the config - * @return The value from the config, if key exists. Default value returned if not - */ - @Deprecated - public int getInt(String key, int defaultValue) { - return JSONUtils.getInt(configJSON, key, defaultValue); - } - - /** - * Get a string array value from the Capacitor config. - * @deprecated use {@link PluginConfig#getArray(String)} to access the plugin config - * values. For main Capacitor config values, use the appropriate getter. - * - * @param key A key to fetch from the config - * @return The value from the config, if exists. Null if not - */ - @Deprecated - public String[] getArray(String key) { - return JSONUtils.getArray(configJSON, key, null); - } - - /** - * Get a string array value from the Capacitor config. - * @deprecated use {@link PluginConfig#getArray(String, String[])} to access the plugin - * config values. For main Capacitor config values, use the appropriate getter. - * - * @param key A key to fetch from the config - * @param defaultValue A default value to return if the key does not exist in the config - * @return The value from the config, if key exists. Default value returned if not - */ - @Deprecated - public String[] getArray(String key, String[] defaultValue) { - return JSONUtils.getArray(configJSON, key, defaultValue); - } - - private static Map deserializePluginsConfig(JSONObject pluginsConfig) { - Map pluginsMap = new HashMap<>(); - - // return an empty map if there is no pluginsConfig json - if (pluginsConfig == null) { - return pluginsMap; - } - - Iterator pluginIds = pluginsConfig.keys(); - - while (pluginIds.hasNext()) { - String pluginId = pluginIds.next(); - JSONObject value = null; - - try { - value = pluginsConfig.getJSONObject(pluginId); - PluginConfig pluginConfig = new PluginConfig(value); - pluginsMap.put(pluginId, pluginConfig); - } catch (JSONException e) { - e.printStackTrace(); - } - } - - return pluginsMap; - } - - /** - * Builds a Capacitor Configuration in code - */ - public static class Builder { - - private Context context; - - // Server Config Values - private boolean html5mode = true; - private String serverUrl; - private String errorPath; - private String hostname = "localhost"; - private String androidScheme = CAPACITOR_HTTP_SCHEME; - private String[] allowNavigation; - - // Android Config Values - private String overriddenUserAgentString; - private String appendedUserAgentString; - private String backgroundColor; - private boolean allowMixedContent = false; - private boolean captureInput = false; - private Boolean webContentsDebuggingEnabled = null; - private boolean loggingEnabled = true; - private boolean initialFocus = false; - private boolean useLegacyBridge = false; - private int minWebViewVersion = DEFAULT_ANDROID_WEBVIEW_VERSION; - private int minHuaweiWebViewVersion = DEFAULT_HUAWEI_WEBVIEW_VERSION; - - // Embedded - private String startPath = null; - - // Plugins Config Object - private Map pluginsConfiguration = new HashMap<>(); - - /** - * Constructs a new CapConfig Builder. - * - * @param context The context - */ - public Builder(Context context) { - this.context = context; - } - - /** - * Builds a Capacitor Config from the builder. - * - * @return A new Capacitor Config - */ - public CapConfig create() { - if (webContentsDebuggingEnabled == null) { - webContentsDebuggingEnabled = (context.getApplicationInfo().flags & ApplicationInfo.FLAG_DEBUGGABLE) != 0; - } - - return new CapConfig(this); - } - - public Builder setPluginsConfiguration(JSONObject pluginsConfiguration) { - this.pluginsConfiguration = deserializePluginsConfig(pluginsConfiguration); - return this; - } - - public Builder setHTML5mode(boolean html5mode) { - this.html5mode = html5mode; - return this; - } - - public Builder setServerUrl(String serverUrl) { - this.serverUrl = serverUrl; - return this; - } - - public Builder setErrorPath(String errorPath) { - this.errorPath = errorPath; - return this; - } - - public Builder setHostname(String hostname) { - this.hostname = hostname; - return this; - } - - public Builder setStartPath(String path) { - this.startPath = path; - return this; - } - - public Builder setAndroidScheme(String androidScheme) { - this.androidScheme = androidScheme; - return this; - } - - public Builder setAllowNavigation(String[] allowNavigation) { - this.allowNavigation = allowNavigation; - return this; - } - - public Builder setOverriddenUserAgentString(String overriddenUserAgentString) { - this.overriddenUserAgentString = overriddenUserAgentString; - return this; - } - - public Builder setAppendedUserAgentString(String appendedUserAgentString) { - this.appendedUserAgentString = appendedUserAgentString; - return this; - } - - public Builder setBackgroundColor(String backgroundColor) { - this.backgroundColor = backgroundColor; - return this; - } - - public Builder setAllowMixedContent(boolean allowMixedContent) { - this.allowMixedContent = allowMixedContent; - return this; - } - - public Builder setCaptureInput(boolean captureInput) { - this.captureInput = captureInput; - return this; - } - - public Builder setUseLegacyBridge(boolean useLegacyBridge) { - this.useLegacyBridge = useLegacyBridge; - return this; - } - - public Builder setWebContentsDebuggingEnabled(boolean webContentsDebuggingEnabled) { - this.webContentsDebuggingEnabled = webContentsDebuggingEnabled; - return this; - } - - public Builder setLoggingEnabled(boolean enabled) { - this.loggingEnabled = enabled; - return this; - } - - public Builder setInitialFocus(boolean focus) { - this.initialFocus = focus; - return this; - } - } -} diff --git a/@capacitor/android/capacitor/src/main/java/com/getcapacitor/CapConfig.kt b/@capacitor/android/capacitor/src/main/java/com/getcapacitor/CapConfig.kt new file mode 100644 index 00000000..a21cedfc --- /dev/null +++ b/@capacitor/android/capacitor/src/main/java/com/getcapacitor/CapConfig.kt @@ -0,0 +1,650 @@ +package com.getcapacitor + +import android.content.Context +import android.content.pm.ApplicationInfo +import android.content.res.AssetManager +import com.getcapacitor.util.JSONUtils +import org.json.JSONException +import org.json.JSONObject +import java.io.File +import java.io.IOException + +/** + * Represents the configuration options for Capacitor + */ +class CapConfig { + // Server Config + var isHTML5Mode: Boolean = true + private set + var serverUrl: String? = null + private set + var hostname: String = "localhost" + private set + var androidScheme: String = Bridge.CAPACITOR_HTTP_SCHEME + private set + var allowNavigation: Array + private set + + // Android Config + var overriddenUserAgentString: String? = null + private set + var appendedUserAgentString: String? = null + private set + var backgroundColor: String? = null + private set + var isMixedContentAllowed: Boolean = false + private set + var isInputCaptured: Boolean = false + private set + var isWebContentsDebuggingEnabled: Boolean = false + private set + var isLoggingEnabled: Boolean = true + private set + var isInitialFocus: Boolean = true + private set + var isUsingLegacyBridge: Boolean = false + private set + private var minWebViewVersion = Bridge.DEFAULT_ANDROID_WEBVIEW_VERSION + private var minHuaweiWebViewVersion = Bridge.DEFAULT_HUAWEI_WEBVIEW_VERSION + var errorPath: String? = null + private set + + // Embedded + var startPath: String? = null + private set + + // Plugins + private var pluginsConfiguration: Map? = null + + // Config Object JSON (legacy) + private var configJSON = JSONObject() + + /** + * Constructs an empty config file. + */ + private constructor() + + /** + * Get an instance of the Config file object. + * @param assetManager The AssetManager used to load the config file + * @param config JSON describing a configuration to use + */ + @Deprecated( + """use {@link #loadDefault(Context)} to load an instance of the Config object + from the capacitor.config.json file, or use the {@link CapConfig.Builder} to construct + a CapConfig for embedded use. + + """ + ) + constructor(assetManager: AssetManager, config: JSONObject?) { + if (config != null) { + this.configJSON = config + } else { + // Load the capacitor.config.json + loadConfigFromAssets(assetManager, null) + } + + deserializeConfig(null) + } + + /** + * Constructs a Capacitor Configuration using ConfigBuilder. + * + * @param builder A config builder initialized with values + */ + private constructor(builder: Builder) { + // Server Config + this.isHTML5Mode = builder.html5mode + this.serverUrl = builder.serverUrl + this.hostname = builder.hostname + + if (this.validateScheme(builder.androidScheme)) { + this.androidScheme = builder.androidScheme + } + + this.allowNavigation = builder.allowNavigation + + // Android Config + this.overriddenUserAgentString = builder.overriddenUserAgentString + this.appendedUserAgentString = builder.appendedUserAgentString + this.backgroundColor = builder.backgroundColor + this.isMixedContentAllowed = builder.allowMixedContent + this.isInputCaptured = builder.captureInput + this.isWebContentsDebuggingEnabled = builder.webContentsDebuggingEnabled!! + this.isLoggingEnabled = builder.loggingEnabled + this.isInitialFocus = builder.initialFocus + this.isUsingLegacyBridge = builder.useLegacyBridge + this.minWebViewVersion = builder.minWebViewVersion + this.minHuaweiWebViewVersion = builder.minHuaweiWebViewVersion + this.errorPath = builder.errorPath + + // Embedded + this.startPath = builder.startPath + + // Plugins Config + this.pluginsConfiguration = builder.pluginsConfiguration + } + + /** + * Loads a Capacitor Configuration JSON file into a Capacitor Configuration object. + * An optional path string can be provided to look for the config in a subdirectory path. + */ + private fun loadConfigFromAssets(assetManager: AssetManager, path: String?) { + var path = path + if (path == null) { + path = "" + } else { + // Add slash at the end to form a proper file path if going deeper in assets dir + if (path[path.length - 1] != '/') { + path = "$path/" + } + } + + try { + val jsonString = + FileUtils.readFileFromAssets(assetManager, path + "capacitor.config.json") + configJSON = JSONObject(jsonString) + } catch (ex: IOException) { + Logger.Companion.error( + "Unable to load capacitor.config.json. Run npx cap copy first", + ex + ) + } catch (ex: JSONException) { + Logger.Companion.error( + "Unable to parse capacitor.config.json. Make sure it's valid json", + ex + ) + } + } + + /** + * Loads a Capacitor Configuration JSON file into a Capacitor Configuration object. + * An optional path string can be provided to look for the config in a subdirectory path. + */ + private fun loadConfigFromFile(path: String) { + var path: String? = path + if (path == null) { + path = "" + } else { + // Add slash at the end to form a proper file path if going deeper in assets dir + if (path[path.length - 1] != '/') { + path = "$path/" + } + } + + try { + val configFile = File(path + "capacitor.config.json") + val jsonString = FileUtils.readFileFromDisk(configFile) + configJSON = JSONObject(jsonString) + } catch (ex: JSONException) { + Logger.Companion.error( + "Unable to parse capacitor.config.json. Make sure it's valid json", + ex + ) + } catch (ex: IOException) { + Logger.Companion.error("Unable to load capacitor.config.json.", ex) + } + } + + /** + * Deserializes the config from JSON into a Capacitor Configuration object. + */ + private fun deserializeConfig(context: Context?) { + val isDebug = + context != null && (context.applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE) != 0 + + // Server + isHTML5Mode = JSONUtils.getBoolean(configJSON, "server.html5mode", isHTML5Mode) + serverUrl = JSONUtils.getString(configJSON, "server.url", null) + hostname = JSONUtils.getString(configJSON, "server.hostname", hostname) + errorPath = JSONUtils.getString(configJSON, "server.errorPath", null) + + val configSchema = JSONUtils.getString(configJSON, "server.androidScheme", androidScheme) + if (this.validateScheme(configSchema)) { + androidScheme = configSchema + } + + allowNavigation = JSONUtils.getArray(configJSON, "server.allowNavigation", null) + + // Android + overriddenUserAgentString = + JSONUtils.getString( + configJSON, + "android.overrideUserAgent", + JSONUtils.getString(configJSON, "overrideUserAgent", null) + ) + appendedUserAgentString = + JSONUtils.getString( + configJSON, + "android.appendUserAgent", + JSONUtils.getString(configJSON, "appendUserAgent", null) + ) + backgroundColor = + JSONUtils.getString( + configJSON, + "android.backgroundColor", + JSONUtils.getString(configJSON, "backgroundColor", null) + ) + isMixedContentAllowed = + JSONUtils.getBoolean( + configJSON, + "android.allowMixedContent", + JSONUtils.getBoolean(configJSON, "allowMixedContent", isMixedContentAllowed) + ) + minWebViewVersion = JSONUtils.getInt( + configJSON, + "android.minWebViewVersion", + Bridge.DEFAULT_ANDROID_WEBVIEW_VERSION + ) + minHuaweiWebViewVersion = JSONUtils.getInt( + configJSON, + "android.minHuaweiWebViewVersion", + Bridge.DEFAULT_HUAWEI_WEBVIEW_VERSION + ) + isInputCaptured = JSONUtils.getBoolean(configJSON, "android.captureInput", isInputCaptured) + isUsingLegacyBridge = + JSONUtils.getBoolean(configJSON, "android.useLegacyBridge", isUsingLegacyBridge) + isWebContentsDebuggingEnabled = + JSONUtils.getBoolean(configJSON, "android.webContentsDebuggingEnabled", isDebug) + + val logBehavior = JSONUtils.getString( + configJSON, + "android.loggingBehavior", + JSONUtils.getString(configJSON, "loggingBehavior", LOG_BEHAVIOR_DEBUG) + ) + when (logBehavior.lowercase()) { + LOG_BEHAVIOR_PRODUCTION -> isLoggingEnabled = true + LOG_BEHAVIOR_NONE -> isLoggingEnabled = false + else -> isLoggingEnabled = isDebug + } + isInitialFocus = JSONUtils.getBoolean(configJSON, "android.initialFocus", isInitialFocus) + + // Plugins + pluginsConfiguration = deserializePluginsConfig(JSONUtils.getObject(configJSON, "plugins")) + } + + private fun validateScheme(scheme: String): Boolean { + val invalidSchemes: List = + mutableListOf("file", "ftp", "ftps", "ws", "wss", "about", "blob", "data") + if (invalidSchemes.contains(scheme)) { + Logger.Companion.warn("$scheme is not an allowed scheme. Defaulting to http.") + return false + } + + // Non-http(s) schemes are not allowed to modify the URL path as of Android Webview 117 + if (scheme != "http" && scheme != "https") { + Logger.Companion.warn( + "Using a non-standard scheme: $scheme for Android. This is known to cause issues as of Android Webview 117." + ) + } + + return true + } + + fun getMinWebViewVersion(): Int { + if (minWebViewVersion < Bridge.MINIMUM_ANDROID_WEBVIEW_VERSION) { + Logger.Companion.warn("Specified minimum webview version is too low, defaulting to " + Bridge.MINIMUM_ANDROID_WEBVIEW_VERSION) + return Bridge.MINIMUM_ANDROID_WEBVIEW_VERSION + } + + return minWebViewVersion + } + + fun getMinHuaweiWebViewVersion(): Int { + if (minHuaweiWebViewVersion < Bridge.MINIMUM_HUAWEI_WEBVIEW_VERSION) { + Logger.Companion.warn("Specified minimum Huawei webview version is too low, defaulting to " + Bridge.MINIMUM_HUAWEI_WEBVIEW_VERSION) + return Bridge.MINIMUM_HUAWEI_WEBVIEW_VERSION + } + + return minHuaweiWebViewVersion + } + + fun getPluginConfiguration(pluginId: String?): PluginConfig { + var pluginConfig = pluginsConfiguration!![pluginId] + if (pluginConfig == null) { + pluginConfig = PluginConfig(JSONObject()) + } + + return pluginConfig + } + + /** + * Get a JSON object value from the Capacitor config. + * @param key A key to fetch from the config + * @return The value from the config, if exists. Null if not + */ + @Deprecated( + """use {@link PluginConfig#getObject(String)} to access plugin config values. + For main Capacitor config values, use the appropriate getter. + + """ + ) + fun getObject(key: String?): JSONObject? { + try { + return configJSON.getJSONObject(key) + } catch (ex: Exception) { + } + return null + } + + /** + * Get a string value from the Capacitor config. + * @param key A key to fetch from the config + * @return The value from the config, if exists. Null if not + */ + @Deprecated( + """use {@link PluginConfig#getString(String, String)} to access plugin config + values. For main Capacitor config values, use the appropriate getter. + + """ + ) + fun getString(key: String?): String { + return JSONUtils.getString(configJSON, key, null) + } + + /** + * Get a string value from the Capacitor config. + * @param key A key to fetch from the config + * @param defaultValue A default value to return if the key does not exist in the config + * @return The value from the config, if key exists. Default value returned if not + */ + @Deprecated( + """use {@link PluginConfig#getString(String, String)} to access plugin config + values. For main Capacitor config values, use the appropriate getter. + + """ + ) + fun getString(key: String?, defaultValue: String?): String { + return JSONUtils.getString(configJSON, key, defaultValue) + } + + /** + * Get a boolean value from the Capacitor config. + * @param key A key to fetch from the config + * @param defaultValue A default value to return if the key does not exist in the config + * @return The value from the config, if key exists. Default value returned if not + */ + @Deprecated( + """use {@link PluginConfig#getBoolean(String, boolean)} to access plugin config + values. For main Capacitor config values, use the appropriate getter. + + """ + ) + fun getBoolean(key: String?, defaultValue: Boolean): Boolean { + return JSONUtils.getBoolean(configJSON, key, defaultValue) + } + + /** + * Get an integer value from the Capacitor config. + * @param key A key to fetch from the config + * @param defaultValue A default value to return if the key does not exist in the config + * @return The value from the config, if key exists. Default value returned if not + */ + @Deprecated( + """use {@link PluginConfig#getInt(String, int)} to access the plugin config + values. For main Capacitor config values, use the appropriate getter. + + """ + ) + fun getInt(key: String?, defaultValue: Int): Int { + return JSONUtils.getInt(configJSON, key, defaultValue) + } + + /** + * Get a string array value from the Capacitor config. + * @param key A key to fetch from the config + * @return The value from the config, if exists. Null if not + */ + @Deprecated( + """use {@link PluginConfig#getArray(String)} to access the plugin config + values. For main Capacitor config values, use the appropriate getter. + + """ + ) + fun getArray(key: String?): Array { + return JSONUtils.getArray(configJSON, key, null) + } + + /** + * Get a string array value from the Capacitor config. + * @param key A key to fetch from the config + * @param defaultValue A default value to return if the key does not exist in the config + * @return The value from the config, if key exists. Default value returned if not + */ + @Deprecated( + """use {@link PluginConfig#getArray(String, String[])} to access the plugin + config values. For main Capacitor config values, use the appropriate getter. + + """ + ) + fun getArray(key: String?, defaultValue: Array?): Array { + return JSONUtils.getArray(configJSON, key, defaultValue) + } + + /** + * Builds a Capacitor Configuration in code + */ + class Builder + /** + * Constructs a new CapConfig Builder. + * + * @param context The context + */(private val context: Context) { + // Server Config Values + var html5mode: Boolean = true + var serverUrl: String? = null + var errorPath: String? = null + var hostname: String = "localhost" + var androidScheme: String = Bridge.CAPACITOR_HTTP_SCHEME + var allowNavigation: Array + + // Android Config Values + var overriddenUserAgentString: String? = null + var appendedUserAgentString: String? = null + var backgroundColor: String? = null + var allowMixedContent: Boolean = false + var captureInput: Boolean = false + var webContentsDebuggingEnabled: Boolean? = null + var loggingEnabled: Boolean = true + var initialFocus: Boolean = false + var useLegacyBridge: Boolean = false + val minWebViewVersion: Int = Bridge.DEFAULT_ANDROID_WEBVIEW_VERSION + val minHuaweiWebViewVersion: Int = Bridge.DEFAULT_HUAWEI_WEBVIEW_VERSION + + // Embedded + var startPath: String? = null + + // Plugins Config Object + var pluginsConfiguration: Map = HashMap() + + /** + * Builds a Capacitor Config from the builder. + * + * @return A new Capacitor Config + */ + fun create(): CapConfig { + if (webContentsDebuggingEnabled == null) { + webContentsDebuggingEnabled = + (context.applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE) != 0 + } + + return CapConfig(this) + } + + fun setPluginsConfiguration(pluginsConfiguration: JSONObject?): Builder { + this.pluginsConfiguration = deserializePluginsConfig(pluginsConfiguration) + return this + } + + fun setHTML5mode(html5mode: Boolean): Builder { + this.html5mode = html5mode + return this + } + + fun setServerUrl(serverUrl: String?): Builder { + this.serverUrl = serverUrl + return this + } + + fun setErrorPath(errorPath: String?): Builder { + this.errorPath = errorPath + return this + } + + fun setHostname(hostname: String): Builder { + this.hostname = hostname + return this + } + + fun setStartPath(path: String?): Builder { + this.startPath = path + return this + } + + fun setAndroidScheme(androidScheme: String): Builder { + this.androidScheme = androidScheme + return this + } + + fun setAllowNavigation(allowNavigation: Array): Builder { + this.allowNavigation = allowNavigation + return this + } + + fun setOverriddenUserAgentString(overriddenUserAgentString: String?): Builder { + this.overriddenUserAgentString = overriddenUserAgentString + return this + } + + fun setAppendedUserAgentString(appendedUserAgentString: String?): Builder { + this.appendedUserAgentString = appendedUserAgentString + return this + } + + fun setBackgroundColor(backgroundColor: String?): Builder { + this.backgroundColor = backgroundColor + return this + } + + fun setAllowMixedContent(allowMixedContent: Boolean): Builder { + this.allowMixedContent = allowMixedContent + return this + } + + fun setCaptureInput(captureInput: Boolean): Builder { + this.captureInput = captureInput + return this + } + + fun setUseLegacyBridge(useLegacyBridge: Boolean): Builder { + this.useLegacyBridge = useLegacyBridge + return this + } + + fun setWebContentsDebuggingEnabled(webContentsDebuggingEnabled: Boolean): Builder { + this.webContentsDebuggingEnabled = webContentsDebuggingEnabled + return this + } + + fun setLoggingEnabled(enabled: Boolean): Builder { + this.loggingEnabled = enabled + return this + } + + fun setInitialFocus(focus: Boolean): Builder { + this.initialFocus = focus + return this + } + } + + companion object { + private const val LOG_BEHAVIOR_NONE = "none" + private const val LOG_BEHAVIOR_DEBUG = "debug" + private const val LOG_BEHAVIOR_PRODUCTION = "production" + + /** + * Constructs a Capacitor Configuration from config.json file. + * + * @param context The context. + * @return A loaded config file, if successful. + */ + fun loadDefault(context: Context?): CapConfig { + val config = CapConfig() + + if (context == null) { + Logger.Companion.error("Capacitor Config could not be created from file. Context must not be null.") + return config + } + + config.loadConfigFromAssets(context.assets, null) + config.deserializeConfig(context) + return config + } + + /** + * Constructs a Capacitor Configuration from config.json file within the app assets. + * + * @param context The context. + * @param path A path relative to the root assets directory. + * @return A loaded config file, if successful. + */ + fun loadFromAssets(context: Context?, path: String?): CapConfig { + val config = CapConfig() + + if (context == null) { + Logger.Companion.error("Capacitor Config could not be created from file. Context must not be null.") + return config + } + + config.loadConfigFromAssets(context.assets, path) + config.deserializeConfig(context) + return config + } + + /** + * Constructs a Capacitor Configuration from config.json file within the app file-space. + * + * @param context The context. + * @param path A path relative to the root of the app file-space. + * @return A loaded config file, if successful. + */ + fun loadFromFile(context: Context?, path: String): CapConfig { + val config = CapConfig() + + if (context == null) { + Logger.Companion.error("Capacitor Config could not be created from file. Context must not be null.") + return config + } + + config.loadConfigFromFile(path) + config.deserializeConfig(context) + return config + } + + private fun deserializePluginsConfig(pluginsConfig: JSONObject?): Map { + val pluginsMap: MutableMap = HashMap() + + // return an empty map if there is no pluginsConfig json + if (pluginsConfig == null) { + return pluginsMap + } + + val pluginIds = pluginsConfig.keys() + + while (pluginIds.hasNext()) { + val pluginId = pluginIds.next() + var value: JSONObject? = null + + try { + value = pluginsConfig.getJSONObject(pluginId) + val pluginConfig = PluginConfig(value) + pluginsMap[pluginId] = pluginConfig + } catch (e: JSONException) { + e.printStackTrace() + } + } + + return pluginsMap + } + } +} diff --git a/@capacitor/android/capacitor/src/main/java/com/getcapacitor/CapacitorWebView.java b/@capacitor/android/capacitor/src/main/java/com/getcapacitor/CapacitorWebView.java deleted file mode 100644 index e46b904a..00000000 --- a/@capacitor/android/capacitor/src/main/java/com/getcapacitor/CapacitorWebView.java +++ /dev/null @@ -1,52 +0,0 @@ -package com.getcapacitor; - -import android.content.Context; -import android.util.AttributeSet; -import android.view.KeyEvent; -import android.view.inputmethod.BaseInputConnection; -import android.view.inputmethod.EditorInfo; -import android.view.inputmethod.InputConnection; -import android.webkit.WebView; - -public class CapacitorWebView extends WebView { - - private BaseInputConnection capInputConnection; - private Bridge bridge; - - public CapacitorWebView(Context context, AttributeSet attrs) { - super(context, attrs); - } - - public void setBridge(Bridge bridge) { - this.bridge = bridge; - } - - @Override - public InputConnection onCreateInputConnection(EditorInfo outAttrs) { - CapConfig config; - if (bridge != null) { - config = bridge.getConfig(); - } else { - config = CapConfig.loadDefault(getContext()); - } - - boolean captureInput = config.isInputCaptured(); - if (captureInput) { - if (capInputConnection == null) { - capInputConnection = new BaseInputConnection(this, false); - } - return capInputConnection; - } - return super.onCreateInputConnection(outAttrs); - } - - @Override - @SuppressWarnings("deprecation") - public boolean dispatchKeyEvent(KeyEvent event) { - if (event.getAction() == KeyEvent.ACTION_MULTIPLE) { - evaluateJavascript("document.activeElement.value = document.activeElement.value + '" + event.getCharacters() + "';", null); - return false; - } - return super.dispatchKeyEvent(event); - } -} diff --git a/@capacitor/android/capacitor/src/main/java/com/getcapacitor/CapacitorWebView.kt b/@capacitor/android/capacitor/src/main/java/com/getcapacitor/CapacitorWebView.kt new file mode 100644 index 00000000..51be2e46 --- /dev/null +++ b/@capacitor/android/capacitor/src/main/java/com/getcapacitor/CapacitorWebView.kt @@ -0,0 +1,49 @@ +package com.getcapacitor + +import android.content.Context +import android.util.AttributeSet +import android.view.KeyEvent +import android.view.inputmethod.BaseInputConnection +import android.view.inputmethod.EditorInfo +import android.view.inputmethod.InputConnection +import android.webkit.WebView + +class CapacitorWebView(context: Context?, attrs: AttributeSet?) : WebView( + context!!, attrs +) { + private var capInputConnection: BaseInputConnection? = null + private var bridge: Bridge? = null + + fun setBridge(bridge: Bridge?) { + this.bridge = bridge + } + + override fun onCreateInputConnection(outAttrs: EditorInfo): InputConnection { + val config = if (bridge != null) { + bridge!!.config + } else { + CapConfig.Companion.loadDefault(context) + } + + val captureInput = config.isInputCaptured + if (captureInput) { + if (capInputConnection == null) { + capInputConnection = BaseInputConnection(this, false) + } + return capInputConnection!! + } + return super.onCreateInputConnection(outAttrs) + } + + @Suppress("deprecation") + override fun dispatchKeyEvent(event: KeyEvent): Boolean { + if (event.action == KeyEvent.ACTION_MULTIPLE) { + evaluateJavascript( + "document.activeElement.value = document.activeElement.value + '" + event.characters + "';", + null + ) + return false + } + return super.dispatchKeyEvent(event) + } +} diff --git a/@capacitor/android/capacitor/src/main/java/com/getcapacitor/FileUtils.java b/@capacitor/android/capacitor/src/main/java/com/getcapacitor/FileUtils.java deleted file mode 100644 index 47add8cd..00000000 --- a/@capacitor/android/capacitor/src/main/java/com/getcapacitor/FileUtils.java +++ /dev/null @@ -1,292 +0,0 @@ -/** - * Portions adopted from react-native-image-crop-picker - * - * MIT License - - * Copyright (c) 2017 Ivan Pusic - - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - * SOFTWARE. - */ -package com.getcapacitor; - -import android.content.ContentUris; -import android.content.Context; -import android.content.res.AssetManager; -import android.database.Cursor; -import android.net.Uri; -import android.os.Environment; -import android.provider.DocumentsContract; -import android.provider.MediaStore; -import android.provider.OpenableColumns; -import java.io.BufferedReader; -import java.io.File; -import java.io.FileOutputStream; -import java.io.FileReader; -import java.io.IOException; -import java.io.InputStream; -import java.io.InputStreamReader; - -/** - * Common File utilities, such as resolve content URIs and - * creating portable web paths from low-level files - */ -public class FileUtils { - - private static String CapacitorFileScheme = Bridge.CAPACITOR_FILE_START; - - public enum Type { - IMAGE("image"); - - private String type; - - Type(String type) { - this.type = type; - } - } - - public static String getPortablePath(Context c, String host, Uri u) { - String path = getFileUrlForUri(c, u); - if (path.startsWith("file://")) { - path = path.replace("file://", ""); - } - return host + Bridge.CAPACITOR_FILE_START + path; - } - - public static String getFileUrlForUri(final Context context, final Uri uri) { - // DocumentProvider - if (DocumentsContract.isDocumentUri(context, uri)) { - // ExternalStorageProvider - if (isExternalStorageDocument(uri)) { - final String docId = DocumentsContract.getDocumentId(uri); - final String[] split = docId.split(":"); - final String type = split[0]; - - if ("primary".equalsIgnoreCase(type)) { - return legacyPrimaryPath(split[1]); - } else { - final int splitIndex = docId.indexOf(':', 1); - final String tag = docId.substring(0, splitIndex); - final String path = docId.substring(splitIndex + 1); - - String nonPrimaryVolume = getPathToNonPrimaryVolume(context, tag); - if (nonPrimaryVolume != null) { - String result = nonPrimaryVolume + "/" + path; - File file = new File(result); - if (file.exists() && file.canRead()) { - return result; - } - return null; - } - } - } - // DownloadsProvider - else if (isDownloadsDocument(uri)) { - final String id = DocumentsContract.getDocumentId(uri); - final Uri contentUri = ContentUris.withAppendedId(Uri.parse("content://downloads/public_downloads"), Long.valueOf(id)); - - return getDataColumn(context, contentUri, null, null); - } - // MediaProvider - else if (isMediaDocument(uri)) { - final String docId = DocumentsContract.getDocumentId(uri); - final String[] split = docId.split(":"); - final String type = split[0]; - - Uri contentUri = null; - if ("image".equals(type)) { - contentUri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI; - } else if ("video".equals(type)) { - contentUri = MediaStore.Video.Media.EXTERNAL_CONTENT_URI; - } else if ("audio".equals(type)) { - contentUri = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI; - } - - final String selection = "_id=?"; - final String[] selectionArgs = new String[] { split[1] }; - - return getDataColumn(context, contentUri, selection, selectionArgs); - } - } - // MediaStore (and general) - else if ("content".equalsIgnoreCase(uri.getScheme())) { - // Return the remote address - if (isGooglePhotosUri(uri)) return uri.getLastPathSegment(); - return getDataColumn(context, uri, null, null); - } - // File - else if ("file".equalsIgnoreCase(uri.getScheme())) { - return uri.getPath(); - } - - return null; - } - - @SuppressWarnings("deprecation") - private static String legacyPrimaryPath(String pathPart) { - return Environment.getExternalStorageDirectory() + "/" + pathPart; - } - - /** - * Read a plaintext file from the assets directory. - * - * @param assetManager Used to open the file. - * @param fileName The path of the file to read. - * @return The contents of the file path. - * @throws IOException Thrown if any issues reading the provided file path. - */ - static String readFileFromAssets(AssetManager assetManager, String fileName) throws IOException { - try (BufferedReader reader = new BufferedReader(new InputStreamReader(assetManager.open(fileName)))) { - StringBuilder buffer = new StringBuilder(); - String line; - while ((line = reader.readLine()) != null) { - buffer.append(line).append("\n"); - } - - return buffer.toString(); - } - } - - /** - * Read a plaintext file from within the app disk space. - * - * @param file The file to read. - * @return The contents of the file path. - * @throws IOException Thrown if any issues reading the provided file path. - */ - static String readFileFromDisk(File file) throws IOException { - try (BufferedReader reader = new BufferedReader(new FileReader(file))) { - StringBuilder buffer = new StringBuilder(); - String line; - while ((line = reader.readLine()) != null) { - buffer.append(line).append("\n"); - } - - return buffer.toString(); - } - } - - /** - * Get the value of the data column for this Uri. This is useful for - * MediaStore Uris, and other file-based ContentProviders. - * - * @param context The context. - * @param uri The Uri to query. - * @param selection (Optional) Filter used in the query. - * @param selectionArgs (Optional) Selection arguments used in the query. - * @return The value of the _data column, which is typically a file path. - */ - private static String getDataColumn(Context context, Uri uri, String selection, String[] selectionArgs) { - String path = null; - Cursor cursor = null; - final String column = "_data"; - final String[] projection = { column }; - - try { - cursor = context.getContentResolver().query(uri, projection, selection, selectionArgs, null); - if (cursor != null && cursor.moveToFirst()) { - final int index = cursor.getColumnIndexOrThrow(column); - path = cursor.getString(index); - } - } catch (IllegalArgumentException ex) { - return getCopyFilePath(uri, context); - } finally { - if (cursor != null) cursor.close(); - } - if (path == null) { - return getCopyFilePath(uri, context); - } - return path; - } - - private static String getCopyFilePath(Uri uri, Context context) { - Cursor cursor = context.getContentResolver().query(uri, null, null, null, null); - int nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME); - cursor.moveToFirst(); - String name = (cursor.getString(nameIndex)); - File file = new File(context.getFilesDir(), name); - try { - InputStream inputStream = context.getContentResolver().openInputStream(uri); - FileOutputStream outputStream = new FileOutputStream(file); - int read = 0; - int maxBufferSize = 1024 * 1024; - int bufferSize = Math.min(inputStream.available(), maxBufferSize); - final byte[] buffers = new byte[bufferSize]; - while ((read = inputStream.read(buffers)) != -1) { - outputStream.write(buffers, 0, read); - } - inputStream.close(); - outputStream.close(); - } catch (Exception e) { - return null; - } finally { - if (cursor != null) cursor.close(); - } - return file.getPath(); - } - - /** - * @param uri The Uri to check. - * @return Whether the Uri authority is ExternalStorageProvider. - */ - private static boolean isExternalStorageDocument(Uri uri) { - return "com.android.externalstorage.documents".equals(uri.getAuthority()); - } - - /** - * @param uri The Uri to check. - * @return Whether the Uri authority is DownloadsProvider. - */ - private static boolean isDownloadsDocument(Uri uri) { - return "com.android.providers.downloads.documents".equals(uri.getAuthority()); - } - - /** - * @param uri The Uri to check. - * @return Whether the Uri authority is MediaProvider. - */ - private static boolean isMediaDocument(Uri uri) { - return "com.android.providers.media.documents".equals(uri.getAuthority()); - } - - /** - * @param uri The Uri to check. - * @return Whether the Uri authority is Google Photos. - */ - private static boolean isGooglePhotosUri(Uri uri) { - return "com.google.android.apps.photos.content".equals(uri.getAuthority()); - } - - private static String getPathToNonPrimaryVolume(Context context, String tag) { - File[] volumes = context.getExternalCacheDirs(); - if (volumes != null) { - for (File volume : volumes) { - if (volume != null) { - String path = volume.getAbsolutePath(); - if (path != null) { - int index = path.indexOf(tag); - if (index != -1) { - return path.substring(0, index) + tag; - } - } - } - } - } - return null; - } -} diff --git a/@capacitor/android/capacitor/src/main/java/com/getcapacitor/FileUtils.kt b/@capacitor/android/capacitor/src/main/java/com/getcapacitor/FileUtils.kt new file mode 100644 index 00000000..4f29e3f8 --- /dev/null +++ b/@capacitor/android/capacitor/src/main/java/com/getcapacitor/FileUtils.kt @@ -0,0 +1,287 @@ +/** + * Portions adopted from react-native-image-crop-picker + * + * MIT License + * + * Copyright (c) 2017 Ivan Pusic + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package com.getcapacitor + +import android.content.ContentUris +import android.content.Context +import android.content.res.AssetManager +import android.database.Cursor +import android.net.Uri +import android.os.Environment +import android.provider.DocumentsContract +import android.provider.MediaStore +import android.provider.OpenableColumns +import java.io.BufferedReader +import java.io.File +import java.io.FileOutputStream +import java.io.FileReader +import java.io.IOException +import java.io.InputStreamReader +import kotlin.math.min + +/** + * Common File utilities, such as resolve content URIs and + * creating portable web paths from low-level files + */ +object FileUtils { + private const val CapacitorFileScheme = Bridge.CAPACITOR_FILE_START + + fun getPortablePath(c: Context, host: String, u: Uri): String { + var path = getFileUrlForUri(c, u) + if (path!!.startsWith("file://")) { + path = path.replace("file://", "") + } + return host + Bridge.CAPACITOR_FILE_START + path + } + + fun getFileUrlForUri(context: Context, uri: Uri): String? { + // DocumentProvider + if (DocumentsContract.isDocumentUri(context, uri)) { + // ExternalStorageProvider + if (isExternalStorageDocument(uri)) { + val docId = DocumentsContract.getDocumentId(uri) + val split = docId.split(":".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() + val type = split[0] + + if ("primary".equals(type, ignoreCase = true)) { + return legacyPrimaryPath(split[1]) + } else { + val splitIndex = docId.indexOf(':', 1) + val tag = docId.substring(0, splitIndex) + val path = docId.substring(splitIndex + 1) + + val nonPrimaryVolume = getPathToNonPrimaryVolume(context, tag) + if (nonPrimaryVolume != null) { + val result = "$nonPrimaryVolume/$path" + val file = File(result) + if (file.exists() && file.canRead()) { + return result + } + return null + } + } + } else if (isDownloadsDocument(uri)) { + val id = DocumentsContract.getDocumentId(uri) + val contentUri = ContentUris.withAppendedId( + Uri.parse("content://downloads/public_downloads"), + id.toLong() + ) + + return getDataColumn(context, contentUri, null, null) + } else if (isMediaDocument(uri)) { + val docId = DocumentsContract.getDocumentId(uri) + val split = docId.split(":".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() + val type = split[0] + + var contentUri: Uri? = null + if ("image" == type) { + contentUri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI + } else if ("video" == type) { + contentUri = MediaStore.Video.Media.EXTERNAL_CONTENT_URI + } else if ("audio" == type) { + contentUri = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI + } + + val selection = "_id=?" + val selectionArgs = arrayOf(split[1]) + + return getDataColumn(context, contentUri, selection, selectionArgs) + } + } else if ("content".equals(uri.scheme, ignoreCase = true)) { + // Return the remote address + if (isGooglePhotosUri(uri)) return uri.lastPathSegment + return getDataColumn(context, uri, null, null) + } else if ("file".equals(uri.scheme, ignoreCase = true)) { + return uri.path + } + + return null + } + + @Suppress("deprecation") + private fun legacyPrimaryPath(pathPart: String): String { + return Environment.getExternalStorageDirectory().toString() + "/" + pathPart + } + + /** + * Read a plaintext file from the assets directory. + * + * @param assetManager Used to open the file. + * @param fileName The path of the file to read. + * @return The contents of the file path. + * @throws IOException Thrown if any issues reading the provided file path. + */ + @Throws(IOException::class) + fun readFileFromAssets(assetManager: AssetManager, fileName: String?): String { + BufferedReader(InputStreamReader(assetManager.open(fileName!!))).use { reader -> + val buffer = StringBuilder() + var line: String? + while ((reader.readLine().also { line = it }) != null) { + buffer.append(line).append("\n") + } + return buffer.toString() + } + } + + /** + * Read a plaintext file from within the app disk space. + * + * @param file The file to read. + * @return The contents of the file path. + * @throws IOException Thrown if any issues reading the provided file path. + */ + @Throws(IOException::class) + fun readFileFromDisk(file: File?): String { + BufferedReader(FileReader(file)).use { reader -> + val buffer = StringBuilder() + var line: String? + while ((reader.readLine().also { line = it }) != null) { + buffer.append(line).append("\n") + } + return buffer.toString() + } + } + + /** + * Get the value of the data column for this Uri. This is useful for + * MediaStore Uris, and other file-based ContentProviders. + * + * @param context The context. + * @param uri The Uri to query. + * @param selection (Optional) Filter used in the query. + * @param selectionArgs (Optional) Selection arguments used in the query. + * @return The value of the _data column, which is typically a file path. + */ + private fun getDataColumn( + context: Context, + uri: Uri?, + selection: String?, + selectionArgs: Array? + ): String? { + var path: String? = null + var cursor: Cursor? = null + val column = "_data" + val projection = arrayOf(column) + + try { + cursor = + context.contentResolver.query(uri!!, projection, selection, selectionArgs, null) + if (cursor != null && cursor.moveToFirst()) { + val index = cursor.getColumnIndexOrThrow(column) + path = cursor.getString(index) + } + } catch (ex: IllegalArgumentException) { + return getCopyFilePath(uri, context) + } finally { + cursor?.close() + } + if (path == null) { + return getCopyFilePath(uri, context) + } + return path + } + + private fun getCopyFilePath(uri: Uri?, context: Context): String? { + val cursor = context.contentResolver.query(uri!!, null, null, null, null) + val nameIndex = cursor!!.getColumnIndex(OpenableColumns.DISPLAY_NAME) + cursor.moveToFirst() + val name = (cursor.getString(nameIndex)) + val file = File(context.filesDir, name) + try { + val inputStream = context.contentResolver.openInputStream(uri) + val outputStream = FileOutputStream(file) + var read = 0 + val maxBufferSize = 1024 * 1024 + val bufferSize = min(inputStream!!.available().toDouble(), maxBufferSize.toDouble()) + .toInt() + val buffers = ByteArray(bufferSize) + while ((inputStream.read(buffers).also { read = it }) != -1) { + outputStream.write(buffers, 0, read) + } + inputStream.close() + outputStream.close() + } catch (e: Exception) { + return null + } finally { + if (cursor != null) cursor.close() + } + return file.path + } + + /** + * @param uri The Uri to check. + * @return Whether the Uri authority is ExternalStorageProvider. + */ + private fun isExternalStorageDocument(uri: Uri): Boolean { + return "com.android.externalstorage.documents" == uri.authority + } + + /** + * @param uri The Uri to check. + * @return Whether the Uri authority is DownloadsProvider. + */ + private fun isDownloadsDocument(uri: Uri): Boolean { + return "com.android.providers.downloads.documents" == uri.authority + } + + /** + * @param uri The Uri to check. + * @return Whether the Uri authority is MediaProvider. + */ + private fun isMediaDocument(uri: Uri): Boolean { + return "com.android.providers.media.documents" == uri.authority + } + + /** + * @param uri The Uri to check. + * @return Whether the Uri authority is Google Photos. + */ + private fun isGooglePhotosUri(uri: Uri): Boolean { + return "com.google.android.apps.photos.content" == uri.authority + } + + private fun getPathToNonPrimaryVolume(context: Context, tag: String): String? { + val volumes = context.externalCacheDirs + if (volumes != null) { + for (volume in volumes) { + if (volume != null) { + val path = volume.absolutePath + if (path != null) { + val index = path.indexOf(tag) + if (index != -1) { + return path.substring(0, index) + tag + } + } + } + } + } + return null + } + + enum class Type(private val type: String) { + IMAGE("image") + } +} diff --git a/@capacitor/android/capacitor/src/main/java/com/getcapacitor/InvalidPluginException.java b/@capacitor/android/capacitor/src/main/java/com/getcapacitor/InvalidPluginException.java deleted file mode 100644 index 1757e326..00000000 --- a/@capacitor/android/capacitor/src/main/java/com/getcapacitor/InvalidPluginException.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.getcapacitor; - -class InvalidPluginException extends Exception { - - public InvalidPluginException(String s) { - super(s); - } -} diff --git a/@capacitor/android/capacitor/src/main/java/com/getcapacitor/InvalidPluginException.kt b/@capacitor/android/capacitor/src/main/java/com/getcapacitor/InvalidPluginException.kt new file mode 100644 index 00000000..531f54a0 --- /dev/null +++ b/@capacitor/android/capacitor/src/main/java/com/getcapacitor/InvalidPluginException.kt @@ -0,0 +1,3 @@ +package com.getcapacitor + +internal class InvalidPluginException(s: String?) : Exception(s) diff --git a/@capacitor/android/capacitor/src/main/java/com/getcapacitor/InvalidPluginMethodException.java b/@capacitor/android/capacitor/src/main/java/com/getcapacitor/InvalidPluginMethodException.java deleted file mode 100644 index 94be491e..00000000 --- a/@capacitor/android/capacitor/src/main/java/com/getcapacitor/InvalidPluginMethodException.java +++ /dev/null @@ -1,16 +0,0 @@ -package com.getcapacitor; - -class InvalidPluginMethodException extends Exception { - - public InvalidPluginMethodException(String s) { - super(s); - } - - public InvalidPluginMethodException(Throwable t) { - super(t); - } - - public InvalidPluginMethodException(String s, Throwable t) { - super(s, t); - } -} diff --git a/@capacitor/android/capacitor/src/main/java/com/getcapacitor/InvalidPluginMethodException.kt b/@capacitor/android/capacitor/src/main/java/com/getcapacitor/InvalidPluginMethodException.kt new file mode 100644 index 00000000..c552b811 --- /dev/null +++ b/@capacitor/android/capacitor/src/main/java/com/getcapacitor/InvalidPluginMethodException.kt @@ -0,0 +1,9 @@ +package com.getcapacitor + +internal class InvalidPluginMethodException : Exception { + constructor(s: String?) : super(s) + + constructor(t: Throwable?) : super(t) + + constructor(s: String?, t: Throwable?) : super(s, t) +} diff --git a/@capacitor/android/capacitor/src/main/java/com/getcapacitor/JSArray.java b/@capacitor/android/capacitor/src/main/java/com/getcapacitor/JSArray.java deleted file mode 100644 index 06b7f4dd..00000000 --- a/@capacitor/android/capacitor/src/main/java/com/getcapacitor/JSArray.java +++ /dev/null @@ -1,51 +0,0 @@ -package com.getcapacitor; - -import java.util.ArrayList; -import java.util.Collection; -import java.util.List; -import org.json.JSONArray; -import org.json.JSONException; - -public class JSArray extends JSONArray { - - public JSArray() { - super(); - } - - public JSArray(String json) throws JSONException { - super(json); - } - - public JSArray(Collection copyFrom) { - super(copyFrom); - } - - public JSArray(Object array) throws JSONException { - super(array); - } - - @SuppressWarnings("unchecked") - public List toList() throws JSONException { - List items = new ArrayList<>(); - Object o = null; - for (int i = 0; i < this.length(); i++) { - o = this.get(i); - try { - items.add((E) this.get(i)); - } catch (Exception ex) { - throw new JSONException("Not all items are instances of the given type"); - } - } - return items; - } - - /** - * Create a new JSArray without throwing a error - */ - public static JSArray from(Object array) { - try { - return new JSArray(array); - } catch (JSONException ex) {} - return null; - } -} diff --git a/@capacitor/android/capacitor/src/main/java/com/getcapacitor/JSArray.kt b/@capacitor/android/capacitor/src/main/java/com/getcapacitor/JSArray.kt new file mode 100644 index 00000000..c08fb5aa --- /dev/null +++ b/@capacitor/android/capacitor/src/main/java/com/getcapacitor/JSArray.kt @@ -0,0 +1,42 @@ +package com.getcapacitor + +import org.json.JSONArray +import org.json.JSONException + +class JSArray : JSONArray { + constructor() : super() + + constructor(json: String?) : super(json) + + constructor(copyFrom: Collection<*>?) : super(copyFrom) + + constructor(array: Any?) : super(array) + + @Throws(JSONException::class) + fun toList(): List { + val items: MutableList = ArrayList() + var o: Any? = null + for (i in 0 until this.length()) { + o = this[i] + try { + items.add(this[i] as E) + } catch (ex: Exception) { + throw JSONException("Not all items are instances of the given type") + } + } + return items + } + + companion object { + /** + * Create a new JSArray without throwing a error + */ + fun from(array: Any?): JSArray? { + try { + return JSArray(array) + } catch (ex: JSONException) { + } + return null + } + } +} diff --git a/@capacitor/android/capacitor/src/main/java/com/getcapacitor/JSExport.java b/@capacitor/android/capacitor/src/main/java/com/getcapacitor/JSExport.java deleted file mode 100644 index 382f4b5d..00000000 --- a/@capacitor/android/capacitor/src/main/java/com/getcapacitor/JSExport.java +++ /dev/null @@ -1,193 +0,0 @@ -package com.getcapacitor; - -import static com.getcapacitor.FileUtils.readFileFromAssets; - -import android.content.Context; -import android.text.TextUtils; -import java.io.IOException; -import java.util.ArrayList; -import java.util.Collection; -import java.util.List; -import org.json.JSONArray; -import org.json.JSONException; -import org.json.JSONObject; - -public class JSExport { - - private static String CATCHALL_OPTIONS_PARAM = "_options"; - private static String CALLBACK_PARAM = "_callback"; - - public static String getGlobalJS(Context context, boolean loggingEnabled, boolean isDebug) { - return "window.Capacitor = { DEBUG: " + isDebug + ", isLoggingEnabled: " + loggingEnabled + ", Plugins: {} };"; - } - - public static String getCordovaJS(Context context) { - String fileContent = ""; - try { - fileContent = readFileFromAssets(context.getAssets(), "public/cordova.js"); - } catch (IOException ex) { - Logger.error("Unable to read public/cordova.js file, Cordova plugins will not work"); - } - return fileContent; - } - - public static String getCordovaPluginsFileJS(Context context) { - String fileContent = ""; - try { - fileContent = readFileFromAssets(context.getAssets(), "public/cordova_plugins.js"); - } catch (IOException ex) { - Logger.error("Unable to read public/cordova_plugins.js file, Cordova plugins will not work"); - } - return fileContent; - } - - public static String getPluginJS(Collection plugins) { - List lines = new ArrayList<>(); - JSONArray pluginArray = new JSONArray(); - - lines.add("// Begin: Capacitor Plugin JS"); - for (PluginHandle plugin : plugins) { - lines.add( - "(function(w) {\n" + - "var a = (w.Capacitor = w.Capacitor || {});\n" + - "var p = (a.Plugins = a.Plugins || {});\n" + - "var t = (p['" + - plugin.getId() + - "'] = {});\n" + - "t.addListener = function(eventName, callback) {\n" + - " return w.Capacitor.addListener('" + - plugin.getId() + - "', eventName, callback);\n" + - "}" - ); - Collection methods = plugin.getMethods(); - for (PluginMethodHandle method : methods) { - if (method.getName().equals("addListener") || method.getName().equals("removeListener")) { - // Don't export add/remove listener, we do that automatically above as they are "special snowflakes" - continue; - } - lines.add(generateMethodJS(plugin, method)); - } - - lines.add("})(window);\n"); - pluginArray.put(createPluginHeader(plugin)); - } - - return TextUtils.join("\n", lines) + "\nwindow.Capacitor.PluginHeaders = " + pluginArray.toString() + ";"; - } - - public static String getCordovaPluginJS(Context context) { - return getFilesContent(context, "public/plugins"); - } - - public static String getFilesContent(Context context, String path) { - StringBuilder builder = new StringBuilder(); - try { - String[] content = context.getAssets().list(path); - if (content.length > 0) { - for (String file : content) { - if (!file.endsWith(".map")) { - builder.append(getFilesContent(context, path + "/" + file)); - } - } - } else { - return readFileFromAssets(context.getAssets(), path); - } - } catch (IOException ex) { - Logger.warn("Unable to read file at path " + path); - } - return builder.toString(); - } - - private static JSONObject createPluginHeader(PluginHandle plugin) { - JSONObject pluginObj = new JSONObject(); - Collection methods = plugin.getMethods(); - try { - String id = plugin.getId(); - JSONArray methodArray = new JSONArray(); - pluginObj.put("name", id); - - for (PluginMethodHandle method : methods) { - methodArray.put(createPluginMethodHeader(method)); - } - - pluginObj.put("methods", methodArray); - } catch (JSONException e) { - // ignore - } - return pluginObj; - } - - private static JSONObject createPluginMethodHeader(PluginMethodHandle method) { - JSONObject methodObj = new JSONObject(); - - try { - methodObj.put("name", method.getName()); - if (!method.getReturnType().equals(PluginMethod.RETURN_NONE)) { - methodObj.put("rtype", method.getReturnType()); - } - } catch (JSONException e) { - // ignore - } - - return methodObj; - } - - public static String getBridgeJS(Context context) throws JSExportException { - return getFilesContent(context, "native-bridge.js"); - } - - private static String generateMethodJS(PluginHandle plugin, PluginMethodHandle method) { - List lines = new ArrayList<>(); - - List args = new ArrayList<>(); - // Add the catch all param that will take a full javascript object to pass to the plugin - args.add(CATCHALL_OPTIONS_PARAM); - - String returnType = method.getReturnType(); - if (returnType.equals(PluginMethod.RETURN_CALLBACK)) { - args.add(CALLBACK_PARAM); - } - - // Create the method function declaration - lines.add("t['" + method.getName() + "'] = function(" + TextUtils.join(", ", args) + ") {"); - - switch (returnType) { - case PluginMethod.RETURN_NONE: - lines.add( - "return w.Capacitor.nativeCallback('" + - plugin.getId() + - "', '" + - method.getName() + - "', " + - CATCHALL_OPTIONS_PARAM + - ")" - ); - break; - case PluginMethod.RETURN_PROMISE: - lines.add( - "return w.Capacitor.nativePromise('" + plugin.getId() + "', '" + method.getName() + "', " + CATCHALL_OPTIONS_PARAM + ")" - ); - break; - case PluginMethod.RETURN_CALLBACK: - lines.add( - "return w.Capacitor.nativeCallback('" + - plugin.getId() + - "', '" + - method.getName() + - "', " + - CATCHALL_OPTIONS_PARAM + - ", " + - CALLBACK_PARAM + - ")" - ); - break; - default: - // TODO: Do something here? - } - - lines.add("}"); - - return TextUtils.join("\n", lines); - } -} diff --git a/@capacitor/android/capacitor/src/main/java/com/getcapacitor/JSExport.kt b/@capacitor/android/capacitor/src/main/java/com/getcapacitor/JSExport.kt new file mode 100644 index 00000000..47bbb2de --- /dev/null +++ b/@capacitor/android/capacitor/src/main/java/com/getcapacitor/JSExport.kt @@ -0,0 +1,183 @@ +package com.getcapacitor + +import android.content.Context +import android.text.TextUtils +import com.getcapacitor.JSExportException +import org.json.JSONArray +import org.json.JSONException +import org.json.JSONObject +import java.io.IOException + +object JSExport { + private const val CATCHALL_OPTIONS_PARAM = "_options" + private const val CALLBACK_PARAM = "_callback" + + fun getGlobalJS(context: Context?, loggingEnabled: Boolean, isDebug: Boolean): String { + return "window.Capacitor = { DEBUG: $isDebug, isLoggingEnabled: $loggingEnabled, Plugins: {} };" + } + + fun getCordovaJS(context: Context): String? { + var fileContent: String? = "" + try { + fileContent = FileUtils.readFileFromAssets(context.assets, "public/cordova.js") + } catch (ex: IOException) { + Logger.Companion.error("Unable to read public/cordova.js file, Cordova plugins will not work") + } + return fileContent + } + + fun getCordovaPluginsFileJS(context: Context): String? { + var fileContent: String? = "" + try { + fileContent = FileUtils.readFileFromAssets(context.assets, "public/cordova_plugins.js") + } catch (ex: IOException) { + Logger.Companion.error("Unable to read public/cordova_plugins.js file, Cordova plugins will not work") + } + return fileContent + } + + fun getPluginJS(plugins: Collection): String { + val lines: MutableList = ArrayList() + val pluginArray = JSONArray() + + lines.add("// Begin: Capacitor Plugin JS") + for (plugin in plugins) { + lines.add( + """(function(w) { +var a = (w.Capacitor = w.Capacitor || {}); +var p = (a.Plugins = a.Plugins || {}); +var t = (p['${plugin.id}'] = {}); +t.addListener = function(eventName, callback) { + return w.Capacitor.addListener('${plugin.id}', eventName, callback); +}""" + ) + val methods = plugin.methods + for (method in methods!!) { + if (method.name == "addListener" || method.name == "removeListener") { + // Don't export add/remove listener, we do that automatically above as they are "special snowflakes" + continue + } + lines.add(generateMethodJS(plugin, method)) + } + + lines.add("})(window);\n") + pluginArray.put(createPluginHeader(plugin)) + } + + return """ + ${TextUtils.join("\n", lines)} + window.Capacitor.PluginHeaders = $pluginArray; + """.trimIndent() + } + + fun getCordovaPluginJS(context: Context): String? { + return getFilesContent(context, "public/plugins") + } + + fun getFilesContent(context: Context, path: String): String? { + val builder = StringBuilder() + try { + val content = context.assets.list(path) + if (content!!.size > 0) { + for (file in content) { + if (!file.endsWith(".map")) { + builder.append(getFilesContent(context, "$path/$file")) + } + } + } else { + return FileUtils.readFileFromAssets(context.assets, path) + } + } catch (ex: IOException) { + Logger.Companion.warn("Unable to read file at path $path") + } + return builder.toString() + } + + private fun createPluginHeader(plugin: PluginHandle): JSONObject { + val pluginObj = JSONObject() + val methods = plugin.methods + try { + val id = plugin.id + val methodArray = JSONArray() + pluginObj.put("name", id) + + for (method in methods!!) { + methodArray.put(createPluginMethodHeader(method)) + } + + pluginObj.put("methods", methodArray) + } catch (e: JSONException) { + // ignore + } + return pluginObj + } + + private fun createPluginMethodHeader(method: PluginMethodHandle?): JSONObject { + val methodObj = JSONObject() + + try { + methodObj.put("name", method.getName()) + if (method.getReturnType() != PluginMethod.Companion.RETURN_NONE) { + methodObj.put("rtype", method.getReturnType()) + } + } catch (e: JSONException) { + // ignore + } + + return methodObj + } + + @Throws(JSExportException::class) + fun getBridgeJS(context: Context): String? { + return getFilesContent(context, "native-bridge.js") + } + + private fun generateMethodJS(plugin: PluginHandle, method: PluginMethodHandle?): String { + val lines: MutableList = ArrayList() + + val args: MutableList = ArrayList() + // Add the catch all param that will take a full javascript object to pass to the plugin + args.add(CATCHALL_OPTIONS_PARAM) + + val returnType = method.getReturnType() + if (returnType == PluginMethod.Companion.RETURN_CALLBACK) { + args.add(CALLBACK_PARAM) + } + + // Create the method function declaration + lines.add("t['" + method.getName() + "'] = function(" + TextUtils.join(", ", args) + ") {") + + when (returnType) { + PluginMethod.Companion.RETURN_NONE -> lines.add( + "return w.Capacitor.nativeCallback('" + + plugin.id + + "', '" + + method.getName() + + "', " + + CATCHALL_OPTIONS_PARAM + + ")" + ) + + PluginMethod.Companion.RETURN_PROMISE -> lines.add( + "return w.Capacitor.nativePromise('" + plugin.id + "', '" + method.getName() + "', " + CATCHALL_OPTIONS_PARAM + ")" + ) + + PluginMethod.Companion.RETURN_CALLBACK -> lines.add( + "return w.Capacitor.nativeCallback('" + + plugin.id + + "', '" + + method.getName() + + "', " + + CATCHALL_OPTIONS_PARAM + + ", " + + CALLBACK_PARAM + + ")" + ) + + else -> {} + } + lines.add("}") + + return TextUtils.join("\n", lines) + } +} diff --git a/@capacitor/android/capacitor/src/main/java/com/getcapacitor/JSExportException.java b/@capacitor/android/capacitor/src/main/java/com/getcapacitor/JSExportException.java deleted file mode 100644 index 14b6043a..00000000 --- a/@capacitor/android/capacitor/src/main/java/com/getcapacitor/JSExportException.java +++ /dev/null @@ -1,16 +0,0 @@ -package com.getcapacitor; - -public class JSExportException extends Exception { - - public JSExportException(String s) { - super(s); - } - - public JSExportException(Throwable t) { - super(t); - } - - public JSExportException(String s, Throwable t) { - super(s, t); - } -} diff --git a/@capacitor/android/capacitor/src/main/java/com/getcapacitor/JSExportException.kt b/@capacitor/android/capacitor/src/main/java/com/getcapacitor/JSExportException.kt new file mode 100644 index 00000000..a1557b7d --- /dev/null +++ b/@capacitor/android/capacitor/src/main/java/com/getcapacitor/JSExportException.kt @@ -0,0 +1,9 @@ +package com.getcapacitor + +class JSExportException : Exception { + constructor(s: String?) : super(s) + + constructor(t: Throwable?) : super(t) + + constructor(s: String?, t: Throwable?) : super(s, t) +} diff --git a/@capacitor/android/capacitor/src/main/java/com/getcapacitor/JSInjector.java b/@capacitor/android/capacitor/src/main/java/com/getcapacitor/JSInjector.java deleted file mode 100644 index a3871f7b..00000000 --- a/@capacitor/android/capacitor/src/main/java/com/getcapacitor/JSInjector.java +++ /dev/null @@ -1,107 +0,0 @@ -package com.getcapacitor; - -import java.io.ByteArrayInputStream; -import java.io.InputStream; -import java.io.InputStreamReader; -import java.io.Reader; -import java.nio.charset.StandardCharsets; - -/** - * JSInject is responsible for returning Capacitor's core - * runtime JS and any plugin JS back into HTML page responses - * to the client. - */ -class JSInjector { - - private String globalJS; - private String bridgeJS; - private String pluginJS; - private String cordovaJS; - private String cordovaPluginsJS; - private String cordovaPluginsFileJS; - private String localUrlJS; - - public JSInjector( - String globalJS, - String bridgeJS, - String pluginJS, - String cordovaJS, - String cordovaPluginsJS, - String cordovaPluginsFileJS, - String localUrlJS - ) { - this.globalJS = globalJS; - this.bridgeJS = bridgeJS; - this.pluginJS = pluginJS; - this.cordovaJS = cordovaJS; - this.cordovaPluginsJS = cordovaPluginsJS; - this.cordovaPluginsFileJS = cordovaPluginsFileJS; - this.localUrlJS = localUrlJS; - } - - /** - * Generates injectable JS content. - * This may be used in other forms of injecting that aren't using an InputStream. - * @return - */ - public String getScriptString() { - return ( - globalJS + - "\n\n" + - localUrlJS + - "\n\n" + - bridgeJS + - "\n\n" + - pluginJS + - "\n\n" + - cordovaJS + - "\n\n" + - cordovaPluginsFileJS + - "\n\n" + - cordovaPluginsJS - ); - } - - /** - * Given an InputStream from the web server, prepend it with - * our JS stream - * @param responseStream - * @return - */ - public InputStream getInjectedStream(InputStream responseStream) { - String js = ""; - String html = this.readAssetStream(responseStream); - - // Insert the js string at the position after or before using StringBuilder - StringBuilder modifiedHtml = new StringBuilder(html); - if (html.contains("")) { - modifiedHtml.insert(html.indexOf("") + "".length(), "\n" + js + "\n"); - html = modifiedHtml.toString(); - } else if (html.contains("")) { - modifiedHtml.insert(html.indexOf(""), "\n" + js + "\n"); - html = modifiedHtml.toString(); - } else { - Logger.error("Unable to inject Capacitor, Plugins won't work"); - } - return new ByteArrayInputStream(html.getBytes(StandardCharsets.UTF_8)); - } - - private String readAssetStream(InputStream stream) { - try { - final int bufferSize = 1024; - final char[] buffer = new char[bufferSize]; - final StringBuilder out = new StringBuilder(); - Reader in = new InputStreamReader(stream, StandardCharsets.UTF_8); - for (;;) { - int rsz = in.read(buffer, 0, buffer.length); - if (rsz < 0) break; - out.append(buffer, 0, rsz); - } - return out.toString(); - } catch (Exception e) { - Logger.error("Unable to process HTML asset file. This is a fatal error", e); - } - - return ""; - } -} diff --git a/@capacitor/android/capacitor/src/main/java/com/getcapacitor/JSInjector.kt b/@capacitor/android/capacitor/src/main/java/com/getcapacitor/JSInjector.kt new file mode 100644 index 00000000..0f516013 --- /dev/null +++ b/@capacitor/android/capacitor/src/main/java/com/getcapacitor/JSInjector.kt @@ -0,0 +1,100 @@ +package com.getcapacitor + +import java.io.ByteArrayInputStream +import java.io.InputStream +import java.io.InputStreamReader +import java.io.Reader +import java.nio.charset.StandardCharsets + +/** + * JSInject is responsible for returning Capacitor's core + * runtime JS and any plugin JS back into HTML page responses + * to the client. + */ +internal class JSInjector( + private val globalJS: String, + private val bridgeJS: String, + private val pluginJS: String, + private val cordovaJS: String, + private val cordovaPluginsJS: String, + private val cordovaPluginsFileJS: String, + private val localUrlJS: String +) { + val scriptString: String + /** + * Generates injectable JS content. + * This may be used in other forms of injecting that aren't using an InputStream. + * @return + */ + get() = (""" + $globalJS + + $localUrlJS + + $bridgeJS + + $pluginJS + + $cordovaJS + + $cordovaPluginsFileJS + + $cordovaPluginsJS + """.trimIndent() + ) + + /** + * Given an InputStream from the web server, prepend it with + * our JS stream + * @param responseStream + * @return + */ + fun getInjectedStream(responseStream: InputStream?): InputStream { + val js = "" + var html = this.readAssetStream(responseStream) + + // Insert the js string at the position after or before using StringBuilder + val modifiedHtml = StringBuilder(html) + if (html.contains("")) { + modifiedHtml.insert( + html.indexOf("") + "".length, """ + + $js + + """.trimIndent() + ) + html = modifiedHtml.toString() + } else if (html.contains("")) { + modifiedHtml.insert( + html.indexOf(""), """ + + $js + + """.trimIndent() + ) + html = modifiedHtml.toString() + } else { + Logger.Companion.error("Unable to inject Capacitor, Plugins won't work") + } + return ByteArrayInputStream(html.toByteArray(StandardCharsets.UTF_8)) + } + + private fun readAssetStream(stream: InputStream?): String { + try { + val bufferSize = 1024 + val buffer = CharArray(bufferSize) + val out = StringBuilder() + val `in`: Reader = InputStreamReader(stream, StandardCharsets.UTF_8) + while (true) { + val rsz = `in`.read(buffer, 0, buffer.size) + if (rsz < 0) break + out.append(buffer, 0, rsz) + } + return out.toString() + } catch (e: Exception) { + Logger.Companion.error("Unable to process HTML asset file. This is a fatal error", e) + } + + return "" + } +} diff --git a/@capacitor/android/capacitor/src/main/java/com/getcapacitor/JSObject.java b/@capacitor/android/capacitor/src/main/java/com/getcapacitor/JSObject.java deleted file mode 100644 index 0e987076..00000000 --- a/@capacitor/android/capacitor/src/main/java/com/getcapacitor/JSObject.java +++ /dev/null @@ -1,164 +0,0 @@ -package com.getcapacitor; - -import androidx.annotation.Nullable; -import java.util.ArrayList; -import java.util.Iterator; -import java.util.List; -import org.json.JSONException; -import org.json.JSONObject; - -/** - * A wrapper around JSONObject that isn't afraid to do simple - * JSON put operations without having to throw an exception - * for every little thing jeez - */ -public class JSObject extends JSONObject { - - public JSObject() { - super(); - } - - public JSObject(String json) throws JSONException { - super(json); - } - - public JSObject(JSONObject obj, String[] names) throws JSONException { - super(obj, names); - } - - /** - * Convert a pathetic JSONObject into a JSObject - * @param obj - */ - public static JSObject fromJSONObject(JSONObject obj) throws JSONException { - Iterator keysIter = obj.keys(); - List keys = new ArrayList<>(); - while (keysIter.hasNext()) { - keys.add(keysIter.next()); - } - - return new JSObject(obj, keys.toArray(new String[keys.size()])); - } - - @Override - @Nullable - public String getString(String key) { - return getString(key, null); - } - - @Nullable - public String getString(String key, @Nullable String defaultValue) { - try { - String value = super.getString(key); - if (!super.isNull(key)) { - return value; - } - } catch (JSONException ex) {} - return defaultValue; - } - - @Nullable - public Integer getInteger(String key) { - return getInteger(key, null); - } - - @Nullable - public Integer getInteger(String key, @Nullable Integer defaultValue) { - try { - return super.getInt(key); - } catch (JSONException e) {} - return defaultValue; - } - - @Nullable - public Boolean getBoolean(String key, @Nullable Boolean defaultValue) { - try { - return super.getBoolean(key); - } catch (JSONException e) {} - return defaultValue; - } - - /** - * Fetch boolean from jsonObject - */ - @Nullable - public Boolean getBool(String key) { - return getBoolean(key, null); - } - - @Nullable - public JSObject getJSObject(String name) { - try { - return getJSObject(name, null); - } catch (JSONException e) {} - return null; - } - - @Nullable - public JSObject getJSObject(String name, @Nullable JSObject defaultValue) throws JSONException { - try { - Object obj = get(name); - if (obj instanceof JSONObject) { - Iterator keysIter = ((JSONObject) obj).keys(); - List keys = new ArrayList<>(); - while (keysIter.hasNext()) { - keys.add(keysIter.next()); - } - - return new JSObject((JSONObject) obj, keys.toArray(new String[keys.size()])); - } - } catch (JSONException ex) {} - return defaultValue; - } - - @Override - public JSObject put(String key, boolean value) { - try { - super.put(key, value); - } catch (JSONException ex) {} - return this; - } - - @Override - public JSObject put(String key, int value) { - try { - super.put(key, value); - } catch (JSONException ex) {} - return this; - } - - @Override - public JSObject put(String key, long value) { - try { - super.put(key, value); - } catch (JSONException ex) {} - return this; - } - - @Override - public JSObject put(String key, double value) { - try { - super.put(key, value); - } catch (JSONException ex) {} - return this; - } - - @Override - public JSObject put(String key, Object value) { - try { - super.put(key, value); - } catch (JSONException ex) {} - return this; - } - - public JSObject put(String key, String value) { - try { - super.put(key, value); - } catch (JSONException ex) {} - return this; - } - - public JSObject putSafe(String key, Object value) throws JSONException { - return (JSObject) super.put(key, value); - } -} diff --git a/@capacitor/android/capacitor/src/main/java/com/getcapacitor/JSObject.kt b/@capacitor/android/capacitor/src/main/java/com/getcapacitor/JSObject.kt new file mode 100644 index 00000000..e04a2c33 --- /dev/null +++ b/@capacitor/android/capacitor/src/main/java/com/getcapacitor/JSObject.kt @@ -0,0 +1,155 @@ +package com.getcapacitor + +import org.json.JSONException +import org.json.JSONObject + +/** + * A wrapper around JSONObject that isn't afraid to do simple + * JSON put operations without having to throw an exception + * for every little thing jeez + */ +open class JSObject : JSONObject { + constructor() : super() + + constructor(json: String?) : super(json) + + constructor(obj: JSONObject?, names: Array?) : super(obj, names) + + override fun getString(key: String): String? { + return getString(key, null) + } + + fun getString(key: String?, defaultValue: String?): String? { + try { + val value = super.getString(key) + if (!super.isNull(key)) { + return value + } + } catch (ex: JSONException) { + } + return defaultValue + } + + fun getInteger(key: String?): Int? { + return getInteger(key, null) + } + + fun getInteger(key: String?, defaultValue: Int?): Int? { + try { + return super.getInt(key) + } catch (e: JSONException) { + } + return defaultValue + } + + fun getBoolean(key: String?, defaultValue: Boolean?): Boolean? { + try { + return super.getBoolean(key) + } catch (e: JSONException) { + } + return defaultValue + } + + /** + * Fetch boolean from jsonObject + */ + fun getBool(key: String?): Boolean? { + return getBoolean(key, null) + } + + fun getJSObject(name: String?): JSObject? { + try { + return getJSObject(name, null) + } catch (e: JSONException) { + } + return null + } + + @Throws(JSONException::class) + fun getJSObject(name: String?, defaultValue: JSObject?): JSObject? { + try { + val obj = get(name) + if (obj is JSONObject) { + val keysIter = obj.keys() + val keys: MutableList = ArrayList() + while (keysIter.hasNext()) { + keys.add(keysIter.next()) + } + + return JSObject(obj, keys.toTypedArray()) + } + } catch (ex: JSONException) { + } + return defaultValue + } + + override fun put(key: String, value: Boolean): JSObject { + try { + super.put(key, value) + } catch (ex: JSONException) { + } + return this + } + + override fun put(key: String, value: Int): JSObject { + try { + super.put(key, value) + } catch (ex: JSONException) { + } + return this + } + + override fun put(key: String, value: Long): JSObject { + try { + super.put(key, value) + } catch (ex: JSONException) { + } + return this + } + + override fun put(key: String, value: Double): JSObject { + try { + super.put(key, value) + } catch (ex: JSONException) { + } + return this + } + + override fun put(key: String, value: Any): JSObject { + try { + super.put(key, value) + } catch (ex: JSONException) { + } + return this + } + + fun put(key: String?, value: String?): JSObject { + try { + super.put(key, value) + } catch (ex: JSONException) { + } + return this + } + + @Throws(JSONException::class) + fun putSafe(key: String?, value: Any?): JSObject { + return super.put(key, value) as JSObject + } + + companion object { + /** + * Convert a pathetic JSONObject into a JSObject + * @param obj + */ + @Throws(JSONException::class) + fun fromJSONObject(obj: JSONObject): JSObject { + val keysIter = obj.keys() + val keys: MutableList = ArrayList() + while (keysIter.hasNext()) { + keys.add(keysIter.next()) + } + + return JSObject(obj, keys.toTypedArray()) + } + } +} diff --git a/@capacitor/android/capacitor/src/main/java/com/getcapacitor/JSValue.java b/@capacitor/android/capacitor/src/main/java/com/getcapacitor/JSValue.java deleted file mode 100644 index d97ba91b..00000000 --- a/@capacitor/android/capacitor/src/main/java/com/getcapacitor/JSValue.java +++ /dev/null @@ -1,65 +0,0 @@ -package com.getcapacitor; - -import org.json.JSONException; - -/** - * Represents a single user-data value of any type on the capacitor PluginCall object. - */ -public class JSValue { - - private final Object value; - - /** - * @param call The capacitor plugin call, used for accessing the value safely. - * @param name The name of the property to access. - */ - public JSValue(PluginCall call, String name) { - this.value = this.toValue(call, name); - } - - /** - * Returns the coerced but uncasted underlying value. - */ - public Object getValue() { - return this.value; - } - - @Override - public String toString() { - return this.getValue().toString(); - } - - /** - * Returns the underlying value as a JSObject, or throwing if it cannot. - * - * @throws JSONException If the underlying value is not a JSObject. - */ - public JSObject toJSObject() throws JSONException { - if (this.value instanceof JSObject) return (JSObject) this.value; - throw new JSONException("JSValue could not be coerced to JSObject."); - } - - /** - * Returns the underlying value as a JSArray, or throwing if it cannot. - * - * @throws JSONException If the underlying value is not a JSArray. - */ - public JSArray toJSArray() throws JSONException { - if (this.value instanceof JSArray) return (JSArray) this.value; - throw new JSONException("JSValue could not be coerced to JSArray."); - } - - /** - * Returns the underlying value this object represents, coercing it into a capacitor-friendly object if supported. - */ - private Object toValue(PluginCall call, String name) { - Object value = null; - value = call.getArray(name, null); - if (value != null) return value; - value = call.getObject(name, null); - if (value != null) return value; - value = call.getString(name, null); - if (value != null) return value; - return call.getData().opt(name); - } -} diff --git a/@capacitor/android/capacitor/src/main/java/com/getcapacitor/JSValue.kt b/@capacitor/android/capacitor/src/main/java/com/getcapacitor/JSValue.kt new file mode 100644 index 00000000..e57395ec --- /dev/null +++ b/@capacitor/android/capacitor/src/main/java/com/getcapacitor/JSValue.kt @@ -0,0 +1,62 @@ +package com.getcapacitor + +import org.json.JSONException + +/** + * Represents a single user-data value of any type on the capacitor PluginCall object. + */ +class JSValue(call: PluginCall, name: String) { + /** + * Returns the coerced but uncasted underlying value. + */ + @JvmField + val value: Any + + /** + * @param call The capacitor plugin call, used for accessing the value safely. + * @param name The name of the property to access. + */ + init { + this.value = this.toValue(call, name) + } + + override fun toString(): String { + return value.toString() + } + + /** + * Returns the underlying value as a JSObject, or throwing if it cannot. + * + * @throws JSONException If the underlying value is not a JSObject. + */ + @Throws(JSONException::class) + fun toJSObject(): JSObject { + if (value is JSObject) return value + throw JSONException("JSValue could not be coerced to JSObject.") + } + + /** + * Returns the underlying value as a JSArray, or throwing if it cannot. + * + * @throws JSONException If the underlying value is not a JSArray. + */ + @Throws(JSONException::class) + fun toJSArray(): JSArray { + if (value is JSArray) return value + throw JSONException("JSValue could not be coerced to JSArray.") + } + + /** + * Returns the underlying value this object represents, coercing it into a capacitor-friendly object if supported. + */ + private fun toValue(call: PluginCall, name: String): Any { + var value: Any? = null + value = call.getArray(name, null) + if (value != null) return value + value = call.getObject(name, null) + if (value != null) return value + value = call.getString(name, null) + if (value != null) return value + return call.data.opt(name) + } +} diff --git a/@capacitor/android/capacitor/src/main/java/com/getcapacitor/Logger.java b/@capacitor/android/capacitor/src/main/java/com/getcapacitor/Logger.java deleted file mode 100644 index 9d24fedd..00000000 --- a/@capacitor/android/capacitor/src/main/java/com/getcapacitor/Logger.java +++ /dev/null @@ -1,103 +0,0 @@ -package com.getcapacitor; - -import android.text.TextUtils; -import android.util.Log; - -public class Logger { - - public static final String LOG_TAG_CORE = "Capacitor"; - public static CapConfig config; - - private static Logger instance; - - private static Logger getInstance() { - if (instance == null) { - instance = new Logger(); - } - return instance; - } - - public static void init(CapConfig config) { - Logger.getInstance().loadConfig(config); - } - - private void loadConfig(CapConfig config) { - Logger.config = config; - } - - public static String tags(String... subtags) { - if (subtags != null && subtags.length > 0) { - return LOG_TAG_CORE + "/" + TextUtils.join("/", subtags); - } - - return LOG_TAG_CORE; - } - - public static void verbose(String message) { - verbose(LOG_TAG_CORE, message); - } - - public static void verbose(String tag, String message) { - if (!shouldLog()) { - return; - } - - Log.v(tag, message); - } - - public static void debug(String message) { - debug(LOG_TAG_CORE, message); - } - - public static void debug(String tag, String message) { - if (!shouldLog()) { - return; - } - - Log.d(tag, message); - } - - public static void info(String message) { - info(LOG_TAG_CORE, message); - } - - public static void info(String tag, String message) { - if (!shouldLog()) { - return; - } - - Log.i(tag, message); - } - - public static void warn(String message) { - warn(LOG_TAG_CORE, message); - } - - public static void warn(String tag, String message) { - if (!shouldLog()) { - return; - } - - Log.w(tag, message); - } - - public static void error(String message) { - error(LOG_TAG_CORE, message, null); - } - - public static void error(String message, Throwable e) { - error(LOG_TAG_CORE, message, e); - } - - public static void error(String tag, String message, Throwable e) { - if (!shouldLog()) { - return; - } - - Log.e(tag, message, e); - } - - public static boolean shouldLog() { - return config == null || config.isLoggingEnabled(); - } -} diff --git a/@capacitor/android/capacitor/src/main/java/com/getcapacitor/Logger.kt b/@capacitor/android/capacitor/src/main/java/com/getcapacitor/Logger.kt new file mode 100644 index 00000000..c80cbb2c --- /dev/null +++ b/@capacitor/android/capacitor/src/main/java/com/getcapacitor/Logger.kt @@ -0,0 +1,109 @@ +package com.getcapacitor + +import android.text.TextUtils +import android.util.Log + +class Logger { + private fun loadConfig(config: CapConfig) { + Companion.config = config + } + + companion object { + const val LOG_TAG_CORE: String = "Capacitor" + var config: CapConfig? = null + + private var instance: Logger? = null + get() { + if (field == null) { + field = Logger() + } + return field + } + + fun init(config: CapConfig) { + instance!!.loadConfig(config) + } + + @JvmStatic + fun tags(vararg subtags: String?): String { + if (subtags != null && subtags.size > 0) { + return LOG_TAG_CORE + "/" + TextUtils.join("/", subtags) + } + + return LOG_TAG_CORE + } + + fun verbose(message: String?) { + verbose(LOG_TAG_CORE, message) + } + + fun verbose(tag: String?, message: String?) { + if (!shouldLog()) { + return + } + + Log.v(tag, message!!) + } + + fun debug(message: String?) { + debug(LOG_TAG_CORE, message) + } + + @JvmStatic + fun debug(tag: String?, message: String?) { + if (!shouldLog()) { + return + } + + Log.d(tag, message!!) + } + + fun info(message: String?) { + info(LOG_TAG_CORE, message) + } + + @JvmStatic + fun info(tag: String?, message: String?) { + if (!shouldLog()) { + return + } + + Log.i(tag, message!!) + } + + fun warn(message: String?) { + warn(LOG_TAG_CORE, message) + } + + fun warn(tag: String?, message: String?) { + if (!shouldLog()) { + return + } + + Log.w(tag, message!!) + } + + @JvmStatic + fun error(message: String?) { + error(LOG_TAG_CORE, message, null) + } + + @JvmStatic + fun error(message: String?, e: Throwable?) { + error(LOG_TAG_CORE, message, e) + } + + @JvmStatic + fun error(tag: String?, message: String?, e: Throwable?) { + if (!shouldLog()) { + return + } + + Log.e(tag, message, e) + } + + fun shouldLog(): Boolean { + return config == null || config!!.isLoggingEnabled + } + } +} diff --git a/@capacitor/android/capacitor/src/main/java/com/getcapacitor/MessageHandler.java b/@capacitor/android/capacitor/src/main/java/com/getcapacitor/MessageHandler.java deleted file mode 100644 index b71124e8..00000000 --- a/@capacitor/android/capacitor/src/main/java/com/getcapacitor/MessageHandler.java +++ /dev/null @@ -1,159 +0,0 @@ -package com.getcapacitor; - -import android.webkit.JavascriptInterface; -import android.webkit.WebView; -import androidx.webkit.JavaScriptReplyProxy; -import androidx.webkit.WebViewCompat; -import androidx.webkit.WebViewFeature; -import org.apache.cordova.PluginManager; - -/** - * MessageHandler handles messages from the WebView, dispatching them - * to plugins. - */ -public class MessageHandler { - - private Bridge bridge; - private WebView webView; - private PluginManager cordovaPluginManager; - private JavaScriptReplyProxy javaScriptReplyProxy; - - public MessageHandler(Bridge bridge, WebView webView, PluginManager cordovaPluginManager) { - this.bridge = bridge; - this.webView = webView; - this.cordovaPluginManager = cordovaPluginManager; - - if (WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER) && !bridge.getConfig().isUsingLegacyBridge()) { - WebViewCompat.WebMessageListener capListener = (view, message, sourceOrigin, isMainFrame, replyProxy) -> { - if (isMainFrame) { - postMessage(message.getData()); - javaScriptReplyProxy = replyProxy; - } else { - Logger.warn("Plugin execution is allowed in Main Frame only"); - } - }; - try { - WebViewCompat.addWebMessageListener(webView, "androidBridge", bridge.getAllowedOriginRules(), capListener); - } catch (Exception ex) { - webView.addJavascriptInterface(this, "androidBridge"); - } - } else { - webView.addJavascriptInterface(this, "androidBridge"); - } - } - - /** - * The main message handler that will be called from JavaScript - * to send a message to the native bridge. - * @param jsonStr - */ - @JavascriptInterface - @SuppressWarnings("unused") - public void postMessage(String jsonStr) { - try { - JSObject postData = new JSObject(jsonStr); - - String type = postData.getString("type"); - - boolean typeIsNotNull = type != null; - boolean isCordovaPlugin = typeIsNotNull && type.equals("cordova"); - boolean isJavaScriptError = typeIsNotNull && type.equals("js.error"); - - String callbackId = postData.getString("callbackId"); - - if (isCordovaPlugin) { - String service = postData.getString("service"); - String action = postData.getString("action"); - String actionArgs = postData.getString("actionArgs"); - - Logger.verbose( - Logger.tags("Plugin"), - "To native (Cordova plugin): callbackId: " + - callbackId + - ", service: " + - service + - ", action: " + - action + - ", actionArgs: " + - actionArgs - ); - - this.callCordovaPluginMethod(callbackId, service, action, actionArgs); - } else if (isJavaScriptError) { - Logger.error("JavaScript Error: " + jsonStr); - } else { - String pluginId = postData.getString("pluginId"); - String methodName = postData.getString("methodName"); - JSObject methodData = postData.getJSObject("options", new JSObject()); - - Logger.verbose( - Logger.tags("Plugin"), - "To native (Capacitor plugin): callbackId: " + callbackId + ", pluginId: " + pluginId + ", methodName: " + methodName - ); - - this.callPluginMethod(callbackId, pluginId, methodName, methodData); - } - } catch (Exception ex) { - Logger.error("Post message error:", ex); - } - } - - public void sendResponseMessage(PluginCall call, PluginResult successResult, PluginResult errorResult) { - try { - PluginResult data = new PluginResult(); - data.put("save", call.isKeptAlive()); - data.put("callbackId", call.getCallbackId()); - data.put("pluginId", call.getPluginId()); - data.put("methodName", call.getMethodName()); - - boolean pluginResultInError = errorResult != null; - if (pluginResultInError) { - data.put("success", false); - data.put("error", errorResult); - Logger.debug("Sending plugin error: " + data.toString()); - } else { - data.put("success", true); - if (successResult != null) { - data.put("data", successResult); - } - } - - boolean isValidCallbackId = !call.getCallbackId().equals(PluginCall.CALLBACK_ID_DANGLING); - if (isValidCallbackId) { - if (bridge.getConfig().isUsingLegacyBridge()) { - legacySendResponseMessage(data); - } else if (WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER) && javaScriptReplyProxy != null) { - javaScriptReplyProxy.postMessage(data.toString()); - } else { - legacySendResponseMessage(data); - } - } else { - bridge.getApp().fireRestoredResult(data); - } - } catch (Exception ex) { - Logger.error("sendResponseMessage: error: " + ex); - } - if (!call.isKeptAlive()) { - call.release(bridge); - } - } - - private void legacySendResponseMessage(PluginResult data) { - final String runScript = "window.Capacitor.fromNative(" + data.toString() + ")"; - final WebView webView = this.webView; - webView.post(() -> webView.evaluateJavascript(runScript, null)); - } - - private void callPluginMethod(String callbackId, String pluginId, String methodName, JSObject methodData) { - PluginCall call = new PluginCall(this, pluginId, callbackId, methodName, methodData); - bridge.callPluginMethod(pluginId, methodName, call); - } - - private void callCordovaPluginMethod(String callbackId, String service, String action, String actionArgs) { - bridge.execute( - () -> { - cordovaPluginManager.exec(service, action, callbackId, actionArgs); - } - ); - } -} diff --git a/@capacitor/android/capacitor/src/main/java/com/getcapacitor/MessageHandler.kt b/@capacitor/android/capacitor/src/main/java/com/getcapacitor/MessageHandler.kt new file mode 100644 index 00000000..3655b3ad --- /dev/null +++ b/@capacitor/android/capacitor/src/main/java/com/getcapacitor/MessageHandler.kt @@ -0,0 +1,176 @@ +package com.getcapacitor + +import android.net.Uri +import android.webkit.JavascriptInterface +import android.webkit.WebView +import androidx.webkit.JavaScriptReplyProxy +import androidx.webkit.WebMessageCompat +import androidx.webkit.WebViewCompat +import androidx.webkit.WebViewCompat.WebMessageListener +import androidx.webkit.WebViewFeature +import org.apache.cordova.PluginManager + +/** + * MessageHandler handles messages from the WebView, dispatching them + * to plugins. + */ +class MessageHandler( + private val bridge: Bridge, + private val webView: WebView, + private val cordovaPluginManager: PluginManager +) { + private var javaScriptReplyProxy: JavaScriptReplyProxy? = null + + init { + if (WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER) && !bridge.config.isUsingLegacyBridge) { + val capListener = + WebMessageListener { view: WebView?, message: WebMessageCompat, sourceOrigin: Uri?, isMainFrame: Boolean, replyProxy: JavaScriptReplyProxy? -> + if (isMainFrame) { + postMessage(message.data) + javaScriptReplyProxy = replyProxy + } else { + Logger.Companion.warn("Plugin execution is allowed in Main Frame only") + } + } + try { + WebViewCompat.addWebMessageListener( + webView, + "androidBridge", + bridge.getAllowedOriginRules(), + capListener + ) + } catch (ex: Exception) { + webView.addJavascriptInterface(this, "androidBridge") + } + } else { + webView.addJavascriptInterface(this, "androidBridge") + } + } + + /** + * The main message handler that will be called from JavaScript + * to send a message to the native bridge. + * @param jsonStr + */ + @JavascriptInterface + @Suppress("unused") + fun postMessage(jsonStr: String?) { + try { + val postData = JSObject(jsonStr) + + val type = postData.getString("type") + + val typeIsNotNull = type != null + val isCordovaPlugin = typeIsNotNull && type == "cordova" + val isJavaScriptError = typeIsNotNull && type == "js.error" + + val callbackId = postData.getString("callbackId") + + if (isCordovaPlugin) { + val service = postData.getString("service") + val action = postData.getString("action") + val actionArgs = postData.getString("actionArgs") + + Logger.Companion.verbose( + Logger.Companion.tags("Plugin"), + "To native (Cordova plugin): callbackId: " + + callbackId + + ", service: " + + service + + ", action: " + + action + + ", actionArgs: " + + actionArgs + ) + + this.callCordovaPluginMethod(callbackId, service, action, actionArgs) + } else if (isJavaScriptError) { + Logger.Companion.error("JavaScript Error: $jsonStr") + } else { + val pluginId = postData.getString("pluginId") + val methodName = postData.getString("methodName") + val methodData = postData.getJSObject("options", JSObject()) + + Logger.Companion.verbose( + Logger.Companion.tags("Plugin"), + "To native (Capacitor plugin): callbackId: $callbackId, pluginId: $pluginId, methodName: $methodName" + ) + + this.callPluginMethod(callbackId, pluginId, methodName, methodData) + } + } catch (ex: Exception) { + Logger.Companion.error("Post message error:", ex) + } + } + + fun sendResponseMessage( + call: PluginCall, + successResult: PluginResult?, + errorResult: PluginResult? + ) { + try { + val data = PluginResult() + data.put("save", call.isKeptAlive) + data.put("callbackId", call.callbackId) + data.put("pluginId", call.pluginId) + data.put("methodName", call.methodName) + + val pluginResultInError = errorResult != null + if (pluginResultInError) { + data.put("success", false) + data.put("error", errorResult) + Logger.Companion.debug("Sending plugin error: $data") + } else { + data.put("success", true) + if (successResult != null) { + data.put("data", successResult) + } + } + + val isValidCallbackId = call.callbackId != PluginCall.Companion.CALLBACK_ID_DANGLING + if (isValidCallbackId) { + if (bridge.config.isUsingLegacyBridge) { + legacySendResponseMessage(data) + } else if (WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER) && javaScriptReplyProxy != null) { + javaScriptReplyProxy.postMessage(data.toString()) + } else { + legacySendResponseMessage(data) + } + } else { + bridge.app.fireRestoredResult(data) + } + } catch (ex: Exception) { + Logger.Companion.error("sendResponseMessage: error: $ex") + } + if (!call.isKeptAlive) { + call.release(bridge) + } + } + + private fun legacySendResponseMessage(data: PluginResult) { + val runScript = "window.Capacitor.fromNative($data)" + val webView = this.webView + webView.post { webView.evaluateJavascript(runScript, null) } + } + + private fun callPluginMethod( + callbackId: String?, + pluginId: String?, + methodName: String?, + methodData: JSObject? + ) { + val call = PluginCall(this, pluginId, callbackId, methodName, methodData) + bridge.callPluginMethod(pluginId!!, methodName!!, call) + } + + private fun callCordovaPluginMethod( + callbackId: String?, + service: String?, + action: String?, + actionArgs: String? + ) { + bridge.execute { + cordovaPluginManager.exec(service, action, callbackId, actionArgs) + } + } +} diff --git a/@capacitor/android/capacitor/src/main/java/com/getcapacitor/NativePlugin.java b/@capacitor/android/capacitor/src/main/java/com/getcapacitor/NativePlugin.kt similarity index 53% rename from @capacitor/android/capacitor/src/main/java/com/getcapacitor/NativePlugin.java rename to @capacitor/android/capacitor/src/main/java/com/getcapacitor/NativePlugin.kt index c4307624..62c84056 100644 --- a/@capacitor/android/capacitor/src/main/java/com/getcapacitor/NativePlugin.java +++ b/@capacitor/android/capacitor/src/main/java/com/getcapacitor/NativePlugin.kt @@ -1,37 +1,28 @@ -package com.getcapacitor; - -import com.getcapacitor.annotation.CapacitorPlugin; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; +package com.getcapacitor /** * Base annotation for all Plugins - * @deprecated - *

Use {@link CapacitorPlugin} instead */ -@Retention(RetentionPolicy.RUNTIME) -@Deprecated -public @interface NativePlugin { +@Retention(AnnotationRetention.RUNTIME) +@Deprecated("

Use {@link CapacitorPlugin} instead") +annotation class NativePlugin( /** * Request codes this plugin uses and responds to, in order to tie * Android events back the plugin to handle */ - int[] requestCodes() default {}; - + val requestCodes: IntArray = [], /** * Permissions this plugin needs, in order to make permission requests * easy if the plugin only needs basic permission prompting */ - String[] permissions() default {}; - + val permissions: Array = [], /** * The request code to use when automatically requesting permissions */ - int permissionRequestCode() default 9000; - + val permissionRequestCode: Int = 9000, /** * A custom name for the plugin, otherwise uses the * simple class name. */ - String name() default ""; -} + val name: String = "" +) diff --git a/@capacitor/android/capacitor/src/main/java/com/getcapacitor/PermissionState.java b/@capacitor/android/capacitor/src/main/java/com/getcapacitor/PermissionState.java deleted file mode 100644 index 382cff71..00000000 --- a/@capacitor/android/capacitor/src/main/java/com/getcapacitor/PermissionState.java +++ /dev/null @@ -1,31 +0,0 @@ -package com.getcapacitor; - -import java.util.Locale; - -/** - * Represents the state of a permission - * - * @since 3.0.0 - */ -public enum PermissionState { - GRANTED("granted"), - DENIED("denied"), - PROMPT("prompt"), - PROMPT_WITH_RATIONALE("prompt-with-rationale"); - - private String state; - - PermissionState(String state) { - this.state = state; - } - - @Override - public String toString() { - return state; - } - - public static PermissionState byState(String state) { - state = state.toUpperCase(Locale.ROOT).replace('-', '_'); - return valueOf(state); - } -} diff --git a/@capacitor/android/capacitor/src/main/java/com/getcapacitor/PermissionState.kt b/@capacitor/android/capacitor/src/main/java/com/getcapacitor/PermissionState.kt new file mode 100644 index 00000000..5b2c7c58 --- /dev/null +++ b/@capacitor/android/capacitor/src/main/java/com/getcapacitor/PermissionState.kt @@ -0,0 +1,25 @@ +package com.getcapacitor + +/** + * Represents the state of a permission + * + * @since 3.0.0 + */ +enum class PermissionState(private val state: String) { + GRANTED("granted"), + DENIED("denied"), + PROMPT("prompt"), + PROMPT_WITH_RATIONALE("prompt-with-rationale"); + + override fun toString(): String { + return state + } + + companion object { + fun byState(state: String): PermissionState { + var state = state + state = state.uppercase().replace('-', '_') + return valueOf(state) + } + } +} diff --git a/@capacitor/android/capacitor/src/main/java/com/getcapacitor/Plugin.java b/@capacitor/android/capacitor/src/main/java/com/getcapacitor/Plugin.java deleted file mode 100644 index d8a3e82a..00000000 --- a/@capacitor/android/capacitor/src/main/java/com/getcapacitor/Plugin.java +++ /dev/null @@ -1,1046 +0,0 @@ -package com.getcapacitor; - -import android.app.Activity; -import android.content.Context; -import android.content.Intent; -import android.content.pm.PackageManager; -import android.content.res.Configuration; -import android.net.Uri; -import android.os.Bundle; -import androidx.activity.result.ActivityResult; -import androidx.activity.result.ActivityResultLauncher; -import androidx.activity.result.contract.ActivityResultContracts; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.appcompat.app.AppCompatActivity; -import androidx.core.app.ActivityCompat; -import com.getcapacitor.annotation.ActivityCallback; -import com.getcapacitor.annotation.CapacitorPlugin; -import com.getcapacitor.annotation.Permission; -import com.getcapacitor.annotation.PermissionCallback; -import com.getcapacitor.util.PermissionHelper; -import java.lang.reflect.InvocationTargetException; -import java.lang.reflect.Method; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.HashMap; -import java.util.HashSet; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.Set; -import java.util.concurrent.CopyOnWriteArrayList; -import org.json.JSONException; - -/** - * Plugin is the base class for all plugins, containing a number of - * convenient features for interacting with the {@link Bridge}, managing - * plugin permissions, tracking lifecycle events, and more. - * - * You should inherit from this class when creating new plugins, along with - * adding the {@link CapacitorPlugin} annotation to add additional required - * metadata about the Plugin - */ -public class Plugin { - - // The key we will use inside of a persisted Bundle for the JSON blob - // for a plugin call options. - private static final String BUNDLE_PERSISTED_OPTIONS_JSON_KEY = "_json"; - - // Reference to the Bridge - protected Bridge bridge; - - // Reference to the PluginHandle wrapper for this Plugin - protected PluginHandle handle; - - /** - * A way for plugins to quickly save a call that they will need to reference - * between activity/permissions starts/requests - * - * @deprecated store calls on the bridge using the methods - * {@link com.getcapacitor.Bridge#saveCall(PluginCall)}, - * {@link com.getcapacitor.Bridge#getSavedCall(String)} and - * {@link com.getcapacitor.Bridge#releaseCall(PluginCall)} - */ - @Deprecated - protected PluginCall savedLastCall; - - // Stored event listeners - private final Map> eventListeners; - - /** - * Launchers used by the plugin to handle activity results - */ - private final Map> activityLaunchers = new HashMap<>(); - - /** - * Launchers used by the plugin to handle permission results - */ - private final Map> permissionLaunchers = new HashMap<>(); - - private String lastPluginCallId; - - // Stored results of an event if an event was fired and - // no listeners were attached yet. Only stores the last value. - private final Map> retainedEventArguments; - - public Plugin() { - eventListeners = new HashMap<>(); - retainedEventArguments = new HashMap<>(); - } - - /** - * Called when the plugin has been connected to the bridge - * and is ready to start initializing. - */ - public void load() {} - - /** - * Registers activity result launchers defined on plugins, used for permission requests and - * activities started for result. - */ - void initializeActivityLaunchers() { - List pluginClassMethods = new ArrayList<>(); - for ( - Class pluginCursor = getClass(); - !pluginCursor.getName().equals(Object.class.getName()); - pluginCursor = pluginCursor.getSuperclass() - ) { - pluginClassMethods.addAll(Arrays.asList(pluginCursor.getDeclaredMethods())); - } - - for (final Method method : pluginClassMethods) { - if (method.isAnnotationPresent(ActivityCallback.class)) { - // register callbacks annotated with ActivityCallback for activity results - ActivityResultLauncher launcher = bridge.registerForActivityResult( - new ActivityResultContracts.StartActivityForResult(), - result -> triggerActivityCallback(method, result) - ); - - activityLaunchers.put(method.getName(), launcher); - } else if (method.isAnnotationPresent(PermissionCallback.class)) { - // register callbacks annotated with PermissionCallback for permission results - ActivityResultLauncher launcher = bridge.registerForActivityResult( - new ActivityResultContracts.RequestMultiplePermissions(), - permissions -> triggerPermissionCallback(method, permissions) - ); - - permissionLaunchers.put(method.getName(), launcher); - } - } - } - - private void triggerPermissionCallback(Method method, Map permissionResultMap) { - PluginCall savedCall = bridge.getPermissionCall(handle.getId()); - - // validate permissions and invoke the permission result callback - if (bridge.validatePermissions(this, savedCall, permissionResultMap)) { - try { - method.setAccessible(true); - method.invoke(this, savedCall); - } catch (IllegalAccessException | InvocationTargetException e) { - e.printStackTrace(); - } - } - } - - private void triggerActivityCallback(Method method, ActivityResult result) { - PluginCall savedCall = bridge.getSavedCall(lastPluginCallId); - if (savedCall == null) { - savedCall = bridge.getPluginCallForLastActivity(); - } - // invoke the activity result callback - try { - method.setAccessible(true); - method.invoke(this, savedCall, result); - } catch (IllegalAccessException | InvocationTargetException e) { - e.printStackTrace(); - } - } - - /** - * Start activity for result with the provided Intent and resolve with the provided callback method name. - *

- * If there is no registered activity callback for the method name passed in, the call will - * be rejected. Make sure a valid activity result callback method is registered using the - * {@link ActivityCallback} annotation. - * - * @param call the plugin call - * @param intent the intent used to start an activity - * @param callbackName the name of the callback to run when the launched activity is finished - * @since 3.0.0 - */ - public void startActivityForResult(PluginCall call, Intent intent, String callbackName) { - ActivityResultLauncher activityResultLauncher = getActivityLauncherOrReject(call, callbackName); - if (activityResultLauncher == null) { - // return when null since call was rejected in getLauncherOrReject - return; - } - bridge.setPluginCallForLastActivity(call); - lastPluginCallId = call.getCallbackId(); - bridge.saveCall(call); - activityResultLauncher.launch(intent); - } - - private void permissionActivityResult(PluginCall call, String[] permissionStrings, String callbackName) { - ActivityResultLauncher permissionResultLauncher = getPermissionLauncherOrReject(call, callbackName); - if (permissionResultLauncher == null) { - // return when null since call was rejected in getLauncherOrReject - return; - } - - bridge.savePermissionCall(call); - permissionResultLauncher.launch(permissionStrings); - } - - /** - * Get the main {@link Context} for the current Activity (your app) - * @return the Context for the current activity - */ - public Context getContext() { - return this.bridge.getContext(); - } - - /** - * Get the main {@link Activity} for the app - * @return the Activity for the current app - */ - public AppCompatActivity getActivity() { - return this.bridge.getActivity(); - } - - /** - * Set the Bridge instance for this plugin - * @param bridge - */ - public void setBridge(Bridge bridge) { - this.bridge = bridge; - } - - /** - * Get the Bridge instance for this plugin - */ - public Bridge getBridge() { - return this.bridge; - } - - /** - * Set the wrapper {@link PluginHandle} instance for this plugin that - * contains additional metadata about the Plugin instance (such - * as indexed methods for reflection, and {@link CapacitorPlugin} annotation data). - * @param pluginHandle - */ - public void setPluginHandle(PluginHandle pluginHandle) { - this.handle = pluginHandle; - } - - /** - * Return the wrapper {@link PluginHandle} for this plugin. - * - * This wrapper contains additional metadata about the plugin instance, - * such as indexed methods for reflection, and {@link CapacitorPlugin} annotation data). - * @return - */ - public PluginHandle getPluginHandle() { - return this.handle; - } - - /** - * Get the root App ID - * @return - */ - public String getAppId() { - return getContext().getPackageName(); - } - - /** - * Called to save a {@link PluginCall} in order to reference it - * later, such as in an activity or permissions result handler - * @deprecated use {@link Bridge#saveCall(PluginCall)} - * - * @param lastCall - */ - @Deprecated - public void saveCall(PluginCall lastCall) { - this.savedLastCall = lastCall; - } - - /** - * Set the last saved call to null to free memory - * @deprecated use {@link PluginCall#release(Bridge)} - */ - @Deprecated - public void freeSavedCall() { - this.savedLastCall.release(bridge); - this.savedLastCall = null; - } - - /** - * Get the last saved call, if any - * @deprecated use {@link Bridge#getSavedCall(String)} - * - * @return - */ - @Deprecated - public PluginCall getSavedCall() { - return this.savedLastCall; - } - - /** - * Get the config options for this plugin. - * - * @return a config object representing the plugin config options, or an empty config - * if none exists - */ - public PluginConfig getConfig() { - return bridge.getConfig().getPluginConfiguration(handle.getId()); - } - - /** - * Get the value for a key on the config for this plugin. - * @deprecated use {@link #getConfig()} and access config values using the methods available - * depending on the type. - * - * @param key the key for the config value - * @return some object containing the value from the config - */ - @Deprecated - public Object getConfigValue(String key) { - try { - PluginConfig pluginConfig = getConfig(); - return pluginConfig.getConfigJSON().get(key); - } catch (JSONException ex) { - return null; - } - } - - /** - * Check whether any of the given permissions has been defined in the AndroidManifest.xml - * @deprecated use {@link #isPermissionDeclared(String)} - * - * @param permissions - * @return - */ - @Deprecated - public boolean hasDefinedPermissions(String[] permissions) { - for (String permission : permissions) { - if (!PermissionHelper.hasDefinedPermission(getContext(), permission)) { - return false; - } - } - return true; - } - - /** - * Check if all annotated permissions have been defined in the AndroidManifest.xml - * @deprecated use {@link #isPermissionDeclared(String)} - * - * @return true if permissions are all defined in the Manifest - */ - @Deprecated - public boolean hasDefinedRequiredPermissions() { - CapacitorPlugin annotation = handle.getPluginAnnotation(); - if (annotation == null) { - // Check for legacy plugin annotation, @NativePlugin - NativePlugin legacyAnnotation = handle.getLegacyPluginAnnotation(); - return hasDefinedPermissions(legacyAnnotation.permissions()); - } else { - for (Permission perm : annotation.permissions()) { - for (String permString : perm.strings()) { - if (!PermissionHelper.hasDefinedPermission(getContext(), permString)) { - return false; - } - } - } - } - - return true; - } - - /** - * Checks if the given permission alias is correctly declared in AndroidManifest.xml - * @param alias a permission alias defined on the plugin - * @return true only if all permissions associated with the given alias are declared in the manifest - */ - public boolean isPermissionDeclared(String alias) { - CapacitorPlugin annotation = handle.getPluginAnnotation(); - if (annotation != null) { - for (Permission perm : annotation.permissions()) { - if (alias.equalsIgnoreCase(perm.alias())) { - boolean result = true; - for (String permString : perm.strings()) { - result = result && PermissionHelper.hasDefinedPermission(getContext(), permString); - } - - return result; - } - } - } - - Logger.error(String.format("isPermissionDeclared: No alias defined for %s " + "or missing @CapacitorPlugin annotation.", alias)); - return false; - } - - /** - * Check whether the given permission has been granted by the user - * @deprecated use {@link #getPermissionState(String)} and {@link #getPermissionStates()} to get - * the states of permissions defined on the Plugin in conjunction with the @CapacitorPlugin - * annotation. Use the Android API {@link ActivityCompat#checkSelfPermission(Context, String)} - * methods to check permissions with Android permission strings - * - * @param permission - * @return - */ - @Deprecated - public boolean hasPermission(String permission) { - return ActivityCompat.checkSelfPermission(this.getContext(), permission) == PackageManager.PERMISSION_GRANTED; - } - - /** - * If the plugin annotation specified a set of permissions, this method checks if each is - * granted - * @deprecated use {@link #getPermissionState(String)} or {@link #getPermissionStates()} to - * check whether permissions are granted or not - * - * @return - */ - @Deprecated - public boolean hasRequiredPermissions() { - CapacitorPlugin annotation = handle.getPluginAnnotation(); - if (annotation == null) { - // Check for legacy plugin annotation, @NativePlugin - NativePlugin legacyAnnotation = handle.getLegacyPluginAnnotation(); - for (String perm : legacyAnnotation.permissions()) { - if (ActivityCompat.checkSelfPermission(this.getContext(), perm) != PackageManager.PERMISSION_GRANTED) { - return false; - } - } - - return true; - } - - for (Permission perm : annotation.permissions()) { - for (String permString : perm.strings()) { - if (ActivityCompat.checkSelfPermission(this.getContext(), permString) != PackageManager.PERMISSION_GRANTED) { - return false; - } - } - } - - return true; - } - - /** - * Request all of the specified permissions in the CapacitorPlugin annotation (if any) - * - * If there is no registered permission callback for the PluginCall passed in, the call will - * be rejected. Make sure a valid permission callback method is registered using the - * {@link PermissionCallback} annotation. - * - * @since 3.0.0 - * @param call the plugin call - * @param callbackName the name of the callback to run when the permission request is complete - */ - protected void requestAllPermissions(@NonNull PluginCall call, @NonNull String callbackName) { - CapacitorPlugin annotation = handle.getPluginAnnotation(); - if (annotation != null) { - HashSet perms = new HashSet<>(); - for (Permission perm : annotation.permissions()) { - perms.addAll(Arrays.asList(perm.strings())); - } - - permissionActivityResult(call, perms.toArray(new String[0]), callbackName); - } - } - - /** - * Request permissions using an alias defined on the plugin. - * - * If there is no registered permission callback for the PluginCall passed in, the call will - * be rejected. Make sure a valid permission callback method is registered using the - * {@link PermissionCallback} annotation. - * - * @param alias an alias defined on the plugin - * @param call the plugin call involved in originating the request - * @param callbackName the name of the callback to run when the permission request is complete - */ - protected void requestPermissionForAlias(@NonNull String alias, @NonNull PluginCall call, @NonNull String callbackName) { - requestPermissionForAliases(new String[] { alias }, call, callbackName); - } - - /** - * Request permissions using aliases defined on the plugin. - * - * If there is no registered permission callback for the PluginCall passed in, the call will - * be rejected. Make sure a valid permission callback method is registered using the - * {@link PermissionCallback} annotation. - * - * @param aliases a set of aliases defined on the plugin - * @param call the plugin call involved in originating the request - * @param callbackName the name of the callback to run when the permission request is complete - */ - protected void requestPermissionForAliases(@NonNull String[] aliases, @NonNull PluginCall call, @NonNull String callbackName) { - if (aliases.length == 0) { - Logger.error("No permission alias was provided"); - return; - } - - String[] permissions = getPermissionStringsForAliases(aliases); - - if (permissions.length > 0) { - permissionActivityResult(call, permissions, callbackName); - } - } - - /** - * Gets the Android permission strings defined on the {@link CapacitorPlugin} annotation with - * the provided aliases. - * - * @param aliases aliases for permissions defined on the plugin - * @return Android permission strings associated with the provided aliases, if exists - */ - private String[] getPermissionStringsForAliases(@NonNull String[] aliases) { - CapacitorPlugin annotation = handle.getPluginAnnotation(); - HashSet perms = new HashSet<>(); - for (Permission perm : annotation.permissions()) { - if (Arrays.asList(aliases).contains(perm.alias())) { - perms.addAll(Arrays.asList(perm.strings())); - } - } - - return perms.toArray(new String[0]); - } - - /** - * Gets the activity launcher associated with the calling methodName, or rejects the call if - * no registered launcher exists - * - * @param call the plugin call - * @param methodName the name of the activity callback method - * @return a launcher, or null if none found - */ - private @Nullable ActivityResultLauncher getActivityLauncherOrReject(PluginCall call, String methodName) { - ActivityResultLauncher activityLauncher = activityLaunchers.get(methodName); - - // if there is no registered launcher, reject the call with an error and return null - if (activityLauncher == null) { - String registerError = - "There is no ActivityCallback method registered for the name: %s. " + - "Please define a callback method annotated with @ActivityCallback " + - "that receives arguments: (PluginCall, ActivityResult)"; - registerError = String.format(Locale.US, registerError, methodName); - Logger.error(registerError); - call.reject(registerError); - return null; - } - - return activityLauncher; - } - - /** - * Gets the permission launcher associated with the calling methodName, or rejects the call if - * no registered launcher exists - * - * @param call the plugin call - * @param methodName the name of the permission callback method - * @return a launcher, or null if none found - */ - private @Nullable ActivityResultLauncher getPermissionLauncherOrReject(PluginCall call, String methodName) { - ActivityResultLauncher permissionLauncher = permissionLaunchers.get(methodName); - - // if there is no registered launcher, reject the call with an error and return null - if (permissionLauncher == null) { - String registerError = - "There is no PermissionCallback method registered for the name: %s. " + - "Please define a callback method annotated with @PermissionCallback " + - "that receives arguments: (PluginCall)"; - registerError = String.format(Locale.US, registerError, methodName); - Logger.error(registerError); - call.reject(registerError); - return null; - } - - return permissionLauncher; - } - - /** - * Request all of the specified permissions in the CapacitorPlugin annotation (if any) - * - * @deprecated use {@link #requestAllPermissions(PluginCall, String)} in conjunction with @CapacitorPlugin - */ - @Deprecated - public void pluginRequestAllPermissions() { - NativePlugin legacyAnnotation = handle.getLegacyPluginAnnotation(); - ActivityCompat.requestPermissions(getActivity(), legacyAnnotation.permissions(), legacyAnnotation.permissionRequestCode()); - } - - /** - * Helper for requesting a specific permission - * - * @param permission the permission to request - * @param requestCode the requestCode to use to associate the result with the plugin - * @deprecated use {@link #requestPermissionForAlias(String, PluginCall, String)} in conjunction with @CapacitorPlugin - */ - @Deprecated - public void pluginRequestPermission(String permission, int requestCode) { - ActivityCompat.requestPermissions(getActivity(), new String[] { permission }, requestCode); - } - - /** - * Helper for requesting specific permissions - * @deprecated use {@link #requestPermissionForAliases(String[], PluginCall, String)} in conjunction - * with @CapacitorPlugin - * - * @param permissions the set of permissions to request - * @param requestCode the requestCode to use to associate the result with the plugin - */ - @Deprecated - public void pluginRequestPermissions(String[] permissions, int requestCode) { - ActivityCompat.requestPermissions(getActivity(), permissions, requestCode); - } - - /** - * Get the permission state for the provided permission alias. - * - * @param alias the permission alias to get - * @return the state of the provided permission alias or null - */ - public PermissionState getPermissionState(String alias) { - return getPermissionStates().get(alias); - } - - /** - * Helper to check all permissions defined on a plugin and see the state of each. - * - * @since 3.0.0 - * @return A mapping of permission aliases to the associated granted status. - */ - public Map getPermissionStates() { - return bridge.getPermissionStates(this); - } - - /** - * Add a listener for the given event - * @param eventName - * @param call - */ - private void addEventListener(String eventName, PluginCall call) { - List listeners = eventListeners.get(eventName); - if (listeners == null || listeners.isEmpty()) { - listeners = new ArrayList<>(); - eventListeners.put(eventName, listeners); - - // Must add the call before sending retained arguments - listeners.add(call); - - sendRetainedArgumentsForEvent(eventName); - } else { - listeners.add(call); - } - } - - /** - * Remove a listener from the given event - * @param eventName - * @param call - */ - private void removeEventListener(String eventName, PluginCall call) { - List listeners = eventListeners.get(eventName); - if (listeners == null) { - return; - } - - listeners.remove(call); - } - - /** - * Notify all listeners that an event occurred - * @param eventName - * @param data - */ - protected void notifyListeners(String eventName, JSObject data, boolean retainUntilConsumed) { - Logger.verbose(getLogTag(), "Notifying listeners for event " + eventName); - List listeners = eventListeners.get(eventName); - if (listeners == null || listeners.isEmpty()) { - Logger.debug(getLogTag(), "No listeners found for event " + eventName); - if (retainUntilConsumed) { - List argList = retainedEventArguments.get(eventName); - - if (argList == null) { - argList = new ArrayList(); - } - - argList.add(data); - retainedEventArguments.put(eventName, argList); - } - return; - } - - CopyOnWriteArrayList listenersCopy = new CopyOnWriteArrayList(listeners); - for (PluginCall call : listenersCopy) { - call.resolve(data); - } - } - - /** - * Notify all listeners that an event occurred - * This calls {@link Plugin#notifyListeners(String, JSObject, boolean)} - * with retainUntilConsumed set to false - * @param eventName - * @param data - */ - protected void notifyListeners(String eventName, JSObject data) { - notifyListeners(eventName, data, false); - } - - /** - * Check if there are any listeners for the given event - */ - protected boolean hasListeners(String eventName) { - List listeners = eventListeners.get(eventName); - if (listeners == null) { - return false; - } - return !listeners.isEmpty(); - } - - /** - * Send retained arguments (if any) for this event. This - * is called only when the first listener for an event is added - * @param eventName - */ - private void sendRetainedArgumentsForEvent(String eventName) { - // copy retained args and null source to prevent potential race conditions - List retainedArgs = retainedEventArguments.get(eventName); - if (retainedArgs == null) { - return; - } - - retainedEventArguments.remove(eventName); - - for (JSObject retained : retainedArgs) { - notifyListeners(eventName, retained); - } - } - - /** - * Exported plugin call for adding a listener to this plugin - * @param call - */ - @SuppressWarnings("unused") - @PluginMethod(returnType = PluginMethod.RETURN_NONE) - public void addListener(PluginCall call) { - String eventName = call.getString("eventName"); - call.setKeepAlive(true); - addEventListener(eventName, call); - } - - /** - * Exported plugin call to remove a listener from this plugin - * @param call - */ - @SuppressWarnings("unused") - @PluginMethod(returnType = PluginMethod.RETURN_NONE) - public void removeListener(PluginCall call) { - String eventName = call.getString("eventName"); - String callbackId = call.getString("callbackId"); - PluginCall savedCall = bridge.getSavedCall(callbackId); - if (savedCall != null) { - removeEventListener(eventName, savedCall); - bridge.releaseCall(savedCall); - } - } - - /** - * Exported plugin call to remove all listeners from this plugin - * @param call - */ - @SuppressWarnings("unused") - @PluginMethod(returnType = PluginMethod.RETURN_PROMISE) - public void removeAllListeners(PluginCall call) { - eventListeners.clear(); - call.resolve(); - } - - /** - * Exported plugin call for checking the granted status for each permission - * declared on the plugin. This plugin call responds with a mapping of permissions to - * the associated granted status. - * - * @since 3.0.0 - */ - @PluginMethod - @PermissionCallback - public void checkPermissions(PluginCall pluginCall) { - Map permissionsResult = getPermissionStates(); - - if (permissionsResult.size() == 0) { - // if no permissions are defined on the plugin, resolve undefined - pluginCall.resolve(); - } else { - JSObject permissionsResultJSON = new JSObject(); - for (Map.Entry entry : permissionsResult.entrySet()) { - permissionsResultJSON.put(entry.getKey(), entry.getValue()); - } - - pluginCall.resolve(permissionsResultJSON); - } - } - - /** - * Exported plugin call to request all permissions for this plugin. - * To manually request permissions within a plugin use: - * {@link #requestAllPermissions(PluginCall, String)}, or - * {@link #requestPermissionForAlias(String, PluginCall, String)}, or - * {@link #requestPermissionForAliases(String[], PluginCall, String)} - * - * @param call the plugin call - */ - @PluginMethod - public void requestPermissions(PluginCall call) { - CapacitorPlugin annotation = handle.getPluginAnnotation(); - if (annotation == null) { - handleLegacyPermission(call); - } else { - // handle permission requests for plugins defined with @CapacitorPlugin (since 3.0.0) - String[] permAliases = null; - Set autoGrantPerms = new HashSet<>(); - - // If call was made with a list of specific permission aliases to request, save them - // to be requested - JSArray providedPerms = call.getArray("permissions"); - List providedPermsList = null; - - if (providedPerms != null) { - try { - providedPermsList = providedPerms.toList(); - } catch (JSONException ignore) { - // do nothing - } - } - - // If call was made without any custom permissions, request all from plugin annotation - Set aliasSet = new HashSet<>(); - if (providedPermsList == null || providedPermsList.isEmpty()) { - for (Permission perm : annotation.permissions()) { - // If a permission is defined with no permission strings, separate it for auto-granting. - // Otherwise, the alias is added to the list to be requested. - if (perm.strings().length == 0 || (perm.strings().length == 1 && perm.strings()[0].isEmpty())) { - if (!perm.alias().isEmpty()) { - autoGrantPerms.add(perm.alias()); - } - } else { - aliasSet.add(perm.alias()); - } - } - - permAliases = aliasSet.toArray(new String[0]); - } else { - for (Permission perm : annotation.permissions()) { - if (providedPermsList.contains(perm.alias())) { - aliasSet.add(perm.alias()); - } - } - - if (aliasSet.isEmpty()) { - call.reject("No valid permission alias was requested of this plugin."); - } else { - permAliases = aliasSet.toArray(new String[0]); - } - } - - if (permAliases != null && permAliases.length > 0) { - // request permissions using provided aliases or all defined on the plugin - requestPermissionForAliases(permAliases, call, "checkPermissions"); - } else if (!autoGrantPerms.isEmpty()) { - // if the plugin only has auto-grant permissions, return all as GRANTED - JSObject permissionsResults = new JSObject(); - - for (String perm : autoGrantPerms) { - permissionsResults.put(perm, PermissionState.GRANTED.toString()); - } - - call.resolve(permissionsResults); - } else { - // no permissions are defined on the plugin, resolve undefined - call.resolve(); - } - } - } - - @SuppressWarnings("deprecation") - private void handleLegacyPermission(PluginCall call) { - // handle permission requests for plugins defined with @NativePlugin (prior to 3.0.0) - NativePlugin legacyAnnotation = this.handle.getLegacyPluginAnnotation(); - String[] perms = legacyAnnotation.permissions(); - if (perms.length > 0) { - saveCall(call); - pluginRequestPermissions(perms, legacyAnnotation.permissionRequestCode()); - } else { - call.resolve(); - } - } - - /** - * Handle request permissions result. A plugin using the deprecated {@link NativePlugin} - * should override this to handle the result, or this method will handle the result - * for our convenient requestPermissions call. - * @deprecated in favor of using callbacks in conjunction with {@link CapacitorPlugin} - * - * @param requestCode - * @param permissions - * @param grantResults - */ - @Deprecated - protected void handleRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) { - if (!hasDefinedPermissions(permissions)) { - StringBuilder builder = new StringBuilder(); - builder.append("Missing the following permissions in AndroidManifest.xml:\n"); - String[] missing = PermissionHelper.getUndefinedPermissions(getContext(), permissions); - for (String perm : missing) { - builder.append(perm + "\n"); - } - savedLastCall.reject(builder.toString()); - savedLastCall = null; - } - } - - /** - * Called before the app is destroyed to give a plugin the chance to - * save the last call options for a saved plugin. By default, this - * method saves the full JSON blob of the options call. Since Bundle sizes - * may be limited, plugins that expect to be called with large data - * objects (such as a file), should override this method and selectively - * store option values in a {@link Bundle} to avoid exceeding limits. - * @return a new {@link Bundle} with fields set from the options of the last saved {@link PluginCall} - */ - protected Bundle saveInstanceState() { - PluginCall savedCall = bridge.getSavedCall(lastPluginCallId); - - if (savedCall == null) { - return null; - } - - Bundle ret = new Bundle(); - JSObject callData = savedCall.getData(); - - if (callData != null) { - ret.putString(BUNDLE_PERSISTED_OPTIONS_JSON_KEY, callData.toString()); - } - - return ret; - } - - /** - * Called when the app is opened with a previously un-handled - * activity response. If the plugin that started the activity - * stored data in {@link Plugin#saveInstanceState()} then this - * method will be called to allow the plugin to restore from that. - * @param state - */ - protected void restoreState(Bundle state) {} - - /** - * Handle activity result, should be overridden by each plugin - * - * @deprecated provide a callback method using the {@link ActivityCallback} annotation and use - * the {@link #startActivityForResult(PluginCall, Intent, String)} method - * - * @param requestCode - * @param resultCode - * @param data - */ - @Deprecated - protected void handleOnActivityResult(int requestCode, int resultCode, Intent data) {} - - /** - * Handle onNewIntent - * @param intent - */ - protected void handleOnNewIntent(Intent intent) {} - - /** - * Handle onConfigurationChanged - * @param newConfig - */ - protected void handleOnConfigurationChanged(Configuration newConfig) {} - - /** - * Handle onStart - */ - protected void handleOnStart() {} - - /** - * Handle onRestart - */ - protected void handleOnRestart() {} - - /** - * Handle onResume - */ - protected void handleOnResume() {} - - /** - * Handle onPause - */ - protected void handleOnPause() {} - - /** - * Handle onStop - */ - protected void handleOnStop() {} - - /** - * Handle onDestroy - */ - protected void handleOnDestroy() {} - - /** - * Give the plugins a chance to take control when a URL is about to be loaded in the WebView. - * Returning true causes the WebView to abort loading the URL. - * Returning false causes the WebView to continue loading the URL. - * Returning null will defer to the default Capacitor policy - */ - @SuppressWarnings("unused") - public Boolean shouldOverrideLoad(Uri url) { - return null; - } - - /** - * Start a new Activity. - * - * Note: This method must be used by all plugins instead of calling - * {@link Activity#startActivityForResult} as it associates the plugin with - * any resulting data from the new Activity even if this app - * is destroyed by the OS (to free up memory, for example). - * @param intent - * @param resultCode - */ - @Deprecated - protected void startActivityForResult(PluginCall call, Intent intent, int resultCode) { - bridge.startActivityForPluginWithResult(call, intent, resultCode); - } - - /** - * Execute the given runnable on the Bridge's task handler - * @param runnable - */ - public void execute(Runnable runnable) { - bridge.execute(runnable); - } - - /** - * Shortcut for getting the plugin log tag - * @param subTags - */ - protected String getLogTag(String... subTags) { - return Logger.tags(subTags); - } - - /** - * Gets a plugin log tag with the child's class name as subTag. - */ - protected String getLogTag() { - return Logger.tags(this.getClass().getSimpleName()); - } -} diff --git a/@capacitor/android/capacitor/src/main/java/com/getcapacitor/Plugin.kt b/@capacitor/android/capacitor/src/main/java/com/getcapacitor/Plugin.kt new file mode 100644 index 00000000..6addf332 --- /dev/null +++ b/@capacitor/android/capacitor/src/main/java/com/getcapacitor/Plugin.kt @@ -0,0 +1,1077 @@ +package com.getcapacitor + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.content.res.Configuration +import android.net.Uri +import android.os.Bundle +import androidx.activity.result.ActivityResult +import androidx.activity.result.ActivityResultLauncher +import androidx.appcompat.app.AppCompatActivity +import androidx.core.app.ActivityCompat +import com.getcapacitor.annotation.ActivityCallback +import com.getcapacitor.annotation.CapacitorPlugin +import com.getcapacitor.annotation.PermissionCallback +import com.getcapacitor.util.PermissionHelper +import org.json.JSONException +import java.lang.reflect.InvocationTargetException +import java.lang.reflect.Method +import java.util.Arrays +import java.util.Locale +import java.util.concurrent.CopyOnWriteArrayList + +/** + * Plugin is the base class for all plugins, containing a number of + * convenient features for interacting with the [Bridge], managing + * plugin permissions, tracking lifecycle events, and more. + * + * You should inherit from this class when creating new plugins, along with + * adding the [CapacitorPlugin] annotation to add additional required + * metadata about the Plugin + */ +open class Plugin { + /** + * Get the Bridge instance for this plugin + */ + /** + * Set the Bridge instance for this plugin + * @param bridge + */ + // Reference to the Bridge + @JvmField + var bridge: Bridge? = null + + /** + * Return the wrapper [PluginHandle] for this plugin. + * + * This wrapper contains additional metadata about the plugin instance, + * such as indexed methods for reflection, and [CapacitorPlugin] annotation data). + * @return + */ + /** + * Set the wrapper [PluginHandle] instance for this plugin that + * contains additional metadata about the Plugin instance (such + * as indexed methods for reflection, and [CapacitorPlugin] annotation data). + * @param pluginHandle + */ + // Reference to the PluginHandle wrapper for this Plugin + var pluginHandle: PluginHandle? = null + + /** + * Get the last saved call, if any + * @return + */ + /** + * A way for plugins to quickly save a call that they will need to reference + * between activity/permissions starts/requests + * + */ + @get:Deprecated( + """use {@link Bridge#getSavedCall(String)} + + """ + ) + @Deprecated( + """store calls on the bridge using the methods + {@link com.getcapacitor.Bridge#saveCall(PluginCall)}, + {@link com.getcapacitor.Bridge#getSavedCall(String)} and + {@link com.getcapacitor.Bridge#releaseCall(PluginCall)}""" + ) + var savedCall: PluginCall? = null + /** + * Get the last saved call, if any + * @return + */ + @Deprecated( + """use {@link Bridge#getSavedCall(String)} + + """ + ) get + protected set + + // Stored event listeners + private val eventListeners: MutableMap> = + HashMap() + + /** + * Launchers used by the plugin to handle activity results + */ + private val activityLaunchers: MutableMap> = HashMap() + + /** + * Launchers used by the plugin to handle permission results + */ + private val permissionLaunchers: MutableMap>> = + HashMap() + + private var lastPluginCallId: String? = null + + // Stored results of an event if an event was fired and + // no listeners were attached yet. Only stores the last value. + private val retainedEventArguments: MutableMap> = + HashMap() + + /** + * Called when the plugin has been connected to the bridge + * and is ready to start initializing. + */ + open fun load() {} + + /** + * Registers activity result launchers defined on plugins, used for permission requests and + * activities started for result. + */ + fun initializeActivityLaunchers() { + val pluginClassMethods: MutableList = ArrayList() + var pluginCursor: Class<*> = javaClass + while (pluginCursor.name != Any::class.java.name + ) { + pluginClassMethods.addAll(Arrays.asList(*pluginCursor.declaredMethods)) + pluginCursor = pluginCursor.superclass + } + + for (method in pluginClassMethods) { + if (method.isAnnotationPresent(ActivityCallback::class.java)) { + // register callbacks annotated with ActivityCallback for activity results + val launcher = bridge!!.registerForActivityResult( + StartActivityForResult() + ) { result: ActivityResult -> triggerActivityCallback(method, result) } + + activityLaunchers[method.name] = launcher + } else if (method.isAnnotationPresent(PermissionCallback::class.java)) { + // register callbacks annotated with PermissionCallback for permission results + val launcher = + bridge!!.registerForActivityResult, Map>( + RequestMultiplePermissions() + ) { permissions: Map -> + triggerPermissionCallback( + method, + permissions + ) + } + + permissionLaunchers[method.name] = launcher + } + } + } + + private fun triggerPermissionCallback( + method: Method, + permissionResultMap: Map + ) { + val savedCall = bridge!!.getPermissionCall(pluginHandle.getId()) + + // validate permissions and invoke the permission result callback + if (bridge!!.validatePermissions(this, savedCall!!, permissionResultMap)) { + try { + method.isAccessible = true + method.invoke(this, savedCall) + } catch (e: IllegalAccessException) { + e.printStackTrace() + } catch (e: InvocationTargetException) { + e.printStackTrace() + } + } + } + + private fun triggerActivityCallback(method: Method, result: ActivityResult) { + var savedCall = bridge!!.getSavedCall(lastPluginCallId) + if (savedCall == null) { + savedCall = bridge!!.getPluginCallForLastActivity() + } + // invoke the activity result callback + try { + method.isAccessible = true + method.invoke(this, savedCall, result) + } catch (e: IllegalAccessException) { + e.printStackTrace() + } catch (e: InvocationTargetException) { + e.printStackTrace() + } + } + + /** + * Start activity for result with the provided Intent and resolve with the provided callback method name. + * + * + * If there is no registered activity callback for the method name passed in, the call will + * be rejected. Make sure a valid activity result callback method is registered using the + * [ActivityCallback] annotation. + * + * @param call the plugin call + * @param intent the intent used to start an activity + * @param callbackName the name of the callback to run when the launched activity is finished + * @since 3.0.0 + */ + fun startActivityForResult(call: PluginCall, intent: Intent, callbackName: String) { + val activityResultLauncher = getActivityLauncherOrReject(call, callbackName) + ?: // return when null since call was rejected in getLauncherOrReject + return + bridge!!.setPluginCallForLastActivity(call) + lastPluginCallId = call.callbackId + bridge!!.saveCall(call) + activityResultLauncher.launch(intent) + } + + private fun permissionActivityResult( + call: PluginCall, + permissionStrings: Array, + callbackName: String + ) { + val permissionResultLauncher = getPermissionLauncherOrReject(call, callbackName) + ?: // return when null since call was rejected in getLauncherOrReject + return + + bridge!!.savePermissionCall(call) + permissionResultLauncher.launch(permissionStrings) + } + + val context: Context? + /** + * Get the main [Context] for the current Activity (your app) + * @return the Context for the current activity + */ + get() = bridge!!.getContext() + + val activity: AppCompatActivity? + /** + * Get the main [Activity] for the app + * @return the Activity for the current app + */ + get() = bridge!!.activity + + val appId: String + /** + * Get the root App ID + * @return + */ + get() = context!!.packageName + + /** + * Called to save a [PluginCall] in order to reference it + * later, such as in an activity or permissions result handler + * @param lastCall + */ + @Deprecated( + """use {@link Bridge#saveCall(PluginCall)} + + """ + ) + fun saveCall(lastCall: PluginCall?) { + this.savedCall = lastCall + } + + /** + * Set the last saved call to null to free memory + */ + @Deprecated("use {@link PluginCall#release(Bridge)}") + fun freeSavedCall() { + savedCall!!.release(bridge) + this.savedCall = null + } + + val config: PluginConfig? + /** + * Get the config options for this plugin. + * + * @return a config object representing the plugin config options, or an empty config + * if none exists + */ + get() = bridge!!.config.getPluginConfiguration(pluginHandle.getId()) + + /** + * Get the value for a key on the config for this plugin. + * @param key the key for the config value + * @return some object containing the value from the config + */ + @Deprecated( + """use {@link #getConfig()} and access config values using the methods available + depending on the type. + + """ + ) + fun getConfigValue(key: String?): Any? { + try { + val pluginConfig = config + return pluginConfig.getConfigJSON()[key] + } catch (ex: JSONException) { + return null + } + } + + /** + * Check whether any of the given permissions has been defined in the AndroidManifest.xml + * @param permissions + * @return + */ + @Deprecated( + """use {@link #isPermissionDeclared(String)} + + """ + ) + fun hasDefinedPermissions(permissions: Array): Boolean { + for (permission in permissions) { + if (!PermissionHelper.hasDefinedPermission(context, permission)) { + return false + } + } + return true + } + + /** + * Check if all annotated permissions have been defined in the AndroidManifest.xml + * @return true if permissions are all defined in the Manifest + */ + @Deprecated( + """use {@link #isPermissionDeclared(String)} + + """ + ) + fun hasDefinedRequiredPermissions(): Boolean { + val annotation = pluginHandle.getPluginAnnotation() + if (annotation == null) { + // Check for legacy plugin annotation, @NativePlugin + val legacyAnnotation = pluginHandle.getLegacyPluginAnnotation() + return hasDefinedPermissions(legacyAnnotation!!.permissions) + } else { + for (perm in annotation.permissions) { + for (permString in perm.strings) { + if (!PermissionHelper.hasDefinedPermission(context, permString)) { + return false + } + } + } + } + + return true + } + + /** + * Checks if the given permission alias is correctly declared in AndroidManifest.xml + * @param alias a permission alias defined on the plugin + * @return true only if all permissions associated with the given alias are declared in the manifest + */ + fun isPermissionDeclared(alias: String): Boolean { + val annotation = pluginHandle.getPluginAnnotation() + if (annotation != null) { + for (perm in annotation.permissions) { + if (alias.equals(perm.alias, ignoreCase = true)) { + var result = true + for (permString in perm.strings) { + result = result && PermissionHelper.hasDefinedPermission( + context, permString + ) + } + + return result + } + } + } + + Logger.Companion.error( + String.format( + "isPermissionDeclared: No alias defined for %s " + "or missing @CapacitorPlugin annotation.", + alias + ) + ) + return false + } + + /** + * Check whether the given permission has been granted by the user + * @param permission + * @return + */ + @Deprecated( + """use {@link #getPermissionState(String)} and {@link #getPermissionStates()} to get + the states of permissions defined on the Plugin in conjunction with the @CapacitorPlugin + annotation. Use the Android API {@link ActivityCompat#checkSelfPermission(Context, String)} + methods to check permissions with Android permission strings + + """ + ) + fun hasPermission(permission: String?): Boolean { + return ActivityCompat.checkSelfPermission( + context!!, + permission!! + ) == PackageManager.PERMISSION_GRANTED + } + + /** + * If the plugin annotation specified a set of permissions, this method checks if each is + * granted + * @return + */ + @Deprecated( + """use {@link #getPermissionState(String)} or {@link #getPermissionStates()} to + check whether permissions are granted or not + + """ + ) + fun hasRequiredPermissions(): Boolean { + val annotation = pluginHandle.getPluginAnnotation() + if (annotation == null) { + // Check for legacy plugin annotation, @NativePlugin + val legacyAnnotation = pluginHandle.getLegacyPluginAnnotation() + for (perm in legacyAnnotation!!.permissions) { + if (ActivityCompat.checkSelfPermission( + context!!, + perm!! + ) != PackageManager.PERMISSION_GRANTED + ) { + return false + } + } + + return true + } + + for (perm in annotation.permissions) { + for (permString in perm.strings) { + if (ActivityCompat.checkSelfPermission( + context!!, + permString!! + ) != PackageManager.PERMISSION_GRANTED + ) { + return false + } + } + } + + return true + } + + /** + * Request all of the specified permissions in the CapacitorPlugin annotation (if any) + * + * If there is no registered permission callback for the PluginCall passed in, the call will + * be rejected. Make sure a valid permission callback method is registered using the + * [PermissionCallback] annotation. + * + * @since 3.0.0 + * @param call the plugin call + * @param callbackName the name of the callback to run when the permission request is complete + */ + protected fun requestAllPermissions(call: PluginCall, callbackName: String) { + val annotation = pluginHandle.getPluginAnnotation() + if (annotation != null) { + val perms = HashSet() + for (perm in annotation.permissions) { + perms.addAll(Arrays.asList(*perm.strings)) + } + + permissionActivityResult(call, perms.toTypedArray(), callbackName) + } + } + + /** + * Request permissions using an alias defined on the plugin. + * + * If there is no registered permission callback for the PluginCall passed in, the call will + * be rejected. Make sure a valid permission callback method is registered using the + * [PermissionCallback] annotation. + * + * @param alias an alias defined on the plugin + * @param call the plugin call involved in originating the request + * @param callbackName the name of the callback to run when the permission request is complete + */ + protected fun requestPermissionForAlias(alias: String, call: PluginCall, callbackName: String) { + requestPermissionForAliases(arrayOf(alias), call, callbackName) + } + + /** + * Request permissions using aliases defined on the plugin. + * + * If there is no registered permission callback for the PluginCall passed in, the call will + * be rejected. Make sure a valid permission callback method is registered using the + * [PermissionCallback] annotation. + * + * @param aliases a set of aliases defined on the plugin + * @param call the plugin call involved in originating the request + * @param callbackName the name of the callback to run when the permission request is complete + */ + protected fun requestPermissionForAliases( + aliases: Array, + call: PluginCall, + callbackName: String + ) { + if (aliases.size == 0) { + Logger.Companion.error("No permission alias was provided") + return + } + + val permissions = getPermissionStringsForAliases(aliases) + + if (permissions.size > 0) { + permissionActivityResult(call, permissions, callbackName) + } + } + + /** + * Gets the Android permission strings defined on the [CapacitorPlugin] annotation with + * the provided aliases. + * + * @param aliases aliases for permissions defined on the plugin + * @return Android permission strings associated with the provided aliases, if exists + */ + private fun getPermissionStringsForAliases(aliases: Array): Array { + val annotation = pluginHandle.getPluginAnnotation() + val perms = HashSet() + for (perm in annotation!!.permissions) { + if (Arrays.asList(*aliases).contains(perm.alias)) { + perms.addAll(Arrays.asList(*perm.strings)) + } + } + + return perms.toTypedArray() + } + + /** + * Gets the activity launcher associated with the calling methodName, or rejects the call if + * no registered launcher exists + * + * @param call the plugin call + * @param methodName the name of the activity callback method + * @return a launcher, or null if none found + */ + private fun getActivityLauncherOrReject( + call: PluginCall, + methodName: String + ): ActivityResultLauncher? { + val activityLauncher = activityLaunchers[methodName] + + // if there is no registered launcher, reject the call with an error and return null + if (activityLauncher == null) { + var registerError = + "There is no ActivityCallback method registered for the name: %s. " + + "Please define a callback method annotated with @ActivityCallback " + + "that receives arguments: (PluginCall, ActivityResult)" + registerError = String.format(Locale.US, registerError, methodName) + Logger.Companion.error(registerError) + call.reject(registerError) + return null + } + + return activityLauncher + } + + /** + * Gets the permission launcher associated with the calling methodName, or rejects the call if + * no registered launcher exists + * + * @param call the plugin call + * @param methodName the name of the permission callback method + * @return a launcher, or null if none found + */ + private fun getPermissionLauncherOrReject( + call: PluginCall, + methodName: String + ): ActivityResultLauncher>? { + val permissionLauncher = permissionLaunchers[methodName] + + // if there is no registered launcher, reject the call with an error and return null + if (permissionLauncher == null) { + var registerError = + "There is no PermissionCallback method registered for the name: %s. " + + "Please define a callback method annotated with @PermissionCallback " + + "that receives arguments: (PluginCall)" + registerError = String.format(Locale.US, registerError, methodName) + Logger.Companion.error(registerError) + call.reject(registerError) + return null + } + + return permissionLauncher + } + + /** + * Request all of the specified permissions in the CapacitorPlugin annotation (if any) + * + */ + @Deprecated("use {@link #requestAllPermissions(PluginCall, String)} in conjunction with @CapacitorPlugin") + fun pluginRequestAllPermissions() { + val legacyAnnotation = pluginHandle.getLegacyPluginAnnotation() + ActivityCompat.requestPermissions( + activity!!, + legacyAnnotation!!.permissions, + legacyAnnotation!!.permissionRequestCode + ) + } + + /** + * Helper for requesting a specific permission + * + * @param permission the permission to request + * @param requestCode the requestCode to use to associate the result with the plugin + */ + @Deprecated("use {@link #requestPermissionForAlias(String, PluginCall, String)} in conjunction with @CapacitorPlugin") + fun pluginRequestPermission(permission: String, requestCode: Int) { + ActivityCompat.requestPermissions(activity!!, arrayOf(permission), requestCode) + } + + /** + * Helper for requesting specific permissions + * @param permissions the set of permissions to request + * @param requestCode the requestCode to use to associate the result with the plugin + */ + @Deprecated( + """use {@link #requestPermissionForAliases(String[], PluginCall, String)} in conjunction + with @CapacitorPlugin + + """ + ) + fun pluginRequestPermissions(permissions: Array?, requestCode: Int) { + ActivityCompat.requestPermissions(activity!!, permissions!!, requestCode) + } + + /** + * Get the permission state for the provided permission alias. + * + * @param alias the permission alias to get + * @return the state of the provided permission alias or null + */ + fun getPermissionState(alias: String): PermissionState? { + return permissionStates[alias] + } + + val permissionStates: Map + /** + * Helper to check all permissions defined on a plugin and see the state of each. + * + * @since 3.0.0 + * @return A mapping of permission aliases to the associated granted status. + */ + get() = bridge!!.getPermissionStates(this) + + /** + * Add a listener for the given event + * @param eventName + * @param call + */ + private fun addEventListener(eventName: String?, call: PluginCall) { + var listeners = eventListeners[eventName] + if (listeners == null || listeners.isEmpty()) { + listeners = ArrayList() + eventListeners[eventName] = listeners + + // Must add the call before sending retained arguments + listeners.add(call) + + sendRetainedArgumentsForEvent(eventName) + } else { + listeners.add(call) + } + } + + /** + * Remove a listener from the given event + * @param eventName + * @param call + */ + private fun removeEventListener(eventName: String?, call: PluginCall) { + val listeners = eventListeners[eventName] ?: return + + listeners.remove(call) + } + + /** + * Notify all listeners that an event occurred + * @param eventName + * @param data + */ + /** + * Notify all listeners that an event occurred + * This calls [Plugin.notifyListeners] + * with retainUntilConsumed set to false + * @param eventName + * @param data + */ + protected fun notifyListeners( + eventName: String?, + data: JSObject, + retainUntilConsumed: Boolean = false + ) { + Logger.Companion.verbose(logTag, "Notifying listeners for event $eventName") + val listeners: List? = eventListeners[eventName] + if (listeners == null || listeners.isEmpty()) { + Logger.Companion.debug(logTag, "No listeners found for event $eventName") + if (retainUntilConsumed) { + var argList = retainedEventArguments[eventName] + + if (argList == null) { + argList = ArrayList() + } + + argList.add(data) + retainedEventArguments[eventName] = argList + } + return + } + + val listenersCopy: CopyOnWriteArrayList = CopyOnWriteArrayList(listeners) + for (call in listenersCopy) { + call!!.resolve(data) + } + } + + /** + * Check if there are any listeners for the given event + */ + protected fun hasListeners(eventName: String?): Boolean { + val listeners = eventListeners[eventName] + ?: return false + return !listeners.isEmpty() + } + + /** + * Send retained arguments (if any) for this event. This + * is called only when the first listener for an event is added + * @param eventName + */ + private fun sendRetainedArgumentsForEvent(eventName: String?) { + // copy retained args and null source to prevent potential race conditions + val retainedArgs = retainedEventArguments[eventName] + ?: return + + retainedEventArguments.remove(eventName) + + for (retained in retainedArgs) { + notifyListeners(eventName, retained) + } + } + + /** + * Exported plugin call for adding a listener to this plugin + * @param call + */ + @Suppress("unused") + @PluginMethod(returnType = PluginMethod.Companion.RETURN_NONE) + fun addListener(call: PluginCall) { + val eventName = call.getString("eventName") + call.setKeepAlive(true) + addEventListener(eventName, call) + } + + /** + * Exported plugin call to remove a listener from this plugin + * @param call + */ + @Suppress("unused") + @PluginMethod(returnType = PluginMethod.Companion.RETURN_NONE) + fun removeListener(call: PluginCall) { + val eventName = call.getString("eventName") + val callbackId = call.getString("callbackId") + val savedCall = bridge!!.getSavedCall(callbackId) + if (savedCall != null) { + removeEventListener(eventName, savedCall) + bridge!!.releaseCall(savedCall) + } + } + + /** + * Exported plugin call to remove all listeners from this plugin + * @param call + */ + @Suppress("unused") + @PluginMethod(returnType = PluginMethod.Companion.RETURN_PROMISE) + fun removeAllListeners(call: PluginCall) { + eventListeners.clear() + call.resolve() + } + + /** + * Exported plugin call for checking the granted status for each permission + * declared on the plugin. This plugin call responds with a mapping of permissions to + * the associated granted status. + * + * @since 3.0.0 + */ + @PluginMethod + @PermissionCallback + open fun checkPermissions(pluginCall: PluginCall) { + val permissionsResult = permissionStates + + if (permissionsResult.size == 0) { + // if no permissions are defined on the plugin, resolve undefined + pluginCall.resolve() + } else { + val permissionsResultJSON = JSObject() + for ((key, value) in permissionsResult) { + permissionsResultJSON.put(key, value) + } + + pluginCall.resolve(permissionsResultJSON) + } + } + + /** + * Exported plugin call to request all permissions for this plugin. + * To manually request permissions within a plugin use: + * [.requestAllPermissions], or + * [.requestPermissionForAlias], or + * [.requestPermissionForAliases] + * + * @param call the plugin call + */ + @PluginMethod + open fun requestPermissions(call: PluginCall) { + val annotation = pluginHandle.getPluginAnnotation() + if (annotation == null) { + handleLegacyPermission(call) + } else { + // handle permission requests for plugins defined with @CapacitorPlugin (since 3.0.0) + var permAliases: Array? = null + val autoGrantPerms: MutableSet = HashSet() + + // If call was made with a list of specific permission aliases to request, save them + // to be requested + val providedPerms = call.getArray("permissions") + var providedPermsList: List? = null + + if (providedPerms != null) { + try { + providedPermsList = providedPerms.toList() + } catch (ignore: JSONException) { + // do nothing + } + } + + // If call was made without any custom permissions, request all from plugin annotation + val aliasSet: MutableSet = HashSet() + if (providedPermsList == null || providedPermsList.isEmpty()) { + for (perm in annotation.permissions) { + // If a permission is defined with no permission strings, separate it for auto-granting. + // Otherwise, the alias is added to the list to be requested. + if (perm.strings.size == 0 || (perm.strings.size == 1 && perm.strings[0].isEmpty())) { + if (!perm.alias.isEmpty()) { + autoGrantPerms.add(perm.alias) + } + } else { + aliasSet.add(perm.alias) + } + } + + permAliases = aliasSet.toTypedArray() + } else { + for (perm in annotation.permissions) { + if (providedPermsList.contains(perm.alias)) { + aliasSet.add(perm.alias) + } + } + + if (aliasSet.isEmpty()) { + call.reject("No valid permission alias was requested of this plugin.") + } else { + permAliases = aliasSet.toTypedArray() + } + } + + if (permAliases != null && permAliases.size > 0) { + // request permissions using provided aliases or all defined on the plugin + requestPermissionForAliases(permAliases, call, "checkPermissions") + } else if (!autoGrantPerms.isEmpty()) { + // if the plugin only has auto-grant permissions, return all as GRANTED + val permissionsResults = JSObject() + + for (perm in autoGrantPerms) { + permissionsResults.put(perm, PermissionState.GRANTED.toString()) + } + + call.resolve(permissionsResults) + } else { + // no permissions are defined on the plugin, resolve undefined + call.resolve() + } + } + } + + @Suppress("deprecation") + private fun handleLegacyPermission(call: PluginCall) { + // handle permission requests for plugins defined with @NativePlugin (prior to 3.0.0) + val legacyAnnotation = pluginHandle.getLegacyPluginAnnotation() + val perms = legacyAnnotation!!.permissions + if (perms.size > 0) { + saveCall(call) + pluginRequestPermissions(perms, legacyAnnotation!!.permissionRequestCode) + } else { + call.resolve() + } + } + + /** + * Handle request permissions result. A plugin using the deprecated [NativePlugin] + * should override this to handle the result, or this method will handle the result + * for our convenient requestPermissions call. + * @param requestCode + * @param permissions + * @param grantResults + */ + @Deprecated( + """in favor of using callbacks in conjunction with {@link CapacitorPlugin} + + """ + ) + protected fun handleRequestPermissionsResult( + requestCode: Int, + permissions: Array, + grantResults: IntArray? + ) { + if (!hasDefinedPermissions(permissions)) { + val builder = StringBuilder() + builder.append("Missing the following permissions in AndroidManifest.xml:\n") + val missing = PermissionHelper.getUndefinedPermissions( + context, permissions + ) + for (perm in missing) { + builder.append(perm + "\n") + } + savedCall!!.reject(builder.toString()) + savedCall = null + } + } + + /** + * Called before the app is destroyed to give a plugin the chance to + * save the last call options for a saved plugin. By default, this + * method saves the full JSON blob of the options call. Since Bundle sizes + * may be limited, plugins that expect to be called with large data + * objects (such as a file), should override this method and selectively + * store option values in a [Bundle] to avoid exceeding limits. + * @return a new [Bundle] with fields set from the options of the last saved [PluginCall] + */ + protected fun saveInstanceState(): Bundle? { + val savedCall = bridge!!.getSavedCall(lastPluginCallId) ?: return null + + val ret = Bundle() + val callData = savedCall.data + + if (callData != null) { + ret.putString(BUNDLE_PERSISTED_OPTIONS_JSON_KEY, callData.toString()) + } + + return ret + } + + /** + * Called when the app is opened with a previously un-handled + * activity response. If the plugin that started the activity + * stored data in [Plugin.saveInstanceState] then this + * method will be called to allow the plugin to restore from that. + * @param state + */ + protected fun restoreState(state: Bundle?) {} + + /** + * Handle activity result, should be overridden by each plugin + * + * @param requestCode + * @param resultCode + * @param data + */ + @Deprecated( + """provide a callback method using the {@link ActivityCallback} annotation and use + the {@link #startActivityForResult(PluginCall, Intent, String)} method + + """ + ) + protected fun handleOnActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + } + + /** + * Handle onNewIntent + * @param intent + */ + protected open fun handleOnNewIntent(intent: Intent?) {} + + /** + * Handle onConfigurationChanged + * @param newConfig + */ + protected fun handleOnConfigurationChanged(newConfig: Configuration?) {} + + /** + * Handle onStart + */ + protected fun handleOnStart() {} + + /** + * Handle onRestart + */ + protected fun handleOnRestart() {} + + /** + * Handle onResume + */ + protected open fun handleOnResume() {} + + /** + * Handle onPause + */ + protected open fun handleOnPause() {} + + /** + * Handle onStop + */ + protected open fun handleOnStop() {} + + /** + * Handle onDestroy + */ + protected open fun handleOnDestroy() {} + + /** + * Give the plugins a chance to take control when a URL is about to be loaded in the WebView. + * Returning true causes the WebView to abort loading the URL. + * Returning false causes the WebView to continue loading the URL. + * Returning null will defer to the default Capacitor policy + */ + @Suppress("unused") + fun shouldOverrideLoad(url: Uri?): Boolean? { + return null + } + + /** + * Start a new Activity. + * + * Note: This method must be used by all plugins instead of calling + * [Activity.startActivityForResult] as it associates the plugin with + * any resulting data from the new Activity even if this app + * is destroyed by the OS (to free up memory, for example). + * @param intent + * @param resultCode + */ + @Deprecated("") + protected fun startActivityForResult(call: PluginCall?, intent: Intent?, resultCode: Int) { + bridge!!.startActivityForPluginWithResult(call, intent, resultCode) + } + + /** + * Execute the given runnable on the Bridge's task handler + * @param runnable + */ + fun execute(runnable: Runnable?) { + bridge!!.execute(runnable) + } + + /** + * Shortcut for getting the plugin log tag + * @param subTags + */ + protected fun getLogTag(vararg subTags: String?): String { + return Logger.Companion.tags(*subTags) + } + + protected val logTag: String + /** + * Gets a plugin log tag with the child's class name as subTag. + */ + get() = Logger.Companion.tags(this.javaClass.simpleName) + + companion object { + // The key we will use inside of a persisted Bundle for the JSON blob + // for a plugin call options. + private const val BUNDLE_PERSISTED_OPTIONS_JSON_KEY = "_json" + } +} diff --git a/@capacitor/android/capacitor/src/main/java/com/getcapacitor/PluginCall.java b/@capacitor/android/capacitor/src/main/java/com/getcapacitor/PluginCall.java deleted file mode 100644 index 18661d76..00000000 --- a/@capacitor/android/capacitor/src/main/java/com/getcapacitor/PluginCall.java +++ /dev/null @@ -1,440 +0,0 @@ -package com.getcapacitor; - -import androidx.annotation.Nullable; -import java.util.ArrayList; -import java.util.List; -import org.json.JSONArray; -import org.json.JSONException; -import org.json.JSONObject; - -/** - * Wraps a call from the web layer to native - */ -public class PluginCall { - - /** - * A special callback id that indicates there is no matching callback - * on the client to associate any PluginCall results back to. This is used - * in the case of an app resuming with saved instance data, for example. - */ - public static final String CALLBACK_ID_DANGLING = "-1"; - - private final MessageHandler msgHandler; - private final String pluginId; - private final String callbackId; - private final String methodName; - private final JSObject data; - - private boolean keepAlive = false; - - /** - * Indicates that this PluginCall was released, and should no longer be used - */ - @Deprecated - private boolean isReleased = false; - - public PluginCall(MessageHandler msgHandler, String pluginId, String callbackId, String methodName, JSObject data) { - this.msgHandler = msgHandler; - this.pluginId = pluginId; - this.callbackId = callbackId; - this.methodName = methodName; - this.data = data; - } - - public void successCallback(PluginResult successResult) { - if (CALLBACK_ID_DANGLING.equals(this.callbackId)) { - // don't send back response if the callbackId was "-1" - return; - } - - this.msgHandler.sendResponseMessage(this, successResult, null); - } - - /** - * @deprecated - * Use {@link #resolve(JSObject data)} - */ - @Deprecated - public void success(JSObject data) { - PluginResult result = new PluginResult(data); - this.msgHandler.sendResponseMessage(this, result, null); - } - - /** - * @deprecated - * Use {@link #resolve()} - */ - @Deprecated - public void success() { - this.resolve(new JSObject()); - } - - public void resolve(JSObject data) { - PluginResult result = new PluginResult(data); - this.msgHandler.sendResponseMessage(this, result, null); - } - - public void resolve() { - this.msgHandler.sendResponseMessage(this, null, null); - } - - public void errorCallback(String msg) { - PluginResult errorResult = new PluginResult(); - - try { - errorResult.put("message", msg); - } catch (Exception jsonEx) { - Logger.error(Logger.tags("Plugin"), jsonEx.toString(), null); - } - - this.msgHandler.sendResponseMessage(this, null, errorResult); - } - - /** - * @deprecated - * Use {@link #reject(String msg, Exception ex)} - */ - @Deprecated - public void error(String msg, Exception ex) { - reject(msg, ex); - } - - /** - * @deprecated - * Use {@link #reject(String msg, String code, Exception ex)} - */ - @Deprecated - public void error(String msg, String code, Exception ex) { - reject(msg, code, ex); - } - - /** - * @deprecated - * Use {@link #reject(String msg)} - */ - @Deprecated - public void error(String msg) { - reject(msg); - } - - public void reject(String msg, String code, Exception ex, JSObject data) { - PluginResult errorResult = new PluginResult(); - - if (ex != null) { - Logger.error(Logger.tags("Plugin"), msg, ex); - } - - try { - errorResult.put("message", msg); - errorResult.put("code", code); - if (null != data) { - errorResult.put("data", data); - } - } catch (Exception jsonEx) { - Logger.error(Logger.tags("Plugin"), jsonEx.getMessage(), jsonEx); - } - - this.msgHandler.sendResponseMessage(this, null, errorResult); - } - - public void reject(String msg, Exception ex, JSObject data) { - reject(msg, null, ex, data); - } - - public void reject(String msg, String code, JSObject data) { - reject(msg, code, null, data); - } - - public void reject(String msg, String code, Exception ex) { - reject(msg, code, ex, null); - } - - public void reject(String msg, JSObject data) { - reject(msg, null, null, data); - } - - public void reject(String msg, Exception ex) { - reject(msg, null, ex, null); - } - - public void reject(String msg, String code) { - reject(msg, code, null, null); - } - - public void reject(String msg) { - reject(msg, null, null, null); - } - - public void unimplemented() { - unimplemented("not implemented"); - } - - public void unimplemented(String msg) { - reject(msg, "UNIMPLEMENTED", null, null); - } - - public void unavailable() { - unavailable("not available"); - } - - public void unavailable(String msg) { - reject(msg, "UNAVAILABLE", null, null); - } - - public String getPluginId() { - return this.pluginId; - } - - public String getCallbackId() { - return this.callbackId; - } - - public String getMethodName() { - return this.methodName; - } - - public JSObject getData() { - return this.data; - } - - @Nullable - public String getString(String name) { - return this.getString(name, null); - } - - @Nullable - public String getString(String name, @Nullable String defaultValue) { - Object value = this.data.opt(name); - if (value == null) { - return defaultValue; - } - - if (value instanceof String) { - return (String) value; - } - return defaultValue; - } - - @Nullable - public Integer getInt(String name) { - return this.getInt(name, null); - } - - @Nullable - public Integer getInt(String name, @Nullable Integer defaultValue) { - Object value = this.data.opt(name); - if (value == null) { - return defaultValue; - } - - if (value instanceof Integer) { - return (Integer) value; - } - return defaultValue; - } - - @Nullable - public Long getLong(String name) { - return this.getLong(name, null); - } - - @Nullable - public Long getLong(String name, @Nullable Long defaultValue) { - Object value = this.data.opt(name); - if (value == null) { - return defaultValue; - } - - if (value instanceof Long) { - return (Long) value; - } - return defaultValue; - } - - @Nullable - public Float getFloat(String name) { - return this.getFloat(name, null); - } - - @Nullable - public Float getFloat(String name, @Nullable Float defaultValue) { - Object value = this.data.opt(name); - if (value == null) { - return defaultValue; - } - - if (value instanceof Float) { - return (Float) value; - } - if (value instanceof Double) { - return ((Double) value).floatValue(); - } - if (value instanceof Integer) { - return ((Integer) value).floatValue(); - } - return defaultValue; - } - - @Nullable - public Double getDouble(String name) { - return this.getDouble(name, null); - } - - @Nullable - public Double getDouble(String name, @Nullable Double defaultValue) { - Object value = this.data.opt(name); - if (value == null) { - return defaultValue; - } - - if (value instanceof Double) { - return (Double) value; - } - if (value instanceof Float) { - return ((Float) value).doubleValue(); - } - if (value instanceof Integer) { - return ((Integer) value).doubleValue(); - } - return defaultValue; - } - - @Nullable - public Boolean getBoolean(String name) { - return this.getBoolean(name, null); - } - - @Nullable - public Boolean getBoolean(String name, @Nullable Boolean defaultValue) { - Object value = this.data.opt(name); - if (value == null) { - return defaultValue; - } - - if (value instanceof Boolean) { - return (Boolean) value; - } - return defaultValue; - } - - public JSObject getObject(String name) { - return this.getObject(name, null); - } - - @Nullable - public JSObject getObject(String name, JSObject defaultValue) { - Object value = this.data.opt(name); - if (value == null) { - return defaultValue; - } - - if (value instanceof JSONObject) { - try { - return JSObject.fromJSONObject((JSONObject) value); - } catch (JSONException ex) { - return defaultValue; - } - } - return defaultValue; - } - - public JSArray getArray(String name) { - return this.getArray(name, null); - } - - /** - * Get a JSONArray and turn it into a JSArray - * @param name - * @param defaultValue - * @return - */ - @Nullable - public JSArray getArray(String name, JSArray defaultValue) { - Object value = this.data.opt(name); - if (value == null) { - return defaultValue; - } - - if (value instanceof JSONArray) { - try { - JSONArray valueArray = (JSONArray) value; - List items = new ArrayList<>(); - for (int i = 0; i < valueArray.length(); i++) { - items.add(valueArray.get(i)); - } - return new JSArray(items.toArray()); - } catch (JSONException ex) { - return defaultValue; - } - } - return defaultValue; - } - - /** - * @param name of the option to check - * @return boolean indicating if the plugin call has an option for the provided name. - * @deprecated Presence of a key should not be considered significant. - * Use typed accessors to check the value instead. - */ - @Deprecated - public boolean hasOption(String name) { - return this.data.has(name); - } - - /** - * Indicate that the Bridge should cache this call in order to call - * it again later. For example, the addListener system uses this to - * continuously call the call's callback (😆). - * @deprecated use {@link #setKeepAlive(Boolean)} instead - */ - @Deprecated - public void save() { - setKeepAlive(true); - } - - /** - * Indicate that the Bridge should cache this call in order to call - * it again later. For example, the addListener system uses this to - * continuously call the call's callback. - * - * @param keepAlive whether to keep the callback saved - */ - public void setKeepAlive(Boolean keepAlive) { - this.keepAlive = keepAlive; - } - - public void release(Bridge bridge) { - this.keepAlive = false; - bridge.releaseCall(this); - this.isReleased = true; - } - - /** - * @deprecated use {@link #isKeptAlive()} - * @return true if the plugin call is kept alive - */ - @Deprecated - public boolean isSaved() { - return isKeptAlive(); - } - - /** - * Gets the keepAlive value of the plugin call - * @return true if the plugin call is kept alive - */ - public boolean isKeptAlive() { - return keepAlive; - } - - @Deprecated - public boolean isReleased() { - return isReleased; - } - - class PluginCallDataTypeException extends Exception { - - PluginCallDataTypeException(String m) { - super(m); - } - } -} diff --git a/@capacitor/android/capacitor/src/main/java/com/getcapacitor/PluginCall.kt b/@capacitor/android/capacitor/src/main/java/com/getcapacitor/PluginCall.kt new file mode 100644 index 00000000..6e63b5b6 --- /dev/null +++ b/@capacitor/android/capacitor/src/main/java/com/getcapacitor/PluginCall.kt @@ -0,0 +1,333 @@ +package com.getcapacitor + +import org.json.JSONArray +import org.json.JSONException +import org.json.JSONObject + +/** + * Wraps a call from the web layer to native + */ +class PluginCall( + private val msgHandler: MessageHandler, + val pluginId: String?, + val callbackId: String?, + @JvmField val methodName: String?, + @JvmField val data: JSObject? +) { + /** + * Gets the keepAlive value of the plugin call + * @return true if the plugin call is kept alive + */ + var isKeptAlive: Boolean = false + private set + + /** + * Indicates that this PluginCall was released, and should no longer be used + */ + @get:Deprecated("") + @Deprecated("") + var isReleased: Boolean = false + private set + + fun successCallback(successResult: PluginResult?) { + if (CALLBACK_ID_DANGLING == this.callbackId) { + // don't send back response if the callbackId was "-1" + return + } + + msgHandler.sendResponseMessage(this, successResult, null) + } + + + @Deprecated(" Use {@link #resolve(JSObject data)}") + fun success(data: JSObject) { + val result = PluginResult(data) + msgHandler.sendResponseMessage(this, result, null) + } + + + @Deprecated(" Use {@link #resolve()}") + fun success() { + this.resolve(JSObject()) + } + + fun resolve(data: JSObject) { + val result = PluginResult(data) + msgHandler.sendResponseMessage(this, result, null) + } + + fun resolve() { + msgHandler.sendResponseMessage(this, null, null) + } + + fun errorCallback(msg: String?) { + val errorResult = PluginResult() + + try { + errorResult.put("message", msg) + } catch (jsonEx: Exception) { + Logger.Companion.error(Logger.Companion.tags("Plugin"), jsonEx.toString(), null) + } + + msgHandler.sendResponseMessage(this, null, errorResult) + } + + + @Deprecated(" Use {@link #reject(String msg, Exception ex)}") + fun error(msg: String?, ex: Exception?) { + reject(msg, ex) + } + + + @Deprecated(" Use {@link #reject(String msg, String code, Exception ex)}") + fun error(msg: String?, code: String?, ex: Exception?) { + reject(msg, code, ex) + } + + + @Deprecated(" Use {@link #reject(String msg)}") + fun error(msg: String?) { + reject(msg) + } + + @JvmOverloads + fun reject(msg: String?, code: String? = null, ex: Exception? = null, data: JSObject? = null) { + val errorResult = PluginResult() + + if (ex != null) { + Logger.Companion.error(Logger.Companion.tags("Plugin"), msg, ex) + } + + try { + errorResult.put("message", msg) + errorResult.put("code", code) + if (null != data) { + errorResult.put("data", data) + } + } catch (jsonEx: Exception) { + Logger.Companion.error(Logger.Companion.tags("Plugin"), jsonEx.message, jsonEx) + } + + msgHandler.sendResponseMessage(this, null, errorResult) + } + + fun reject(msg: String?, ex: Exception?, data: JSObject?) { + reject(msg, null, ex, data) + } + + fun reject(msg: String?, code: String?, data: JSObject?) { + reject(msg, code, null, data) + } + + fun reject(msg: String?, data: JSObject?) { + reject(msg, null, null, data) + } + + fun reject(msg: String?, ex: Exception?) { + reject(msg, null, ex, null) + } + + @JvmOverloads + fun unimplemented(msg: String? = "not implemented") { + reject(msg, "UNIMPLEMENTED", null, null) + } + + @JvmOverloads + fun unavailable(msg: String? = "not available") { + reject(msg, "UNAVAILABLE", null, null) + } + + fun getString(name: String?): String? { + return this.getString(name, null) + } + + fun getString(name: String?, defaultValue: String?): String? { + val value = data!!.opt(name) ?: return defaultValue + + if (value is String) { + return value + } + return defaultValue + } + + fun getInt(name: String?): Int? { + return this.getInt(name, null) + } + + fun getInt(name: String?, defaultValue: Int?): Int? { + val value = data!!.opt(name) ?: return defaultValue + + if (value is Int) { + return value + } + return defaultValue + } + + fun getLong(name: String?): Long? { + return this.getLong(name, null) + } + + fun getLong(name: String?, defaultValue: Long?): Long? { + val value = data!!.opt(name) ?: return defaultValue + + if (value is Long) { + return value + } + return defaultValue + } + + fun getFloat(name: String?): Float? { + return this.getFloat(name, null) + } + + fun getFloat(name: String?, defaultValue: Float?): Float? { + val value = data!!.opt(name) ?: return defaultValue + + if (value is Float) { + return value + } + if (value is Double) { + return value.toFloat() + } + if (value is Int) { + return value.toFloat() + } + return defaultValue + } + + fun getDouble(name: String?): Double? { + return this.getDouble(name, null) + } + + fun getDouble(name: String?, defaultValue: Double?): Double? { + val value = data!!.opt(name) ?: return defaultValue + + if (value is Double) { + return value + } + if (value is Float) { + return value.toDouble() + } + if (value is Int) { + return value.toDouble() + } + return defaultValue + } + + fun getBoolean(name: String?): Boolean? { + return this.getBoolean(name, null) + } + + fun getBoolean(name: String?, defaultValue: Boolean?): Boolean? { + val value = data!!.opt(name) ?: return defaultValue + + if (value is Boolean) { + return value + } + return defaultValue + } + + fun getObject(name: String?): JSObject? { + return this.getObject(name, null) + } + + fun getObject(name: String?, defaultValue: JSObject?): JSObject? { + val value = data!!.opt(name) ?: return defaultValue + + if (value is JSONObject) { + return try { + JSObject.Companion.fromJSONObject(value) + } catch (ex: JSONException) { + defaultValue + } + } + return defaultValue + } + + fun getArray(name: String?): JSArray? { + return this.getArray(name, null) + } + + /** + * Get a JSONArray and turn it into a JSArray + * @param name + * @param defaultValue + * @return + */ + fun getArray(name: String?, defaultValue: JSArray?): JSArray? { + val value = data!!.opt(name) ?: return defaultValue + + if (value is JSONArray) { + try { + val valueArray = value + val items: MutableList = ArrayList() + for (i in 0 until valueArray.length()) { + items.add(valueArray[i]) + } + return JSArray(items.toTypedArray()) + } catch (ex: JSONException) { + return defaultValue + } + } + return defaultValue + } + + /** + * @param name of the option to check + * @return boolean indicating if the plugin call has an option for the provided name. + */ + @Deprecated( + """Presence of a key should not be considered significant. + Use typed accessors to check the value instead.""" + ) + fun hasOption(name: String?): Boolean { + return data!!.has(name) + } + + /** + * Indicate that the Bridge should cache this call in order to call + * it again later. For example, the addListener system uses this to + * continuously call the call's callback (😆). + */ + @Deprecated("use {@link #setKeepAlive(Boolean)} instead") + fun save() { + setKeepAlive(true) + } + + /** + * Indicate that the Bridge should cache this call in order to call + * it again later. For example, the addListener system uses this to + * continuously call the call's callback. + * + * @param keepAlive whether to keep the callback saved + */ + fun setKeepAlive(keepAlive: Boolean) { + this.isKeptAlive = keepAlive + } + + fun release(bridge: Bridge?) { + this.isKeptAlive = false + bridge!!.releaseCall(this) + this.isReleased = true + } + + @get:Deprecated( + """use {@link #isKeptAlive()} + """ + ) + val isSaved: Boolean + /** + * @return true if the plugin call is kept alive + */ + get() = isKeptAlive + + internal inner class PluginCallDataTypeException(m: String?) : Exception(m) + companion object { + /** + * A special callback id that indicates there is no matching callback + * on the client to associate any PluginCall results back to. This is used + * in the case of an app resuming with saved instance data, for example. + */ + const val CALLBACK_ID_DANGLING: String = "-1" + } +} diff --git a/@capacitor/android/capacitor/src/main/java/com/getcapacitor/PluginConfig.java b/@capacitor/android/capacitor/src/main/java/com/getcapacitor/PluginConfig.kt similarity index 60% rename from @capacitor/android/capacitor/src/main/java/com/getcapacitor/PluginConfig.java rename to @capacitor/android/capacitor/src/main/java/com/getcapacitor/PluginConfig.kt index 0f00fc53..56625a1b 100644 --- a/@capacitor/android/capacitor/src/main/java/com/getcapacitor/PluginConfig.java +++ b/@capacitor/android/capacitor/src/main/java/com/getcapacitor/PluginConfig.kt @@ -1,26 +1,28 @@ -package com.getcapacitor; +package com.getcapacitor -import com.getcapacitor.util.JSONUtils; -import org.json.JSONObject; +import com.getcapacitor.Bridge.config +import com.getcapacitor.util.JSONUtils +import org.json.JSONObject /** * Represents the configuration options for plugins used by Capacitor */ -public class PluginConfig { - +class PluginConfig +/** + * Constructs a PluginsConfig with the provided JSONObject value. + * + * @param config A plugin configuration expressed as a JSON Object + */ internal constructor( /** * The object containing plugin config values. */ - private final JSONObject config; - + val configJSON: JSONObject? +) { /** - * Constructs a PluginsConfig with the provided JSONObject value. + * Gets the JSON Object containing the config of the the provided plugin ID. * - * @param config A plugin configuration expressed as a JSON Object + * @return The config for that plugin */ - PluginConfig(JSONObject config) { - this.config = config; - } /** * Get a string value for a plugin in the Capacitor config. @@ -28,8 +30,8 @@ public class PluginConfig { * @param configKey The key of the value to retrieve * @return The value from the config, if exists. Null if not */ - public String getString(String configKey) { - return getString(configKey, null); + fun getString(configKey: String?): String { + return getString(configKey, null) } /** @@ -39,8 +41,8 @@ public String getString(String configKey) { * @param defaultValue A default value to return if the key does not exist in the config * @return The value from the config, if key exists. Default value returned if not */ - public String getString(String configKey, String defaultValue) { - return JSONUtils.getString(config, configKey, defaultValue); + fun getString(configKey: String?, defaultValue: String?): String { + return JSONUtils.getString(configJSON, configKey, defaultValue) } /** @@ -50,8 +52,8 @@ public String getString(String configKey, String defaultValue) { * @param defaultValue A default value to return if the key does not exist in the config * @return The value from the config, if key exists. Default value returned if not */ - public boolean getBoolean(String configKey, boolean defaultValue) { - return JSONUtils.getBoolean(config, configKey, defaultValue); + fun getBoolean(configKey: String?, defaultValue: Boolean): Boolean { + return JSONUtils.getBoolean(configJSON, configKey, defaultValue) } /** @@ -61,8 +63,8 @@ public boolean getBoolean(String configKey, boolean defaultValue) { * @param defaultValue A default value to return if the key does not exist in the config * @return The value from the config, if key exists. Default value returned if not */ - public int getInt(String configKey, int defaultValue) { - return JSONUtils.getInt(config, configKey, defaultValue); + fun getInt(configKey: String?, defaultValue: Int): Int { + return JSONUtils.getInt(configJSON, configKey, defaultValue) } /** @@ -71,8 +73,8 @@ public int getInt(String configKey, int defaultValue) { * @param configKey The key of the value to retrieve * @return The value from the config, if exists. Null if not */ - public String[] getArray(String configKey) { - return getArray(configKey, null); + fun getArray(configKey: String?): Array { + return getArray(configKey, null) } /** @@ -82,8 +84,8 @@ public String[] getArray(String configKey) { * @param defaultValue A default value to return if the key does not exist in the config * @return The value from the config, if key exists. Default value returned if not */ - public String[] getArray(String configKey, String[] defaultValue) { - return JSONUtils.getArray(config, configKey, defaultValue); + fun getArray(configKey: String?, defaultValue: Array?): Array { + return JSONUtils.getArray(configJSON, configKey, defaultValue) } /** @@ -92,25 +94,15 @@ public String[] getArray(String configKey, String[] defaultValue) { * @param configKey The key of the value to retrieve * @return The value from the config, if exists. Null if not */ - public JSONObject getObject(String configKey) { - return JSONUtils.getObject(config, configKey); + fun getObject(configKey: String?): JSONObject { + return JSONUtils.getObject(configJSON, configKey) } - /** - * Check if the PluginConfig is empty. - * - * @return true if the plugin config has no entries - */ - public boolean isEmpty() { - return config.length() == 0; - } - - /** - * Gets the JSON Object containing the config of the the provided plugin ID. - * - * @return The config for that plugin - */ - public JSONObject getConfigJSON() { - return config; - } + val isEmpty: Boolean + /** + * Check if the PluginConfig is empty. + * + * @return true if the plugin config has no entries + */ + get() = configJSON!!.length() == 0 } diff --git a/@capacitor/android/capacitor/src/main/java/com/getcapacitor/PluginHandle.java b/@capacitor/android/capacitor/src/main/java/com/getcapacitor/PluginHandle.java deleted file mode 100644 index 2e520b3a..00000000 --- a/@capacitor/android/capacitor/src/main/java/com/getcapacitor/PluginHandle.java +++ /dev/null @@ -1,160 +0,0 @@ -package com.getcapacitor; - -import com.getcapacitor.annotation.CapacitorPlugin; -import java.lang.reflect.InvocationTargetException; -import java.lang.reflect.Method; -import java.util.Collection; -import java.util.HashMap; -import java.util.Map; - -/** - * PluginHandle is an instance of a plugin that has been registered - * and indexed. Think of it as a Plugin instance with extra metadata goodies - */ -public class PluginHandle { - - private final Bridge bridge; - private final Class pluginClass; - - private final Map pluginMethods = new HashMap<>(); - - private final String pluginId; - - @SuppressWarnings("deprecation") - private NativePlugin legacyPluginAnnotation; - - private CapacitorPlugin pluginAnnotation; - - private Plugin instance; - - @SuppressWarnings("deprecation") - private PluginHandle(Class clazz, Bridge bridge) throws InvalidPluginException { - this.bridge = bridge; - this.pluginClass = clazz; - - CapacitorPlugin pluginAnnotation = pluginClass.getAnnotation(CapacitorPlugin.class); - if (pluginAnnotation == null) { - // Check for legacy plugin annotation, @NativePlugin - NativePlugin legacyPluginAnnotation = pluginClass.getAnnotation(NativePlugin.class); - if (legacyPluginAnnotation == null) { - throw new InvalidPluginException("No @CapacitorPlugin annotation found for plugin " + pluginClass.getName()); - } - - if (!legacyPluginAnnotation.name().equals("")) { - this.pluginId = legacyPluginAnnotation.name(); - } else { - this.pluginId = pluginClass.getSimpleName(); - } - - this.legacyPluginAnnotation = legacyPluginAnnotation; - } else { - if (!pluginAnnotation.name().equals("")) { - this.pluginId = pluginAnnotation.name(); - } else { - this.pluginId = pluginClass.getSimpleName(); - } - - this.pluginAnnotation = pluginAnnotation; - } - - this.indexMethods(clazz); - } - - public PluginHandle(Bridge bridge, Class pluginClass) throws InvalidPluginException, PluginLoadException { - this(pluginClass, bridge); - this.load(); - } - - public PluginHandle(Bridge bridge, Plugin plugin) throws InvalidPluginException { - this(plugin.getClass(), bridge); - this.loadInstance(plugin); - } - - public Class getPluginClass() { - return pluginClass; - } - - public String getId() { - return this.pluginId; - } - - @SuppressWarnings("deprecation") - public NativePlugin getLegacyPluginAnnotation() { - return this.legacyPluginAnnotation; - } - - public CapacitorPlugin getPluginAnnotation() { - return this.pluginAnnotation; - } - - public Plugin getInstance() { - return this.instance; - } - - public Collection getMethods() { - return this.pluginMethods.values(); - } - - public Plugin load() throws PluginLoadException { - if (this.instance != null) { - return this.instance; - } - - try { - this.instance = this.pluginClass.newInstance(); - return this.loadInstance(instance); - } catch (InstantiationException | IllegalAccessException ex) { - throw new PluginLoadException("Unable to load plugin instance. Ensure plugin is publicly accessible"); - } - } - - public Plugin loadInstance(Plugin plugin) { - this.instance = plugin; - this.instance.setPluginHandle(this); - this.instance.setBridge(this.bridge); - this.instance.load(); - this.instance.initializeActivityLaunchers(); - return this.instance; - } - - /** - * Call a method on a plugin. - * @param methodName the name of the method to call - * @param call the constructed PluginCall with parameters from the caller - * @throws InvalidPluginMethodException if no method was found on that plugin - */ - public void invoke(String methodName, PluginCall call) - throws PluginLoadException, InvalidPluginMethodException, InvocationTargetException, IllegalAccessException { - if (this.instance == null) { - // Can throw PluginLoadException - this.load(); - } - - PluginMethodHandle methodMeta = pluginMethods.get(methodName); - if (methodMeta == null) { - throw new InvalidPluginMethodException("No method " + methodName + " found for plugin " + pluginClass.getName()); - } - - methodMeta.getMethod().invoke(this.instance, call); - } - - /** - * Index all the known callable methods for a plugin for faster - * invocation later - */ - private void indexMethods(Class plugin) { - //Method[] methods = pluginClass.getDeclaredMethods(); - Method[] methods = pluginClass.getMethods(); - - for (Method methodReflect : methods) { - PluginMethod method = methodReflect.getAnnotation(PluginMethod.class); - - if (method == null) { - continue; - } - - PluginMethodHandle methodMeta = new PluginMethodHandle(methodReflect, method); - pluginMethods.put(methodReflect.getName(), methodMeta); - } - } -} diff --git a/@capacitor/android/capacitor/src/main/java/com/getcapacitor/PluginHandle.kt b/@capacitor/android/capacitor/src/main/java/com/getcapacitor/PluginHandle.kt new file mode 100644 index 00000000..c12e360b --- /dev/null +++ b/@capacitor/android/capacitor/src/main/java/com/getcapacitor/PluginHandle.kt @@ -0,0 +1,137 @@ +package com.getcapacitor + +import com.getcapacitor.InvalidPluginMethodException +import com.getcapacitor.PluginLoadException +import com.getcapacitor.annotation.CapacitorPlugin +import java.lang.reflect.InvocationTargetException + +/** + * PluginHandle is an instance of a plugin that has been registered + * and indexed. Think of it as a Plugin instance with extra metadata goodies + */ +class PluginHandle @Suppress("deprecation") private constructor( + val pluginClass: Class, + private val bridge: Bridge +) { + private val pluginMethods: MutableMap = HashMap() + + var id: String? = null + + @get:Suppress("deprecation") + @Suppress("deprecation") + var legacyPluginAnnotation: NativePlugin? = null + + var pluginAnnotation: CapacitorPlugin? = null + + var instance: Plugin? = null + private set + + init { + val pluginAnnotation = pluginClass.getAnnotation( + CapacitorPlugin::class.java + ) + if (pluginAnnotation == null) { + // Check for legacy plugin annotation, @NativePlugin + val legacyPluginAnnotation = pluginClass.getAnnotation( + NativePlugin::class.java + ) + if (legacyPluginAnnotation == null) { + throw InvalidPluginException("No @CapacitorPlugin annotation found for plugin " + pluginClass.name) + } + + if (legacyPluginAnnotation.name != "") { + this.id = legacyPluginAnnotation.name + } else { + this.id = pluginClass.simpleName + } + + this.legacyPluginAnnotation = legacyPluginAnnotation + } else { + if (pluginAnnotation.name != "") { + this.id = pluginAnnotation.name + } else { + this.id = pluginClass.simpleName + } + + this.pluginAnnotation = pluginAnnotation + } + + this.indexMethods(pluginClass) + } + + constructor(bridge: Bridge, pluginClass: Class) : this(pluginClass, bridge) { + this.load() + } + + constructor(bridge: Bridge, plugin: Plugin) : this(plugin.javaClass, bridge) { + this.loadInstance(plugin) + } + + val methods: Collection + get() = pluginMethods.values + + @Throws(PluginLoadException::class) + fun load(): Plugin? { + if (this.instance != null) { + return this.instance + } + + try { + this.instance = pluginClass.newInstance() + return this.loadInstance(instance) + } catch (ex: InstantiationException) { + throw PluginLoadException("Unable to load plugin instance. Ensure plugin is publicly accessible") + } catch (ex: IllegalAccessException) { + throw PluginLoadException("Unable to load plugin instance. Ensure plugin is publicly accessible") + } + } + + fun loadInstance(plugin: Plugin?): Plugin? { + this.instance = plugin + instance.setPluginHandle(this) + instance.setBridge(this.bridge) + instance!!.load() + instance!!.initializeActivityLaunchers() + return this.instance + } + + /** + * Call a method on a plugin. + * @param methodName the name of the method to call + * @param call the constructed PluginCall with parameters from the caller + * @throws InvalidPluginMethodException if no method was found on that plugin + */ + @Throws( + PluginLoadException::class, + InvalidPluginMethodException::class, + InvocationTargetException::class, + IllegalAccessException::class + ) + fun invoke(methodName: String, call: PluginCall?) { + if (this.instance == null) { + // Can throw PluginLoadException + this.load() + } + + val methodMeta = pluginMethods[methodName] + ?: throw InvalidPluginMethodException("No method " + methodName + " found for plugin " + pluginClass.name) + + methodMeta.method.invoke(this.instance, call) + } + + /** + * Index all the known callable methods for a plugin for faster + * invocation later + */ + private fun indexMethods(plugin: Class) { + //Method[] methods = pluginClass.getDeclaredMethods(); + val methods = pluginClass.methods + + for (methodReflect in methods) { + val method = methodReflect.getAnnotation(PluginMethod::class.java) ?: continue + + val methodMeta = PluginMethodHandle(methodReflect, method) + pluginMethods[methodReflect.name] = methodMeta + } + } +} diff --git a/@capacitor/android/capacitor/src/main/java/com/getcapacitor/PluginInvocationException.java b/@capacitor/android/capacitor/src/main/java/com/getcapacitor/PluginInvocationException.java deleted file mode 100644 index ae6b0eb8..00000000 --- a/@capacitor/android/capacitor/src/main/java/com/getcapacitor/PluginInvocationException.java +++ /dev/null @@ -1,16 +0,0 @@ -package com.getcapacitor; - -class PluginInvocationException extends Exception { - - public PluginInvocationException(String s) { - super(s); - } - - public PluginInvocationException(Throwable t) { - super(t); - } - - public PluginInvocationException(String s, Throwable t) { - super(s, t); - } -} diff --git a/@capacitor/android/capacitor/src/main/java/com/getcapacitor/PluginInvocationException.kt b/@capacitor/android/capacitor/src/main/java/com/getcapacitor/PluginInvocationException.kt new file mode 100644 index 00000000..7ea5c2b2 --- /dev/null +++ b/@capacitor/android/capacitor/src/main/java/com/getcapacitor/PluginInvocationException.kt @@ -0,0 +1,9 @@ +package com.getcapacitor + +internal class PluginInvocationException : Exception { + constructor(s: String?) : super(s) + + constructor(t: Throwable?) : super(t) + + constructor(s: String?, t: Throwable?) : super(s, t) +} diff --git a/@capacitor/android/capacitor/src/main/java/com/getcapacitor/PluginLoadException.java b/@capacitor/android/capacitor/src/main/java/com/getcapacitor/PluginLoadException.java deleted file mode 100644 index 8d81a382..00000000 --- a/@capacitor/android/capacitor/src/main/java/com/getcapacitor/PluginLoadException.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.getcapacitor; - -/** - * Thrown when a plugin fails to instantiate - */ -public class PluginLoadException extends Exception { - - public PluginLoadException(String s) { - super(s); - } - - public PluginLoadException(Throwable t) { - super(t); - } - - public PluginLoadException(String s, Throwable t) { - super(s, t); - } -} diff --git a/@capacitor/android/capacitor/src/main/java/com/getcapacitor/PluginLoadException.kt b/@capacitor/android/capacitor/src/main/java/com/getcapacitor/PluginLoadException.kt new file mode 100644 index 00000000..5223697a --- /dev/null +++ b/@capacitor/android/capacitor/src/main/java/com/getcapacitor/PluginLoadException.kt @@ -0,0 +1,12 @@ +package com.getcapacitor + +/** + * Thrown when a plugin fails to instantiate + */ +class PluginLoadException : Exception { + constructor(s: String?) : super(s) + + constructor(t: Throwable?) : super(t) + + constructor(s: String?, t: Throwable?) : super(s, t) +} diff --git a/@capacitor/android/capacitor/src/main/java/com/getcapacitor/PluginManager.java b/@capacitor/android/capacitor/src/main/java/com/getcapacitor/PluginManager.java deleted file mode 100644 index 540bc912..00000000 --- a/@capacitor/android/capacitor/src/main/java/com/getcapacitor/PluginManager.java +++ /dev/null @@ -1,56 +0,0 @@ -package com.getcapacitor; - -import android.content.res.AssetManager; -import java.io.BufferedReader; -import java.io.IOException; -import java.io.InputStreamReader; -import java.util.ArrayList; -import java.util.List; -import org.json.JSONArray; -import org.json.JSONException; -import org.json.JSONObject; - -public class PluginManager { - - private final AssetManager assetManager; - - public PluginManager(AssetManager assetManager) { - this.assetManager = assetManager; - } - - public List> loadPluginClasses() throws PluginLoadException { - JSONArray pluginsJSON = parsePluginsJSON(); - ArrayList> pluginList = new ArrayList<>(); - - try { - for (int i = 0, size = pluginsJSON.length(); i < size; i++) { - JSONObject pluginJSON = pluginsJSON.getJSONObject(i); - String classPath = pluginJSON.getString("classpath"); - Class c = Class.forName(classPath); - pluginList.add(c.asSubclass(Plugin.class)); - } - } catch (JSONException e) { - throw new PluginLoadException("Could not parse capacitor.plugins.json as JSON"); - } catch (ClassNotFoundException e) { - throw new PluginLoadException("Could not find class by class path: " + e.getMessage()); - } - - return pluginList; - } - - private JSONArray parsePluginsJSON() throws PluginLoadException { - try (BufferedReader reader = new BufferedReader(new InputStreamReader(assetManager.open("capacitor.plugins.json")))) { - StringBuilder builder = new StringBuilder(); - String line; - while ((line = reader.readLine()) != null) { - builder.append(line); - } - String jsonString = builder.toString(); - return new JSONArray(jsonString); - } catch (IOException e) { - throw new PluginLoadException("Could not load capacitor.plugins.json"); - } catch (JSONException e) { - throw new PluginLoadException("Could not parse capacitor.plugins.json as JSON"); - } - } -} diff --git a/@capacitor/android/capacitor/src/main/java/com/getcapacitor/PluginManager.kt b/@capacitor/android/capacitor/src/main/java/com/getcapacitor/PluginManager.kt new file mode 100644 index 00000000..f88b56e0 --- /dev/null +++ b/@capacitor/android/capacitor/src/main/java/com/getcapacitor/PluginManager.kt @@ -0,0 +1,54 @@ +package com.getcapacitor + +import android.content.res.AssetManager +import com.getcapacitor.PluginLoadException +import org.json.JSONArray +import org.json.JSONException +import java.io.BufferedReader +import java.io.IOException +import java.io.InputStreamReader + +class PluginManager(private val assetManager: AssetManager) { + @Throws(PluginLoadException::class) + fun loadPluginClasses(): List> { + val pluginsJSON = parsePluginsJSON() + val pluginList = ArrayList>() + + try { + var i = 0 + val size = pluginsJSON.length() + while (i < size) { + val pluginJSON = pluginsJSON.getJSONObject(i) + val classPath = pluginJSON.getString("classpath") + val c = Class.forName(classPath) + pluginList.add(c.asSubclass(Plugin::class.java)) + i++ + } + } catch (e: JSONException) { + throw PluginLoadException("Could not parse capacitor.plugins.json as JSON") + } catch (e: ClassNotFoundException) { + throw PluginLoadException("Could not find class by class path: " + e.message) + } + + return pluginList + } + + @Throws(PluginLoadException::class) + private fun parsePluginsJSON(): JSONArray { + try { + BufferedReader(InputStreamReader(assetManager.open("capacitor.plugins.json"))).use { reader -> + val builder = StringBuilder() + var line: String? + while ((reader.readLine().also { line = it }) != null) { + builder.append(line) + } + val jsonString = builder.toString() + return JSONArray(jsonString) + } + } catch (e: IOException) { + throw PluginLoadException("Could not load capacitor.plugins.json") + } catch (e: JSONException) { + throw PluginLoadException("Could not parse capacitor.plugins.json as JSON") + } + } +} diff --git a/@capacitor/android/capacitor/src/main/java/com/getcapacitor/PluginMethod.java b/@capacitor/android/capacitor/src/main/java/com/getcapacitor/PluginMethod.java deleted file mode 100644 index 85663043..00000000 --- a/@capacitor/android/capacitor/src/main/java/com/getcapacitor/PluginMethod.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.getcapacitor; - -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; - -@Retention(RetentionPolicy.RUNTIME) -public @interface PluginMethod { - String RETURN_PROMISE = "promise"; - - String RETURN_CALLBACK = "callback"; - - String RETURN_NONE = "none"; - - String returnType() default RETURN_PROMISE; -} diff --git a/@capacitor/android/capacitor/src/main/java/com/getcapacitor/PluginMethod.kt b/@capacitor/android/capacitor/src/main/java/com/getcapacitor/PluginMethod.kt new file mode 100644 index 00000000..13a9e8f3 --- /dev/null +++ b/@capacitor/android/capacitor/src/main/java/com/getcapacitor/PluginMethod.kt @@ -0,0 +1,12 @@ +package com.getcapacitor + +@Retention(AnnotationRetention.RUNTIME) +annotation class PluginMethod(val returnType: String = RETURN_PROMISE) { + companion object { + const val RETURN_PROMISE: String = "promise" + + const val RETURN_CALLBACK: String = "callback" + + const val RETURN_NONE: String = "none" + } +} diff --git a/@capacitor/android/capacitor/src/main/java/com/getcapacitor/PluginMethodHandle.java b/@capacitor/android/capacitor/src/main/java/com/getcapacitor/PluginMethodHandle.java deleted file mode 100644 index a728c1f1..00000000 --- a/@capacitor/android/capacitor/src/main/java/com/getcapacitor/PluginMethodHandle.java +++ /dev/null @@ -1,33 +0,0 @@ -package com.getcapacitor; - -import java.lang.reflect.Method; - -public class PluginMethodHandle { - - // The reflect method reference - private final Method method; - // The name of the method - private final String name; - // The return type of the method (see PluginMethod for constants) - private final String returnType; - - public PluginMethodHandle(Method method, PluginMethod methodDecorator) { - this.method = method; - - this.name = method.getName(); - - this.returnType = methodDecorator.returnType(); - } - - public String getReturnType() { - return returnType; - } - - public String getName() { - return name; - } - - public Method getMethod() { - return method; - } -} diff --git a/@capacitor/android/capacitor/src/main/java/com/getcapacitor/PluginMethodHandle.kt b/@capacitor/android/capacitor/src/main/java/com/getcapacitor/PluginMethodHandle.kt new file mode 100644 index 00000000..85d96dd7 --- /dev/null +++ b/@capacitor/android/capacitor/src/main/java/com/getcapacitor/PluginMethodHandle.kt @@ -0,0 +1,15 @@ +package com.getcapacitor + +import java.lang.reflect.Method + +class PluginMethodHandle(// The reflect method reference + val method: Method, methodDecorator: PluginMethod +) { + // The name of the method + + val name: String = method.name + + // The return type of the method (see PluginMethod for constants) + + val returnType: String = methodDecorator.returnType +} diff --git a/@capacitor/android/capacitor/src/main/java/com/getcapacitor/PluginResult.java b/@capacitor/android/capacitor/src/main/java/com/getcapacitor/PluginResult.java deleted file mode 100644 index cdc169e0..00000000 --- a/@capacitor/android/capacitor/src/main/java/com/getcapacitor/PluginResult.java +++ /dev/null @@ -1,84 +0,0 @@ -package com.getcapacitor; - -import java.text.DateFormat; -import java.text.SimpleDateFormat; -import java.util.Date; -import java.util.TimeZone; - -/** - * Wraps a result for web from calling a native plugin. - */ -public class PluginResult { - - private final JSObject json; - - public PluginResult() { - this(new JSObject()); - } - - public PluginResult(JSObject json) { - this.json = json; - } - - public PluginResult put(String name, boolean value) { - return this.jsonPut(name, value); - } - - public PluginResult put(String name, double value) { - return this.jsonPut(name, value); - } - - public PluginResult put(String name, int value) { - return this.jsonPut(name, value); - } - - public PluginResult put(String name, long value) { - return this.jsonPut(name, value); - } - - /** - * Format a date as an ISO string - */ - public PluginResult put(String name, Date value) { - TimeZone tz = TimeZone.getTimeZone("UTC"); - DateFormat df = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm'Z'"); - df.setTimeZone(tz); - return this.jsonPut(name, df.format(value)); - } - - public PluginResult put(String name, Object value) { - return this.jsonPut(name, value); - } - - public PluginResult put(String name, PluginResult value) { - return this.jsonPut(name, value.json); - } - - PluginResult jsonPut(String name, Object value) { - try { - this.json.put(name, value); - } catch (Exception ex) { - Logger.error(Logger.tags("Plugin"), "", ex); - } - return this; - } - - public String toString() { - return this.json.toString(); - } - - /** - * Return plugin metadata and information about the result, if it succeeded the data, or error information if it didn't. - * This is used for appRestoredResult, as it's technically a raw data response from a plugin. - * @return the raw data response from the plugin. - */ - public JSObject getWrappedResult() { - JSObject ret = new JSObject(); - ret.put("pluginId", this.json.getString("pluginId")); - ret.put("methodName", this.json.getString("methodName")); - ret.put("success", this.json.getBoolean("success", false)); - ret.put("data", this.json.getJSObject("data")); - ret.put("error", this.json.getJSObject("error")); - return ret; - } -} diff --git a/@capacitor/android/capacitor/src/main/java/com/getcapacitor/PluginResult.kt b/@capacitor/android/capacitor/src/main/java/com/getcapacitor/PluginResult.kt new file mode 100644 index 00000000..fc044314 --- /dev/null +++ b/@capacitor/android/capacitor/src/main/java/com/getcapacitor/PluginResult.kt @@ -0,0 +1,74 @@ +package com.getcapacitor + +import java.text.DateFormat +import java.text.SimpleDateFormat +import java.util.Date +import java.util.TimeZone + +/** + * Wraps a result for web from calling a native plugin. + */ +class PluginResult @JvmOverloads constructor(private val json: JSObject = JSObject()) { + fun put(name: String, value: Boolean): PluginResult { + return this.jsonPut(name, value) + } + + fun put(name: String, value: Double): PluginResult { + return this.jsonPut(name, value) + } + + fun put(name: String, value: Int): PluginResult { + return this.jsonPut(name, value) + } + + fun put(name: String, value: Long): PluginResult { + return this.jsonPut(name, value) + } + + /** + * Format a date as an ISO string + */ + fun put(name: String, value: Date?): PluginResult { + val tz = TimeZone.getTimeZone("UTC") + val df: DateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm'Z'") + df.timeZone = tz + return this.jsonPut(name, df.format(value)) + } + + fun put(name: String, value: Any?): PluginResult { + return this.jsonPut(name, value!!) + } + + fun put(name: String, value: PluginResult?): PluginResult { + return this.jsonPut(name, value!!.json) + } + + fun jsonPut(name: String, value: Any): PluginResult { + try { + json.put(name, value) + } catch (ex: Exception) { + Logger.Companion.error(Logger.Companion.tags("Plugin"), "", ex) + } + return this + } + + override fun toString(): String { + return json.toString() + } + + val wrappedResult: JSObject + /** + * Return plugin metadata and information about the result, if it succeeded the data, or error information if it didn't. + * This is used for appRestoredResult, as it's technically a raw data response from a plugin. + * @return the raw data response from the plugin. + */ + get() { + val ret = JSObject() + ret.put("pluginId", json.getString("pluginId")) + ret.put("methodName", json.getString("methodName")) + ret.put("success", json.getBoolean("success", false)!!) + ret.put("data", json.getJSObject("data")!!) + ret.put("error", json.getJSObject("error")!!) + return ret + } +} diff --git a/@capacitor/android/capacitor/src/main/java/com/getcapacitor/ProcessedRoute.java b/@capacitor/android/capacitor/src/main/java/com/getcapacitor/ProcessedRoute.java deleted file mode 100644 index eb3d7b0d..00000000 --- a/@capacitor/android/capacitor/src/main/java/com/getcapacitor/ProcessedRoute.java +++ /dev/null @@ -1,37 +0,0 @@ -package com.getcapacitor; - -/** - * An data class used in conjunction with RouteProcessor. - * - * @see com.getcapacitor.RouteProcessor - */ -public class ProcessedRoute { - - private String path; - private boolean isAsset; - private boolean ignoreAssetPath; - - public String getPath() { - return path; - } - - public void setPath(String path) { - this.path = path; - } - - public boolean isAsset() { - return isAsset; - } - - public void setAsset(boolean asset) { - isAsset = asset; - } - - public boolean isIgnoreAssetPath() { - return ignoreAssetPath; - } - - public void setIgnoreAssetPath(boolean ignoreAssetPath) { - this.ignoreAssetPath = ignoreAssetPath; - } -} diff --git a/@capacitor/android/capacitor/src/main/java/com/getcapacitor/ProcessedRoute.kt b/@capacitor/android/capacitor/src/main/java/com/getcapacitor/ProcessedRoute.kt new file mode 100644 index 00000000..5bc6f25b --- /dev/null +++ b/@capacitor/android/capacitor/src/main/java/com/getcapacitor/ProcessedRoute.kt @@ -0,0 +1,12 @@ +package com.getcapacitor + +/** + * An data class used in conjunction with RouteProcessor. + * + * @see com.getcapacitor.RouteProcessor + */ +class ProcessedRoute { + var path: String? = null + var isAsset: Boolean = false + var isIgnoreAssetPath: Boolean = false +} diff --git a/@capacitor/android/capacitor/src/main/java/com/getcapacitor/RouteProcessor.java b/@capacitor/android/capacitor/src/main/java/com/getcapacitor/RouteProcessor.java deleted file mode 100644 index 670c8bc6..00000000 --- a/@capacitor/android/capacitor/src/main/java/com/getcapacitor/RouteProcessor.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.getcapacitor; - -/** - * An interface used in the processing of routes - */ -public interface RouteProcessor { - ProcessedRoute process(String basePath, String path); -} diff --git a/@capacitor/android/capacitor/src/main/java/com/getcapacitor/RouteProcessor.kt b/@capacitor/android/capacitor/src/main/java/com/getcapacitor/RouteProcessor.kt new file mode 100644 index 00000000..6987c0a0 --- /dev/null +++ b/@capacitor/android/capacitor/src/main/java/com/getcapacitor/RouteProcessor.kt @@ -0,0 +1,8 @@ +package com.getcapacitor + +/** + * An interface used in the processing of routes + */ +interface RouteProcessor { + fun process(basePath: String?, path: String?): ProcessedRoute? +} diff --git a/@capacitor/android/capacitor/src/main/java/com/getcapacitor/ServerPath.java b/@capacitor/android/capacitor/src/main/java/com/getcapacitor/ServerPath.java deleted file mode 100644 index 5b34b460..00000000 --- a/@capacitor/android/capacitor/src/main/java/com/getcapacitor/ServerPath.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.getcapacitor; - -public class ServerPath { - - public enum PathType { - BASE_PATH, - ASSET_PATH - } - - private final PathType type; - private final String path; - - public ServerPath(PathType type, String path) { - this.type = type; - this.path = path; - } - - public PathType getType() { - return type; - } - - public String getPath() { - return path; - } -} diff --git a/@capacitor/android/capacitor/src/main/java/com/getcapacitor/ServerPath.kt b/@capacitor/android/capacitor/src/main/java/com/getcapacitor/ServerPath.kt new file mode 100644 index 00000000..d4ec9e72 --- /dev/null +++ b/@capacitor/android/capacitor/src/main/java/com/getcapacitor/ServerPath.kt @@ -0,0 +1,8 @@ +package com.getcapacitor + +class ServerPath(val type: PathType, val path: String) { + enum class PathType { + BASE_PATH, + ASSET_PATH + } +} diff --git a/@capacitor/android/capacitor/src/main/java/com/getcapacitor/UriMatcher.java b/@capacitor/android/capacitor/src/main/java/com/getcapacitor/UriMatcher.java deleted file mode 100644 index 715a0a0b..00000000 --- a/@capacitor/android/capacitor/src/main/java/com/getcapacitor/UriMatcher.java +++ /dev/null @@ -1,180 +0,0 @@ -/* - * Copyright (C) 2006 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -//package com.google.webviewlocalserver.third_party.android; -package com.getcapacitor; - -import android.net.Uri; -import com.getcapacitor.util.HostMask; -import java.util.ArrayList; -import java.util.List; -import java.util.regex.Pattern; - -public class UriMatcher { - - /** - * Creates the root node of the URI tree. - * - * @param code the code to match for the root URI - */ - public UriMatcher(Object code) { - mCode = code; - mWhich = -1; - mChildren = new ArrayList<>(); - mText = null; - } - - private UriMatcher() { - mCode = null; - mWhich = -1; - mChildren = new ArrayList<>(); - mText = null; - } - - /** - * Add a URI to match, and the code to return when this URI is - * matched. URI nodes may be exact match string, the token "*" - * that matches any text, or the token "#" that matches only - * numbers. - *

- * Starting from API level {@link android.os.Build.VERSION_CODES#JELLY_BEAN_MR2}, - * this method will accept a leading slash in the path. - * - * @param authority the authority to match - * @param path the path to match. * may be used as a wild card for - * any text, and # may be used as a wild card for numbers. - * @param code the code that is returned when a URI is matched - * against the given components. Must be positive. - */ - public void addURI(String scheme, String authority, String path, Object code) { - if (code == null) { - throw new IllegalArgumentException("Code can't be null"); - } - - String[] tokens = null; - if (path != null) { - String newPath = path; - // Strip leading slash if present. - if (!path.isEmpty() && path.charAt(0) == '/') { - newPath = path.substring(1); - } - tokens = PATH_SPLIT_PATTERN.split(newPath); - } - - int numTokens = tokens != null ? tokens.length : 0; - UriMatcher node = this; - for (int i = -2; i < numTokens; i++) { - String token; - if (i == -2) token = scheme; else if (i == -1) token = authority; else token = tokens[i]; - ArrayList children = node.mChildren; - int numChildren = children.size(); - UriMatcher child; - int j; - for (j = 0; j < numChildren; j++) { - child = children.get(j); - if (token.equals(child.mText)) { - node = child; - break; - } - } - if (j == numChildren) { - // Child not found, create it - child = new UriMatcher(); - if (i == -1 && token.contains("*")) { - child.mWhich = MASK; - } else if (token.equals("**")) { - child.mWhich = REST; - } else if (token.equals("*")) { - child.mWhich = TEXT; - } else { - child.mWhich = EXACT; - } - child.mText = token; - node.mChildren.add(child); - node = child; - } - } - node.mCode = code; - } - - static final Pattern PATH_SPLIT_PATTERN = Pattern.compile("/"); - - /** - * Try to match against the path in a url. - * - * @param uri The url whose path we will match against. - * @return The code for the matched node (added using addURI), - * or null if there is no matched node. - */ - public Object match(Uri uri) { - final List pathSegments = uri.getPathSegments(); - final int li = pathSegments.size(); - - UriMatcher node = this; - - if (li == 0 && uri.getAuthority() == null) { - return this.mCode; - } - - for (int i = -2; i < li; i++) { - String u; - if (i == -2) u = uri.getScheme(); else if (i == -1) u = uri.getAuthority(); else u = pathSegments.get(i); - ArrayList list = node.mChildren; - if (list == null) { - break; - } - node = null; - int lj = list.size(); - for (int j = 0; j < lj; j++) { - UriMatcher n = list.get(j); - which_switch:switch (n.mWhich) { - case MASK: - if (HostMask.Parser.parse(n.mText).matches(u)) { - node = n; - } - break; - case EXACT: - if (n.mText.equals(u)) { - node = n; - } - break; - case TEXT: - node = n; - break; - case REST: - return n.mCode; - } - if (node != null) { - break; - } - } - if (node == null) { - return null; - } - } - - return node.mCode; - } - - private static final int EXACT = 0; - private static final int TEXT = 1; - private static final int REST = 2; - private static final int MASK = 3; - - private Object mCode; - private int mWhich; - private String mText; - private ArrayList mChildren; -} diff --git a/@capacitor/android/capacitor/src/main/java/com/getcapacitor/UriMatcher.kt b/@capacitor/android/capacitor/src/main/java/com/getcapacitor/UriMatcher.kt new file mode 100644 index 00000000..94a5dc2c --- /dev/null +++ b/@capacitor/android/capacitor/src/main/java/com/getcapacitor/UriMatcher.kt @@ -0,0 +1,169 @@ +/* + * Copyright (C) 2006 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +//package com.google.webviewlocalserver.third_party.android; +package com.getcapacitor + +import android.net.Uri +import com.getcapacitor.util.HostMask +import java.util.regex.Pattern + +class UriMatcher { + /** + * Creates the root node of the URI tree. + * + * @param code the code to match for the root URI + */ + constructor(code: Any?) { + mCode = code + mWhich = -1 + mChildren = ArrayList() + mText = null + } + + private constructor() { + mCode = null + mWhich = -1 + mChildren = ArrayList() + mText = null + } + + /** + * Add a URI to match, and the code to return when this URI is + * matched. URI nodes may be exact match string, the token "*" + * that matches any text, or the token "#" that matches only + * numbers. + * + * + * Starting from API level [android.os.Build.VERSION_CODES.JELLY_BEAN_MR2], + * this method will accept a leading slash in the path. + * + * @param authority the authority to match + * @param path the path to match. * may be used as a wild card for + * any text, and # may be used as a wild card for numbers. + * @param code the code that is returned when a URI is matched + * against the given components. Must be positive. + */ + fun addURI(scheme: String?, authority: String?, path: String?, code: Any?) { + requireNotNull(code) { "Code can't be null" } + + var tokens: Array? = null + if (path != null) { + var newPath = path + // Strip leading slash if present. + if (!path.isEmpty() && path[0] == '/') { + newPath = path.substring(1) + } + tokens = PATH_SPLIT_PATTERN.split(newPath) + } + + val numTokens = tokens?.size ?: 0 + var node = this + for (i in -2 until numTokens) { + var token = if (i == -2) scheme else if (i == -1) authority else tokens!![i] + val children = node.mChildren + val numChildren = children.size + var child: UriMatcher + var j = 0 + while (j < numChildren) { + child = children[j] + if (token == child.mText) { + node = child + break + } + j++ + } + if (j == numChildren) { + // Child not found, create it + child = UriMatcher() + if (i == -1 && token!!.contains("*")) { + child.mWhich = MASK + } else if (token == "**") { + child.mWhich = REST + } else if (token == "*") { + child.mWhich = TEXT + } else { + child.mWhich = EXACT + } + child.mText = token + node.mChildren.add(child) + node = child + } + } + node.mCode = code + } + + /** + * Try to match against the path in a url. + * + * @param uri The url whose path we will match against. + * @return The code for the matched node (added using addURI), + * or null if there is no matched node. + */ + fun match(uri: Uri): Any? { + val pathSegments = uri.pathSegments + val li = pathSegments.size + + var node: UriMatcher? = this + + if (li == 0 && uri.authority == null) { + return this.mCode + } + + for (i in -2 until li) { + var u = if (i == -2) uri.scheme else if (i == -1) uri.authority else pathSegments[i] + val list = node!!.mChildren ?: break + node = null + val lj = list.size + for (j in 0 until lj) { + val n = list[j] + which_switch@ when (n.mWhich) { + MASK -> if (HostMask.Parser.parse(n.mText).matches(u)) { + node = n + } + + EXACT -> if (n.mText == u) { + node = n + } + + TEXT -> node = n + REST -> return n.mCode + } + if (node != null) { + break + } + } + if (node == null) { + return null + } + } + + return node!!.mCode + } + + private var mCode: Any? + private var mWhich: Int + private var mText: String? + private var mChildren: ArrayList + + companion object { + val PATH_SPLIT_PATTERN: Pattern = Pattern.compile("/") + + private const val EXACT = 0 + private const val TEXT = 1 + private const val REST = 2 + private const val MASK = 3 + } +} diff --git a/@capacitor/android/capacitor/src/main/java/com/getcapacitor/WebViewListener.java b/@capacitor/android/capacitor/src/main/java/com/getcapacitor/WebViewListener.kt similarity index 67% rename from @capacitor/android/capacitor/src/main/java/com/getcapacitor/WebViewListener.java rename to @capacitor/android/capacitor/src/main/java/com/getcapacitor/WebViewListener.kt index 6df4f6c0..dbd5578b 100644 --- a/@capacitor/android/capacitor/src/main/java/com/getcapacitor/WebViewListener.java +++ b/@capacitor/android/capacitor/src/main/java/com/getcapacitor/WebViewListener.kt @@ -1,19 +1,18 @@ -package com.getcapacitor; +package com.getcapacitor -import android.webkit.RenderProcessGoneDetail; -import android.webkit.WebView; +import android.webkit.RenderProcessGoneDetail +import android.webkit.WebView /** - * Provides callbacks associated with the {@link BridgeWebViewClient} + * Provides callbacks associated with the [BridgeWebViewClient] */ -public abstract class WebViewListener { - +abstract class WebViewListener { /** * Callback for page load event. * * @param webView The WebView that loaded */ - public void onPageLoaded(WebView webView) { + fun onPageLoaded(webView: WebView?) { // Override me to add behavior to the page loaded event } @@ -22,7 +21,7 @@ public void onPageLoaded(WebView webView) { * * @param webView The WebView that loaded */ - public void onReceivedError(WebView webView) { + fun onReceivedError(webView: WebView?) { // Override me to add behavior to handle the onReceivedError event } @@ -31,7 +30,7 @@ public void onReceivedError(WebView webView) { * * @param webView The WebView that loaded */ - public void onReceivedHttpError(WebView webView) { + fun onReceivedHttpError(webView: WebView?) { // Override me to add behavior to handle the onReceivedHttpError event } @@ -40,7 +39,7 @@ public void onReceivedHttpError(WebView webView) { * * @param webView The WebView that loaded */ - public void onPageStarted(WebView webView) { + fun onPageStarted(webView: WebView?) { // Override me to add behavior to the page started event } @@ -50,8 +49,8 @@ public void onPageStarted(WebView webView) { * @param webView The WebView that loaded * @return returns false by default if the listener is not overridden and used */ - public boolean onRenderProcessGone(WebView webView, RenderProcessGoneDetail detail) { + fun onRenderProcessGone(webView: WebView?, detail: RenderProcessGoneDetail?): Boolean { // Override me to add behavior to the web view render process gone event - return false; + return false } } diff --git a/@capacitor/android/capacitor/src/main/java/com/getcapacitor/WebViewLocalServer.java b/@capacitor/android/capacitor/src/main/java/com/getcapacitor/WebViewLocalServer.java deleted file mode 100644 index 3745a8f3..00000000 --- a/@capacitor/android/capacitor/src/main/java/com/getcapacitor/WebViewLocalServer.java +++ /dev/null @@ -1,878 +0,0 @@ -/* -Copyright 2015 Google Inc. All rights reserved. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. - */ -package com.getcapacitor; - -import static com.getcapacitor.plugin.util.HttpRequestHandler.isDomainExcludedFromSSL; - -import android.content.Context; -import android.net.Uri; -import android.util.Base64; -import android.webkit.CookieManager; -import android.webkit.WebResourceRequest; -import android.webkit.WebResourceResponse; -import com.getcapacitor.plugin.util.CapacitorHttpUrlConnection; -import com.getcapacitor.plugin.util.HttpRequestHandler; -import java.io.IOException; -import java.io.InputStream; -import java.net.HttpURLConnection; -import java.net.URL; -import java.net.URLConnection; -import java.net.URLDecoder; -import java.nio.charset.StandardCharsets; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; - -import java.io.ByteArrayInputStream; -import java.io.ByteArrayOutputStream; - -import java.lang.reflect.Field; - -/** - * Helper class meant to be used with the android.webkit.WebView class to enable - * hosting assets, - * resources and other data on 'virtual' https:// URL. - * Hosting assets and resources on https:// URLs is desirable as it is - * compatible with the - * Same-Origin policy. - *

- * This class is intended to be used from within the - * {@link android.webkit.WebViewClient#shouldInterceptRequest(android.webkit.WebView, String)} - * and - * {@link android.webkit.WebViewClient#shouldInterceptRequest(android.webkit.WebView, - * android.webkit.WebResourceRequest)} - * methods. - */ -public class WebViewLocalServer { - - private static final String capacitorFileStart = Bridge.CAPACITOR_FILE_START; - private static final String capacitorContentStart = Bridge.CAPACITOR_CONTENT_START; - private String basePath; - - private final UriMatcher uriMatcher; - private final AndroidProtocolHandler protocolHandler; - private final ArrayList authorities; - private boolean isAsset; - // Whether to route all requests to paths without extensions back to - // `index.html` - private final boolean html5mode; - private final JSInjector jsInjector; - private final Bridge bridge; - - /** - * A handler that produces responses for paths on the virtual asset server. - *

- * Methods of this handler will be invoked on a background thread and care must - * be taken to - * correctly synchronize access to any shared state. - *

- * On Android KitKat and above these methods may be called on more than one - * thread. This thread - * may be different than the thread on which the shouldInterceptRequest method - * was invoke. - * This means that on Android KitKat and above it is possible to block in this - * method without - * blocking other resources from loading. The number of threads used to - * parallelize loading - * is an internal implementation detail of the WebView and may change between - * updates which - * means that the amount of time spend blocking in this method should be kept to - * an absolute - * minimum. - */ - public abstract static class PathHandler { - - protected String mimeType; - private String encoding; - private String charset; - private int statusCode; - private String reasonPhrase; - private Map responseHeaders; - - public PathHandler() { - this(null, null, 200, "OK", null); - } - - public PathHandler(String encoding, String charset, int statusCode, String reasonPhrase, - Map responseHeaders) { - this.encoding = encoding; - this.charset = charset; - this.statusCode = statusCode; - this.reasonPhrase = reasonPhrase; - Map tempResponseHeaders; - if (responseHeaders == null) { - tempResponseHeaders = new HashMap<>(); - } else { - tempResponseHeaders = responseHeaders; - } - tempResponseHeaders.put("Cache-Control", "no-cache"); - this.responseHeaders = tempResponseHeaders; - } - - public InputStream handle(WebResourceRequest request) { - return handle(request.getUrl()); - } - - public abstract InputStream handle(Uri url); - - public String getEncoding() { - return encoding; - } - - public String getCharset() { - return charset; - } - - public int getStatusCode() { - return statusCode; - } - - public String getReasonPhrase() { - return reasonPhrase; - } - - public Map getResponseHeaders() { - return responseHeaders; - } - } - - WebViewLocalServer(Context context, Bridge bridge, JSInjector jsInjector, ArrayList authorities, - boolean html5mode) { - uriMatcher = new UriMatcher(null); - this.html5mode = html5mode; - this.protocolHandler = new AndroidProtocolHandler(context.getApplicationContext()); - this.authorities = authorities; - this.bridge = bridge; - this.jsInjector = jsInjector; - } - - private static Uri parseAndVerifyUrl(String url) { - if (url == null) { - return null; - } - Uri uri = Uri.parse(url); - if (uri == null) { - Logger.error("Malformed URL: " + url); - return null; - } - String path = uri.getPath(); - if (path == null || path.isEmpty()) { - Logger.error("URL does not have a path: " + url); - return null; - } - return uri; - } - - /** - * Attempt to retrieve the WebResourceResponse associated with the given - * request. - * This method should be invoked from within - * {@link android.webkit.WebViewClient#shouldInterceptRequest(android.webkit.WebView, - * android.webkit.WebResourceRequest)}. - * - * @param request the request to process. - * @return a response if the request URL had a matching handler, null if no - * handler was found. - */ - public WebResourceResponse shouldInterceptRequest(WebResourceRequest request) { - Uri loadingUrl = request.getUrl(); - - if (loadingUrl.toString().endsWith("#image")) { - Map headers = new HashMap<>(request.getRequestHeaders()); - headers.remove("x-requested-with"); - - try { - URL url = new URL(loadingUrl.toString()); - HttpURLConnection connection = (HttpURLConnection) url.openConnection(); - - for (Map.Entry entry : headers.entrySet()) { - connection.setRequestProperty(entry.getKey(), entry.getValue()); - } - - String contentType = connection.getContentType(); - if (contentType == null) { - contentType = "image/unknown"; - } - - InputStream inputStream = connection.getInputStream(); - - return new WebResourceResponse(contentType, "UTF-8", inputStream); - - } catch (Exception e) { - e.printStackTrace(); - } - } - if (loadingUrl.toString().endsWith("#resolve")) { - Map headers = new HashMap<>(request.getRequestHeaders()); - headers.remove("x-requested-with"); - - try { - URL url = new URL(loadingUrl.toString()); - HttpURLConnection connection = (HttpURLConnection) url.openConnection(); - - for (Map.Entry entry : headers.entrySet()) { - connection.setRequestProperty(entry.getKey(), entry.getValue()); - } - - connection.setInstanceFollowRedirects(false); - connection.connect(); - - String locationHeader = connection.getHeaderField("Location"); - String resolvedUrl = (locationHeader != null) ? locationHeader : loadingUrl.toString(); - - WebResourceResponse response = new WebResourceResponse("text/plain", "UTF-8", - new ByteArrayInputStream(resolvedUrl.getBytes(StandardCharsets.UTF_8))); - - Map responseHeaders = new HashMap<>(); - for (Map.Entry> entry : connection.getHeaderFields().entrySet()) { - if (entry.getKey() != null && entry.getValue() != null && !entry.getValue().isEmpty()) { - responseHeaders.put(entry.getKey(), entry.getValue().get(0)); - } - } - - responseHeaders.put("x-location", resolvedUrl); - responseHeaders.put("Access-Control-Allow-Origin", "*"); - responseHeaders.put("Access-Control-Allow-Methods", "GET, POST, OPTIONS"); - response.setResponseHeaders(responseHeaders); - - return response; - - } catch (Exception e) { - e.printStackTrace(); - } - } - - if (loadingUrl.toString().endsWith("#animevsub-vsub_extra")) { - Map headers = new HashMap<>(request.getRequestHeaders()); - headers.remove("x-requested-with"); - headers.put("referer", "https://animevietsub.tv"); - - try { - URL url = new URL(loadingUrl.toString()); - HttpURLConnection connection = (HttpURLConnection) url.openConnection(); - - for (Map.Entry entry : headers.entrySet()) { - connection.setRequestProperty(entry.getKey(), entry.getValue()); - } - - connection.setInstanceFollowRedirects(false); - connection.connect(); - - String locationHeader = connection.getHeaderField("Location"); - String resolvedUrl = (locationHeader != null) ? locationHeader : loadingUrl.toString(); - - Map responseHeaders = new HashMap<>(); - // for (Map.Entry> entry : - // connection.getHeaderFields().entrySet()) { - // if (entry.getKey() != null && entry.getValue() != null && - // !entry.getValue().isEmpty()) { - // responseHeaders.put(entry.getKey(), entry.getValue().get(0)); - // } - // } - // - responseHeaders.put("Access-Control-Allow-Origin", "*"); - responseHeaders.put("Access-Control-Allow-Methods", "PUT, GET, HEAD, POST, DELETE, OPTIONS"); - - // responseHeaders.put("Location", resolvedUrl); - responseHeaders.put("W-Location", resolvedUrl); - - WebResourceResponse response = new WebResourceResponse("text/plain", "UTF-8", - new ByteArrayInputStream(resolvedUrl.getBytes(StandardCharsets.UTF_8))); - - response.setResponseHeaders(responseHeaders); - - return response; - } catch (Exception e) { - e.printStackTrace(); - } - } - - if (null != loadingUrl.getPath() && - (loadingUrl.getPath().startsWith(Bridge.CAPACITOR_HTTP_INTERCEPTOR_START) || - loadingUrl.getPath().startsWith(Bridge.CAPACITOR_HTTPS_INTERCEPTOR_START))) { - Logger.debug("Handling CapacitorHttp request: " + loadingUrl); - try { - return handleCapacitorHttpRequest(request); - } catch (Exception e) { - Logger.error(e.getLocalizedMessage()); - return null; - } - } - - PathHandler handler; - synchronized (uriMatcher) { - handler = (PathHandler) uriMatcher.match(request.getUrl()); - } - if (handler == null) { - return null; - } - - if (isLocalFile(loadingUrl) || isMainUrl(loadingUrl) || !isAllowedUrl(loadingUrl) || isErrorUrl(loadingUrl)) { - Logger.debug("Handling local request: " + request.getUrl().toString()); - return handleLocalRequest(request, handler); - } else { - return handleProxyRequest(request, handler); - } - } - - private boolean isLocalFile(Uri uri) { - String path = uri.getPath(); - return path.startsWith(capacitorContentStart) || path.startsWith(capacitorFileStart); - } - - private boolean isErrorUrl(Uri uri) { - String url = uri.toString(); - return url.equals(bridge.getErrorUrl()); - } - - private boolean isMainUrl(Uri loadingUrl) { - return (bridge.getServerUrl() == null && loadingUrl.getHost().equalsIgnoreCase(bridge.getHost())); - } - - private boolean isAllowedUrl(Uri loadingUrl) { - return !(bridge.getServerUrl() == null && !bridge.getAppAllowNavigationMask().matches(loadingUrl.getHost())); - } - - private String getReasonPhraseFromResponseCode(int code) { - return switch (code) { - case 100 -> "Continue"; - case 101 -> "Switching Protocols"; - case 200 -> "OK"; - case 201 -> "Created"; - case 202 -> "Accepted"; - case 203 -> "Non-Authoritative Information"; - case 204 -> "No Content"; - case 205 -> "Reset Content"; - case 206 -> "Partial Content"; - case 300 -> "Multiple Choices"; - case 301 -> "Moved Permanently"; - case 302 -> "Found"; - case 303 -> "See Other"; - case 304 -> "Not Modified"; - case 400 -> "Bad Request"; - case 401 -> "Unauthorized"; - case 403 -> "Forbidden"; - case 404 -> "Not Found"; - case 405 -> "Method Not Allowed"; - case 406 -> "Not Acceptable"; - case 407 -> "Proxy Authentication Required"; - case 408 -> "Request Timeout"; - case 409 -> "Conflict"; - case 410 -> "Gone"; - case 500 -> "Internal Server Error"; - case 501 -> "Not Implemented"; - case 502 -> "Bad Gateway"; - case 503 -> "Service Unavailable"; - case 504 -> "Gateway Timeout"; - case 505 -> "HTTP Version Not Supported"; - default -> "Unknown"; - }; - } - - private WebResourceResponse handleCapacitorHttpRequest(WebResourceRequest request) throws IOException { - boolean isHttps = request.getUrl().getPath() != null - && request.getUrl().getPath().startsWith(Bridge.CAPACITOR_HTTPS_INTERCEPTOR_START); - - String urlString = request - .getUrl() - .toString() - .replace(bridge.getLocalUrl(), isHttps ? "https:/" : "http:/") - .replace(Bridge.CAPACITOR_HTTP_INTERCEPTOR_START, "") - .replace(Bridge.CAPACITOR_HTTPS_INTERCEPTOR_START, ""); - urlString = URLDecoder.decode(urlString, "UTF-8"); - URL url = new URL(urlString); - JSObject headers = new JSObject(); - - for (Map.Entry header : request.getRequestHeaders().entrySet()) { - headers.put(header.getKey(), header.getValue()); - } - - HttpRequestHandler.HttpURLConnectionBuilder connectionBuilder = new HttpRequestHandler.HttpURLConnectionBuilder() - .setUrl(url) - .setMethod(request.getMethod()) - .setHeaders(headers) - .openConnection(); - - CapacitorHttpUrlConnection connection = connectionBuilder.build(); - - if (!isDomainExcludedFromSSL(bridge, url)) { - connection.setSSLSocketFactory(bridge); - } - - connection.connect(); - - String mimeType = null; - String encoding = null; - Map responseHeaders = new LinkedHashMap<>(); - for (Map.Entry> entry : connection.getHeaderFields().entrySet()) { - StringBuilder builder = new StringBuilder(); - for (String value : entry.getValue()) { - builder.append(value); - builder.append(", "); - } - builder.setLength(builder.length() - 2); - - if ("Content-Type".equalsIgnoreCase(entry.getKey())) { - String[] contentTypeParts = builder.toString().split(";"); - mimeType = contentTypeParts[0].trim(); - if (contentTypeParts.length > 1) { - String[] encodingParts = contentTypeParts[1].split("="); - if (encodingParts.length > 1) { - encoding = encodingParts[1].trim(); - } - } - } else { - responseHeaders.put(entry.getKey(), builder.toString()); - } - } - - InputStream inputStream = connection.getErrorStream(); - if (inputStream == null) { - inputStream = connection.getInputStream(); - } - - if (null == mimeType) { - mimeType = getMimeType(request.getUrl().getPath(), inputStream); - } - - int responseCode = connection.getResponseCode(); - String reasonPhrase = getReasonPhraseFromResponseCode(responseCode); - - return new WebResourceResponse(mimeType, encoding, responseCode, reasonPhrase, responseHeaders, inputStream); - } - - private WebResourceResponse handleLocalRequest(WebResourceRequest request, PathHandler handler) { - String path = request.getUrl().getPath(); - - if (request.getRequestHeaders().get("Range") != null) { - InputStream responseStream = new LollipopLazyInputStream(handler, request); - String mimeType = getMimeType(path, responseStream); - Map tempResponseHeaders = handler.getResponseHeaders(); - int statusCode = 206; - try { - int totalRange = responseStream.available(); - String rangeString = request.getRequestHeaders().get("Range"); - String[] parts = rangeString.split("="); - String[] streamParts = parts[1].split("-"); - String fromRange = streamParts[0]; - int range = totalRange - 1; - if (streamParts.length > 1) { - range = Integer.parseInt(streamParts[1]); - } - tempResponseHeaders.put("Accept-Ranges", "bytes"); - tempResponseHeaders.put("Content-Range", "bytes " + fromRange + "-" + range + "/" + totalRange); - } catch (IOException e) { - statusCode = 404; - } - return new WebResourceResponse( - mimeType, - handler.getEncoding(), - statusCode, - handler.getReasonPhrase(), - tempResponseHeaders, - responseStream); - } - - if (isLocalFile(request.getUrl()) || isErrorUrl(request.getUrl())) { - InputStream responseStream = new LollipopLazyInputStream(handler, request); - String mimeType = getMimeType(request.getUrl().getPath(), responseStream); - int statusCode = getStatusCode(responseStream, handler.getStatusCode()); - return new WebResourceResponse( - mimeType, - handler.getEncoding(), - statusCode, - handler.getReasonPhrase(), - handler.getResponseHeaders(), - responseStream); - } - - if (path.equals("/cordova.js")) { - return new WebResourceResponse( - "application/javascript", - handler.getEncoding(), - handler.getStatusCode(), - handler.getReasonPhrase(), - handler.getResponseHeaders(), - null); - } - - if (path.equals("/") || (!request.getUrl().getLastPathSegment().contains(".") && html5mode)) { - InputStream responseStream; - try { - String startPath = this.basePath + "/index.html"; - if (bridge.getRouteProcessor() != null) { - ProcessedRoute processedRoute = bridge.getRouteProcessor().process(this.basePath, "/index.html"); - startPath = processedRoute.getPath(); - isAsset = processedRoute.isAsset(); - } - - if (isAsset) { - responseStream = protocolHandler.openAsset(startPath); - } else { - responseStream = protocolHandler.openFile(startPath); - } - } catch (IOException e) { - Logger.error("Unable to open index.html", e); - return null; - } - - responseStream = jsInjector.getInjectedStream(responseStream); - - int statusCode = getStatusCode(responseStream, handler.getStatusCode()); - return new WebResourceResponse( - "text/html", - handler.getEncoding(), - statusCode, - handler.getReasonPhrase(), - handler.getResponseHeaders(), - responseStream); - } - - if ("/favicon.ico".equalsIgnoreCase(path)) { - try { - return new WebResourceResponse("image/png", null, null); - } catch (Exception e) { - Logger.error("favicon handling failed", e); - } - } - - int periodIndex = path.lastIndexOf("."); - if (periodIndex >= 0) { - String ext = path.substring(path.lastIndexOf(".")); - - InputStream responseStream = new LollipopLazyInputStream(handler, request); - - // TODO: Conjure up a bit more subtlety than this - if (ext.equals(".html")) { - responseStream = jsInjector.getInjectedStream(responseStream); - } - - String mimeType = getMimeType(path, responseStream); - int statusCode = getStatusCode(responseStream, handler.getStatusCode()); - return new WebResourceResponse( - mimeType, - handler.getEncoding(), - statusCode, - handler.getReasonPhrase(), - handler.getResponseHeaders(), - responseStream); - } - - return null; - } - - /** - * Instead of reading files from the filesystem/assets, proxy through to the URL - * and let an external server handle it. - * - * @param request - * @param handler - * @return - */ - private WebResourceResponse handleProxyRequest(WebResourceRequest request, PathHandler handler) { - final String method = request.getMethod(); - if (method.equals("GET")) { - try { - String url = request.getUrl().toString(); - Map headers = request.getRequestHeaders(); - boolean isHtmlText = false; - for (Map.Entry header : headers.entrySet()) { - if (header.getKey().equalsIgnoreCase("Accept") - && header.getValue().toLowerCase().contains("text/html")) { - isHtmlText = true; - break; - } - } - if (isHtmlText) { - HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection(); - for (Map.Entry header : headers.entrySet()) { - conn.setRequestProperty(header.getKey(), header.getValue()); - } - String getCookie = CookieManager.getInstance().getCookie(url); - if (getCookie != null) { - conn.setRequestProperty("Cookie", getCookie); - } - conn.setRequestMethod(method); - conn.setReadTimeout(30 * 1000); - conn.setConnectTimeout(30 * 1000); - if (request.getUrl().getUserInfo() != null) { - byte[] userInfoBytes = request.getUrl().getUserInfo().getBytes(StandardCharsets.UTF_8); - String base64 = Base64.encodeToString(userInfoBytes, Base64.NO_WRAP); - conn.setRequestProperty("Authorization", "Basic " + base64); - } - - List cookies = conn.getHeaderFields().get("Set-Cookie"); - if (cookies != null) { - for (String cookie : cookies) { - CookieManager.getInstance().setCookie(url, cookie); - } - } - InputStream responseStream = conn.getInputStream(); - responseStream = jsInjector.getInjectedStream(responseStream); - return new WebResourceResponse( - "text/html", - handler.getEncoding(), - handler.getStatusCode(), - handler.getReasonPhrase(), - handler.getResponseHeaders(), - responseStream); - } - } catch (Exception ex) { - bridge.handleAppUrlLoadError(ex); - } - } - return null; - } - - private String getMimeType(String path, InputStream stream) { - String mimeType = null; - try { - mimeType = URLConnection.guessContentTypeFromName(path); // Does not recognize *.js - if (mimeType != null && path.endsWith(".js") && mimeType.equals("image/x-icon")) { - Logger.debug("We shouldn't be here"); - } - if (mimeType == null) { - if (path.endsWith(".js") || path.endsWith(".mjs")) { - // Make sure JS files get the proper mimetype to support ES modules - mimeType = "application/javascript"; - } else if (path.endsWith(".wasm")) { - mimeType = "application/wasm"; - } else { - mimeType = URLConnection.guessContentTypeFromStream(stream); - } - } - } catch (Exception ex) { - Logger.error("Unable to get mime type" + path, ex); - } - return mimeType; - } - - private int getStatusCode(InputStream stream, int defaultCode) { - int finalStatusCode = defaultCode; - try { - if (stream.available() == -1) { - finalStatusCode = 404; - } - } catch (IOException e) { - finalStatusCode = 500; - } - return finalStatusCode; - } - - /** - * Registers a handler for the given uri. The handler - * will be invoked - * every time the shouldInterceptRequest method of the instance is - * called with - * a matching uri. - * - * @param uri the uri to use the handler for. The scheme and authority - * (domain) will be matched - * exactly. The path may contain a '*' element which will match a - * single element of - * a path (so a handler registered for /a/* will be invoked for - * /a/b and /a/c.html - * but not for /a/b/b) or the '**' element which will match any - * number of path - * elements. - * @param handler the handler to use for the uri. - */ - void register(Uri uri, PathHandler handler) { - synchronized (uriMatcher) { - uriMatcher.addURI(uri.getScheme(), uri.getAuthority(), uri.getPath(), handler); - } - } - - /** - * Hosts the application's assets on an https:// URL. Assets from the local path - * assetPath/... will be available under - * https://{uuid}.androidplatform.net/assets/.... - * - * @param assetPath the local path in the application's asset folder which will - * be made - * available by the server (for example "/www"). - * @return prefixes under which the assets are hosted. - */ - public void hostAssets(String assetPath) { - this.isAsset = true; - this.basePath = assetPath; - createHostingDetails(); - } - - /** - * Hosts the application's files on an https:// URL. Files from the basePath - * basePath/... will be available under - * https://{uuid}.androidplatform.net/.... - * - * @param basePath the local path in the application's data folder which will be - * made - * available by the server (for example "/www"). - * @return prefixes under which the assets are hosted. - */ - public void hostFiles(final String basePath) { - this.isAsset = false; - this.basePath = basePath; - createHostingDetails(); - } - - private void createHostingDetails() { - final String assetPath = this.basePath; - - if (assetPath.indexOf('*') != -1) { - throw new IllegalArgumentException("assetPath cannot contain the '*' character."); - } - - PathHandler handler = new PathHandler() { - @Override - public InputStream handle(Uri url) { - InputStream stream = null; - String path = url.getPath(); - - // Pass path to routeProcessor if present - RouteProcessor routeProcessor = bridge.getRouteProcessor(); - boolean ignoreAssetPath = false; - if (routeProcessor != null) { - ProcessedRoute processedRoute = bridge.getRouteProcessor().process("", path); - path = processedRoute.getPath(); - isAsset = processedRoute.isAsset(); - ignoreAssetPath = processedRoute.isIgnoreAssetPath(); - } - - try { - if (path.startsWith(capacitorContentStart)) { - stream = protocolHandler.openContentUrl(url); - } else if (path.startsWith(capacitorFileStart)) { - stream = protocolHandler.openFile(path); - } else if (!isAsset) { - if (routeProcessor == null) { - path = basePath + url.getPath(); - } - - stream = protocolHandler.openFile(path); - } else if (ignoreAssetPath) { - stream = protocolHandler.openAsset(path); - } else { - stream = protocolHandler.openAsset(assetPath + path); - } - } catch (IOException e) { - Logger.error("Unable to open asset URL: " + url); - return null; - } - - return stream; - } - }; - - for (String authority : authorities) { - registerUriForScheme(Bridge.CAPACITOR_HTTP_SCHEME, handler, authority); - registerUriForScheme(Bridge.CAPACITOR_HTTPS_SCHEME, handler, authority); - - String customScheme = this.bridge.getScheme(); - if (!customScheme.equals(Bridge.CAPACITOR_HTTP_SCHEME) - && !customScheme.equals(Bridge.CAPACITOR_HTTPS_SCHEME)) { - registerUriForScheme(customScheme, handler, authority); - } - } - } - - private void registerUriForScheme(String scheme, PathHandler handler, String authority) { - Uri.Builder uriBuilder = new Uri.Builder(); - uriBuilder.scheme(scheme); - uriBuilder.authority(authority); - uriBuilder.path(""); - Uri uriPrefix = uriBuilder.build(); - - register(Uri.withAppendedPath(uriPrefix, "/"), handler); - register(Uri.withAppendedPath(uriPrefix, "**"), handler); - } - - /** - * The KitKat WebView reads the InputStream on a separate threadpool. We can use - * that to - * parallelize loading. - */ - private abstract static class LazyInputStream extends InputStream { - - protected final PathHandler handler; - private InputStream is = null; - - public LazyInputStream(PathHandler handler) { - this.handler = handler; - } - - private InputStream getInputStream() { - if (is == null) { - is = handle(); - } - return is; - } - - protected abstract InputStream handle(); - - @Override - public int available() throws IOException { - InputStream is = getInputStream(); - return (is != null) ? is.available() : -1; - } - - @Override - public int read() throws IOException { - InputStream is = getInputStream(); - return (is != null) ? is.read() : -1; - } - - @Override - public int read(byte[] b) throws IOException { - InputStream is = getInputStream(); - return (is != null) ? is.read(b) : -1; - } - - @Override - public int read(byte[] b, int off, int len) throws IOException { - InputStream is = getInputStream(); - return (is != null) ? is.read(b, off, len) : -1; - } - - @Override - public long skip(long n) throws IOException { - InputStream is = getInputStream(); - return (is != null) ? is.skip(n) : 0; - } - } - - // For L and above. - private static class LollipopLazyInputStream extends LazyInputStream { - - private WebResourceRequest request; - private InputStream is; - - public LollipopLazyInputStream(PathHandler handler, WebResourceRequest request) { - super(handler); - this.request = request; - } - - @Override - protected InputStream handle() { - return handler.handle(request); - } - } - - public String getBasePath() { - return this.basePath; - } -} diff --git a/@capacitor/android/capacitor/src/main/java/com/getcapacitor/WebViewLocalServer.kt b/@capacitor/android/capacitor/src/main/java/com/getcapacitor/WebViewLocalServer.kt new file mode 100644 index 00000000..96078c78 --- /dev/null +++ b/@capacitor/android/capacitor/src/main/java/com/getcapacitor/WebViewLocalServer.kt @@ -0,0 +1,852 @@ +/* +Copyright 2015 Google Inc. All rights reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + */ +package com.getcapacitor + +import android.content.Context +import android.net.Uri +import android.util.Base64 +import android.webkit.CookieManager +import android.webkit.WebResourceRequest +import android.webkit.WebResourceResponse +import com.getcapacitor.plugin.util.HttpRequestHandler +import com.getcapacitor.plugin.util.HttpRequestHandler.HttpURLConnectionBuilder +import java.io.ByteArrayInputStream +import java.io.IOException +import java.io.InputStream +import java.net.HttpURLConnection +import java.net.URL +import java.net.URLConnection +import java.net.URLDecoder +import java.nio.charset.StandardCharsets +import java.util.Locale + +/** + * Helper class meant to be used with the android.webkit.WebView class to enable + * hosting assets, + * resources and other data on 'virtual' https:// URL. + * Hosting assets and resources on https:// URLs is desirable as it is + * compatible with the + * Same-Origin policy. + * + * + * This class is intended to be used from within the + * [android.webkit.WebViewClient.shouldInterceptRequest] + * and + * [android.webkit.WebViewClient.shouldInterceptRequest] + * methods. + */ +class WebViewLocalServer internal constructor( + context: Context, + private val bridge: Bridge, + private val jsInjector: JSInjector, + private val authorities: ArrayList, + // Whether to route all requests to paths without extensions back to + // `index.html` + private val html5mode: Boolean +) { + var basePath: String? = null + private set + + private val uriMatcher = UriMatcher(null) + private val protocolHandler = AndroidProtocolHandler(context.applicationContext) + private var isAsset = false + + /** + * A handler that produces responses for paths on the virtual asset server. + * + * + * Methods of this handler will be invoked on a background thread and care must + * be taken to + * correctly synchronize access to any shared state. + * + * + * On Android KitKat and above these methods may be called on more than one + * thread. This thread + * may be different than the thread on which the shouldInterceptRequest method + * was invoke. + * This means that on Android KitKat and above it is possible to block in this + * method without + * blocking other resources from loading. The number of threads used to + * parallelize loading + * is an internal implementation detail of the WebView and may change between + * updates which + * means that the amount of time spend blocking in this method should be kept to + * an absolute + * minimum. + */ + abstract class PathHandler @JvmOverloads constructor( + val encoding: String? = null, + val charset: String? = null, + val statusCode: Int = 200, + val reasonPhrase: String = "OK", + responseHeaders: MutableMap? = null + ) { + protected var mimeType: String? = null + val responseHeaders: MutableMap + + init { + val tempResponseHeaders = responseHeaders ?: HashMap() + tempResponseHeaders["Cache-Control"] = "no-cache" + this.responseHeaders = tempResponseHeaders + } + + fun handle(request: WebResourceRequest): InputStream? { + return handle(request.url) + } + + abstract fun handle(url: Uri): InputStream? + } + + /** + * Attempt to retrieve the WebResourceResponse associated with the given + * `request`. + * This method should be invoked from within + * [android.webkit.WebViewClient.shouldInterceptRequest]. + * + * @param request the request to process. + * @return a response if the request URL had a matching handler, null if no + * handler was found. + */ + fun shouldInterceptRequest(request: WebResourceRequest): WebResourceResponse? { + val loadingUrl = request.url + + if (loadingUrl.toString().endsWith("#image")) { + val headers: MutableMap = HashMap(request.requestHeaders) + headers.remove("x-requested-with") + + try { + val url = URL(loadingUrl.toString()) + val connection = url.openConnection() as HttpURLConnection + + for ((key, value) in headers) { + connection.setRequestProperty(key, value) + } + + var contentType = connection.contentType + if (contentType == null) { + contentType = "image/unknown" + } + + val inputStream = connection.inputStream + + return WebResourceResponse(contentType, "UTF-8", inputStream) + } catch (e: Exception) { + e.printStackTrace() + } + } + if (loadingUrl.toString().endsWith("#resolve")) { + val headers: MutableMap = HashMap(request.requestHeaders) + headers.remove("x-requested-with") + + try { + val url = URL(loadingUrl.toString()) + val connection = url.openConnection() as HttpURLConnection + + for ((key, value) in headers) { + connection.setRequestProperty(key, value) + } + + connection.instanceFollowRedirects = false + connection.connect() + + val locationHeader = connection.getHeaderField("Location") + val resolvedUrl = + if ((locationHeader != null)) locationHeader else loadingUrl.toString() + + val response = WebResourceResponse( + "text/plain", "UTF-8", + ByteArrayInputStream(resolvedUrl.toByteArray(StandardCharsets.UTF_8)) + ) + + val responseHeaders: MutableMap = HashMap() + for ((key, value) in connection.headerFields) { + if (key != null && value != null && !value.isEmpty()) { + responseHeaders[key] = value[0] + } + } + + responseHeaders["x-location"] = resolvedUrl + responseHeaders["Access-Control-Allow-Origin"] = "*" + responseHeaders["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS" + response.responseHeaders = responseHeaders + + return response + } catch (e: Exception) { + e.printStackTrace() + } + } + + if (loadingUrl.toString().endsWith("#animevsub-vsub_extra")) { + val headers: MutableMap = HashMap(request.requestHeaders) + headers.remove("x-requested-with") + headers["referer"] = "https://animevietsub.tv" + + try { + val url = URL(loadingUrl.toString()) + val connection = url.openConnection() as HttpURLConnection + + for ((key, value) in headers) { + connection.setRequestProperty(key, value) + } + + connection.instanceFollowRedirects = false + connection.connect() + + val locationHeader = connection.getHeaderField("Location") + val resolvedUrl = + if ((locationHeader != null)) locationHeader else loadingUrl.toString() + + val responseHeaders: MutableMap = HashMap() + // for (Map.Entry> entry : + // connection.getHeaderFields().entrySet()) { + // if (entry.getKey() != null && entry.getValue() != null && + // !entry.getValue().isEmpty()) { + // responseHeaders.put(entry.getKey(), entry.getValue().get(0)); + // } + // } + // + responseHeaders["Access-Control-Allow-Origin"] = "*" + responseHeaders["Access-Control-Allow-Methods"] = + "PUT, GET, HEAD, POST, DELETE, OPTIONS" + + // responseHeaders.put("Location", resolvedUrl); + responseHeaders["W-Location"] = resolvedUrl + + val response = WebResourceResponse( + "text/plain", "UTF-8", + ByteArrayInputStream(resolvedUrl.toByteArray(StandardCharsets.UTF_8)) + ) + + response.responseHeaders = responseHeaders + + return response + } catch (e: Exception) { + e.printStackTrace() + } + } + + if (null != loadingUrl.path && + (loadingUrl.path!!.startsWith(Bridge.CAPACITOR_HTTP_INTERCEPTOR_START) || + loadingUrl.path!!.startsWith(Bridge.CAPACITOR_HTTPS_INTERCEPTOR_START)) + ) { + Logger.Companion.debug("Handling CapacitorHttp request: $loadingUrl") + try { + return handleCapacitorHttpRequest(request) + } catch (e: Exception) { + Logger.Companion.error(e.localizedMessage) + return null + } + } + + var handler: PathHandler + synchronized(uriMatcher) { + handler = uriMatcher.match(request.url) as PathHandler + } + if (handler == null) { + return null + } + + if (isLocalFile(loadingUrl) || isMainUrl(loadingUrl) || !isAllowedUrl(loadingUrl) || isErrorUrl( + loadingUrl + ) + ) { + Logger.Companion.debug("Handling local request: " + request.url.toString()) + return handleLocalRequest(request, handler) + } else { + return handleProxyRequest(request, handler) + } + } + + private fun isLocalFile(uri: Uri): Boolean { + val path = uri.path + return path!!.startsWith(capacitorContentStart) || path.startsWith(capacitorFileStart) + } + + private fun isErrorUrl(uri: Uri): Boolean { + val url = uri.toString() + return url == bridge.errorUrl + } + + private fun isMainUrl(loadingUrl: Uri): Boolean { + return (bridge.serverUrl == null && loadingUrl.host.equals(bridge.host, ignoreCase = true)) + } + + private fun isAllowedUrl(loadingUrl: Uri): Boolean { + return !(bridge.serverUrl == null && !bridge.appAllowNavigationMask!!.matches(loadingUrl.host)) + } + + private fun getReasonPhraseFromResponseCode(code: Int): String { + return when (code) { + 100 -> "Continue" + 101 -> "Switching Protocols" + 200 -> "OK" + 201 -> "Created" + 202 -> "Accepted" + 203 -> "Non-Authoritative Information" + 204 -> "No Content" + 205 -> "Reset Content" + 206 -> "Partial Content" + 300 -> "Multiple Choices" + 301 -> "Moved Permanently" + 302 -> "Found" + 303 -> "See Other" + 304 -> "Not Modified" + 400 -> "Bad Request" + 401 -> "Unauthorized" + 403 -> "Forbidden" + 404 -> "Not Found" + 405 -> "Method Not Allowed" + 406 -> "Not Acceptable" + 407 -> "Proxy Authentication Required" + 408 -> "Request Timeout" + 409 -> "Conflict" + 410 -> "Gone" + 500 -> "Internal Server Error" + 501 -> "Not Implemented" + 502 -> "Bad Gateway" + 503 -> "Service Unavailable" + 504 -> "Gateway Timeout" + 505 -> "HTTP Version Not Supported" + else -> "Unknown" + } + } + + @Throws(IOException::class) + private fun handleCapacitorHttpRequest(request: WebResourceRequest): WebResourceResponse { + val isHttps = (request.url.path != null + && request.url.path!!.startsWith(Bridge.CAPACITOR_HTTPS_INTERCEPTOR_START)) + + var urlString: String? = request + .url + .toString() + .replace(bridge.localUrl!!, if (isHttps) "https:/" else "http:/") + .replace(Bridge.CAPACITOR_HTTP_INTERCEPTOR_START, "") + .replace(Bridge.CAPACITOR_HTTPS_INTERCEPTOR_START, "") + urlString = URLDecoder.decode(urlString, "UTF-8") + val url = URL(urlString) + val headers = JSObject() + + for ((key, value) in request.requestHeaders) { + headers.put(key, value) + } + + val connectionBuilder = HttpURLConnectionBuilder() + .setUrl(url) + .setMethod(request.method) + .setHeaders(headers) + .openConnection() + + val connection = connectionBuilder.build() + + if (!HttpRequestHandler.isDomainExcludedFromSSL(bridge, url)) { + connection.setSSLSocketFactory(bridge) + } + + connection.connect() + + var mimeType: String? = null + var encoding: String? = null + val responseHeaders: MutableMap = LinkedHashMap() + for ((key, value1) in connection.headerFields) { + val builder = StringBuilder() + for (value in value1) { + builder.append(value) + builder.append(", ") + } + builder.setLength(builder.length - 2) + + if ("Content-Type".equals(key, ignoreCase = true)) { + val contentTypeParts = + builder.toString().split(";".toRegex()).dropLastWhile { it.isEmpty() } + .toTypedArray() + mimeType = contentTypeParts[0].trim { it <= ' ' } + if (contentTypeParts.size > 1) { + val encodingParts = + contentTypeParts[1].split("=".toRegex()).dropLastWhile { it.isEmpty() } + .toTypedArray() + if (encodingParts.size > 1) { + encoding = encodingParts[1].trim { it <= ' ' } + } + } + } else { + responseHeaders[key] = builder.toString() + } + } + + var inputStream = connection.errorStream + if (inputStream == null) { + inputStream = connection.inputStream + } + + if (null == mimeType) { + mimeType = getMimeType(request.url.path, inputStream) + } + + val responseCode = connection.responseCode + val reasonPhrase = getReasonPhraseFromResponseCode(responseCode) + + return WebResourceResponse( + mimeType, + encoding, + responseCode, + reasonPhrase, + responseHeaders, + inputStream + ) + } + + private fun handleLocalRequest( + request: WebResourceRequest, + handler: PathHandler + ): WebResourceResponse? { + val path = request.url.path + + if (request.requestHeaders["Range"] != null) { + val responseStream: InputStream = LollipopLazyInputStream(handler, request) + val mimeType = getMimeType(path, responseStream) + val tempResponseHeaders = handler.responseHeaders + var statusCode = 206 + try { + val totalRange = responseStream.available() + val rangeString = request.requestHeaders["Range"] + val parts = + rangeString!!.split("=".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() + val streamParts = + parts[1].split("-".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() + val fromRange = streamParts[0] + var range = totalRange - 1 + if (streamParts.size > 1) { + range = streamParts[1].toInt() + } + tempResponseHeaders["Accept-Ranges"] = "bytes" + tempResponseHeaders["Content-Range"] = "bytes $fromRange-$range/$totalRange" + } catch (e: IOException) { + statusCode = 404 + } + return WebResourceResponse( + mimeType, + handler.encoding, + statusCode, + handler.reasonPhrase, + tempResponseHeaders, + responseStream + ) + } + + if (isLocalFile(request.url) || isErrorUrl(request.url)) { + val responseStream: InputStream = LollipopLazyInputStream(handler, request) + val mimeType = getMimeType(request.url.path, responseStream) + val statusCode = getStatusCode(responseStream, handler.statusCode) + return WebResourceResponse( + mimeType, + handler.encoding, + statusCode, + handler.reasonPhrase, + handler.responseHeaders, + responseStream + ) + } + + if (path == "/cordova.js") { + return WebResourceResponse( + "application/javascript", + handler.encoding, + handler.statusCode, + handler.reasonPhrase, + handler.responseHeaders, + null + ) + } + + if (path == "/" || (!request.url.lastPathSegment!!.contains(".") && html5mode)) { + var responseStream: InputStream? + try { + var startPath = this.basePath + "/index.html" + if (bridge.routeProcessor != null) { + val processedRoute = + bridge.routeProcessor!!.process(this.basePath, "/index.html") + startPath = processedRoute.path + isAsset = processedRoute!!.isAsset + } + + responseStream = if (isAsset) { + protocolHandler.openAsset(startPath) + } else { + protocolHandler.openFile(startPath) + } + } catch (e: IOException) { + Logger.Companion.error("Unable to open index.html", e) + return null + } + + responseStream = jsInjector.getInjectedStream(responseStream) + + val statusCode = getStatusCode(responseStream, handler.statusCode) + return WebResourceResponse( + "text/html", + handler.encoding, + statusCode, + handler.reasonPhrase, + handler.responseHeaders, + responseStream + ) + } + + if ("/favicon.ico".equals(path, ignoreCase = true)) { + try { + return WebResourceResponse("image/png", null, null) + } catch (e: Exception) { + Logger.Companion.error("favicon handling failed", e) + } + } + + val periodIndex = path!!.lastIndexOf(".") + if (periodIndex >= 0) { + val ext = path.substring(path.lastIndexOf(".")) + + var responseStream: InputStream? = LollipopLazyInputStream(handler, request) + + // TODO: Conjure up a bit more subtlety than this + if (ext == ".html") { + responseStream = jsInjector.getInjectedStream(responseStream) + } + + val mimeType = getMimeType(path, responseStream) + val statusCode = getStatusCode(responseStream, handler.statusCode) + return WebResourceResponse( + mimeType, + handler.encoding, + statusCode, + handler.reasonPhrase, + handler.responseHeaders, + responseStream + ) + } + + return null + } + + /** + * Instead of reading files from the filesystem/assets, proxy through to the URL + * and let an external server handle it. + * + * @param request + * @param handler + * @return + */ + private fun handleProxyRequest( + request: WebResourceRequest, + handler: PathHandler + ): WebResourceResponse? { + val method = request.method + if (method == "GET") { + try { + val url = request.url.toString() + val headers = request.requestHeaders + var isHtmlText = false + for ((key, value) in headers) { + if (key.equals("Accept", ignoreCase = true) + && value.lowercase(Locale.getDefault()).contains("text/html") + ) { + isHtmlText = true + break + } + } + if (isHtmlText) { + val conn = URL(url).openConnection() as HttpURLConnection + for ((key, value) in headers) { + conn.setRequestProperty(key, value) + } + val getCookie = CookieManager.getInstance().getCookie(url) + if (getCookie != null) { + conn.setRequestProperty("Cookie", getCookie) + } + conn.requestMethod = method + conn.readTimeout = 30 * 1000 + conn.connectTimeout = 30 * 1000 + if (request.url.userInfo != null) { + val userInfoBytes = + request.url.userInfo!!.toByteArray(StandardCharsets.UTF_8) + val base64 = Base64.encodeToString(userInfoBytes, Base64.NO_WRAP) + conn.setRequestProperty("Authorization", "Basic $base64") + } + + val cookies = conn.headerFields["Set-Cookie"] + if (cookies != null) { + for (cookie in cookies) { + CookieManager.getInstance().setCookie(url, cookie) + } + } + var responseStream = conn.inputStream + responseStream = jsInjector.getInjectedStream(responseStream) + return WebResourceResponse( + "text/html", + handler.encoding, + handler.statusCode, + handler.reasonPhrase, + handler.responseHeaders, + responseStream + ) + } + } catch (ex: Exception) { + bridge.handleAppUrlLoadError(ex) + } + } + return null + } + + private fun getMimeType(path: String?, stream: InputStream?): String? { + var mimeType: String? = null + try { + mimeType = URLConnection.guessContentTypeFromName(path) // Does not recognize *.js + if (mimeType != null && path!!.endsWith(".js") && mimeType == "image/x-icon") { + Logger.Companion.debug("We shouldn't be here") + } + if (mimeType == null) { + mimeType = if (path!!.endsWith(".js") || path.endsWith(".mjs")) { + // Make sure JS files get the proper mimetype to support ES modules + "application/javascript" + } else if (path.endsWith(".wasm")) { + "application/wasm" + } else { + URLConnection.guessContentTypeFromStream(stream) + } + } + } catch (ex: Exception) { + Logger.Companion.error("Unable to get mime type$path", ex) + } + return mimeType + } + + private fun getStatusCode(stream: InputStream?, defaultCode: Int): Int { + var finalStatusCode = defaultCode + try { + if (stream!!.available() == -1) { + finalStatusCode = 404 + } + } catch (e: IOException) { + finalStatusCode = 500 + } + return finalStatusCode + } + + /** + * Registers a handler for the given `uri`. The `handler` + * will be invoked + * every time the `shouldInterceptRequest` method of the instance is + * called with + * a matching `uri`. + * + * @param uri the uri to use the handler for. The scheme and authority + * (domain) will be matched + * exactly. The path may contain a '*' element which will match a + * single element of + * a path (so a handler registered for /a/ * will be invoked for + * /a/b and /a/c.html + * but not for /a/b/b) or the '**' element which will match any + * number of path + * elements. + * @param handler the handler to use for the uri. + */ + fun register(uri: Uri, handler: PathHandler?) { + synchronized(uriMatcher) { + uriMatcher.addURI(uri.scheme, uri.authority, uri.path, handler) + } + } + + /** + * Hosts the application's assets on an https:// URL. Assets from the local path + * `assetPath/...` will be available under + * `https://{uuid}.androidplatform.net/assets/...`. + * + * @param assetPath the local path in the application's asset folder which will + * be made + * available by the server (for example "/www"). + * @return prefixes under which the assets are hosted. + */ + fun hostAssets(assetPath: String?) { + this.isAsset = true + this.basePath = assetPath + createHostingDetails() + } + + /** + * Hosts the application's files on an https:// URL. Files from the basePath + * `basePath/...` will be available under + * `https://{uuid}.androidplatform.net/...`. + * + * @param basePath the local path in the application's data folder which will be + * made + * available by the server (for example "/www"). + * @return prefixes under which the assets are hosted. + */ + fun hostFiles(basePath: String?) { + this.isAsset = false + this.basePath = basePath + createHostingDetails() + } + + private fun createHostingDetails() { + val assetPath = this.basePath + + require(assetPath!!.indexOf('*') == -1) { "assetPath cannot contain the '*' character." } + + val handler: PathHandler = object : PathHandler() { + override fun handle(url: Uri): InputStream? { + var stream: InputStream? = null + var path = url.path + + // Pass path to routeProcessor if present + val routeProcessor = bridge.routeProcessor + var ignoreAssetPath = false + if (routeProcessor != null) { + val processedRoute = bridge.routeProcessor!!.process("", path) + path = processedRoute.path + isAsset = processedRoute!!.isAsset + ignoreAssetPath = processedRoute.isIgnoreAssetPath + } + + try { + if (path!!.startsWith(capacitorContentStart)) { + stream = protocolHandler.openContentUrl(url) + } else if (path.startsWith(capacitorFileStart)) { + stream = protocolHandler.openFile(path) + } else if (!isAsset) { + if (routeProcessor == null) { + path = basePath + url.path + } + + stream = protocolHandler.openFile(path) + } else if (ignoreAssetPath) { + stream = protocolHandler.openAsset(path) + } else { + stream = protocolHandler.openAsset(assetPath + path) + } + } catch (e: IOException) { + Logger.Companion.error("Unable to open asset URL: $url") + return null + } + + return stream + } + } + + for (authority in authorities) { + registerUriForScheme(Bridge.CAPACITOR_HTTP_SCHEME, handler, authority) + registerUriForScheme(Bridge.CAPACITOR_HTTPS_SCHEME, handler, authority) + + val customScheme = bridge.scheme + if (customScheme != Bridge.CAPACITOR_HTTP_SCHEME + && customScheme != Bridge.CAPACITOR_HTTPS_SCHEME + ) { + registerUriForScheme(customScheme, handler, authority) + } + } + } + + private fun registerUriForScheme(scheme: String, handler: PathHandler, authority: String) { + val uriBuilder = Uri.Builder() + uriBuilder.scheme(scheme) + uriBuilder.authority(authority) + uriBuilder.path("") + val uriPrefix = uriBuilder.build() + + register(Uri.withAppendedPath(uriPrefix, "/"), handler) + register(Uri.withAppendedPath(uriPrefix, "**"), handler) + } + + /** + * The KitKat WebView reads the InputStream on a separate threadpool. We can use + * that to + * parallelize loading. + */ + private abstract class LazyInputStream(protected val handler: PathHandler) : InputStream() { + private var `is`: InputStream? = null + + private val inputStream: InputStream? + get() { + if (`is` == null) { + `is` = handle() + } + return `is` + } + + protected abstract fun handle(): InputStream? + + @Throws(IOException::class) + override fun available(): Int { + val `is` = inputStream + return if ((`is` != null)) `is`.available() else -1 + } + + @Throws(IOException::class) + override fun read(): Int { + val `is` = inputStream + return if ((`is` != null)) `is`.read() else -1 + } + + @Throws(IOException::class) + override fun read(b: ByteArray): Int { + val `is` = inputStream + return if ((`is` != null)) `is`.read(b) else -1 + } + + @Throws(IOException::class) + override fun read(b: ByteArray, off: Int, len: Int): Int { + val `is` = inputStream + return if ((`is` != null)) `is`.read(b, off, len) else -1 + } + + @Throws(IOException::class) + override fun skip(n: Long): Long { + val `is` = inputStream + return if ((`is` != null)) `is`.skip(n) else 0 + } + } + + // For L and above. + private class LollipopLazyInputStream( + handler: PathHandler, + private val request: WebResourceRequest + ) : LazyInputStream(handler) { + private val `is`: InputStream? = null + + override fun handle(): InputStream? { + return handler.handle(request) + } + } + + companion object { + private const val capacitorFileStart = Bridge.CAPACITOR_FILE_START + private const val capacitorContentStart = Bridge.CAPACITOR_CONTENT_START + private fun parseAndVerifyUrl(url: String?): Uri? { + if (url == null) { + return null + } + val uri = Uri.parse(url) + if (uri == null) { + Logger.Companion.error("Malformed URL: $url") + return null + } + val path = uri.path + if (path == null || path.isEmpty()) { + Logger.Companion.error("URL does not have a path: $url") + return null + } + return uri + } + } +} diff --git a/@capacitor/android/capacitor/src/main/java/com/getcapacitor/plugin/CapacitorCookies.java b/@capacitor/android/capacitor/src/main/java/com/getcapacitor/plugin/CapacitorCookies.java index 45c01be2..ac07202a 100644 --- a/@capacitor/android/capacitor/src/main/java/com/getcapacitor/plugin/CapacitorCookies.java +++ b/@capacitor/android/capacitor/src/main/java/com/getcapacitor/plugin/CapacitorCookies.java @@ -35,12 +35,12 @@ protected void handleOnDestroy() { @JavascriptInterface public boolean isEnabled() { - PluginConfig pluginConfig = getBridge().getConfig().getPluginConfiguration("CapacitorCookies"); + PluginConfig pluginConfig = bridge.getConfig().getPluginConfiguration("CapacitorCookies"); return pluginConfig.getBoolean("enabled", false); } private boolean isAllowingInsecureCookies() { - PluginConfig pluginConfig = getBridge().getConfig().getPluginConfiguration("CapacitorCookies"); + PluginConfig pluginConfig = bridge.getConfig().getPluginConfiguration("CapacitorCookies"); return pluginConfig.getBoolean("androidCustomSchemeAllowInsecureAccess", false); } diff --git a/@capacitor/android/capacitor/src/main/java/com/getcapacitor/plugin/CapacitorHttp.java b/@capacitor/android/capacitor/src/main/java/com/getcapacitor/plugin/CapacitorHttp.java index 46bc1741..12cff717 100644 --- a/@capacitor/android/capacitor/src/main/java/com/getcapacitor/plugin/CapacitorHttp.java +++ b/@capacitor/android/capacitor/src/main/java/com/getcapacitor/plugin/CapacitorHttp.java @@ -41,17 +41,16 @@ protected void handleOnDestroy() { Runnable job = entry.getKey(); PluginCall call = entry.getValue(); - if (call.getData().has("activeCapacitorHttpUrlConnection")) { + if (call.data.has("activeCapacitorHttpUrlConnection")) { try { - CapacitorHttpUrlConnection connection = (CapacitorHttpUrlConnection) call - .getData() + CapacitorHttpUrlConnection connection = (CapacitorHttpUrlConnection) call.data .get("activeCapacitorHttpUrlConnection"); connection.disconnect(); - call.getData().remove("activeCapacitorHttpUrlConnection"); + call.data.remove("activeCapacitorHttpUrlConnection"); } catch (Exception ignored) {} } - getBridge().releaseCall(call); + bridge.releaseCall(call); } activeRequests.clear(); @@ -63,7 +62,7 @@ private void http(final PluginCall call, final String httpMethod) { @Override public void run() { try { - JSObject response = HttpRequestHandler.request(call, httpMethod, getBridge()); + JSObject response = HttpRequestHandler.request(call, httpMethod, bridge); call.resolve(response); } catch (Exception e) { call.reject(e.getLocalizedMessage(), e.getClass().getSimpleName(), e); @@ -83,7 +82,7 @@ public void run() { @JavascriptInterface public boolean isEnabled() { - PluginConfig pluginConfig = getBridge().getConfig().getPluginConfiguration("CapacitorHttp"); + PluginConfig pluginConfig = bridge.getConfig().getPluginConfiguration("CapacitorHttp"); return pluginConfig.getBoolean("enabled", false); } diff --git a/@capacitor/android/capacitor/src/main/java/com/getcapacitor/plugin/util/HttpRequestHandler.java b/@capacitor/android/capacitor/src/main/java/com/getcapacitor/plugin/util/HttpRequestHandler.java index 6e4bb747..c446b93a 100644 --- a/@capacitor/android/capacitor/src/main/java/com/getcapacitor/plugin/util/HttpRequestHandler.java +++ b/@capacitor/android/capacitor/src/main/java/com/getcapacitor/plugin/util/HttpRequestHandler.java @@ -418,19 +418,19 @@ public static JSObject request(PluginCall call, String httpMethod, Bridge bridge // Set HTTP body on a non GET or HEAD request if (isHttpMutate) { JSValue data = new JSValue(call, "data"); - if (data.getValue() != null) { + if (data.value != null) { connection.setDoOutput(true); connection.setRequestBody(call, data, dataType); } } - call.getData().put("activeCapacitorHttpUrlConnection", connection); + call.data.put("activeCapacitorHttpUrlConnection", connection); connection.connect(); JSObject response = buildResponse(connection, responseType); connection.disconnect(); - call.getData().remove("activeCapacitorHttpUrlConnection"); + call.data.remove("activeCapacitorHttpUrlConnection"); return response; } diff --git a/bun.lockb b/bun.lockb index fda8284c56a29f21e9d6ad78a13824d58c44f861..6679e602d245c1b42c7cfb6aedb5eda308f97575 100755 GIT binary patch delta 120381 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�Vqzo3XcL~{=lDqDfbkoYEl_SwA@M*!Gy;GsK( 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 delta 121591 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 futures = new ArrayList<>(); - List urls = urlArray.toList(); - - List results = new ArrayList<>(); - - for (JSONObject jsonObject : urls) { - JSObject urlObject = JSObject.fromJSONObject(jsonObject); - - String url = urlObject.getString("url"); - JSObject headers = urlObject.getJSObject("headers"); - - results.add(resolveUrl(url, headers)); - } - - call.resolve(new JSObject().put("results", new JSArray(results))); - } catch (JSONException e) { - call.reject("Error processing URLs array: " + e.getMessage()); - } - } - - private JSObject resolveUrl(String url, JSObject headers) { - JSObject result = new JSObject(); - - try { - URL obj = new URL(url); - HttpURLConnection connection = (HttpURLConnection) obj.openConnection(); - - // Set headers - if (headers != null) { - Iterator keys = headers.keys(); - while (keys.hasNext()) { - String key = keys.next(); - connection.setRequestProperty(key, headers.getString(key)); - } - } - - connection.setInstanceFollowRedirects(false); - connection.connect(); - - String locationHeader = connection.getHeaderField("Location"); - String resolvedUrl = (locationHeader != null) ? locationHeader : url; - - result.put("url", resolvedUrl); - } catch (IOException e) { - result.put("error", e.getMessage()); - } - - return result; - } -} diff --git a/src-capacitor/android/app/src/main/java/git/shin/animevsub/ResolvePlugin.kt b/src-capacitor/android/app/src/main/java/git/shin/animevsub/ResolvePlugin.kt new file mode 100644 index 00000000..fb456229 --- /dev/null +++ b/src-capacitor/android/app/src/main/java/git/shin/animevsub/ResolvePlugin.kt @@ -0,0 +1,105 @@ +package git.shin.animevsub + +import com.getcapacitor.JSArray +import com.getcapacitor.JSObject +import com.getcapacitor.Plugin +import com.getcapacitor.PluginCall +import com.getcapacitor.PluginMethod +import com.getcapacitor.annotation.CapacitorPlugin +import org.json.JSONException +import org.json.JSONObject +import java.io.IOException +import java.net.HttpURLConnection +import java.net.URL +import java.util.concurrent.CompletableFuture + +@CapacitorPlugin(name = "Resolve") +class ResolvePlugin : Plugin() { + @PluginMethod + fun echo(call: PluginCall) { + val value = call.getString("value") + + val ret = JSObject() + ret.put("value", value) + call.resolve(ret) + } + + @PluginMethod + fun resolve(call: PluginCall) { + val url = call.getString("url") + val headers = call.getObject("headers") + + if (url == null) { + call.reject("Must provide a URL") + return + } + + val ret = resolveUrl(url, headers) + if (ret.has("error")) { + call.reject(ret.getString("error")) + } else { + call.resolve(ret) + } + } + + @PluginMethod + fun resolveAll(call: PluginCall) { + val urlArray = call.getArray("urls") + + // JSArray headersArray = call.getArray("headers") ?? []; + if (urlArray == null) { + call.reject("Must provide an array of URLs") + return + } + + try { + val futures: List> = ArrayList() + val urls = urlArray.toList() + + val results: MutableList = ArrayList() + + for (jsonObject in urls) { + val urlObject = JSObject.fromJSONObject(jsonObject) + + val url = urlObject.getString("url") + val headers = urlObject.getJSObject("headers") + + results.add(resolveUrl(url, headers)) + } + + call.resolve(JSObject().put("results", JSArray(results))) + } catch (e: JSONException) { + call.reject("Error processing URLs array: " + e.message) + } + } + + private fun resolveUrl(url: String?, headers: JSObject?): JSObject { + val result = JSObject() + + try { + val obj = URL(url) + val connection = obj.openConnection() as HttpURLConnection + + // Set headers + if (headers != null) { + val keys = headers.keys() + while (keys.hasNext()) { + val key = keys.next() + connection.setRequestProperty(key, headers.getString(key)) + } + } + + connection.instanceFollowRedirects = false + connection.connect() + + val locationHeader = connection.getHeaderField("Location") + val resolvedUrl = if ((locationHeader != null)) locationHeader else url!! + + result.put("url", resolvedUrl) + } catch (e: IOException) { + result.put("error", e.message) + } + + return result + } +} diff --git a/src-capacitor/android/build.gradle b/src-capacitor/android/build.gradle index 37d63ae4..0e437570 100644 --- a/src-capacitor/android/build.gradle +++ b/src-capacitor/android/build.gradle @@ -1,7 +1,10 @@ // Top-level build file where you can add configuration options common to all sub-projects/modules. buildscript { - + + ext { + kotlin_version = '2.0.0' + } repositories { google() mavenCentral() @@ -9,6 +12,7 @@ buildscript { dependencies { classpath 'com.android.tools.build:gradle:8.0.0' classpath 'com.google.gms:google-services:4.3.15' + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" // NOTE: Do not place your application dependencies here; they belong // in the individual module build.gradle files diff --git a/src-capacitor/android/capacitor.settings.gradle b/src-capacitor/android/capacitor.settings.gradle index 0203f144..2b270dad 100644 --- a/src-capacitor/android/capacitor.settings.gradle +++ b/src-capacitor/android/capacitor.settings.gradle @@ -1,36 +1,36 @@ // DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN include ':capacitor-android' -project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor') +project(':capacitor-android').projectDir = new File('../../@capacitor/android/capacitor') include ':capacitor-community-firebase-analytics' -project(':capacitor-community-firebase-analytics').projectDir = new File('../node_modules/.pnpm/@capacitor-community+firebase-analytics@5.0.1_@capacitor+core@5.7.6/node_modules/@capacitor-community/firebase-analytics/android') +project(':capacitor-community-firebase-analytics').projectDir = new File('../node_modules/@capacitor-community/firebase-analytics/android') include ':capacitor-app' -project(':capacitor-app').projectDir = new File('../node_modules/.pnpm/@capacitor+app@5.0.8_@capacitor+core@5.7.6/node_modules/@capacitor/app/android') +project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/android') include ':capacitor-browser' -project(':capacitor-browser').projectDir = new File('../node_modules/.pnpm/@capacitor+browser@5.2.1_@capacitor+core@5.7.6/node_modules/@capacitor/browser/android') +project(':capacitor-browser').projectDir = new File('../node_modules/@capacitor/browser/android') include ':capacitor-device' -project(':capacitor-device').projectDir = new File('../node_modules/.pnpm/@capacitor+device@5.0.8_@capacitor+core@5.7.6/node_modules/@capacitor/device/android') +project(':capacitor-device').projectDir = new File('../node_modules/@capacitor/device/android') include ':capacitor-filesystem' -project(':capacitor-filesystem').projectDir = new File('../node_modules/.pnpm/@capacitor+filesystem@5.2.2_@capacitor+core@5.7.6/node_modules/@capacitor/filesystem/android') +project(':capacitor-filesystem').projectDir = new File('../node_modules/@capacitor/filesystem/android') include ':capacitor-haptics' -project(':capacitor-haptics').projectDir = new File('../node_modules/.pnpm/@capacitor+haptics@5.0.8_@capacitor+core@5.7.6/node_modules/@capacitor/haptics/android') +project(':capacitor-haptics').projectDir = new File('../node_modules/@capacitor/haptics/android') include ':capacitor-preferences' -project(':capacitor-preferences').projectDir = new File('../node_modules/.pnpm/@capacitor+preferences@5.0.8_@capacitor+core@5.7.6/node_modules/@capacitor/preferences/android') +project(':capacitor-preferences').projectDir = new File('../node_modules/@capacitor/preferences/android') include ':capacitor-share' -project(':capacitor-share').projectDir = new File('../node_modules/.pnpm/@capacitor+share@5.0.8_@capacitor+core@5.7.6/node_modules/@capacitor/share/android') +project(':capacitor-share').projectDir = new File('../node_modules/@capacitor/share/android') include ':capacitor-status-bar' -project(':capacitor-status-bar').projectDir = new File('../node_modules/.pnpm/@capacitor+status-bar@5.0.8_@capacitor+core@5.7.6/node_modules/@capacitor/status-bar/android') +project(':capacitor-status-bar').projectDir = new File('../node_modules/@capacitor/status-bar/android') include ':hugotomazi-capacitor-navigation-bar' -project(':hugotomazi-capacitor-navigation-bar').projectDir = new File('../node_modules/.pnpm/@hugotomazi+capacitor-navigation-bar@3.0.0_@capacitor+core@5.7.6/node_modules/@hugotomazi/capacitor-navigation-bar/android') +project(':hugotomazi-capacitor-navigation-bar').projectDir = new File('../node_modules/@hugotomazi/capacitor-navigation-bar/android') include ':jcesarmobile-ssl-skip' -project(':jcesarmobile-ssl-skip').projectDir = new File('../node_modules/.pnpm/@jcesarmobile+ssl-skip@0.4.0/node_modules/@jcesarmobile/ssl-skip/android') +project(':jcesarmobile-ssl-skip').projectDir = new File('../node_modules/@jcesarmobile/ssl-skip/android') diff --git a/src-capacitor/bun.lockb b/src-capacitor/bun.lockb index 3cfc0a14a35b13fca671e067a34bdde8bb13a3d7..4b4a8622cc3807365389ce029e562b8f5bead4ff 100755 GIT binary patch delta 3622 zcmbtWdr(x@8NX+j1upNWaM=(ajUp0Omldywa*ZWMG>A%k1Sjei5jFxYyP)6$7JRi; zN9@;eG74R_QA;!`U4^8MqtVGU8k5nEgJV-;tkGsW1tprALdE{h-M!+Z>0jNMz2E(P z@AIASocncsqWjEyzdD0fzzi0Vjao2Mq-s;iq?k1tAvTw(^pP zz@b8e;JSww9>5LC4W^-%JfW?i+#nQymvIst@)g!uYu1({!A>lL%a4Kb!1j{Lj4@JYyNV&G}|z#_dM?sok-v!d~qZ(x_LLz*sG+I-RgAe z{rtW}AAd!XFYD7})u%|EMj!VuC~vW9t3%D;UD~f7n{3BBuMBTSp6?t#22V)wbnA=t zQL~;-IQ`3~U8@uSKG!;ZV9g;~7BqvcqqZQ0-6e03BBtpC0cCUPsW((+Zn6a{?9bGO zXBc^d6+@w35T;OfaJHctZ+ZNU-Jmv6F~kH2!Ye8+q|^|_um@OCHSgmsiz=YGhrA&Q z>!s9Ch5dwVp^8|5{+oq(Ix-{PH{^vRol?UT(TR?p42glvVKO^TZQwasE76qOJUI5nL1oEQ4%#)=^uu zBHlvV@XQ#QLu66HroE=?DhC}Id2*Ow(~Ak-TJICX+> z2!H0eIiggLixSZDVQThBdc7c5WWqq?06wP`>6@svl z(iimWI*s(@ye(W&tXR3+=CBI_dCn|+EuI!F%J596xTj-(@g&>N>gX#+FbgEFBSpaP~}?asgQ z(rQ&Mo3Injkl;+a&bNQ=9F@B*W>Dg$_nngQ1mt{*__Tv+dlMu6sme1<=#`J@wo_u) zsUKQ*p@p;IN~=1MedAbM`(XTj8e1)8YBS5WCp||!e|qxSpj?}Bs;sXZyykSH081>$ zd>mSpHdIUFwW;Vr%iQn(kaMciFGsKZF!Lz369tb#7IbBOJl(1`ux4tkk)D4V|J!KE z1e2ME8`O;pG@Hy|cp7Vx85>RR)l#PRt=%){>bFIo?+!s4C<3?BdSTf2nNinr+$U6t ze*xXAC8IXD6rG=;lt#}wsmd)Tiy)NI@HG-^plmz~Y5tm0e7DT&EbIvNg400_>m+uL zKB|@2Wm0M_Ms3FGd?3cg>bGuzr}4g0-6FPLVk2l*-SZZbQJdz9Sj!jFAG__a;BA4$ zU8cx7DN~#3{;|Ah{>{nfav`7S^Qg^zM^~9omiH9@1`_|a+@ZJXB%?O>U0w9vmApsp zyE;~nqM5KQzASp^2($1HFnqnWhvZl~#2k6ynKH$=xW zATc9zGo`Qnv8ra6EId5g#=5qY?VyWm^NrdN8RA`-tnAy~H@J%br_%ZOiWQ*Vg=AZ= z?N%3ct}n$LGjv0#QJXBs$X^7W4C-HssOT`>D-UV^1`8XY`x_+RGt)PY3a*JWFZE>X zO{xvxdurwfQ=3P3jV;eQ4h9UFty@!wXOQlHOqX&k^ c_tA%&vPjyTVkoy&uAD^QZ8CXgZVnszA8k3G82|tP delta 3623 zcmbtWdr*|u760zS3Lg)7KR#A`HEPhhxUTqtXg)R6Xq76?#8)TiR(Wg$1QryGz+xon zRAa=Gacq#FMryWEM`j_3?L@0iTVp2HIH=8}BpM$x+X@!VB);wMe7m1GY5G@pX79Pb zbIv{I+;h*p-}&g2>B1?~HtUFK?fbW#+w;zu#ET>M9&1k>w%YUl{y8geM1Qw8GHqLN zVZvXU=Vv*W2uT_tN$#1TiJ(J4p8?$}Nm3MOm8-=0E6io^6M*kx9u0a0l=aVp4hB65 z8Vh;|l=WIcV?f=YEU#O#$d;FnPE6 zSj`T~9aY8SR~OV80jEPA2fhU~9<&=uyn*AO%%2GdyuQ#izM!V6((wRW=0IL{ttl&X zmK5{@hf58T`zj{vpcRxCOh+y`pe9gWAOmn3GhvW-SnH@>UxEO;unm?U0AfxDU6pp zTrLsd-%0lKSEcx)K0=P5F-n)k&us_g6034l>JjXvJwfTIWtekjR)BK6S)gM;@t0z~ zy+JF3qLcrb=a}_q-S6f;p8Ut(Jif4&R)owJwd4uW#1-;|XmXlKl8`oc5cP+vqLrMX zn)o|;FouyYR5RxVNzydx4b3)h#cU4G#6|MRnmIaHl4j^QkJ7?4^G;w**X+eCi%Oxn zlYC*CxJhZ@nixUOa81rd{mq3x6WQSJI{6?OM`;n7T!D(73W=F)5vn*y9`K@2-HVLq z8f)Za6y2;RXf+V~*YhVI1j^J=hpdWgloq3j3FO4M zjyy4%+=H^=$OPHKR5=NU_JVGyB1RSYf6I zY4S1f*ssuYA;=e}iMf;(uZd=I#%uC9l*`Wy+wv$>Eaw1ON0w?SEkP5<$(f+ZCKQlA zBDOu1eBhOU$NrG9C{=jLnW)JLxHUO`e^IKu^a-knRK42bu;HMVzH?$eFB(A>_fhlzhpW{5oDsK4*z4Vpa2>fvnUUnJsRU zN72M|@+q3!fV;h$Wu| zb(!qf2%(NpRemdgkO7M-p8}cyeOa&BB=QZ>IESg4+=<)Edp7Hpb_0mx*3U#9o8pf! zRL_SC$X_*lqJIS%$YwZhH(P->RK@c^963;&Dpv!sZ&*R(M}c@BChA94Zvj2Yf&9}U zk~G^dL)BLSS%C&|e&mBd92w3Ar~EJS4A%IZ$=tlgCtb;V90KJ4=o5Uq=YWXn}?Jf_de^iEw;dHHXj75JKf)xi^M$27FhC3j?^vioYa?d`qR+p<*q<$+T z{DnHvamF<|XLIa;q?HcB@*&8%arpEDs_9Q2{GBeJXhm_Hq{}WvoTvNHx&kd+2KTs% zz1d$JitiZkf0xp$lnkRy+5E8YfcMI#r0#)V5a1Fg~wuFZle{vNJov5C)RK>x?28ekUWk`?$)Bno5*rkhN^|YgD-?^-z%3KIzMS=aTRB#6SzvRgDXLbdF0$+oK`n|xSnKUuZgDB=ZcxMx&FuUfV$KZ;h$@yQdz#an`tjJQ zJ9h+53lVkE$p*XFN8e+-OXJ*%#b{Oc+AsW~t^KYVh#r}5rR8oV!)Q!vcNPA6OZ}X7 z$nntdQ)9HQix=*a9^JfmT3?yL)z(4>-FA^dmocVMLZclG>72$~i_vfnGkuev*Vo7O z$@PcF1rL9tA|mM1#&Pl@eA4i(a+lNNMkT{&PM03*Gw=GU_gSpLJ!CIOpfnhIi}Sj3 zU%K_lW!=A?_jbzNsE95q+Gxkvvav<%CH2+Dr(cGr=JA77KCJRh{&XLvs7*8G`{yei z<@)4)L+gV*n>)3O_}1_f|IxFh8@IfiIOQrZch(D*pJ;7p`E1k=<(C3BkJ4{9Wd&KS W=@i{Gl!i1#)2eq