From 2b7ed4d9c741969ac18dd732aab15101e935de0a Mon Sep 17 00:00:00 2001 From: Tachibana Shin Date: Sun, 4 Aug 2024 19:41:49 +0000 Subject: [PATCH] Add core classes for Capacitor framework in Java - Introduced `Bridge` class to manage loading and communication with plugins, proxying native events, and interacting with the WebView. - Added `Plugin` base class for creating new plugins, including features for interacting with the `Bridge`, managing permissions, and tracking lifecycle events. - Implemented `WebViewLocalServer` to host assets, resources, and other data on virtual URLs, compatible with the Same-Origin policy. - Included various utility classes and methods to support the core functionality, such as `CapConfig`, `JSInjector`, `AndroidProtocolHandler`, and more. - Added annotations and helper classes for managing permissions and activity results. - Removed obsolete `bun.lockb` files. --- .vscode/settings.json | 5 +- @capacitor/capacitor/.classpath | 6 + .../.gradle/8.8/checksums/checksums.lock | Bin 0 -> 17 bytes .../.gradle/8.8/checksums/md5-checksums.bin | Bin 0 -> 27997 bytes .../.gradle/8.8/checksums/sha1-checksums.bin | Bin 0 -> 40907 bytes .../8.8/dependencies-accessors/gc.properties | 0 .../.gradle/8.8/fileChanges/last-build.bin | Bin 0 -> 1 bytes .../.gradle/8.8/fileHashes/fileHashes.lock | Bin 0 -> 17 bytes .../capacitor/.gradle/8.8/gc.properties | 0 .../.gradle/8.9/checksums/checksums.lock | Bin 0 -> 38 bytes .../.gradle/8.9/checksums/md5-checksums.bin | Bin 0 -> 21497 bytes .../.gradle/8.9/checksums/sha1-checksums.bin | Bin 0 -> 29945 bytes .../.gradle/8.9/fileHashes/fileHashes.lock | Bin 0 -> 38 bytes .../buildOutputCleanup.lock | Bin 0 -> 17 bytes .../buildOutputCleanup/cache.properties | 2 + .../capacitor/.gradle/vcs-1/gc.properties | 0 @capacitor/capacitor/.project | 34 + .../org.eclipse.buildship.core.prefs | 13 + @capacitor/capacitor/build.gradle | 96 + @capacitor/capacitor/lint-baseline.xml | 136 ++ @capacitor/capacitor/lint.xml | 9 + @capacitor/capacitor/proguard-rules.pro | 28 + .../capacitor/src/main/AndroidManifest.xml | 3 + .../src/main/assets/native-bridge.js | 1047 +++++++++++ .../getcapacitor/AndroidProtocolHandler.java | 94 + .../src/main/java/com/getcapacitor/App.java | 61 + .../main/java/com/getcapacitor/AppUUID.java | 65 + .../main/java/com/getcapacitor/Bridge.java | 1568 +++++++++++++++++ .../java/com/getcapacitor/BridgeActivity.java | 197 +++ .../java/com/getcapacitor/BridgeFragment.java | 134 ++ .../getcapacitor/BridgeWebChromeClient.java | 510 ++++++ .../com/getcapacitor/BridgeWebViewClient.java | 111 ++ .../main/java/com/getcapacitor/CapConfig.java | 670 +++++++ .../com/getcapacitor/CapacitorWebView.java | 52 + .../main/java/com/getcapacitor/FileUtils.java | 292 +++ .../getcapacitor/InvalidPluginException.java | 8 + .../InvalidPluginMethodException.java | 16 + .../main/java/com/getcapacitor/JSArray.java | 51 + .../main/java/com/getcapacitor/JSExport.java | 193 ++ .../com/getcapacitor/JSExportException.java | 16 + .../java/com/getcapacitor/JSInjector.java | 107 ++ .../main/java/com/getcapacitor/JSObject.java | 164 ++ .../main/java/com/getcapacitor/JSValue.java | 65 + .../main/java/com/getcapacitor/Logger.java | 103 ++ .../java/com/getcapacitor/MessageHandler.java | 159 ++ .../java/com/getcapacitor/NativePlugin.java | 37 + .../com/getcapacitor/PermissionState.java | 31 + .../main/java/com/getcapacitor/Plugin.java | 1046 +++++++++++ .../java/com/getcapacitor/PluginCall.java | 440 +++++ .../java/com/getcapacitor/PluginConfig.java | 116 ++ .../java/com/getcapacitor/PluginHandle.java | 160 ++ .../PluginInvocationException.java | 16 + .../com/getcapacitor/PluginLoadException.java | 19 + .../java/com/getcapacitor/PluginManager.java | 56 + .../java/com/getcapacitor/PluginMethod.java | 15 + .../com/getcapacitor/PluginMethodHandle.java | 33 + .../java/com/getcapacitor/PluginResult.java | 84 + .../java/com/getcapacitor/ProcessedRoute.java | 37 + .../java/com/getcapacitor/RouteProcessor.java | 8 + .../java/com/getcapacitor/ServerPath.java | 25 + .../java/com/getcapacitor/UriMatcher.java | 180 ++ .../com/getcapacitor/WebViewListener.java | 57 + .../com/getcapacitor/WebViewLocalServer.java | 878 +++++++++ .../annotation/ActivityCallback.java | 11 + .../annotation/CapacitorPlugin.java | 35 + .../getcapacitor/annotation/Permission.java | 22 + .../annotation/PermissionCallback.java | 11 + .../CapacitorCordovaCookieManager.java | 42 + .../cordova/MockCordovaInterfaceImpl.java | 39 + .../cordova/MockCordovaWebViewImpl.java | 284 +++ .../plugin/CapacitorCookieManager.java | 236 +++ .../getcapacitor/plugin/CapacitorCookies.java | 137 ++ .../getcapacitor/plugin/CapacitorHttp.java | 119 ++ .../java/com/getcapacitor/plugin/WebView.java | 48 + .../getcapacitor/plugin/util/AssetUtil.java | 358 ++++ .../util/CapacitorHttpUrlConnection.java | 475 +++++ .../plugin/util/HttpRequestHandler.java | 452 +++++ .../util/ICapacitorHttpUrlConnection.java | 15 + .../getcapacitor/plugin/util/MimeType.java | 17 + .../java/com/getcapacitor/util/HostMask.java | 123 ++ .../com/getcapacitor/util/InternalUtils.java | 27 + .../java/com/getcapacitor/util/JSONUtils.java | 166 ++ .../getcapacitor/util/PermissionHelper.java | 114 ++ .../java/com/getcapacitor/util/WebColor.java | 28 + .../main/res/layout/bridge_layout_main.xml | 15 + .../src/main/res/layout/fragment_bridge.xml | 13 + .../capacitor/src/main/res/values/attrs.xml | 6 + .../capacitor/src/main/res/values/colors.xml | 6 + .../capacitor/src/main/res/values/strings.xml | 2 + .../capacitor/src/main/res/values/styles.xml | 6 + bun.lockb | Bin 659178 -> 659178 bytes package.json | 4 +- quasar.config.ts | 4 +- .../android/app/capacitor.build.gradle | 1 + .../app/src/main/assets/capacitor.config.json | 4 +- .../src/main/assets/capacitor.plugins.json | 4 + .../java/git/shin/animevsub/MainActivity.java | 11 +- .../git/shin/animevsub/ResolvePlugin.java | 112 ++ .../android/capacitor.settings.gradle | 23 +- src-capacitor/bun.lockb | Bin 41351 -> 42114 bytes src-capacitor/capacitor.config.json | 4 +- .../ios/App/App/capacitor.config.json | 2 +- src-capacitor/ios/App/Podfile | 12 +- .../CordovaPlugins.podspec | 4 +- .../CordovaPluginsStatic.podspec | 4 +- src-capacitor/package.json | 2 + src/App.vue | 7 - src/apis/runs/ajax/player-link.ts | 2 +- src/boot/patch-request.ts | 4 + src/components/BrtPlayer.vue | 24 +- src/components/QImgCustom.ts | 2 +- src/global.d.ts | 5 + src/logic/patch-request.ts | 7 + src/logic/resolve-master-manifest.worker.ts | 12 +- src/pages/phim/_season.vue | 4 +- 115 files changed, 12260 insertions(+), 58 deletions(-) create mode 100644 @capacitor/capacitor/.classpath create mode 100644 @capacitor/capacitor/.gradle/8.8/checksums/checksums.lock create mode 100644 @capacitor/capacitor/.gradle/8.8/checksums/md5-checksums.bin create mode 100644 @capacitor/capacitor/.gradle/8.8/checksums/sha1-checksums.bin create mode 100644 @capacitor/capacitor/.gradle/8.8/dependencies-accessors/gc.properties create mode 100644 @capacitor/capacitor/.gradle/8.8/fileChanges/last-build.bin create mode 100644 @capacitor/capacitor/.gradle/8.8/fileHashes/fileHashes.lock create mode 100644 @capacitor/capacitor/.gradle/8.8/gc.properties create mode 100644 @capacitor/capacitor/.gradle/8.9/checksums/checksums.lock create mode 100644 @capacitor/capacitor/.gradle/8.9/checksums/md5-checksums.bin create mode 100644 @capacitor/capacitor/.gradle/8.9/checksums/sha1-checksums.bin create mode 100644 @capacitor/capacitor/.gradle/8.9/fileHashes/fileHashes.lock create mode 100644 @capacitor/capacitor/.gradle/buildOutputCleanup/buildOutputCleanup.lock create mode 100644 @capacitor/capacitor/.gradle/buildOutputCleanup/cache.properties create mode 100644 @capacitor/capacitor/.gradle/vcs-1/gc.properties create mode 100644 @capacitor/capacitor/.project create mode 100644 @capacitor/capacitor/.settings/org.eclipse.buildship.core.prefs create mode 100644 @capacitor/capacitor/build.gradle create mode 100644 @capacitor/capacitor/lint-baseline.xml create mode 100644 @capacitor/capacitor/lint.xml create mode 100644 @capacitor/capacitor/proguard-rules.pro create mode 100644 @capacitor/capacitor/src/main/AndroidManifest.xml create mode 100644 @capacitor/capacitor/src/main/assets/native-bridge.js create mode 100644 @capacitor/capacitor/src/main/java/com/getcapacitor/AndroidProtocolHandler.java create mode 100644 @capacitor/capacitor/src/main/java/com/getcapacitor/App.java create mode 100644 @capacitor/capacitor/src/main/java/com/getcapacitor/AppUUID.java create mode 100644 @capacitor/capacitor/src/main/java/com/getcapacitor/Bridge.java create mode 100644 @capacitor/capacitor/src/main/java/com/getcapacitor/BridgeActivity.java create mode 100644 @capacitor/capacitor/src/main/java/com/getcapacitor/BridgeFragment.java create mode 100644 @capacitor/capacitor/src/main/java/com/getcapacitor/BridgeWebChromeClient.java create mode 100644 @capacitor/capacitor/src/main/java/com/getcapacitor/BridgeWebViewClient.java create mode 100644 @capacitor/capacitor/src/main/java/com/getcapacitor/CapConfig.java create mode 100644 @capacitor/capacitor/src/main/java/com/getcapacitor/CapacitorWebView.java create mode 100644 @capacitor/capacitor/src/main/java/com/getcapacitor/FileUtils.java create mode 100644 @capacitor/capacitor/src/main/java/com/getcapacitor/InvalidPluginException.java create mode 100644 @capacitor/capacitor/src/main/java/com/getcapacitor/InvalidPluginMethodException.java create mode 100644 @capacitor/capacitor/src/main/java/com/getcapacitor/JSArray.java create mode 100644 @capacitor/capacitor/src/main/java/com/getcapacitor/JSExport.java create mode 100644 @capacitor/capacitor/src/main/java/com/getcapacitor/JSExportException.java create mode 100644 @capacitor/capacitor/src/main/java/com/getcapacitor/JSInjector.java create mode 100644 @capacitor/capacitor/src/main/java/com/getcapacitor/JSObject.java create mode 100644 @capacitor/capacitor/src/main/java/com/getcapacitor/JSValue.java create mode 100644 @capacitor/capacitor/src/main/java/com/getcapacitor/Logger.java create mode 100644 @capacitor/capacitor/src/main/java/com/getcapacitor/MessageHandler.java create mode 100644 @capacitor/capacitor/src/main/java/com/getcapacitor/NativePlugin.java create mode 100644 @capacitor/capacitor/src/main/java/com/getcapacitor/PermissionState.java create mode 100644 @capacitor/capacitor/src/main/java/com/getcapacitor/Plugin.java create mode 100644 @capacitor/capacitor/src/main/java/com/getcapacitor/PluginCall.java create mode 100644 @capacitor/capacitor/src/main/java/com/getcapacitor/PluginConfig.java create mode 100644 @capacitor/capacitor/src/main/java/com/getcapacitor/PluginHandle.java create mode 100644 @capacitor/capacitor/src/main/java/com/getcapacitor/PluginInvocationException.java create mode 100644 @capacitor/capacitor/src/main/java/com/getcapacitor/PluginLoadException.java create mode 100644 @capacitor/capacitor/src/main/java/com/getcapacitor/PluginManager.java create mode 100644 @capacitor/capacitor/src/main/java/com/getcapacitor/PluginMethod.java create mode 100644 @capacitor/capacitor/src/main/java/com/getcapacitor/PluginMethodHandle.java create mode 100644 @capacitor/capacitor/src/main/java/com/getcapacitor/PluginResult.java create mode 100644 @capacitor/capacitor/src/main/java/com/getcapacitor/ProcessedRoute.java create mode 100644 @capacitor/capacitor/src/main/java/com/getcapacitor/RouteProcessor.java create mode 100644 @capacitor/capacitor/src/main/java/com/getcapacitor/ServerPath.java create mode 100644 @capacitor/capacitor/src/main/java/com/getcapacitor/UriMatcher.java create mode 100644 @capacitor/capacitor/src/main/java/com/getcapacitor/WebViewListener.java create mode 100644 @capacitor/capacitor/src/main/java/com/getcapacitor/WebViewLocalServer.java create mode 100644 @capacitor/capacitor/src/main/java/com/getcapacitor/annotation/ActivityCallback.java create mode 100644 @capacitor/capacitor/src/main/java/com/getcapacitor/annotation/CapacitorPlugin.java create mode 100644 @capacitor/capacitor/src/main/java/com/getcapacitor/annotation/Permission.java create mode 100644 @capacitor/capacitor/src/main/java/com/getcapacitor/annotation/PermissionCallback.java create mode 100644 @capacitor/capacitor/src/main/java/com/getcapacitor/cordova/CapacitorCordovaCookieManager.java create mode 100644 @capacitor/capacitor/src/main/java/com/getcapacitor/cordova/MockCordovaInterfaceImpl.java create mode 100644 @capacitor/capacitor/src/main/java/com/getcapacitor/cordova/MockCordovaWebViewImpl.java create mode 100644 @capacitor/capacitor/src/main/java/com/getcapacitor/plugin/CapacitorCookieManager.java create mode 100644 @capacitor/capacitor/src/main/java/com/getcapacitor/plugin/CapacitorCookies.java create mode 100644 @capacitor/capacitor/src/main/java/com/getcapacitor/plugin/CapacitorHttp.java create mode 100644 @capacitor/capacitor/src/main/java/com/getcapacitor/plugin/WebView.java create mode 100644 @capacitor/capacitor/src/main/java/com/getcapacitor/plugin/util/AssetUtil.java create mode 100644 @capacitor/capacitor/src/main/java/com/getcapacitor/plugin/util/CapacitorHttpUrlConnection.java create mode 100644 @capacitor/capacitor/src/main/java/com/getcapacitor/plugin/util/HttpRequestHandler.java create mode 100644 @capacitor/capacitor/src/main/java/com/getcapacitor/plugin/util/ICapacitorHttpUrlConnection.java create mode 100644 @capacitor/capacitor/src/main/java/com/getcapacitor/plugin/util/MimeType.java create mode 100644 @capacitor/capacitor/src/main/java/com/getcapacitor/util/HostMask.java create mode 100644 @capacitor/capacitor/src/main/java/com/getcapacitor/util/InternalUtils.java create mode 100644 @capacitor/capacitor/src/main/java/com/getcapacitor/util/JSONUtils.java create mode 100644 @capacitor/capacitor/src/main/java/com/getcapacitor/util/PermissionHelper.java create mode 100644 @capacitor/capacitor/src/main/java/com/getcapacitor/util/WebColor.java create mode 100644 @capacitor/capacitor/src/main/res/layout/bridge_layout_main.xml create mode 100644 @capacitor/capacitor/src/main/res/layout/fragment_bridge.xml create mode 100644 @capacitor/capacitor/src/main/res/values/attrs.xml create mode 100644 @capacitor/capacitor/src/main/res/values/colors.xml create mode 100644 @capacitor/capacitor/src/main/res/values/strings.xml create mode 100644 @capacitor/capacitor/src/main/res/values/styles.xml create mode 100644 src-capacitor/android/app/src/main/java/git/shin/animevsub/ResolvePlugin.java create mode 100644 src/boot/patch-request.ts create mode 100644 src/global.d.ts create mode 100644 src/logic/patch-request.ts diff --git a/.vscode/settings.json b/.vscode/settings.json index f63c92d9..e0b1325c 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -14,5 +14,8 @@ "i18n-ally.keystyle": "nested", "editor.tabCompletion": "on", "diffEditor.codeLens": true, - "MutableAI.upsell": true + "MutableAI.upsell": true, + "[java]": { + "editor.defaultFormatter": "redhat.java" + } } diff --git a/@capacitor/capacitor/.classpath b/@capacitor/capacitor/.classpath new file mode 100644 index 00000000..bbe97e50 --- /dev/null +++ b/@capacitor/capacitor/.classpath @@ -0,0 +1,6 @@ + + + + + + diff --git a/@capacitor/capacitor/.gradle/8.8/checksums/checksums.lock b/@capacitor/capacitor/.gradle/8.8/checksums/checksums.lock new file mode 100644 index 0000000000000000000000000000000000000000..87e74b7784b2ccf09884de8180075d4e0b08203c GIT binary patch literal 17 VcmZQp&6_Q$wq5cR0~jzR0stiA18x8S literal 0 HcmV?d00001 diff --git a/@capacitor/capacitor/.gradle/8.8/checksums/md5-checksums.bin b/@capacitor/capacitor/.gradle/8.8/checksums/md5-checksums.bin new file mode 100644 index 0000000000000000000000000000000000000000..83158ff13057e130584d2b106d3a3c84220dcccc GIT binary patch literal 27997 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 JnC_y+{{!-c(xm_Z literal 0 HcmV?d00001 diff --git a/@capacitor/capacitor/.gradle/8.8/checksums/sha1-checksums.bin b/@capacitor/capacitor/.gradle/8.8/checksums/sha1-checksums.bin new file mode 100644 index 0000000000000000000000000000000000000000..3ded0464cabbeaa0dfd97333795f34877df8f7be GIT binary patch literal 40907 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

nRnT((U%(Mf+)ldrU^qe+kZR-{atT==XQ;a zg=&qJzI@u^HP->HB5Mc|&*h+EyQUEHp~q^=|GF~l8(mW?vj z$EoAu=JTBoSDjGLV_8t+Ie@rxtLW;T0^2jdy-X2z?R9K&*jeBY+#?Th4;dlfX1DMO zaF;>E`C}gH)P#3k2kzR3_@ao|(oEHP1Hj$&5nru>6Tk`J1aJa40h|C%04IPGzzN_4Z~{01oB&P$ zCx8>c3E%{90yqJj08RiWfD^z8-~@02I02jhP5>u>6MzKFh!-Q`p+`R075TRgAIpjR zGH5=OwRwpyamTGp(!>M#3D9@iLgMwO|22$tg0(U=BBLVB&94{!7((3hOd8epV3>u{ zjX?e{etOP%+m^2>tQBM&x(P<7F&GNR=!SFrqn3!YH=&s~&so0RGY}2N`z2toROp6p zV`*yrv8=02rVEAksS(5k$x*$J0E12LG?1Fdl!1T)_O0oi;+F!`Udvz;$`FE~MDAIT zGW=7|wH01jDO~X+-(kx{?|EQ!{{)8G4!WV)Q2OflvQg_gEsdYEgpw&>e6$2ZfxLfO zs>b5_$l>OC+wH>IJHblY%bdXIQ3r!Hn{JpN`fI&x+Pw+vjh<jVaIPJAUji^pJmb^~LitUmY2 zPMvSIbFYoe#rCZ7mFbj0-EpnBfLN;bKOs+-~@5+J$VE_UxFH=^5}-L zn$3)>kzae94f-1Nm3N&5<7+$^isT6{HIIWqFMRupzFc{$G4q08Y6OucS>r2t|}@yscU=!!$B(;*q3jU<{HO5lw}>(_N~@l%yVSuX^!qjT0s71#feSy&7bV z!L8o}hFliiFv|RRXQ(L7C;prU>!zVaCm6#HlyR7Da4qpsyT_?$zKkF687dnRD4$sm~;8R8g z7|Q$THRO}NCQIsCv%h3}ZdF?HJDlw)tb1TE$sHY1=M_gjq+m`;pW_x+tH+b9Fa82G z)aHOOhTQ8UWn3KEP~uW%I(tT|M*EKAX~eDuavo|_R;=}OWAN20rDc0Js#a~8o08t$ zI~5G}EU2MCuGmsFtRL!}clIk{4o^!|dm0&$OBv+KMl?mzlrl61CtIBkT+;mU7Y_k$<*9+&k#ey+bmh8`9D2;G^JUy($XV%AZ zB6UPrcl|R*Q3m-%Ml>~YpNMpgv;FDaTRoJQJd}OEzj`%wmS~!P6Bz0b=tg<*?Sf9j z&)1Y!&8QLAdgMS24wcnt^6HhU5z_dVwv(mURrK)H@;qigF@AF96P`m2tr5<|d{L{- zs#@FSX969f!yOe@gJJkP7z*3zH4o_B^Hc-SJRE-TX;zU zQ*N@e*BlFI+?q@5iy>z|ot!bznBK&bRE&@?lpeJXT%+Ut z>{b|c1ih5OkUv5<{(j0E$M%0*>@8a1qrd1Y@vDNY;iU?O(rvo2;6;3>o>R+;rFD

AdU@c7-TqO{lM|16Bp{<447qHw#(x2uKG`t< literal 0 HcmV?d00001 diff --git a/@capacitor/capacitor/.gradle/8.9/checksums/sha1-checksums.bin b/@capacitor/capacitor/.gradle/8.9/checksums/sha1-checksums.bin new file mode 100644 index 0000000000000000000000000000000000000000..76c72ef2e933483821e834d3c75912a4613bcaab GIT binary patch literal 29945 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 literal 0 HcmV?d00001 diff --git a/@capacitor/capacitor/.gradle/8.9/fileHashes/fileHashes.lock b/@capacitor/capacitor/.gradle/8.9/fileHashes/fileHashes.lock new file mode 100644 index 0000000000000000000000000000000000000000..26964b4a98672c7ca794cd6e06fb52da3444e4ed GIT binary patch literal 38 ocmZSH>|ZP6_Ith#0~9bbFx>pq(s}03&bxe;3@j$*7A6b~0Na`i1^@s6 literal 0 HcmV?d00001 diff --git a/@capacitor/capacitor/.gradle/buildOutputCleanup/buildOutputCleanup.lock b/@capacitor/capacitor/.gradle/buildOutputCleanup/buildOutputCleanup.lock new file mode 100644 index 0000000000000000000000000000000000000000..83e249718295207aad9493dd9a735288df558385 GIT binary patch literal 17 TcmZRsmzH?=H&&^N0Ror+ECB + + capacitor-android + Project capacitor-android created by Buildship. + + + + + org.eclipse.jdt.core.javabuilder + + + + + org.eclipse.buildship.core.gradleprojectbuilder + + + + + + org.eclipse.jdt.core.javanature + org.eclipse.buildship.core.gradleprojectnature + + + + 1722756069615 + + 30 + + org.eclipse.core.resources.regexFilterMatcher + node_modules|\.git|__CREATED_BY_JAVA_LANGUAGE_SERVER__ + + + + diff --git a/@capacitor/capacitor/.settings/org.eclipse.buildship.core.prefs b/@capacitor/capacitor/.settings/org.eclipse.buildship.core.prefs new file mode 100644 index 00000000..278010ab --- /dev/null +++ b/@capacitor/capacitor/.settings/org.eclipse.buildship.core.prefs @@ -0,0 +1,13 @@ +arguments=--init-script /home/gitpod/.vscode-server/data/User/globalStorage/redhat.java/1.33.0/config_linux/org.eclipse.osgi/55/0/.cp/gradle/init/init.gradle --init-script /home/gitpod/.vscode-server/data/User/globalStorage/redhat.java/1.33.0/config_linux/org.eclipse.osgi/55/0/.cp/gradle/protobuf/init.gradle +auto.sync=false +build.scans.enabled=false +connection.gradle.distribution=GRADLE_DISTRIBUTION(LOCAL_INSTALLATION(/home/gitpod/.sdkman/candidates/gradle/current)) +connection.project.dir= +eclipse.preferences.version=1 +gradle.user.home=/workspace/.gradle +java.home=/home/gitpod/.sdkman/candidates/java/11.0.23.fx-zulu +jvm.arguments= +offline.mode=false +override.workspace.settings=true +show.console.view=true +show.executions.view=true diff --git a/@capacitor/capacitor/build.gradle b/@capacitor/capacitor/build.gradle new file mode 100644 index 00000000..5e2a4011 --- /dev/null +++ b/@capacitor/capacitor/build.gradle @@ -0,0 +1,96 @@ +ext { + androidxActivityVersion = project.hasProperty('androidxActivityVersion') ? rootProject.ext.androidxActivityVersion : '1.7.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.10.0' + androidxFragmentVersion = project.hasProperty('androidxFragmentVersion') ? rootProject.ext.androidxFragmentVersion : '1.5.6' + androidxWebkitVersion = project.hasProperty('androidxWebkitVersion') ? rootProject.ext.androidxWebkitVersion : '1.6.1' + 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.8.20' + repositories { + google() + mavenCentral() + maven { + url "https://plugins.gradle.org/m2/" + } + } + dependencies { + classpath 'com.android.tools.build:gradle:8.0.0' + + 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" + compileSdkVersion project.hasProperty('compileSdkVersion') ? rootProject.ext.compileSdkVersion : 33 + defaultConfig { + minSdkVersion project.hasProperty('minSdkVersion') ? rootProject.ext.minSdkVersion : 22 + targetSdkVersion project.hasProperty('targetSdkVersion') ? rootProject.ext.targetSdkVersion : 33 + 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/capacitor/lint-baseline.xml b/@capacitor/capacitor/lint-baseline.xml new file mode 100644 index 00000000..c1ed9ccb --- /dev/null +++ b/@capacitor/capacitor/lint-baseline.xml @@ -0,0 +1,136 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/@capacitor/capacitor/lint.xml b/@capacitor/capacitor/lint.xml new file mode 100644 index 00000000..b00604ba --- /dev/null +++ b/@capacitor/capacitor/lint.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/@capacitor/capacitor/proguard-rules.pro b/@capacitor/capacitor/proguard-rules.pro new file mode 100644 index 00000000..96db065b --- /dev/null +++ b/@capacitor/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/capacitor/src/main/AndroidManifest.xml b/@capacitor/capacitor/src/main/AndroidManifest.xml new file mode 100644 index 00000000..74b7379f --- /dev/null +++ b/@capacitor/capacitor/src/main/AndroidManifest.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/@capacitor/capacitor/src/main/assets/native-bridge.js b/@capacitor/capacitor/src/main/assets/native-bridge.js new file mode 100644 index 00000000..7cee6e1d --- /dev/null +++ b/@capacitor/capacitor/src/main/assets/native-bridge.js @@ -0,0 +1,1047 @@ + +/*! 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); + const boundary = `${Date.now()}`; + return { + data: formData, + type: 'formData', + headers: { + 'Content-Type': `multipart/form-data; boundary=--${boundary}`, + }, + }; + } + 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_HTTPS_INTERCEPTOR = '/_capacitor_https_interceptor_'; + // TODO: export as Cap function + const isRelativeOrProxyUrl = (url) => !url || + !(url.startsWith('http:') || url.startsWith('https:')) || + url.indexOf(CAPACITOR_HTTP_INTERCEPTOR) > -1 || + url.indexOf(CAPACITOR_HTTPS_INTERCEPTOR) > -1; + // TODO: export as Cap function + const createProxyUrl = (url, win) => { + var _a, _b; + if (isRelativeOrProxyUrl(url)) + return url; + const proxyUrl = new URL(url); + const bridgeUrl = new URL((_b = (_a = win.Capacitor) === null || _a === void 0 ? void 0 : _a.getServerUrl()) !== null && _b !== void 0 ? _b : ''); + const isHttps = proxyUrl.protocol === 'https:'; + bridgeUrl.search = proxyUrl.search; + bridgeUrl.hash = proxyUrl.hash; + bridgeUrl.pathname = `${isHttps ? CAPACITOR_HTTPS_INTERCEPTOR : CAPACITOR_HTTP_INTERCEPTOR}/${encodeURIComponent(proxyUrl.host)}${proxyUrl.pathname}`; + 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; + const responseString = typeof nativeResponse.data !== 'string' + ? JSON.stringify(nativeResponse.data) + : nativeResponse.data; + if (this.responseType === '' || + this.responseType === 'text') { + this.response = responseString; + } + else if (this.responseType === 'blob') { + this.response = new Blob([responseString], { + type: 'application/json', + }); + } + else if (this.responseType === 'arraybuffer') { + const encoder = new TextEncoder(); + const uint8Array = encoder.encode(responseString); + this.response = uint8Array.buffer; + } + else { + this.response = nativeResponse.data; + } + this.responseText = ((_a = 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.toLowerCase() !== '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); + } + for (const key in this._headers) { + if (key.toLowerCase() === name.toLowerCase()) { + return this._headers[key]; + } + } + return null; + }; + 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/capacitor/src/main/java/com/getcapacitor/AndroidProtocolHandler.java b/@capacitor/capacitor/src/main/java/com/getcapacitor/AndroidProtocolHandler.java new file mode 100644 index 00000000..df893c7f --- /dev/null +++ b/@capacitor/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/capacitor/src/main/java/com/getcapacitor/App.java b/@capacitor/capacitor/src/main/java/com/getcapacitor/App.java new file mode 100644 index 00000000..f46b6332 --- /dev/null +++ b/@capacitor/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/capacitor/src/main/java/com/getcapacitor/AppUUID.java b/@capacitor/capacitor/src/main/java/com/getcapacitor/AppUUID.java new file mode 100644 index 00000000..3c1b1db6 --- /dev/null +++ b/@capacitor/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/capacitor/src/main/java/com/getcapacitor/Bridge.java b/@capacitor/capacitor/src/main/java/com/getcapacitor/Bridge.java new file mode 100644 index 00000000..1b01134c --- /dev/null +++ b/@capacitor/capacitor/src/main/java/com/getcapacitor/Bridge.java @@ -0,0 +1,1568 @@ +package com.getcapacitor; + +import android.annotation.SuppressLint; +import android.app.Activity; +import android.content.ActivityNotFoundException; +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.content.pm.ApplicationInfo; +import android.content.pm.PackageInfo; +import android.content.pm.PackageManager; +import android.content.res.Configuration; +import android.net.Uri; +import android.os.Build; +import android.os.Bundle; +import android.os.Handler; +import android.os.HandlerThread; +import android.webkit.ValueCallback; +import android.webkit.WebSettings; +import android.webkit.WebView; +import androidx.activity.result.ActivityResultCallback; +import androidx.activity.result.ActivityResultLauncher; +import androidx.activity.result.contract.ActivityResultContract; +import androidx.annotation.NonNull; +import androidx.appcompat.app.AppCompatActivity; +import androidx.core.app.ActivityCompat; +import androidx.core.content.pm.PackageInfoCompat; +import androidx.fragment.app.Fragment; +import com.getcapacitor.android.R; +import com.getcapacitor.annotation.CapacitorPlugin; +import com.getcapacitor.annotation.Permission; +import com.getcapacitor.cordova.MockCordovaInterfaceImpl; +import com.getcapacitor.cordova.MockCordovaWebViewImpl; +import com.getcapacitor.util.HostMask; +import com.getcapacitor.util.InternalUtils; +import com.getcapacitor.util.PermissionHelper; +import com.getcapacitor.util.WebColor; +import java.io.File; +import java.net.SocketTimeoutException; +import java.net.URL; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.apache.cordova.ConfigXmlParser; +import org.apache.cordova.CordovaPreferences; +import org.apache.cordova.CordovaWebView; +import org.apache.cordova.PluginEntry; +import org.apache.cordova.PluginManager; +import org.json.JSONException; + +/** + * The Bridge class is the main engine of Capacitor. It manages + * loading and communicating with all Plugins, + * proxying Native events to Plugins, executing Plugin methods, + * communicating with the WebView, and a whole lot more. + * + * Generally, you'll not use Bridge directly, instead, extend from BridgeActivity + * to get a WebView instance and proxy native events automatically. + * + * If you want to use this Bridge in an existing Android app, please + * see the source for BridgeActivity for the methods you'll need to + * pass through to Bridge: + * + * BridgeActivity.java + */ +public class Bridge { + + private static final String PREFS_NAME = "CapacitorSettings"; + private static final String PERMISSION_PREFS_NAME = "PluginPermStates"; + private static final String BUNDLE_LAST_PLUGIN_ID_KEY = "capacitorLastActivityPluginId"; + private static final String BUNDLE_LAST_PLUGIN_CALL_METHOD_NAME_KEY = "capacitorLastActivityPluginMethod"; + private static final String BUNDLE_PLUGIN_CALL_OPTIONS_SAVED_KEY = "capacitorLastPluginCallOptions"; + private static final String BUNDLE_PLUGIN_CALL_BUNDLE_KEY = "capacitorLastPluginCallBundle"; + private static final String LAST_BINARY_VERSION_CODE = "lastBinaryVersionCode"; + private static final String LAST_BINARY_VERSION_NAME = "lastBinaryVersionName"; + private static final String MINIMUM_ANDROID_WEBVIEW_ERROR = "System WebView is not supported"; + + // The name of the directory we use to look for index.html and the rest of our web assets + public static final String DEFAULT_WEB_ASSET_DIR = "public"; + public static final String CAPACITOR_HTTP_SCHEME = "http"; + public static final String CAPACITOR_HTTPS_SCHEME = "https"; + public static final String CAPACITOR_FILE_START = "/_capacitor_file_"; + public static final String CAPACITOR_CONTENT_START = "/_capacitor_content_"; + public static final String CAPACITOR_HTTP_INTERCEPTOR_START = "/_capacitor_http_interceptor_"; + public static final String CAPACITOR_HTTPS_INTERCEPTOR_START = "/_capacitor_https_interceptor_"; + + public static final int DEFAULT_ANDROID_WEBVIEW_VERSION = 60; + public static final int MINIMUM_ANDROID_WEBVIEW_VERSION = 55; + public static final int DEFAULT_HUAWEI_WEBVIEW_VERSION = 10; + public static final int MINIMUM_HUAWEI_WEBVIEW_VERSION = 10; + + // Loaded Capacitor config + private CapConfig config; + + // A reference to the main activity for the app + private final AppCompatActivity context; + // A reference to the containing Fragment if used + private final Fragment fragment; + private WebViewLocalServer localServer; + private String localUrl; + private String appUrl; + private String appUrlConfig; + private HostMask appAllowNavigationMask; + private Set allowedOriginRules = new HashSet(); + private ArrayList authorities = new ArrayList<>(); + // A reference to the main WebView for the app + private final WebView webView; + public final MockCordovaInterfaceImpl cordovaInterface; + private CordovaWebView cordovaWebView; + private CordovaPreferences preferences; + private BridgeWebViewClient webViewClient; + private App app; + + // Our MessageHandler for sending and receiving data to the WebView + private final MessageHandler msgHandler; + + // The ThreadHandler for executing plugin calls + private final HandlerThread handlerThread = new HandlerThread("CapacitorPlugins"); + + // Our Handler for posting plugin calls. Created from the ThreadHandler + private Handler taskHandler = null; + + private final List> initialPlugins; + + private final List pluginInstances; + + // A map of Plugin Id's to PluginHandle's + private Map plugins = new HashMap<>(); + + // Stored plugin calls that we're keeping around to call again someday + private Map savedCalls = new HashMap<>(); + + // The call IDs of saved plugin calls with associated plugin id for handling permissions + private Map> savedPermissionCallIds = new HashMap<>(); + + // Store a plugin that started a new activity, in case we need to resume + // the app and return that data back + private PluginCall pluginCallForLastActivity; + + // Any URI that was passed to the app on start + private Uri intentUri; + + // A list of listeners that trigger when webView events occur + private List webViewListeners = new ArrayList<>(); + + // An interface to manipulate route resolving + private RouteProcessor routeProcessor; + + // A pre-determined path to load the bridge + private ServerPath serverPath; + + /** + * Create the Bridge with a reference to the main {@link Activity} for the + * app, and a reference to the {@link WebView} our app will use. + * @param context + * @param webView + * @deprecated Use {@link Bridge.Builder} to create Bridge instances + */ + @Deprecated + public Bridge( + AppCompatActivity context, + WebView webView, + List> initialPlugins, + MockCordovaInterfaceImpl cordovaInterface, + PluginManager pluginManager, + CordovaPreferences preferences, + CapConfig config + ) { + this(context, null, null, webView, initialPlugins, new ArrayList<>(), cordovaInterface, pluginManager, preferences, config); + } + + private Bridge( + AppCompatActivity context, + ServerPath serverPath, + Fragment fragment, + WebView webView, + List> initialPlugins, + List pluginInstances, + MockCordovaInterfaceImpl cordovaInterface, + PluginManager pluginManager, + CordovaPreferences preferences, + CapConfig config + ) { + this.app = new App(); + this.serverPath = serverPath; + this.context = context; + this.fragment = fragment; + this.webView = webView; + this.webViewClient = new BridgeWebViewClient(this); + this.initialPlugins = initialPlugins; + this.pluginInstances = pluginInstances; + this.cordovaInterface = cordovaInterface; + this.preferences = preferences; + + // Start our plugin execution threads and handlers + handlerThread.start(); + taskHandler = new Handler(handlerThread.getLooper()); + + this.config = config != null ? config : CapConfig.loadDefault(getActivity()); + Logger.init(this.config); + + // Initialize web view and message handler for it + this.initWebView(); + this.setAllowedOriginRules(); + this.msgHandler = new MessageHandler(this, webView, pluginManager); + + // Grab any intent info that our app was launched with + Intent intent = context.getIntent(); + this.intentUri = intent.getData(); + // Register our core plugins + this.registerAllPlugins(); + + this.loadWebView(); + } + + private void setAllowedOriginRules() { + String[] appAllowNavigationConfig = this.config.getAllowNavigation(); + String authority = this.getHost(); + String scheme = this.getScheme(); + allowedOriginRules.add(scheme + "://" + authority); + if (this.getServerUrl() != null) { + allowedOriginRules.add(this.getServerUrl()); + } + if (appAllowNavigationConfig != null) { + for (String allowNavigation : appAllowNavigationConfig) { + if (!allowNavigation.startsWith("http")) { + allowedOriginRules.add("https://" + allowNavigation); + } else { + allowedOriginRules.add(allowNavigation); + } + } + authorities.addAll(Arrays.asList(appAllowNavigationConfig)); + } + this.appAllowNavigationMask = HostMask.Parser.parse(appAllowNavigationConfig); + } + + public App getApp() { + return app; + } + + private void loadWebView() { + final boolean html5mode = this.config.isHTML5Mode(); + + // Start the local web server + localServer = new WebViewLocalServer(context, this, getJSInjector(), authorities, html5mode); + localServer.hostAssets(DEFAULT_WEB_ASSET_DIR); + + Logger.debug("Loading app at " + appUrl); + + webView.setWebChromeClient(new BridgeWebChromeClient(this)); + webView.setWebViewClient(this.webViewClient); + + if (!isDeployDisabled() && !isNewBinary()) { + SharedPreferences prefs = getContext() + .getSharedPreferences(com.getcapacitor.plugin.WebView.WEBVIEW_PREFS_NAME, Activity.MODE_PRIVATE); + String path = prefs.getString(com.getcapacitor.plugin.WebView.CAP_SERVER_PATH, null); + if (path != null && !path.isEmpty() && new File(path).exists()) { + setServerBasePath(path); + } + } + if (!this.isMinimumWebViewInstalled()) { + String errorUrl = this.getErrorUrl(); + if (errorUrl != null) { + webView.loadUrl(errorUrl); + return; + } else { + Logger.error(MINIMUM_ANDROID_WEBVIEW_ERROR); + } + } + + // If serverPath configured, start server based on provided path + if (serverPath != null) { + if (serverPath.getType() == ServerPath.PathType.ASSET_PATH) { + setServerAssetPath(serverPath.getPath()); + } else { + setServerBasePath(serverPath.getPath()); + } + } else { + // Get to work + webView.loadUrl(appUrl); + } + } + + @SuppressLint("WebViewApiAvailability") + public boolean isMinimumWebViewInstalled() { + PackageManager pm = getContext().getPackageManager(); + + // Check getCurrentWebViewPackage() directly if above Android 8 + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + PackageInfo info = WebView.getCurrentWebViewPackage(); + Pattern pattern = Pattern.compile("(\\d+)"); + Matcher matcher = pattern.matcher(info.versionName); + if (matcher.find()) { + String majorVersionStr = matcher.group(0); + int majorVersion = Integer.parseInt(majorVersionStr); + if (info.packageName.equals("com.huawei.webview")) { + return majorVersion >= config.getMinHuaweiWebViewVersion(); + } + return majorVersion >= config.getMinWebViewVersion(); + } else { + return false; + } + } + + // Otherwise manually check WebView versions + try { + String webViewPackage = "com.google.android.webview"; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + webViewPackage = "com.android.chrome"; + } + PackageInfo info = InternalUtils.getPackageInfo(pm, webViewPackage); + String majorVersionStr = info.versionName.split("\\.")[0]; + int majorVersion = Integer.parseInt(majorVersionStr); + return majorVersion >= config.getMinWebViewVersion(); + } catch (Exception ex) { + Logger.warn("Unable to get package info for 'com.google.android.webview'" + ex.toString()); + } + + try { + PackageInfo info = InternalUtils.getPackageInfo(pm, "com.android.webview"); + String majorVersionStr = info.versionName.split("\\.")[0]; + int majorVersion = Integer.parseInt(majorVersionStr); + return majorVersion >= config.getMinWebViewVersion(); + } catch (Exception ex) { + Logger.warn("Unable to get package info for 'com.android.webview'" + ex.toString()); + } + + final int amazonFireMajorWebViewVersion = extractWebViewMajorVersion(pm, "com.amazon.webview.chromium"); + if (amazonFireMajorWebViewVersion >= config.getMinWebViewVersion()) { + return true; + } + + // Could not detect any webview, return false + return false; + } + + private int extractWebViewMajorVersion(final PackageManager pm, final String webViewPackageName) { + try { + final PackageInfo info = InternalUtils.getPackageInfo(pm, webViewPackageName); + final String majorVersionStr = info.versionName.split("\\.")[0]; + final int majorVersion = Integer.parseInt(majorVersionStr); + return majorVersion; + } catch (Exception ex) { + Logger.warn(String.format("Unable to get package info for '%s' with err '%s'", webViewPackageName, ex)); + } + return 0; + } + + public boolean launchIntent(Uri url) { + /* + * Give plugins the chance to handle the url + */ + for (Map.Entry entry : plugins.entrySet()) { + Plugin plugin = entry.getValue().getInstance(); + if (plugin != null) { + Boolean shouldOverrideLoad = plugin.shouldOverrideLoad(url); + if (shouldOverrideLoad != null) { + return shouldOverrideLoad; + } + } + } + + if (url.getScheme().equals("data") || url.getScheme().equals("blob")) { + return false; + } + + Uri appUri = Uri.parse(appUrl); + if ( + !(appUri.getHost().equals(url.getHost()) && url.getScheme().equals(appUri.getScheme())) && + !appAllowNavigationMask.matches(url.getHost()) + ) { + try { + Intent openIntent = new Intent(Intent.ACTION_VIEW, url); + getContext().startActivity(openIntent); + } catch (ActivityNotFoundException e) { + // TODO - trigger an event + } + return true; + } + return false; + } + + private boolean isNewBinary() { + String versionCode = ""; + String versionName = ""; + SharedPreferences prefs = getContext() + .getSharedPreferences(com.getcapacitor.plugin.WebView.WEBVIEW_PREFS_NAME, Activity.MODE_PRIVATE); + String lastVersionCode = prefs.getString(LAST_BINARY_VERSION_CODE, null); + String lastVersionName = prefs.getString(LAST_BINARY_VERSION_NAME, null); + + try { + PackageManager pm = getContext().getPackageManager(); + PackageInfo pInfo = InternalUtils.getPackageInfo(pm, getContext().getPackageName()); + versionCode = Integer.toString((int) PackageInfoCompat.getLongVersionCode(pInfo)); + versionName = pInfo.versionName; + } catch (Exception ex) { + Logger.error("Unable to get package info", ex); + } + + if (!versionCode.equals(lastVersionCode) || !versionName.equals(lastVersionName)) { + SharedPreferences.Editor editor = prefs.edit(); + editor.putString(LAST_BINARY_VERSION_CODE, versionCode); + editor.putString(LAST_BINARY_VERSION_NAME, versionName); + editor.putString(com.getcapacitor.plugin.WebView.CAP_SERVER_PATH, ""); + editor.apply(); + return true; + } + return false; + } + + public boolean isDeployDisabled() { + return preferences.getBoolean("DisableDeploy", false); + } + + public boolean shouldKeepRunning() { + return preferences.getBoolean("KeepRunning", true); + } + + public void handleAppUrlLoadError(Exception ex) { + if (ex instanceof SocketTimeoutException) { + Logger.error( + "Unable to load app. Ensure the server is running at " + + appUrl + + ", or modify the " + + "appUrl setting in capacitor.config.json (make sure to npx cap copy after to commit changes).", + ex + ); + } + } + + public boolean isDevMode() { + return (getActivity().getApplicationInfo().flags & ApplicationInfo.FLAG_DEBUGGABLE) != 0; + } + + protected void setCordovaWebView(CordovaWebView cordovaWebView) { + this.cordovaWebView = cordovaWebView; + } + + /** + * Get the Context for the App + * @return + */ + public Context getContext() { + return this.context; + } + + /** + * Get the activity for the app + * @return + */ + public AppCompatActivity getActivity() { + return this.context; + } + + /** + * Get the fragment for the app, if applicable. This will likely be null unless Capacitor + * is being used embedded in a Native Android app. + * + * @return The fragment containing the Capacitor WebView. + */ + public Fragment getFragment() { + return this.fragment; + } + + /** + * Get the core WebView under Capacitor's control + * @return + */ + public WebView getWebView() { + return this.webView; + } + + /** + * Get the URI that was used to launch the app (if any) + * @return + */ + public Uri getIntentUri() { + return intentUri; + } + + /** + * Get scheme that is used to serve content + * @return + */ + public String getScheme() { + return this.config.getAndroidScheme(); + } + + /** + * Get host name that is used to serve content + * @return + */ + public String getHost() { + return this.config.getHostname(); + } + + /** + * Get the server url that is used to serve content + * @return + */ + public String getServerUrl() { + return this.config.getServerUrl(); + } + + public String getErrorUrl() { + String errorPath = this.config.getErrorPath(); + + if (errorPath != null && !errorPath.trim().isEmpty()) { + String authority = this.getHost(); + String scheme = this.getScheme(); + + String localUrl = scheme + "://" + authority; + + return localUrl + "/" + errorPath; + } + + return null; + } + + public String getAppUrl() { + return appUrl; + } + + public CapConfig getConfig() { + return this.config; + } + + public void reset() { + savedCalls = new HashMap<>(); + } + + /** + * Initialize the WebView, setting required flags + */ + @SuppressLint("SetJavaScriptEnabled") + private void initWebView() { + WebSettings settings = webView.getSettings(); + settings.setJavaScriptEnabled(true); + settings.setDomStorageEnabled(true); + settings.setGeolocationEnabled(true); + settings.setDatabaseEnabled(true); + settings.setMediaPlaybackRequiresUserGesture(false); + settings.setJavaScriptCanOpenWindowsAutomatically(true); + if (this.config.isMixedContentAllowed()) { + settings.setMixedContentMode(WebSettings.MIXED_CONTENT_ALWAYS_ALLOW); + } + + String appendUserAgent = this.config.getAppendedUserAgentString(); + if (appendUserAgent != null) { + String defaultUserAgent = settings.getUserAgentString(); + settings.setUserAgentString(defaultUserAgent + " " + appendUserAgent); + } + String overrideUserAgent = this.config.getOverriddenUserAgentString(); + if (overrideUserAgent != null) { + settings.setUserAgentString(overrideUserAgent); + } + + String backgroundColor = this.config.getBackgroundColor(); + try { + if (backgroundColor != null) { + webView.setBackgroundColor(WebColor.parseColor(backgroundColor)); + } + } catch (IllegalArgumentException ex) { + Logger.debug("WebView background color not applied"); + } + + if (config.isInitialFocus()) { + webView.requestFocusFromTouch(); + } + + WebView.setWebContentsDebuggingEnabled(this.config.isWebContentsDebuggingEnabled()); + + appUrlConfig = this.getServerUrl(); + String authority = this.getHost(); + authorities.add(authority); + String scheme = this.getScheme(); + + localUrl = scheme + "://" + authority; + + if (appUrlConfig != null) { + try { + URL appUrlObject = new URL(appUrlConfig); + authorities.add(appUrlObject.getAuthority()); + } catch (Exception ex) { + Logger.error("Provided server url is invalid: " + ex.getMessage()); + return; + } + localUrl = appUrlConfig; + appUrl = appUrlConfig; + } else { + appUrl = localUrl; + // custom URL schemes requires path ending with / + if (!scheme.equals(Bridge.CAPACITOR_HTTP_SCHEME) && !scheme.equals(CAPACITOR_HTTPS_SCHEME)) { + appUrl += "/"; + } + } + + String appUrlPath = this.config.getStartPath(); + if (appUrlPath != null && !appUrlPath.trim().isEmpty()) { + appUrl += appUrlPath; + } + } + + /** + * Register our core Plugin APIs + */ + private void registerAllPlugins() { + this.registerPlugin(com.getcapacitor.plugin.CapacitorCookies.class); + this.registerPlugin(com.getcapacitor.plugin.WebView.class); + this.registerPlugin(com.getcapacitor.plugin.CapacitorHttp.class); + + for (Class pluginClass : this.initialPlugins) { + this.registerPlugin(pluginClass); + } + + for (Plugin plugin : pluginInstances) { + registerPluginInstance(plugin); + } + } + + /** + * Register additional plugins + * @param pluginClasses the plugins to register + */ + public void registerPlugins(Class[] pluginClasses) { + for (Class plugin : pluginClasses) { + this.registerPlugin(plugin); + } + } + + public void registerPluginInstances(Plugin[] pluginInstances) { + for (Plugin plugin : pluginInstances) { + this.registerPluginInstance(plugin); + } + } + + @SuppressWarnings("deprecation") + private String getLegacyPluginName(Class pluginClass) { + NativePlugin legacyPluginAnnotation = pluginClass.getAnnotation(NativePlugin.class); + if (legacyPluginAnnotation == null) { + Logger.error("Plugin doesn't have the @CapacitorPlugin annotation. Please add it"); + return null; + } + + return legacyPluginAnnotation.name(); + } + + /** + * Register a plugin class + * @param pluginClass a class inheriting from Plugin + */ + public void registerPlugin(Class pluginClass) { + String pluginId = pluginId(pluginClass); + if (pluginId == null) return; + + try { + this.plugins.put(pluginId, new PluginHandle(this, pluginClass)); + } catch (InvalidPluginException ex) { + logInvalidPluginException(pluginClass); + } catch (PluginLoadException ex) { + logPluginLoadException(pluginClass, ex); + } + } + + public void registerPluginInstance(Plugin plugin) { + Class clazz = plugin.getClass(); + String pluginId = pluginId(clazz); + if (pluginId == null) return; + + try { + this.plugins.put(pluginId, new PluginHandle(this, plugin)); + } catch (InvalidPluginException ex) { + logInvalidPluginException(clazz); + } + } + + private String pluginId(Class clazz) { + String pluginName = pluginName(clazz); + String pluginId = clazz.getSimpleName(); + if (pluginName == null) return null; + + if (!pluginName.equals("")) { + pluginId = pluginName; + } + Logger.debug("Registering plugin instance: " + pluginId); + return pluginId; + } + + private String pluginName(Class clazz) { + String pluginName; + CapacitorPlugin pluginAnnotation = clazz.getAnnotation(CapacitorPlugin.class); + if (pluginAnnotation == null) { + pluginName = this.getLegacyPluginName(clazz); + } else { + pluginName = pluginAnnotation.name(); + } + + return pluginName; + } + + private void logInvalidPluginException(Class clazz) { + Logger.error( + "NativePlugin " + + clazz.getName() + + " is invalid. Ensure the @CapacitorPlugin annotation exists on the plugin class and" + + " the class extends Plugin" + ); + } + + private void logPluginLoadException(Class clazz, Exception ex) { + Logger.error("NativePlugin " + clazz.getName() + " failed to load", ex); + } + + public PluginHandle getPlugin(String pluginId) { + return this.plugins.get(pluginId); + } + + /** + * Find the plugin handle that responds to the given request code. This will + * fire after certain Android OS intent results/permission checks/etc. + * @param requestCode + * @return + */ + @Deprecated + @SuppressWarnings("deprecation") + public PluginHandle getPluginWithRequestCode(int requestCode) { + for (PluginHandle handle : this.plugins.values()) { + int[] requestCodes; + + CapacitorPlugin pluginAnnotation = handle.getPluginAnnotation(); + if (pluginAnnotation == null) { + // Check for legacy plugin annotation, @NativePlugin + NativePlugin legacyPluginAnnotation = handle.getLegacyPluginAnnotation(); + if (legacyPluginAnnotation == null) { + continue; + } + + if (legacyPluginAnnotation.permissionRequestCode() == requestCode) { + return handle; + } + + requestCodes = legacyPluginAnnotation.requestCodes(); + + for (int rc : requestCodes) { + if (rc == requestCode) { + return handle; + } + } + } else { + requestCodes = pluginAnnotation.requestCodes(); + + for (int rc : requestCodes) { + if (rc == requestCode) { + return handle; + } + } + } + } + return null; + } + + /** + * Call a method on a plugin. + * @param pluginId the plugin id to use to lookup the plugin handle + * @param methodName the name of the method to call + * @param call the call object to pass to the method + */ + public void callPluginMethod(String pluginId, final String methodName, final PluginCall call) { + try { + final PluginHandle plugin = this.getPlugin(pluginId); + + if (plugin == null) { + Logger.error("unable to find plugin : " + pluginId); + call.errorCallback("unable to find plugin : " + pluginId); + return; + } + + if (Logger.shouldLog()) { + Logger.verbose( + "callback: " + + call.getCallbackId() + + ", pluginId: " + + plugin.getId() + + ", methodName: " + + methodName + + ", methodData: " + + call.getData().toString() + ); + } + + Runnable currentThreadTask = () -> { + try { + plugin.invoke(methodName, call); + + if (call.isKeptAlive()) { + saveCall(call); + } + } catch (PluginLoadException | InvalidPluginMethodException ex) { + Logger.error("Unable to execute plugin method", ex); + } catch (Exception ex) { + Logger.error("Serious error executing plugin", ex); + throw new RuntimeException(ex); + } + }; + + taskHandler.post(currentThreadTask); + } catch (Exception ex) { + Logger.error(Logger.tags("callPluginMethod"), "error : " + ex, null); + call.errorCallback(ex.toString()); + } + } + + /** + * Evaluate JavaScript in the web view. This method + * executes on the main thread automatically. + * @param js the JS to execute + * @param callback an optional ValueCallback that will synchronously receive a value + * after calling the JS + */ + public void eval(final String js, final ValueCallback callback) { + Handler mainHandler = new Handler(context.getMainLooper()); + mainHandler.post(() -> webView.evaluateJavascript(js, callback)); + } + + public void logToJs(final String message, final String level) { + eval("window.Capacitor.logJs(\"" + message + "\", \"" + level + "\")", null); + } + + public void logToJs(final String message) { + logToJs(message, "log"); + } + + public void triggerJSEvent(final String eventName, final String target) { + eval("window.Capacitor.triggerEvent(\"" + eventName + "\", \"" + target + "\")", s -> {}); + } + + public void triggerJSEvent(final String eventName, final String target, final String data) { + eval("window.Capacitor.triggerEvent(\"" + eventName + "\", \"" + target + "\", " + data + ")", s -> {}); + } + + public void triggerWindowJSEvent(final String eventName) { + this.triggerJSEvent(eventName, "window"); + } + + public void triggerWindowJSEvent(final String eventName, final String data) { + this.triggerJSEvent(eventName, "window", data); + } + + public void triggerDocumentJSEvent(final String eventName) { + this.triggerJSEvent(eventName, "document"); + } + + public void triggerDocumentJSEvent(final String eventName, final String data) { + this.triggerJSEvent(eventName, "document", data); + } + + public void execute(Runnable runnable) { + taskHandler.post(runnable); + } + + public void executeOnMainThread(Runnable runnable) { + Handler mainHandler = new Handler(context.getMainLooper()); + + mainHandler.post(runnable); + } + + /** + * Retain a call between plugin invocations + * @param call + */ + public void saveCall(PluginCall call) { + this.savedCalls.put(call.getCallbackId(), call); + } + + /** + * Get a retained plugin call + * @param callbackId the callbackId to use to lookup the call with + * @return the stored call + */ + public PluginCall getSavedCall(String callbackId) { + if (callbackId == null) { + return null; + } + + return this.savedCalls.get(callbackId); + } + + PluginCall getPluginCallForLastActivity() { + PluginCall pluginCallForLastActivity = this.pluginCallForLastActivity; + this.pluginCallForLastActivity = null; + return pluginCallForLastActivity; + } + + void setPluginCallForLastActivity(PluginCall pluginCallForLastActivity) { + this.pluginCallForLastActivity = pluginCallForLastActivity; + } + + /** + * Release a retained call + * @param call a call to release + */ + public void releaseCall(PluginCall call) { + releaseCall(call.getCallbackId()); + } + + /** + * Release a retained call by its ID + * @param callbackId an ID of a callback to release + */ + public void releaseCall(String callbackId) { + this.savedCalls.remove(callbackId); + } + + /** + * Removes the earliest saved call prior to a permissions request for a given plugin and + * returns it. + * + * @return The saved plugin call + */ + protected PluginCall getPermissionCall(String pluginId) { + LinkedList permissionCallIds = this.savedPermissionCallIds.get(pluginId); + String savedCallId = null; + if (permissionCallIds != null) { + savedCallId = permissionCallIds.poll(); + } + + return getSavedCall(savedCallId); + } + + /** + * Save a call to be retrieved after requesting permissions. Calls are saved in order. + * + * @param call The plugin call to save. + */ + protected void savePermissionCall(PluginCall call) { + if (call != null) { + if (!savedPermissionCallIds.containsKey(call.getPluginId())) { + savedPermissionCallIds.put(call.getPluginId(), new LinkedList<>()); + } + + savedPermissionCallIds.get(call.getPluginId()).add(call.getCallbackId()); + saveCall(call); + } + } + + /** + * Register an Activity Result Launcher to the containing Fragment or Activity. + * + * @param contract A contract specifying that an activity can be called with an input of + * type I and produce an output of type O. + * @param callback The callback run on Activity Result. + * @return A registered Activity Result Launcher. + */ + public ActivityResultLauncher registerForActivityResult( + @NonNull final ActivityResultContract contract, + @NonNull final ActivityResultCallback callback + ) { + if (fragment != null) { + return fragment.registerForActivityResult(contract, callback); + } else { + return context.registerForActivityResult(contract, callback); + } + } + + /** + * Build the JSInjector that will be used to inject JS into files served to the app, + * to ensure that Capacitor's JS and the JS for all the plugins is loaded each time. + */ + private JSInjector getJSInjector() { + try { + String globalJS = JSExport.getGlobalJS(context, config.isLoggingEnabled(), isDevMode()); + String bridgeJS = JSExport.getBridgeJS(context); + String pluginJS = JSExport.getPluginJS(plugins.values()); + String cordovaJS = JSExport.getCordovaJS(context); + String cordovaPluginsJS = JSExport.getCordovaPluginJS(context); + String cordovaPluginsFileJS = JSExport.getCordovaPluginsFileJS(context); + String localUrlJS = "window.WEBVIEW_SERVER_URL = '" + localUrl + "';"; + + return new JSInjector(globalJS, bridgeJS, pluginJS, cordovaJS, cordovaPluginsJS, cordovaPluginsFileJS, localUrlJS); + } catch (Exception ex) { + Logger.error("Unable to export Capacitor JS. App will not function!", ex); + } + return null; + } + + /** + * Restore any saved bundle state data + * @param savedInstanceState + */ + public void restoreInstanceState(Bundle savedInstanceState) { + String lastPluginId = savedInstanceState.getString(BUNDLE_LAST_PLUGIN_ID_KEY); + String lastPluginCallMethod = savedInstanceState.getString(BUNDLE_LAST_PLUGIN_CALL_METHOD_NAME_KEY); + String lastOptionsJson = savedInstanceState.getString(BUNDLE_PLUGIN_CALL_OPTIONS_SAVED_KEY); + + if (lastPluginId != null) { + // If we have JSON blob saved, create a new plugin call with the original options + if (lastOptionsJson != null) { + try { + JSObject options = new JSObject(lastOptionsJson); + + pluginCallForLastActivity = + new PluginCall(msgHandler, lastPluginId, PluginCall.CALLBACK_ID_DANGLING, lastPluginCallMethod, options); + } catch (JSONException ex) { + Logger.error("Unable to restore plugin call, unable to parse persisted JSON object", ex); + } + } + + // Let the plugin restore any state it needs + Bundle bundleData = savedInstanceState.getBundle(BUNDLE_PLUGIN_CALL_BUNDLE_KEY); + PluginHandle lastPlugin = getPlugin(lastPluginId); + if (bundleData != null && lastPlugin != null) { + lastPlugin.getInstance().restoreState(bundleData); + } else { + Logger.error("Unable to restore last plugin call"); + } + } + } + + public void saveInstanceState(Bundle outState) { + Logger.debug("Saving instance state!"); + + // If there was a last PluginCall for a started activity, we need to + // persist it so we can load it again in case our app gets terminated + if (pluginCallForLastActivity != null) { + PluginCall call = pluginCallForLastActivity; + PluginHandle handle = getPlugin(call.getPluginId()); + + if (handle != null) { + Bundle bundle = handle.getInstance().saveInstanceState(); + if (bundle != null) { + outState.putString(BUNDLE_LAST_PLUGIN_ID_KEY, call.getPluginId()); + outState.putString(BUNDLE_LAST_PLUGIN_CALL_METHOD_NAME_KEY, call.getMethodName()); + outState.putString(BUNDLE_PLUGIN_CALL_OPTIONS_SAVED_KEY, call.getData().toString()); + outState.putBundle(BUNDLE_PLUGIN_CALL_BUNDLE_KEY, bundle); + } else { + Logger.error("Couldn't save last " + call.getPluginId() + "'s Plugin " + call.getMethodName() + " call"); + } + } + } + } + + @Deprecated + @SuppressWarnings("deprecation") + public void startActivityForPluginWithResult(PluginCall call, Intent intent, int requestCode) { + Logger.debug("Starting activity for result"); + + pluginCallForLastActivity = call; + + getActivity().startActivityForResult(intent, requestCode); + } + + /** + * Check for legacy Capacitor or Cordova plugins that may have registered to handle a permission + * request, and handle them if so. If not handled, false is returned. + * + * @param requestCode the code that was requested + * @param permissions the permissions requested + * @param grantResults the set of granted/denied permissions + * @return true if permission code was handled by a plugin explicitly, false if not + */ + @SuppressWarnings("deprecation") + boolean onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) { + PluginHandle plugin = getPluginWithRequestCode(requestCode); + + if (plugin == null) { + boolean permissionHandled = false; + Logger.debug("Unable to find a Capacitor plugin to handle permission requestCode, trying Cordova plugins " + requestCode); + try { + permissionHandled = cordovaInterface.handlePermissionResult(requestCode, permissions, grantResults); + } catch (JSONException e) { + Logger.debug("Error on Cordova plugin permissions request " + e.getMessage()); + } + return permissionHandled; + } + + // Call deprecated method if using deprecated NativePlugin annotation + if (plugin.getPluginAnnotation() == null) { + plugin.getInstance().handleRequestPermissionsResult(requestCode, permissions, grantResults); + return true; + } + + return false; + } + + /** + * Saves permission states and rejects if permissions were not correctly defined in + * the AndroidManifest.xml file. + * + * @param plugin + * @param savedCall + * @param permissions + * @return true if permissions were saved and defined correctly, false if not + */ + protected boolean validatePermissions(Plugin plugin, PluginCall savedCall, Map permissions) { + SharedPreferences prefs = getContext().getSharedPreferences(PERMISSION_PREFS_NAME, Activity.MODE_PRIVATE); + + for (Map.Entry permission : permissions.entrySet()) { + String permString = permission.getKey(); + boolean isGranted = permission.getValue(); + + if (isGranted) { + // Permission granted. If previously denied, remove cached state + String state = prefs.getString(permString, null); + + if (state != null) { + SharedPreferences.Editor editor = prefs.edit(); + editor.remove(permString); + editor.apply(); + } + } else { + SharedPreferences.Editor editor = prefs.edit(); + + if (ActivityCompat.shouldShowRequestPermissionRationale(getActivity(), permString)) { + // Permission denied, can prompt again with rationale + editor.putString(permString, PermissionState.PROMPT_WITH_RATIONALE.toString()); + } else { + // Permission denied permanently, store this state for future reference + editor.putString(permString, PermissionState.DENIED.toString()); + } + + editor.apply(); + } + } + + String[] permStrings = permissions.keySet().toArray(new String[0]); + + if (!PermissionHelper.hasDefinedPermissions(getContext(), permStrings)) { + StringBuilder builder = new StringBuilder(); + builder.append("Missing the following permissions in AndroidManifest.xml:\n"); + String[] missing = PermissionHelper.getUndefinedPermissions(getContext(), permStrings); + for (String perm : missing) { + builder.append(perm + "\n"); + } + savedCall.reject(builder.toString()); + return false; + } + + return true; + } + + /** + * Helper to check all permissions and see the current states of each permission. + * + * @since 3.0.0 + * @return A mapping of permission aliases to the associated granted status. + */ + protected Map getPermissionStates(Plugin plugin) { + Map permissionsResults = new HashMap<>(); + CapacitorPlugin annotation = plugin.getPluginHandle().getPluginAnnotation(); + for (Permission perm : annotation.permissions()) { + // If a permission is defined with no permission constants, return GRANTED for it. + // Otherwise, get its true state. + if (perm.strings().length == 0 || (perm.strings().length == 1 && perm.strings()[0].isEmpty())) { + String key = perm.alias(); + if (!key.isEmpty()) { + PermissionState existingResult = permissionsResults.get(key); + + // auto set permission state to GRANTED if the alias is empty. + if (existingResult == null) { + permissionsResults.put(key, PermissionState.GRANTED); + } + } + } else { + for (String permString : perm.strings()) { + String key = perm.alias().isEmpty() ? permString : perm.alias(); + PermissionState permissionStatus; + if (ActivityCompat.checkSelfPermission(this.getContext(), permString) == PackageManager.PERMISSION_GRANTED) { + permissionStatus = PermissionState.GRANTED; + } else { + permissionStatus = PermissionState.PROMPT; + + // Check if there is a cached permission state for the "Never ask again" state + SharedPreferences prefs = getContext().getSharedPreferences(PERMISSION_PREFS_NAME, Activity.MODE_PRIVATE); + String state = prefs.getString(permString, null); + + if (state != null) { + permissionStatus = PermissionState.byState(state); + } + } + + PermissionState existingResult = permissionsResults.get(key); + + // multiple permissions with the same alias must all be true, otherwise all false. + if (existingResult == null || existingResult == PermissionState.GRANTED) { + permissionsResults.put(key, permissionStatus); + } + } + } + } + + return permissionsResults; + } + + /** + * Handle an activity result and pass it to a plugin that has indicated it wants to + * handle the result. + * @param requestCode + * @param resultCode + * @param data + */ + @SuppressWarnings("deprecation") + boolean onActivityResult(int requestCode, int resultCode, Intent data) { + PluginHandle plugin = getPluginWithRequestCode(requestCode); + + if (plugin == null || plugin.getInstance() == null) { + Logger.debug("Unable to find a Capacitor plugin to handle requestCode, trying Cordova plugins " + requestCode); + return cordovaInterface.onActivityResult(requestCode, resultCode, data); + } + + // deprecated, to be removed + PluginCall lastCall = plugin.getInstance().getSavedCall(); + + // If we don't have a saved last call (because our app was killed and restarted, for example), + // Then we should see if we have any saved plugin call information and generate a new, + // "dangling" plugin call (a plugin call that doesn't have a corresponding web callback) + // and then send that to the plugin + if (lastCall == null && pluginCallForLastActivity != null) { + plugin.getInstance().saveCall(pluginCallForLastActivity); + } + + plugin.getInstance().handleOnActivityResult(requestCode, resultCode, data); + + // Clear the plugin call we may have re-hydrated on app launch + pluginCallForLastActivity = null; + + return true; + } + + /** + * Handle an onNewIntent lifecycle event and notify the plugins + * @param intent + */ + public void onNewIntent(Intent intent) { + for (PluginHandle plugin : plugins.values()) { + plugin.getInstance().handleOnNewIntent(intent); + } + + if (cordovaWebView != null) { + cordovaWebView.onNewIntent(intent); + } + } + + /** + * Handle an onConfigurationChanged event and notify the plugins + * @param newConfig + */ + public void onConfigurationChanged(Configuration newConfig) { + for (PluginHandle plugin : plugins.values()) { + plugin.getInstance().handleOnConfigurationChanged(newConfig); + } + } + + /** + * Handle onRestart lifecycle event and notify the plugins + */ + public void onRestart() { + for (PluginHandle plugin : plugins.values()) { + plugin.getInstance().handleOnRestart(); + } + } + + /** + * Handle onStart lifecycle event and notify the plugins + */ + public void onStart() { + for (PluginHandle plugin : plugins.values()) { + plugin.getInstance().handleOnStart(); + } + + if (cordovaWebView != null) { + cordovaWebView.handleStart(); + } + } + + /** + * Handle onResume lifecycle event and notify the plugins + */ + public void onResume() { + for (PluginHandle plugin : plugins.values()) { + plugin.getInstance().handleOnResume(); + } + + if (cordovaWebView != null) { + cordovaWebView.handleResume(this.shouldKeepRunning()); + } + } + + /** + * Handle onPause lifecycle event and notify the plugins + */ + public void onPause() { + for (PluginHandle plugin : plugins.values()) { + plugin.getInstance().handleOnPause(); + } + + if (cordovaWebView != null) { + boolean keepRunning = this.shouldKeepRunning() || cordovaInterface.getActivityResultCallback() != null; + cordovaWebView.handlePause(keepRunning); + } + } + + /** + * Handle onStop lifecycle event and notify the plugins + */ + public void onStop() { + for (PluginHandle plugin : plugins.values()) { + plugin.getInstance().handleOnStop(); + } + + if (cordovaWebView != null) { + cordovaWebView.handleStop(); + } + } + + /** + * Handle onDestroy lifecycle event and notify the plugins + */ + public void onDestroy() { + for (PluginHandle plugin : plugins.values()) { + plugin.getInstance().handleOnDestroy(); + } + + handlerThread.quitSafely(); + + if (cordovaWebView != null) { + cordovaWebView.handleDestroy(); + } + } + + /** + * Handle onDetachedFromWindow lifecycle event + */ + public void onDetachedFromWindow() { + webView.removeAllViews(); + webView.destroy(); + } + + public String getServerBasePath() { + return this.localServer.getBasePath(); + } + + /** + * Tell the local server to load files from the given + * file path instead of the assets path. + * @param path + */ + public void setServerBasePath(String path) { + localServer.hostFiles(path); + webView.post(() -> webView.loadUrl(appUrl)); + } + + /** + * Tell the local server to load files from the given + * asset path. + * @param path + */ + public void setServerAssetPath(String path) { + localServer.hostAssets(path); + webView.post(() -> webView.loadUrl(appUrl)); + } + + /** + * Reload the WebView + */ + public void reload() { + webView.post(() -> webView.loadUrl(appUrl)); + } + + public String getLocalUrl() { + return localUrl; + } + + public WebViewLocalServer getLocalServer() { + return localServer; + } + + public HostMask getAppAllowNavigationMask() { + return appAllowNavigationMask; + } + + public Set getAllowedOriginRules() { + return allowedOriginRules; + } + + public BridgeWebViewClient getWebViewClient() { + return this.webViewClient; + } + + public void setWebViewClient(BridgeWebViewClient client) { + this.webViewClient = client; + webView.setWebViewClient(client); + } + + List getWebViewListeners() { + return webViewListeners; + } + + void setWebViewListeners(List webViewListeners) { + this.webViewListeners = webViewListeners; + } + + RouteProcessor getRouteProcessor() { + return routeProcessor; + } + + void setRouteProcessor(RouteProcessor routeProcessor) { + this.routeProcessor = routeProcessor; + } + + ServerPath getServerPath() { + return serverPath; + } + + /** + * Add a listener that the WebViewClient can trigger on certain events. + * @param webViewListener A {@link WebViewListener} to add. + */ + public void addWebViewListener(WebViewListener webViewListener) { + webViewListeners.add(webViewListener); + } + + /** + * Remove a listener that the WebViewClient triggers on certain events. + * @param webViewListener A {@link WebViewListener} to remove. + */ + public void removeWebViewListener(WebViewListener webViewListener) { + webViewListeners.remove(webViewListener); + } + + public static class Builder { + + private Bundle instanceState = null; + private CapConfig config = null; + private List> plugins = new ArrayList<>(); + private List pluginInstances = new ArrayList<>(); + private AppCompatActivity activity; + private Fragment fragment; + private RouteProcessor routeProcessor; + private final List webViewListeners = new ArrayList<>(); + private ServerPath serverPath; + + public Builder(AppCompatActivity activity) { + this.activity = activity; + } + + public Builder(Fragment fragment) { + this.activity = (AppCompatActivity) fragment.getActivity(); + this.fragment = fragment; + } + + public Builder setInstanceState(Bundle instanceState) { + this.instanceState = instanceState; + return this; + } + + public Builder setConfig(CapConfig config) { + this.config = config; + return this; + } + + public Builder setPlugins(List> plugins) { + this.plugins = plugins; + return this; + } + + public Builder addPlugin(Class plugin) { + this.plugins.add(plugin); + return this; + } + + public Builder addPlugins(List> plugins) { + for (Class cls : plugins) { + this.addPlugin(cls); + } + + return this; + } + + public Builder addPluginInstance(Plugin plugin) { + this.pluginInstances.add(plugin); + return this; + } + + public Builder addPluginInstances(List plugins) { + this.pluginInstances.addAll(plugins); + return this; + } + + public Builder addWebViewListener(WebViewListener webViewListener) { + webViewListeners.add(webViewListener); + return this; + } + + public Builder addWebViewListeners(List webViewListeners) { + for (WebViewListener listener : webViewListeners) { + this.addWebViewListener(listener); + } + + return this; + } + + public Builder setRouteProcessor(RouteProcessor routeProcessor) { + this.routeProcessor = routeProcessor; + return this; + } + + public Builder setServerPath(ServerPath serverPath) { + this.serverPath = serverPath; + return this; + } + + public Bridge create() { + // Cordova initialization + ConfigXmlParser parser = new ConfigXmlParser(); + parser.parse(activity.getApplicationContext()); + CordovaPreferences preferences = parser.getPreferences(); + preferences.setPreferencesBundle(activity.getIntent().getExtras()); + List pluginEntries = parser.getPluginEntries(); + + MockCordovaInterfaceImpl cordovaInterface = new MockCordovaInterfaceImpl(activity); + if (instanceState != null) { + cordovaInterface.restoreInstanceState(instanceState); + } + + WebView webView = this.fragment != null ? fragment.getView().findViewById(R.id.webview) : activity.findViewById(R.id.webview); + MockCordovaWebViewImpl mockWebView = new MockCordovaWebViewImpl(activity.getApplicationContext()); + mockWebView.init(cordovaInterface, pluginEntries, preferences, webView); + PluginManager pluginManager = mockWebView.getPluginManager(); + cordovaInterface.onCordovaInit(pluginManager); + + // Bridge initialization + Bridge bridge = new Bridge( + activity, + serverPath, + fragment, + webView, + plugins, + pluginInstances, + cordovaInterface, + pluginManager, + preferences, + config + ); + + if (webView instanceof CapacitorWebView) { + CapacitorWebView capacitorWebView = (CapacitorWebView) webView; + capacitorWebView.setBridge(bridge); + } + + bridge.setCordovaWebView(mockWebView); + bridge.setWebViewListeners(webViewListeners); + bridge.setRouteProcessor(routeProcessor); + + if (instanceState != null) { + bridge.restoreInstanceState(instanceState); + } + + return bridge; + } + } +} diff --git a/@capacitor/capacitor/src/main/java/com/getcapacitor/BridgeActivity.java b/@capacitor/capacitor/src/main/java/com/getcapacitor/BridgeActivity.java new file mode 100644 index 00000000..c3660265 --- /dev/null +++ b/@capacitor/capacitor/src/main/java/com/getcapacitor/BridgeActivity.java @@ -0,0 +1,197 @@ +package com.getcapacitor; + +import android.content.Intent; +import android.content.res.Configuration; +import android.os.Bundle; +import androidx.appcompat.app.AppCompatActivity; +import com.getcapacitor.android.R; +import java.util.ArrayList; +import java.util.List; + +public class BridgeActivity extends AppCompatActivity { + + protected Bridge bridge; + protected boolean keepRunning = true; + protected CapConfig config; + + protected int activityDepth = 0; + protected List> initialPlugins = new ArrayList<>(); + protected final Bridge.Builder bridgeBuilder = new Bridge.Builder(this); + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + bridgeBuilder.setInstanceState(savedInstanceState); + getApplication().setTheme(R.style.AppTheme_NoActionBar); + setTheme(R.style.AppTheme_NoActionBar); + setContentView(R.layout.bridge_layout_main); + PluginManager loader = new PluginManager(getAssets()); + + try { + bridgeBuilder.addPlugins(loader.loadPluginClasses()); + } catch (PluginLoadException ex) { + Logger.error("Error loading plugins.", ex); + } + + this.load(); + } + + protected void load() { + Logger.debug("Starting BridgeActivity"); + + bridge = bridgeBuilder.addPlugins(initialPlugins).setConfig(config).create(); + + this.keepRunning = bridge.shouldKeepRunning(); + this.onNewIntent(getIntent()); + } + + public void registerPlugin(Class plugin) { + bridgeBuilder.addPlugin(plugin); + } + + public void registerPlugins(List> plugins) { + bridgeBuilder.addPlugins(plugins); + } + + public Bridge getBridge() { + return this.bridge; + } + + @Override + public void onSaveInstanceState(Bundle outState) { + super.onSaveInstanceState(outState); + bridge.saveInstanceState(outState); + } + + @Override + public void onStart() { + super.onStart(); + activityDepth++; + this.bridge.onStart(); + Logger.debug("App started"); + } + + @Override + public void onRestart() { + super.onRestart(); + this.bridge.onRestart(); + Logger.debug("App restarted"); + } + + @Override + public void onResume() { + super.onResume(); + bridge.getApp().fireStatusChange(true); + this.bridge.onResume(); + Logger.debug("App resumed"); + } + + @Override + public void onPause() { + super.onPause(); + this.bridge.onPause(); + Logger.debug("App paused"); + } + + @Override + public void onStop() { + super.onStop(); + + activityDepth = Math.max(0, activityDepth - 1); + if (activityDepth == 0) { + bridge.getApp().fireStatusChange(false); + } + + this.bridge.onStop(); + Logger.debug("App stopped"); + } + + @Override + public void onDestroy() { + super.onDestroy(); + this.bridge.onDestroy(); + Logger.debug("App destroyed"); + } + + @Override + public void onDetachedFromWindow() { + super.onDetachedFromWindow(); + this.bridge.onDetachedFromWindow(); + } + + /** + * Handles permission request results. + * + * Capacitor is backwards compatible such that plugins using legacy permission request codes + * may coexist with plugins using the AndroidX Activity v1.2 permission callback flow introduced + * in Capacitor 3.0. + * + * In this method, plugins are checked first for ownership of the legacy permission request code. + * If the {@link Bridge#onRequestPermissionsResult(int, String[], int[])} method indicates it has + * handled the permission, then the permission callback will be considered complete. Otherwise, + * the permission will be handled using the AndroidX Activity flow. + * + * @param requestCode the request code associated with the permission request + * @param permissions the Android permission strings requested + * @param grantResults the status result of the permission request + */ + @Override + public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) { + if (this.bridge == null) { + return; + } + + if (!bridge.onRequestPermissionsResult(requestCode, permissions, grantResults)) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults); + } + } + + /** + * Handles activity results. + * + * Capacitor is backwards compatible such that plugins using legacy activity result codes + * may coexist with plugins using the AndroidX Activity v1.2 activity callback flow introduced + * in Capacitor 3.0. + * + * In this method, plugins are checked first for ownership of the legacy request code. If the + * {@link Bridge#onActivityResult(int, int, Intent)} method indicates it has handled the activity + * result, then the callback will be considered complete. Otherwise, the result will be handled + * using the AndroidX Activiy flow. + * + * @param requestCode the request code associated with the activity result + * @param resultCode the result code + * @param data any data included with the activity result + */ + @Override + protected void onActivityResult(int requestCode, int resultCode, Intent data) { + if (this.bridge == null) { + return; + } + + if (!bridge.onActivityResult(requestCode, resultCode, data)) { + super.onActivityResult(requestCode, resultCode, data); + } + } + + @Override + protected void onNewIntent(Intent intent) { + super.onNewIntent(intent); + + if (this.bridge == null || intent == null) { + return; + } + + this.bridge.onNewIntent(intent); + } + + @Override + public void onConfigurationChanged(Configuration newConfig) { + super.onConfigurationChanged(newConfig); + + if (this.bridge == null) { + return; + } + + this.bridge.onConfigurationChanged(newConfig); + } +} diff --git a/@capacitor/capacitor/src/main/java/com/getcapacitor/BridgeFragment.java b/@capacitor/capacitor/src/main/java/com/getcapacitor/BridgeFragment.java new file mode 100644 index 00000000..f269bd56 --- /dev/null +++ b/@capacitor/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/capacitor/src/main/java/com/getcapacitor/BridgeWebChromeClient.java b/@capacitor/capacitor/src/main/java/com/getcapacitor/BridgeWebChromeClient.java new file mode 100644 index 00000000..400b65a0 --- /dev/null +++ b/@capacitor/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/capacitor/src/main/java/com/getcapacitor/BridgeWebViewClient.java b/@capacitor/capacitor/src/main/java/com/getcapacitor/BridgeWebViewClient.java new file mode 100644 index 00000000..c434247a --- /dev/null +++ b/@capacitor/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/capacitor/src/main/java/com/getcapacitor/CapConfig.java b/@capacitor/capacitor/src/main/java/com/getcapacitor/CapConfig.java new file mode 100644 index 00000000..63db2a47 --- /dev/null +++ b/@capacitor/capacitor/src/main/java/com/getcapacitor/CapConfig.java @@ -0,0 +1,670 @@ +package com.getcapacitor; + +import static com.getcapacitor.Bridge.CAPACITOR_HTTP_SCHEME; +import static com.getcapacitor.Bridge.DEFAULT_ANDROID_WEBVIEW_VERSION; +import static com.getcapacitor.Bridge.DEFAULT_HUAWEI_WEBVIEW_VERSION; +import static com.getcapacitor.Bridge.MINIMUM_ANDROID_WEBVIEW_VERSION; +import static com.getcapacitor.Bridge.MINIMUM_HUAWEI_WEBVIEW_VERSION; +import static com.getcapacitor.FileUtils.readFileFromAssets; + +import android.content.Context; +import android.content.pm.ApplicationInfo; +import android.content.res.AssetManager; +import androidx.annotation.Nullable; +import com.getcapacitor.util.JSONUtils; +import java.io.File; +import java.io.IOException; +import java.util.Arrays; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import org.json.JSONException; +import org.json.JSONObject; + +/** + * Represents the configuration options for Capacitor + */ +public class CapConfig { + + private static final String LOG_BEHAVIOR_NONE = "none"; + private static final String LOG_BEHAVIOR_DEBUG = "debug"; + private static final String LOG_BEHAVIOR_PRODUCTION = "production"; + + // Server Config + private boolean html5mode = true; + private String serverUrl; + private String hostname = "localhost"; + private String androidScheme = CAPACITOR_HTTP_SCHEME; + private String[] allowNavigation; + + // Android Config + private String overriddenUserAgentString; + private String appendedUserAgentString; + private String backgroundColor; + private boolean allowMixedContent = false; + private boolean captureInput = false; + private boolean webContentsDebuggingEnabled = false; + private boolean loggingEnabled = true; + private boolean initialFocus = true; + private boolean useLegacyBridge = false; + private int minWebViewVersion = DEFAULT_ANDROID_WEBVIEW_VERSION; + private int minHuaweiWebViewVersion = DEFAULT_HUAWEI_WEBVIEW_VERSION; + private String errorPath; + + // Embedded + private String startPath; + + // Plugins + private Map pluginsConfiguration = null; + + // Config Object JSON (legacy) + private JSONObject configJSON = new JSONObject(); + + /** + * Constructs an empty config file. + */ + private CapConfig() {} + + /** + * Get an instance of the Config file object. + * @deprecated use {@link #loadDefault(Context)} to load an instance of the Config object + * from the capacitor.config.json file, or use the {@link CapConfig.Builder} to construct + * a CapConfig for embedded use. + * + * @param assetManager The AssetManager used to load the config file + * @param config JSON describing a configuration to use + */ + @Deprecated + public CapConfig(AssetManager assetManager, JSONObject config) { + if (config != null) { + this.configJSON = config; + } else { + // Load the capacitor.config.json + loadConfigFromAssets(assetManager, null); + } + + deserializeConfig(null); + } + + /** + * Constructs a Capacitor Configuration from config.json file. + * + * @param context The context. + * @return A loaded config file, if successful. + */ + public static CapConfig loadDefault(Context context) { + CapConfig config = new CapConfig(); + + if (context == null) { + Logger.error("Capacitor Config could not be created from file. Context must not be null."); + return config; + } + + config.loadConfigFromAssets(context.getAssets(), null); + config.deserializeConfig(context); + return config; + } + + /** + * Constructs a Capacitor Configuration from config.json file within the app assets. + * + * @param context The context. + * @param path A path relative to the root assets directory. + * @return A loaded config file, if successful. + */ + public static CapConfig loadFromAssets(Context context, String path) { + CapConfig config = new CapConfig(); + + if (context == null) { + Logger.error("Capacitor Config could not be created from file. Context must not be null."); + return config; + } + + config.loadConfigFromAssets(context.getAssets(), path); + config.deserializeConfig(context); + return config; + } + + /** + * Constructs a Capacitor Configuration from config.json file within the app file-space. + * + * @param context The context. + * @param path A path relative to the root of the app file-space. + * @return A loaded config file, if successful. + */ + public static CapConfig loadFromFile(Context context, String path) { + CapConfig config = new CapConfig(); + + if (context == null) { + Logger.error("Capacitor Config could not be created from file. Context must not be null."); + return config; + } + + config.loadConfigFromFile(path); + config.deserializeConfig(context); + return config; + } + + /** + * Constructs a Capacitor Configuration using ConfigBuilder. + * + * @param builder A config builder initialized with values + */ + private CapConfig(Builder builder) { + // Server Config + this.html5mode = builder.html5mode; + this.serverUrl = builder.serverUrl; + this.hostname = builder.hostname; + + if (this.validateScheme(builder.androidScheme)) { + this.androidScheme = builder.androidScheme; + } + + this.allowNavigation = builder.allowNavigation; + + // Android Config + this.overriddenUserAgentString = builder.overriddenUserAgentString; + this.appendedUserAgentString = builder.appendedUserAgentString; + this.backgroundColor = builder.backgroundColor; + this.allowMixedContent = builder.allowMixedContent; + this.captureInput = builder.captureInput; + this.webContentsDebuggingEnabled = builder.webContentsDebuggingEnabled; + this.loggingEnabled = builder.loggingEnabled; + this.initialFocus = builder.initialFocus; + this.useLegacyBridge = builder.useLegacyBridge; + this.minWebViewVersion = builder.minWebViewVersion; + this.minHuaweiWebViewVersion = builder.minHuaweiWebViewVersion; + this.errorPath = builder.errorPath; + + // Embedded + this.startPath = builder.startPath; + + // Plugins Config + this.pluginsConfiguration = builder.pluginsConfiguration; + } + + /** + * Loads a Capacitor Configuration JSON file into a Capacitor Configuration object. + * An optional path string can be provided to look for the config in a subdirectory path. + */ + private void loadConfigFromAssets(AssetManager assetManager, String path) { + if (path == null) { + path = ""; + } else { + // Add slash at the end to form a proper file path if going deeper in assets dir + if (path.charAt(path.length() - 1) != '/') { + path = path + "/"; + } + } + + try { + String jsonString = readFileFromAssets(assetManager, path + "capacitor.config.json"); + configJSON = new JSONObject(jsonString); + } catch (IOException ex) { + Logger.error("Unable to load capacitor.config.json. Run npx cap copy first", ex); + } catch (JSONException ex) { + Logger.error("Unable to parse capacitor.config.json. Make sure it's valid json", ex); + } + } + + /** + * Loads a Capacitor Configuration JSON file into a Capacitor Configuration object. + * An optional path string can be provided to look for the config in a subdirectory path. + */ + private void loadConfigFromFile(String path) { + if (path == null) { + path = ""; + } else { + // Add slash at the end to form a proper file path if going deeper in assets dir + if (path.charAt(path.length() - 1) != '/') { + path = path + "/"; + } + } + + try { + File configFile = new File(path + "capacitor.config.json"); + String jsonString = FileUtils.readFileFromDisk(configFile); + configJSON = new JSONObject(jsonString); + } catch (JSONException ex) { + Logger.error("Unable to parse capacitor.config.json. Make sure it's valid json", ex); + } catch (IOException ex) { + Logger.error("Unable to load capacitor.config.json.", ex); + } + } + + /** + * Deserializes the config from JSON into a Capacitor Configuration object. + */ + private void deserializeConfig(@Nullable Context context) { + boolean isDebug = context != null && (context.getApplicationInfo().flags & ApplicationInfo.FLAG_DEBUGGABLE) != 0; + + // Server + html5mode = JSONUtils.getBoolean(configJSON, "server.html5mode", html5mode); + serverUrl = JSONUtils.getString(configJSON, "server.url", null); + hostname = JSONUtils.getString(configJSON, "server.hostname", hostname); + errorPath = JSONUtils.getString(configJSON, "server.errorPath", null); + + String configSchema = JSONUtils.getString(configJSON, "server.androidScheme", androidScheme); + if (this.validateScheme(configSchema)) { + androidScheme = configSchema; + } + + allowNavigation = JSONUtils.getArray(configJSON, "server.allowNavigation", null); + + // Android + overriddenUserAgentString = + JSONUtils.getString(configJSON, "android.overrideUserAgent", JSONUtils.getString(configJSON, "overrideUserAgent", null)); + appendedUserAgentString = + JSONUtils.getString(configJSON, "android.appendUserAgent", JSONUtils.getString(configJSON, "appendUserAgent", null)); + backgroundColor = + JSONUtils.getString(configJSON, "android.backgroundColor", JSONUtils.getString(configJSON, "backgroundColor", null)); + allowMixedContent = + JSONUtils.getBoolean( + configJSON, + "android.allowMixedContent", + JSONUtils.getBoolean(configJSON, "allowMixedContent", allowMixedContent) + ); + minWebViewVersion = JSONUtils.getInt(configJSON, "android.minWebViewVersion", DEFAULT_ANDROID_WEBVIEW_VERSION); + minHuaweiWebViewVersion = JSONUtils.getInt(configJSON, "android.minHuaweiWebViewVersion", DEFAULT_HUAWEI_WEBVIEW_VERSION); + captureInput = JSONUtils.getBoolean(configJSON, "android.captureInput", captureInput); + useLegacyBridge = JSONUtils.getBoolean(configJSON, "android.useLegacyBridge", useLegacyBridge); + webContentsDebuggingEnabled = JSONUtils.getBoolean(configJSON, "android.webContentsDebuggingEnabled", isDebug); + + String logBehavior = JSONUtils.getString( + configJSON, + "android.loggingBehavior", + JSONUtils.getString(configJSON, "loggingBehavior", LOG_BEHAVIOR_DEBUG) + ); + switch (logBehavior.toLowerCase(Locale.ROOT)) { + case LOG_BEHAVIOR_PRODUCTION: + loggingEnabled = true; + break; + case LOG_BEHAVIOR_NONE: + loggingEnabled = false; + break; + default: // LOG_BEHAVIOR_DEBUG + loggingEnabled = isDebug; + } + + initialFocus = JSONUtils.getBoolean(configJSON, "android.initialFocus", initialFocus); + + // Plugins + pluginsConfiguration = deserializePluginsConfig(JSONUtils.getObject(configJSON, "plugins")); + } + + private boolean validateScheme(String scheme) { + List invalidSchemes = Arrays.asList("file", "ftp", "ftps", "ws", "wss", "about", "blob", "data"); + if (invalidSchemes.contains(scheme)) { + Logger.warn(scheme + " is not an allowed scheme. Defaulting to http."); + return false; + } + + // Non-http(s) schemes are not allowed to modify the URL path as of Android Webview 117 + if (!scheme.equals("http") && !scheme.equals("https")) { + Logger.warn( + "Using a non-standard scheme: " + scheme + " for Android. This is known to cause issues as of Android Webview 117." + ); + } + + return true; + } + + public boolean isHTML5Mode() { + return html5mode; + } + + public String getServerUrl() { + return serverUrl; + } + + public String getErrorPath() { + return errorPath; + } + + public String getHostname() { + return hostname; + } + + public String getStartPath() { + return startPath; + } + + public String getAndroidScheme() { + return androidScheme; + } + + public String[] getAllowNavigation() { + return allowNavigation; + } + + public String getOverriddenUserAgentString() { + return overriddenUserAgentString; + } + + public String getAppendedUserAgentString() { + return appendedUserAgentString; + } + + public String getBackgroundColor() { + return backgroundColor; + } + + public boolean isMixedContentAllowed() { + return allowMixedContent; + } + + public boolean isInputCaptured() { + return captureInput; + } + + public boolean isWebContentsDebuggingEnabled() { + return webContentsDebuggingEnabled; + } + + public boolean isLoggingEnabled() { + return loggingEnabled; + } + + public boolean isInitialFocus() { + return initialFocus; + } + + public boolean isUsingLegacyBridge() { + return useLegacyBridge; + } + + public int getMinWebViewVersion() { + if (minWebViewVersion < MINIMUM_ANDROID_WEBVIEW_VERSION) { + Logger.warn("Specified minimum webview version is too low, defaulting to " + MINIMUM_ANDROID_WEBVIEW_VERSION); + return MINIMUM_ANDROID_WEBVIEW_VERSION; + } + + return minWebViewVersion; + } + + public int getMinHuaweiWebViewVersion() { + if (minHuaweiWebViewVersion < MINIMUM_HUAWEI_WEBVIEW_VERSION) { + Logger.warn("Specified minimum Huawei webview version is too low, defaulting to " + MINIMUM_HUAWEI_WEBVIEW_VERSION); + return MINIMUM_HUAWEI_WEBVIEW_VERSION; + } + + return minHuaweiWebViewVersion; + } + + public PluginConfig getPluginConfiguration(String pluginId) { + PluginConfig pluginConfig = pluginsConfiguration.get(pluginId); + if (pluginConfig == null) { + pluginConfig = new PluginConfig(new JSONObject()); + } + + return pluginConfig; + } + + /** + * Get a JSON object value from the Capacitor config. + * @deprecated use {@link PluginConfig#getObject(String)} to access plugin config values. + * For main Capacitor config values, use the appropriate getter. + * + * @param key A key to fetch from the config + * @return The value from the config, if exists. Null if not + */ + @Deprecated + public JSONObject getObject(String key) { + try { + return configJSON.getJSONObject(key); + } catch (Exception ex) {} + return null; + } + + /** + * Get a string value from the Capacitor config. + * @deprecated use {@link PluginConfig#getString(String, String)} to access plugin config + * values. For main Capacitor config values, use the appropriate getter. + * + * @param key A key to fetch from the config + * @return The value from the config, if exists. Null if not + */ + @Deprecated + public String getString(String key) { + return JSONUtils.getString(configJSON, key, null); + } + + /** + * Get a string value from the Capacitor config. + * @deprecated use {@link PluginConfig#getString(String, String)} to access plugin config + * values. For main Capacitor config values, use the appropriate getter. + * + * @param key A key to fetch from the config + * @param defaultValue A default value to return if the key does not exist in the config + * @return The value from the config, if key exists. Default value returned if not + */ + @Deprecated + public String getString(String key, String defaultValue) { + return JSONUtils.getString(configJSON, key, defaultValue); + } + + /** + * Get a boolean value from the Capacitor config. + * @deprecated use {@link PluginConfig#getBoolean(String, boolean)} to access plugin config + * values. For main Capacitor config values, use the appropriate getter. + * + * @param key A key to fetch from the config + * @param defaultValue A default value to return if the key does not exist in the config + * @return The value from the config, if key exists. Default value returned if not + */ + @Deprecated + public boolean getBoolean(String key, boolean defaultValue) { + return JSONUtils.getBoolean(configJSON, key, defaultValue); + } + + /** + * Get an integer value from the Capacitor config. + * @deprecated use {@link PluginConfig#getInt(String, int)} to access the plugin config + * values. For main Capacitor config values, use the appropriate getter. + * + * @param key A key to fetch from the config + * @param defaultValue A default value to return if the key does not exist in the config + * @return The value from the config, if key exists. Default value returned if not + */ + @Deprecated + public int getInt(String key, int defaultValue) { + return JSONUtils.getInt(configJSON, key, defaultValue); + } + + /** + * Get a string array value from the Capacitor config. + * @deprecated use {@link PluginConfig#getArray(String)} to access the plugin config + * values. For main Capacitor config values, use the appropriate getter. + * + * @param key A key to fetch from the config + * @return The value from the config, if exists. Null if not + */ + @Deprecated + public String[] getArray(String key) { + return JSONUtils.getArray(configJSON, key, null); + } + + /** + * Get a string array value from the Capacitor config. + * @deprecated use {@link PluginConfig#getArray(String, String[])} to access the plugin + * config values. For main Capacitor config values, use the appropriate getter. + * + * @param key A key to fetch from the config + * @param defaultValue A default value to return if the key does not exist in the config + * @return The value from the config, if key exists. Default value returned if not + */ + @Deprecated + public String[] getArray(String key, String[] defaultValue) { + return JSONUtils.getArray(configJSON, key, defaultValue); + } + + private static Map deserializePluginsConfig(JSONObject pluginsConfig) { + Map pluginsMap = new HashMap<>(); + + // return an empty map if there is no pluginsConfig json + if (pluginsConfig == null) { + return pluginsMap; + } + + Iterator pluginIds = pluginsConfig.keys(); + + while (pluginIds.hasNext()) { + String pluginId = pluginIds.next(); + JSONObject value = null; + + try { + value = pluginsConfig.getJSONObject(pluginId); + PluginConfig pluginConfig = new PluginConfig(value); + pluginsMap.put(pluginId, pluginConfig); + } catch (JSONException e) { + e.printStackTrace(); + } + } + + return pluginsMap; + } + + /** + * Builds a Capacitor Configuration in code + */ + public static class Builder { + + private Context context; + + // Server Config Values + private boolean html5mode = true; + private String serverUrl; + private String errorPath; + private String hostname = "localhost"; + private String androidScheme = CAPACITOR_HTTP_SCHEME; + private String[] allowNavigation; + + // Android Config Values + private String overriddenUserAgentString; + private String appendedUserAgentString; + private String backgroundColor; + private boolean allowMixedContent = false; + private boolean captureInput = false; + private Boolean webContentsDebuggingEnabled = null; + private boolean loggingEnabled = true; + private boolean initialFocus = false; + private boolean useLegacyBridge = false; + private int minWebViewVersion = DEFAULT_ANDROID_WEBVIEW_VERSION; + private int minHuaweiWebViewVersion = DEFAULT_HUAWEI_WEBVIEW_VERSION; + + // Embedded + private String startPath = null; + + // Plugins Config Object + private Map pluginsConfiguration = new HashMap<>(); + + /** + * Constructs a new CapConfig Builder. + * + * @param context The context + */ + public Builder(Context context) { + this.context = context; + } + + /** + * Builds a Capacitor Config from the builder. + * + * @return A new Capacitor Config + */ + public CapConfig create() { + if (webContentsDebuggingEnabled == null) { + webContentsDebuggingEnabled = (context.getApplicationInfo().flags & ApplicationInfo.FLAG_DEBUGGABLE) != 0; + } + + return new CapConfig(this); + } + + public Builder setPluginsConfiguration(JSONObject pluginsConfiguration) { + this.pluginsConfiguration = deserializePluginsConfig(pluginsConfiguration); + return this; + } + + public Builder setHTML5mode(boolean html5mode) { + this.html5mode = html5mode; + return this; + } + + public Builder setServerUrl(String serverUrl) { + this.serverUrl = serverUrl; + return this; + } + + public Builder setErrorPath(String errorPath) { + this.errorPath = errorPath; + return this; + } + + public Builder setHostname(String hostname) { + this.hostname = hostname; + return this; + } + + public Builder setStartPath(String path) { + this.startPath = path; + return this; + } + + public Builder setAndroidScheme(String androidScheme) { + this.androidScheme = androidScheme; + return this; + } + + public Builder setAllowNavigation(String[] allowNavigation) { + this.allowNavigation = allowNavigation; + return this; + } + + public Builder setOverriddenUserAgentString(String overriddenUserAgentString) { + this.overriddenUserAgentString = overriddenUserAgentString; + return this; + } + + public Builder setAppendedUserAgentString(String appendedUserAgentString) { + this.appendedUserAgentString = appendedUserAgentString; + return this; + } + + public Builder setBackgroundColor(String backgroundColor) { + this.backgroundColor = backgroundColor; + return this; + } + + public Builder setAllowMixedContent(boolean allowMixedContent) { + this.allowMixedContent = allowMixedContent; + return this; + } + + public Builder setCaptureInput(boolean captureInput) { + this.captureInput = captureInput; + return this; + } + + public Builder setUseLegacyBridge(boolean useLegacyBridge) { + this.useLegacyBridge = useLegacyBridge; + return this; + } + + public Builder setWebContentsDebuggingEnabled(boolean webContentsDebuggingEnabled) { + this.webContentsDebuggingEnabled = webContentsDebuggingEnabled; + return this; + } + + public Builder setLoggingEnabled(boolean enabled) { + this.loggingEnabled = enabled; + return this; + } + + public Builder setInitialFocus(boolean focus) { + this.initialFocus = focus; + return this; + } + } +} diff --git a/@capacitor/capacitor/src/main/java/com/getcapacitor/CapacitorWebView.java b/@capacitor/capacitor/src/main/java/com/getcapacitor/CapacitorWebView.java new file mode 100644 index 00000000..e46b904a --- /dev/null +++ b/@capacitor/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/capacitor/src/main/java/com/getcapacitor/FileUtils.java b/@capacitor/capacitor/src/main/java/com/getcapacitor/FileUtils.java new file mode 100644 index 00000000..47add8cd --- /dev/null +++ b/@capacitor/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/capacitor/src/main/java/com/getcapacitor/InvalidPluginException.java b/@capacitor/capacitor/src/main/java/com/getcapacitor/InvalidPluginException.java new file mode 100644 index 00000000..1757e326 --- /dev/null +++ b/@capacitor/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/capacitor/src/main/java/com/getcapacitor/InvalidPluginMethodException.java b/@capacitor/capacitor/src/main/java/com/getcapacitor/InvalidPluginMethodException.java new file mode 100644 index 00000000..94be491e --- /dev/null +++ b/@capacitor/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/capacitor/src/main/java/com/getcapacitor/JSArray.java b/@capacitor/capacitor/src/main/java/com/getcapacitor/JSArray.java new file mode 100644 index 00000000..06b7f4dd --- /dev/null +++ b/@capacitor/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/capacitor/src/main/java/com/getcapacitor/JSExport.java b/@capacitor/capacitor/src/main/java/com/getcapacitor/JSExport.java new file mode 100644 index 00000000..382f4b5d --- /dev/null +++ b/@capacitor/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/capacitor/src/main/java/com/getcapacitor/JSExportException.java b/@capacitor/capacitor/src/main/java/com/getcapacitor/JSExportException.java new file mode 100644 index 00000000..14b6043a --- /dev/null +++ b/@capacitor/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/capacitor/src/main/java/com/getcapacitor/JSInjector.java b/@capacitor/capacitor/src/main/java/com/getcapacitor/JSInjector.java new file mode 100644 index 00000000..a3871f7b --- /dev/null +++ b/@capacitor/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/capacitor/src/main/java/com/getcapacitor/JSObject.java b/@capacitor/capacitor/src/main/java/com/getcapacitor/JSObject.java new file mode 100644 index 00000000..0e987076 --- /dev/null +++ b/@capacitor/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/capacitor/src/main/java/com/getcapacitor/JSValue.java b/@capacitor/capacitor/src/main/java/com/getcapacitor/JSValue.java new file mode 100644 index 00000000..d97ba91b --- /dev/null +++ b/@capacitor/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/capacitor/src/main/java/com/getcapacitor/Logger.java b/@capacitor/capacitor/src/main/java/com/getcapacitor/Logger.java new file mode 100644 index 00000000..9d24fedd --- /dev/null +++ b/@capacitor/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/capacitor/src/main/java/com/getcapacitor/MessageHandler.java b/@capacitor/capacitor/src/main/java/com/getcapacitor/MessageHandler.java new file mode 100644 index 00000000..b71124e8 --- /dev/null +++ b/@capacitor/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/capacitor/src/main/java/com/getcapacitor/NativePlugin.java b/@capacitor/capacitor/src/main/java/com/getcapacitor/NativePlugin.java new file mode 100644 index 00000000..c4307624 --- /dev/null +++ b/@capacitor/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/capacitor/src/main/java/com/getcapacitor/PermissionState.java b/@capacitor/capacitor/src/main/java/com/getcapacitor/PermissionState.java new file mode 100644 index 00000000..382cff71 --- /dev/null +++ b/@capacitor/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/capacitor/src/main/java/com/getcapacitor/Plugin.java b/@capacitor/capacitor/src/main/java/com/getcapacitor/Plugin.java new file mode 100644 index 00000000..d8a3e82a --- /dev/null +++ b/@capacitor/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/capacitor/src/main/java/com/getcapacitor/PluginCall.java b/@capacitor/capacitor/src/main/java/com/getcapacitor/PluginCall.java new file mode 100644 index 00000000..18661d76 --- /dev/null +++ b/@capacitor/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/capacitor/src/main/java/com/getcapacitor/PluginConfig.java b/@capacitor/capacitor/src/main/java/com/getcapacitor/PluginConfig.java new file mode 100644 index 00000000..0f00fc53 --- /dev/null +++ b/@capacitor/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/capacitor/src/main/java/com/getcapacitor/PluginHandle.java b/@capacitor/capacitor/src/main/java/com/getcapacitor/PluginHandle.java new file mode 100644 index 00000000..2e520b3a --- /dev/null +++ b/@capacitor/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.newInstance(); + return this.loadInstance(instance); + } catch (InstantiationException | IllegalAccessException ex) { + throw new PluginLoadException("Unable to load plugin instance. Ensure plugin is publicly accessible"); + } + } + + public Plugin loadInstance(Plugin plugin) { + this.instance = plugin; + this.instance.setPluginHandle(this); + this.instance.setBridge(this.bridge); + this.instance.load(); + this.instance.initializeActivityLaunchers(); + return this.instance; + } + + /** + * Call a method on a plugin. + * @param methodName the name of the method to call + * @param call the constructed PluginCall with parameters from the caller + * @throws InvalidPluginMethodException if no method was found on that plugin + */ + public void invoke(String methodName, PluginCall call) + throws PluginLoadException, InvalidPluginMethodException, InvocationTargetException, IllegalAccessException { + if (this.instance == null) { + // Can throw PluginLoadException + this.load(); + } + + PluginMethodHandle methodMeta = pluginMethods.get(methodName); + if (methodMeta == null) { + throw new InvalidPluginMethodException("No method " + methodName + " found for plugin " + pluginClass.getName()); + } + + methodMeta.getMethod().invoke(this.instance, call); + } + + /** + * Index all the known callable methods for a plugin for faster + * invocation later + */ + private void indexMethods(Class plugin) { + //Method[] methods = pluginClass.getDeclaredMethods(); + Method[] methods = pluginClass.getMethods(); + + for (Method methodReflect : methods) { + PluginMethod method = methodReflect.getAnnotation(PluginMethod.class); + + if (method == null) { + continue; + } + + PluginMethodHandle methodMeta = new PluginMethodHandle(methodReflect, method); + pluginMethods.put(methodReflect.getName(), methodMeta); + } + } +} diff --git a/@capacitor/capacitor/src/main/java/com/getcapacitor/PluginInvocationException.java b/@capacitor/capacitor/src/main/java/com/getcapacitor/PluginInvocationException.java new file mode 100644 index 00000000..ae6b0eb8 --- /dev/null +++ b/@capacitor/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/capacitor/src/main/java/com/getcapacitor/PluginLoadException.java b/@capacitor/capacitor/src/main/java/com/getcapacitor/PluginLoadException.java new file mode 100644 index 00000000..8d81a382 --- /dev/null +++ b/@capacitor/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/capacitor/src/main/java/com/getcapacitor/PluginManager.java b/@capacitor/capacitor/src/main/java/com/getcapacitor/PluginManager.java new file mode 100644 index 00000000..540bc912 --- /dev/null +++ b/@capacitor/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/capacitor/src/main/java/com/getcapacitor/PluginMethod.java b/@capacitor/capacitor/src/main/java/com/getcapacitor/PluginMethod.java new file mode 100644 index 00000000..85663043 --- /dev/null +++ b/@capacitor/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/capacitor/src/main/java/com/getcapacitor/PluginMethodHandle.java b/@capacitor/capacitor/src/main/java/com/getcapacitor/PluginMethodHandle.java new file mode 100644 index 00000000..a728c1f1 --- /dev/null +++ b/@capacitor/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/capacitor/src/main/java/com/getcapacitor/PluginResult.java b/@capacitor/capacitor/src/main/java/com/getcapacitor/PluginResult.java new file mode 100644 index 00000000..cdc169e0 --- /dev/null +++ b/@capacitor/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/capacitor/src/main/java/com/getcapacitor/ProcessedRoute.java b/@capacitor/capacitor/src/main/java/com/getcapacitor/ProcessedRoute.java new file mode 100644 index 00000000..eb3d7b0d --- /dev/null +++ b/@capacitor/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/capacitor/src/main/java/com/getcapacitor/RouteProcessor.java b/@capacitor/capacitor/src/main/java/com/getcapacitor/RouteProcessor.java new file mode 100644 index 00000000..670c8bc6 --- /dev/null +++ b/@capacitor/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/capacitor/src/main/java/com/getcapacitor/ServerPath.java b/@capacitor/capacitor/src/main/java/com/getcapacitor/ServerPath.java new file mode 100644 index 00000000..5b34b460 --- /dev/null +++ b/@capacitor/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/capacitor/src/main/java/com/getcapacitor/UriMatcher.java b/@capacitor/capacitor/src/main/java/com/getcapacitor/UriMatcher.java new file mode 100644 index 00000000..715a0a0b --- /dev/null +++ b/@capacitor/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/capacitor/src/main/java/com/getcapacitor/WebViewListener.java b/@capacitor/capacitor/src/main/java/com/getcapacitor/WebViewListener.java new file mode 100644 index 00000000..6df4f6c0 --- /dev/null +++ b/@capacitor/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/capacitor/src/main/java/com/getcapacitor/WebViewLocalServer.java b/@capacitor/capacitor/src/main/java/com/getcapacitor/WebViewLocalServer.java new file mode 100644 index 00000000..3745a8f3 --- /dev/null +++ b/@capacitor/capacitor/src/main/java/com/getcapacitor/WebViewLocalServer.java @@ -0,0 +1,878 @@ +/* +Copyright 2015 Google Inc. All rights reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + */ +package com.getcapacitor; + +import static com.getcapacitor.plugin.util.HttpRequestHandler.isDomainExcludedFromSSL; + +import android.content.Context; +import android.net.Uri; +import android.util.Base64; +import android.webkit.CookieManager; +import android.webkit.WebResourceRequest; +import android.webkit.WebResourceResponse; +import com.getcapacitor.plugin.util.CapacitorHttpUrlConnection; +import com.getcapacitor.plugin.util.HttpRequestHandler; +import java.io.IOException; +import java.io.InputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.net.URLConnection; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; + +import java.lang.reflect.Field; + +/** + * Helper class meant to be used with the android.webkit.WebView class to enable + * hosting assets, + * resources and other data on 'virtual' https:// URL. + * Hosting assets and resources on https:// URLs is desirable as it is + * compatible with the + * Same-Origin policy. + *

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

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

+ * On Android KitKat and above these methods may be called on more than one + * thread. This thread + * may be different than the thread on which the shouldInterceptRequest method + * was invoke. + * This means that on Android KitKat and above it is possible to block in this + * method without + * blocking other resources from loading. The number of threads used to + * parallelize loading + * is an internal implementation detail of the WebView and may change between + * updates which + * means that the amount of time spend blocking in this method should be kept to + * an absolute + * minimum. + */ + public abstract static class PathHandler { + + protected String mimeType; + private String encoding; + private String charset; + private int statusCode; + private String reasonPhrase; + private Map responseHeaders; + + public PathHandler() { + this(null, null, 200, "OK", null); + } + + public PathHandler(String encoding, String charset, int statusCode, String reasonPhrase, + Map responseHeaders) { + this.encoding = encoding; + this.charset = charset; + this.statusCode = statusCode; + this.reasonPhrase = reasonPhrase; + Map tempResponseHeaders; + if (responseHeaders == null) { + tempResponseHeaders = new HashMap<>(); + } else { + tempResponseHeaders = responseHeaders; + } + tempResponseHeaders.put("Cache-Control", "no-cache"); + this.responseHeaders = tempResponseHeaders; + } + + public InputStream handle(WebResourceRequest request) { + return handle(request.getUrl()); + } + + public abstract InputStream handle(Uri url); + + public String getEncoding() { + return encoding; + } + + public String getCharset() { + return charset; + } + + public int getStatusCode() { + return statusCode; + } + + public String getReasonPhrase() { + return reasonPhrase; + } + + public Map getResponseHeaders() { + return responseHeaders; + } + } + + WebViewLocalServer(Context context, Bridge bridge, JSInjector jsInjector, ArrayList authorities, + boolean html5mode) { + uriMatcher = new UriMatcher(null); + this.html5mode = html5mode; + this.protocolHandler = new AndroidProtocolHandler(context.getApplicationContext()); + this.authorities = authorities; + this.bridge = bridge; + this.jsInjector = jsInjector; + } + + private static Uri parseAndVerifyUrl(String url) { + if (url == null) { + return null; + } + Uri uri = Uri.parse(url); + if (uri == null) { + Logger.error("Malformed URL: " + url); + return null; + } + String path = uri.getPath(); + if (path == null || path.isEmpty()) { + Logger.error("URL does not have a path: " + url); + return null; + } + return uri; + } + + /** + * Attempt to retrieve the WebResourceResponse associated with the given + * request. + * This method should be invoked from within + * {@link android.webkit.WebViewClient#shouldInterceptRequest(android.webkit.WebView, + * android.webkit.WebResourceRequest)}. + * + * @param request the request to process. + * @return a response if the request URL had a matching handler, null if no + * handler was found. + */ + public WebResourceResponse shouldInterceptRequest(WebResourceRequest request) { + Uri loadingUrl = request.getUrl(); + + if (loadingUrl.toString().endsWith("#image")) { + Map headers = new HashMap<>(request.getRequestHeaders()); + headers.remove("x-requested-with"); + + try { + URL url = new URL(loadingUrl.toString()); + HttpURLConnection connection = (HttpURLConnection) url.openConnection(); + + for (Map.Entry entry : headers.entrySet()) { + connection.setRequestProperty(entry.getKey(), entry.getValue()); + } + + String contentType = connection.getContentType(); + if (contentType == null) { + contentType = "image/unknown"; + } + + InputStream inputStream = connection.getInputStream(); + + return new WebResourceResponse(contentType, "UTF-8", inputStream); + + } catch (Exception e) { + e.printStackTrace(); + } + } + if (loadingUrl.toString().endsWith("#resolve")) { + Map headers = new HashMap<>(request.getRequestHeaders()); + headers.remove("x-requested-with"); + + try { + URL url = new URL(loadingUrl.toString()); + HttpURLConnection connection = (HttpURLConnection) url.openConnection(); + + for (Map.Entry entry : headers.entrySet()) { + connection.setRequestProperty(entry.getKey(), entry.getValue()); + } + + connection.setInstanceFollowRedirects(false); + connection.connect(); + + String locationHeader = connection.getHeaderField("Location"); + String resolvedUrl = (locationHeader != null) ? locationHeader : loadingUrl.toString(); + + WebResourceResponse response = new WebResourceResponse("text/plain", "UTF-8", + new ByteArrayInputStream(resolvedUrl.getBytes(StandardCharsets.UTF_8))); + + Map responseHeaders = new HashMap<>(); + for (Map.Entry> entry : connection.getHeaderFields().entrySet()) { + if (entry.getKey() != null && entry.getValue() != null && !entry.getValue().isEmpty()) { + responseHeaders.put(entry.getKey(), entry.getValue().get(0)); + } + } + + responseHeaders.put("x-location", resolvedUrl); + responseHeaders.put("Access-Control-Allow-Origin", "*"); + responseHeaders.put("Access-Control-Allow-Methods", "GET, POST, OPTIONS"); + response.setResponseHeaders(responseHeaders); + + return response; + + } catch (Exception e) { + e.printStackTrace(); + } + } + + if (loadingUrl.toString().endsWith("#animevsub-vsub_extra")) { + Map headers = new HashMap<>(request.getRequestHeaders()); + headers.remove("x-requested-with"); + headers.put("referer", "https://animevietsub.tv"); + + try { + URL url = new URL(loadingUrl.toString()); + HttpURLConnection connection = (HttpURLConnection) url.openConnection(); + + for (Map.Entry entry : headers.entrySet()) { + connection.setRequestProperty(entry.getKey(), entry.getValue()); + } + + connection.setInstanceFollowRedirects(false); + connection.connect(); + + String locationHeader = connection.getHeaderField("Location"); + String resolvedUrl = (locationHeader != null) ? locationHeader : loadingUrl.toString(); + + Map responseHeaders = new HashMap<>(); + // for (Map.Entry> entry : + // connection.getHeaderFields().entrySet()) { + // if (entry.getKey() != null && entry.getValue() != null && + // !entry.getValue().isEmpty()) { + // responseHeaders.put(entry.getKey(), entry.getValue().get(0)); + // } + // } + // + responseHeaders.put("Access-Control-Allow-Origin", "*"); + responseHeaders.put("Access-Control-Allow-Methods", "PUT, GET, HEAD, POST, DELETE, OPTIONS"); + + // responseHeaders.put("Location", resolvedUrl); + responseHeaders.put("W-Location", resolvedUrl); + + WebResourceResponse response = new WebResourceResponse("text/plain", "UTF-8", + new ByteArrayInputStream(resolvedUrl.getBytes(StandardCharsets.UTF_8))); + + response.setResponseHeaders(responseHeaders); + + return response; + } catch (Exception e) { + e.printStackTrace(); + } + } + + if (null != loadingUrl.getPath() && + (loadingUrl.getPath().startsWith(Bridge.CAPACITOR_HTTP_INTERCEPTOR_START) || + loadingUrl.getPath().startsWith(Bridge.CAPACITOR_HTTPS_INTERCEPTOR_START))) { + Logger.debug("Handling CapacitorHttp request: " + loadingUrl); + try { + return handleCapacitorHttpRequest(request); + } catch (Exception e) { + Logger.error(e.getLocalizedMessage()); + return null; + } + } + + PathHandler handler; + synchronized (uriMatcher) { + handler = (PathHandler) uriMatcher.match(request.getUrl()); + } + if (handler == null) { + return null; + } + + if (isLocalFile(loadingUrl) || isMainUrl(loadingUrl) || !isAllowedUrl(loadingUrl) || isErrorUrl(loadingUrl)) { + Logger.debug("Handling local request: " + request.getUrl().toString()); + return handleLocalRequest(request, handler); + } else { + return handleProxyRequest(request, handler); + } + } + + private boolean isLocalFile(Uri uri) { + String path = uri.getPath(); + return path.startsWith(capacitorContentStart) || path.startsWith(capacitorFileStart); + } + + private boolean isErrorUrl(Uri uri) { + String url = uri.toString(); + return url.equals(bridge.getErrorUrl()); + } + + private boolean isMainUrl(Uri loadingUrl) { + return (bridge.getServerUrl() == null && loadingUrl.getHost().equalsIgnoreCase(bridge.getHost())); + } + + private boolean isAllowedUrl(Uri loadingUrl) { + return !(bridge.getServerUrl() == null && !bridge.getAppAllowNavigationMask().matches(loadingUrl.getHost())); + } + + private String getReasonPhraseFromResponseCode(int code) { + return switch (code) { + case 100 -> "Continue"; + case 101 -> "Switching Protocols"; + case 200 -> "OK"; + case 201 -> "Created"; + case 202 -> "Accepted"; + case 203 -> "Non-Authoritative Information"; + case 204 -> "No Content"; + case 205 -> "Reset Content"; + case 206 -> "Partial Content"; + case 300 -> "Multiple Choices"; + case 301 -> "Moved Permanently"; + case 302 -> "Found"; + case 303 -> "See Other"; + case 304 -> "Not Modified"; + case 400 -> "Bad Request"; + case 401 -> "Unauthorized"; + case 403 -> "Forbidden"; + case 404 -> "Not Found"; + case 405 -> "Method Not Allowed"; + case 406 -> "Not Acceptable"; + case 407 -> "Proxy Authentication Required"; + case 408 -> "Request Timeout"; + case 409 -> "Conflict"; + case 410 -> "Gone"; + case 500 -> "Internal Server Error"; + case 501 -> "Not Implemented"; + case 502 -> "Bad Gateway"; + case 503 -> "Service Unavailable"; + case 504 -> "Gateway Timeout"; + case 505 -> "HTTP Version Not Supported"; + default -> "Unknown"; + }; + } + + private WebResourceResponse handleCapacitorHttpRequest(WebResourceRequest request) throws IOException { + boolean isHttps = request.getUrl().getPath() != null + && request.getUrl().getPath().startsWith(Bridge.CAPACITOR_HTTPS_INTERCEPTOR_START); + + String urlString = request + .getUrl() + .toString() + .replace(bridge.getLocalUrl(), isHttps ? "https:/" : "http:/") + .replace(Bridge.CAPACITOR_HTTP_INTERCEPTOR_START, "") + .replace(Bridge.CAPACITOR_HTTPS_INTERCEPTOR_START, ""); + urlString = URLDecoder.decode(urlString, "UTF-8"); + URL url = new URL(urlString); + JSObject headers = new JSObject(); + + for (Map.Entry header : request.getRequestHeaders().entrySet()) { + headers.put(header.getKey(), header.getValue()); + } + + HttpRequestHandler.HttpURLConnectionBuilder connectionBuilder = new HttpRequestHandler.HttpURLConnectionBuilder() + .setUrl(url) + .setMethod(request.getMethod()) + .setHeaders(headers) + .openConnection(); + + CapacitorHttpUrlConnection connection = connectionBuilder.build(); + + if (!isDomainExcludedFromSSL(bridge, url)) { + connection.setSSLSocketFactory(bridge); + } + + connection.connect(); + + String mimeType = null; + String encoding = null; + Map responseHeaders = new LinkedHashMap<>(); + for (Map.Entry> entry : connection.getHeaderFields().entrySet()) { + StringBuilder builder = new StringBuilder(); + for (String value : entry.getValue()) { + builder.append(value); + builder.append(", "); + } + builder.setLength(builder.length() - 2); + + if ("Content-Type".equalsIgnoreCase(entry.getKey())) { + String[] contentTypeParts = builder.toString().split(";"); + mimeType = contentTypeParts[0].trim(); + if (contentTypeParts.length > 1) { + String[] encodingParts = contentTypeParts[1].split("="); + if (encodingParts.length > 1) { + encoding = encodingParts[1].trim(); + } + } + } else { + responseHeaders.put(entry.getKey(), builder.toString()); + } + } + + InputStream inputStream = connection.getErrorStream(); + if (inputStream == null) { + inputStream = connection.getInputStream(); + } + + if (null == mimeType) { + mimeType = getMimeType(request.getUrl().getPath(), inputStream); + } + + int responseCode = connection.getResponseCode(); + String reasonPhrase = getReasonPhraseFromResponseCode(responseCode); + + return new WebResourceResponse(mimeType, encoding, responseCode, reasonPhrase, responseHeaders, inputStream); + } + + private WebResourceResponse handleLocalRequest(WebResourceRequest request, PathHandler handler) { + String path = request.getUrl().getPath(); + + if (request.getRequestHeaders().get("Range") != null) { + InputStream responseStream = new LollipopLazyInputStream(handler, request); + String mimeType = getMimeType(path, responseStream); + Map tempResponseHeaders = handler.getResponseHeaders(); + int statusCode = 206; + try { + int totalRange = responseStream.available(); + String rangeString = request.getRequestHeaders().get("Range"); + String[] parts = rangeString.split("="); + String[] streamParts = parts[1].split("-"); + String fromRange = streamParts[0]; + int range = totalRange - 1; + if (streamParts.length > 1) { + range = Integer.parseInt(streamParts[1]); + } + tempResponseHeaders.put("Accept-Ranges", "bytes"); + tempResponseHeaders.put("Content-Range", "bytes " + fromRange + "-" + range + "/" + totalRange); + } catch (IOException e) { + statusCode = 404; + } + return new WebResourceResponse( + mimeType, + handler.getEncoding(), + statusCode, + handler.getReasonPhrase(), + tempResponseHeaders, + responseStream); + } + + if (isLocalFile(request.getUrl()) || isErrorUrl(request.getUrl())) { + InputStream responseStream = new LollipopLazyInputStream(handler, request); + String mimeType = getMimeType(request.getUrl().getPath(), responseStream); + int statusCode = getStatusCode(responseStream, handler.getStatusCode()); + return new WebResourceResponse( + mimeType, + handler.getEncoding(), + statusCode, + handler.getReasonPhrase(), + handler.getResponseHeaders(), + responseStream); + } + + if (path.equals("/cordova.js")) { + return new WebResourceResponse( + "application/javascript", + handler.getEncoding(), + handler.getStatusCode(), + handler.getReasonPhrase(), + handler.getResponseHeaders(), + null); + } + + if (path.equals("/") || (!request.getUrl().getLastPathSegment().contains(".") && html5mode)) { + InputStream responseStream; + try { + String startPath = this.basePath + "/index.html"; + if (bridge.getRouteProcessor() != null) { + ProcessedRoute processedRoute = bridge.getRouteProcessor().process(this.basePath, "/index.html"); + startPath = processedRoute.getPath(); + isAsset = processedRoute.isAsset(); + } + + if (isAsset) { + responseStream = protocolHandler.openAsset(startPath); + } else { + responseStream = protocolHandler.openFile(startPath); + } + } catch (IOException e) { + Logger.error("Unable to open index.html", e); + return null; + } + + responseStream = jsInjector.getInjectedStream(responseStream); + + int statusCode = getStatusCode(responseStream, handler.getStatusCode()); + return new WebResourceResponse( + "text/html", + handler.getEncoding(), + statusCode, + handler.getReasonPhrase(), + handler.getResponseHeaders(), + responseStream); + } + + if ("/favicon.ico".equalsIgnoreCase(path)) { + try { + return new WebResourceResponse("image/png", null, null); + } catch (Exception e) { + Logger.error("favicon handling failed", e); + } + } + + int periodIndex = path.lastIndexOf("."); + if (periodIndex >= 0) { + String ext = path.substring(path.lastIndexOf(".")); + + InputStream responseStream = new LollipopLazyInputStream(handler, request); + + // TODO: Conjure up a bit more subtlety than this + if (ext.equals(".html")) { + responseStream = jsInjector.getInjectedStream(responseStream); + } + + String mimeType = getMimeType(path, responseStream); + int statusCode = getStatusCode(responseStream, handler.getStatusCode()); + return new WebResourceResponse( + mimeType, + handler.getEncoding(), + statusCode, + handler.getReasonPhrase(), + handler.getResponseHeaders(), + responseStream); + } + + return null; + } + + /** + * Instead of reading files from the filesystem/assets, proxy through to the URL + * and let an external server handle it. + * + * @param request + * @param handler + * @return + */ + private WebResourceResponse handleProxyRequest(WebResourceRequest request, PathHandler handler) { + final String method = request.getMethod(); + if (method.equals("GET")) { + try { + String url = request.getUrl().toString(); + Map headers = request.getRequestHeaders(); + boolean isHtmlText = false; + for (Map.Entry header : headers.entrySet()) { + if (header.getKey().equalsIgnoreCase("Accept") + && header.getValue().toLowerCase().contains("text/html")) { + isHtmlText = true; + break; + } + } + if (isHtmlText) { + HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection(); + for (Map.Entry header : headers.entrySet()) { + conn.setRequestProperty(header.getKey(), header.getValue()); + } + String getCookie = CookieManager.getInstance().getCookie(url); + if (getCookie != null) { + conn.setRequestProperty("Cookie", getCookie); + } + conn.setRequestMethod(method); + conn.setReadTimeout(30 * 1000); + conn.setConnectTimeout(30 * 1000); + if (request.getUrl().getUserInfo() != null) { + byte[] userInfoBytes = request.getUrl().getUserInfo().getBytes(StandardCharsets.UTF_8); + String base64 = Base64.encodeToString(userInfoBytes, Base64.NO_WRAP); + conn.setRequestProperty("Authorization", "Basic " + base64); + } + + List cookies = conn.getHeaderFields().get("Set-Cookie"); + if (cookies != null) { + for (String cookie : cookies) { + CookieManager.getInstance().setCookie(url, cookie); + } + } + InputStream responseStream = conn.getInputStream(); + responseStream = jsInjector.getInjectedStream(responseStream); + return new WebResourceResponse( + "text/html", + handler.getEncoding(), + handler.getStatusCode(), + handler.getReasonPhrase(), + handler.getResponseHeaders(), + responseStream); + } + } catch (Exception ex) { + bridge.handleAppUrlLoadError(ex); + } + } + return null; + } + + private String getMimeType(String path, InputStream stream) { + String mimeType = null; + try { + mimeType = URLConnection.guessContentTypeFromName(path); // Does not recognize *.js + if (mimeType != null && path.endsWith(".js") && mimeType.equals("image/x-icon")) { + Logger.debug("We shouldn't be here"); + } + if (mimeType == null) { + if (path.endsWith(".js") || path.endsWith(".mjs")) { + // Make sure JS files get the proper mimetype to support ES modules + mimeType = "application/javascript"; + } else if (path.endsWith(".wasm")) { + mimeType = "application/wasm"; + } else { + mimeType = URLConnection.guessContentTypeFromStream(stream); + } + } + } catch (Exception ex) { + Logger.error("Unable to get mime type" + path, ex); + } + return mimeType; + } + + private int getStatusCode(InputStream stream, int defaultCode) { + int finalStatusCode = defaultCode; + try { + if (stream.available() == -1) { + finalStatusCode = 404; + } + } catch (IOException e) { + finalStatusCode = 500; + } + return finalStatusCode; + } + + /** + * Registers a handler for the given uri. The handler + * will be invoked + * every time the shouldInterceptRequest method of the instance is + * called with + * a matching uri. + * + * @param uri the uri to use the handler for. The scheme and authority + * (domain) will be matched + * exactly. The path may contain a '*' element which will match a + * single element of + * a path (so a handler registered for /a/* will be invoked for + * /a/b and /a/c.html + * but not for /a/b/b) or the '**' element which will match any + * number of path + * elements. + * @param handler the handler to use for the uri. + */ + void register(Uri uri, PathHandler handler) { + synchronized (uriMatcher) { + uriMatcher.addURI(uri.getScheme(), uri.getAuthority(), uri.getPath(), handler); + } + } + + /** + * Hosts the application's assets on an https:// URL. Assets from the local path + * assetPath/... will be available under + * https://{uuid}.androidplatform.net/assets/.... + * + * @param assetPath the local path in the application's asset folder which will + * be made + * available by the server (for example "/www"). + * @return prefixes under which the assets are hosted. + */ + public void hostAssets(String assetPath) { + this.isAsset = true; + this.basePath = assetPath; + createHostingDetails(); + } + + /** + * Hosts the application's files on an https:// URL. Files from the basePath + * basePath/... will be available under + * https://{uuid}.androidplatform.net/.... + * + * @param basePath the local path in the application's data folder which will be + * made + * available by the server (for example "/www"). + * @return prefixes under which the assets are hosted. + */ + public void hostFiles(final String basePath) { + this.isAsset = false; + this.basePath = basePath; + createHostingDetails(); + } + + private void createHostingDetails() { + final String assetPath = this.basePath; + + if (assetPath.indexOf('*') != -1) { + throw new IllegalArgumentException("assetPath cannot contain the '*' character."); + } + + PathHandler handler = new PathHandler() { + @Override + public InputStream handle(Uri url) { + InputStream stream = null; + String path = url.getPath(); + + // Pass path to routeProcessor if present + RouteProcessor routeProcessor = bridge.getRouteProcessor(); + boolean ignoreAssetPath = false; + if (routeProcessor != null) { + ProcessedRoute processedRoute = bridge.getRouteProcessor().process("", path); + path = processedRoute.getPath(); + isAsset = processedRoute.isAsset(); + ignoreAssetPath = processedRoute.isIgnoreAssetPath(); + } + + try { + if (path.startsWith(capacitorContentStart)) { + stream = protocolHandler.openContentUrl(url); + } else if (path.startsWith(capacitorFileStart)) { + stream = protocolHandler.openFile(path); + } else if (!isAsset) { + if (routeProcessor == null) { + path = basePath + url.getPath(); + } + + stream = protocolHandler.openFile(path); + } else if (ignoreAssetPath) { + stream = protocolHandler.openAsset(path); + } else { + stream = protocolHandler.openAsset(assetPath + path); + } + } catch (IOException e) { + Logger.error("Unable to open asset URL: " + url); + return null; + } + + return stream; + } + }; + + for (String authority : authorities) { + registerUriForScheme(Bridge.CAPACITOR_HTTP_SCHEME, handler, authority); + registerUriForScheme(Bridge.CAPACITOR_HTTPS_SCHEME, handler, authority); + + String customScheme = this.bridge.getScheme(); + if (!customScheme.equals(Bridge.CAPACITOR_HTTP_SCHEME) + && !customScheme.equals(Bridge.CAPACITOR_HTTPS_SCHEME)) { + registerUriForScheme(customScheme, handler, authority); + } + } + } + + private void registerUriForScheme(String scheme, PathHandler handler, String authority) { + Uri.Builder uriBuilder = new Uri.Builder(); + uriBuilder.scheme(scheme); + uriBuilder.authority(authority); + uriBuilder.path(""); + Uri uriPrefix = uriBuilder.build(); + + register(Uri.withAppendedPath(uriPrefix, "/"), handler); + register(Uri.withAppendedPath(uriPrefix, "**"), handler); + } + + /** + * The KitKat WebView reads the InputStream on a separate threadpool. We can use + * that to + * parallelize loading. + */ + private abstract static class LazyInputStream extends InputStream { + + protected final PathHandler handler; + private InputStream is = null; + + public LazyInputStream(PathHandler handler) { + this.handler = handler; + } + + private InputStream getInputStream() { + if (is == null) { + is = handle(); + } + return is; + } + + protected abstract InputStream handle(); + + @Override + public int available() throws IOException { + InputStream is = getInputStream(); + return (is != null) ? is.available() : -1; + } + + @Override + public int read() throws IOException { + InputStream is = getInputStream(); + return (is != null) ? is.read() : -1; + } + + @Override + public int read(byte[] b) throws IOException { + InputStream is = getInputStream(); + return (is != null) ? is.read(b) : -1; + } + + @Override + public int read(byte[] b, int off, int len) throws IOException { + InputStream is = getInputStream(); + return (is != null) ? is.read(b, off, len) : -1; + } + + @Override + public long skip(long n) throws IOException { + InputStream is = getInputStream(); + return (is != null) ? is.skip(n) : 0; + } + } + + // For L and above. + private static class LollipopLazyInputStream extends LazyInputStream { + + private WebResourceRequest request; + private InputStream is; + + public LollipopLazyInputStream(PathHandler handler, WebResourceRequest request) { + super(handler); + this.request = request; + } + + @Override + protected InputStream handle() { + return handler.handle(request); + } + } + + public String getBasePath() { + return this.basePath; + } +} diff --git a/@capacitor/capacitor/src/main/java/com/getcapacitor/annotation/ActivityCallback.java b/@capacitor/capacitor/src/main/java/com/getcapacitor/annotation/ActivityCallback.java new file mode 100644 index 00000000..a158145d --- /dev/null +++ b/@capacitor/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/capacitor/src/main/java/com/getcapacitor/annotation/CapacitorPlugin.java b/@capacitor/capacitor/src/main/java/com/getcapacitor/annotation/CapacitorPlugin.java new file mode 100644 index 00000000..903378db --- /dev/null +++ b/@capacitor/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/capacitor/src/main/java/com/getcapacitor/annotation/Permission.java b/@capacitor/capacitor/src/main/java/com/getcapacitor/annotation/Permission.java new file mode 100644 index 00000000..35114370 --- /dev/null +++ b/@capacitor/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/capacitor/src/main/java/com/getcapacitor/annotation/PermissionCallback.java b/@capacitor/capacitor/src/main/java/com/getcapacitor/annotation/PermissionCallback.java new file mode 100644 index 00000000..d4ca0992 --- /dev/null +++ b/@capacitor/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/capacitor/src/main/java/com/getcapacitor/cordova/CapacitorCordovaCookieManager.java b/@capacitor/capacitor/src/main/java/com/getcapacitor/cordova/CapacitorCordovaCookieManager.java new file mode 100644 index 00000000..72ac4ee7 --- /dev/null +++ b/@capacitor/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/capacitor/src/main/java/com/getcapacitor/cordova/MockCordovaInterfaceImpl.java b/@capacitor/capacitor/src/main/java/com/getcapacitor/cordova/MockCordovaInterfaceImpl.java new file mode 100644 index 00000000..7e8358da --- /dev/null +++ b/@capacitor/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/capacitor/src/main/java/com/getcapacitor/cordova/MockCordovaWebViewImpl.java b/@capacitor/capacitor/src/main/java/com/getcapacitor/cordova/MockCordovaWebViewImpl.java new file mode 100644 index 00000000..d5b77cdd --- /dev/null +++ b/@capacitor/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/capacitor/src/main/java/com/getcapacitor/plugin/CapacitorCookieManager.java b/@capacitor/capacitor/src/main/java/com/getcapacitor/plugin/CapacitorCookieManager.java new file mode 100644 index 00000000..1baf1669 --- /dev/null +++ b/@capacitor/capacitor/src/main/java/com/getcapacitor/plugin/CapacitorCookieManager.java @@ -0,0 +1,236 @@ +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 (url == null || url.isEmpty()) { + url = this.serverUrl; + } + + try { + new URI(url); + } catch (Exception ignored) { + url = this.localUrl; + + 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/capacitor/src/main/java/com/getcapacitor/plugin/CapacitorCookies.java b/@capacitor/capacitor/src/main/java/com/getcapacitor/plugin/CapacitorCookies.java new file mode 100644 index 00000000..45c01be2 --- /dev/null +++ b/@capacitor/capacitor/src/main/java/com/getcapacitor/plugin/CapacitorCookies.java @@ -0,0 +1,137 @@ +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); + } + + private boolean isAllowingInsecureCookies() { + PluginConfig pluginConfig = getBridge().getConfig().getPluginConfiguration("CapacitorCookies"); + return pluginConfig.getBoolean("androidCustomSchemeAllowInsecureAccess", false); + } + + @JavascriptInterface + public void setCookie(String domain, String action) { + cookieManager.setCookie(domain, action); + } + + @PluginMethod + public void getCookies(PluginCall call) { + if (isAllowingInsecureCookies()) { + String url = call.getString("url"); + JSObject cookiesMap = new JSObject(); + HttpCookie[] cookies = cookieManager.getCookies(url); + for (HttpCookie cookie : cookies) { + cookiesMap.put(cookie.getName(), cookie.getValue()); + } + call.resolve(cookiesMap); + } else { + 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/capacitor/src/main/java/com/getcapacitor/plugin/CapacitorHttp.java b/@capacitor/capacitor/src/main/java/com/getcapacitor/plugin/CapacitorHttp.java new file mode 100644 index 00000000..46bc1741 --- /dev/null +++ b/@capacitor/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/capacitor/src/main/java/com/getcapacitor/plugin/WebView.java b/@capacitor/capacitor/src/main/java/com/getcapacitor/plugin/WebView.java new file mode 100644 index 00000000..096d62a5 --- /dev/null +++ b/@capacitor/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/capacitor/src/main/java/com/getcapacitor/plugin/util/AssetUtil.java b/@capacitor/capacitor/src/main/java/com/getcapacitor/plugin/util/AssetUtil.java new file mode 100644 index 00000000..3a7043bb --- /dev/null +++ b/@capacitor/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/capacitor/src/main/java/com/getcapacitor/plugin/util/CapacitorHttpUrlConnection.java b/@capacitor/capacitor/src/main/java/com/getcapacitor/plugin/util/CapacitorHttpUrlConnection.java new file mode 100644 index 00000000..cc784de7 --- /dev/null +++ b/@capacitor/capacitor/src/main/java/com/getcapacitor/plugin/util/CapacitorHttpUrlConnection.java @@ -0,0 +1,475 @@ +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.newInstance(), bridge); + if (sslSocketFactory != null) { + ((HttpsURLConnection) this.connection).setSSLSocketFactory(sslSocketFactory); + } + } catch (Exception ignored) {} + } +} diff --git a/@capacitor/capacitor/src/main/java/com/getcapacitor/plugin/util/HttpRequestHandler.java b/@capacitor/capacitor/src/main/java/com/getcapacitor/plugin/util/HttpRequestHandler.java new file mode 100644 index 00000000..6e4bb747 --- /dev/null +++ b/@capacitor/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.newInstance(), bridge, url); + } catch (Exception ignored) { + return false; + } + } + + @FunctionalInterface + public interface ProgressEmitter { + void emit(Integer bytes, Integer contentLength); + } +} diff --git a/@capacitor/capacitor/src/main/java/com/getcapacitor/plugin/util/ICapacitorHttpUrlConnection.java b/@capacitor/capacitor/src/main/java/com/getcapacitor/plugin/util/ICapacitorHttpUrlConnection.java new file mode 100644 index 00000000..4ed8881a --- /dev/null +++ b/@capacitor/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/capacitor/src/main/java/com/getcapacitor/plugin/util/MimeType.java b/@capacitor/capacitor/src/main/java/com/getcapacitor/plugin/util/MimeType.java new file mode 100644 index 00000000..cfc90f82 --- /dev/null +++ b/@capacitor/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/capacitor/src/main/java/com/getcapacitor/util/HostMask.java b/@capacitor/capacitor/src/main/java/com/getcapacitor/util/HostMask.java new file mode 100644 index 00000000..486d0fd0 --- /dev/null +++ b/@capacitor/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/capacitor/src/main/java/com/getcapacitor/util/InternalUtils.java b/@capacitor/capacitor/src/main/java/com/getcapacitor/util/InternalUtils.java new file mode 100644 index 00000000..b7354159 --- /dev/null +++ b/@capacitor/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/capacitor/src/main/java/com/getcapacitor/util/JSONUtils.java b/@capacitor/capacitor/src/main/java/com/getcapacitor/util/JSONUtils.java new file mode 100644 index 00000000..1d2fc207 --- /dev/null +++ b/@capacitor/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/capacitor/src/main/java/com/getcapacitor/util/PermissionHelper.java b/@capacitor/capacitor/src/main/java/com/getcapacitor/util/PermissionHelper.java new file mode 100644 index 00000000..e7b83321 --- /dev/null +++ b/@capacitor/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/capacitor/src/main/java/com/getcapacitor/util/WebColor.java b/@capacitor/capacitor/src/main/java/com/getcapacitor/util/WebColor.java new file mode 100644 index 00000000..e055021e --- /dev/null +++ b/@capacitor/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/capacitor/src/main/res/layout/bridge_layout_main.xml b/@capacitor/capacitor/src/main/res/layout/bridge_layout_main.xml new file mode 100644 index 00000000..12f0b8fc --- /dev/null +++ b/@capacitor/capacitor/src/main/res/layout/bridge_layout_main.xml @@ -0,0 +1,15 @@ + + + + + + diff --git a/@capacitor/capacitor/src/main/res/layout/fragment_bridge.xml b/@capacitor/capacitor/src/main/res/layout/fragment_bridge.xml new file mode 100644 index 00000000..b6123ea8 --- /dev/null +++ b/@capacitor/capacitor/src/main/res/layout/fragment_bridge.xml @@ -0,0 +1,13 @@ + + + + + diff --git a/@capacitor/capacitor/src/main/res/values/attrs.xml b/@capacitor/capacitor/src/main/res/values/attrs.xml new file mode 100644 index 00000000..23a10371 --- /dev/null +++ b/@capacitor/capacitor/src/main/res/values/attrs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/@capacitor/capacitor/src/main/res/values/colors.xml b/@capacitor/capacitor/src/main/res/values/colors.xml new file mode 100644 index 00000000..347d6088 --- /dev/null +++ b/@capacitor/capacitor/src/main/res/values/colors.xml @@ -0,0 +1,6 @@ + + + #3F51B5 + #303F9F + #FF4081 + diff --git a/@capacitor/capacitor/src/main/res/values/strings.xml b/@capacitor/capacitor/src/main/res/values/strings.xml new file mode 100644 index 00000000..85420055 --- /dev/null +++ b/@capacitor/capacitor/src/main/res/values/strings.xml @@ -0,0 +1,2 @@ + + diff --git a/@capacitor/capacitor/src/main/res/values/styles.xml b/@capacitor/capacitor/src/main/res/values/styles.xml new file mode 100644 index 00000000..d3268920 --- /dev/null +++ b/@capacitor/capacitor/src/main/res/values/styles.xml @@ -0,0 +1,6 @@ + + + diff --git a/bun.lockb b/bun.lockb index 7fdb3b5ceb6708f8752fb893cbc1cd6d7fd7657b..4edef8e6b8eb29e5360af6a6692f711d6b34a521 100755 GIT binary patch delta 51431 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 delta 51332 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 diff --git a/package.json b/package.json index 320f3dc2..a82c93f2 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,7 @@ }, "dependencies": { "@capacitor-community/firebase-analytics": "^5.0.0", - "@capacitor/android": "^5.0.0", + "@capacitor/android": "file:./@capacitor/android", "@capacitor/app": "^5.0.0", "@capacitor/browser": "^5.0.0", "@capacitor/cli": "^5.0.0", @@ -63,7 +63,7 @@ "eruda2": "0.0.2-b8", "fb-comments-web": "^0.0.13", "group-array": "^1.0.0", - "hls.js": "^1.5.13", + "hls.js": "1.5.9", "htmlparser2": "^8.0.1", "idb-keyval": "^6.2.1", "iso-639-1": "^2.1.15", diff --git a/quasar.config.ts b/quasar.config.ts index ba67a210..e7dd64e2 100644 --- a/quasar.config.ts +++ b/quasar.config.ts @@ -39,7 +39,7 @@ export default configure(function (/* ctx */) { // app boot file (/src/boot) // --> boot files are part of "main.js" // https://v2.quasar.dev/quasar-cli-vite/boot-files - boot: ["windi", "firebase", "supabase", "head", "i18n"], + boot: ["patch-request", "windi", "firebase", "supabase", "head", "i18n"], // https://v2.quasar.dev/quasar-cli-vite/quasar-config-js#css css: ["app.scss"], @@ -162,7 +162,7 @@ export default configure(function (/* ctx */) { // Full list of options: https://v2.quasar.dev/quasar-cli-vite/quasar-config-js#devServer devServer: { - // ["https" as unknown as any]: true, + ["https" as unknown as any]: true, open: false, // opens browser window automatically }, diff --git a/src-capacitor/android/app/capacitor.build.gradle b/src-capacitor/android/app/capacitor.build.gradle index d74154c7..24a5c06d 100644 --- a/src-capacitor/android/app/capacitor.build.gradle +++ b/src-capacitor/android/app/capacitor.build.gradle @@ -19,6 +19,7 @@ dependencies { implementation project(':capacitor-share') implementation project(':capacitor-status-bar') implementation project(':hugotomazi-capacitor-navigation-bar') + implementation project(':jcesarmobile-ssl-skip') } diff --git a/src-capacitor/android/app/src/main/assets/capacitor.config.json b/src-capacitor/android/app/src/main/assets/capacitor.config.json index 0be22654..5e201dc3 100644 --- a/src-capacitor/android/app/src/main/assets/capacitor.config.json +++ b/src-capacitor/android/app/src/main/assets/capacitor.config.json @@ -9,9 +9,9 @@ "enabled": true } }, - "bundledWebRuntime": false, "webDir": "www", "server": { - "url": "http://10.0.5.2:9500" + "androidScheme": "https", + "url": "https://192.168.0.102:9500" } } diff --git a/src-capacitor/android/app/src/main/assets/capacitor.plugins.json b/src-capacitor/android/app/src/main/assets/capacitor.plugins.json index 22353e4b..455d5552 100644 --- a/src-capacitor/android/app/src/main/assets/capacitor.plugins.json +++ b/src-capacitor/android/app/src/main/assets/capacitor.plugins.json @@ -38,5 +38,9 @@ { "pkg": "@hugotomazi/capacitor-navigation-bar", "classpath": "br.com.tombus.capacitor.plugin.navigationbar.NavigationBarPlugin" + }, + { + "pkg": "@jcesarmobile/ssl-skip", + "classpath": "com.jcesarmobile.sslskip.SslSkipPlugin" } ] diff --git a/src-capacitor/android/app/src/main/java/git/shin/animevsub/MainActivity.java b/src-capacitor/android/app/src/main/java/git/shin/animevsub/MainActivity.java index 26b7d379..bbbd83f9 100644 --- a/src-capacitor/android/app/src/main/java/git/shin/animevsub/MainActivity.java +++ b/src-capacitor/android/app/src/main/java/git/shin/animevsub/MainActivity.java @@ -9,9 +9,16 @@ public class MainActivity extends BridgeActivity { // no code +// @Override +// public void onCreate() { +// registerPlugin(ResolvePlugin.class); +// super.onStart(); +// bridge.getWebView().setVerticalScrollBarEnabled(false); +// } @Override - public void onStart() { - super.onStart(); + public void onCreate(Bundle savedInstanceState) { + registerPlugin(ResolvePlugin.class); + super.onCreate(savedInstanceState); bridge.getWebView().setVerticalScrollBarEnabled(false); } } diff --git a/src-capacitor/android/app/src/main/java/git/shin/animevsub/ResolvePlugin.java b/src-capacitor/android/app/src/main/java/git/shin/animevsub/ResolvePlugin.java new file mode 100644 index 00000000..0695b441 --- /dev/null +++ b/src-capacitor/android/app/src/main/java/git/shin/animevsub/ResolvePlugin.java @@ -0,0 +1,112 @@ +package git.shin.animevsub; + +import com.getcapacitor.JSArray; +import com.getcapacitor.JSObject; +import com.getcapacitor.Plugin; +import com.getcapacitor.PluginCall; +import com.getcapacitor.PluginMethod; +import com.getcapacitor.annotation.CapacitorPlugin; + +import org.json.JSONException; +import org.json.JSONObject; + +import java.io.IOException; +import java.net.HttpURLConnection; +import java.net.URL; +import java.util.Iterator; +import java.util.List; +import java.util.ArrayList; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.stream.Collectors; + +@CapacitorPlugin(name = "Resolve") +public class ResolvePlugin extends Plugin { + + @PluginMethod() + public void echo(PluginCall call) { + String value = call.getString("value"); + + JSObject ret = new JSObject(); + ret.put("value", value); + call.resolve(ret); + } + + @PluginMethod() + public void resolve(PluginCall call) { + String url = call.getString("url"); + JSObject headers = call.getObject("headers"); + + if (url == null) { + call.reject("Must provide a URL"); + return; + } + + JSObject ret = resolveUrl(url, headers); + if (ret.has("error")) { + call.reject(ret.getString("error")); + } else { + call.resolve(ret); + } + } + @PluginMethod() + public void resolveAll(PluginCall call) { + JSArray urlArray = call.getArray("urls"); +// JSArray headersArray = call.getArray("headers") ?? []; + + if (urlArray == null) { + call.reject("Must provide an array of URLs"); + return; + } + + try { + List> futures = new ArrayList<>(); + List urls = urlArray.toList(); + + List results = new ArrayList<>(); + + for (JSONObject jsonObject : urls) { + JSObject urlObject = JSObject.fromJSONObject(jsonObject); + + String url = urlObject.getString("url"); + JSObject headers = urlObject.getJSObject("headers"); + + results.add(resolveUrl(url, headers)); + } + + call.resolve(new JSObject().put("results", new JSArray(results))); + } catch (JSONException e) { + call.reject("Error processing URLs array: " + e.getMessage()); + } + } + + private JSObject resolveUrl(String url, JSObject headers) { + JSObject result = new JSObject(); + + try { + URL obj = new URL(url); + HttpURLConnection connection = (HttpURLConnection) obj.openConnection(); + + // Set headers + if (headers != null) { + Iterator keys = headers.keys(); + while (keys.hasNext()) { + String key = keys.next(); + connection.setRequestProperty(key, headers.getString(key)); + } + } + + connection.setInstanceFollowRedirects(false); + connection.connect(); + + String locationHeader = connection.getHeaderField("Location"); + String resolvedUrl = (locationHeader != null) ? locationHeader : url; + + result.put("url", resolvedUrl); + } catch (IOException e) { + result.put("error", e.getMessage()); + } + + return result; + } +} diff --git a/src-capacitor/android/capacitor.settings.gradle b/src-capacitor/android/capacitor.settings.gradle index c827bbea..0203f144 100644 --- a/src-capacitor/android/capacitor.settings.gradle +++ b/src-capacitor/android/capacitor.settings.gradle @@ -3,31 +3,34 @@ include ':capacitor-android' project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor') include ':capacitor-community-firebase-analytics' -project(':capacitor-community-firebase-analytics').projectDir = new File('../node_modules/@capacitor-community/firebase-analytics/android') +project(':capacitor-community-firebase-analytics').projectDir = new File('../node_modules/.pnpm/@capacitor-community+firebase-analytics@5.0.1_@capacitor+core@5.7.6/node_modules/@capacitor-community/firebase-analytics/android') include ':capacitor-app' -project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/android') +project(':capacitor-app').projectDir = new File('../node_modules/.pnpm/@capacitor+app@5.0.8_@capacitor+core@5.7.6/node_modules/@capacitor/app/android') include ':capacitor-browser' -project(':capacitor-browser').projectDir = new File('../node_modules/@capacitor/browser/android') +project(':capacitor-browser').projectDir = new File('../node_modules/.pnpm/@capacitor+browser@5.2.1_@capacitor+core@5.7.6/node_modules/@capacitor/browser/android') include ':capacitor-device' -project(':capacitor-device').projectDir = new File('../node_modules/@capacitor/device/android') +project(':capacitor-device').projectDir = new File('../node_modules/.pnpm/@capacitor+device@5.0.8_@capacitor+core@5.7.6/node_modules/@capacitor/device/android') include ':capacitor-filesystem' -project(':capacitor-filesystem').projectDir = new File('../node_modules/@capacitor/filesystem/android') +project(':capacitor-filesystem').projectDir = new File('../node_modules/.pnpm/@capacitor+filesystem@5.2.2_@capacitor+core@5.7.6/node_modules/@capacitor/filesystem/android') include ':capacitor-haptics' -project(':capacitor-haptics').projectDir = new File('../node_modules/@capacitor/haptics/android') +project(':capacitor-haptics').projectDir = new File('../node_modules/.pnpm/@capacitor+haptics@5.0.8_@capacitor+core@5.7.6/node_modules/@capacitor/haptics/android') include ':capacitor-preferences' -project(':capacitor-preferences').projectDir = new File('../node_modules/@capacitor/preferences/android') +project(':capacitor-preferences').projectDir = new File('../node_modules/.pnpm/@capacitor+preferences@5.0.8_@capacitor+core@5.7.6/node_modules/@capacitor/preferences/android') include ':capacitor-share' -project(':capacitor-share').projectDir = new File('../node_modules/@capacitor/share/android') +project(':capacitor-share').projectDir = new File('../node_modules/.pnpm/@capacitor+share@5.0.8_@capacitor+core@5.7.6/node_modules/@capacitor/share/android') include ':capacitor-status-bar' -project(':capacitor-status-bar').projectDir = new File('../node_modules/@capacitor/status-bar/android') +project(':capacitor-status-bar').projectDir = new File('../node_modules/.pnpm/@capacitor+status-bar@5.0.8_@capacitor+core@5.7.6/node_modules/@capacitor/status-bar/android') include ':hugotomazi-capacitor-navigation-bar' -project(':hugotomazi-capacitor-navigation-bar').projectDir = new File('../node_modules/@hugotomazi/capacitor-navigation-bar/android') +project(':hugotomazi-capacitor-navigation-bar').projectDir = new File('../node_modules/.pnpm/@hugotomazi+capacitor-navigation-bar@3.0.0_@capacitor+core@5.7.6/node_modules/@hugotomazi/capacitor-navigation-bar/android') + +include ':jcesarmobile-ssl-skip' +project(':jcesarmobile-ssl-skip').projectDir = new File('../node_modules/.pnpm/@jcesarmobile+ssl-skip@0.4.0/node_modules/@jcesarmobile/ssl-skip/android') diff --git a/src-capacitor/bun.lockb b/src-capacitor/bun.lockb index 363d1606f0f0c062c36975d9017b8699d897f68c..22ad599e6ee9e3d22ee7c11589fe1c9b38322d50 100755 GIT binary patch delta 7172 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 La9_a2H3|O-4{2Kf delta 6825 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 true def capacitor_pods - pod 'Capacitor', :path => '../../node_modules/.pnpm/@capacitor+ios@3.9.0_@capacitor+core@3.9.0/node_modules/@capacitor/ios' - pod 'CapacitorCordova', :path => '../../node_modules/.pnpm/@capacitor+ios@3.9.0_@capacitor+core@3.9.0/node_modules/@capacitor/ios' - pod 'CapacitorCommunityBarcodeScanner', :path => '../../node_modules/.pnpm/@capacitor-community+barcode-scanner@3.0.3_@capacitor+core@5.7.6/node_modules/@capacitor-community/barcode-scanner' + pod 'Capacitor', :path => '../../node_modules/@capacitor/ios' + pod 'CapacitorCordova', :path => '../../node_modules/@capacitor/ios' pod 'CapacitorCommunityFirebaseAnalytics', :path => '../../node_modules/.pnpm/@capacitor-community+firebase-analytics@5.0.1_@capacitor+core@5.7.6/node_modules/@capacitor-community/firebase-analytics' - pod 'CapacitorCommunityHttp', :path => '../../node_modules/.pnpm/@capacitor-community+http@1.4.1/node_modules/@capacitor-community/http' pod 'CapacitorApp', :path => '../../node_modules/.pnpm/@capacitor+app@5.0.8_@capacitor+core@5.7.6/node_modules/@capacitor/app' pod 'CapacitorBrowser', :path => '../../node_modules/.pnpm/@capacitor+browser@5.2.1_@capacitor+core@5.7.6/node_modules/@capacitor/browser' pod 'CapacitorDevice', :path => '../../node_modules/.pnpm/@capacitor+device@5.0.8_@capacitor+core@5.7.6/node_modules/@capacitor/device' @@ -22,8 +20,8 @@ def capacitor_pods pod 'CapacitorPreferences', :path => '../../node_modules/.pnpm/@capacitor+preferences@5.0.8_@capacitor+core@5.7.6/node_modules/@capacitor/preferences' pod 'CapacitorShare', :path => '../../node_modules/.pnpm/@capacitor+share@5.0.8_@capacitor+core@5.7.6/node_modules/@capacitor/share' pod 'CapacitorStatusBar', :path => '../../node_modules/.pnpm/@capacitor+status-bar@5.0.8_@capacitor+core@5.7.6/node_modules/@capacitor/status-bar' - pod 'HugotomaziCapacitorNavigationBar', :path => '../../node_modules/.pnpm/@hugotomazi+capacitor-navigation-bar@2.0.1_@capacitor+core@5.7.6/node_modules/@hugotomazi/capacitor-navigation-bar' - pod 'JcesarmobileSslSkip', :path => '../../node_modules/.pnpm/@jcesarmobile+ssl-skip@0.2.0/node_modules/@jcesarmobile/ssl-skip' + pod 'HugotomaziCapacitorNavigationBar', :path => '../../node_modules/.pnpm/@hugotomazi+capacitor-navigation-bar@3.0.0_@capacitor+core@5.7.6/node_modules/@hugotomazi/capacitor-navigation-bar' + pod 'JcesarmobileSslSkip', :path => '../../node_modules/.pnpm/@jcesarmobile+ssl-skip@0.4.0/node_modules/@jcesarmobile/ssl-skip' pod 'CordovaPlugins', :path => '../capacitor-cordova-ios-plugins' end diff --git a/src-capacitor/ios/capacitor-cordova-ios-plugins/CordovaPlugins.podspec b/src-capacitor/ios/capacitor-cordova-ios-plugins/CordovaPlugins.podspec index e05f0c2f..b0952105 100644 --- a/src-capacitor/ios/capacitor-cordova-ios-plugins/CordovaPlugins.podspec +++ b/src-capacitor/ios/capacitor-cordova-ios-plugins/CordovaPlugins.podspec @@ -1,12 +1,12 @@ Pod::Spec.new do |s| s.name = 'CordovaPlugins' - s.version = '5.7.6' + s.version = '5.7.7' s.summary = 'Autogenerated spec' s.license = 'Unknown' s.homepage = 'https://example.com' s.authors = { 'Capacitor Generator' => 'hi@example.com' } - s.source = { :git => 'https://github.com/ionic-team/does-not-exist.git', :tag => '5.7.6' } + s.source = { :git => 'https://github.com/ionic-team/does-not-exist.git', :tag => '5.7.7' } s.source_files = 'sources/**/*.{swift,h,m,c,cc,mm,cpp}' s.ios.deployment_target = '13.0' s.xcconfig = {'GCC_PREPROCESSOR_DEFINITIONS' => '$(inherited) COCOAPODS=1 WK_WEB_VIEW_ONLY=1' } diff --git a/src-capacitor/ios/capacitor-cordova-ios-plugins/CordovaPluginsStatic.podspec b/src-capacitor/ios/capacitor-cordova-ios-plugins/CordovaPluginsStatic.podspec index 2017bf3b..5f5e85d2 100644 --- a/src-capacitor/ios/capacitor-cordova-ios-plugins/CordovaPluginsStatic.podspec +++ b/src-capacitor/ios/capacitor-cordova-ios-plugins/CordovaPluginsStatic.podspec @@ -1,12 +1,12 @@ Pod::Spec.new do |s| s.name = 'CordovaPluginsStatic' - s.version = '5.7.6' + s.version = '5.7.7' s.summary = 'Autogenerated spec' s.license = 'Unknown' s.homepage = 'https://example.com' s.authors = { 'Capacitor Generator' => 'hi@example.com' } - s.source = { :git => 'https://github.com/ionic-team/does-not-exist.git', :tag => '5.7.6' } + s.source = { :git => 'https://github.com/ionic-team/does-not-exist.git', :tag => '5.7.7' } s.source_files = 'sourcesstatic/**/*.{swift,h,m,c,cc,mm,cpp}' s.ios.deployment_target = '13.0' s.xcconfig = {'GCC_PREPROCESSOR_DEFINITIONS' => '$(inherited) COCOAPODS=1 WK_WEB_VIEW_ONLY=1' } diff --git a/src-capacitor/package.json b/src-capacitor/package.json index f7b59bf2..bf390222 100644 --- a/src-capacitor/package.json +++ b/src-capacitor/package.json @@ -18,10 +18,12 @@ "@capacitor/device": "^5.0.8", "@capacitor/filesystem": "^5.2.2", "@capacitor/haptics": "^5.0.8", + "@capacitor/ios": "^6.1.1", "@capacitor/preferences": "^5.0.8", "@capacitor/share": "^5.0.8", "@capacitor/status-bar": "^5.0.8", "@hugotomazi/capacitor-navigation-bar": "^3.0.0", + "@jcesarmobile/ssl-skip": "^0.4.0", "cordova-plugin-screen-orientation": "^3.0.4" } } \ No newline at end of file diff --git a/src/App.vue b/src/App.vue index 9c288849..45016649 100644 --- a/src/App.vue +++ b/src/App.vue @@ -4,11 +4,4 @@ diff --git a/src/apis/runs/ajax/player-link.ts b/src/apis/runs/ajax/player-link.ts index 73ddd2c8..06e9a900 100644 --- a/src/apis/runs/ajax/player-link.ts +++ b/src/apis/runs/ajax/player-link.ts @@ -59,7 +59,7 @@ export function PlayerLink(config: { // eslint-disable-next-line @typescript-eslint/no-explicit-any, promise/no-nesting ;(self as unknown as any).hn ??= await App.getInfo().then( (info) => info.id - ).catch(() => 'git.shin.animevsub') + ) await init() try { diff --git a/src/boot/patch-request.ts b/src/boot/patch-request.ts new file mode 100644 index 00000000..42f15d34 --- /dev/null +++ b/src/boot/patch-request.ts @@ -0,0 +1,4 @@ +import "src/logic/patch-request" + +if (!("CapacitorWebFetch" in self)) + self.CapacitorWebFetch = fetch diff --git a/src/components/BrtPlayer.vue b/src/components/BrtPlayer.vue index c541ae7d..37a4743f 100644 --- a/src/components/BrtPlayer.vue +++ b/src/components/BrtPlayer.vue @@ -6,7 +6,7 @@