diff --git a/capacitor.settings.gradle b/capacitor.settings.gradle index 9a5fa872..cfaa9ea3 100644 --- a/capacitor.settings.gradle +++ b/capacitor.settings.gradle @@ -1,3 +1,3 @@ // 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') diff --git a/capacitor/build.gradle b/capacitor/build.gradle new file mode 100644 index 00000000..1039f19b --- /dev/null +++ b/capacitor/build.gradle @@ -0,0 +1,96 @@ +ext { + androidxActivityVersion = project.hasProperty('androidxActivityVersion') ? rootProject.ext.androidxActivityVersion : '1.8.0' + androidxAppCompatVersion = project.hasProperty('androidxAppCompatVersion') ? rootProject.ext.androidxAppCompatVersion : '1.6.1' + androidxCoordinatorLayoutVersion = project.hasProperty('androidxCoordinatorLayoutVersion') ? rootProject.ext.androidxCoordinatorLayoutVersion : '1.2.0' + androidxCoreVersion = project.hasProperty('androidxCoreVersion') ? rootProject.ext.androidxCoreVersion : '1.12.0' + androidxFragmentVersion = project.hasProperty('androidxFragmentVersion') ? rootProject.ext.androidxFragmentVersion : '1.6.2' + androidxWebkitVersion = project.hasProperty('androidxWebkitVersion') ? rootProject.ext.androidxWebkitVersion : '1.9.0' + junitVersion = project.hasProperty('junitVersion') ? rootProject.ext.junitVersion : '4.13.2' + androidxJunitVersion = project.hasProperty('androidxJunitVersion') ? rootProject.ext.androidxJunitVersion : '1.1.5' + androidxEspressoCoreVersion = project.hasProperty('androidxEspressoCoreVersion') ? rootProject.ext.androidxEspressoCoreVersion : '3.5.1' + cordovaAndroidVersion = project.hasProperty('cordovaAndroidVersion') ? rootProject.ext.cordovaAndroidVersion : '10.1.1' +} + + +buildscript { + ext.kotlin_version = project.hasProperty("kotlin_version") ? rootProject.ext.kotlin_version : '1.9.10' + repositories { + google() + mavenCentral() + maven { + url "https://plugins.gradle.org/m2/" + } + } + dependencies { + classpath 'com.android.tools.build:gradle:8.2.1' + + if (System.getenv("CAP_PUBLISH") == "true") { + classpath 'io.github.gradle-nexus:publish-plugin:1.3.0' + } + } +} + +tasks.withType(Javadoc).all { enabled = false } + +apply plugin: 'com.android.library' + +if (System.getenv("CAP_PUBLISH") == "true") { + apply plugin: 'io.github.gradle-nexus.publish-plugin' + apply from: file('../scripts/publish-root.gradle') + apply from: file('../scripts/publish-module.gradle') +} + +android { + namespace "com.getcapacitor.android" + compileSdk project.hasProperty('compileSdkVersion') ? rootProject.ext.compileSdkVersion : 34 + defaultConfig { + minSdkVersion project.hasProperty('minSdkVersion') ? rootProject.ext.minSdkVersion : 22 + targetSdkVersion project.hasProperty('targetSdkVersion') ? rootProject.ext.targetSdkVersion : 34 + versionCode 1 + versionName "1.0" + consumerProguardFiles 'proguard-rules.pro' + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + } + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + } + } + lintOptions { + baseline file("lint-baseline.xml") + abortOnError true + warningsAsErrors true + lintConfig file('lint.xml') + } + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } + publishing { + singleVariant("release") + } +} + +repositories { + google() + mavenCentral() +} + +dependencies { + implementation fileTree(dir: 'libs', include: ['*.jar']) + implementation platform("org.jetbrains.kotlin:kotlin-bom:$kotlin_version") + implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion" + implementation "androidx.core:core:$androidxCoreVersion" + implementation "androidx.activity:activity:$androidxActivityVersion" + implementation "androidx.fragment:fragment:$androidxFragmentVersion" + implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion" + implementation "androidx.webkit:webkit:$androidxWebkitVersion" + testImplementation "junit:junit:$junitVersion" + androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion" + androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion" + implementation "org.apache.cordova:framework:$cordovaAndroidVersion" + testImplementation 'org.json:json:20231013' + testImplementation 'org.mockito:mockito-inline:5.2.0' +} + diff --git a/capacitor/lint-baseline.xml b/capacitor/lint-baseline.xml new file mode 100644 index 00000000..c1ed9ccb --- /dev/null +++ b/capacitor/lint-baseline.xml @@ -0,0 +1,136 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/capacitor/lint.xml b/capacitor/lint.xml new file mode 100644 index 00000000..b00604ba --- /dev/null +++ b/capacitor/lint.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/capacitor/proguard-rules.pro b/capacitor/proguard-rules.pro new file mode 100644 index 00000000..96db065b --- /dev/null +++ b/capacitor/proguard-rules.pro @@ -0,0 +1,28 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# Rules for Capacitor v3 plugins and annotations + -keep @com.getcapacitor.annotation.CapacitorPlugin public class * { + @com.getcapacitor.annotation.PermissionCallback ; + @com.getcapacitor.annotation.ActivityCallback ; + @com.getcapacitor.annotation.Permission ; + @com.getcapacitor.PluginMethod public ; + } + + -keep public class * extends com.getcapacitor.Plugin { *; } + +# Rules for Capacitor v2 plugins and annotations +# These are deprecated but can still be used with Capacitor for now +-keep @com.getcapacitor.NativePlugin public class * { + @com.getcapacitor.PluginMethod public ; +} + +# Rules for Cordova plugins +-keep public class * extends org.apache.cordova.* { + public ; + public ; +} \ No newline at end of file diff --git a/capacitor/src/main/AndroidManifest.xml b/capacitor/src/main/AndroidManifest.xml new file mode 100644 index 00000000..74b7379f --- /dev/null +++ b/capacitor/src/main/AndroidManifest.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/capacitor/src/main/assets/native-bridge.js b/capacitor/src/main/assets/native-bridge.js new file mode 100644 index 00000000..17db853d --- /dev/null +++ b/capacitor/src/main/assets/native-bridge.js @@ -0,0 +1,1025 @@ + +/*! Capacitor: https://capacitorjs.com/ - MIT License */ +/* Generated File. Do not edit. */ + +var nativeBridge = (function (exports) { + 'use strict'; + + var ExceptionCode; + (function (ExceptionCode) { + /** + * API is not implemented. + * + * This usually means the API can't be used because it is not implemented for + * the current platform. + */ + ExceptionCode["Unimplemented"] = "UNIMPLEMENTED"; + /** + * API is not available. + * + * This means the API can't be used right now because: + * - it is currently missing a prerequisite, such as network connectivity + * - it requires a particular platform or browser version + */ + ExceptionCode["Unavailable"] = "UNAVAILABLE"; + })(ExceptionCode || (ExceptionCode = {})); + class CapacitorException extends Error { + constructor(message, code, data) { + super(message); + this.message = message; + this.code = code; + this.data = data; + } + } + + // For removing exports for iOS/Android, keep let for reassignment + // eslint-disable-next-line + let dummy = {}; + const readFileAsBase64 = (file) => new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onloadend = () => { + const data = reader.result; + resolve(btoa(data)); + }; + reader.onerror = reject; + reader.readAsBinaryString(file); + }); + const convertFormData = async (formData) => { + const newFormData = []; + for (const pair of formData.entries()) { + const [key, value] = pair; + if (value instanceof File) { + const base64File = await readFileAsBase64(value); + newFormData.push({ + key, + value: base64File, + type: 'base64File', + contentType: value.type, + fileName: value.name, + }); + } + else { + newFormData.push({ key, value, type: 'string' }); + } + } + return newFormData; + }; + const convertBody = async (body, contentType) => { + if (body instanceof ReadableStream || body instanceof Uint8Array) { + let encodedData; + if (body instanceof ReadableStream) { + const reader = body.getReader(); + const chunks = []; + while (true) { + const { done, value } = await reader.read(); + if (done) + break; + chunks.push(value); + } + const concatenated = new Uint8Array(chunks.reduce((acc, chunk) => acc + chunk.length, 0)); + let position = 0; + for (const chunk of chunks) { + concatenated.set(chunk, position); + position += chunk.length; + } + encodedData = concatenated; + } + else { + encodedData = body; + } + let data = new TextDecoder().decode(encodedData); + let type; + if (contentType === 'application/json') { + try { + data = JSON.parse(data); + } + catch (ignored) { + // ignore + } + type = 'json'; + } + else if (contentType === 'multipart/form-data') { + type = 'formData'; + } + else if (contentType === null || contentType === void 0 ? void 0 : contentType.startsWith('image')) { + type = 'image'; + } + else if (contentType === 'application/octet-stream') { + type = 'binary'; + } + else { + type = 'text'; + } + return { + data, + type, + headers: { 'Content-Type': contentType || 'application/octet-stream' }, + }; + } + else if (body instanceof URLSearchParams) { + return { + data: body.toString(), + type: 'text', + }; + } + else if (body instanceof FormData) { + const formData = await convertFormData(body); + return { + data: formData, + type: 'formData', + }; + } + else if (body instanceof File) { + const fileData = await readFileAsBase64(body); + return { + data: fileData, + type: 'file', + headers: { 'Content-Type': body.type }, + }; + } + return { data: body, type: 'json' }; + }; + const CAPACITOR_HTTP_INTERCEPTOR = '/_capacitor_http_interceptor_'; + const CAPACITOR_HTTP_INTERCEPTOR_URL_PARAM = 'u'; + // TODO: export as Cap function + const isRelativeOrProxyUrl = (url) => !url || + !(url.startsWith('http:') || url.startsWith('https:')) || + url.indexOf(CAPACITOR_HTTP_INTERCEPTOR) > -1; + // TODO: export as Cap function + const createProxyUrl = (url, win) => { + var _a, _b; + if (isRelativeOrProxyUrl(url)) + return url; + const bridgeUrl = new URL((_b = (_a = win.Capacitor) === null || _a === void 0 ? void 0 : _a.getServerUrl()) !== null && _b !== void 0 ? _b : ''); + bridgeUrl.pathname = CAPACITOR_HTTP_INTERCEPTOR; + bridgeUrl.searchParams.append(CAPACITOR_HTTP_INTERCEPTOR_URL_PARAM, url); + return bridgeUrl.toString(); + }; + const initBridge = (w) => { + const getPlatformId = (win) => { + var _a, _b; + if (win === null || win === void 0 ? void 0 : win.androidBridge) { + return 'android'; + } + else if ((_b = (_a = win === null || win === void 0 ? void 0 : win.webkit) === null || _a === void 0 ? void 0 : _a.messageHandlers) === null || _b === void 0 ? void 0 : _b.bridge) { + return 'ios'; + } + else { + return 'web'; + } + }; + const convertFileSrcServerUrl = (webviewServerUrl, filePath) => { + if (typeof filePath === 'string') { + if (filePath.startsWith('/')) { + return webviewServerUrl + '/_capacitor_file_' + filePath; + } + else if (filePath.startsWith('file://')) { + return (webviewServerUrl + filePath.replace('file://', '/_capacitor_file_')); + } + else if (filePath.startsWith('content://')) { + return (webviewServerUrl + + filePath.replace('content:/', '/_capacitor_content_')); + } + } + return filePath; + }; + const initEvents = (win, cap) => { + cap.addListener = (pluginName, eventName, callback) => { + const callbackId = cap.nativeCallback(pluginName, 'addListener', { + eventName: eventName, + }, callback); + return { + remove: async () => { + var _a; + (_a = win === null || win === void 0 ? void 0 : win.console) === null || _a === void 0 ? void 0 : _a.debug('Removing listener', pluginName, eventName); + cap.removeListener(pluginName, callbackId, eventName, callback); + }, + }; + }; + cap.removeListener = (pluginName, callbackId, eventName, callback) => { + cap.nativeCallback(pluginName, 'removeListener', { + callbackId: callbackId, + eventName: eventName, + }, callback); + }; + cap.createEvent = (eventName, eventData) => { + const doc = win.document; + if (doc) { + const ev = doc.createEvent('Events'); + ev.initEvent(eventName, false, false); + if (eventData && typeof eventData === 'object') { + for (const i in eventData) { + // eslint-disable-next-line no-prototype-builtins + if (eventData.hasOwnProperty(i)) { + ev[i] = eventData[i]; + } + } + } + return ev; + } + return null; + }; + cap.triggerEvent = (eventName, target, eventData) => { + const doc = win.document; + const cordova = win.cordova; + eventData = eventData || {}; + const ev = cap.createEvent(eventName, eventData); + if (ev) { + if (target === 'document') { + if (cordova === null || cordova === void 0 ? void 0 : cordova.fireDocumentEvent) { + cordova.fireDocumentEvent(eventName, eventData); + return true; + } + else if (doc === null || doc === void 0 ? void 0 : doc.dispatchEvent) { + return doc.dispatchEvent(ev); + } + } + else if (target === 'window' && win.dispatchEvent) { + return win.dispatchEvent(ev); + } + else if (doc === null || doc === void 0 ? void 0 : doc.querySelector) { + const targetEl = doc.querySelector(target); + if (targetEl) { + return targetEl.dispatchEvent(ev); + } + } + } + return false; + }; + win.Capacitor = cap; + }; + const initLegacyHandlers = (win, cap) => { + // define cordova if it's not there already + win.cordova = win.cordova || {}; + const doc = win.document; + const nav = win.navigator; + if (nav) { + nav.app = nav.app || {}; + nav.app.exitApp = () => { + var _a; + if (!((_a = cap.Plugins) === null || _a === void 0 ? void 0 : _a.App)) { + win.console.warn('App plugin not installed'); + } + else { + cap.nativeCallback('App', 'exitApp', {}); + } + }; + } + if (doc) { + const docAddEventListener = doc.addEventListener; + doc.addEventListener = (...args) => { + var _a; + const eventName = args[0]; + const handler = args[1]; + if (eventName === 'deviceready' && handler) { + Promise.resolve().then(handler); + } + else if (eventName === 'backbutton' && cap.Plugins.App) { + // Add a dummy listener so Capacitor doesn't do the default + // back button action + if (!((_a = cap.Plugins) === null || _a === void 0 ? void 0 : _a.App)) { + win.console.warn('App plugin not installed'); + } + else { + cap.Plugins.App.addListener('backButton', () => { + // ignore + }); + } + } + return docAddEventListener.apply(doc, args); + }; + } + // deprecated in v3, remove from v4 + cap.platform = cap.getPlatform(); + cap.isNative = cap.isNativePlatform(); + win.Capacitor = cap; + }; + const initVendor = (win, cap) => { + const Ionic = (win.Ionic = win.Ionic || {}); + const IonicWebView = (Ionic.WebView = Ionic.WebView || {}); + const Plugins = cap.Plugins; + IonicWebView.getServerBasePath = (callback) => { + var _a; + (_a = Plugins === null || Plugins === void 0 ? void 0 : Plugins.WebView) === null || _a === void 0 ? void 0 : _a.getServerBasePath().then((result) => { + callback(result.path); + }); + }; + IonicWebView.setServerAssetPath = (path) => { + var _a; + (_a = Plugins === null || Plugins === void 0 ? void 0 : Plugins.WebView) === null || _a === void 0 ? void 0 : _a.setServerAssetPath({ path }); + }; + IonicWebView.setServerBasePath = (path) => { + var _a; + (_a = Plugins === null || Plugins === void 0 ? void 0 : Plugins.WebView) === null || _a === void 0 ? void 0 : _a.setServerBasePath({ path }); + }; + IonicWebView.persistServerBasePath = () => { + var _a; + (_a = Plugins === null || Plugins === void 0 ? void 0 : Plugins.WebView) === null || _a === void 0 ? void 0 : _a.persistServerBasePath(); + }; + IonicWebView.convertFileSrc = (url) => cap.convertFileSrc(url); + win.Capacitor = cap; + win.Ionic.WebView = IonicWebView; + }; + const initLogger = (win, cap) => { + const BRIDGED_CONSOLE_METHODS = [ + 'debug', + 'error', + 'info', + 'log', + 'trace', + 'warn', + ]; + const createLogFromNative = (c) => (result) => { + if (isFullConsole(c)) { + const success = result.success === true; + const tagStyles = success + ? 'font-style: italic; font-weight: lighter; color: gray' + : 'font-style: italic; font-weight: lighter; color: red'; + c.groupCollapsed('%cresult %c' + + result.pluginId + + '.' + + result.methodName + + ' (#' + + result.callbackId + + ')', tagStyles, 'font-style: italic; font-weight: bold; color: #444'); + if (result.success === false) { + c.error(result.error); + } + else { + c.dir(result.data); + } + c.groupEnd(); + } + else { + if (result.success === false) { + c.error('LOG FROM NATIVE', result.error); + } + else { + c.log('LOG FROM NATIVE', result.data); + } + } + }; + const createLogToNative = (c) => (call) => { + if (isFullConsole(c)) { + c.groupCollapsed('%cnative %c' + + call.pluginId + + '.' + + call.methodName + + ' (#' + + call.callbackId + + ')', 'font-weight: lighter; color: gray', 'font-weight: bold; color: #000'); + c.dir(call); + c.groupEnd(); + } + else { + c.log('LOG TO NATIVE: ', call); + } + }; + const isFullConsole = (c) => { + if (!c) { + return false; + } + return (typeof c.groupCollapsed === 'function' || + typeof c.groupEnd === 'function' || + typeof c.dir === 'function'); + }; + const serializeConsoleMessage = (msg) => { + try { + if (typeof msg === 'object') { + msg = JSON.stringify(msg); + } + return String(msg); + } + catch (e) { + return ''; + } + }; + const platform = getPlatformId(win); + if (platform == 'android' || platform == 'ios') { + // patch document.cookie on Android/iOS + win.CapacitorCookiesDescriptor = + Object.getOwnPropertyDescriptor(Document.prototype, 'cookie') || + Object.getOwnPropertyDescriptor(HTMLDocument.prototype, 'cookie'); + let doPatchCookies = false; + // check if capacitor cookies is disabled before patching + if (platform === 'ios') { + // Use prompt to synchronously get capacitor cookies config. + // https://stackoverflow.com/questions/29249132/wkwebview-complex-communication-between-javascript-native-code/49474323#49474323 + const payload = { + type: 'CapacitorCookies.isEnabled', + }; + const isCookiesEnabled = prompt(JSON.stringify(payload)); + if (isCookiesEnabled === 'true') { + doPatchCookies = true; + } + } + else if (typeof win.CapacitorCookiesAndroidInterface !== 'undefined') { + const isCookiesEnabled = win.CapacitorCookiesAndroidInterface.isEnabled(); + if (isCookiesEnabled === true) { + doPatchCookies = true; + } + } + if (doPatchCookies) { + Object.defineProperty(document, 'cookie', { + get: function () { + var _a, _b, _c; + if (platform === 'ios') { + // Use prompt to synchronously get cookies. + // https://stackoverflow.com/questions/29249132/wkwebview-complex-communication-between-javascript-native-code/49474323#49474323 + const payload = { + type: 'CapacitorCookies.get', + }; + const res = prompt(JSON.stringify(payload)); + return res; + } + else if (typeof win.CapacitorCookiesAndroidInterface !== 'undefined') { + // return original document.cookie since Android does not support filtering of `httpOnly` cookies + return (_c = (_b = (_a = win.CapacitorCookiesDescriptor) === null || _a === void 0 ? void 0 : _a.get) === null || _b === void 0 ? void 0 : _b.call(document)) !== null && _c !== void 0 ? _c : ''; + } + }, + set: function (val) { + const cookiePairs = val.split(';'); + const domainSection = val.toLowerCase().split('domain=')[1]; + const domain = cookiePairs.length > 1 && + domainSection != null && + domainSection.length > 0 + ? domainSection.split(';')[0].trim() + : ''; + if (platform === 'ios') { + // Use prompt to synchronously set cookies. + // https://stackoverflow.com/questions/29249132/wkwebview-complex-communication-between-javascript-native-code/49474323#49474323 + const payload = { + type: 'CapacitorCookies.set', + action: val, + domain, + }; + prompt(JSON.stringify(payload)); + } + else if (typeof win.CapacitorCookiesAndroidInterface !== 'undefined') { + win.CapacitorCookiesAndroidInterface.setCookie(domain, val); + } + }, + }); + } + // patch fetch / XHR on Android/iOS + // store original fetch & XHR functions + win.CapacitorWebFetch = window.fetch; + win.CapacitorWebXMLHttpRequest = { + abort: window.XMLHttpRequest.prototype.abort, + constructor: window.XMLHttpRequest.prototype.constructor, + fullObject: window.XMLHttpRequest, + getAllResponseHeaders: window.XMLHttpRequest.prototype.getAllResponseHeaders, + getResponseHeader: window.XMLHttpRequest.prototype.getResponseHeader, + open: window.XMLHttpRequest.prototype.open, + prototype: window.XMLHttpRequest.prototype, + send: window.XMLHttpRequest.prototype.send, + setRequestHeader: window.XMLHttpRequest.prototype.setRequestHeader, + }; + let doPatchHttp = false; + // check if capacitor http is disabled before patching + if (platform === 'ios') { + // Use prompt to synchronously get capacitor http config. + // https://stackoverflow.com/questions/29249132/wkwebview-complex-communication-between-javascript-native-code/49474323#49474323 + const payload = { + type: 'CapacitorHttp', + }; + const isHttpEnabled = prompt(JSON.stringify(payload)); + if (isHttpEnabled === 'true') { + doPatchHttp = true; + } + } + else if (typeof win.CapacitorHttpAndroidInterface !== 'undefined') { + const isHttpEnabled = win.CapacitorHttpAndroidInterface.isEnabled(); + if (isHttpEnabled === true) { + doPatchHttp = true; + } + } + if (doPatchHttp) { + // fetch patch + window.fetch = async (resource, options) => { + const request = new Request(resource, options); + if (request.url.startsWith(`${cap.getServerUrl()}/`)) { + return win.CapacitorWebFetch(resource, options); + } + const { method } = request; + if (method.toLocaleUpperCase() === 'GET' || + method.toLocaleUpperCase() === 'HEAD' || + method.toLocaleUpperCase() === 'OPTIONS' || + method.toLocaleUpperCase() === 'TRACE') { + if (typeof resource === 'string') { + return await win.CapacitorWebFetch(createProxyUrl(resource, win), options); + } + else if (resource instanceof Request) { + const modifiedRequest = new Request(createProxyUrl(resource.url, win), resource); + return await win.CapacitorWebFetch(modifiedRequest, options); + } + } + const tag = `CapacitorHttp fetch ${Date.now()} ${resource}`; + console.time(tag); + try { + const { body } = request; + const optionHeaders = Object.fromEntries(request.headers.entries()); + const { data: requestData, type, headers, } = await convertBody((options === null || options === void 0 ? void 0 : options.body) || body || undefined, optionHeaders['Content-Type'] || optionHeaders['content-type']); + const nativeResponse = await cap.nativePromise('CapacitorHttp', 'request', { + url: request.url, + method: method, + data: requestData, + dataType: type, + headers: Object.assign(Object.assign({}, headers), optionHeaders), + }); + const contentType = nativeResponse.headers['Content-Type'] || + nativeResponse.headers['content-type']; + let data = (contentType === null || contentType === void 0 ? void 0 : contentType.startsWith('application/json')) + ? JSON.stringify(nativeResponse.data) + : nativeResponse.data; + // use null data for 204 No Content HTTP response + if (nativeResponse.status === 204) { + data = null; + } + // intercept & parse response before returning + const response = new Response(data, { + headers: nativeResponse.headers, + status: nativeResponse.status, + }); + /* + * copy url to response, `cordova-plugin-ionic` uses this url from the response + * we need `Object.defineProperty` because url is an inherited getter on the Response + * see: https://stackoverflow.com/a/57382543 + * */ + Object.defineProperty(response, 'url', { + value: nativeResponse.url, + }); + console.timeEnd(tag); + return response; + } + catch (error) { + console.timeEnd(tag); + return Promise.reject(error); + } + }; + window.XMLHttpRequest = function () { + const xhr = new win.CapacitorWebXMLHttpRequest.constructor(); + Object.defineProperties(xhr, { + _headers: { + value: {}, + writable: true, + }, + _method: { + value: xhr.method, + writable: true, + }, + }); + const prototype = win.CapacitorWebXMLHttpRequest.prototype; + const isProgressEventAvailable = () => typeof ProgressEvent !== 'undefined' && + ProgressEvent.prototype instanceof Event; + // XHR patch abort + prototype.abort = function () { + if (isRelativeOrProxyUrl(this._url)) { + return win.CapacitorWebXMLHttpRequest.abort.call(this); + } + this.readyState = 0; + setTimeout(() => { + this.dispatchEvent(new Event('abort')); + this.dispatchEvent(new Event('loadend')); + }); + }; + // XHR patch open + prototype.open = function (method, url) { + this._method = method.toLocaleUpperCase(); + this._url = url; + if (!this._method || + this._method === 'GET' || + this._method === 'HEAD' || + this._method === 'OPTIONS' || + this._method === 'TRACE') { + if (isRelativeOrProxyUrl(url)) { + return win.CapacitorWebXMLHttpRequest.open.call(this, method, url); + } + this._url = createProxyUrl(this._url, win); + return win.CapacitorWebXMLHttpRequest.open.call(this, method, this._url); + } + Object.defineProperties(this, { + readyState: { + get: function () { + var _a; + return (_a = this._readyState) !== null && _a !== void 0 ? _a : 0; + }, + set: function (val) { + this._readyState = val; + setTimeout(() => { + this.dispatchEvent(new Event('readystatechange')); + }); + }, + }, + }); + setTimeout(() => { + this.dispatchEvent(new Event('loadstart')); + }); + this.readyState = 1; + }; + // XHR patch set request header + prototype.setRequestHeader = function (header, value) { + if (isRelativeOrProxyUrl(this._url)) { + return win.CapacitorWebXMLHttpRequest.setRequestHeader.call(this, header, value); + } + this._headers[header] = value; + }; + // XHR patch send + prototype.send = function (body) { + if (isRelativeOrProxyUrl(this._url)) { + return win.CapacitorWebXMLHttpRequest.send.call(this, body); + } + const tag = `CapacitorHttp XMLHttpRequest ${Date.now()} ${this._url}`; + console.time(tag); + try { + this.readyState = 2; + Object.defineProperties(this, { + response: { + value: '', + writable: true, + }, + responseText: { + value: '', + writable: true, + }, + responseURL: { + value: '', + writable: true, + }, + status: { + value: 0, + writable: true, + }, + }); + convertBody(body).then(({ data, type, headers }) => { + const otherHeaders = this._headers != null && Object.keys(this._headers).length > 0 + ? this._headers + : undefined; + // intercept request & pass to the bridge + cap + .nativePromise('CapacitorHttp', 'request', { + url: this._url, + method: this._method, + data: data !== null ? data : undefined, + headers: Object.assign(Object.assign({}, headers), otherHeaders), + dataType: type, + }) + .then((nativeResponse) => { + var _a; + // intercept & parse response before returning + if (this.readyState == 2) { + //TODO: Add progress event emission on native side + if (isProgressEventAvailable()) { + this.dispatchEvent(new ProgressEvent('progress', { + lengthComputable: true, + loaded: nativeResponse.data.length, + total: nativeResponse.data.length, + })); + } + this._headers = nativeResponse.headers; + this.status = nativeResponse.status; + if (this.responseType === '' || + this.responseType === 'text') { + this.response = + typeof nativeResponse.data !== 'string' + ? JSON.stringify(nativeResponse.data) + : nativeResponse.data; + } + else { + this.response = nativeResponse.data; + } + this.responseText = ((_a = (nativeResponse.headers['Content-Type'] || + nativeResponse.headers['content-type'])) === null || _a === void 0 ? void 0 : _a.startsWith('application/json')) + ? JSON.stringify(nativeResponse.data) + : nativeResponse.data; + this.responseURL = nativeResponse.url; + this.readyState = 4; + setTimeout(() => { + this.dispatchEvent(new Event('load')); + this.dispatchEvent(new Event('loadend')); + }); + } + console.timeEnd(tag); + }) + .catch((error) => { + this.status = error.status; + this._headers = error.headers; + this.response = error.data; + this.responseText = JSON.stringify(error.data); + this.responseURL = error.url; + this.readyState = 4; + if (isProgressEventAvailable()) { + this.dispatchEvent(new ProgressEvent('progress', { + lengthComputable: false, + loaded: 0, + total: 0, + })); + } + setTimeout(() => { + this.dispatchEvent(new Event('error')); + this.dispatchEvent(new Event('loadend')); + }); + console.timeEnd(tag); + }); + }); + } + catch (error) { + this.status = 500; + this._headers = {}; + this.response = error; + this.responseText = error.toString(); + this.responseURL = this._url; + this.readyState = 4; + if (isProgressEventAvailable()) { + this.dispatchEvent(new ProgressEvent('progress', { + lengthComputable: false, + loaded: 0, + total: 0, + })); + } + setTimeout(() => { + this.dispatchEvent(new Event('error')); + this.dispatchEvent(new Event('loadend')); + }); + console.timeEnd(tag); + } + }; + // XHR patch getAllResponseHeaders + prototype.getAllResponseHeaders = function () { + if (isRelativeOrProxyUrl(this._url)) { + return win.CapacitorWebXMLHttpRequest.getAllResponseHeaders.call(this); + } + let returnString = ''; + for (const key in this._headers) { + if (key != 'Set-Cookie') { + returnString += key + ': ' + this._headers[key] + '\r\n'; + } + } + return returnString; + }; + // XHR patch getResponseHeader + prototype.getResponseHeader = function (name) { + if (isRelativeOrProxyUrl(this._url)) { + return win.CapacitorWebXMLHttpRequest.getResponseHeader.call(this, name); + } + return this._headers[name]; + }; + Object.setPrototypeOf(xhr, prototype); + return xhr; + }; + Object.assign(window.XMLHttpRequest, win.CapacitorWebXMLHttpRequest.fullObject); + } + } + // patch window.console on iOS and store original console fns + const isIos = getPlatformId(win) === 'ios'; + if (win.console && isIos) { + Object.defineProperties(win.console, BRIDGED_CONSOLE_METHODS.reduce((props, method) => { + const consoleMethod = win.console[method].bind(win.console); + props[method] = { + value: (...args) => { + const msgs = [...args]; + cap.toNative('Console', 'log', { + level: method, + message: msgs.map(serializeConsoleMessage).join(' '), + }); + return consoleMethod(...args); + }, + }; + return props; + }, {})); + } + cap.logJs = (msg, level) => { + switch (level) { + case 'error': + win.console.error(msg); + break; + case 'warn': + win.console.warn(msg); + break; + case 'info': + win.console.info(msg); + break; + default: + win.console.log(msg); + } + }; + cap.logToNative = createLogToNative(win.console); + cap.logFromNative = createLogFromNative(win.console); + cap.handleError = err => win.console.error(err); + win.Capacitor = cap; + }; + function initNativeBridge(win) { + const cap = win.Capacitor || {}; + // keep a collection of callbacks for native response data + const callbacks = new Map(); + const webviewServerUrl = typeof win.WEBVIEW_SERVER_URL === 'string' ? win.WEBVIEW_SERVER_URL : ''; + cap.getServerUrl = () => webviewServerUrl; + cap.convertFileSrc = filePath => convertFileSrcServerUrl(webviewServerUrl, filePath); + // Counter of callback ids, randomized to avoid + // any issues during reloads if a call comes back with + // an existing callback id from an old session + let callbackIdCount = Math.floor(Math.random() * 134217728); + let postToNative = null; + const isNativePlatform = () => true; + const getPlatform = () => getPlatformId(win); + cap.getPlatform = getPlatform; + cap.isPluginAvailable = name => Object.prototype.hasOwnProperty.call(cap.Plugins, name); + cap.isNativePlatform = isNativePlatform; + // create the postToNative() fn if needed + if (getPlatformId(win) === 'android') { + // android platform + postToNative = data => { + var _a; + try { + win.androidBridge.postMessage(JSON.stringify(data)); + } + catch (e) { + (_a = win === null || win === void 0 ? void 0 : win.console) === null || _a === void 0 ? void 0 : _a.error(e); + } + }; + } + else if (getPlatformId(win) === 'ios') { + // ios platform + postToNative = data => { + var _a; + try { + data.type = data.type ? data.type : 'message'; + win.webkit.messageHandlers.bridge.postMessage(data); + } + catch (e) { + (_a = win === null || win === void 0 ? void 0 : win.console) === null || _a === void 0 ? void 0 : _a.error(e); + } + }; + } + cap.handleWindowError = (msg, url, lineNo, columnNo, err) => { + const str = msg.toLowerCase(); + if (str.indexOf('script error') > -1) ; + else { + const errObj = { + type: 'js.error', + error: { + message: msg, + url: url, + line: lineNo, + col: columnNo, + errorObject: JSON.stringify(err), + }, + }; + if (err !== null) { + cap.handleError(err); + } + postToNative(errObj); + } + return false; + }; + if (cap.DEBUG) { + window.onerror = cap.handleWindowError; + } + initLogger(win, cap); + /** + * Send a plugin method call to the native layer + */ + cap.toNative = (pluginName, methodName, options, storedCallback) => { + var _a, _b; + try { + if (typeof postToNative === 'function') { + let callbackId = '-1'; + if (storedCallback && + (typeof storedCallback.callback === 'function' || + typeof storedCallback.resolve === 'function')) { + // store the call for later lookup + callbackId = String(++callbackIdCount); + callbacks.set(callbackId, storedCallback); + } + const callData = { + callbackId: callbackId, + pluginId: pluginName, + methodName: methodName, + options: options || {}, + }; + if (cap.isLoggingEnabled && pluginName !== 'Console') { + cap.logToNative(callData); + } + // post the call data to native + postToNative(callData); + return callbackId; + } + else { + (_a = win === null || win === void 0 ? void 0 : win.console) === null || _a === void 0 ? void 0 : _a.warn(`implementation unavailable for: ${pluginName}`); + } + } + catch (e) { + (_b = win === null || win === void 0 ? void 0 : win.console) === null || _b === void 0 ? void 0 : _b.error(e); + } + return null; + }; + if (win === null || win === void 0 ? void 0 : win.androidBridge) { + win.androidBridge.onmessage = function (event) { + returnResult(JSON.parse(event.data)); + }; + } + /** + * Process a response from the native layer. + */ + cap.fromNative = result => { + returnResult(result); + }; + const returnResult = (result) => { + var _a, _b; + if (cap.isLoggingEnabled && result.pluginId !== 'Console') { + cap.logFromNative(result); + } + // get the stored call, if it exists + try { + const storedCall = callbacks.get(result.callbackId); + if (storedCall) { + // looks like we've got a stored call + if (result.error) { + // ensure stacktraces by copying error properties to an Error + result.error = Object.keys(result.error).reduce((err, key) => { + // use any type to avoid importing util and compiling most of .ts files + err[key] = result.error[key]; + return err; + }, new cap.Exception('')); + } + if (typeof storedCall.callback === 'function') { + // callback + if (result.success) { + storedCall.callback(result.data); + } + else { + storedCall.callback(null, result.error); + } + } + else if (typeof storedCall.resolve === 'function') { + // promise + if (result.success) { + storedCall.resolve(result.data); + } + else { + storedCall.reject(result.error); + } + // no need to keep this stored callback + // around for a one time resolve promise + callbacks.delete(result.callbackId); + } + } + else if (!result.success && result.error) { + // no stored callback, but if there was an error let's log it + (_a = win === null || win === void 0 ? void 0 : win.console) === null || _a === void 0 ? void 0 : _a.warn(result.error); + } + if (result.save === false) { + callbacks.delete(result.callbackId); + } + } + catch (e) { + (_b = win === null || win === void 0 ? void 0 : win.console) === null || _b === void 0 ? void 0 : _b.error(e); + } + // always delete to prevent memory leaks + // overkill but we're not sure what apps will do with this data + delete result.data; + delete result.error; + }; + cap.nativeCallback = (pluginName, methodName, options, callback) => { + if (typeof options === 'function') { + console.warn(`Using a callback as the 'options' parameter of 'nativeCallback()' is deprecated.`); + callback = options; + options = null; + } + return cap.toNative(pluginName, methodName, options, { callback }); + }; + cap.nativePromise = (pluginName, methodName, options) => { + return new Promise((resolve, reject) => { + cap.toNative(pluginName, methodName, options, { + resolve: resolve, + reject: reject, + }); + }); + }; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + cap.withPlugin = (_pluginId, _fn) => dummy; + cap.Exception = CapacitorException; + initEvents(win, cap); + initLegacyHandlers(win, cap); + initVendor(win, cap); + win.Capacitor = cap; + } + initNativeBridge(w); + }; + initBridge(typeof globalThis !== 'undefined' + ? globalThis + : typeof self !== 'undefined' + ? self + : typeof window !== 'undefined' + ? window + : typeof global !== 'undefined' + ? global + : {}); + + dummy = initBridge; + + Object.defineProperty(exports, '__esModule', { value: true }); + + return exports; + +})({}); diff --git a/capacitor/src/main/java/com/getcapacitor/AndroidProtocolHandler.java b/capacitor/src/main/java/com/getcapacitor/AndroidProtocolHandler.java new file mode 100755 index 00000000..df893c7f --- /dev/null +++ b/capacitor/src/main/java/com/getcapacitor/AndroidProtocolHandler.java @@ -0,0 +1,94 @@ +// Copyright 2012 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package com.getcapacitor; + +import android.content.Context; +import android.content.res.AssetManager; +import android.net.Uri; +import android.util.TypedValue; +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.List; + +public class AndroidProtocolHandler { + + private Context context; + + public AndroidProtocolHandler(Context context) { + this.context = context; + } + + public InputStream openAsset(String path) throws IOException { + return context.getAssets().open(path, AssetManager.ACCESS_STREAMING); + } + + public InputStream openResource(Uri uri) { + assert uri.getPath() != null; + // The path must be of the form ".../asset_type/asset_name.ext". + List pathSegments = uri.getPathSegments(); + String assetType = pathSegments.get(pathSegments.size() - 2); + String assetName = pathSegments.get(pathSegments.size() - 1); + + // Drop the file extension. + assetName = assetName.split("\\.")[0]; + try { + // Use the application context for resolving the resource package name so that we do + // not use the browser's own resources. Note that if 'context' here belongs to the + // test suite, it does not have a separate application context. In that case we use + // the original context object directly. + if (context.getApplicationContext() != null) { + context = context.getApplicationContext(); + } + int fieldId = getFieldId(context, assetType, assetName); + int valueType = getValueType(context, fieldId); + if (valueType == TypedValue.TYPE_STRING) { + return context.getResources().openRawResource(fieldId); + } else { + Logger.error("Asset not of type string: " + uri); + } + } catch (ClassNotFoundException | IllegalAccessException | NoSuchFieldException e) { + Logger.error("Unable to open resource URL: " + uri, e); + } + return null; + } + + private static int getFieldId(Context context, String assetType, String assetName) + throws ClassNotFoundException, NoSuchFieldException, IllegalAccessException { + Class d = context.getClassLoader().loadClass(context.getPackageName() + ".R$" + assetType); + java.lang.reflect.Field field = d.getField(assetName); + return field.getInt(null); + } + + public InputStream openFile(String filePath) throws IOException { + String realPath = filePath.replace(Bridge.CAPACITOR_FILE_START, ""); + File localFile = new File(realPath); + return new FileInputStream(localFile); + } + + public InputStream openContentUrl(Uri uri) throws IOException { + Integer port = uri.getPort(); + String baseUrl = uri.getScheme() + "://" + uri.getHost(); + if (port != -1) { + baseUrl += ":" + port; + } + String realPath = uri.toString().replace(baseUrl + Bridge.CAPACITOR_CONTENT_START, "content:/"); + + InputStream stream = null; + try { + stream = context.getContentResolver().openInputStream(Uri.parse(realPath)); + } catch (SecurityException e) { + Logger.error("Unable to open content URL: " + uri, e); + } + return stream; + } + + private static int getValueType(Context context, int fieldId) { + TypedValue value = new TypedValue(); + context.getResources().getValue(fieldId, value, true); + return value.type; + } +} diff --git a/capacitor/src/main/java/com/getcapacitor/App.java b/capacitor/src/main/java/com/getcapacitor/App.java new file mode 100644 index 00000000..f46b6332 --- /dev/null +++ b/capacitor/src/main/java/com/getcapacitor/App.java @@ -0,0 +1,61 @@ +package com.getcapacitor; + +import androidx.annotation.Nullable; + +public class App { + + /** + * Interface for callbacks when app status changes. + */ + public interface AppStatusChangeListener { + void onAppStatusChanged(Boolean isActive); + } + + /** + * Interface for callbacks when app is restored with pending plugin call. + */ + public interface AppRestoredListener { + void onAppRestored(PluginResult result); + } + + @Nullable + private AppStatusChangeListener statusChangeListener; + + @Nullable + private AppRestoredListener appRestoredListener; + + private boolean isActive = false; + + public boolean isActive() { + return isActive; + } + + /** + * Set the object to receive callbacks. + * @param listener + */ + public void setStatusChangeListener(@Nullable AppStatusChangeListener listener) { + this.statusChangeListener = listener; + } + + /** + * Set the object to receive callbacks. + * @param listener + */ + public void setAppRestoredListener(@Nullable AppRestoredListener listener) { + this.appRestoredListener = listener; + } + + protected void fireRestoredResult(PluginResult result) { + if (appRestoredListener != null) { + appRestoredListener.onAppRestored(result); + } + } + + public void fireStatusChange(boolean isActive) { + this.isActive = isActive; + if (statusChangeListener != null) { + statusChangeListener.onAppStatusChanged(isActive); + } + } +} diff --git a/capacitor/src/main/java/com/getcapacitor/AppUUID.java b/capacitor/src/main/java/com/getcapacitor/AppUUID.java new file mode 100644 index 00000000..3c1b1db6 --- /dev/null +++ b/capacitor/src/main/java/com/getcapacitor/AppUUID.java @@ -0,0 +1,65 @@ +package com.getcapacitor; + +import android.content.Context; +import android.content.SharedPreferences; +import androidx.appcompat.app.AppCompatActivity; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Objects; +import java.util.UUID; + +public final class AppUUID { + + private static final String KEY = "CapacitorAppUUID"; + + public static String getAppUUID(AppCompatActivity activity) throws Exception { + assertAppUUID(activity); + return readUUID(activity); + } + + public static void regenerateAppUUID(AppCompatActivity activity) throws Exception { + try { + String uuid = generateUUID(); + writeUUID(activity, uuid); + } catch (NoSuchAlgorithmException ex) { + throw new Exception("Capacitor App UUID could not be generated."); + } + } + + private static void assertAppUUID(AppCompatActivity activity) throws Exception { + String uuid = readUUID(activity); + if (uuid.equals("")) { + regenerateAppUUID(activity); + } + } + + private static String generateUUID() throws NoSuchAlgorithmException { + MessageDigest salt = MessageDigest.getInstance("SHA-256"); + salt.update(UUID.randomUUID().toString().getBytes(StandardCharsets.UTF_8)); + return bytesToHex(salt.digest()); + } + + private static String readUUID(AppCompatActivity activity) { + SharedPreferences sharedPref = activity.getPreferences(Context.MODE_PRIVATE); + return sharedPref.getString(KEY, ""); + } + + private static void writeUUID(AppCompatActivity activity, String uuid) { + SharedPreferences sharedPref = activity.getPreferences(Context.MODE_PRIVATE); + SharedPreferences.Editor editor = sharedPref.edit(); + editor.putString(KEY, uuid); + editor.apply(); + } + + private static String bytesToHex(byte[] bytes) { + byte[] HEX_ARRAY = "0123456789ABCDEF".getBytes(StandardCharsets.US_ASCII); + byte[] hexChars = new byte[bytes.length * 2]; + for (int j = 0; j < bytes.length; j++) { + int v = bytes[j] & 0xFF; + hexChars[j * 2] = HEX_ARRAY[v >>> 4]; + hexChars[j * 2 + 1] = HEX_ARRAY[v & 0x0F]; + } + return new String(hexChars, StandardCharsets.UTF_8); + } +} diff --git a/capacitor/src/main/java/com/getcapacitor/Bridge.java b/capacitor/src/main/java/com/getcapacitor/Bridge.java new file mode 100644 index 00000000..594146a0 --- /dev/null +++ b/capacitor/src/main/java/com/getcapacitor/Bridge.java @@ -0,0 +1,1589 @@ +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 androidx.webkit.WebViewCompat; +import androidx.webkit.WebViewFeature; +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.Collections; +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_"; + + /** @deprecated CAPACITOR_HTTPS_INTERCEPTOR_START is no longer required. All proxied requests are handled via CAPACITOR_HTTP_INTERCEPTOR_START instead */ + @Deprecated + public static final String CAPACITOR_HTTPS_INTERCEPTOR_START = "/_capacitor_https_interceptor_"; + + public static final String CAPACITOR_HTTP_INTERCEPTOR_URL_PARAM = "u"; + + 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 + JSInjector injector = getJSInjector(); + if (WebViewFeature.isFeatureSupported(WebViewFeature.DOCUMENT_START_SCRIPT)) { + String allowedOrigin = Uri.parse(appUrl).buildUpon().path(null).fragment(null).clearQuery().build().toString(); + try { + WebViewCompat.addDocumentStartJavaScript(webView, injector.getScriptString(), Collections.singleton(allowedOrigin)); + injector = null; + } catch (IllegalArgumentException ex) { + Logger.warn("Invalid url, using fallback"); + } + } + localServer = new WebViewLocalServer(context, this, injector, 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"); + } + + settings.setDisplayZoomControls(false); + settings.setBuiltInZoomControls(this.config.isZoomableWebView()); + + 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/src/main/java/com/getcapacitor/BridgeActivity.java b/capacitor/src/main/java/com/getcapacitor/BridgeActivity.java new file mode 100644 index 00000000..dcb1b56a --- /dev/null +++ b/capacitor/src/main/java/com/getcapacitor/BridgeActivity.java @@ -0,0 +1,212 @@ +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); + try { + setContentView(R.layout.bridge_layout_main); + } catch (Exception ex) { + setContentView(R.layout.no_webview); + return; + } + + 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++; + if (this.bridge != null) { + 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(); + if (bridge != null) { + bridge.getApp().fireStatusChange(true); + this.bridge.onResume(); + Logger.debug("App resumed"); + } + } + + @Override + public void onPause() { + super.onPause(); + if (bridge != null) { + this.bridge.onPause(); + Logger.debug("App paused"); + } + } + + @Override + public void onStop() { + super.onStop(); + if (bridge != null) { + 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(); + if (this.bridge != null) { + 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/src/main/java/com/getcapacitor/BridgeFragment.java b/capacitor/src/main/java/com/getcapacitor/BridgeFragment.java new file mode 100644 index 00000000..f269bd56 --- /dev/null +++ b/capacitor/src/main/java/com/getcapacitor/BridgeFragment.java @@ -0,0 +1,134 @@ +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/src/main/java/com/getcapacitor/BridgeWebChromeClient.java b/capacitor/src/main/java/com/getcapacitor/BridgeWebChromeClient.java new file mode 100644 index 00000000..400b65a0 --- /dev/null +++ b/capacitor/src/main/java/com/getcapacitor/BridgeWebChromeClient.java @@ -0,0 +1,510 @@ +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/src/main/java/com/getcapacitor/BridgeWebViewClient.java b/capacitor/src/main/java/com/getcapacitor/BridgeWebViewClient.java new file mode 100644 index 00000000..c434247a --- /dev/null +++ b/capacitor/src/main/java/com/getcapacitor/BridgeWebViewClient.java @@ -0,0 +1,111 @@ +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/src/main/java/com/getcapacitor/CapConfig.java b/capacitor/src/main/java/com/getcapacitor/CapConfig.java new file mode 100644 index 00000000..28645340 --- /dev/null +++ b/capacitor/src/main/java/com/getcapacitor/CapConfig.java @@ -0,0 +1,683 @@ +package com.getcapacitor; + +import static com.getcapacitor.Bridge.CAPACITOR_HTTPS_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_HTTPS_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; + private boolean zoomableWebView = false; + + // 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; + this.zoomableWebView = builder.zoomableWebView; + + // 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); + zoomableWebView = JSONUtils.getBoolean(configJSON, "android.zoomEnabled", JSONUtils.getBoolean(configJSON, "zoomEnabled", false)); + + 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 https."); + 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 isZoomableWebView() { + return zoomableWebView; + } + + 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_HTTPS_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; + private boolean zoomableWebView = false; + + // 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 setZoomableWebView(boolean zoomableWebView) { + this.zoomableWebView = zoomableWebView; + 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/src/main/java/com/getcapacitor/CapacitorWebView.java b/capacitor/src/main/java/com/getcapacitor/CapacitorWebView.java new file mode 100644 index 00000000..e46b904a --- /dev/null +++ b/capacitor/src/main/java/com/getcapacitor/CapacitorWebView.java @@ -0,0 +1,52 @@ +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/src/main/java/com/getcapacitor/FileUtils.java b/capacitor/src/main/java/com/getcapacitor/FileUtils.java new file mode 100644 index 00000000..47add8cd --- /dev/null +++ b/capacitor/src/main/java/com/getcapacitor/FileUtils.java @@ -0,0 +1,292 @@ +/** + * 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/src/main/java/com/getcapacitor/InvalidPluginException.java b/capacitor/src/main/java/com/getcapacitor/InvalidPluginException.java new file mode 100644 index 00000000..1757e326 --- /dev/null +++ b/capacitor/src/main/java/com/getcapacitor/InvalidPluginException.java @@ -0,0 +1,8 @@ +package com.getcapacitor; + +class InvalidPluginException extends Exception { + + public InvalidPluginException(String s) { + super(s); + } +} diff --git a/capacitor/src/main/java/com/getcapacitor/InvalidPluginMethodException.java b/capacitor/src/main/java/com/getcapacitor/InvalidPluginMethodException.java new file mode 100644 index 00000000..94be491e --- /dev/null +++ b/capacitor/src/main/java/com/getcapacitor/InvalidPluginMethodException.java @@ -0,0 +1,16 @@ +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/src/main/java/com/getcapacitor/JSArray.java b/capacitor/src/main/java/com/getcapacitor/JSArray.java new file mode 100644 index 00000000..06b7f4dd --- /dev/null +++ b/capacitor/src/main/java/com/getcapacitor/JSArray.java @@ -0,0 +1,51 @@ +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/src/main/java/com/getcapacitor/JSExport.java b/capacitor/src/main/java/com/getcapacitor/JSExport.java new file mode 100644 index 00000000..382f4b5d --- /dev/null +++ b/capacitor/src/main/java/com/getcapacitor/JSExport.java @@ -0,0 +1,193 @@ +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/src/main/java/com/getcapacitor/JSExportException.java b/capacitor/src/main/java/com/getcapacitor/JSExportException.java new file mode 100644 index 00000000..14b6043a --- /dev/null +++ b/capacitor/src/main/java/com/getcapacitor/JSExportException.java @@ -0,0 +1,16 @@ +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/src/main/java/com/getcapacitor/JSInjector.java b/capacitor/src/main/java/com/getcapacitor/JSInjector.java new file mode 100644 index 00000000..a3871f7b --- /dev/null +++ b/capacitor/src/main/java/com/getcapacitor/JSInjector.java @@ -0,0 +1,107 @@ +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/src/main/java/com/getcapacitor/JSObject.java b/capacitor/src/main/java/com/getcapacitor/JSObject.java new file mode 100644 index 00000000..0e987076 --- /dev/null +++ b/capacitor/src/main/java/com/getcapacitor/JSObject.java @@ -0,0 +1,164 @@ +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/src/main/java/com/getcapacitor/JSValue.java b/capacitor/src/main/java/com/getcapacitor/JSValue.java new file mode 100644 index 00000000..d97ba91b --- /dev/null +++ b/capacitor/src/main/java/com/getcapacitor/JSValue.java @@ -0,0 +1,65 @@ +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/src/main/java/com/getcapacitor/Logger.java b/capacitor/src/main/java/com/getcapacitor/Logger.java new file mode 100644 index 00000000..9d24fedd --- /dev/null +++ b/capacitor/src/main/java/com/getcapacitor/Logger.java @@ -0,0 +1,103 @@ +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/src/main/java/com/getcapacitor/MessageHandler.java b/capacitor/src/main/java/com/getcapacitor/MessageHandler.java new file mode 100644 index 00000000..b71124e8 --- /dev/null +++ b/capacitor/src/main/java/com/getcapacitor/MessageHandler.java @@ -0,0 +1,159 @@ +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/src/main/java/com/getcapacitor/NativePlugin.java b/capacitor/src/main/java/com/getcapacitor/NativePlugin.java new file mode 100644 index 00000000..c4307624 --- /dev/null +++ b/capacitor/src/main/java/com/getcapacitor/NativePlugin.java @@ -0,0 +1,37 @@ +package com.getcapacitor; + +import com.getcapacitor.annotation.CapacitorPlugin; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +/** + * Base annotation for all Plugins + * @deprecated + *

Use {@link CapacitorPlugin} instead + */ +@Retention(RetentionPolicy.RUNTIME) +@Deprecated +public @interface NativePlugin { + /** + * Request codes this plugin uses and responds to, in order to tie + * Android events back the plugin to handle + */ + int[] requestCodes() default {}; + + /** + * Permissions this plugin needs, in order to make permission requests + * easy if the plugin only needs basic permission prompting + */ + String[] permissions() default {}; + + /** + * The request code to use when automatically requesting permissions + */ + int permissionRequestCode() default 9000; + + /** + * A custom name for the plugin, otherwise uses the + * simple class name. + */ + String name() default ""; +} diff --git a/capacitor/src/main/java/com/getcapacitor/PermissionState.java b/capacitor/src/main/java/com/getcapacitor/PermissionState.java new file mode 100644 index 00000000..382cff71 --- /dev/null +++ b/capacitor/src/main/java/com/getcapacitor/PermissionState.java @@ -0,0 +1,31 @@ +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/src/main/java/com/getcapacitor/Plugin.java b/capacitor/src/main/java/com/getcapacitor/Plugin.java new file mode 100644 index 00000000..d8a3e82a --- /dev/null +++ b/capacitor/src/main/java/com/getcapacitor/Plugin.java @@ -0,0 +1,1046 @@ +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/src/main/java/com/getcapacitor/PluginCall.java b/capacitor/src/main/java/com/getcapacitor/PluginCall.java new file mode 100644 index 00000000..18661d76 --- /dev/null +++ b/capacitor/src/main/java/com/getcapacitor/PluginCall.java @@ -0,0 +1,440 @@ +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/src/main/java/com/getcapacitor/PluginConfig.java b/capacitor/src/main/java/com/getcapacitor/PluginConfig.java new file mode 100644 index 00000000..0f00fc53 --- /dev/null +++ b/capacitor/src/main/java/com/getcapacitor/PluginConfig.java @@ -0,0 +1,116 @@ +package com.getcapacitor; + +import com.getcapacitor.util.JSONUtils; +import org.json.JSONObject; + +/** + * Represents the configuration options for plugins used by Capacitor + */ +public class PluginConfig { + + /** + * The object containing plugin config values. + */ + private final JSONObject config; + + /** + * Constructs a PluginsConfig with the provided JSONObject value. + * + * @param config A plugin configuration expressed as a JSON Object + */ + PluginConfig(JSONObject config) { + this.config = config; + } + + /** + * Get a string value for a plugin in the Capacitor config. + * + * @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); + } + + /** + * Get a string value for a plugin in the Capacitor config. + * + * @param configKey The key of the value to retrieve + * @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); + } + + /** + * Get a boolean value for a plugin in the Capacitor config. + * + * @param configKey The key of the value to retrieve + * @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); + } + + /** + * Get an integer value for a plugin in the Capacitor config. + * + * @param configKey The key of the value to retrieve + * @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); + } + + /** + * Get a string array value for a plugin in the Capacitor config. + * + * @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); + } + + /** + * Get a string array value for a plugin in the Capacitor config. + * + * @param configKey The key of the value to retrieve + * @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); + } + + /** + * Get a JSON object value for a plugin in the Capacitor config. + * + * @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); + } + + /** + * 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; + } +} diff --git a/capacitor/src/main/java/com/getcapacitor/PluginHandle.java b/capacitor/src/main/java/com/getcapacitor/PluginHandle.java new file mode 100644 index 00000000..bfdd9228 --- /dev/null +++ b/capacitor/src/main/java/com/getcapacitor/PluginHandle.java @@ -0,0 +1,160 @@ +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.getDeclaredConstructor().newInstance(); + return this.loadInstance(instance); + } catch (Exception 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/src/main/java/com/getcapacitor/PluginInvocationException.java b/capacitor/src/main/java/com/getcapacitor/PluginInvocationException.java new file mode 100644 index 00000000..ae6b0eb8 --- /dev/null +++ b/capacitor/src/main/java/com/getcapacitor/PluginInvocationException.java @@ -0,0 +1,16 @@ +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/src/main/java/com/getcapacitor/PluginLoadException.java b/capacitor/src/main/java/com/getcapacitor/PluginLoadException.java new file mode 100644 index 00000000..8d81a382 --- /dev/null +++ b/capacitor/src/main/java/com/getcapacitor/PluginLoadException.java @@ -0,0 +1,19 @@ +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/src/main/java/com/getcapacitor/PluginManager.java b/capacitor/src/main/java/com/getcapacitor/PluginManager.java new file mode 100644 index 00000000..540bc912 --- /dev/null +++ b/capacitor/src/main/java/com/getcapacitor/PluginManager.java @@ -0,0 +1,56 @@ +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/src/main/java/com/getcapacitor/PluginMethod.java b/capacitor/src/main/java/com/getcapacitor/PluginMethod.java new file mode 100644 index 00000000..85663043 --- /dev/null +++ b/capacitor/src/main/java/com/getcapacitor/PluginMethod.java @@ -0,0 +1,15 @@ +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/src/main/java/com/getcapacitor/PluginMethodHandle.java b/capacitor/src/main/java/com/getcapacitor/PluginMethodHandle.java new file mode 100644 index 00000000..a728c1f1 --- /dev/null +++ b/capacitor/src/main/java/com/getcapacitor/PluginMethodHandle.java @@ -0,0 +1,33 @@ +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/src/main/java/com/getcapacitor/PluginResult.java b/capacitor/src/main/java/com/getcapacitor/PluginResult.java new file mode 100644 index 00000000..cdc169e0 --- /dev/null +++ b/capacitor/src/main/java/com/getcapacitor/PluginResult.java @@ -0,0 +1,84 @@ +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/src/main/java/com/getcapacitor/ProcessedRoute.java b/capacitor/src/main/java/com/getcapacitor/ProcessedRoute.java new file mode 100644 index 00000000..eb3d7b0d --- /dev/null +++ b/capacitor/src/main/java/com/getcapacitor/ProcessedRoute.java @@ -0,0 +1,37 @@ +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/src/main/java/com/getcapacitor/RouteProcessor.java b/capacitor/src/main/java/com/getcapacitor/RouteProcessor.java new file mode 100644 index 00000000..670c8bc6 --- /dev/null +++ b/capacitor/src/main/java/com/getcapacitor/RouteProcessor.java @@ -0,0 +1,8 @@ +package com.getcapacitor; + +/** + * An interface used in the processing of routes + */ +public interface RouteProcessor { + ProcessedRoute process(String basePath, String path); +} diff --git a/capacitor/src/main/java/com/getcapacitor/ServerPath.java b/capacitor/src/main/java/com/getcapacitor/ServerPath.java new file mode 100644 index 00000000..5b34b460 --- /dev/null +++ b/capacitor/src/main/java/com/getcapacitor/ServerPath.java @@ -0,0 +1,25 @@ +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/src/main/java/com/getcapacitor/UriMatcher.java b/capacitor/src/main/java/com/getcapacitor/UriMatcher.java new file mode 100755 index 00000000..715a0a0b --- /dev/null +++ b/capacitor/src/main/java/com/getcapacitor/UriMatcher.java @@ -0,0 +1,180 @@ +/* + * 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/src/main/java/com/getcapacitor/WebViewListener.java b/capacitor/src/main/java/com/getcapacitor/WebViewListener.java new file mode 100644 index 00000000..6df4f6c0 --- /dev/null +++ b/capacitor/src/main/java/com/getcapacitor/WebViewListener.java @@ -0,0 +1,57 @@ +package com.getcapacitor; + +import android.webkit.RenderProcessGoneDetail; +import android.webkit.WebView; + +/** + * Provides callbacks associated with the {@link BridgeWebViewClient} + */ +public abstract class WebViewListener { + + /** + * Callback for page load event. + * + * @param webView The WebView that loaded + */ + public void onPageLoaded(WebView webView) { + // Override me to add behavior to the page loaded event + } + + /** + * Callback for onReceivedError event. + * + * @param webView The WebView that loaded + */ + public void onReceivedError(WebView webView) { + // Override me to add behavior to handle the onReceivedError event + } + + /** + * Callback for onReceivedHttpError event. + * + * @param webView The WebView that loaded + */ + public void onReceivedHttpError(WebView webView) { + // Override me to add behavior to handle the onReceivedHttpError event + } + + /** + * Callback for page start event. + * + * @param webView The WebView that loaded + */ + public void onPageStarted(WebView webView) { + // Override me to add behavior to the page started event + } + + /** + * Callback for render process gone event. Return true if the state is handled. + * + * @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) { + // Override me to add behavior to the web view render process gone event + return false; + } +} diff --git a/capacitor/src/main/java/com/getcapacitor/WebViewLocalServer.java b/capacitor/src/main/java/com/getcapacitor/WebViewLocalServer.java new file mode 100755 index 00000000..f1fc63cb --- /dev/null +++ b/capacitor/src/main/java/com/getcapacitor/WebViewLocalServer.java @@ -0,0 +1,749 @@ +/* +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.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * 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 (null != loadingUrl.getPath() && loadingUrl.getPath().startsWith(Bridge.CAPACITOR_HTTP_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 { + String urlString = request.getUrl().getQueryParameter(Bridge.CAPACITOR_HTTP_INTERCEPTOR_URL_PARAM); + 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; + } + + if (jsInjector != 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") && jsInjector != null) { + 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; + } + + /** + * Prepends an {@code InputStream} with the JavaScript required by Capacitor. + * This method only changes the original {@code InputStream} if {@code WebView} does not + * support the {@code DOCUMENT_START_SCRIPT} feature. + * @param original the original {@code InputStream} + * @return the modified {@code InputStream} + */ + public InputStream getJavaScriptInjectedStream(InputStream original) { + if (jsInjector != null) { + return jsInjector.getInjectedStream(original); + } + return original; + } + + /** + * 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) { + if (jsInjector != null) { + 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/src/main/java/com/getcapacitor/annotation/ActivityCallback.java b/capacitor/src/main/java/com/getcapacitor/annotation/ActivityCallback.java new file mode 100644 index 00000000..a158145d --- /dev/null +++ b/capacitor/src/main/java/com/getcapacitor/annotation/ActivityCallback.java @@ -0,0 +1,11 @@ +package com.getcapacitor.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.METHOD) +public @interface ActivityCallback { +} diff --git a/capacitor/src/main/java/com/getcapacitor/annotation/CapacitorPlugin.java b/capacitor/src/main/java/com/getcapacitor/annotation/CapacitorPlugin.java new file mode 100644 index 00000000..903378db --- /dev/null +++ b/capacitor/src/main/java/com/getcapacitor/annotation/CapacitorPlugin.java @@ -0,0 +1,35 @@ +package com.getcapacitor.annotation; + +import android.content.Intent; +import com.getcapacitor.PluginCall; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +/** + * Base annotation for all Plugins + */ +@Retention(RetentionPolicy.RUNTIME) +public @interface CapacitorPlugin { + /** + * A custom name for the plugin, otherwise uses the + * simple class name. + */ + String name() default ""; + + /** + * Request codes this plugin uses and responds to, in order to tie + * Android events back the plugin to handle. + * + * NOTE: This is a legacy option provided to support third party libraries + * not currently implementing the new AndroidX Activity Results API. Plugins + * without this limitation should use a registered callback with + * {@link com.getcapacitor.Plugin#startActivityForResult(PluginCall, Intent, String)} + */ + int[] requestCodes() default {}; + + /** + * Permissions this plugin needs, in order to make permission requests + * easy if the plugin only needs basic permission prompting + */ + Permission[] permissions() default {}; +} diff --git a/capacitor/src/main/java/com/getcapacitor/annotation/Permission.java b/capacitor/src/main/java/com/getcapacitor/annotation/Permission.java new file mode 100644 index 00000000..35114370 --- /dev/null +++ b/capacitor/src/main/java/com/getcapacitor/annotation/Permission.java @@ -0,0 +1,22 @@ +package com.getcapacitor.annotation; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +/** + * Permission annotation for use with @CapacitorPlugin + */ +@Retention(RetentionPolicy.RUNTIME) +public @interface Permission { + /** + * An array of Android permission strings. + * Eg: {Manifest.permission.ACCESS_COARSE_LOCATION} + * or {"android.permission.ACCESS_COARSE_LOCATION"} + */ + String[] strings() default {}; + + /** + * An optional name to use instead of the Android permission string. + */ + String alias() default ""; +} diff --git a/capacitor/src/main/java/com/getcapacitor/annotation/PermissionCallback.java b/capacitor/src/main/java/com/getcapacitor/annotation/PermissionCallback.java new file mode 100644 index 00000000..d4ca0992 --- /dev/null +++ b/capacitor/src/main/java/com/getcapacitor/annotation/PermissionCallback.java @@ -0,0 +1,11 @@ +package com.getcapacitor.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.METHOD) +public @interface PermissionCallback { +} diff --git a/capacitor/src/main/java/com/getcapacitor/cordova/CapacitorCordovaCookieManager.java b/capacitor/src/main/java/com/getcapacitor/cordova/CapacitorCordovaCookieManager.java new file mode 100644 index 00000000..72ac4ee7 --- /dev/null +++ b/capacitor/src/main/java/com/getcapacitor/cordova/CapacitorCordovaCookieManager.java @@ -0,0 +1,42 @@ +package com.getcapacitor.cordova; + +import android.webkit.CookieManager; +import android.webkit.WebView; +import org.apache.cordova.ICordovaCookieManager; + +class CapacitorCordovaCookieManager implements ICordovaCookieManager { + + protected final WebView webView; + private final CookieManager cookieManager; + + public CapacitorCordovaCookieManager(WebView webview) { + webView = webview; + cookieManager = CookieManager.getInstance(); + cookieManager.setAcceptThirdPartyCookies(webView, true); + } + + @Override + public void setCookiesEnabled(boolean accept) { + cookieManager.setAcceptCookie(accept); + } + + @Override + public void setCookie(final String url, final String value) { + cookieManager.setCookie(url, value); + } + + @Override + public String getCookie(final String url) { + return cookieManager.getCookie(url); + } + + @Override + public void clearCookies() { + cookieManager.removeAllCookies(null); + } + + @Override + public void flush() { + cookieManager.flush(); + } +} diff --git a/capacitor/src/main/java/com/getcapacitor/cordova/MockCordovaInterfaceImpl.java b/capacitor/src/main/java/com/getcapacitor/cordova/MockCordovaInterfaceImpl.java new file mode 100644 index 00000000..7e8358da --- /dev/null +++ b/capacitor/src/main/java/com/getcapacitor/cordova/MockCordovaInterfaceImpl.java @@ -0,0 +1,39 @@ +package com.getcapacitor.cordova; + +import android.util.Pair; +import androidx.appcompat.app.AppCompatActivity; +import java.util.concurrent.Executors; +import org.apache.cordova.CordovaInterfaceImpl; +import org.apache.cordova.CordovaPlugin; +import org.json.JSONException; + +public class MockCordovaInterfaceImpl extends CordovaInterfaceImpl { + + public MockCordovaInterfaceImpl(AppCompatActivity activity) { + super(activity, Executors.newCachedThreadPool()); + } + + public CordovaPlugin getActivityResultCallback() { + return this.activityResultCallback; + } + + /** + * Checks Cordova permission callbacks to handle permissions defined by a Cordova plugin. + * Returns true if Cordova is handling the permission request with a registered code. + * + * @param requestCode + * @param permissions + * @param grantResults + * @return true if Cordova handled the permission request, false if not + */ + @SuppressWarnings("deprecation") + public boolean handlePermissionResult(int requestCode, String[] permissions, int[] grantResults) throws JSONException { + Pair callback = permissionResultCallbacks.getAndRemoveCallback(requestCode); + if (callback != null) { + callback.first.onRequestPermissionResult(callback.second, permissions, grantResults); + return true; + } + + return false; + } +} diff --git a/capacitor/src/main/java/com/getcapacitor/cordova/MockCordovaWebViewImpl.java b/capacitor/src/main/java/com/getcapacitor/cordova/MockCordovaWebViewImpl.java new file mode 100644 index 00000000..d5b77cdd --- /dev/null +++ b/capacitor/src/main/java/com/getcapacitor/cordova/MockCordovaWebViewImpl.java @@ -0,0 +1,284 @@ +package com.getcapacitor.cordova; + +import android.content.Context; +import android.content.Intent; +import android.os.Handler; +import android.view.View; +import android.webkit.ValueCallback; +import android.webkit.WebChromeClient; +import android.webkit.WebView; +import java.util.List; +import java.util.Map; +import org.apache.cordova.CordovaInterface; +import org.apache.cordova.CordovaPreferences; +import org.apache.cordova.CordovaResourceApi; +import org.apache.cordova.CordovaWebView; +import org.apache.cordova.CordovaWebViewEngine; +import org.apache.cordova.ICordovaCookieManager; +import org.apache.cordova.NativeToJsMessageQueue; +import org.apache.cordova.PluginEntry; +import org.apache.cordova.PluginManager; +import org.apache.cordova.PluginResult; + +public class MockCordovaWebViewImpl implements CordovaWebView { + + private Context context; + private PluginManager pluginManager; + private CordovaPreferences preferences; + private CordovaResourceApi resourceApi; + private NativeToJsMessageQueue nativeToJsMessageQueue; + private CordovaInterface cordova; + private CapacitorCordovaCookieManager cookieManager; + private WebView webView; + private boolean hasPausedEver; + + public MockCordovaWebViewImpl(Context context) { + this.context = context; + } + + @Override + public void init(CordovaInterface cordova, List pluginEntries, CordovaPreferences preferences) { + this.cordova = cordova; + this.preferences = preferences; + this.pluginManager = new PluginManager(this, this.cordova, pluginEntries); + this.resourceApi = new CordovaResourceApi(this.context, this.pluginManager); + this.pluginManager.init(); + } + + public void init(CordovaInterface cordova, List pluginEntries, CordovaPreferences preferences, WebView webView) { + this.cordova = cordova; + this.webView = webView; + this.preferences = preferences; + this.pluginManager = new PluginManager(this, this.cordova, pluginEntries); + this.resourceApi = new CordovaResourceApi(this.context, this.pluginManager); + nativeToJsMessageQueue = new NativeToJsMessageQueue(); + nativeToJsMessageQueue.addBridgeMode(new CapacitorEvalBridgeMode(webView, this.cordova)); + nativeToJsMessageQueue.setBridgeMode(0); + this.cookieManager = new CapacitorCordovaCookieManager(webView); + this.pluginManager.init(); + } + + public static class CapacitorEvalBridgeMode extends NativeToJsMessageQueue.BridgeMode { + + private final WebView webView; + private final CordovaInterface cordova; + + public CapacitorEvalBridgeMode(WebView webView, CordovaInterface cordova) { + this.webView = webView; + this.cordova = cordova; + } + + @Override + public void onNativeToJsMessageAvailable(final NativeToJsMessageQueue queue) { + cordova + .getActivity() + .runOnUiThread( + () -> { + String js = queue.popAndEncodeAsJs(); + if (js != null) { + webView.evaluateJavascript(js, null); + } + } + ); + } + } + + @Override + public boolean isInitialized() { + return cordova != null; + } + + @Override + public View getView() { + return this.webView; + } + + @Override + public void loadUrlIntoView(String url, boolean recreatePlugins) { + if (url.equals("about:blank") || url.startsWith("javascript:")) { + webView.loadUrl(url); + return; + } + } + + @Override + public void stopLoading() {} + + @Override + public boolean canGoBack() { + return false; + } + + @Override + public void clearCache() {} + + @Deprecated + @Override + public void clearCache(boolean b) {} + + @Override + public void clearHistory() {} + + @Override + public boolean backHistory() { + return false; + } + + @Override + public void handlePause(boolean keepRunning) { + if (!isInitialized()) { + return; + } + hasPausedEver = true; + pluginManager.onPause(keepRunning); + triggerDocumentEvent("pause"); + // If app doesn't want to run in background + if (!keepRunning) { + // Pause JavaScript timers. This affects all webviews within the app! + this.setPaused(true); + } + } + + @Override + public void onNewIntent(Intent intent) { + if (this.pluginManager != null) { + this.pluginManager.onNewIntent(intent); + } + } + + @Override + public void handleResume(boolean keepRunning) { + if (!isInitialized()) { + return; + } + this.setPaused(false); + this.pluginManager.onResume(keepRunning); + if (hasPausedEver) { + triggerDocumentEvent("resume"); + } + } + + @Override + public void handleStart() { + if (!isInitialized()) { + return; + } + pluginManager.onStart(); + } + + @Override + public void handleStop() { + if (!isInitialized()) { + return; + } + pluginManager.onStop(); + } + + @Override + public void handleDestroy() { + if (!isInitialized()) { + return; + } + this.pluginManager.onDestroy(); + } + + @Deprecated + @Override + public void sendJavascript(String statememt) { + nativeToJsMessageQueue.addJavaScript(statememt); + } + + public void eval(final String js, final ValueCallback callback) { + Handler mainHandler = new Handler(context.getMainLooper()); + mainHandler.post(() -> webView.evaluateJavascript(js, callback)); + } + + public void triggerDocumentEvent(final String eventName) { + eval("window.Capacitor.triggerEvent('" + eventName + "', 'document');", s -> {}); + } + + @Override + public void showWebPage(String url, boolean openExternal, boolean clearHistory, Map params) {} + + @Deprecated + @Override + public boolean isCustomViewShowing() { + return false; + } + + @Deprecated + @Override + public void showCustomView(View view, WebChromeClient.CustomViewCallback callback) {} + + @Deprecated + @Override + public void hideCustomView() {} + + @Override + public CordovaResourceApi getResourceApi() { + return this.resourceApi; + } + + @Override + public void setButtonPlumbedToJs(int keyCode, boolean override) {} + + @Override + public boolean isButtonPlumbedToJs(int keyCode) { + return false; + } + + @Override + public void sendPluginResult(PluginResult cr, String callbackId) { + nativeToJsMessageQueue.addPluginResult(cr, callbackId); + } + + @Override + public PluginManager getPluginManager() { + return this.pluginManager; + } + + @Override + public CordovaWebViewEngine getEngine() { + return null; + } + + @Override + public CordovaPreferences getPreferences() { + return this.preferences; + } + + @Override + public ICordovaCookieManager getCookieManager() { + return cookieManager; + } + + @Override + public String getUrl() { + return webView.getUrl(); + } + + @Override + public Context getContext() { + return this.webView.getContext(); + } + + @Override + public void loadUrl(String url) { + loadUrlIntoView(url, true); + } + + @Override + public Object postMessage(String id, Object data) { + return pluginManager.postMessage(id, data); + } + + public void setPaused(boolean value) { + if (value) { + webView.onPause(); + webView.pauseTimers(); + } else { + webView.onResume(); + webView.resumeTimers(); + } + } +} diff --git a/capacitor/src/main/java/com/getcapacitor/plugin/CapacitorCookieManager.java b/capacitor/src/main/java/com/getcapacitor/plugin/CapacitorCookieManager.java new file mode 100644 index 00000000..cf4ab632 --- /dev/null +++ b/capacitor/src/main/java/com/getcapacitor/plugin/CapacitorCookieManager.java @@ -0,0 +1,239 @@ +package com.getcapacitor.plugin; + +import com.getcapacitor.Bridge; +import com.getcapacitor.Logger; +import java.net.CookieManager; +import java.net.CookiePolicy; +import java.net.CookieStore; +import java.net.HttpCookie; +import java.net.URI; +import java.net.URISyntaxException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; + +public class CapacitorCookieManager extends CookieManager { + + private final android.webkit.CookieManager webkitCookieManager; + + private final String localUrl; + + private final String serverUrl; + + private final String TAG = "CapacitorCookies"; + + /** + * Create a new cookie manager with the default cookie store and policy + */ + public CapacitorCookieManager(Bridge bridge) { + this(null, null, bridge); + } + + /** + * Create a new cookie manager with specified cookie store and cookie policy. + * @param store a {@code CookieStore} to be used by CookieManager. if {@code null}, cookie + * manager will use a default one, which is an in-memory CookieStore implementation. + * @param policy a {@code CookiePolicy} instance to be used by cookie manager as policy + * callback. if {@code null}, ACCEPT_ORIGINAL_SERVER will be used. + */ + public CapacitorCookieManager(CookieStore store, CookiePolicy policy, Bridge bridge) { + super(store, policy); + webkitCookieManager = android.webkit.CookieManager.getInstance(); + this.localUrl = bridge.getLocalUrl(); + this.serverUrl = bridge.getServerUrl(); + } + + public void removeSessionCookies() { + this.webkitCookieManager.removeSessionCookies(null); + } + + public String getSanitizedDomain(String url) throws URISyntaxException { + if (this.serverUrl != null && !this.serverUrl.isEmpty() && (url == null || url.isEmpty() || this.serverUrl.contains(url))) { + url = this.serverUrl; + } else if (this.localUrl != null && !this.localUrl.isEmpty() && (url == null || url.isEmpty() || this.localUrl.contains(url))) { + url = this.localUrl; + } else try { + URI uri = new URI(url); + String scheme = uri.getScheme(); + if (scheme == null || scheme.isEmpty()) { + url = "https://" + url; + } + } catch (URISyntaxException e) { + Logger.error(TAG, "Failed to get scheme from URL.", e); + } + + try { + new URI(url); + } catch (Exception error) { + Logger.error(TAG, "Failed to get sanitized URL.", error); + throw error; + } + return url; + } + + private String getDomainFromCookieString(String cookie) throws URISyntaxException { + String[] domain = cookie.toLowerCase(Locale.ROOT).split("domain="); + return getSanitizedDomain(domain.length <= 1 ? null : domain[1].split(";")[0].trim()); + } + + /** + * Gets the cookies for the given URL. + * @param url the URL for which the cookies are requested + * @return value the cookies as a string, using the format of the 'Cookie' HTTP request header + */ + public String getCookieString(String url) { + try { + url = getSanitizedDomain(url); + Logger.info(TAG, "Getting cookies at: '" + url + "'"); + return webkitCookieManager.getCookie(url); + } catch (Exception error) { + Logger.error(TAG, "Failed to get cookies at the given URL.", error); + } + + return null; + } + + /** + * Gets a cookie value for the given URL and key. + * @param url the URL for which the cookies are requested + * @param key the key of the cookie to search for + * @return the {@code HttpCookie} value of the cookie at the key, + * otherwise it will return a new empty {@code HttpCookie} + */ + public HttpCookie getCookie(String url, String key) { + HttpCookie[] cookies = getCookies(url); + for (HttpCookie cookie : cookies) { + if (cookie.getName().equals(key)) { + return cookie; + } + } + + return null; + } + + /** + * Gets an array of {@code HttpCookie} given a URL. + * @param url the URL for which the cookies are requested + * @return an {@code HttpCookie} array of non-expired cookies + */ + public HttpCookie[] getCookies(String url) { + try { + ArrayList cookieList = new ArrayList<>(); + String cookieString = getCookieString(url); + if (cookieString != null) { + String[] singleCookie = cookieString.split(";"); + for (String c : singleCookie) { + HttpCookie parsed = HttpCookie.parse(c).get(0); + parsed.setValue(parsed.getValue()); + cookieList.add(parsed); + } + } + HttpCookie[] cookies = new HttpCookie[cookieList.size()]; + return cookieList.toArray(cookies); + } catch (Exception ex) { + return new HttpCookie[0]; + } + } + + /** + * Sets a cookie for the given URL. Any existing cookie with the same host, path and name will + * be replaced with the new cookie. The cookie being set will be ignored if it is expired. + * @param url the URL for which the cookie is to be set + * @param value the cookie as a string, using the format of the 'Set-Cookie' HTTP response header + */ + public void setCookie(String url, String value) { + try { + url = getSanitizedDomain(url); + Logger.info(TAG, "Setting cookie '" + value + "' at: '" + url + "'"); + webkitCookieManager.setCookie(url, value); + flush(); + } catch (Exception error) { + Logger.error(TAG, "Failed to set cookie.", error); + } + } + + /** + * Sets a cookie for the given URL. Any existing cookie with the same host, path and name will + * be replaced with the new cookie. The cookie being set will be ignored if it is expired. + * @param url the URL for which the cookie is to be set + * @param key the {@code HttpCookie} name to use for lookup + * @param value the value of the {@code HttpCookie} given a key + */ + public void setCookie(String url, String key, String value) { + String cookieValue = key + "=" + value; + setCookie(url, cookieValue); + } + + public void setCookie(String url, String key, String value, String expires, String path) { + String cookieValue = key + "=" + value + "; expires=" + expires + "; path=" + path; + setCookie(url, cookieValue); + } + + /** + * Removes all cookies. This method is asynchronous. + */ + public void removeAllCookies() { + webkitCookieManager.removeAllCookies(null); + flush(); + } + + /** + * Ensures all cookies currently accessible through the getCookie API are written to persistent + * storage. This call will block the caller until it is done and may perform I/O. + */ + public void flush() { + webkitCookieManager.flush(); + } + + @Override + public void put(URI uri, Map> responseHeaders) { + // make sure our args are valid + if ((uri == null) || (responseHeaders == null)) return; + + // go over the headers + for (String headerKey : responseHeaders.keySet()) { + // ignore headers which aren't cookie related + if ((headerKey == null) || !(headerKey.equalsIgnoreCase("Set-Cookie2") || headerKey.equalsIgnoreCase("Set-Cookie"))) continue; + + // process each of the headers + for (String headerValue : Objects.requireNonNull(responseHeaders.get(headerKey))) { + try { + // Set at the requested server url + setCookie(uri.toString(), headerValue); + + // Set at the defined domain in the response or at default capacitor hosted url + setCookie(getDomainFromCookieString(headerValue), headerValue); + } catch (Exception ignored) {} + } + } + } + + @Override + public Map> get(URI uri, Map> requestHeaders) { + // make sure our args are valid + if ((uri == null) || (requestHeaders == null)) throw new IllegalArgumentException("Argument is null"); + + // save our url once + String url = uri.toString(); + + // prepare our response + Map> res = new HashMap<>(); + + // get the cookie + String cookie = getCookieString(url); + + // return it + if (cookie != null) res.put("Cookie", Collections.singletonList(cookie)); + return res; + } + + @Override + public CookieStore getCookieStore() { + // we don't want anyone to work with this cookie store directly + throw new UnsupportedOperationException(); + } +} diff --git a/capacitor/src/main/java/com/getcapacitor/plugin/CapacitorCookies.java b/capacitor/src/main/java/com/getcapacitor/plugin/CapacitorCookies.java new file mode 100644 index 00000000..64f97d87 --- /dev/null +++ b/capacitor/src/main/java/com/getcapacitor/plugin/CapacitorCookies.java @@ -0,0 +1,122 @@ +package com.getcapacitor.plugin; + +import android.webkit.JavascriptInterface; +import com.getcapacitor.JSObject; +import com.getcapacitor.Plugin; +import com.getcapacitor.PluginCall; +import com.getcapacitor.PluginConfig; +import com.getcapacitor.PluginMethod; +import com.getcapacitor.annotation.CapacitorPlugin; +import java.io.UnsupportedEncodingException; +import java.net.CookieHandler; +import java.net.HttpCookie; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; + +@CapacitorPlugin +public class CapacitorCookies extends Plugin { + + CapacitorCookieManager cookieManager; + + @Override + public void load() { + this.bridge.getWebView().addJavascriptInterface(this, "CapacitorCookiesAndroidInterface"); + this.cookieManager = new CapacitorCookieManager(null, java.net.CookiePolicy.ACCEPT_ALL, this.bridge); + this.cookieManager.removeSessionCookies(); + CookieHandler.setDefault(this.cookieManager); + super.load(); + } + + @Override + protected void handleOnDestroy() { + super.handleOnDestroy(); + this.cookieManager.removeSessionCookies(); + } + + @JavascriptInterface + public boolean isEnabled() { + PluginConfig pluginConfig = getBridge().getConfig().getPluginConfiguration("CapacitorCookies"); + return pluginConfig.getBoolean("enabled", false); + } + + @JavascriptInterface + public void setCookie(String domain, String action) { + cookieManager.setCookie(domain, action); + } + + @PluginMethod + public void getCookies(PluginCall call) { + this.bridge.eval( + "document.cookie", + value -> { + String cookies = value.substring(1, value.length() - 1); + String[] cookieArray = cookies.split(";"); + + JSObject cookieMap = new JSObject(); + + for (String cookie : cookieArray) { + if (cookie.length() > 0) { + String[] keyValue = cookie.split("=", 2); + + if (keyValue.length == 2) { + String key = keyValue[0].trim(); + String val = keyValue[1].trim(); + try { + key = URLDecoder.decode(keyValue[0].trim(), StandardCharsets.UTF_8.name()); + val = URLDecoder.decode(keyValue[1].trim(), StandardCharsets.UTF_8.name()); + } catch (UnsupportedEncodingException ignored) {} + + cookieMap.put(key, val); + } + } + } + + call.resolve(cookieMap); + } + ); + } + + @PluginMethod + public void setCookie(PluginCall call) { + String key = call.getString("key"); + if (null == key) { + call.reject("Must provide key"); + } + String value = call.getString("value"); + if (null == value) { + call.reject("Must provide value"); + } + String url = call.getString("url"); + String expires = call.getString("expires", ""); + String path = call.getString("path", "/"); + cookieManager.setCookie(url, key, value, expires, path); + call.resolve(); + } + + @PluginMethod + public void deleteCookie(PluginCall call) { + String key = call.getString("key"); + if (null == key) { + call.reject("Must provide key"); + } + String url = call.getString("url"); + cookieManager.setCookie(url, key + "=; Expires=Wed, 31 Dec 2000 23:59:59 GMT"); + call.resolve(); + } + + @PluginMethod + public void clearCookies(PluginCall call) { + String url = call.getString("url"); + HttpCookie[] cookies = cookieManager.getCookies(url); + for (HttpCookie cookie : cookies) { + cookieManager.setCookie(url, cookie.getName() + "=; Expires=Wed, 31 Dec 2000 23:59:59 GMT"); + } + call.resolve(); + } + + @PluginMethod + public void clearAllCookies(PluginCall call) { + cookieManager.removeAllCookies(); + call.resolve(); + } +} diff --git a/capacitor/src/main/java/com/getcapacitor/plugin/CapacitorHttp.java b/capacitor/src/main/java/com/getcapacitor/plugin/CapacitorHttp.java new file mode 100644 index 00000000..46bc1741 --- /dev/null +++ b/capacitor/src/main/java/com/getcapacitor/plugin/CapacitorHttp.java @@ -0,0 +1,119 @@ +package com.getcapacitor.plugin; + +import android.Manifest; +import android.webkit.JavascriptInterface; +import com.getcapacitor.JSObject; +import com.getcapacitor.Plugin; +import com.getcapacitor.PluginCall; +import com.getcapacitor.PluginConfig; +import com.getcapacitor.PluginMethod; +import com.getcapacitor.annotation.CapacitorPlugin; +import com.getcapacitor.annotation.Permission; +import com.getcapacitor.plugin.util.CapacitorHttpUrlConnection; +import com.getcapacitor.plugin.util.HttpRequestHandler; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +@CapacitorPlugin( + permissions = { + @Permission(strings = { Manifest.permission.WRITE_EXTERNAL_STORAGE }, alias = "HttpWrite"), + @Permission(strings = { Manifest.permission.READ_EXTERNAL_STORAGE }, alias = "HttpRead") + } +) +public class CapacitorHttp extends Plugin { + + private final Map activeRequests = new ConcurrentHashMap<>(); + private final ExecutorService executor = Executors.newCachedThreadPool(); + + @Override + public void load() { + this.bridge.getWebView().addJavascriptInterface(this, "CapacitorHttpAndroidInterface"); + super.load(); + } + + @Override + protected void handleOnDestroy() { + super.handleOnDestroy(); + + for (Map.Entry entry : activeRequests.entrySet()) { + Runnable job = entry.getKey(); + PluginCall call = entry.getValue(); + + if (call.getData().has("activeCapacitorHttpUrlConnection")) { + try { + CapacitorHttpUrlConnection connection = (CapacitorHttpUrlConnection) call + .getData() + .get("activeCapacitorHttpUrlConnection"); + connection.disconnect(); + call.getData().remove("activeCapacitorHttpUrlConnection"); + } catch (Exception ignored) {} + } + + getBridge().releaseCall(call); + } + + activeRequests.clear(); + executor.shutdownNow(); + } + + private void http(final PluginCall call, final String httpMethod) { + Runnable asyncHttpCall = new Runnable() { + @Override + public void run() { + try { + JSObject response = HttpRequestHandler.request(call, httpMethod, getBridge()); + call.resolve(response); + } catch (Exception e) { + call.reject(e.getLocalizedMessage(), e.getClass().getSimpleName(), e); + } finally { + activeRequests.remove(this); + } + } + }; + + if (!executor.isShutdown()) { + activeRequests.put(asyncHttpCall, call); + executor.submit(asyncHttpCall); + } else { + call.reject("Failed to execute request - Http Plugin was shutdown"); + } + } + + @JavascriptInterface + public boolean isEnabled() { + PluginConfig pluginConfig = getBridge().getConfig().getPluginConfiguration("CapacitorHttp"); + return pluginConfig.getBoolean("enabled", false); + } + + @PluginMethod + public void request(final PluginCall call) { + this.http(call, null); + } + + @PluginMethod + public void get(final PluginCall call) { + this.http(call, "GET"); + } + + @PluginMethod + public void post(final PluginCall call) { + this.http(call, "POST"); + } + + @PluginMethod + public void put(final PluginCall call) { + this.http(call, "PUT"); + } + + @PluginMethod + public void patch(final PluginCall call) { + this.http(call, "PATCH"); + } + + @PluginMethod + public void delete(final PluginCall call) { + this.http(call, "DELETE"); + } +} diff --git a/capacitor/src/main/java/com/getcapacitor/plugin/WebView.java b/capacitor/src/main/java/com/getcapacitor/plugin/WebView.java new file mode 100644 index 00000000..096d62a5 --- /dev/null +++ b/capacitor/src/main/java/com/getcapacitor/plugin/WebView.java @@ -0,0 +1,48 @@ +package com.getcapacitor.plugin; + +import android.app.Activity; +import android.content.SharedPreferences; +import com.getcapacitor.JSObject; +import com.getcapacitor.Plugin; +import com.getcapacitor.PluginCall; +import com.getcapacitor.PluginMethod; +import com.getcapacitor.annotation.CapacitorPlugin; + +@CapacitorPlugin +public class WebView extends Plugin { + + public static final String WEBVIEW_PREFS_NAME = "CapWebViewSettings"; + public static final String CAP_SERVER_PATH = "serverBasePath"; + + @PluginMethod + public void setServerAssetPath(PluginCall call) { + String path = call.getString("path"); + bridge.setServerAssetPath(path); + call.resolve(); + } + + @PluginMethod + public void setServerBasePath(PluginCall call) { + String path = call.getString("path"); + bridge.setServerBasePath(path); + call.resolve(); + } + + @PluginMethod + public void getServerBasePath(PluginCall call) { + String path = bridge.getServerBasePath(); + JSObject ret = new JSObject(); + ret.put("path", path); + call.resolve(ret); + } + + @PluginMethod + public void persistServerBasePath(PluginCall call) { + String path = bridge.getServerBasePath(); + SharedPreferences prefs = getContext().getSharedPreferences(WEBVIEW_PREFS_NAME, Activity.MODE_PRIVATE); + SharedPreferences.Editor editor = prefs.edit(); + editor.putString(CAP_SERVER_PATH, path); + editor.apply(); + call.resolve(); + } +} diff --git a/capacitor/src/main/java/com/getcapacitor/plugin/util/AssetUtil.java b/capacitor/src/main/java/com/getcapacitor/plugin/util/AssetUtil.java new file mode 100644 index 00000000..3a7043bb --- /dev/null +++ b/capacitor/src/main/java/com/getcapacitor/plugin/util/AssetUtil.java @@ -0,0 +1,358 @@ +package com.getcapacitor.plugin.util; + +import android.content.ContentResolver; +import android.content.Context; +import android.content.res.AssetManager; +import android.content.res.Resources; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.net.Uri; +import android.os.StrictMode; +import androidx.core.content.FileProvider; +import com.getcapacitor.Logger; +import java.io.File; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.HttpURLConnection; +import java.net.MalformedURLException; +import java.net.URL; +import java.util.UUID; + +/** + * Manager for assets. + */ +public final class AssetUtil { + + public static final int RESOURCE_ID_ZERO_VALUE = 0; + // Name of the storage folder + private static final String STORAGE_FOLDER = "/capacitorassets"; + + // Ref to the context passed through the constructor to access the + // resources and app directory. + private final Context context; + + /** + * Constructor + * + * @param context Application context. + */ + private AssetUtil(Context context) { + this.context = context; + } + + /** + * Static method to retrieve class instance. + * + * @param context Application context. + */ + public static AssetUtil getInstance(Context context) { + return new AssetUtil(context); + } + + /** + * The URI for a path. + * + * @param path The given path. + */ + public Uri parse(String path) { + if (path == null || path.isEmpty()) { + return Uri.EMPTY; + } else if (path.startsWith("res:")) { + return getUriForResourcePath(path); + } else if (path.startsWith("file:///")) { + return getUriFromPath(path); + } else if (path.startsWith("file://")) { + return getUriFromAsset(path); + } else if (path.startsWith("http")) { + return getUriFromRemote(path); + } else if (path.startsWith("content://")) { + return Uri.parse(path); + } + + return Uri.EMPTY; + } + + /** + * URI for a file. + * + * @param path Absolute path like file:///... + * + * @return URI pointing to the given path. + */ + private Uri getUriFromPath(String path) { + String absPath = path.replaceFirst("file://", "").replaceFirst("\\?.*$", ""); + File file = new File(absPath); + + if (!file.exists()) { + Logger.error("File not found: " + file.getAbsolutePath()); + return Uri.EMPTY; + } + + return getUriFromFile(file); + } + + /** + * URI for an asset. + * + * @param path Asset path like file://... + * + * @return URI pointing to the given path. + */ + private Uri getUriFromAsset(String path) { + String resPath = path.replaceFirst("file:/", "www").replaceFirst("\\?.*$", ""); + String fileName = resPath.substring(resPath.lastIndexOf('/') + 1); + File file = getTmpFile(fileName); + + if (file == null) return Uri.EMPTY; + + try { + AssetManager assets = context.getAssets(); + InputStream in = assets.open(resPath); + FileOutputStream out = new FileOutputStream(file); + copyFile(in, out); + } catch (Exception e) { + Logger.error("File not found: assets/" + resPath); + return Uri.EMPTY; + } + + return getUriFromFile(file); + } + + /** + * The URI for a resource. + * + * @param path The given relative path. + * + * @return URI pointing to the given path. + */ + private Uri getUriForResourcePath(String path) { + Resources res = context.getResources(); + String resPath = path.replaceFirst("res://", ""); + int resId = getResId(resPath); + + if (resId == 0) { + Logger.error("File not found: " + resPath); + return Uri.EMPTY; + } + + return new Uri.Builder() + .scheme(ContentResolver.SCHEME_ANDROID_RESOURCE) + .authority(res.getResourcePackageName(resId)) + .appendPath(res.getResourceTypeName(resId)) + .appendPath(res.getResourceEntryName(resId)) + .build(); + } + + /** + * Uri from remote located content. + * + * @param path Remote address. + * + * @return Uri of the downloaded file. + */ + private Uri getUriFromRemote(String path) { + File file = getTmpFile(); + + if (file == null) return Uri.EMPTY; + + try { + URL url = new URL(path); + HttpURLConnection connection = (HttpURLConnection) url.openConnection(); + + StrictMode.ThreadPolicy policy = new StrictMode.ThreadPolicy.Builder().permitAll().build(); + + StrictMode.setThreadPolicy(policy); + + connection.setRequestProperty("Connection", "close"); + connection.setConnectTimeout(5000); + connection.connect(); + + InputStream in = connection.getInputStream(); + FileOutputStream out = new FileOutputStream(file); + + copyFile(in, out); + return getUriFromFile(file); + } catch (MalformedURLException e) { + Logger.error(Logger.tags("Asset"), "Incorrect URL", e); + } catch (FileNotFoundException e) { + Logger.error(Logger.tags("Asset"), "Failed to create new File from HTTP Content", e); + } catch (IOException e) { + Logger.error(Logger.tags("Asset"), "No Input can be created from http Stream", e); + } + + return Uri.EMPTY; + } + + /** + * Copy content from input stream into output stream. + * + * @param in The input stream. + * @param out The output stream. + */ + private void copyFile(InputStream in, FileOutputStream out) { + byte[] buffer = new byte[1024]; + int read; + + try { + while ((read = in.read(buffer)) != -1) { + out.write(buffer, 0, read); + } + out.flush(); + out.close(); + } catch (Exception e) { + Logger.error("Error copying", e); + } + } + + /** + * Resource ID for drawable. + * + * @param resPath Resource path as string. + * + * @return The resource ID or 0 if not found. + */ + public int getResId(String resPath) { + int resId = getResId(context.getResources(), resPath); + + if (resId == 0) { + resId = getResId(Resources.getSystem(), resPath); + } + + return resId; + } + + /** + * Get resource ID. + * + * @param res The resources where to look for. + * @param resPath The name of the resource. + * + * @return The resource ID or 0 if not found. + */ + private int getResId(Resources res, String resPath) { + String pkgName = getPkgName(res); + String resName = getBaseName(resPath); + int resId; + + resId = res.getIdentifier(resName, "mipmap", pkgName); + + if (resId == 0) { + resId = res.getIdentifier(resName, "drawable", pkgName); + } + + if (resId == 0) { + resId = res.getIdentifier(resName, "raw", pkgName); + } + + return resId; + } + + /** + * Convert URI to Bitmap. + * + * @param uri Internal image URI + */ + public Bitmap getIconFromUri(Uri uri) throws IOException { + InputStream input = context.getContentResolver().openInputStream(uri); + return BitmapFactory.decodeStream(input); + } + + /** + * Extract name of drawable resource from path. + * + * @param resPath Resource path as string. + */ + private String getBaseName(String resPath) { + String drawable = resPath; + + if (drawable.contains("/")) { + drawable = drawable.substring(drawable.lastIndexOf('/') + 1); + } + + if (resPath.contains(".")) { + drawable = drawable.substring(0, drawable.lastIndexOf('.')); + } + + return drawable; + } + + /** + * Returns a file located under the external cache dir of that app. + * + * @return File with a random UUID name. + */ + private File getTmpFile() { + return getTmpFile(UUID.randomUUID().toString()); + } + + /** + * Returns a file located under the external cache dir of that app. + * + * @param name The name of the file. + * + * @return File with the provided name. + */ + private File getTmpFile(String name) { + File dir = context.getExternalCacheDir(); + + if (dir == null) { + dir = context.getCacheDir(); + } + + if (dir == null) { + Logger.error(Logger.tags("Asset"), "Missing cache dir", null); + return null; + } + + String storage = dir.toString() + STORAGE_FOLDER; + + //noinspection ResultOfMethodCallIgnored + new File(storage).mkdir(); + + return new File(storage, name); + } + + /** + * Get content URI for the specified file. + * + * @param file The file to get the URI. + * + * @return content://... + */ + private Uri getUriFromFile(File file) { + try { + String authority = context.getPackageName() + ".provider"; + return FileProvider.getUriForFile(context, authority, file); + } catch (IllegalArgumentException e) { + Logger.error("File not supported by provider", e); + return Uri.EMPTY; + } + } + + /** + * Package name specified by the resource bundle. + */ + private String getPkgName(Resources res) { + return res == Resources.getSystem() ? "android" : context.getPackageName(); + } + + public static int getResourceID(Context context, String resourceName, String dir) { + return context.getResources().getIdentifier(resourceName, dir, context.getPackageName()); + } + + public static String getResourceBaseName(String resPath) { + if (resPath == null) return null; + + if (resPath.contains("/")) { + return resPath.substring(resPath.lastIndexOf('/') + 1); + } + + if (resPath.contains(".")) { + return resPath.substring(0, resPath.lastIndexOf('.')); + } + + return resPath; + } +} diff --git a/capacitor/src/main/java/com/getcapacitor/plugin/util/CapacitorHttpUrlConnection.java b/capacitor/src/main/java/com/getcapacitor/plugin/util/CapacitorHttpUrlConnection.java new file mode 100644 index 00000000..44115374 --- /dev/null +++ b/capacitor/src/main/java/com/getcapacitor/plugin/util/CapacitorHttpUrlConnection.java @@ -0,0 +1,478 @@ +package com.getcapacitor.plugin.util; + +import android.os.Build; +import android.os.LocaleList; +import android.text.TextUtils; +import com.getcapacitor.Bridge; +import com.getcapacitor.JSArray; +import com.getcapacitor.JSObject; +import com.getcapacitor.JSValue; +import com.getcapacitor.PluginCall; +import java.io.DataOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.lang.reflect.Method; +import java.net.HttpURLConnection; +import java.net.ProtocolException; +import java.net.SocketTimeoutException; +import java.net.URL; +import java.net.URLEncoder; +import java.net.UnknownServiceException; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.Iterator; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import javax.net.ssl.HttpsURLConnection; +import javax.net.ssl.SSLSocketFactory; +import org.json.JSONException; +import org.json.JSONObject; + +public class CapacitorHttpUrlConnection implements ICapacitorHttpUrlConnection { + + private final HttpURLConnection connection; + + /** + * Make a new CapacitorHttpUrlConnection instance, which wraps around HttpUrlConnection + * and provides some helper functions for setting request headers and the request body + * @param conn the base HttpUrlConnection. You can pass the value from + * {@code (HttpUrlConnection) URL.openConnection()} + */ + public CapacitorHttpUrlConnection(HttpURLConnection conn) { + connection = conn; + this.setDefaultRequestProperties(); + } + + /** + * Returns the underlying HttpUrlConnection value + * @return the underlying HttpUrlConnection value + */ + public HttpURLConnection getHttpConnection() { + return connection; + } + + public void disconnect() { + connection.disconnect(); + } + + /** + * Set the value of the {@code allowUserInteraction} field of + * this {@code URLConnection}. + * + * @param isAllowedInteraction the new value. + * @throws IllegalStateException if already connected + */ + public void setAllowUserInteraction(boolean isAllowedInteraction) { + connection.setAllowUserInteraction(isAllowedInteraction); + } + + /** + * Set the method for the URL request, one of: + *

    + *
  • GET + *
  • POST + *
  • HEAD + *
  • OPTIONS + *
  • PUT + *
  • DELETE + *
  • TRACE + *
are legal, subject to protocol restrictions. The default + * method is GET. + * + * @param method the HTTP method + * @exception ProtocolException if the method cannot be reset or if + * the requested method isn't valid for HTTP. + * @exception SecurityException if a security manager is set and the + * method is "TRACE", but the "allowHttpTrace" + * NetPermission is not granted. + */ + public void setRequestMethod(String method) throws ProtocolException { + connection.setRequestMethod(method); + } + + /** + * Sets a specified timeout value, in milliseconds, to be used + * when opening a communications link to the resource referenced + * by this URLConnection. If the timeout expires before the + * connection can be established, a + * java.net.SocketTimeoutException is raised. A timeout of zero is + * interpreted as an infinite timeout. + * + *

Warning: If the hostname resolves to multiple IP + * addresses, Android's default implementation of {@link HttpURLConnection} + * will try each in + * RFC 3484 order. If + * connecting to each of these addresses fails, multiple timeouts will + * elapse before the connect attempt throws an exception. Host names + * that support both IPv6 and IPv4 always have at least 2 IP addresses. + * + * @param timeout an {@code int} that specifies the connect + * timeout value in milliseconds + * @throws IllegalArgumentException if the timeout parameter is negative + */ + public void setConnectTimeout(int timeout) { + if (timeout < 0) { + throw new IllegalArgumentException("timeout can not be negative"); + } + connection.setConnectTimeout(timeout); + } + + /** + * Sets the read timeout to a specified timeout, in + * milliseconds. A non-zero value specifies the timeout when + * reading from Input stream when a connection is established to a + * resource. If the timeout expires before there is data available + * for read, a java.net.SocketTimeoutException is raised. A + * timeout of zero is interpreted as an infinite timeout. + * + * @param timeout an {@code int} that specifies the timeout + * value to be used in milliseconds + * @throws IllegalArgumentException if the timeout parameter is negative + */ + public void setReadTimeout(int timeout) { + if (timeout < 0) { + throw new IllegalArgumentException("timeout can not be negative"); + } + connection.setReadTimeout(timeout); + } + + /** + * Sets whether automatic HTTP redirects should be disabled + * @param disableRedirects the flag to determine if redirects should be followed + */ + public void setDisableRedirects(boolean disableRedirects) { + connection.setInstanceFollowRedirects(!disableRedirects); + } + + /** + * Sets the request headers given a JSObject of key-value pairs + * @param headers the JSObject values to map to the HttpUrlConnection request headers + */ + public void setRequestHeaders(JSObject headers) { + Iterator keys = headers.keys(); + while (keys.hasNext()) { + String key = keys.next(); + String value = headers.getString(key); + connection.setRequestProperty(key, value); + } + } + + /** + * Sets the value of the {@code doOutput} field for this + * {@code URLConnection} to the specified value. + *

+ * A URL connection can be used for input and/or output. Set the DoOutput + * flag to true if you intend to use the URL connection for output, + * false if not. The default is false. + * + * @param shouldDoOutput the new value. + * @throws IllegalStateException if already connected + */ + public void setDoOutput(boolean shouldDoOutput) { + connection.setDoOutput(shouldDoOutput); + } + + /** + * + * @param call + * @throws JSONException + * @throws IOException + */ + public void setRequestBody(PluginCall call, JSValue body) throws JSONException, IOException { + setRequestBody(call, body, null); + } + + /** + * + * @param call + * @throws JSONException + * @throws IOException + */ + public void setRequestBody(PluginCall call, JSValue body, String bodyType) throws JSONException, IOException { + String contentType = connection.getRequestProperty("Content-Type"); + String dataString = ""; + + if (contentType == null || contentType.isEmpty()) return; + + if (contentType.contains("application/json")) { + JSArray jsArray = null; + if (body != null) { + dataString = body.toString(); + } else { + jsArray = call.getArray("data", null); + } + if (jsArray != null) { + dataString = jsArray.toString(); + } else if (body == null) { + dataString = call.getString("data"); + } + this.writeRequestBody(dataString != null ? dataString : ""); + } else if (bodyType != null && bodyType.equals("file")) { + try (DataOutputStream os = new DataOutputStream(connection.getOutputStream())) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + os.write(Base64.getDecoder().decode(body.toString())); + } + os.flush(); + } + } else if (contentType.contains("application/x-www-form-urlencoded")) { + try { + JSObject obj = body.toJSObject(); + this.writeObjectRequestBody(obj); + } catch (Exception e) { + // Body is not a valid JSON, treat it as an already formatted string + this.writeRequestBody(body.toString()); + } + } else if (bodyType != null && bodyType.equals("formData")) { + this.writeFormDataRequestBody(contentType, body.toJSArray()); + } else { + this.writeRequestBody(body.toString()); + } + } + + /** + * Writes the provided string to the HTTP connection managed by this instance. + * + * @param body The string value to write to the connection stream. + */ + private void writeRequestBody(String body) throws IOException { + try (DataOutputStream os = new DataOutputStream(connection.getOutputStream())) { + os.write(body.getBytes(StandardCharsets.UTF_8)); + os.flush(); + } + } + + private void writeObjectRequestBody(JSObject object) throws IOException, JSONException { + try (DataOutputStream os = new DataOutputStream(connection.getOutputStream())) { + Iterator keys = object.keys(); + while (keys.hasNext()) { + String key = keys.next(); + Object d = object.get(key); + os.writeBytes(key); + os.writeBytes("="); + os.writeBytes(URLEncoder.encode(d.toString(), "UTF-8")); + + if (keys.hasNext()) { + os.writeBytes("&"); + } + } + os.flush(); + } + } + + private void writeFormDataRequestBody(String contentType, JSArray entries) throws IOException, JSONException { + try (DataOutputStream os = new DataOutputStream(connection.getOutputStream())) { + String boundary = contentType.split(";")[1].split("=")[1]; + String lineEnd = "\r\n"; + String twoHyphens = "--"; + + for (Object e : entries.toList()) { + if (e instanceof JSONObject) { + JSONObject entry = (JSONObject) e; + String type = entry.getString("type"); + String key = entry.getString("key"); + String value = entry.getString("value"); + if (type.equals("string")) { + os.writeBytes(twoHyphens + boundary + lineEnd); + os.writeBytes("Content-Disposition: form-data; name=\"" + key + "\"" + lineEnd + lineEnd); + os.write(value.getBytes(StandardCharsets.UTF_8)); + os.writeBytes(lineEnd); + } else if (type.equals("base64File")) { + String fileName = entry.getString("fileName"); + String fileContentType = entry.getString("contentType"); + + os.writeBytes(twoHyphens + boundary + lineEnd); + os.writeBytes("Content-Disposition: form-data; name=\"" + key + "\"; filename=\"" + fileName + "\"" + lineEnd); + os.writeBytes("Content-Type: " + fileContentType + lineEnd); + os.writeBytes("Content-Transfer-Encoding: binary" + lineEnd); + os.writeBytes(lineEnd); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + os.write(Base64.getDecoder().decode(value)); + } else { + os.write(android.util.Base64.decode(value, android.util.Base64.DEFAULT)); + } + + os.writeBytes(lineEnd); + } + } + } + + os.writeBytes(twoHyphens + boundary + twoHyphens + lineEnd); + os.flush(); + } + } + + /** + * Opens a communications link to the resource referenced by this + * URL, if such a connection has not already been established. + *

+ * If the {@code connect} method is called when the connection + * has already been opened (indicated by the {@code connected} + * field having the value {@code true}), the call is ignored. + *

+ * URLConnection objects go through two phases: first they are + * created, then they are connected. After being created, and + * before being connected, various options can be specified + * (e.g., doInput and UseCaches). After connecting, it is an + * error to try to set them. Operations that depend on being + * connected, like getContentLength, will implicitly perform the + * connection, if necessary. + * + * @throws SocketTimeoutException if the timeout expires before + * the connection can be established + * @exception IOException if an I/O error occurs while opening the + * connection. + */ + public void connect() throws IOException { + connection.connect(); + } + + /** + * Gets the status code from an HTTP response message. + * For example, in the case of the following status lines: + *

+     * HTTP/1.0 200 OK
+     * HTTP/1.0 401 Unauthorized
+     * 
+ * It will return 200 and 401 respectively. + * Returns -1 if no code can be discerned + * from the response (i.e., the response is not valid HTTP). + * @throws IOException if an error occurred connecting to the server. + * @return the HTTP Status-Code, or -1 + */ + public int getResponseCode() throws IOException { + return connection.getResponseCode(); + } + + /** + * Returns the value of this {@code URLConnection}'s {@code URL} + * field. + * + * @return the value of this {@code URLConnection}'s {@code URL} + * field. + */ + public URL getURL() { + return connection.getURL(); + } + + /** + * Returns the error stream if the connection failed + * but the server sent useful data nonetheless. The + * typical example is when an HTTP server responds + * with a 404, which will cause a FileNotFoundException + * to be thrown in connect, but the server sent an HTML + * help page with suggestions as to what to do. + * + *

This method will not cause a connection to be initiated. If + * the connection was not connected, or if the server did not have + * an error while connecting or if the server had an error but + * no error data was sent, this method will return null. This is + * the default. + * + * @return an error stream if any, null if there have been no + * errors, the connection is not connected or the server sent no + * useful data. + */ + @Override + public InputStream getErrorStream() { + return connection.getErrorStream(); + } + + /** + * Returns the value of the named header field. + *

+ * If called on a connection that sets the same header multiple times + * with possibly different values, only the last value is returned. + * + * + * @param name the name of a header field. + * @return the value of the named header field, or {@code null} + * if there is no such field in the header. + */ + @Override + public String getHeaderField(String name) { + return connection.getHeaderField(name); + } + + /** + * Returns an input stream that reads from this open connection. + * + * A SocketTimeoutException can be thrown when reading from the + * returned input stream if the read timeout expires before data + * is available for read. + * + * @return an input stream that reads from this open connection. + * @exception IOException if an I/O error occurs while + * creating the input stream. + * @exception UnknownServiceException if the protocol does not support + * input. + * @see #setReadTimeout(int) + */ + @Override + public InputStream getInputStream() throws IOException { + return connection.getInputStream(); + } + + /** + * Returns an unmodifiable Map of the header fields. + * The Map keys are Strings that represent the + * response-header field names. Each Map value is an + * unmodifiable List of Strings that represents + * the corresponding field values. + * + * @return a Map of header fields + */ + public Map> getHeaderFields() { + return connection.getHeaderFields(); + } + + /** + * Sets the default request properties on the newly created connection. + * This is called as early as possible to allow overrides by user-provided values. + */ + private void setDefaultRequestProperties() { + String acceptLanguage = buildDefaultAcceptLanguageProperty(); + if (!TextUtils.isEmpty(acceptLanguage)) { + connection.setRequestProperty("Accept-Language", acceptLanguage); + } + } + + /** + * Builds and returns a locale string describing the device's current locale preferences. + */ + private String buildDefaultAcceptLanguageProperty() { + Locale locale; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + locale = LocaleList.getDefault().get(0); + } else { + locale = Locale.getDefault(); + } + String result = ""; + String lang = locale.getLanguage(); + String country = locale.getCountry(); + if (!TextUtils.isEmpty(lang)) { + if (!TextUtils.isEmpty(country)) { + result = String.format("%s-%s,%s;q=0.5", lang, country, lang); + } else { + result = String.format("%s;q=0.5", lang); + } + } + return result; + } + + public void setSSLSocketFactory(Bridge bridge) { + // Attach SSL Certificates if Enterprise Plugin is available + try { + Class sslPinningImpl = Class.forName("io.ionic.sslpinning.SSLPinning"); + Method method = sslPinningImpl.getDeclaredMethod("getSSLSocketFactory", Bridge.class); + SSLSocketFactory sslSocketFactory = (SSLSocketFactory) method.invoke( + sslPinningImpl.getDeclaredConstructor().newInstance(), + bridge + ); + if (sslSocketFactory != null) { + ((HttpsURLConnection) this.connection).setSSLSocketFactory(sslSocketFactory); + } + } catch (Exception ignored) {} + } +} diff --git a/capacitor/src/main/java/com/getcapacitor/plugin/util/HttpRequestHandler.java b/capacitor/src/main/java/com/getcapacitor/plugin/util/HttpRequestHandler.java new file mode 100644 index 00000000..be72678c --- /dev/null +++ b/capacitor/src/main/java/com/getcapacitor/plugin/util/HttpRequestHandler.java @@ -0,0 +1,452 @@ +package com.getcapacitor.plugin.util; + +import android.text.TextUtils; +import android.util.Base64; +import com.getcapacitor.Bridge; +import com.getcapacitor.JSArray; +import com.getcapacitor.JSObject; +import com.getcapacitor.JSValue; +import com.getcapacitor.PluginCall; +import java.io.BufferedReader; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.UnsupportedEncodingException; +import java.lang.reflect.Method; +import java.net.HttpURLConnection; +import java.net.MalformedURLException; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.URL; +import java.net.URLEncoder; +import java.util.Iterator; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +public class HttpRequestHandler { + + /** + * An enum specifying conventional HTTP Response Types + * See https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest/responseType + */ + public enum ResponseType { + ARRAY_BUFFER("arraybuffer"), + BLOB("blob"), + DOCUMENT("document"), + JSON("json"), + TEXT("text"); + + private final String name; + + ResponseType(String name) { + this.name = name; + } + + static final ResponseType DEFAULT = TEXT; + + public static ResponseType parse(String value) { + for (ResponseType responseType : values()) { + if (responseType.name.equalsIgnoreCase(value)) { + return responseType; + } + } + return DEFAULT; + } + } + + /** + * Internal builder class for building a CapacitorHttpUrlConnection + */ + public static class HttpURLConnectionBuilder { + + public Integer connectTimeout; + public Integer readTimeout; + public Boolean disableRedirects; + public JSObject headers; + public String method; + public URL url; + + public CapacitorHttpUrlConnection connection; + + public HttpURLConnectionBuilder setConnectTimeout(Integer connectTimeout) { + this.connectTimeout = connectTimeout; + return this; + } + + public HttpURLConnectionBuilder setReadTimeout(Integer readTimeout) { + this.readTimeout = readTimeout; + return this; + } + + public HttpURLConnectionBuilder setDisableRedirects(Boolean disableRedirects) { + this.disableRedirects = disableRedirects; + return this; + } + + public HttpURLConnectionBuilder setHeaders(JSObject headers) { + this.headers = headers; + return this; + } + + public HttpURLConnectionBuilder setMethod(String method) { + this.method = method; + return this; + } + + public HttpURLConnectionBuilder setUrl(URL url) { + this.url = url; + return this; + } + + public HttpURLConnectionBuilder openConnection() throws IOException { + connection = new CapacitorHttpUrlConnection((HttpURLConnection) url.openConnection()); + + connection.setAllowUserInteraction(false); + connection.setRequestMethod(method); + + if (connectTimeout != null) connection.setConnectTimeout(connectTimeout); + if (readTimeout != null) connection.setReadTimeout(readTimeout); + if (disableRedirects != null) connection.setDisableRedirects(disableRedirects); + + connection.setRequestHeaders(headers); + return this; + } + + public HttpURLConnectionBuilder setUrlParams(JSObject params) throws MalformedURLException, URISyntaxException, JSONException { + return this.setUrlParams(params, true); + } + + public HttpURLConnectionBuilder setUrlParams(JSObject params, boolean shouldEncode) + throws URISyntaxException, MalformedURLException { + String initialQuery = url.getQuery(); + String initialQueryBuilderStr = initialQuery == null ? "" : initialQuery; + + Iterator keys = params.keys(); + + if (!keys.hasNext()) { + return this; + } + + StringBuilder urlQueryBuilder = new StringBuilder(initialQueryBuilderStr); + + // Build the new query string + while (keys.hasNext()) { + String key = keys.next(); + + // Attempt as JSONArray and fallback to string if it fails + try { + StringBuilder value = new StringBuilder(); + JSONArray arr = params.getJSONArray(key); + for (int x = 0; x < arr.length(); x++) { + this.addUrlParam(value, key, arr.getString(x), shouldEncode); + if (x != arr.length() - 1) { + value.append("&"); + } + } + if (urlQueryBuilder.length() > 0) { + urlQueryBuilder.append("&"); + } + urlQueryBuilder.append(value); + } catch (JSONException e) { + if (urlQueryBuilder.length() > 0) { + urlQueryBuilder.append("&"); + } + this.addUrlParam(urlQueryBuilder, key, params.getString(key), shouldEncode); + } + } + + String urlQuery = urlQueryBuilder.toString(); + + URI uri = url.toURI(); + String unEncodedUrlString = + uri.getScheme() + + "://" + + uri.getAuthority() + + uri.getPath() + + ((!urlQuery.equals("")) ? "?" + urlQuery : "") + + ((uri.getFragment() != null) ? uri.getFragment() : ""); + this.url = new URL(unEncodedUrlString); + + return this; + } + + private static void addUrlParam(StringBuilder sb, String key, String value, boolean shouldEncode) { + if (shouldEncode) { + try { + key = URLEncoder.encode(key, "UTF-8"); + value = URLEncoder.encode(value, "UTF-8"); + } catch (UnsupportedEncodingException ex) { + throw new RuntimeException(ex.getCause()); + } + } + sb.append(key).append("=").append(value); + } + + public CapacitorHttpUrlConnection build() { + return connection; + } + } + + /** + * Builds an HTTP Response given CapacitorHttpUrlConnection and ResponseType objects. + * Defaults to ResponseType.DEFAULT + * @param connection The CapacitorHttpUrlConnection to respond with + * @throws IOException Thrown if the InputStream is unable to be parsed correctly + * @throws JSONException Thrown if the JSON is unable to be parsed + */ + public static JSObject buildResponse(CapacitorHttpUrlConnection connection) throws IOException, JSONException { + return buildResponse(connection, ResponseType.DEFAULT); + } + + /** + * Builds an HTTP Response given CapacitorHttpUrlConnection and ResponseType objects + * @param connection The CapacitorHttpUrlConnection to respond with + * @param responseType The requested ResponseType + * @return A JSObject that contains the HTTPResponse to return to the browser + * @throws IOException Thrown if the InputStream is unable to be parsed correctly + * @throws JSONException Thrown if the JSON is unable to be parsed + */ + public static JSObject buildResponse(CapacitorHttpUrlConnection connection, ResponseType responseType) + throws IOException, JSONException { + int statusCode = connection.getResponseCode(); + + JSObject output = new JSObject(); + output.put("status", statusCode); + output.put("headers", buildResponseHeaders(connection)); + output.put("url", connection.getURL()); + output.put("data", readData(connection, responseType)); + + InputStream errorStream = connection.getErrorStream(); + if (errorStream != null) { + output.put("error", true); + } + + return output; + } + + /** + * Read the existing ICapacitorHttpUrlConnection data + * @param connection The ICapacitorHttpUrlConnection object to read in + * @param responseType The type of HTTP response to return to the API + * @return The parsed data from the connection + * @throws IOException Thrown if the InputStreams cannot be properly parsed + * @throws JSONException Thrown if the JSON is malformed when parsing as JSON + */ + public static Object readData(ICapacitorHttpUrlConnection connection, ResponseType responseType) throws IOException, JSONException { + InputStream errorStream = connection.getErrorStream(); + String contentType = connection.getHeaderField("Content-Type"); + + if (errorStream != null) { + if (isOneOf(contentType, MimeType.APPLICATION_JSON, MimeType.APPLICATION_VND_API_JSON)) { + return parseJSON(readStreamAsString(errorStream)); + } else { + return readStreamAsString(errorStream); + } + } else if (contentType != null && contentType.contains(MimeType.APPLICATION_JSON.getValue())) { + // backward compatibility + return parseJSON(readStreamAsString(connection.getInputStream())); + } else { + InputStream stream = connection.getInputStream(); + switch (responseType) { + case ARRAY_BUFFER: + case BLOB: + return readStreamAsBase64(stream); + case JSON: + return parseJSON(readStreamAsString(stream)); + case DOCUMENT: + case TEXT: + default: + return readStreamAsString(stream); + } + } + } + + /** + * Helper function for determining if the Content-Type is a typeof an existing Mime-Type + * @param contentType The Content-Type string to check for + * @param mimeTypes The Mime-Type values to check against + * @return + */ + public static boolean isOneOf(String contentType, MimeType... mimeTypes) { + if (contentType != null) { + for (MimeType mimeType : mimeTypes) { + if (contentType.contains(mimeType.getValue())) { + return true; + } + } + } + return false; + } + + /** + * Build the JSObject response headers based on the connection header map + * @param connection The CapacitorHttpUrlConnection connection + * @return A JSObject of the header values from the CapacitorHttpUrlConnection + */ + public static JSObject buildResponseHeaders(CapacitorHttpUrlConnection connection) { + JSObject output = new JSObject(); + + for (Map.Entry> entry : connection.getHeaderFields().entrySet()) { + String valuesString = TextUtils.join(", ", entry.getValue()); + output.put(entry.getKey(), valuesString); + } + + return output; + } + + /** + * Returns a JSObject or a JSArray based on a string-ified input + * @param input String-ified JSON that needs parsing + * @return A JSObject or JSArray + * @throws JSONException thrown if the JSON is malformed + */ + public static Object parseJSON(String input) throws JSONException { + JSONObject json = new JSONObject(); + try { + if ("null".equals(input.trim())) { + return JSONObject.NULL; + } else if ("true".equals(input.trim())) { + return true; + } else if ("false".equals(input.trim())) { + return false; + } else if (input.trim().length() <= 0) { + return ""; + } else if (input.trim().matches("^\".*\"$")) { + // a string enclosed in " " is a json value, return the string without the quotes + return input.trim().substring(1, input.trim().length() - 1); + } else if (input.trim().matches("^-?\\d+$")) { + return Integer.parseInt(input.trim()); + } else if (input.trim().matches("^-?\\d+(\\.\\d+)?$")) { + return Double.parseDouble(input.trim()); + } else { + try { + return new JSObject(input); + } catch (JSONException e) { + return new JSArray(input); + } + } + } catch (JSONException e) { + return input; + } + } + + /** + * Returns a string based on a base64 InputStream + * @param in The base64 InputStream to convert to a String + * @return String value of InputStream + * @throws IOException thrown if the InputStream is unable to be read as base64 + */ + public static String readStreamAsBase64(InputStream in) throws IOException { + try (ByteArrayOutputStream out = new ByteArrayOutputStream()) { + byte[] buffer = new byte[1024]; + int readBytes; + while ((readBytes = in.read(buffer)) != -1) { + out.write(buffer, 0, readBytes); + } + byte[] result = out.toByteArray(); + return Base64.encodeToString(result, 0, result.length, Base64.DEFAULT); + } + } + + /** + * Returns a string based on an InputStream + * @param in The InputStream to convert to a String + * @return String value of InputStream + * @throws IOException thrown if the InputStream is unable to be read + */ + public static String readStreamAsString(InputStream in) throws IOException { + try (BufferedReader reader = new BufferedReader(new InputStreamReader(in))) { + StringBuilder builder = new StringBuilder(); + String line = reader.readLine(); + while (line != null) { + builder.append(line); + line = reader.readLine(); + if (line != null) { + builder.append(System.getProperty("line.separator")); + } + } + return builder.toString(); + } + } + + /** + * Makes an Http Request based on the PluginCall parameters + * @param call The Capacitor PluginCall that contains the options need for an Http request + * @param httpMethod The HTTP method that overrides the PluginCall HTTP method + * @throws IOException throws an IO request when a connection can't be made + * @throws URISyntaxException thrown when the URI is malformed + * @throws JSONException thrown when the incoming JSON is malformed + */ + public static JSObject request(PluginCall call, String httpMethod, Bridge bridge) + throws IOException, URISyntaxException, JSONException { + String urlString = call.getString("url", ""); + JSObject headers = call.getObject("headers", new JSObject()); + JSObject params = call.getObject("params", new JSObject()); + Integer connectTimeout = call.getInt("connectTimeout"); + Integer readTimeout = call.getInt("readTimeout"); + Boolean disableRedirects = call.getBoolean("disableRedirects"); + Boolean shouldEncode = call.getBoolean("shouldEncodeUrlParams", true); + ResponseType responseType = ResponseType.parse(call.getString("responseType")); + String dataType = call.getString("dataType"); + + String method = httpMethod != null ? httpMethod.toUpperCase(Locale.ROOT) : call.getString("method", "GET").toUpperCase(Locale.ROOT); + + boolean isHttpMutate = method.equals("DELETE") || method.equals("PATCH") || method.equals("POST") || method.equals("PUT"); + + URL url = new URL(urlString); + HttpURLConnectionBuilder connectionBuilder = new HttpURLConnectionBuilder() + .setUrl(url) + .setMethod(method) + .setHeaders(headers) + .setUrlParams(params, shouldEncode) + .setConnectTimeout(connectTimeout) + .setReadTimeout(readTimeout) + .setDisableRedirects(disableRedirects) + .openConnection(); + + CapacitorHttpUrlConnection connection = connectionBuilder.build(); + + if (null != bridge && !isDomainExcludedFromSSL(bridge, url)) { + connection.setSSLSocketFactory(bridge); + } + + // Set HTTP body on a non GET or HEAD request + if (isHttpMutate) { + JSValue data = new JSValue(call, "data"); + if (data.getValue() != null) { + connection.setDoOutput(true); + connection.setRequestBody(call, data, dataType); + } + } + + call.getData().put("activeCapacitorHttpUrlConnection", connection); + connection.connect(); + + JSObject response = buildResponse(connection, responseType); + + connection.disconnect(); + call.getData().remove("activeCapacitorHttpUrlConnection"); + + return response; + } + + public static Boolean isDomainExcludedFromSSL(Bridge bridge, URL url) { + try { + Class sslPinningImpl = Class.forName("io.ionic.sslpinning.SSLPinning"); + Method method = sslPinningImpl.getDeclaredMethod("isDomainExcluded", Bridge.class, URL.class); + return (Boolean) method.invoke(sslPinningImpl.getDeclaredConstructor().newInstance(), bridge, url); + } catch (Exception ignored) { + return false; + } + } + + @FunctionalInterface + public interface ProgressEmitter { + void emit(Integer bytes, Integer contentLength); + } +} diff --git a/capacitor/src/main/java/com/getcapacitor/plugin/util/ICapacitorHttpUrlConnection.java b/capacitor/src/main/java/com/getcapacitor/plugin/util/ICapacitorHttpUrlConnection.java new file mode 100644 index 00000000..4ed8881a --- /dev/null +++ b/capacitor/src/main/java/com/getcapacitor/plugin/util/ICapacitorHttpUrlConnection.java @@ -0,0 +1,15 @@ +package com.getcapacitor.plugin.util; + +import java.io.IOException; +import java.io.InputStream; + +/** + * This interface was extracted from {@link CapacitorHttpUrlConnection} to enable mocking that class. + */ +public interface ICapacitorHttpUrlConnection { + InputStream getErrorStream(); + + String getHeaderField(String name); + + InputStream getInputStream() throws IOException; +} diff --git a/capacitor/src/main/java/com/getcapacitor/plugin/util/MimeType.java b/capacitor/src/main/java/com/getcapacitor/plugin/util/MimeType.java new file mode 100644 index 00000000..cfc90f82 --- /dev/null +++ b/capacitor/src/main/java/com/getcapacitor/plugin/util/MimeType.java @@ -0,0 +1,17 @@ +package com.getcapacitor.plugin.util; + +enum MimeType { + APPLICATION_JSON("application/json"), + APPLICATION_VND_API_JSON("application/vnd.api+json"), // https://jsonapi.org + TEXT_HTML("text/html"); + + private final String value; + + MimeType(String value) { + this.value = value; + } + + String getValue() { + return value; + } +} diff --git a/capacitor/src/main/java/com/getcapacitor/util/HostMask.java b/capacitor/src/main/java/com/getcapacitor/util/HostMask.java new file mode 100644 index 00000000..486d0fd0 --- /dev/null +++ b/capacitor/src/main/java/com/getcapacitor/util/HostMask.java @@ -0,0 +1,123 @@ +package com.getcapacitor.util; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +public interface HostMask { + boolean matches(String host); + + class Parser { + + private static HostMask NOTHING = new Nothing(); + + public static HostMask parse(String[] masks) { + return masks == null ? NOTHING : HostMask.Any.parse(masks); + } + + public static HostMask parse(String mask) { + return mask == null ? NOTHING : HostMask.Simple.parse(mask); + } + } + + class Simple implements HostMask { + + private final List maskParts; + + private Simple(List maskParts) { + if (maskParts == null) { + throw new IllegalArgumentException("Mask parts can not be null"); + } + this.maskParts = maskParts; + } + + static Simple parse(String mask) { + List parts = Util.splitAndReverse(mask); + return new Simple(parts); + } + + @Override + public boolean matches(String host) { + if (host == null) { + return false; + } + List hostParts = Util.splitAndReverse(host); + int hostSize = hostParts.size(); + int maskSize = maskParts.size(); + if (maskSize > 1 && hostSize != maskSize) { + return false; + } + + int minSize = Math.min(hostSize, maskSize); + + for (int i = 0; i < minSize; i++) { + String maskPart = maskParts.get(i); + String hostPart = hostParts.get(i); + if (!Util.matches(maskPart, hostPart)) { + return false; + } + } + return true; + } + } + + class Any implements HostMask { + + private final List masks; + + Any(List masks) { + this.masks = masks; + } + + @Override + public boolean matches(String host) { + for (HostMask mask : masks) { + if (mask.matches(host)) { + return true; + } + } + return false; + } + + static Any parse(String... rawMasks) { + List masks = new ArrayList<>(); + for (String raw : rawMasks) { + masks.add(HostMask.Simple.parse(raw)); + } + return new Any(masks); + } + } + + class Nothing implements HostMask { + + @Override + public boolean matches(String host) { + return false; + } + } + + class Util { + + static boolean matches(String mask, String string) { + if (mask == null) { + return false; + } else if ("*".equals(mask)) { + return true; + } else if (string == null) { + return false; + } else { + return mask.toUpperCase().equals(string.toUpperCase()); + } + } + + static List splitAndReverse(String string) { + if (string == null) { + throw new IllegalArgumentException("Can not split null argument"); + } + List parts = Arrays.asList(string.split("\\.")); + Collections.reverse(parts); + return parts; + } + } +} diff --git a/capacitor/src/main/java/com/getcapacitor/util/InternalUtils.java b/capacitor/src/main/java/com/getcapacitor/util/InternalUtils.java new file mode 100644 index 00000000..b7354159 --- /dev/null +++ b/capacitor/src/main/java/com/getcapacitor/util/InternalUtils.java @@ -0,0 +1,27 @@ +package com.getcapacitor.util; + +import android.content.pm.PackageInfo; +import android.content.pm.PackageManager; +import android.os.Build; + +public class InternalUtils { + + public static PackageInfo getPackageInfo(PackageManager pm, String packageName) throws PackageManager.NameNotFoundException { + return InternalUtils.getPackageInfo(pm, packageName, 0); + } + + public static PackageInfo getPackageInfo(PackageManager pm, String packageName, long flags) + throws PackageManager.NameNotFoundException { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + return pm.getPackageInfo(packageName, PackageManager.PackageInfoFlags.of(flags)); + } else { + return getPackageInfoLegacy(pm, packageName, (int) flags); + } + } + + @SuppressWarnings("deprecation") + private static PackageInfo getPackageInfoLegacy(PackageManager pm, String packageName, long flags) + throws PackageManager.NameNotFoundException { + return pm.getPackageInfo(packageName, (int) flags); + } +} diff --git a/capacitor/src/main/java/com/getcapacitor/util/JSONUtils.java b/capacitor/src/main/java/com/getcapacitor/util/JSONUtils.java new file mode 100644 index 00000000..1d2fc207 --- /dev/null +++ b/capacitor/src/main/java/com/getcapacitor/util/JSONUtils.java @@ -0,0 +1,166 @@ +package com.getcapacitor.util; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +/** + * Helper methods for parsing JSON objects. + */ +public class JSONUtils { + + /** + * Get a string value from the given JSON object. + * + * @param jsonObject A JSON object to search + * @param key A key to fetch from the JSON object + * @param defaultValue A default value to return if the key cannot be found + * @return The value at the given key in the JSON object, or the default value + */ + public static String getString(JSONObject jsonObject, String key, String defaultValue) { + String k = getDeepestKey(key); + try { + JSONObject o = getDeepestObject(jsonObject, key); + + String value = o.getString(k); + if (value == null) { + return defaultValue; + } + return value; + } catch (JSONException ignore) { + // value was not found + } + + return defaultValue; + } + + /** + * Get a boolean value from the given JSON object. + * + * @param jsonObject A JSON object to search + * @param key A key to fetch from the JSON object + * @param defaultValue A default value to return if the key cannot be found + * @return The value at the given key in the JSON object, or the default value + */ + public static boolean getBoolean(JSONObject jsonObject, String key, boolean defaultValue) { + String k = getDeepestKey(key); + try { + JSONObject o = getDeepestObject(jsonObject, key); + + return o.getBoolean(k); + } catch (JSONException ignore) { + // value was not found + } + + return defaultValue; + } + + /** + * Get an int value from the given JSON object. + * + * @param jsonObject A JSON object to search + * @param key A key to fetch from the JSON object + * @param defaultValue A default value to return if the key cannot be found + * @return The value at the given key in the JSON object, or the default value + */ + public static int getInt(JSONObject jsonObject, String key, int defaultValue) { + String k = getDeepestKey(key); + try { + JSONObject o = getDeepestObject(jsonObject, key); + return o.getInt(k); + } catch (JSONException ignore) { + // value was not found + } + + return defaultValue; + } + + /** + * Get a JSON object value from the given JSON object. + * + * @param jsonObject A JSON object to search + * @param key A key to fetch from the JSON object + * @return The value from the config, if exists. Null if not + */ + public static JSONObject getObject(JSONObject jsonObject, String key) { + String k = getDeepestKey(key); + try { + JSONObject o = getDeepestObject(jsonObject, key); + + return o.getJSONObject(k); + } catch (JSONException ignore) { + // value was not found + } + + return null; + } + + /** + * Get a string array value from the given JSON object. + * + * @param jsonObject A JSON object to search + * @param key A key to fetch from the JSON object + * @param defaultValue A default value to return if the key cannot be found + * @return The value at the given key in the JSON object, or the default value + */ + public static String[] getArray(JSONObject jsonObject, String key, String[] defaultValue) { + String k = getDeepestKey(key); + try { + JSONObject o = getDeepestObject(jsonObject, key); + + JSONArray a = o.getJSONArray(k); + if (a == null) { + return defaultValue; + } + + int l = a.length(); + String[] value = new String[l]; + + for (int i = 0; i < l; i++) { + value[i] = (String) a.get(i); + } + + return value; + } catch (JSONException ignore) { + // value was not found + } + + return defaultValue; + } + + /** + * Given a JSON key path, gets the deepest key. + * + * @param key The key path + * @return The deepest key + */ + private static String getDeepestKey(String key) { + String[] parts = key.split("\\."); + if (parts.length > 0) { + return parts[parts.length - 1]; + } + + return null; + } + + /** + * Given a JSON object and key path, gets the deepest object in the path. + * + * @param jsonObject A JSON object + * @param key The key path to follow + * @return The deepest object along the key path + * @throws JSONException Thrown if any JSON errors + */ + private static JSONObject getDeepestObject(JSONObject jsonObject, String key) throws JSONException { + String[] parts = key.split("\\."); + JSONObject o = jsonObject; + + // Search until the second to last part of the key + for (int i = 0; i < parts.length - 1; i++) { + String k = parts[i]; + o = o.getJSONObject(k); + } + + return o; + } +} diff --git a/capacitor/src/main/java/com/getcapacitor/util/PermissionHelper.java b/capacitor/src/main/java/com/getcapacitor/util/PermissionHelper.java new file mode 100644 index 00000000..e7b83321 --- /dev/null +++ b/capacitor/src/main/java/com/getcapacitor/util/PermissionHelper.java @@ -0,0 +1,114 @@ +package com.getcapacitor.util; + +import android.content.Context; +import android.content.pm.PackageInfo; +import android.content.pm.PackageManager; +import androidx.core.app.ActivityCompat; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +/** + * A helper class for checking permissions. + * + * @since 3.0.0 + */ +public class PermissionHelper { + + /** + * Checks if a list of given permissions are all granted by the user + * + * @since 3.0.0 + * @param permissions Permissions to check. + * @return True if all permissions are granted, false if at least one is not. + */ + public static boolean hasPermissions(Context context, String[] permissions) { + for (String perm : permissions) { + if (ActivityCompat.checkSelfPermission(context, perm) != PackageManager.PERMISSION_GRANTED) { + return false; + } + } + return true; + } + + /** + * Check whether the given permission has been defined in the AndroidManifest.xml + * + * @since 3.0.0 + * @param permission A permission to check. + * @return True if the permission has been defined in the Manifest, false if not. + */ + public static boolean hasDefinedPermission(Context context, String permission) { + boolean hasPermission = false; + String[] requestedPermissions = PermissionHelper.getManifestPermissions(context); + if (requestedPermissions != null && requestedPermissions.length > 0) { + List requestedPermissionsList = Arrays.asList(requestedPermissions); + ArrayList requestedPermissionsArrayList = new ArrayList<>(requestedPermissionsList); + if (requestedPermissionsArrayList.contains(permission)) { + hasPermission = true; + } + } + return hasPermission; + } + + /** + * Check whether all of the given permissions have been defined in the AndroidManifest.xml + * @param context the app context + * @param permissions a list of permissions + * @return true only if all permissions are defined in the AndroidManifest.xml + */ + public static boolean hasDefinedPermissions(Context context, String[] permissions) { + for (String permission : permissions) { + if (!PermissionHelper.hasDefinedPermission(context, permission)) { + return false; + } + } + + return true; + } + + /** + * Get the permissions defined in AndroidManifest.xml + * + * @since 3.0.0 + * @return The permissions defined in AndroidManifest.xml + */ + public static String[] getManifestPermissions(Context context) { + String[] requestedPermissions = null; + try { + PackageManager pm = context.getPackageManager(); + PackageInfo packageInfo = InternalUtils.getPackageInfo(pm, context.getPackageName(), PackageManager.GET_PERMISSIONS); + + if (packageInfo != null) { + requestedPermissions = packageInfo.requestedPermissions; + } + } catch (Exception ex) {} + return requestedPermissions; + } + + /** + * Given a list of permissions, return a new list with the ones not present in AndroidManifest.xml + * + * @since 3.0.0 + * @param neededPermissions The permissions needed. + * @return The permissions not present in AndroidManifest.xml + */ + public static String[] getUndefinedPermissions(Context context, String[] neededPermissions) { + ArrayList undefinedPermissions = new ArrayList<>(); + String[] requestedPermissions = getManifestPermissions(context); + if (requestedPermissions != null && requestedPermissions.length > 0) { + List requestedPermissionsList = Arrays.asList(requestedPermissions); + ArrayList requestedPermissionsArrayList = new ArrayList<>(requestedPermissionsList); + for (String permission : neededPermissions) { + if (!requestedPermissionsArrayList.contains(permission)) { + undefinedPermissions.add(permission); + } + } + String[] undefinedPermissionArray = new String[undefinedPermissions.size()]; + undefinedPermissionArray = undefinedPermissions.toArray(undefinedPermissionArray); + + return undefinedPermissionArray; + } + return neededPermissions; + } +} diff --git a/capacitor/src/main/java/com/getcapacitor/util/WebColor.java b/capacitor/src/main/java/com/getcapacitor/util/WebColor.java new file mode 100644 index 00000000..e055021e --- /dev/null +++ b/capacitor/src/main/java/com/getcapacitor/util/WebColor.java @@ -0,0 +1,28 @@ +package com.getcapacitor.util; + +import android.graphics.Color; + +public class WebColor { + + /** + * Parse the color string, and return the corresponding color-int. If the string cannot be parsed, throws an IllegalArgumentException exception. + * @param colorString The hexadecimal color string. The format is an RGB or RGBA hex string. + * @return The corresponding color as an int. + */ + public static int parseColor(String colorString) { + String formattedColor = colorString; + if (colorString.charAt(0) != '#') { + formattedColor = "#" + formattedColor; + } + + if (formattedColor.length() != 7 && formattedColor.length() != 9) { + throw new IllegalArgumentException("The encoded color space is invalid or unknown"); + } else if (formattedColor.length() == 7) { + return Color.parseColor(formattedColor); + } else { + // Convert to Android format #AARRGGBB from #RRGGBBAA + formattedColor = "#" + formattedColor.substring(7) + formattedColor.substring(1, 7); + return Color.parseColor(formattedColor); + } + } +} diff --git a/capacitor/src/main/res/layout/bridge_layout_main.xml b/capacitor/src/main/res/layout/bridge_layout_main.xml new file mode 100644 index 00000000..12f0b8fc --- /dev/null +++ b/capacitor/src/main/res/layout/bridge_layout_main.xml @@ -0,0 +1,15 @@ + + + + + + diff --git a/capacitor/src/main/res/layout/fragment_bridge.xml b/capacitor/src/main/res/layout/fragment_bridge.xml new file mode 100644 index 00000000..b6123ea8 --- /dev/null +++ b/capacitor/src/main/res/layout/fragment_bridge.xml @@ -0,0 +1,13 @@ + + + + + diff --git a/capacitor/src/main/res/layout/no_webview.xml b/capacitor/src/main/res/layout/no_webview.xml new file mode 100644 index 00000000..7228cc2f --- /dev/null +++ b/capacitor/src/main/res/layout/no_webview.xml @@ -0,0 +1,14 @@ + + + + + \ No newline at end of file diff --git a/capacitor/src/main/res/values/attrs.xml b/capacitor/src/main/res/values/attrs.xml new file mode 100644 index 00000000..23a10371 --- /dev/null +++ b/capacitor/src/main/res/values/attrs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/capacitor/src/main/res/values/colors.xml b/capacitor/src/main/res/values/colors.xml new file mode 100644 index 00000000..347d6088 --- /dev/null +++ b/capacitor/src/main/res/values/colors.xml @@ -0,0 +1,6 @@ + + + #3F51B5 + #303F9F + #FF4081 + diff --git a/capacitor/src/main/res/values/strings.xml b/capacitor/src/main/res/values/strings.xml new file mode 100644 index 00000000..2db1111d --- /dev/null +++ b/capacitor/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + This app requires a WebView to work + diff --git a/capacitor/src/main/res/values/styles.xml b/capacitor/src/main/res/values/styles.xml new file mode 100644 index 00000000..d3268920 --- /dev/null +++ b/capacitor/src/main/res/values/styles.xml @@ -0,0 +1,6 @@ + + +