xiongzhu 4 年之前
父节点
当前提交
9aa7318581
共有 100 个文件被更改,包括 13356 次插入0 次删除
  1. 4 0
      android/app/src/main/java/com/izouma/jmrh/AndroidBug5497Workaround.java
  2. 4 0
      android/app/src/main/java/com/izouma/jmrh/KeyboardUtil.java
  3. 78 0
      android/capacitor/build.gradle
  4. 21 0
      android/capacitor/proguard-rules.pro
  5. 27 0
      android/capacitor/src/androidTest/java/com/getcapacitor/android/ExampleInstrumentedTest.java
  6. 28 0
      android/capacitor/src/main/AndroidManifest.xml
  7. 98 0
      android/capacitor/src/main/java/com/getcapacitor/AndroidProtocolHandler.java
  8. 936 0
      android/capacitor/src/main/java/com/getcapacitor/Bridge.java
  9. 243 0
      android/capacitor/src/main/java/com/getcapacitor/BridgeActivity.java
  10. 163 0
      android/capacitor/src/main/java/com/getcapacitor/BridgeFragment.java
  11. 372 0
      android/capacitor/src/main/java/com/getcapacitor/BridgeWebChromeClient.java
  12. 32 0
      android/capacitor/src/main/java/com/getcapacitor/BridgeWebViewClient.java
  13. 151 0
      android/capacitor/src/main/java/com/getcapacitor/CapConfig.java
  14. 21 0
      android/capacitor/src/main/java/com/getcapacitor/CapacitorFirebaseMessagingService.java
  15. 39 0
      android/capacitor/src/main/java/com/getcapacitor/CapacitorWebView.java
  16. 182 0
      android/capacitor/src/main/java/com/getcapacitor/Config.java
  17. 224 0
      android/capacitor/src/main/java/com/getcapacitor/Dialogs.java
  18. 259 0
      android/capacitor/src/main/java/com/getcapacitor/FileUtils.java
  19. 7 0
      android/capacitor/src/main/java/com/getcapacitor/InvalidPluginException.java
  20. 9 0
      android/capacitor/src/main/java/com/getcapacitor/InvalidPluginMethodException.java
  21. 52 0
      android/capacitor/src/main/java/com/getcapacitor/JSArray.java
  22. 153 0
      android/capacitor/src/main/java/com/getcapacitor/JSExport.java
  23. 7 0
      android/capacitor/src/main/java/com/getcapacitor/JSExportException.java
  24. 88 0
      android/capacitor/src/main/java/com/getcapacitor/JSInjector.java
  25. 159 0
      android/capacitor/src/main/java/com/getcapacitor/JSObject.java
  26. 48 0
      android/capacitor/src/main/java/com/getcapacitor/LogUtils.java
  27. 102 0
      android/capacitor/src/main/java/com/getcapacitor/Logger.java
  28. 109 0
      android/capacitor/src/main/java/com/getcapacitor/MessageHandler.java
  29. 33 0
      android/capacitor/src/main/java/com/getcapacitor/NativePlugin.java
  30. 597 0
      android/capacitor/src/main/java/com/getcapacitor/Plugin.java
  31. 295 0
      android/capacitor/src/main/java/com/getcapacitor/PluginCall.java
  32. 122 0
      android/capacitor/src/main/java/com/getcapacitor/PluginHandle.java
  33. 7 0
      android/capacitor/src/main/java/com/getcapacitor/PluginInvocationException.java
  34. 10 0
      android/capacitor/src/main/java/com/getcapacitor/PluginLoadException.java
  35. 14 0
      android/capacitor/src/main/java/com/getcapacitor/PluginMethod.java
  36. 32 0
      android/capacitor/src/main/java/com/getcapacitor/PluginMethodHandle.java
  37. 27 0
      android/capacitor/src/main/java/com/getcapacitor/PluginRequestCodes.java
  38. 84 0
      android/capacitor/src/main/java/com/getcapacitor/PluginResult.java
  39. 426 0
      android/capacitor/src/main/java/com/getcapacitor/Splash.java
  40. 192 0
      android/capacitor/src/main/java/com/getcapacitor/UriMatcher.java
  41. 542 0
      android/capacitor/src/main/java/com/getcapacitor/WebViewLocalServer.java
  42. 38 0
      android/capacitor/src/main/java/com/getcapacitor/cordova/CapacitorCordovaCookieManager.java
  43. 18 0
      android/capacitor/src/main/java/com/getcapacitor/cordova/MockCordovaInterfaceImpl.java
  44. 305 0
      android/capacitor/src/main/java/com/getcapacitor/cordova/MockCordovaWebViewImpl.java
  45. 66 0
      android/capacitor/src/main/java/com/getcapacitor/plugin/Accessibility.java
  46. 148 0
      android/capacitor/src/main/java/com/getcapacitor/plugin/App.java
  47. 167 0
      android/capacitor/src/main/java/com/getcapacitor/plugin/Browser.java
  48. 566 0
      android/capacitor/src/main/java/com/getcapacitor/plugin/Camera.java
  49. 75 0
      android/capacitor/src/main/java/com/getcapacitor/plugin/Clipboard.java
  50. 154 0
      android/capacitor/src/main/java/com/getcapacitor/plugin/Device.java
  51. 694 0
      android/capacitor/src/main/java/com/getcapacitor/plugin/Filesystem.java
  52. 194 0
      android/capacitor/src/main/java/com/getcapacitor/plugin/Geolocation.java
  53. 77 0
      android/capacitor/src/main/java/com/getcapacitor/plugin/Haptics.java
  54. 156 0
      android/capacitor/src/main/java/com/getcapacitor/plugin/Keyboard.java
  55. 142 0
      android/capacitor/src/main/java/com/getcapacitor/plugin/LocalNotifications.java
  56. 142 0
      android/capacitor/src/main/java/com/getcapacitor/plugin/Modals.java
  57. 123 0
      android/capacitor/src/main/java/com/getcapacitor/plugin/Network.java
  58. 89 0
      android/capacitor/src/main/java/com/getcapacitor/plugin/Permissions.java
  59. 30 0
      android/capacitor/src/main/java/com/getcapacitor/plugin/Photos.java
  60. 252 0
      android/capacitor/src/main/java/com/getcapacitor/plugin/PushNotifications.java
  61. 87 0
      android/capacitor/src/main/java/com/getcapacitor/plugin/Share.java
  62. 37 0
      android/capacitor/src/main/java/com/getcapacitor/plugin/SplashScreen.java
  63. 159 0
      android/capacitor/src/main/java/com/getcapacitor/plugin/StatusBar.java
  64. 92 0
      android/capacitor/src/main/java/com/getcapacitor/plugin/Storage.java
  65. 42 0
      android/capacitor/src/main/java/com/getcapacitor/plugin/Toast.java
  66. 41 0
      android/capacitor/src/main/java/com/getcapacitor/plugin/WebView.java
  67. 72 0
      android/capacitor/src/main/java/com/getcapacitor/plugin/background/BackgroundTask.java
  68. 26 0
      android/capacitor/src/main/java/com/getcapacitor/plugin/background/BackgroundTaskService.java
  69. 17 0
      android/capacitor/src/main/java/com/getcapacitor/plugin/camera/CameraResultType.java
  70. 95 0
      android/capacitor/src/main/java/com/getcapacitor/plugin/camera/CameraSettings.java
  71. 17 0
      android/capacitor/src/main/java/com/getcapacitor/plugin/camera/CameraSource.java
  72. 40 0
      android/capacitor/src/main/java/com/getcapacitor/plugin/camera/CameraUtils.java
  73. 154 0
      android/capacitor/src/main/java/com/getcapacitor/plugin/camera/ExifWrapper.java
  74. 185 0
      android/capacitor/src/main/java/com/getcapacitor/plugin/camera/ImageUtils.java
  75. 221 0
      android/capacitor/src/main/java/com/getcapacitor/plugin/notification/DateMatch.java
  76. 378 0
      android/capacitor/src/main/java/com/getcapacitor/plugin/notification/LocalNotification.java
  77. 75 0
      android/capacitor/src/main/java/com/getcapacitor/plugin/notification/LocalNotificationAttachment.java
  78. 424 0
      android/capacitor/src/main/java/com/getcapacitor/plugin/notification/LocalNotificationManager.java
  79. 60 0
      android/capacitor/src/main/java/com/getcapacitor/plugin/notification/LocalNotificationRestoreReceiver.java
  80. 164 0
      android/capacitor/src/main/java/com/getcapacitor/plugin/notification/LocalNotificationSchedule.java
  81. 88 0
      android/capacitor/src/main/java/com/getcapacitor/plugin/notification/NotificationAction.java
  82. 129 0
      android/capacitor/src/main/java/com/getcapacitor/plugin/notification/NotificationChannelManager.java
  83. 29 0
      android/capacitor/src/main/java/com/getcapacitor/plugin/notification/NotificationDismissReceiver.java
  84. 151 0
      android/capacitor/src/main/java/com/getcapacitor/plugin/notification/NotificationStorage.java
  85. 52 0
      android/capacitor/src/main/java/com/getcapacitor/plugin/notification/TimedNotificationPublisher.java
  86. 367 0
      android/capacitor/src/main/java/com/getcapacitor/plugin/util/AssetUtil.java
  87. 146 0
      android/capacitor/src/main/java/com/getcapacitor/ui/ModalsBottomSheetDialogFragment.java
  88. 25 0
      android/capacitor/src/main/java/com/getcapacitor/ui/Toast.java
  89. 125 0
      android/capacitor/src/main/java/com/getcapacitor/util/HostMask.java
  90. 12 0
      android/capacitor/src/main/res/drawable/ic_transparent.xml
  91. 15 0
      android/capacitor/src/main/res/layout/bridge_layout_main.xml
  92. 13 0
      android/capacitor/src/main/res/layout/fragment_bridge.xml
  93. 6 0
      android/capacitor/src/main/res/values/attrs.xml
  94. 6 0
      android/capacitor/src/main/res/values/colors.xml
  95. 7 0
      android/capacitor/src/main/res/values/strings.xml
  96. 15 0
      android/capacitor/src/main/res/values/styles.xml
  97. 17 0
      android/capacitor/src/test/java/com/getcapacitor/ExampleUnitTest.java
  98. 209 0
      android/capacitor/src/test/java/com/getcapacitor/JSObjectTest.java
  99. 44 0
      android/capacitor/src/test/java/com/getcapacitor/PluginMethodHandleTest.java
  100. 81 0
      android/capacitor/src/test/java/com/getcapacitor/util/HostMaskTest.java

+ 4 - 0
android/app/src/main/java/com/izouma/jmrh/AndroidBug5497Workaround.java

@@ -0,0 +1,4 @@
+package com.izouma.jmrh;
+
+public class AndroidBug5497Workaround {
+}

+ 4 - 0
android/app/src/main/java/com/izouma/jmrh/KeyboardUtil.java

@@ -0,0 +1,4 @@
+package com.izouma.jmrh;
+
+public class KeyboardUtil {
+}

+ 78 - 0
android/capacitor/build.gradle

@@ -0,0 +1,78 @@
+ext {
+    androidxAppCompatVersion = project.hasProperty('androidxAppCompatVersion') ? rootProject.ext.androidxAppCompatVersion : '1.1.0'
+    androidxCoreVersion =  project.hasProperty('androidxCoreVersion') ? rootProject.ext.androidxCoreVersion : '1.2.0'
+    androidxMaterialVersion =  project.hasProperty('androidxMaterialVersion') ? rootProject.ext.androidxMaterialVersion : '1.1.0-rc02'
+    androidxBrowserVersion =  project.hasProperty('androidxBrowserVersion') ? rootProject.ext.androidxBrowserVersion : '1.2.0'
+    androidxLocalbroadcastmanagerVersion =  project.hasProperty('androidxLocalbroadcastmanagerVersion') ? rootProject.ext.androidxLocalbroadcastmanagerVersion : '1.0.0'
+    androidxExifInterfaceVersion =  project.hasProperty('androidxExifInterfaceVersion') ? rootProject.ext.androidxExifInterfaceVersion : '1.2.0'
+    firebaseMessagingVersion =  project.hasProperty('firebaseMessagingVersion') ? rootProject.ext.firebaseMessagingVersion : '20.1.2'
+    playServicesLocationVersion =  project.hasProperty('playServicesLocationVersion') ? rootProject.ext.playServicesLocationVersion : '17.0.0'
+    junitVersion =  project.hasProperty('junitVersion') ? rootProject.ext.junitVersion : '4.12'
+    androidxJunitVersion =  project.hasProperty('androidxJunitVersion') ? rootProject.ext.androidxJunitVersion : '1.1.1'
+    androidxEspressoCoreVersion =  project.hasProperty('androidxEspressoCoreVersion') ? rootProject.ext.androidxEspressoCoreVersion : '3.2.0'
+    cordovaAndroidVersion =  project.hasProperty('cordovaAndroidVersion') ? rootProject.ext.cordovaAndroidVersion : '7.0.0'
+}
+
+
+buildscript {
+    repositories {
+        google()
+        jcenter()
+    }
+    dependencies {
+        classpath 'com.android.tools.build:gradle:3.6.1'
+    }
+}
+
+tasks.withType(Javadoc).all { enabled = false }
+
+apply plugin: 'com.android.library'
+
+android {
+    compileSdkVersion project.hasProperty('compileSdkVersion') ? rootProject.ext.compileSdkVersion : 29
+    defaultConfig {
+        minSdkVersion project.hasProperty('minSdkVersion') ? rootProject.ext.minSdkVersion : 21
+        targetSdkVersion project.hasProperty('targetSdkVersion') ? rootProject.ext.targetSdkVersion : 29
+        versionCode 1
+        versionName "1.0"
+        testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
+    }
+    buildTypes {
+        release {
+            minifyEnabled false
+            proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
+        }
+    }
+    lintOptions {
+        abortOnError false
+    }
+    compileOptions {
+        sourceCompatibility JavaVersion.VERSION_1_8
+        targetCompatibility JavaVersion.VERSION_1_8
+    }
+}
+
+repositories {
+    google()
+    jcenter()
+    mavenCentral()
+}
+
+dependencies {
+    implementation fileTree(dir: 'libs', include: ['*.jar'])
+    implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
+    implementation "androidx.core:core:$androidxCoreVersion"
+    implementation "com.google.android.material:material:$androidxMaterialVersion"
+    implementation "androidx.browser:browser:$androidxBrowserVersion"
+    implementation "androidx.localbroadcastmanager:localbroadcastmanager:$androidxLocalbroadcastmanagerVersion"
+    implementation "androidx.exifinterface:exifinterface:$androidxExifInterfaceVersion"
+    implementation "com.google.firebase:firebase-messaging:$firebaseMessagingVersion"
+    implementation "com.google.android.gms:play-services-location:$playServicesLocationVersion"
+    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:20140107'
+    testImplementation 'org.mockito:mockito-inline:2.25.1'
+}
+

+ 21 - 0
android/capacitor/proguard-rules.pro

@@ -0,0 +1,21 @@
+# 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
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+#   public *;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+#-keepattributes SourceFile,LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile

+ 27 - 0
android/capacitor/src/androidTest/java/com/getcapacitor/android/ExampleInstrumentedTest.java

@@ -0,0 +1,27 @@
+package com.getcapacitor.android;
+
+import android.content.Context;
+
+import androidx.test.platform.app.InstrumentationRegistry;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import static org.junit.Assert.*;
+
+/**
+ * Instrumented test, which will execute on an Android device.
+ *
+ * @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
+ */
+@RunWith(AndroidJUnit4.class)
+public class ExampleInstrumentedTest {
+    @Test
+    public void useAppContext() throws Exception {
+        // Context of the app under test.
+        Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext();
+
+        assertEquals("com.getcapacitor.android.test", appContext.getPackageName());
+    }
+}

+ 28 - 0
android/capacitor/src/main/AndroidManifest.xml

@@ -0,0 +1,28 @@
+<?xml version="1.0" encoding="utf-8"?>
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+    package="com.getcapacitor.android">
+    <uses-feature android:name="android.hardware.camera"
+        android:required="false" />
+
+    <application>
+        <service android:name="com.getcapacitor.plugin.background.BackgroundTaskService" android:exported="false" />
+        <receiver android:name="com.getcapacitor.plugin.notification.TimedNotificationPublisher" />
+        <receiver android:name="com.getcapacitor.plugin.notification.NotificationDismissReceiver" />
+        <meta-data android:name="firebase_messaging_auto_init_enabled" android:value="false" />
+        <service android:name="com.getcapacitor.CapacitorFirebaseMessagingService" android:stopWithTask="false">
+            <intent-filter>
+                <action android:name="com.google.firebase.MESSAGING_EVENT" />
+            </intent-filter>
+        </service>
+        <receiver android:name="com.getcapacitor.plugin.notification.LocalNotificationRestoreReceiver" android:directBootAware="true" android:exported="false">
+            <intent-filter>
+                <action android:name="android.intent.action.LOCKED_BOOT_COMPLETED"/>
+                <action android:name="android.intent.action.BOOT_COMPLETED"/>
+                <action android:name="android.intent.action.QUICKBOOT_POWERON" />
+            </intent-filter>
+        </receiver>
+    </application>
+
+    <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
+    <uses-permission android:name="android.permission.WAKE_LOCK"/>
+</manifest>

+ 98 - 0
android/capacitor/src/main/java/com/getcapacitor/AndroidProtocolHandler.java

@@ -0,0 +1,98 @@
+// 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<String> 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);
+    int id = field.getInt(null);
+    return id;
+  }
+
+  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;
+  }
+}

+ 936 - 0
android/capacitor/src/main/java/com/getcapacitor/Bridge.java

@@ -0,0 +1,936 @@
+package com.getcapacitor;
+
+import android.app.Activity;
+import android.content.ActivityNotFoundException;
+import android.content.Context;
+import android.content.Intent;
+import android.content.pm.ApplicationInfo;
+import android.content.pm.PackageInfo;
+import android.graphics.Color;
+import android.net.Uri;
+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 android.content.SharedPreferences;
+
+import com.getcapacitor.android.BuildConfig;
+import com.getcapacitor.plugin.Accessibility;
+import com.getcapacitor.plugin.App;
+import com.getcapacitor.plugin.Browser;
+import com.getcapacitor.plugin.Camera;
+import com.getcapacitor.plugin.Clipboard;
+import com.getcapacitor.plugin.Device;
+import com.getcapacitor.plugin.Filesystem;
+import com.getcapacitor.plugin.Geolocation;
+import com.getcapacitor.plugin.Haptics;
+import com.getcapacitor.plugin.Keyboard;
+import com.getcapacitor.plugin.LocalNotifications;
+import com.getcapacitor.plugin.Modals;
+import com.getcapacitor.plugin.Network;
+import com.getcapacitor.plugin.Permissions;
+import com.getcapacitor.plugin.Photos;
+import com.getcapacitor.plugin.PushNotifications;
+import com.getcapacitor.plugin.Share;
+import com.getcapacitor.plugin.SplashScreen;
+import com.getcapacitor.plugin.StatusBar;
+import com.getcapacitor.plugin.Storage;
+import com.getcapacitor.plugin.background.BackgroundTask;
+import com.getcapacitor.ui.Toast;
+import com.getcapacitor.util.HostMask;
+
+import org.apache.cordova.CordovaInterfaceImpl;
+import org.apache.cordova.CordovaPreferences;
+import org.apache.cordova.PluginManager;
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.io.File;
+import java.net.SocketTimeoutException;
+import java.net.URL;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Arrays;
+import java.util.ArrayList;
+
+
+/**
+ * The Bridge class is the main engine of Capacitor. It manages
+ * loading and communicating with all Plugins,
+ * proxying Native events to Plugins, executing Plugin methods,
+ * communicating with the WebView, and a whole lot more.
+ *
+ * Generally, you'll not use Bridge directly, instead, extend from BridgeActivity
+ * to get a WebView instance and proxy native events automatically.
+ *
+ * If you want to use this Bridge in an existing Android app, please
+ * see the source for BridgeActivity for the methods you'll need to
+ * pass through to Bridge:
+ * <a href="https://github.com/ionic-team/capacitor/blob/master/android/capacitor/src/main/java/com/getcapacitor/BridgeActivity.java">
+ *   BridgeActivity.java</a>
+ */
+public class Bridge {
+
+  private static final String PREFS_NAME = "CapacitorSettings";
+  private static final String 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";
+
+  // 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_";
+
+  // Loaded Capacitor config
+  private CapConfig config;
+
+  // A reference to the main activity for the app
+  private final Activity context;
+  private WebViewLocalServer localServer;
+  private String localUrl;
+  private String appUrl;
+  private String appUrlConfig;
+  private HostMask appAllowNavigationMask;
+  // A reference to the main WebView for the app
+  private final WebView webView;
+  public final CordovaInterfaceImpl cordovaInterface;
+  private CordovaPreferences preferences;
+  private BridgeWebViewClient webViewClient;
+
+  // Our MessageHandler for sending and receiving data to the WebView
+  private final MessageHandler msgHandler;
+
+  // The ThreadHandler for executing plugin calls
+  private final HandlerThread handlerThread = new HandlerThread("CapacitorPlugins");
+
+  // Our Handler for posting plugin calls. Created from the ThreadHandler
+  private Handler taskHandler = null;
+
+  private final List<Class<? extends Plugin>> initialPlugins;
+
+  // A map of Plugin Id's to PluginHandle's
+  private Map<String, PluginHandle> plugins = new HashMap<>();
+
+  // Stored plugin calls that we're keeping around to call again someday
+  private Map<String, PluginCall> savedCalls = new HashMap<>();
+
+  // 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;
+
+
+  /**
+   * 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
+   */
+  public Bridge(Activity context, WebView webView, List<Class<? extends Plugin>> initialPlugins, CordovaInterfaceImpl cordovaInterface, PluginManager pluginManager, CordovaPreferences preferences, JSONObject config) {
+    this.context = context;
+    this.webView = webView;
+    this.webViewClient = new BridgeWebViewClient(this);
+    this.initialPlugins = initialPlugins;
+    this.cordovaInterface = cordovaInterface;
+    this.preferences = preferences;
+
+    // Start our plugin execution threads and handlers
+    handlerThread.start();
+    taskHandler = new Handler(handlerThread.getLooper());
+
+    Config.load(getActivity());
+    this.config = new CapConfig(getActivity().getAssets(), config);
+    Logger.init(this.config);
+
+    // Display splash screen if configured
+    if (context instanceof BridgeActivity) {
+      Splash.showOnLaunch((BridgeActivity) context, this.config);
+    }
+
+    // Initialize web view and message handler for it
+    this.initWebView();
+    this.msgHandler = new MessageHandler(this, webView, pluginManager);
+
+    // Grab any intent info that our app was launched with
+    Intent intent = context.getIntent();
+    Uri intentData = intent.getData();
+    this.intentUri = intentData;
+
+    // Register our core plugins
+    this.registerAllPlugins();
+
+    this.loadWebView();
+  }
+
+  private void loadWebView() {
+    appUrlConfig = this.getServerUrl();
+    String[] appAllowNavigationConfig = this.config.getArray("server.allowNavigation");
+
+    ArrayList<String> authorities = new ArrayList<String>();
+    if (appAllowNavigationConfig != null) {
+      authorities.addAll(Arrays.asList(appAllowNavigationConfig));
+    }
+    this.appAllowNavigationMask = HostMask.Parser.parse(appAllowNavigationConfig);
+
+    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) {
+      }
+      localUrl = appUrlConfig;
+      appUrl = appUrlConfig;
+      if (BuildConfig.DEBUG) {
+        Toast.show(getContext(), "Using app server " + appUrlConfig);
+      }
+    } else {
+      appUrl = localUrl;
+      // custom URL schemes requires path ending with /
+      if (!scheme.equals(Bridge.CAPACITOR_HTTP_SCHEME) && !scheme.equals(CAPACITOR_HTTPS_SCHEME)) {
+        appUrl += "/";
+      }
+    }
+
+    final boolean html5mode = this.config.getBoolean("server.html5mode", true);
+
+    // 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);
+      }
+    }
+    // Get to work
+    webView.loadUrl(appUrl);
+  }
+
+  public boolean launchIntent(Uri url) {
+    /*
+    * Give plugins the chance to handle the url
+    */
+    for (Map.Entry<String, PluginHandle> entry : plugins.entrySet()) {
+      Plugin plugin = entry.getValue().getInstance();
+      if (plugin != null) {
+        Boolean shouldOverrideLoad = plugin.shouldOverrideLoad(url);
+        if (shouldOverrideLoad != null) {
+          return shouldOverrideLoad;
+        }
+      }
+    }
+
+    if (!url.toString().contains(appUrl) && !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 {
+      PackageInfo pInfo = getContext().getPackageManager().getPackageInfo(getContext().getPackageName(), 0);
+      versionCode = Integer.toString(pInfo.versionCode);
+      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 void handleAppUrlLoadError(Exception ex) {
+    if (ex instanceof SocketTimeoutException) {
+      if (BuildConfig.DEBUG) {
+        Toast.show(getContext(), "Unable to load app. Are you sure the server is running at " + appUrl + "?");
+      }
+      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;
+  }
+
+  /**
+   * Get the Context for the App
+   * @return
+   */
+  public Context getContext() {
+    return this.context;
+  }
+
+  /**
+   * Get the activity for the app
+   * @return
+   */
+  public Activity getActivity() { return this.context; }
+
+  /**
+   * 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.getString("server.androidScheme", CAPACITOR_HTTP_SCHEME);
+  }
+
+  /**
+   * Get host name that is used to serve content
+   * @return
+   */
+  public String getHost() {
+    return this.config.getString("server.hostname", "localhost");
+  }
+
+  /**
+   * Get the server url that is used to serve content
+   * @return
+   */
+  public String getServerUrl() {
+    return this.config.getString("server.url");
+  }
+
+  public CapConfig getConfig() {
+    return this.config;
+  }
+
+  public void reset() {
+    savedCalls = new HashMap<>();
+  }
+
+
+  /**
+   * Initialize the WebView, setting required flags
+   */
+  private void initWebView() {
+    WebSettings settings = webView.getSettings();
+    settings.setJavaScriptEnabled(true);
+    settings.setDomStorageEnabled(true);
+    settings.setGeolocationEnabled(true);
+    settings.setDatabaseEnabled(true);
+    settings.setAppCacheEnabled(true);
+    settings.setMediaPlaybackRequiresUserGesture(false);
+    settings.setJavaScriptCanOpenWindowsAutomatically(true);
+    if (this.config.getBoolean("android.allowMixedContent", false)) {
+      settings.setMixedContentMode(WebSettings.MIXED_CONTENT_ALWAYS_ALLOW);
+    }
+
+    String appendUserAgent = this.config.getString("android.appendUserAgent" , this.config.getString("appendUserAgent", null));
+    if (appendUserAgent != null) {
+      String defaultUserAgent = settings.getUserAgentString();
+      settings.setUserAgentString(defaultUserAgent + " " + appendUserAgent);
+    }
+    String overrideUserAgent = this.config.getString("android.overrideUserAgent" , this.config.getString("overrideUserAgent", null));
+    if (overrideUserAgent != null) {
+      settings.setUserAgentString(overrideUserAgent);
+    }
+
+    String backgroundColor = this.config.getString("android.backgroundColor" , this.config.getString("backgroundColor", null));
+    try {
+      if (backgroundColor != null) {
+        webView.setBackgroundColor(Color.parseColor(backgroundColor));
+      }
+    } catch (IllegalArgumentException ex) {
+      Logger.debug("WebView background color not applied");
+    }
+    boolean defaultDebuggable = false;
+    if (isDevMode()) {
+      defaultDebuggable = true;
+    }
+    webView.requestFocusFromTouch();
+    WebView.setWebContentsDebuggingEnabled(this.config.getBoolean("android.webContentsDebuggingEnabled", defaultDebuggable));
+  }
+
+  /**
+   * Register our core Plugin APIs
+   */
+  private void registerAllPlugins() {
+    this.registerPlugin(App.class);
+    this.registerPlugin(Accessibility.class);
+    this.registerPlugin(BackgroundTask.class);
+    this.registerPlugin(Browser.class);
+    this.registerPlugin(Camera.class);
+    this.registerPlugin(Clipboard.class);
+    this.registerPlugin(Device.class);
+    this.registerPlugin(LocalNotifications.class);
+    this.registerPlugin(Filesystem.class);
+    this.registerPlugin(Geolocation.class);
+    this.registerPlugin(Haptics.class);
+    this.registerPlugin(Keyboard.class);
+    this.registerPlugin(Modals.class);
+    this.registerPlugin(Network.class);
+    this.registerPlugin(Permissions.class);
+    this.registerPlugin(Photos.class);
+    this.registerPlugin(PushNotifications.class);
+    this.registerPlugin(Share.class);
+    this.registerPlugin(SplashScreen.class);
+    this.registerPlugin(StatusBar.class);
+    this.registerPlugin(Storage.class);
+    this.registerPlugin(com.getcapacitor.plugin.Toast.class);
+    this.registerPlugin(com.getcapacitor.plugin.WebView.class);
+
+    for (Class<? extends Plugin> pluginClass : this.initialPlugins) {
+      this.registerPlugin(pluginClass);
+    }
+  }
+
+  /**
+   * Register additional plugins
+   * @param pluginClasses the plugins to register
+   */
+  public void registerPlugins(Class<? extends Plugin>[] pluginClasses) {
+    for (Class<? extends Plugin> plugin : pluginClasses) {
+      this.registerPlugin(plugin);
+    }
+  }
+
+  /**
+   * Register a plugin class
+   * @param pluginClass a class inheriting from Plugin
+   */
+  public void registerPlugin(Class<? extends Plugin> pluginClass) {
+    NativePlugin pluginAnnotation = pluginClass.getAnnotation(NativePlugin.class);
+
+    if (pluginAnnotation == null) {
+      Logger.error("NativePlugin doesn't have the @NativePlugin annotation. Please add it");
+      return;
+    }
+
+    String pluginId = pluginClass.getSimpleName();
+
+    // Use the supplied name as the id if available
+    if (!pluginAnnotation.name().equals("")) {
+      pluginId = pluginAnnotation.name();
+    }
+
+    Logger.debug("Registering plugin: " + pluginId);
+
+    try {
+      this.plugins.put(pluginId, new PluginHandle(this, pluginClass));
+    } catch (InvalidPluginException ex) {
+      Logger.error("NativePlugin " + pluginClass.getName() +
+          " is invalid. Ensure the @NativePlugin annotation exists on the plugin class and" +
+          " the class extends Plugin");
+    } catch (PluginLoadException ex) {
+      Logger.error("NativePlugin " + pluginClass.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
+   */
+  public PluginHandle getPluginWithRequestCode(int requestCode) {
+    for (PluginHandle handle : this.plugins.values()) {
+      NativePlugin pluginAnnotation = handle.getPluginAnnotation();
+      if (pluginAnnotation == null) {
+        continue;
+      }
+
+      int[] requestCodes = pluginAnnotation.requestCodes();
+      for (int rc : requestCodes) {
+        if (rc == requestCode) {
+          return handle;
+        }
+      }
+
+      if (pluginAnnotation.permissionRequestCode() == 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;
+      }
+
+      Logger.verbose("callback: " + call.getCallbackId() +
+          ", pluginId: " + plugin.getId() +
+          ", methodName: " + methodName + ", methodData: " + call.getData().toString());
+
+      Runnable currentThreadTask = new Runnable() {
+        @Override
+        public void run() {
+          try {
+            plugin.invoke(methodName, call);
+
+            if (call.isSaved()) {
+              saveCall(call);
+            }
+          } catch(PluginLoadException | InvalidPluginMethodException ex) {
+            Logger.error("Unable to execute plugin method", ex);
+          } catch (Exception ex) {
+            Logger.error("Serious error executing plugin", ex);
+            throw new RuntimeException(ex);
+          }
+        }
+      };
+
+      taskHandler.post(currentThreadTask);
+
+    } catch (Exception ex) {
+      Logger.error(Logger.tags("callPluginMethod"), "error : " + ex, null);
+      call.errorCallback(ex.toString());
+    }
+  }
+
+  /**
+   * Evaluate JavaScript in the web view. This method
+   * executes on the main thread automatically.
+   * @param js the JS to execute
+   * @param callback an optional ValueCallback that will synchronously receive a value
+   *                 after calling the JS
+   */
+  public void eval(final String js, final ValueCallback<String> callback) {
+    Handler mainHandler = new Handler(context.getMainLooper());
+    mainHandler.post(new Runnable() {
+      @Override
+      public void run() {
+        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 + "\")", new ValueCallback<String>() {
+      @Override
+      public void onReceiveValue(String s) {
+      }
+    });
+  }
+
+  public void triggerJSEvent(final String eventName, final String target, final String data) {
+    eval("window.Capacitor.triggerEvent(\"" + eventName + "\", \"" + target + "\", " + data + ")", new ValueCallback<String>() {
+      @Override
+      public void onReceiveValue(String 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) {
+    return this.savedCalls.get(callbackId);
+  }
+
+  /**
+   * Release a retained call
+   * @param call
+   */
+  public void releaseCall(PluginCall call) {
+    this.savedCalls.remove(call.getCallbackId());
+  }
+
+  /**
+   * 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, isDevMode());
+      String coreJS = JSExport.getCoreJS(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, coreJS, pluginJS, cordovaJS, cordovaPluginsJS, cordovaPluginsFileJS, localUrlJS);
+    } catch(JSExportException ex) {
+      Logger.error("Unable to export Capacitor JS. App will not function!", ex);
+    }
+    return null;
+  }
+
+  protected void storeDanglingPluginResult(PluginCall call, PluginResult result) {
+    PluginHandle appHandle = getPlugin("App");
+    App appPlugin = (App) appHandle.getInstance();
+    appPlugin.fireRestoredResult(result);
+  }
+
+  /**
+   * 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 (lastPlugin != null) {
+        lastPlugin.getInstance().restoreState(bundleData);
+      }
+    }
+  }
+
+  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) {
+        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, handle.getInstance().saveInstanceState());
+      }
+    }
+  }
+
+  public void startActivityForPluginWithResult(PluginCall call, Intent intent, int requestCode) {
+    Logger.debug("Starting activity for result");
+
+    pluginCallForLastActivity = call;
+
+    getActivity().startActivityForResult(intent, requestCode);
+  }
+
+  /**
+   * Handle a request permission result by finding the that requested
+   * the permission and calling their permission handler
+   * @param requestCode the code that was requested
+   * @param permissions the permissions requested
+   * @param grantResults the set of granted/denied permissions
+   */
+
+  public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) {
+    PluginHandle plugin = getPluginWithRequestCode(requestCode);
+
+    if (plugin == null) {
+      Logger.debug("Unable to find a Capacitor plugin to handle permission requestCode, trying Cordova plugins " + requestCode);
+      try {
+        cordovaInterface.onRequestPermissionResult(requestCode, permissions, grantResults);
+      } catch (JSONException e) {
+        Logger.debug("Error on Cordova plugin permissions request " + e.getMessage());
+      }
+      return;
+    }
+
+    plugin.getInstance().handleRequestPermissionsResult(requestCode, permissions, grantResults);
+  }
+
+  /**
+   * 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
+   */
+  public void 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);
+      cordovaInterface.onActivityResult(requestCode, resultCode, data);
+      return;
+    }
+
+    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;
+  }
+
+  /**
+   * 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);
+    }
+  }
+
+  /**
+   * 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();
+    }
+  }
+
+  /**
+   * Handle onResume lifecycle event and notify the plugins
+   */
+  public void onResume() {
+    for (PluginHandle plugin : plugins.values()) {
+      plugin.getInstance().handleOnResume();
+    }
+  }
+
+  /**
+   * Handle onPause lifecycle event and notify the plugins
+   */
+  public void onPause() {
+    Splash.onPause();
+
+    for (PluginHandle plugin : plugins.values()) {
+      plugin.getInstance().handleOnPause();
+    }
+  }
+
+  /**
+   * Handle onStop lifecycle event and notify the plugins
+   */
+  public void onStop() {
+    for (PluginHandle plugin : plugins.values()) {
+      plugin.getInstance().handleOnStop();
+    }
+  }
+
+  /**
+   * Handle onDestroy lifecycle event and notify the plugins
+   */
+  public void onDestroy() {
+    for (PluginHandle plugin : plugins.values()) {
+      plugin.getInstance().handleOnDestroy();
+    }
+  }
+
+  public void onBackPressed() {
+    PluginHandle appHandle = getPlugin("App");
+    if (appHandle != null) {
+      App appPlugin = (App) appHandle.getInstance();
+
+      // If there are listeners, don't do the default action, as this means the user
+      // wants to override the back button
+      if (appPlugin.hasBackButtonListeners()) {
+        appPlugin.fireBackButton();
+      } else {
+        if (webView.canGoBack()) {
+          webView.goBack();
+        }
+      }
+    }
+
+  }
+
+  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(new Runnable() {
+      @Override
+      public void run() {
+        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(new Runnable() {
+      @Override
+      public void run() {
+        webView.loadUrl(appUrl);
+      }
+    });
+  }
+
+  public String getLocalUrl() {
+    return localUrl;
+  }
+
+  public WebViewLocalServer getLocalServer() {
+    return localServer;
+  }
+
+  public HostMask getAppAllowNavigationMask() {
+    return appAllowNavigationMask;
+  }
+
+  public BridgeWebViewClient getWebViewClient() {
+    return this.webViewClient;
+  }
+
+  public void setWebViewClient(BridgeWebViewClient client) {
+    this.webViewClient = client;
+  }
+
+}

+ 243 - 0
android/capacitor/src/main/java/com/getcapacitor/BridgeActivity.java

@@ -0,0 +1,243 @@
+package com.getcapacitor;
+
+import android.app.Activity;
+import android.content.Context;
+import android.content.Intent;
+import android.os.Bundle;
+import android.webkit.WebView;
+
+import androidx.appcompat.app.AppCompatActivity;
+
+import com.getcapacitor.android.R;
+import com.getcapacitor.cordova.MockCordovaInterfaceImpl;
+import com.getcapacitor.cordova.MockCordovaWebViewImpl;
+import com.getcapacitor.plugin.App;
+
+import org.apache.cordova.ConfigXmlParser;
+import org.apache.cordova.CordovaPreferences;
+import org.apache.cordova.PluginEntry;
+import org.apache.cordova.PluginManager;
+import org.json.JSONObject;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public class BridgeActivity extends AppCompatActivity {
+  protected Bridge bridge;
+  private WebView webView;
+  protected MockCordovaInterfaceImpl cordovaInterface;
+  protected boolean keepRunning = true;
+  private ArrayList<PluginEntry> pluginEntries;
+  private PluginManager pluginManager;
+  private CordovaPreferences preferences;
+  private MockCordovaWebViewImpl mockWebView;
+  private JSONObject config;
+
+  private int activityDepth = 0;
+
+  private String lastActivityPlugin;
+
+  private List<Class<? extends Plugin>> initialPlugins = new ArrayList<>();
+
+  @Override
+  protected void onCreate(Bundle savedInstanceState) {
+    super.onCreate(savedInstanceState);
+  }
+
+  protected void init(Bundle savedInstanceState, List<Class<? extends Plugin>> plugins) {
+    this.init(savedInstanceState, plugins, null);
+  }
+  protected void init(Bundle savedInstanceState, List<Class<? extends Plugin>> plugins, JSONObject config) {
+    this.initialPlugins = plugins;
+    this.config = config;
+    loadConfig(this.getApplicationContext(),this);
+
+    getApplication().setTheme(getResources().getIdentifier("AppTheme_NoActionBar", "style", getPackageName()));
+    setTheme(getResources().getIdentifier("AppTheme_NoActionBar", "style", getPackageName()));
+    setTheme(R.style.AppTheme_NoActionBar);
+
+
+    setContentView(R.layout.bridge_layout_main);
+
+    this.load(savedInstanceState);
+  }
+
+  /**
+   * Load the WebView and create the Bridge
+   */
+  protected void load(Bundle savedInstanceState) {
+    Logger.debug("Starting BridgeActivity");
+
+    webView = findViewById(R.id.webview);
+
+    cordovaInterface = new MockCordovaInterfaceImpl(this);
+    if (savedInstanceState != null) {
+      cordovaInterface.restoreInstanceState(savedInstanceState);
+    }
+
+    mockWebView = new MockCordovaWebViewImpl(this.getApplicationContext());
+    mockWebView.init(cordovaInterface, pluginEntries, preferences, webView);
+
+    pluginManager = mockWebView.getPluginManager();
+    cordovaInterface.onCordovaInit(pluginManager);
+    bridge = new Bridge(this, webView, initialPlugins, cordovaInterface, pluginManager, preferences, this.config);
+
+    if (savedInstanceState != null) {
+      bridge.restoreInstanceState(savedInstanceState);
+    }
+    this.keepRunning = preferences.getBoolean("KeepRunning", true);
+    this.onNewIntent(getIntent());
+  }
+
+  public Bridge getBridge() {
+    return this.bridge;
+  }
+
+  /**
+   * Notify the App plugin that the current state changed
+   * @param isActive
+   */
+  private void fireAppStateChanged(boolean isActive) {
+    PluginHandle handle = bridge.getPlugin("App");
+    if (handle == null) {
+      return;
+    }
+
+    App appState = (App) handle.getInstance();
+    if (appState != null) {
+      appState.fireChange(isActive);
+    }
+  }
+
+  @Override
+  public void onSaveInstanceState(Bundle outState) {
+    super.onSaveInstanceState(outState);
+    bridge.saveInstanceState(outState);
+  }
+
+  @Override
+  public void onStart() {
+    super.onStart();
+
+    activityDepth++;
+
+    this.bridge.onStart();
+    mockWebView.handleStart();
+
+    Logger.debug("App started");
+  }
+
+  @Override
+  public void onRestart() {
+    super.onRestart();
+    this.bridge.onRestart();
+    Logger.debug("App restarted");
+  }
+
+  @Override
+  public void onResume() {
+    super.onResume();
+
+    fireAppStateChanged(true);
+
+    this.bridge.onResume();
+
+    mockWebView.handleResume(this.keepRunning);
+
+    Logger.debug("App resumed");
+  }
+
+  @Override
+  public void onPause() {
+    super.onPause();
+
+    this.bridge.onPause();
+    if (this.mockWebView != null) {
+      boolean keepRunning = this.keepRunning || this.cordovaInterface.getActivityResultCallback() != null;
+      this.mockWebView.handlePause(keepRunning);
+    }
+
+    Logger.debug("App paused");
+  }
+
+  @Override
+  public void onStop() {
+    super.onStop();
+
+    activityDepth = Math.max(0, activityDepth - 1);
+    if (activityDepth == 0) {
+      fireAppStateChanged(false);
+    }
+
+    this.bridge.onStop();
+
+    if (mockWebView != null) {
+      mockWebView.handleStop();
+    }
+
+    Logger.debug("App stopped");
+  }
+
+  @Override
+  public void onDestroy() {
+    super.onDestroy();
+    this.bridge.onDestroy();
+    if (this.mockWebView != null) {
+      mockWebView.handleDestroy();
+    }
+    Logger.debug("App destroyed");
+  }
+
+  @Override
+  public void onDetachedFromWindow() {
+    super.onDetachedFromWindow();
+    if (webView != null) {
+      webView.removeAllViews();
+      webView.destroy();
+    }
+  }
+
+  @Override
+  public void onRequestPermissionsResult(int requestCode, String permissions[], int[] grantResults) {
+    if (this.bridge == null) {
+      return;
+    }
+
+    this.bridge.onRequestPermissionsResult(requestCode, permissions, grantResults);
+  }
+
+  @Override
+  protected void onActivityResult(int requestCode, int resultCode, Intent data) {
+    if (this.bridge == null) {
+      return;
+    }
+    this.bridge.onActivityResult(requestCode, resultCode, data);
+  }
+
+  @Override
+  protected void onNewIntent(Intent intent) {
+    if (this.bridge == null || intent == null) {
+      return;
+    }
+
+    this.bridge.onNewIntent(intent);
+    mockWebView.onNewIntent(intent);
+  }
+
+  @Override
+  public void onBackPressed() {
+    if (this.bridge == null) {
+      return;
+    }
+
+    this.bridge.onBackPressed();
+  }
+
+  public void loadConfig(Context context, Activity activity) {
+    ConfigXmlParser parser = new ConfigXmlParser();
+    parser.parse(context);
+    preferences = parser.getPreferences();
+    preferences.setPreferencesBundle(activity.getIntent().getExtras());
+    pluginEntries = parser.getPluginEntries();
+  }
+}

+ 163 - 0
android/capacitor/src/main/java/com/getcapacitor/BridgeFragment.java

@@ -0,0 +1,163 @@
+package com.getcapacitor;
+
+import android.app.Activity;
+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 android.webkit.WebView;
+import androidx.fragment.app.Fragment;
+import com.getcapacitor.android.R;
+import com.getcapacitor.cordova.MockCordovaInterfaceImpl;
+import com.getcapacitor.cordova.MockCordovaWebViewImpl;
+import java.util.ArrayList;
+import java.util.List;
+import org.apache.cordova.ConfigXmlParser;
+import org.apache.cordova.CordovaPreferences;
+import org.apache.cordova.PluginEntry;
+import org.apache.cordova.PluginManager;
+import org.json.JSONObject;
+
+/**
+ * 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";
+
+    private WebView webView;
+    protected Bridge bridge;
+    protected MockCordovaInterfaceImpl cordovaInterface;
+    protected boolean keepRunning = true;
+    private ArrayList<PluginEntry> pluginEntries;
+    private PluginManager pluginManager;
+    private CordovaPreferences preferences;
+    private MockCordovaWebViewImpl mockWebView;
+
+    private List<Class<? extends Plugin>> initialPlugins = new ArrayList<>();
+    private JSONObject config = new JSONObject();
+
+    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;
+    }
+
+    protected void init(Bundle savedInstanceState) {
+        loadConfig(this.getActivity().getApplicationContext(), this.getActivity());
+    }
+
+    public void addPlugin(Class<? extends Plugin> plugin) {
+        this.initialPlugins.add(plugin);
+    }
+
+    /**
+     * Load the WebView and create the Bridge
+     */
+    protected void load(Bundle savedInstanceState) {
+        Logger.debug("Starting BridgeActivity");
+
+        Bundle args = getArguments();
+        String startDir = null;
+
+        if (args != null) {
+            startDir = getArguments().getString(ARG_START_DIR);
+        }
+
+        webView = getView().findViewById(R.id.webview);
+        cordovaInterface = new MockCordovaInterfaceImpl(this.getActivity());
+        if (savedInstanceState != null) {
+            cordovaInterface.restoreInstanceState(savedInstanceState);
+        }
+
+        mockWebView = new MockCordovaWebViewImpl(getActivity().getApplicationContext());
+        mockWebView.init(cordovaInterface, pluginEntries, preferences, webView);
+
+        pluginManager = mockWebView.getPluginManager();
+        cordovaInterface.onCordovaInit(pluginManager);
+
+        if (preferences == null) {
+            preferences = new CordovaPreferences();
+        }
+
+        bridge = new Bridge(this.getActivity(), webView, initialPlugins, cordovaInterface, pluginManager, preferences, config);
+
+        if (startDir != null) {
+            bridge.setServerAssetPath(startDir);
+        }
+
+        if (savedInstanceState != null) {
+            bridge.restoreInstanceState(savedInstanceState);
+        }
+        this.keepRunning = preferences.getBoolean("KeepRunning", true);
+    }
+
+    public void loadConfig(Context context, Activity activity) {
+        ConfigXmlParser parser = new ConfigXmlParser();
+        parser.parse(context);
+        preferences = parser.getPreferences();
+        preferences.setPreferencesBundle(activity.getIntent().getExtras());
+        pluginEntries = parser.getPluginEntries();
+    }
+
+    @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 onActivityCreated(Bundle savedInstanceState) {
+        super.onActivityCreated(savedInstanceState);
+        this.init(savedInstanceState);
+        this.load(savedInstanceState);
+    }
+
+    @Override
+    public void onDestroy() {
+        super.onDestroy();
+        if (this.bridge != null) {
+            this.bridge.onDestroy();
+        }
+        if (this.mockWebView != null) {
+            mockWebView.handleDestroy();
+        }
+    }
+}

+ 372 - 0
android/capacitor/src/main/java/com/getcapacitor/BridgeWebChromeClient.java

@@ -0,0 +1,372 @@
+package com.getcapacitor;
+
+import android.Manifest;
+import android.app.Activity;
+import android.content.ActivityNotFoundException;
+import android.content.Intent;
+import android.content.pm.PackageManager;
+import android.net.Uri;
+import android.provider.MediaStore;
+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.view.View;
+
+import com.getcapacitor.plugin.camera.CameraUtils;
+
+import org.apache.cordova.CordovaPlugin;
+import org.json.JSONException;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+/**
+ * Custom WebChromeClient handler, required for showing dialogs, confirms, etc. in our
+ * WebView instance.
+ */
+public class BridgeWebChromeClient extends WebChromeClient {
+  private Bridge bridge;
+  static final int FILE_CHOOSER = PluginRequestCodes.FILE_CHOOSER;
+  static final int FILE_CHOOSER_IMAGE_CAPTURE = PluginRequestCodes.FILE_CHOOSER_IMAGE_CAPTURE;
+  static final int FILE_CHOOSER_VIDEO_CAPTURE = PluginRequestCodes.FILE_CHOOSER_VIDEO_CAPTURE;
+  static final int FILE_CHOOSER_CAMERA_PERMISSION = PluginRequestCodes.FILE_CHOOSER_CAMERA_PERMISSION;
+  static final int GET_USER_MEDIA_PERMISSIONS = PluginRequestCodes.GET_USER_MEDIA_PERMISSIONS;
+
+  public BridgeWebChromeClient(Bridge bridge) {
+    this.bridge = bridge;
+  }
+  
+  @Override
+  public void onShowCustomView(View view, CustomViewCallback callback) {
+    callback.onCustomViewHidden();
+    super.onShowCustomView(view, callback);
+  }
+  
+  @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<String> permissionList = new ArrayList<String>();
+    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]);;
+      bridge.cordovaInterface.requestPermissions(new CordovaPlugin(){
+        @Override
+        public void onRequestPermissionResult(int requestCode, String[] permissions, int[] grantResults) throws JSONException {
+          if (GET_USER_MEDIA_PERMISSIONS == requestCode) {
+            for (int r : grantResults) {
+              if (r == PackageManager.PERMISSION_DENIED) {
+                request.deny();
+                return;
+              }
+            }
+            request.grant(request.getResources());
+          }
+        }
+      }, GET_USER_MEDIA_PERMISSIONS, 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;
+    }
+
+    Dialogs.alert(view.getContext(), message, new Dialogs.OnResultListener() {
+      @Override
+      public void onResult(boolean value, boolean didCancel, String inputValue) {
+        if(value) {
+          result.confirm();
+        } else {
+          result.cancel();
+        }
+      }
+    });
+
+    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;
+    }
+
+    Dialogs.confirm(view.getContext(), message, new Dialogs.OnResultListener() {
+      @Override
+      public void onResult(boolean value, boolean didCancel, String inputValue) {
+        if(value) {
+          result.confirm();
+        } else {
+          result.cancel();
+        }
+      }
+    });
+
+    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;
+    }
+
+    Dialogs.prompt(view.getContext(), message, new Dialogs.OnResultListener() {
+      @Override
+      public void onResult(boolean value, boolean didCancel, String inputValue) {
+        if(value) {
+          result.confirm(inputValue);
+        } else {
+          result.cancel();
+        }
+      }
+    });
+
+    return true;
+  }
+
+  /**
+   * Handle the browser geolocation 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);
+
+    // Set that we want geolocation perms for this origin
+    callback.invoke(origin, true, false);
+
+    Plugin geo = bridge.getPlugin("Geolocation").getInstance();
+    if (!geo.hasRequiredPermissions()) {
+      geo.pluginRequestAllPermissions();
+    } else {
+      Logger.debug("onGeolocationPermissionsShowPrompt: has required permis");
+    }
+  }
+
+  @Override
+  public boolean onShowFileChooser(WebView webView, final ValueCallback<Uri[]> filePathCallback, final FileChooserParams fileChooserParams) {
+    List<String> 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 {
+        this.bridge.cordovaInterface.requestPermission(new CordovaPlugin(){
+          @Override
+          public void onRequestPermissionResult(int requestCode, String[] permissions, int[] grantResults) throws JSONException {
+            if (FILE_CHOOSER_CAMERA_PERMISSION == requestCode) {
+              if (grantResults[0] == PackageManager.PERMISSION_GRANTED) {
+                showMediaCaptureOrFilePicker(filePathCallback, fileChooserParams, captureVideo);
+              } else {
+                Logger.warn(Logger.tags("FileChooser"), "Camera permission not granted");
+                filePathCallback.onReceiveValue(null);
+              }
+            }
+          }
+        }, FILE_CHOOSER_CAMERA_PERMISSION, Manifest.permission.CAMERA);
+      }
+    } else {
+      showFilePicker(filePathCallback, fileChooserParams);
+    }
+
+    return true;
+  }
+
+  private boolean isMediaCaptureSupported() {
+    Plugin camera = bridge.getPlugin("Camera").getInstance();
+    boolean isSupported = camera.hasPermission(Manifest.permission.CAMERA) || !camera.hasDefinedPermission(Manifest.permission.CAMERA);
+    return isSupported;
+  }
+
+  private void showMediaCaptureOrFilePicker(ValueCallback<Uri[]> 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);
+    }
+  }
+
+  private boolean showImageCapturePicker(final ValueCallback<Uri[]> filePathCallback) {
+    Intent takePictureIntent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE);
+    if (takePictureIntent.resolveActivity(bridge.getActivity().getPackageManager()) == null) {
+      return false;
+    }
+
+    final Uri imageFileUri;
+    try {
+      imageFileUri = CameraUtils.createImageFileUri(bridge.getActivity(), bridge.getContext().getPackageName());
+    } catch (Exception ex) {
+      Logger.error("Unable to create temporary media capture file: " + ex.getMessage());
+      return false;
+    }
+    takePictureIntent.putExtra(MediaStore.EXTRA_OUTPUT, imageFileUri);
+
+    bridge.cordovaInterface.startActivityForResult(new CordovaPlugin() {
+      @Override
+      public void onActivityResult(int requestCode, int resultCode, Intent intent) {
+        Uri[] result = null;
+        if (resultCode == Activity.RESULT_OK) {
+          result = new Uri[]{imageFileUri};
+        }
+        filePathCallback.onReceiveValue(result);
+      }
+    }, takePictureIntent, FILE_CHOOSER_IMAGE_CAPTURE);
+
+    return true;
+  }
+
+  private boolean showVideoCapturePicker(final ValueCallback<Uri[]> filePathCallback) {
+    Intent takeVideoIntent = new Intent(MediaStore.ACTION_VIDEO_CAPTURE);
+    if (takeVideoIntent.resolveActivity(bridge.getActivity().getPackageManager()) == null) {
+      return false;
+    }
+
+    bridge.cordovaInterface.startActivityForResult(new CordovaPlugin() {
+      @Override
+      public void onActivityResult(int requestCode, int resultCode, Intent intent) {
+        Uri[] result = null;
+        if (resultCode == Activity.RESULT_OK) {
+          result = new Uri[]{intent.getData()};
+        }
+        filePathCallback.onReceiveValue(result);
+      }
+    }, takeVideoIntent, FILE_CHOOSER_VIDEO_CAPTURE);
+
+    return true;
+  }
+
+  private void showFilePicker(final ValueCallback<Uri[]> 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) {
+      String[] validTypes = getValidTypes(fileChooserParams.getAcceptTypes());
+      intent.putExtra(Intent.EXTRA_MIME_TYPES, validTypes);
+    }
+    try {
+      bridge.cordovaInterface.startActivityForResult(new CordovaPlugin() {
+        @Override
+        public void onActivityResult(int requestCode, int resultCode, Intent intent) {
+          Uri[] result;
+          if (resultCode == Activity.RESULT_OK && intent.getClipData() != null && intent.getClipData().getItemCount() > 1) {
+            final int numFiles = intent.getClipData().getItemCount();
+            result = new Uri[numFiles];
+            for (int i = 0; i < numFiles; i++) {
+              result[i] = intent.getClipData().getItemAt(i).getUri();
+
+            }
+          } else {
+            result = WebChromeClient.FileChooserParams.parseResult(resultCode, intent);
+          }
+          filePathCallback.onReceiveValue(result);
+        }
+      }, intent, FILE_CHOOSER);
+    } catch (ActivityNotFoundException e) {
+      filePathCallback.onReceiveValue(null);
+    }
+  }
+
+  private String[] getValidTypes(String[] currentTypes) {
+    List<String> 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"));
+  }
+}

+ 32 - 0
android/capacitor/src/main/java/com/getcapacitor/BridgeWebViewClient.java

@@ -0,0 +1,32 @@
+package com.getcapacitor;
+
+import android.net.Uri;
+import android.webkit.WebResourceRequest;
+import android.webkit.WebResourceResponse;
+import android.webkit.WebView;
+import android.webkit.WebViewClient;
+
+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);
+  }
+
+  @Override
+  public boolean shouldOverrideUrlLoading(WebView view, String url) {
+    return bridge.launchIntent(Uri.parse(url));
+  }
+
+}

+ 151 - 0
android/capacitor/src/main/java/com/getcapacitor/CapConfig.java

@@ -0,0 +1,151 @@
+package com.getcapacitor;
+
+import android.content.res.AssetManager;
+
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStreamReader;
+
+/**
+ * Management interface for accessing values in capacitor.config.json
+ */
+public class CapConfig {
+
+  private JSONObject config = new JSONObject();
+
+  public CapConfig(AssetManager assetManager, JSONObject config) {
+    if (config != null) {
+      this.config = config;
+    } else {
+      // Load our capacitor.config.json
+      this.loadConfig(assetManager);
+    }
+  }
+
+  private void loadConfig(AssetManager assetManager) {
+    BufferedReader reader = null;
+    try {
+      reader = new BufferedReader(new InputStreamReader(assetManager.open("capacitor.config.json")));
+
+      // do reading, usually loop until end of file reading
+      StringBuilder b = new StringBuilder();
+      String line;
+      while ((line = reader.readLine()) != null) {
+        //process line
+        b.append(line);
+      }
+
+      String jsonString = b.toString();
+      this.config = 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);
+    } finally {
+      if (reader != null) {
+        try {
+          reader.close();
+        } catch (IOException e) {
+        }
+      }
+    }
+  }
+
+  public JSONObject getObject(String key) {
+    try {
+      return this.config.getJSONObject(key);
+    } catch (Exception ex) {
+    }
+    return null;
+  }
+
+  private JSONObject getConfigObjectDeepest(String key) throws JSONException {
+    // Split on periods
+    String[] parts = key.split("\\.");
+
+    JSONObject o = this.config;
+    // 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;
+  }
+
+  public String getString(String key) {
+    return getString(key, null);
+  }
+
+  public String getString(String key, String defaultValue) {
+    String k = getConfigKey(key);
+    try {
+      JSONObject o = this.getConfigObjectDeepest(key);
+
+      String value = o.getString(k);
+      if (value == null) {
+        return defaultValue;
+      }
+      return value;
+    } catch (Exception ex) {}
+    return defaultValue;
+  }
+
+  public boolean getBoolean(String key, boolean defaultValue) {
+    String k = getConfigKey(key);
+    try {
+      JSONObject o = this.getConfigObjectDeepest(key);
+
+      return o.getBoolean(k);
+    } catch (Exception ex) {}
+    return defaultValue;
+  }
+
+  public int getInt(String key, int defaultValue) {
+    String k = getConfigKey(key);
+    try {
+      JSONObject o = this.getConfigObjectDeepest(key);
+      return o.getInt(k);
+    } catch (Exception ignore) {
+      // value was not found
+    }
+    return defaultValue;
+  }
+
+  private String getConfigKey(String key) {
+    String[] parts = key.split("\\.");
+    if (parts.length > 0) {
+      return parts[parts.length - 1];
+    }
+    return null;
+  }
+
+  public String[] getArray(String key) {
+    return getArray(key, null);
+  }
+
+  public String[] getArray(String key, String[] defaultValue) {
+    String k = getConfigKey(key);
+    try {
+      JSONObject o = this.getConfigObjectDeepest(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 (Exception ex) {}
+    return defaultValue;
+  }
+}

+ 21 - 0
android/capacitor/src/main/java/com/getcapacitor/CapacitorFirebaseMessagingService.java

@@ -0,0 +1,21 @@
+package com.getcapacitor;
+
+import com.getcapacitor.plugin.PushNotifications;
+import com.google.firebase.messaging.FirebaseMessagingService;
+import com.google.firebase.messaging.RemoteMessage;
+
+public class CapacitorFirebaseMessagingService extends FirebaseMessagingService {
+
+  @Override
+  public void onNewToken(String newToken) {
+    super.onNewToken(newToken);
+    PushNotifications.onNewToken(newToken);
+  }
+
+  @Override
+  public void onMessageReceived(RemoteMessage remoteMessage) {
+    super.onMessageReceived(remoteMessage);
+    PushNotifications.sendRemoteMessage(remoteMessage);
+  }
+
+}

+ 39 - 0
android/capacitor/src/main/java/com/getcapacitor/CapacitorWebView.java

@@ -0,0 +1,39 @@
+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;
+
+  public CapacitorWebView(Context context, AttributeSet attrs) {
+    super(context, attrs);
+  }
+
+  @Override
+  public InputConnection onCreateInputConnection(EditorInfo outAttrs) {
+    CapConfig config = new CapConfig(getContext().getAssets(), null);
+    boolean captureInput = config.getBoolean("android.captureInput", false);
+    if (captureInput) {
+      if (capInputConnection == null) {
+        capInputConnection = new BaseInputConnection(this, false);
+      }
+      return capInputConnection;
+    }
+    return super.onCreateInputConnection(outAttrs);
+  }
+
+  @Override
+  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);
+  }
+}

+ 182 - 0
android/capacitor/src/main/java/com/getcapacitor/Config.java

@@ -0,0 +1,182 @@
+package com.getcapacitor;
+
+import android.app.Activity;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+import org.json.JSONArray;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStreamReader;
+
+/**
+ * @deprecated use getBridge().getConfig() instead of the static Config
+ */
+public class Config {
+
+  private JSONObject config = new JSONObject();
+
+  private static Config instance;
+
+  private static Config getInstance() {
+    if (instance == null) {
+      instance = new Config();
+    }
+    return instance;
+  }
+
+  /**
+   * @deprecated
+   */
+  public static void load(Activity activity) {
+    Config.getInstance().loadConfig(activity);
+  }
+
+  private void loadConfig(Activity activity) {
+    BufferedReader reader = null;
+    try {
+      reader = new BufferedReader(new InputStreamReader(activity.getAssets().open("capacitor.config.json")));
+
+      // do reading, usually loop until end of file reading
+      StringBuilder b = new StringBuilder();
+      String line;
+      while ((line = reader.readLine()) != null) {
+        //process line
+        b.append(line);
+      }
+
+      String jsonString = b.toString();
+      this.config = 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);
+    } finally {
+      if (reader != null) {
+        try {
+          reader.close();
+        } catch (IOException e) {
+        }
+      }
+    }
+  }
+
+  /**
+   * @deprecated
+   */
+  public static JSONObject getObject(String key) {
+    try {
+      return getInstance().config.getJSONObject(key);
+    } catch (Exception ex) {
+    }
+    return null;
+  }
+
+  private JSONObject getConfigObjectDeepest(String key) throws JSONException {
+    // Split on periods
+    String[] parts = key.split("\\.");
+
+    JSONObject o = this.config;
+    // 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;
+  }
+
+  /**
+   * @deprecated
+   */
+  public static String getString(String key) {
+    return getString(key, null);
+  }
+
+  /**
+   * @deprecated
+   */
+  public static String getString(String key, String defaultValue) {
+    String k = getConfigKey(key);
+    try {
+      JSONObject o = getInstance().getConfigObjectDeepest(key);
+
+      String value = o.getString(k);
+      if (value == null) {
+        return defaultValue;
+      }
+      return value;
+    } catch (Exception ex) {}
+    return defaultValue;
+  }
+
+  /**
+   * @deprecated
+   */
+  public static boolean getBoolean(String key, boolean defaultValue) {
+    String k = getConfigKey(key);
+    try {
+      JSONObject o = getInstance().getConfigObjectDeepest(key);
+
+      return o.getBoolean(k);
+    } catch (Exception ex) {}
+    return defaultValue;
+  }
+
+  /**
+   * @deprecated
+   */
+  public static int getInt(String key, int defaultValue) {
+    String k = getConfigKey(key);
+    try {
+      JSONObject o = getInstance().getConfigObjectDeepest(key);
+      return o.getInt(k);
+    } catch (Exception ignore) {
+      // value was not found
+    }
+    return defaultValue;
+  }
+
+  /**
+   * @deprecated
+   */
+  private static String getConfigKey(String key) {
+    String[] parts = key.split("\\.");
+    if (parts.length > 0) {
+      return parts[parts.length - 1];
+    }
+    return null;
+  }
+
+  /**
+   * @deprecated
+   */
+  public static String[] getArray(String key) {
+    return getArray(key, null);
+  }
+
+  /**
+   * @deprecated
+   */
+  public static String[] getArray(String key, String[] defaultValue) {
+    String k = getConfigKey(key);
+    try {
+      JSONObject o = getInstance().getConfigObjectDeepest(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 (Exception ex) {}
+    return defaultValue;
+  }
+}

+ 224 - 0
android/capacitor/src/main/java/com/getcapacitor/Dialogs.java

@@ -0,0 +1,224 @@
+package com.getcapacitor;
+
+import android.app.AlertDialog;
+import android.content.Context;
+import android.content.DialogInterface;
+import android.os.Handler;
+import android.os.Looper;
+import android.widget.EditText;
+
+import androidx.appcompat.app.AppCompatActivity;
+
+import com.getcapacitor.ui.ModalsBottomSheetDialogFragment;
+
+import org.json.JSONException;
+
+/**
+ * Simple utility for showing common web dialogs
+ */
+public class Dialogs {
+  public interface OnResultListener {
+    void onResult(boolean value, boolean didCancel, String inputValue);
+  }
+
+  public interface OnSelectListener {
+    void onSelect(int index);
+  }
+
+  public interface OnCancelListener {
+    void onCancel();
+  }
+
+  /**
+   * Show a simple alert with a message and default values for
+   * title and ok button
+   * @param message the message to show
+   */
+  public static void alert(final Context context, final String message, final Dialogs.OnResultListener listener) {
+    alert(context, message, null, null, listener);
+  }
+
+  /**
+   * Show an alert window
+   * @param context the context
+   * @param message the message for the alert
+   * @param title the title for the alert
+   * @param okButtonTitle the title for the OK button
+   * @param listener the listener for returning data back
+   */
+  public static void alert(final Context context,
+                           final String message,
+                           final String title,
+                           final String okButtonTitle,
+                           final Dialogs.OnResultListener listener) {
+
+    final String alertTitle = title == null ? "Alert" : title;
+    final String alertOkButtonTitle = okButtonTitle == null ? "OK" : okButtonTitle;
+
+    new Handler(Looper.getMainLooper()).post(new Runnable() {
+      @Override
+      public void run() {
+        AlertDialog.Builder builder = new AlertDialog.Builder(context);
+
+        builder
+            .setMessage(message)
+            // TODO: i18n
+            .setTitle(alertTitle)
+            .setPositiveButton(alertOkButtonTitle, new AlertDialog.OnClickListener() {
+              public void onClick(DialogInterface dialog, int buttonIndex) {
+                dialog.dismiss();
+                listener.onResult(true, false, null);
+              }
+            })
+            .setOnCancelListener(new AlertDialog.OnCancelListener() {
+              public void onCancel(DialogInterface dialog) {
+                dialog.dismiss();
+                listener.onResult(false, true, null);
+              }
+            });
+
+        AlertDialog dialog = builder.create();
+
+        dialog.show();
+      }
+    });
+
+  }
+
+  public static void confirm(final Context context,
+                             final String message,
+                             final Dialogs.OnResultListener listener) {
+    confirm(context, message, null, null, null, listener);
+  }
+
+  public static void confirm(final Context context,
+                             final String message,
+                             final String title,
+                             final String okButtonTitle,
+                             final String cancelButtonTitle,
+                             final Dialogs.OnResultListener listener) {
+    final String confirmTitle = title == null ? "Confirm" : title;
+    final String confirmOkButtonTitle = okButtonTitle == null ? "OK" : okButtonTitle;
+    final String confirmCancelButtonTitle = cancelButtonTitle == null ? "Cancel" : cancelButtonTitle;
+
+    new Handler(Looper.getMainLooper()).post(new Runnable() {
+      @Override
+      public void run() {
+        final AlertDialog.Builder builder = new AlertDialog.Builder(context);
+
+        builder
+            .setMessage(message)
+            .setTitle(confirmTitle)
+            .setPositiveButton(confirmOkButtonTitle, new AlertDialog.OnClickListener() {
+              public void onClick(DialogInterface dialog, int buttonIndex) {
+                dialog.dismiss();
+                listener.onResult(true, false, null);
+              }
+            })
+            .setNegativeButton(confirmCancelButtonTitle, new AlertDialog.OnClickListener() {
+              public void onClick(DialogInterface dialog, int buttonIndex) {
+                dialog.dismiss();
+                listener.onResult(false, false, null);
+              }
+            })
+
+            .setOnCancelListener(new AlertDialog.OnCancelListener() {
+              public void onCancel(DialogInterface dialog) {
+                dialog.dismiss();
+                listener.onResult(false, true, null);
+              }
+            });
+
+        AlertDialog dialog = builder.create();
+
+        dialog.show();
+      }
+    });
+  }
+
+  public static void prompt(final Context context,
+                            final String message,
+                            final Dialogs.OnResultListener listener) {
+
+    prompt(context, message, null, null, null, null, null, listener);
+  }
+
+  public static void prompt(final Context context,
+                            final String message,
+                            final String title,
+                            final String okButtonTitle,
+                            final String cancelButtonTitle,
+                            final String inputPlaceholder,
+                            final String inputText,
+                            final Dialogs.OnResultListener listener) {
+    final String promptTitle = title == null ? "Prompt" : title;
+    final String promptOkButtonTitle = okButtonTitle == null ? "OK" : okButtonTitle;
+    final String promptCancelButtonTitle = cancelButtonTitle == null ? "Cancel" : cancelButtonTitle;
+    final String promptInputPlaceholder = inputPlaceholder == null ? "" : inputPlaceholder;
+    final String promptInputText = inputText == null ? "" : inputText;
+
+    new Handler(Looper.getMainLooper()).post(new Runnable() {
+      @Override
+      public void run() {
+        final AlertDialog.Builder builder = new AlertDialog.Builder(context);
+        final EditText input = new EditText(context);
+
+        input.setHint(promptInputPlaceholder);
+        input.setText(promptInputText);
+
+        builder
+            .setMessage(message)
+            .setTitle(promptTitle)
+            .setView(input)
+            .setPositiveButton(promptOkButtonTitle, new AlertDialog.OnClickListener() {
+              public void onClick(DialogInterface dialog, int buttonIndex) {
+                dialog.dismiss();
+
+                String inputText = input.getText().toString().trim();
+                listener.onResult(true, false, inputText);
+              }
+            })
+            .setNegativeButton(promptCancelButtonTitle, new AlertDialog.OnClickListener() {
+              public void onClick(DialogInterface dialog, int buttonIndex) {
+                dialog.dismiss();
+                listener.onResult(false, true, null);
+              }
+            })
+            .setOnCancelListener(new AlertDialog.OnCancelListener() {
+              public void onCancel(DialogInterface dialog) {
+                dialog.dismiss();
+                listener.onResult(false, true, null);
+              }
+            });
+
+        AlertDialog dialog = builder.create();
+
+        dialog.show();
+      }
+    });
+  }
+
+  public static void actions(final AppCompatActivity activity,
+                             final Object[] options,
+                             final Dialogs.OnSelectListener listener, final Dialogs.OnCancelListener cancelListener) {
+
+    JSArray optionsArray;
+    try {
+      optionsArray = new JSArray(options);
+    } catch (JSONException ex) {
+      return;
+    }
+
+    final ModalsBottomSheetDialogFragment fragment = new ModalsBottomSheetDialogFragment();
+    fragment.setOptions(optionsArray);
+    fragment.setOnSelectedListener(new ModalsBottomSheetDialogFragment.OnSelectedListener() {
+      @Override
+      public void onSelected(int index) {
+        listener.onSelect(index);
+        fragment.dismiss();
+      }
+    });
+    fragment.setOnCancelListener(cancelListener);
+    fragment.show(activity.getSupportFragmentManager(), "capacitorModalsActionSheet");
+  }
+}

+ 259 - 0
android/capacitor/src/main/java/com/getcapacitor/FileUtils.java

@@ -0,0 +1,259 @@
+/**
+ * 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.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.File;
+import java.io.FileOutputStream;
+import java.io.InputStream;
+
+/**
+ * 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://", "");
+    } else if (path.startsWith("/")) {
+      path = path;
+    }
+    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 Environment.getExternalStorageDirectory() + "/" + 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;
+  }
+
+  /**
+   * 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;
+  }
+
+}

+ 7 - 0
android/capacitor/src/main/java/com/getcapacitor/InvalidPluginException.java

@@ -0,0 +1,7 @@
+package com.getcapacitor;
+
+class InvalidPluginException extends Exception {
+  public InvalidPluginException(String s) {
+    super(s);
+  }
+}

+ 9 - 0
android/capacitor/src/main/java/com/getcapacitor/InvalidPluginMethodException.java

@@ -0,0 +1,9 @@
+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); }
+}

+ 52 - 0
android/capacitor/src/main/java/com/getcapacitor/JSArray.java

@@ -0,0 +1,52 @@
+package com.getcapacitor;
+
+import org.json.JSONArray;
+import org.json.JSONException;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+
+
+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 <E> List<E> toList() throws JSONException {
+    List<E> 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;
+  }
+}

+ 153 - 0
android/capacitor/src/main/java/com/getcapacitor/JSExport.java

@@ -0,0 +1,153 @@
+package com.getcapacitor;
+
+import android.content.Context;
+import android.text.TextUtils;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+
+public class JSExport {
+  private static String CATCHALL_OPTIONS_PARAM = "_options";
+  private static String CALLBACK_PARAM = "_callback";
+
+  public static String getGlobalJS(Context context, boolean isDebug) {
+    return "window.Capacitor = { DEBUG: " + isDebug + " };";
+  }
+
+  public static String getCoreJS(Context context) throws JSExportException {
+    try {
+      return getJS(context, "public/native-bridge.js");
+    } catch(IOException ex) {
+      throw new JSExportException("Unable to load native-bridge.js. Capacitor will not function!", ex);
+    }
+  }
+
+  private static String getJS(Context context, String fileName) throws IOException {
+    try {
+      BufferedReader br = new BufferedReader(new InputStreamReader(context.getAssets().open(fileName)));
+
+      StringBuffer b = new StringBuffer();
+      String line;
+      while ((line = br.readLine()) != null) {
+        b.append(line + "\n");
+      }
+
+      return b.toString();
+    } catch(IOException ex) {
+      throw ex;
+    }
+  }
+
+  public static String getCordovaJS(Context context) {
+    String fileContent = "";
+    try {
+      fileContent = getJS(context, "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 = getJS(context, "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<PluginHandle> plugins) {
+    List<String> lines = new ArrayList<String>();
+
+    lines.add("// Begin: Capacitor Plugin JS");
+
+    for(PluginHandle plugin : plugins) {
+      lines.add("(function(w) {\n" +
+          "var a = w.Capacitor; var p = 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<PluginMethodHandle> 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");
+    }
+
+    return TextUtils.join("\n", lines);
+  }
+
+  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) {
+          builder.append(getFilesContent(context, path + "/" + file));
+        }
+      } else {
+        return getJS(context, path);
+      }
+    } catch(IOException ex) {
+      Logger.error("Unable to read file at path " + path);
+    }
+    return builder.toString();
+  }
+
+  private static String generateMethodJS(PluginHandle plugin, PluginMethodHandle method) {
+    List<String> lines = new ArrayList<String>();
+
+    List<String> args = new ArrayList<String>();
+    // 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 == 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);
+  }
+
+
+
+}

+ 7 - 0
android/capacitor/src/main/java/com/getcapacitor/JSExportException.java

@@ -0,0 +1,7 @@
+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); }
+}

+ 88 - 0
android/capacitor/src/main/java/com/getcapacitor/JSInjector.java

@@ -0,0 +1,88 @@
+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 coreJS;
+  private String pluginJS;
+  private String cordovaJS;
+  private String cordovaPluginsJS;
+  private String cordovaPluginsFileJS;
+  private String localUrlJS;
+
+  public JSInjector(String globalJS, String coreJS, String pluginJS) {
+    this(globalJS, coreJS, pluginJS, "" /* cordovaJS */, "" /* cordovaPluginsJS */, "" /* cordovaPluginsFileJS */, "" /* localUrlJS */);
+  }
+
+  public JSInjector(String globalJS, String coreJS, String pluginJS, String cordovaJS, String cordovaPluginsJS, String cordovaPluginsFileJS, String localUrlJS) {
+    this.globalJS = globalJS;
+    this.coreJS = coreJS;
+    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" +
+            coreJS + "\n\n" + pluginJS + "\n\n" + cordovaJS + "\n\n" +
+            cordovaPluginsFileJS + "\n\n" + cordovaPluginsJS + "\n\n" +
+            localUrlJS;
+  }
+
+  /**
+   * Given an InputStream from the web server, prepend it with
+   * our JS stream
+   * @param responseStream
+   * @return
+   */
+  public InputStream getInjectedStream(InputStream responseStream) {
+    String js = "<script type=\"text/javascript\">" + getScriptString() + "</script>";
+    String html = this.readAssetStream(responseStream);
+    if (html.contains("<head>")) {
+      html = html.replace("<head>", "<head>\n" + js + "\n");
+    } else if (html.contains("</head>")) {
+      html = html.replace("</head>", js + "\n" + "</head>");
+    } 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, "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 "";
+  }
+
+}

+ 159 - 0
android/capacitor/src/main/java/com/getcapacitor/JSObject.java

@@ -0,0 +1,159 @@
+package com.getcapacitor;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.util.ArrayList;
+import java.util.Iterator;
+import java.util.List;
+
+/**
+ * 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<String> keysIter = obj.keys();
+    List<String> keys = new ArrayList<>();
+    while (keysIter.hasNext()) {
+      keys.add(keysIter.next());
+    }
+
+    return new JSObject(obj, keys.toArray(new String[keys.size()]));
+  }
+
+  public String getString(String key) {
+    return getString(key, null);
+  }
+
+  public String getString(String key, String defaultValue) {
+    try {
+      String value = super.getString(key);
+      if (!super.isNull(key) && value != null) {
+        return value;
+      }
+    } catch (JSONException ex) {
+    }
+    return defaultValue;
+  }
+
+  public Integer getInteger(String key) {
+    return getInteger(key, null);
+  }
+
+  public Integer getInteger(String key, Integer defaultValue) {
+    try {
+      return super.getInt(key);
+    } catch (JSONException e) {
+    }
+    return defaultValue;
+  }
+
+  public Boolean getBoolean(String key, Boolean defaultValue) {
+    try {
+      return super.getBoolean(key);
+    } catch (JSONException e) {
+    }
+    return defaultValue;
+  }
+
+  /**
+   * Fetch boolean from jsonObject
+   */
+  public Boolean getBool(String key) {
+    return getBoolean(key,null);
+  }
+
+  public JSObject getJSObject(String name) {
+    try {
+      return  getJSObject(name, null);
+    } catch (JSONException e) {
+    }
+    return null;
+  }
+
+  public JSObject getJSObject(String name, JSObject defaultValue) throws JSONException {
+    try {
+      Object obj = get(name);
+      if (obj instanceof JSONObject) {
+        Iterator<String> keysIter = ((JSONObject) obj).keys();
+        List<String> 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);
+  }
+}

+ 48 - 0
android/capacitor/src/main/java/com/getcapacitor/LogUtils.java

@@ -0,0 +1,48 @@
+package com.getcapacitor;
+
+import android.text.TextUtils;
+
+/**
+ * @deprecated
+ */
+public abstract class LogUtils {
+
+  /**
+   * @deprecated
+   */
+  public static final String LOG_TAG_CORE = "Capacitor";
+
+  /**
+   * @deprecated
+   */
+  public static final String LOG_TAG_PLUGIN = LOG_TAG_CORE + "/Plugin";
+
+  /**
+   * Creates a core log TAG
+   *
+   * @deprecated
+   * @param subTags sub log tags joined by a slash
+   */
+  public static String getCoreTag(String... subTags) {
+    return getLogTag(LOG_TAG_CORE, subTags);
+  }
+
+  /**
+   * Creates a plugin log TAG
+   *
+   * @deprecated
+   * @param subTags sub log tags joined by a slash
+   */
+  public static String getPluginTag(String... subTags) {
+    return getLogTag(LOG_TAG_PLUGIN, subTags);
+  }
+
+  private static String getLogTag(String mainTag, String[] subTags) {
+    if (subTags != null && subTags.length > 0) {
+      return mainTag + "/" + TextUtils.join("/", subTags);
+    }
+    return mainTag;
+  }
+
+
+}

+ 102 - 0
android/capacitor/src/main/java/com/getcapacitor/Logger.java

@@ -0,0 +1,102 @@
+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) {
+      this.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);
+  }
+
+  protected static boolean shouldLog() {
+    return config == null || !config.getBoolean("android.hideLogs", config.getBoolean("hideLogs", false));
+  }
+}

+ 109 - 0
android/capacitor/src/main/java/com/getcapacitor/MessageHandler.java

@@ -0,0 +1,109 @@
+package com.getcapacitor;
+
+import android.webkit.JavascriptInterface;
+import android.webkit.WebView;
+
+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;
+
+  public MessageHandler(Bridge bridge, WebView webView, PluginManager cordovaPluginManager) {
+    this.bridge = bridge;
+    this.webView = webView;
+    this.cordovaPluginManager = cordovaPluginManager;
+
+    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.isSaved());
+      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);
+        data.put("data", successResult);
+      }
+
+      boolean isValidCallbackId = !call.getCallbackId().equals(PluginCall.CALLBACK_ID_DANGLING);
+      if (isValidCallbackId) {
+        final String runScript = "window.Capacitor.fromNative(" + data.toString() + ")";
+        final WebView webView = this.webView;
+
+        webView.post(() -> webView.evaluateJavascript(runScript, null));
+      } else {
+        bridge.storeDanglingPluginResult(call, data);
+      }
+
+    } catch (Exception ex) {
+      Logger.error("sendResponseMessage: error: " + ex);
+    }
+  }
+
+  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){
+    cordovaPluginManager.exec(service, action, callbackId, actionArgs);
+  }
+}

+ 33 - 0
android/capacitor/src/main/java/com/getcapacitor/NativePlugin.java

@@ -0,0 +1,33 @@
+package com.getcapacitor;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+
+/**
+ * Base annotation for all Plugins
+ */
+@Retention(RetentionPolicy.RUNTIME)
+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 PluginRequestCodes.DEFAULT_CAPACITOR_REQUEST_PERMISSIONS;
+
+  /**
+   * A custom name for the plugin, otherwise uses the
+   * simple class name.
+   */
+  String name() default "";
+}

+ 597 - 0
android/capacitor/src/main/java/com/getcapacitor/Plugin.java

@@ -0,0 +1,597 @@
+package com.getcapacitor;
+
+import android.app.Activity;
+import android.content.Context;
+import android.content.Intent;
+import android.content.pm.PackageInfo;
+import android.content.pm.PackageManager;
+import android.net.Uri;
+import android.os.Bundle;
+
+import androidx.appcompat.app.AppCompatActivity;
+import androidx.core.app.ActivityCompat;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 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 NativePlugin} 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
+  protected PluginCall savedLastCall;
+
+  // Stored event listeners
+  private final Map<String, List<PluginCall>> eventListeners;
+
+  // Stored results of an event if an event was fired and
+  // no listeners were attached yet. Only stores the last value.
+  private final Map<String, JSObject> 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() {}
+
+  /**
+   * 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 (AppCompatActivity) 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 NativePlugin} 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 NativePlugin} 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
+   * @param lastCall
+   */
+  public void saveCall(PluginCall lastCall) {
+    this.savedLastCall = lastCall;
+  }
+
+  /**
+   * Set the last saved call to null to free memory
+   */
+  public void freeSavedCall() {
+    if (!this.savedLastCall.isReleased()) {
+      this.savedLastCall.release(bridge);
+    }
+    this.savedLastCall = null;
+  }
+
+  /**
+   * Get the last saved call, if any
+   * @return
+   */
+  public PluginCall getSavedCall() {
+    return this.savedLastCall;
+  }
+
+  public Object getConfigValue(String key) {
+    try {
+      JSONObject plugins = bridge.getConfig().getObject("plugins");
+      if (plugins == null) {
+        return null;
+      }
+      JSONObject pluginConfig = plugins.getJSONObject(getPluginHandle().getId());
+      return pluginConfig.get(key);
+    } catch (JSONException ex) {
+      return null;
+    }
+  }
+
+  /**
+   * Given a list of permissions, return a new list with the ones not present in AndroidManifest.xml
+   * @param neededPermissions
+   * @return
+   */
+  public String[] getUndefinedPermissions(String[] neededPermissions) {
+    ArrayList<String> undefinedPermissions =  new ArrayList<String>();
+    String[] requestedPermissions = getManifestPermissions();
+    if (requestedPermissions != null && requestedPermissions.length > 0)
+    {
+      List<String> requestedPermissionsList = Arrays.asList(requestedPermissions);
+      ArrayList<String> requestedPermissionsArrayList = new ArrayList<String>();
+      requestedPermissionsArrayList.addAll(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;
+  }
+
+  /**
+   * Check whether the given permission has been defined in the AndroidManifest.xml
+   * @param permission
+   * @return
+   */
+  public boolean hasDefinedPermission(String permission) {
+    boolean hasPermission = false;
+    String[] requestedPermissions = getManifestPermissions();
+    if (requestedPermissions != null && requestedPermissions.length > 0)
+    {
+      List<String> requestedPermissionsList = Arrays.asList(requestedPermissions);
+      ArrayList<String> requestedPermissionsArrayList = new ArrayList<String>();
+      requestedPermissionsArrayList.addAll(requestedPermissionsList);
+      if (requestedPermissionsArrayList.contains(permission)) {
+        hasPermission = true;
+      }
+    }
+    return hasPermission;
+  }
+
+  /**
+   * Get the permissions defined in AndroidManifest.xml
+   * @return
+   */
+  private String[] getManifestPermissions(){
+    String[] requestedPermissions = null;
+    try {
+      PackageManager pm = getContext().getPackageManager();
+      PackageInfo packageInfo = pm.getPackageInfo(getAppId(), PackageManager.GET_PERMISSIONS);
+
+      if (packageInfo != null) {
+        requestedPermissions = packageInfo.requestedPermissions;
+      }
+    } catch (Exception ex) {
+
+    }
+    return requestedPermissions;
+  }
+
+  /**
+   * Check whether any of the given permissions has been defined in the AndroidManifest.xml
+   * @param permissions
+   * @return
+   */
+  public boolean hasDefinedPermissions(String[] permissions) {
+    for (String permission: permissions) {
+      if (!hasDefinedPermission(permission)){
+        return false;
+      }
+    }
+    return true;
+  }
+
+  /**
+   * Check whether any of annotation permissions has been defined in the AndroidManifest.xml
+   * @return
+   */
+  public boolean hasDefinedRequiredPermissions() {
+    NativePlugin annotation = handle.getPluginAnnotation();
+    return hasDefinedPermissions(annotation.permissions());
+  }
+
+  /**
+   * Check whether the given permission has been granted by the user
+   * @param permission
+   * @return
+   */
+  public boolean hasPermission(String permission) {
+    return ActivityCompat.checkSelfPermission(this.getContext(), permission) == PackageManager.PERMISSION_GRANTED;
+  }
+
+  /**
+   * If the {@link NativePlugin} annotation specified a set of permissions,
+   * this method checks if each is granted. Note: if you are okay
+   * with a limited subset of the permissions being granted, check
+   * each one individually instead with hasPermission
+   * @return
+   */
+  public boolean hasRequiredPermissions() {
+    NativePlugin annotation = handle.getPluginAnnotation();
+    for (String perm : annotation.permissions()) {
+      if (!hasPermission(perm)) {
+        return false;
+      }
+    }
+    return true;
+  }
+
+  /**
+   * Helper to make requesting permissions easy
+   * @param permissions the set of permissions to request
+   * @param requestCode the requestCode to use to associate the result with the plugin
+   */
+  public void pluginRequestPermissions(String[] permissions, int requestCode) {
+    ActivityCompat.requestPermissions(getActivity(), permissions, requestCode);
+  }
+
+  /**
+   * Request all of the specified permissions in the NativePlugin annotation (if any)
+   */
+  public void pluginRequestAllPermissions() {
+    NativePlugin annotation = handle.getPluginAnnotation();
+    ActivityCompat.requestPermissions(getActivity(), annotation.permissions(), annotation.permissionRequestCode());
+  }
+
+  /**
+   * Helper to make requesting individual permissions easy
+   * @param permission the permission to request
+   * @param requestCode the requestCode to use to associate the result with the plugin
+   */
+  public void pluginRequestPermission(String permission, int requestCode) {
+    ActivityCompat.requestPermissions(getActivity(), new String[] { permission }, requestCode);
+  }
+
+
+  /**
+   * Add a listener for the given event
+   * @param eventName
+   * @param call
+   */
+  private void addEventListener(String eventName, PluginCall call) {
+    List<PluginCall> listeners = eventListeners.get(eventName);
+    if (listeners == null || listeners.isEmpty()) {
+      listeners = new ArrayList<PluginCall>();
+      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<PluginCall> 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<PluginCall> listeners = eventListeners.get(eventName);
+    if (listeners == null || listeners.isEmpty()) {
+      Logger.debug(getLogTag(), "No listeners found for event " + eventName);
+      if (retainUntilConsumed) {
+        retainedEventArguments.put(eventName, data);
+      }
+      return;
+    }
+
+    for(PluginCall call : listeners) {
+      call.success(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<PluginCall> listeners = eventListeners.get(eventName);
+    if (listeners == null) {
+      return false;
+    }
+    return listeners.size() > 0;
+  }
+
+  /**
+   * 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) {
+    JSObject retained = retainedEventArguments.get(eventName);
+    if (retained == null) {
+      return;
+    }
+
+    notifyListeners(eventName, retained);
+    retainedEventArguments.remove(eventName);
+  }
+
+
+  /**
+   * 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.save();
+    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_NONE)
+  public void removeAllListeners(PluginCall call) {
+    eventListeners.clear();
+  }
+
+  /**
+   * Exported plugin call to request all permissions for this plugin
+   * @param call
+   */
+  @SuppressWarnings("unused")
+  @PluginMethod()
+  public void requestPermissions(PluginCall call) {
+    // Should be overridden, does nothing by default
+    NativePlugin annotation = this.handle.getPluginAnnotation();
+    String[] perms = annotation.permissions();
+
+    if (perms.length > 0) {
+      // Save the call so we can return data back once the permission request has completed
+      saveCall(call);
+
+      pluginRequestPermissions(perms, annotation.permissionRequestCode());
+    } else {
+      call.success();
+    }
+  }
+
+
+  /**
+   * Handle request permissions result. A plugin can override this to handle the result
+   * themselves, or this method will handle the result for our convenient requestPermissions
+   * call.
+   * @param requestCode
+   * @param permissions
+   * @param grantResults
+   */
+  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 = getUndefinedPermissions(permissions);
+      for (String perm: missing) {
+        builder.append(perm + "\n");
+      }
+      savedLastCall.error(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 = getSavedCall();
+
+    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
+   * @param requestCode
+   * @param resultCode
+   * @param data
+   */
+  protected void handleOnActivityResult(int requestCode, int resultCode, Intent data) {}
+
+  /**
+   * Handle onNewIntent
+   * @param intent
+   */
+  protected void handleOnNewIntent(Intent intent) {}
+
+  /**
+   * 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
+   */
+  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());
+  }
+}

+ 295 - 0
android/capacitor/src/main/java/com/getcapacitor/PluginCall.java

@@ -0,0 +1,295 @@
+package com.getcapacitor;
+
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * 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 static final String UNIMPLEMENTED = "not implemented";
+  private static final String UNAVAILABLE = "not available";
+
+  private final MessageHandler msgHandler;
+  private final String pluginId;
+  private final String callbackId;
+  private final String methodName;
+  private final JSObject data;
+
+  private boolean shouldSave = false;
+
+  /**
+   * Indicates that this PluginCall was released, and should no longer be used
+   */
+  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);
+  }
+
+
+  public void success(JSObject data) {
+    PluginResult result = new PluginResult(data);
+    this.msgHandler.sendResponseMessage(this, result, null);
+  }
+
+  public void success() {
+    this.success(new JSObject());
+  }
+
+  public void resolve(JSObject data) {
+    PluginResult result = new PluginResult(data);
+    this.msgHandler.sendResponseMessage(this, result, null);
+  }
+
+  public void resolve() {
+    this.success(new JSObject());
+  }
+
+  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);
+  }
+
+  public void error(String msg, Exception ex) {
+    error(msg, null, ex);
+  }
+
+  public void error(String msg, String code, Exception ex) {
+    PluginResult errorResult = new PluginResult();
+
+    if(ex != null) {
+      Logger.error(Logger.tags("Plugin"), msg, ex);
+    }
+
+    try {
+      errorResult.put("message", msg);
+      errorResult.put("code", code);
+    } catch (Exception jsonEx) {
+      Logger.error(Logger.tags("Plugin"), jsonEx.getMessage(), null);
+    }
+
+    this.msgHandler.sendResponseMessage(this, null, errorResult);
+  }
+
+  public void error(String msg) {
+    error(msg, null);
+  }
+
+  public void reject(String msg, Exception ex) {
+    error(msg, ex);
+  }
+
+  public void reject(String msg, String code) {
+    error(msg, code, null);
+  }
+
+  public void reject(String msg) {
+    error(msg, null);
+  }
+
+  public void unimplemented() {
+    error(UNIMPLEMENTED, null);
+  }
+
+  public void unavailable() {
+    error(UNAVAILABLE, 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;
+  }
+
+  public String getString(String name) {
+    return this.getString(name, null);
+  }
+  public String getString(String name, String defaultValue) {
+    Object value = this.data.opt(name);
+    if(value == null) { return defaultValue; }
+
+    if(value instanceof String) {
+      return (String) value;
+    }
+    return defaultValue;
+  }
+
+  public Integer getInt(String name) {
+    return this.getInt(name, null);
+  }
+  public Integer getInt(String name, Integer defaultValue) {
+    Object value = this.data.opt(name);
+    if(value == null) { return defaultValue; }
+
+    if(value instanceof Integer) {
+      return (Integer) value;
+    }
+    return defaultValue;
+  }
+
+  public Float getFloat(String name) {
+    return this.getFloat(name, null);
+  }
+  public Float getFloat(String name, 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;
+  }
+
+  public Double getDouble(String name) {
+    return this.getDouble(name, null);
+  }
+  public Double getDouble(String name, 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;
+  }
+
+  public Boolean getBoolean(String name) {
+    return this.getBoolean(name, null);
+  }
+  public Boolean getBoolean(String name, 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, new JSObject());
+  }
+
+  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, new JSArray());
+  }
+
+  /**
+   * Get a JSONArray and turn it into a JSArray
+   * @param name
+   * @param defaultValue
+   * @return
+   */
+  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<Object> 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;
+  }
+
+  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 (😆).
+   */
+  public void save() {
+    this.shouldSave = true;
+  }
+
+  public void release(Bridge bridge) {
+    this.shouldSave = false;
+    bridge.releaseCall(this);
+    this.isReleased = true;
+  }
+
+  public boolean isSaved() {
+    return shouldSave;
+  }
+
+  public boolean isReleased() {
+    return isReleased;
+  }
+
+  class PluginCallDataTypeException extends Exception {
+    PluginCallDataTypeException(String m) { super(m); }
+  }
+}

+ 122 - 0
android/capacitor/src/main/java/com/getcapacitor/PluginHandle.java

@@ -0,0 +1,122 @@
+package com.getcapacitor;
+
+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<? extends Plugin> pluginClass;
+
+  private Map<String, PluginMethodHandle> pluginMethods = new HashMap<>();
+
+  private final String pluginId;
+
+  private NativePlugin pluginAnnotation;
+  private Plugin instance;
+
+  public PluginHandle(Bridge bridge, Class<? extends Plugin> pluginClass) throws InvalidPluginException,
+                                                                                 PluginLoadException {
+    this.bridge = bridge;
+    this.pluginClass = pluginClass;
+
+    NativePlugin pluginAnnotation = pluginClass.getAnnotation(NativePlugin.class);
+    if(pluginAnnotation == null) {
+      throw new InvalidPluginException("No @NativePlugin annotation found for plugin " + pluginClass.getName());
+    }
+
+    if(!pluginAnnotation.name().equals("")) {
+      this.pluginId = pluginAnnotation.name();
+    } else {
+      this.pluginId = pluginClass.getSimpleName();
+    }
+
+    this.pluginAnnotation = pluginAnnotation;
+
+    this.indexMethods(pluginClass);
+
+    this.load();
+  }
+
+  public Class<? extends Plugin> getPluginClass() {
+    return pluginClass;
+  }
+
+  public String getId() {
+    return this.pluginId;
+  }
+  public NativePlugin getPluginAnnotation() { return this.pluginAnnotation; }
+  public Plugin getInstance() {
+    return this.instance;
+  }
+
+  public Collection<PluginMethodHandle> getMethods() {
+    return this.pluginMethods.values();
+  }
+
+  public Plugin load() throws PluginLoadException {
+    if(this.instance != null) {
+      return this.instance;
+    }
+
+    try {
+      this.instance = this.pluginClass.newInstance();
+      this.instance.setPluginHandle(this);
+      this.instance.setBridge(this.bridge);
+      this.instance.load();
+      return this.instance;
+    } catch(InstantiationException | IllegalAccessException ex) {
+      throw new PluginLoadException("Unable to load plugin instance. Ensure plugin is publicly accessible");
+    }
+  }
+
+  /**
+   * 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<? extends Plugin> 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);
+    }
+  }
+}

+ 7 - 0
android/capacitor/src/main/java/com/getcapacitor/PluginInvocationException.java

@@ -0,0 +1,7 @@
+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); }
+}

+ 10 - 0
android/capacitor/src/main/java/com/getcapacitor/PluginLoadException.java

@@ -0,0 +1,10 @@
+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); }
+}

+ 14 - 0
android/capacitor/src/main/java/com/getcapacitor/PluginMethod.java

@@ -0,0 +1,14 @@
+package com.getcapacitor;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+
+@Retention(RetentionPolicy.RUNTIME)
+public @interface PluginMethod {
+  public static String RETURN_PROMISE = "promise";
+  public static String RETURN_CALLBACK = "callback";
+  public static String RETURN_NONE = "none";
+
+  String returnType() default RETURN_PROMISE;
+}
+

+ 32 - 0
android/capacitor/src/main/java/com/getcapacitor/PluginMethodHandle.java

@@ -0,0 +1,32 @@
+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;
+  }
+}

+ 27 - 0
android/capacitor/src/main/java/com/getcapacitor/PluginRequestCodes.java

@@ -0,0 +1,27 @@
+package com.getcapacitor;
+
+public class PluginRequestCodes {
+  public static final int DEFAULT_CAPACITOR_REQUEST_PERMISSIONS = 9000;
+  public static final int BROWSER_OPEN_CHROME_TAB = 9001;
+  public static final int CAMERA_IMAGE_CAPTURE = 9002;
+  public static final int CAMERA_IMAGE_PICK = 9003;
+  public static final int GEOLOCATION_REQUEST_PERMISSIONS = 9004;
+  public static final int CAMERA_IMAGE_EDIT = 9005;
+  public static final int NOTIFICATION_OPEN = 9006;
+  public static final int FILE_CHOOSER = 9007;
+  public static final int FILE_CHOOSER_IMAGE_CAPTURE = 9008;
+  public static final int FILE_CHOOSER_VIDEO_CAPTURE = 9009;
+  public static final int FILE_CHOOSER_CAMERA_PERMISSION = 9010;
+  public static final int GET_USER_MEDIA_PERMISSIONS = 9011;
+  public static final int FILESYSTEM_REQUEST_WRITE_FILE_PERMISSIONS = 9012;
+  public static final int FILESYSTEM_REQUEST_WRITE_FOLDER_PERMISSIONS = 9013;
+  public static final int FILESYSTEM_REQUEST_READ_FILE_PERMISSIONS = 9014;
+  public static final int FILESYSTEM_REQUEST_READ_FOLDER_PERMISSIONS = 9015;
+  public static final int FILESYSTEM_REQUEST_DELETE_FILE_PERMISSIONS = 9016;
+  public static final int FILESYSTEM_REQUEST_DELETE_FOLDER_PERMISSIONS = 9017;
+  public static final int FILESYSTEM_REQUEST_URI_PERMISSIONS = 9018;
+  public static final int FILESYSTEM_REQUEST_STAT_PERMISSIONS = 9019;
+  public static final int FILESYSTEM_REQUEST_RENAME_PERMISSIONS = 9020;
+  public static final int FILESYSTEM_REQUEST_COPY_PERMISSIONS = 9021;
+  public static final int FILESYSTEM_REQUEST_ALL_PERMISSIONS = 9022;
+}

+ 84 - 0
android/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;
+  }
+}

+ 426 - 0
android/capacitor/src/main/java/com/getcapacitor/Splash.java

@@ -0,0 +1,426 @@
+package com.getcapacitor;
+
+import android.animation.Animator;
+import android.app.Activity;
+import android.content.Context;
+import android.content.res.Resources;
+import android.content.res.ColorStateList;
+import android.graphics.Color;
+import android.graphics.PixelFormat;
+import android.graphics.drawable.Animatable;
+import android.graphics.drawable.Drawable;
+import android.graphics.drawable.LayerDrawable;
+import android.os.Handler;
+import android.view.Gravity;
+import android.view.View;
+import android.view.WindowManager;
+import android.view.animation.LinearInterpolator;
+import android.widget.ImageView;
+import android.widget.ProgressBar;
+
+/**
+ * A Splash Screen service for showing and hiding a splash screen in the app.
+ */
+public class Splash {
+  public interface SplashListener {
+    void completed();
+    void error();
+  }
+
+  public static final String CONFIG_KEY_PREFIX = "plugins.SplashScreen.";
+
+  public static final int DEFAULT_LAUNCH_SHOW_DURATION = 3000;
+  public static final int DEFAULT_FADE_IN_DURATION = 200;
+  public static final int DEFAULT_FADE_OUT_DURATION = 200;
+  public static final int DEFAULT_SHOW_DURATION = 3000;
+  public static final boolean DEFAULT_AUTO_HIDE = true;
+  public static final boolean DEFAULT_SPLASH_FULL_SCREEN = false;
+  public static final boolean DEFAULT_SPLASH_IMMERSIVE = false;
+
+  private static ImageView splashImage;
+  private static ProgressBar spinnerBar;
+  private static WindowManager wm;
+  private static boolean isVisible = false;
+  private static boolean isHiding = false;
+
+  private static void buildViews(Context c, CapConfig config) {
+    if (splashImage == null) {
+      String splashResourceName = config.getString(CONFIG_KEY_PREFIX + "androidSplashResourceName", "splash");
+
+      int splashId = c.getResources().getIdentifier(splashResourceName, "drawable", c.getPackageName());
+
+      Drawable splash;
+      try {
+        splash = c.getResources().getDrawable(splashId, c.getTheme());
+      } catch (Resources.NotFoundException ex) {
+        Logger.warn("No splash screen found, not displaying");
+        return;
+      }
+
+      if (splash instanceof Animatable) {
+        ((Animatable) splash).start();
+      }
+
+      if(splash instanceof LayerDrawable){
+        LayerDrawable layeredSplash = (LayerDrawable) splash;
+
+        for(int i = 0; i < layeredSplash.getNumberOfLayers(); i++){
+          Drawable layerDrawable = layeredSplash.getDrawable(i);
+
+          if(layerDrawable instanceof  Animatable) {
+            ((Animatable) layerDrawable).start();
+          }
+        }
+      }
+
+      splashImage = new ImageView(c);
+
+      splashImage.setFitsSystemWindows(true);
+      
+      // Enable immersive mode (hides status bar and navbar) during splash screen or hide status bar.
+      Boolean splashImmersive = config.getBoolean(CONFIG_KEY_PREFIX + "splashImmersive", DEFAULT_SPLASH_IMMERSIVE);
+      Boolean splashFullScreen = config.getBoolean(CONFIG_KEY_PREFIX + "splashFullScreen", DEFAULT_SPLASH_FULL_SCREEN);
+      if (splashImmersive) {
+        final int flags = View.SYSTEM_UI_FLAG_LAYOUT_STABLE
+                | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
+                | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
+                | View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
+                | View.SYSTEM_UI_FLAG_FULLSCREEN
+                | View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY;
+        splashImage.setSystemUiVisibility(flags);
+      } else if (splashFullScreen) {
+        splashImage.setSystemUiVisibility(View.SYSTEM_UI_FLAG_FULLSCREEN);
+      }
+
+      // Stops flickers dead in their tracks
+      // https://stackoverflow.com/a/21847579/32140
+      splashImage.setDrawingCacheEnabled(true);
+
+      String backgroundColor = config.getString(CONFIG_KEY_PREFIX + "backgroundColor");
+      try {
+        if (backgroundColor != null) {
+          splashImage.setBackgroundColor(Color.parseColor(backgroundColor));
+        }
+      } catch (IllegalArgumentException ex) {
+        Logger.debug("Background color not applied");
+      }
+
+      String scaleTypeName = config.getString(CONFIG_KEY_PREFIX + "androidScaleType", "FIT_XY");
+      ImageView.ScaleType scaleType = null;
+      try {
+        scaleType = ImageView.ScaleType.valueOf(scaleTypeName);
+      } catch (IllegalArgumentException ex) {
+        scaleType = ImageView.ScaleType.FIT_XY;
+      }
+
+      splashImage.setScaleType(scaleType);
+      splashImage.setImageDrawable(splash);
+    }
+
+    if (spinnerBar == null) {
+      String spinnerStyle = config.getString(CONFIG_KEY_PREFIX + "androidSpinnerStyle");
+      if (spinnerStyle != null) {
+        int spinnerBarStyle = android.R.attr.progressBarStyleLarge;
+
+        switch (spinnerStyle.toLowerCase()) {
+          case "horizontal":
+            spinnerBarStyle = android.R.attr.progressBarStyleHorizontal;
+            break;
+          case "small":
+            spinnerBarStyle = android.R.attr.progressBarStyleSmall;
+            break;
+          case "large":
+            spinnerBarStyle = android.R.attr.progressBarStyleLarge;
+            break;
+          case "inverse":
+            spinnerBarStyle = android.R.attr.progressBarStyleInverse;
+            break;
+          case "smallinverse":
+            spinnerBarStyle = android.R.attr.progressBarStyleSmallInverse;
+            break;
+          case "largeinverse":
+            spinnerBarStyle = android.R.attr.progressBarStyleLargeInverse;
+            break;
+        }
+
+        spinnerBar = new ProgressBar(c, null, spinnerBarStyle);
+      } else {
+        spinnerBar = new ProgressBar(c);
+      }
+      spinnerBar.setIndeterminate(true);
+
+      String spinnerColor = config.getString(CONFIG_KEY_PREFIX + "spinnerColor");
+      try {
+        if (spinnerColor != null) {
+          int[][] states = new int[][]{
+                  new int[]{android.R.attr.state_enabled}, // enabled
+                  new int[]{-android.R.attr.state_enabled}, // disabled
+                  new int[]{-android.R.attr.state_checked}, // unchecked
+                  new int[]{android.R.attr.state_pressed}  // pressed
+          };
+          int spinnerBarColor = Color.parseColor(spinnerColor);
+          int[] colors = new int[]{
+                  spinnerBarColor,
+                  spinnerBarColor,
+                  spinnerBarColor,
+                  spinnerBarColor
+          };
+          ColorStateList colorStateList = new ColorStateList(states, colors);
+          spinnerBar.setIndeterminateTintList(colorStateList);
+        }
+      } catch (IllegalArgumentException ex) {
+        Logger.debug("Spinner color not applied");
+      }
+    }
+  }
+
+  /**
+   * Show the splash screen on launch without fading in
+   * @param a
+   */
+  public static void showOnLaunch(final BridgeActivity a, CapConfig config) {
+    Integer duration = config.getInt(CONFIG_KEY_PREFIX + "launchShowDuration", DEFAULT_LAUNCH_SHOW_DURATION);
+    Boolean autohide = config.getBoolean(CONFIG_KEY_PREFIX + "launchAutoHide", DEFAULT_AUTO_HIDE);
+
+    if (duration == 0) {
+      return;
+    }
+
+    show(a, duration, 0, DEFAULT_FADE_OUT_DURATION, autohide, null, true, config);
+  }
+
+  /**
+   * Show the Splash Screen with default settings
+   * @param a
+   */
+  public static void show(final Activity a) {
+    show(a, DEFAULT_LAUNCH_SHOW_DURATION, DEFAULT_FADE_IN_DURATION, DEFAULT_FADE_OUT_DURATION, DEFAULT_AUTO_HIDE, null, null);
+  }
+
+  /**
+   * Show the Splash Screen
+   */
+  public static void show(final Activity a,
+                          final int showDuration,
+                          final int fadeInDuration,
+                          final int fadeOutDuration,
+                          final boolean autoHide,
+                          final SplashListener splashListener,
+                          final CapConfig config) {
+    show(a, showDuration, fadeInDuration, fadeOutDuration, autoHide, splashListener, false, config);
+  }
+
+  /**
+   * Show the Splash Screen
+   *
+   * @param a
+   * @param showDuration how long to show the splash for if autoHide is enabled
+   * @param fadeInDuration how long to fade the splash screen in
+   * @param fadeOutDuration how long to fade the splash screen out
+   * @param autoHide whether to auto hide after showDuration ms
+   * @param splashListener A listener to handle the finish of the animation (if any)
+   */
+  public static void show(final Activity a,
+                          final int showDuration,
+                          final int fadeInDuration,
+                          final int fadeOutDuration,
+                          final boolean autoHide,
+                          final SplashListener splashListener,
+                          final boolean isLaunchSplash,
+                          final CapConfig config) {
+    wm = (WindowManager)a.getSystemService(Context.WINDOW_SERVICE);
+
+    if (a.isFinishing()) {
+      return;
+    }
+
+    buildViews(a, config);
+
+    if (isVisible) {
+      splashListener.completed();
+      return;
+    }
+
+    final Animator.AnimatorListener listener = new Animator.AnimatorListener() {
+      @Override
+      public void onAnimationEnd(Animator animator) {
+        isVisible = true;
+
+        if (autoHide) {
+          new Handler().postDelayed(new Runnable() {
+            @Override
+            public void run() {
+              Splash.hide(a, fadeOutDuration, isLaunchSplash);
+
+              if (splashListener != null) {
+                splashListener.completed();
+              }
+            }
+          }, showDuration);
+        } else {
+          // If no autoHide, call complete
+          if (splashListener != null) {
+            splashListener.completed();
+          }
+        }
+      }
+
+      @Override
+      public void onAnimationCancel(Animator animator) {}
+      @Override
+      public void onAnimationRepeat(Animator animator) {}
+      @Override
+      public void onAnimationStart(Animator animator) {}
+    };
+
+    Handler mainHandler = new Handler(a.getMainLooper());
+
+    mainHandler.post(new Runnable() {
+      @Override
+      public void run() {
+
+        WindowManager.LayoutParams params = new WindowManager.LayoutParams();
+        params.gravity = Gravity.CENTER;
+        params.flags = a.getWindow().getAttributes().flags;
+
+        // Required to enable the view to actually fade
+        params.format = PixelFormat.TRANSLUCENT;
+
+        try {
+          wm.addView(splashImage, params);
+        } catch (IllegalStateException | IllegalArgumentException ex) {
+          Logger.debug("Could not add splash view");
+          return;
+        }
+
+        splashImage.setAlpha(0f);
+
+        splashImage.animate()
+                .alpha(1f)
+                .setInterpolator(new LinearInterpolator())
+                .setDuration(fadeInDuration)
+                .setListener(listener)
+                .start();
+
+        splashImage.setVisibility(View.VISIBLE);
+
+        if (spinnerBar != null) {
+          Boolean showSpinner = config.getBoolean(CONFIG_KEY_PREFIX + "showSpinner", false);
+
+          spinnerBar.setVisibility(View.INVISIBLE);
+
+          if (spinnerBar.getParent() != null) {
+            wm.removeView(spinnerBar);
+          }
+
+          params.height = WindowManager.LayoutParams.WRAP_CONTENT;
+          params.width = WindowManager.LayoutParams.WRAP_CONTENT;
+
+          wm.addView(spinnerBar, params);
+
+          if (showSpinner) {
+            spinnerBar.setAlpha(0f);
+
+            spinnerBar.animate()
+                    .alpha(1f)
+                    .setInterpolator(new LinearInterpolator())
+                    .setDuration(fadeInDuration)
+                    .start();
+
+            spinnerBar.setVisibility(View.VISIBLE);
+          }
+        }
+      }
+    });
+
+  }
+
+  public static void hide(Context c, final int fadeOutDuration) {
+    hide(c, fadeOutDuration, false);
+  }
+
+  public static void hide(Context c, final int fadeOutDuration, boolean isLaunchSplash) {
+    // Warn the user if the splash was hidden automatically, which means they could be experiencing an app
+    // that feels slower than it actually is.
+    if(isLaunchSplash && isVisible) {
+      Logger.debug("SplashScreen was automatically hidden after the launch timeout. " +
+              "You should call `SplashScreen.hide()` as soon as your web app is loaded (or increase the timeout)." +
+              "Read more at https://capacitorjs.com/docs/apis/splash-screen#hiding-the-splash-screen");
+    }
+
+    if (isHiding || splashImage == null || splashImage.getParent() == null) {
+      return;
+    }
+
+    isHiding = true;
+
+    final Animator.AnimatorListener listener = new Animator.AnimatorListener() {
+      @Override
+      public void onAnimationEnd(Animator animator) {
+        tearDown(false);
+      }
+
+      @Override
+      public void onAnimationCancel(Animator animator) {
+        tearDown(false);
+      }
+
+      @Override
+      public void onAnimationStart(Animator animator) {}
+      @Override
+      public void onAnimationRepeat(Animator animator) {}
+    };
+
+    Handler mainHandler = new Handler(c.getMainLooper());
+
+    mainHandler.post(new Runnable() {
+      @Override
+      public void run() {
+        if (spinnerBar != null) {
+          spinnerBar.setAlpha(1f);
+
+          spinnerBar.animate()
+                  .alpha(0)
+                  .setInterpolator(new LinearInterpolator())
+                  .setDuration(fadeOutDuration)
+                  .start();
+        }
+
+        splashImage.setAlpha(1f);
+
+        splashImage.animate()
+                .alpha(0)
+                .setInterpolator(new LinearInterpolator())
+                .setDuration(fadeOutDuration)
+                .setListener(listener)
+                .start();
+      }
+    });
+  }
+
+  private static void tearDown(boolean removeSpinner) {
+    if (spinnerBar != null && spinnerBar.getParent() != null) {
+      spinnerBar.setVisibility(View.INVISIBLE);
+
+      if (removeSpinner == true) {
+        wm.removeView(spinnerBar);
+      }
+    }
+
+    if (splashImage != null && splashImage.getParent() != null) {
+      splashImage.setVisibility(View.INVISIBLE);
+
+      wm.removeView(splashImage);
+    }
+
+    isHiding = false;
+    isVisible = false;
+  }
+
+  public static void onPause() {
+    tearDown(true);
+  }
+  public static void onDestroy() {
+    tearDown(true);
+  }
+}

+ 192 - 0
android/capacitor/src/main/java/com/getcapacitor/UriMatcher.java

@@ -0,0 +1,192 @@
+/*
+ * 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<UriMatcher>();
+    mText = null;
+  }
+
+  private UriMatcher() {
+    mCode = null;
+    mWhich = -1;
+    mChildren = new ArrayList<UriMatcher>();
+    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.
+   * <p>
+   * 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.length() > 0 && 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<UriMatcher> 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<String> 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<UriMatcher> 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<UriMatcher> mChildren;
+}

+ 542 - 0
android/capacitor/src/main/java/com/getcapacitor/WebViewLocalServer.java

@@ -0,0 +1,542 @@
+/*
+Copyright 2015 Google Inc. All rights reserved.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+ */
+package com.getcapacitor;
+
+import android.content.Context;
+import android.net.Uri;
+import android.webkit.CookieManager;
+import android.webkit.WebResourceRequest;
+import android.webkit.WebResourceResponse;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.HttpURLConnection;
+import java.net.SocketTimeoutException;
+import java.net.URL;
+import java.net.URLConnection;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * Helper class meant to be used with the android.webkit.WebView class to enable hosting assets,
+ * resources and other data on 'virtual' https:// URL.
+ * Hosting assets and resources on https:// URLs is desirable as it is compatible with the
+ * Same-Origin policy.
+ * <p>
+ * 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 final static String capacitorFileStart = Bridge.CAPACITOR_FILE_START;
+  private final static String capacitorContentStart = Bridge.CAPACITOR_CONTENT_START;
+  private String basePath;
+
+  private final UriMatcher uriMatcher;
+  private final AndroidProtocolHandler protocolHandler;
+  private final ArrayList<String> 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.
+   * <p>
+   * Methods of this handler will be invoked on a background thread and care must be taken to
+   * correctly synchronize access to any shared state.
+   * <p>
+   * 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<String, String> responseHeaders;
+
+    public PathHandler() {
+      this(null, null, 200, "OK", null);
+    }
+
+    public PathHandler(String encoding, String charset, int statusCode,
+                       String reasonPhrase, Map<String, String> responseHeaders) {
+      this.encoding = encoding;
+      this.charset = charset;
+      this.statusCode = statusCode;
+      this.reasonPhrase = reasonPhrase;
+      Map<String, String> 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());
+    }
+
+    abstract public 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<String, String> getResponseHeaders() {
+      return responseHeaders;
+    }
+  }
+
+  WebViewLocalServer(Context context, Bridge bridge, JSInjector jsInjector, ArrayList<String> 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.length() == 0) {
+      Logger.error("URL does not have a path: " + url);
+      return null;
+    }
+    return uri;
+  }
+
+  /**
+   * Attempt to retrieve the WebResourceResponse associated with the given <code>request</code>.
+   * 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();
+    PathHandler handler;
+    synchronized (uriMatcher) {
+      handler = (PathHandler) uriMatcher.match(request.getUrl());
+    }
+    if (handler == null) {
+      return null;
+    }
+
+    if (isLocalFile(loadingUrl) || loadingUrl.getHost().equalsIgnoreCase(bridge.getHost()) || (bridge.getServerUrl() == null && !bridge.getAppAllowNavigationMask().matches(loadingUrl.getHost()))) {
+      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();
+    if (path.startsWith(capacitorContentStart) || path.startsWith(capacitorFileStart)) {
+      return true;
+    }
+    return false;
+  }
+
+  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<String, String> 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())) {
+      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 (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);
+
+      bridge.reset();
+      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("."), path.length());
+
+      InputStream responseStream = new LollipopLazyInputStream(handler, request);
+
+      // TODO: Conjure up a bit more subtlety than this
+      if (ext.equals(".html")) {
+        responseStream = jsInjector.getInjectedStream(responseStream);
+        bridge.reset();
+      }
+
+      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<String, String> headers = request.getRequestHeaders();
+        boolean isHtmlText = false;
+        for (Map.Entry<String, String> 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<String, String> 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);
+          String cookie = conn.getHeaderField("Set-Cookie");
+          if (cookie != null) {
+            CookieManager.getInstance().setCookie(url, cookie);
+          }
+          InputStream responseStream = conn.getInputStream();
+          responseStream = jsInjector.getInjectedStream(responseStream);
+          bridge.reset();
+          return new WebResourceResponse("text/html", handler.getEncoding(),
+                  handler.getStatusCode(), handler.getReasonPhrase(), handler.getResponseHeaders(), responseStream);
+        }
+      } catch (SocketTimeoutException ex) {
+        bridge.handleAppUrlLoadError(ex);
+      } 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 <code>uri</code>. The <code>handler</code> will be invoked
+   * every time the <code>shouldInterceptRequest</code> method of the instance is called with
+   * a matching <code>uri</code>.
+   *
+   * @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
+   * <code>assetPath/...</code> will be available under
+   * <code>https://{uuid}.androidplatform.net/assets/...</code>.
+   *
+   * @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
+   * <code>basePath/...</code> will be available under
+   * <code>https://{uuid}.androidplatform.net/...</code>.
+   *
+   * @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();
+        try {
+          if (path.startsWith(capacitorContentStart)) {
+            stream = protocolHandler.openContentUrl(url);
+          } else if (path.startsWith(capacitorFileStart) || !isAsset) {
+            if (!path.startsWith(capacitorFileStart)) {
+              path = basePath + url.getPath();
+            }
+            stream = protocolHandler.openFile(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 static abstract 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;
+  }
+}

+ 38 - 0
android/capacitor/src/main/java/com/getcapacitor/cordova/CapacitorCordovaCookieManager.java

@@ -0,0 +1,38 @@
+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.setAcceptFileSchemeCookies(true);
+    cookieManager.setAcceptThirdPartyCookies(webView, true);
+  }
+
+  public void setCookiesEnabled(boolean accept) {
+    cookieManager.setAcceptCookie(accept);
+  }
+
+  public void setCookie(final String url, final String value) {
+    cookieManager.setCookie(url, value);
+  }
+
+  public String getCookie(final String url) {
+    return cookieManager.getCookie(url);
+  }
+
+  public void clearCookies() {
+    cookieManager.removeAllCookie();
+  }
+
+  public void flush() {
+    cookieManager.flush();
+  }
+};

+ 18 - 0
android/capacitor/src/main/java/com/getcapacitor/cordova/MockCordovaInterfaceImpl.java

@@ -0,0 +1,18 @@
+package com.getcapacitor.cordova;
+
+import android.app.Activity;
+
+import org.apache.cordova.CordovaInterfaceImpl;
+import org.apache.cordova.CordovaPlugin;
+
+import java.util.concurrent.Executors;
+
+public class MockCordovaInterfaceImpl extends CordovaInterfaceImpl {
+  public MockCordovaInterfaceImpl(Activity activity) {
+    super(activity, Executors.newCachedThreadPool());
+  }
+
+  public CordovaPlugin getActivityResultCallback(){
+    return this.activityResultCallback;
+  }
+}

+ 305 - 0
android/capacitor/src/main/java/com/getcapacitor/cordova/MockCordovaWebViewImpl.java

@@ -0,0 +1,305 @@
+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 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;
+
+
+
+import java.util.List;
+import java.util.Map;
+
+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<PluginEntry> 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<PluginEntry> 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(new Runnable() {
+        public void run() {
+          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() {
+
+  }
+
+  @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();
+  }
+
+  @Override
+  public void sendJavascript(String statememt) {
+    nativeToJsMessageQueue.addJavaScript(statememt);
+  }
+
+  public void eval(final String js, final ValueCallback<String> callback) {
+    Handler mainHandler = new Handler(context.getMainLooper());
+    mainHandler.post(new Runnable() {
+      @Override
+      public void run() {
+        webView.evaluateJavascript(js, callback);
+      }
+    });
+  }
+
+  public void triggerDocumentEvent(final String eventName) {
+    eval("window.Capacitor.triggerEvent('" + eventName + "', 'document');", new ValueCallback<String>() {
+      @Override
+      public void onReceiveValue(String s) {
+      }
+    });
+  }
+
+  @Override
+  public void showWebPage(String url, boolean openExternal, boolean clearHistory, Map<String, Object> params) {
+
+  }
+
+  @Override
+  public boolean isCustomViewShowing() {
+    return false;
+  }
+
+  @Override
+  public void showCustomView(View view, WebChromeClient.CustomViewCallback callback) {
+
+  }
+
+  @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();
+    }
+  }
+}

+ 66 - 0
android/capacitor/src/main/java/com/getcapacitor/plugin/Accessibility.java

@@ -0,0 +1,66 @@
+package com.getcapacitor.plugin;
+
+import android.speech.tts.TextToSpeech;
+import android.view.accessibility.AccessibilityManager;
+import com.getcapacitor.JSObject;
+import com.getcapacitor.Logger;
+import com.getcapacitor.NativePlugin;
+import com.getcapacitor.Plugin;
+import com.getcapacitor.PluginCall;
+import com.getcapacitor.PluginMethod;
+
+import java.util.Locale;
+
+import static android.content.Context.ACCESSIBILITY_SERVICE;
+
+@NativePlugin()
+public class Accessibility extends Plugin {
+  private static final String EVENT_SCREEN_READER_STATE_CHANGE = "accessibilityScreenReaderStateChange";
+
+  private TextToSpeech tts;
+  private AccessibilityManager am;
+
+  public void load() {
+    am = (AccessibilityManager) getContext().getSystemService(ACCESSIBILITY_SERVICE);
+
+    am.addTouchExplorationStateChangeListener(new AccessibilityManager.TouchExplorationStateChangeListener() {
+      @Override
+      public void onTouchExplorationStateChanged(boolean b) {
+        JSObject ret = new JSObject();
+        ret.put("value", b);
+        notifyListeners(EVENT_SCREEN_READER_STATE_CHANGE, ret);
+      }
+    });
+  }
+
+  @PluginMethod()
+  public void isScreenReaderEnabled(PluginCall call) {
+    Logger.debug(getLogTag(), "Checking for screen reader");
+    Logger.debug(getLogTag(), "Is it enabled? " + am.isTouchExplorationEnabled());
+
+    JSObject ret = new JSObject();
+    ret.put("value", am.isTouchExplorationEnabled());
+    call.success(ret);
+  }
+
+  @PluginMethod()
+  public void speak(PluginCall call) {
+    final String value = call.getString("value");
+    final String language = call.getString("language", "en");
+    final Locale locale = Locale.forLanguageTag(language);
+
+    if (locale == null) {
+      call.error("Language was not a valid language tag.");
+      return;
+    }
+
+    tts = new TextToSpeech(getContext(), new TextToSpeech.OnInitListener() {
+      @Override
+      public void onInit(int i) {
+        tts.setLanguage(locale);
+        tts.speak(value, TextToSpeech.QUEUE_FLUSH, null, "capacitoraccessibility" + System.currentTimeMillis());
+      }
+    });
+  }
+
+}

+ 148 - 0
android/capacitor/src/main/java/com/getcapacitor/plugin/App.java

@@ -0,0 +1,148 @@
+package com.getcapacitor.plugin;
+
+import android.content.Context;
+import android.content.Intent;
+import android.content.pm.PackageManager;
+import android.net.Uri;
+import com.getcapacitor.JSObject;
+import com.getcapacitor.Logger;
+import com.getcapacitor.NativePlugin;
+import com.getcapacitor.Plugin;
+import com.getcapacitor.PluginCall;
+import com.getcapacitor.PluginMethod;
+import com.getcapacitor.PluginResult;
+
+@NativePlugin()
+public class App extends Plugin {
+
+  private static final String EVENT_BACK_BUTTON = "backButton";
+  private static final String EVENT_URL_OPEN = "appUrlOpen";
+  private static final String EVENT_STATE_CHANGE = "appStateChange";
+  private static final String EVENT_RESTORED_RESULT = "appRestoredResult";
+  private boolean isActive = false;
+
+  public void fireChange(boolean isActive) {
+    Logger.debug(getLogTag(), "Firing change: " + isActive);
+    JSObject data = new JSObject();
+    data.put("isActive", isActive);
+    this.isActive = isActive;
+    notifyListeners(EVENT_STATE_CHANGE, data, false);
+  }
+
+  public void fireRestoredResult(PluginResult result) {
+    Logger.debug(getLogTag(), "Firing restored result");
+    notifyListeners(EVENT_RESTORED_RESULT, result.getWrappedResult(), true);
+  }
+
+  public void fireBackButton() {
+    notifyListeners(EVENT_BACK_BUTTON, new JSObject(), true);
+
+    // For Cordova compat, emit the backbutton event
+    bridge.triggerJSEvent("backbutton", "document");
+  }
+
+  public boolean hasBackButtonListeners() {
+    return hasListeners(EVENT_BACK_BUTTON);
+  }
+
+  @PluginMethod()
+  public void exitApp(PluginCall call) {
+    getBridge().getActivity().finish();
+  }
+
+  @PluginMethod()
+  public void getLaunchUrl(PluginCall call) {
+    Uri launchUri = bridge.getIntentUri();
+    if (launchUri != null) {
+      JSObject d = new JSObject();
+      d.put("url", launchUri.toString());
+      call.success(d);
+    } else {
+      call.success();
+    }
+  }
+
+  @PluginMethod()
+  public void getState(PluginCall call) {
+    JSObject data = new JSObject();
+    data.put("isActive", this.isActive);
+    call.success(data);
+  }
+
+  @PluginMethod()
+  public void canOpenUrl(PluginCall call) {
+    String url = call.getString("url");
+    if (url == null) {
+      call.error("Must supply a url");
+      return;
+    }
+
+    Context ctx = this.getActivity().getApplicationContext();
+    final PackageManager pm = ctx.getPackageManager();
+
+    JSObject ret = new JSObject();
+    try {
+      pm.getPackageInfo(url, PackageManager.GET_ACTIVITIES);
+      ret.put("value", true);
+      call.success(ret);
+      return;
+    } catch(PackageManager.NameNotFoundException e) {
+      Logger.error(getLogTag(), "Package name '"+url+"' not found!", null);
+    }
+
+    ret.put("value", false);
+    call.success(ret);
+  }
+
+  @PluginMethod()
+  public void openUrl(PluginCall call) {
+    String url = call.getString("url");
+    if (url == null) {
+      call.error("Must provide a url to open");
+      return;
+    }
+
+    JSObject ret = new JSObject();
+    final PackageManager manager = getContext().getPackageManager();
+    Intent launchIntent = new Intent(Intent.ACTION_VIEW);
+    launchIntent.setData(Uri.parse(url));
+
+    try {
+      getActivity().startActivity(launchIntent);
+      ret.put("completed", true);
+    } catch(Exception ex) {
+      launchIntent = manager.getLaunchIntentForPackage(url);
+      try {
+        getActivity().startActivity(launchIntent);
+        ret.put("completed", true);
+      } catch(Exception expgk) {
+        ret.put("completed", false);
+      }
+    }
+    call.success(ret);
+  }
+
+  /**
+   * Handle ACTION_VIEW intents to store a URL that was used to open the app
+   * @param intent
+   */
+  @Override
+  protected void handleOnNewIntent(Intent intent) {
+    super.handleOnNewIntent(intent);
+
+    final String intentString = intent.getDataString();
+
+    // read intent
+    String action = intent.getAction();
+    Uri url = intent.getData();
+
+    if (!Intent.ACTION_VIEW.equals(action) || url == null) {
+      return;
+    }
+
+    JSObject ret = new JSObject();
+    ret.put("url", url.toString());
+    notifyListeners(EVENT_URL_OPEN, ret, true);
+  }
+
+}

+ 167 - 0
android/capacitor/src/main/java/com/getcapacitor/plugin/Browser.java

@@ -0,0 +1,167 @@
+package com.getcapacitor.plugin;
+
+import android.content.ComponentName;
+import android.content.Intent;
+import android.graphics.Color;
+import android.net.Uri;
+import android.os.Bundle;
+
+import androidx.browser.customtabs.CustomTabsCallback;
+import androidx.browser.customtabs.CustomTabsClient;
+import androidx.browser.customtabs.CustomTabsIntent;
+import androidx.browser.customtabs.CustomTabsServiceConnection;
+import androidx.browser.customtabs.CustomTabsSession;
+
+import com.getcapacitor.JSArray;
+import com.getcapacitor.JSObject;
+import com.getcapacitor.Logger;
+import com.getcapacitor.NativePlugin;
+import com.getcapacitor.Plugin;
+import com.getcapacitor.PluginCall;
+import com.getcapacitor.PluginMethod;
+import com.getcapacitor.PluginRequestCodes;
+import org.json.JSONException;
+
+/**
+ * The Browser plugin implements Custom Chrome Tabs. See
+ * https://developer.chrome.com/multidevice/android/customtabs for background
+ * on how this code works.
+ */
+@NativePlugin(requestCodes={PluginRequestCodes.BROWSER_OPEN_CHROME_TAB})
+public class Browser extends Plugin {
+  public static final String CUSTOM_TAB_PACKAGE_NAME = "com.android.chrome";  // Change when in stable
+
+  private CustomTabsClient customTabsClient;
+  private CustomTabsSession currentSession;
+  private boolean fireFinished = false;
+
+  @PluginMethod()
+  public void open(PluginCall call) {
+    String url = call.getString("url");
+    String toolbarColor = call.getString("toolbarColor");
+
+    if (url == null) {
+      call.error("Must provide a URL to open");
+      return;
+    }
+
+    if (url.isEmpty()) {
+      call.error("URL must not be empty");
+      return;
+    }
+
+    CustomTabsIntent.Builder builder = new CustomTabsIntent.Builder(getCustomTabsSession());
+
+    builder.addDefaultShareMenuItem();
+
+    if (toolbarColor != null) {
+      try {
+        builder.setToolbarColor(Color.parseColor(toolbarColor));
+      } catch (IllegalArgumentException ex) {
+        Logger.error(getLogTag(), "Invalid color provided for toolbarColor. Using default", null);
+      }
+    }
+
+    CustomTabsIntent tabsIntent = builder.build();
+    tabsIntent.intent.putExtra(Intent.EXTRA_REFERRER,
+        Uri.parse(Intent.URI_ANDROID_APP_SCHEME + "//" + getContext().getPackageName()));
+    try {
+      tabsIntent.launchUrl(getContext(), Uri.parse(url));
+      call.success();
+    } catch (Exception ex) {
+      call.error(ex.getLocalizedMessage());
+    }
+  }
+
+  @PluginMethod()
+  public void close(PluginCall call) {
+    call.unimplemented();
+  }
+
+
+  @PluginMethod()
+  public void prefetch(PluginCall call) {
+    JSArray urls = call.getArray("urls");
+    if (urls == null || urls.length() == 0) {
+      call.error("Must provide an array of URLs to prefetch");
+      return;
+    }
+
+    CustomTabsSession session = getCustomTabsSession();
+
+    if (session == null) {
+      call.error("Browser session isn't ready yet");
+      return;
+    }
+
+    try {
+      for (String url : urls.<String>toList()) {
+        session.mayLaunchUrl(Uri.parse(url), null, null);
+      }
+    } catch(JSONException ex) {
+      call.error("Unable to process provided urls list. Ensure each item is a string and valid URL", ex);
+      return;
+    }
+  }
+
+  CustomTabsServiceConnection connection = new CustomTabsServiceConnection() {
+    @Override
+    public void onCustomTabsServiceConnected(ComponentName name, CustomTabsClient client) {
+      customTabsClient = client;
+      client.warmup(0);
+    }
+
+    @Override
+    public void onServiceDisconnected(ComponentName name) {
+    }
+  };
+
+  public void load() {
+  }
+
+  protected void handleOnResume() {
+    if (fireFinished) {
+      notifyListeners("browserFinished", new JSObject());
+    }
+    boolean ok = CustomTabsClient.bindCustomTabsService(getContext(), CUSTOM_TAB_PACKAGE_NAME, connection);
+    if (!ok) {
+      Logger.error(getLogTag(), "Error binding to custom tabs service", null);
+    }
+  }
+
+  protected void handleOnPause() {
+    getContext().unbindService(connection);
+  }
+
+  public CustomTabsSession getCustomTabsSession() {
+    if (customTabsClient == null) {
+      return null;
+    }
+
+    if (currentSession == null) {
+      currentSession = customTabsClient.newSession(new CustomTabsCallback(){
+        @Override
+        public void onNavigationEvent(int navigationEvent, Bundle extras) {
+          switch (navigationEvent) {
+            case NAVIGATION_FINISHED:
+              notifyListeners("browserPageLoaded", new JSObject());
+              break;
+            case TAB_HIDDEN:
+              fireFinished = true;
+              break;
+            case TAB_SHOWN:
+              fireFinished = false;
+              break;
+          }
+        }
+      });
+    }
+
+    return currentSession;
+  }
+
+  @Override
+  protected void handleOnActivityResult(int requestCode, int resultCode, Intent data) {
+    super.handleOnActivityResult(requestCode, resultCode, data);
+  }
+}

+ 566 - 0
android/capacitor/src/main/java/com/getcapacitor/plugin/Camera.java

@@ -0,0 +1,566 @@
+package com.getcapacitor.plugin;
+
+import android.Manifest;
+import android.app.Activity;
+import android.content.Intent;
+import android.content.pm.PackageManager;
+import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
+import android.net.Uri;
+import android.os.Bundle;
+import android.provider.MediaStore;
+import android.util.Base64;
+
+import androidx.core.content.FileProvider;
+
+import com.getcapacitor.Dialogs;
+import com.getcapacitor.FileUtils;
+import com.getcapacitor.JSObject;
+import com.getcapacitor.Logger;
+import com.getcapacitor.NativePlugin;
+import com.getcapacitor.Plugin;
+import com.getcapacitor.PluginCall;
+import com.getcapacitor.PluginMethod;
+import com.getcapacitor.PluginRequestCodes;
+import com.getcapacitor.plugin.camera.CameraResultType;
+import com.getcapacitor.plugin.camera.CameraSettings;
+import com.getcapacitor.plugin.camera.CameraSource;
+import com.getcapacitor.plugin.camera.CameraUtils;
+import com.getcapacitor.plugin.camera.ExifWrapper;
+import com.getcapacitor.plugin.camera.ImageUtils;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.io.FileNotFoundException;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.text.SimpleDateFormat;
+import java.util.Date;
+
+/**
+ * The Camera plugin makes it easy to take a photo or have the user select a photo
+ * from their albums.
+ *
+ * On Android, this plugin sends an intent that opens the stock Camera app.
+ *
+ * Adapted from https://developer.android.com/training/camera/photobasics.html
+ */
+@NativePlugin(
+    requestCodes={Camera.REQUEST_IMAGE_CAPTURE, Camera.REQUEST_IMAGE_PICK, Camera.REQUEST_IMAGE_EDIT}
+)
+public class Camera extends Plugin {
+  // Request codes
+  static final int REQUEST_IMAGE_CAPTURE = PluginRequestCodes.CAMERA_IMAGE_CAPTURE;
+  static final int REQUEST_IMAGE_PICK = PluginRequestCodes.CAMERA_IMAGE_PICK;
+  static final int REQUEST_IMAGE_EDIT = PluginRequestCodes.CAMERA_IMAGE_EDIT;
+  // Message constants
+  private static final String INVALID_RESULT_TYPE_ERROR = "Invalid resultType option";
+  private static final String PERMISSION_DENIED_ERROR = "Unable to access camera, user denied permission request";
+  private static final String NO_CAMERA_ERROR = "Device doesn't have a camera available";
+  private static final String NO_CAMERA_ACTIVITY_ERROR = "Unable to resolve camera activity";
+  private static final String IMAGE_FILE_SAVE_ERROR = "Unable to create photo on disk";
+  private static final String IMAGE_PROCESS_NO_FILE_ERROR = "Unable to process image, file not found on disk";
+  private static final String UNABLE_TO_PROCESS_IMAGE = "Unable to process image";
+  private static final String IMAGE_EDIT_ERROR = "Unable to edit image";
+  private static final String IMAGE_GALLERY_SAVE_ERROR = "Unable to save the image in the gallery";
+
+  private String imageFileSavePath;
+  private String imageEditedFileSavePath;
+  private Uri imageFileUri;
+  private boolean isEdited = false;
+
+  private CameraSettings settings = new CameraSettings();
+
+  @PluginMethod()
+  public void getPhoto(PluginCall call) {
+    isEdited = false;
+
+    saveCall(call);
+
+    settings = getSettings(call);
+
+    doShow(call);
+  }
+
+  private void doShow(PluginCall call) {
+    switch (settings.getSource()) {
+      case PROMPT:
+        showPrompt(call);
+        break;
+      case CAMERA:
+        showCamera(call);
+        break;
+      case PHOTOS:
+        showPhotos(call);
+        break;
+      default:
+        showPrompt(call);
+        break;
+    }
+  }
+
+  private void showPrompt(final PluginCall call) {
+    // We have all necessary permissions, open the camera
+    String promptLabelPhoto = call.getString("promptLabelPhoto", "From Photos");
+    String promptLabelPicture = call.getString("promptLabelPicture", "Take Picture");
+
+    JSObject fromPhotos = new JSObject();
+    fromPhotos.put("title", promptLabelPhoto);
+    JSObject takePicture = new JSObject();
+    takePicture.put("title", promptLabelPicture);
+    Object[] options = new Object[] {
+      fromPhotos,
+      takePicture
+    };
+
+    Dialogs.actions(getActivity(), options, new Dialogs.OnSelectListener() {
+      @Override
+      public void onSelect(int index) {
+        if (index == 0) {
+          settings.setSource(CameraSource.PHOTOS);
+          openPhotos(call);
+        } else if (index == 1) {
+          settings.setSource(CameraSource.CAMERA);
+          openCamera(call);
+        }
+      }
+    }, new Dialogs.OnCancelListener() {
+      @Override
+      public void onCancel() {
+        call.error("User cancelled photos app");
+      }
+    });
+  }
+
+  private void showCamera(final PluginCall call) {
+    if (!getActivity().getPackageManager().hasSystemFeature(PackageManager.FEATURE_CAMERA_ANY)) {
+      call.error(NO_CAMERA_ERROR);
+      return;
+    }
+    openCamera(call);
+  }
+
+  private void showPhotos(final PluginCall call) {
+    openPhotos(call);
+  }
+
+  private boolean checkCameraPermissions(PluginCall call) {
+    // If we want to save to the gallery, we need two permissions
+    if(settings.isSaveToGallery() && !(hasPermission(Manifest.permission.CAMERA) && hasPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE))) {
+      pluginRequestPermissions(new String[] {
+        Manifest.permission.CAMERA,
+        Manifest.permission.WRITE_EXTERNAL_STORAGE,
+        Manifest.permission.READ_EXTERNAL_STORAGE
+      }, REQUEST_IMAGE_CAPTURE);
+      return false;
+    }
+    // If we don't need to save to the gallery, we can just ask for camera permissions
+    else if(!hasPermission(Manifest.permission.CAMERA)) {
+      pluginRequestPermission(Manifest.permission.CAMERA, REQUEST_IMAGE_CAPTURE);
+      return false;
+    }
+    return true;
+  }
+
+  private boolean checkPhotosPermissions(PluginCall call) {
+    if(!hasPermission(Manifest.permission.READ_EXTERNAL_STORAGE)) {
+      pluginRequestPermission(Manifest.permission.READ_EXTERNAL_STORAGE, REQUEST_IMAGE_CAPTURE);
+      return false;
+    }
+    return true;
+  }
+
+  private CameraSettings getSettings(PluginCall call) {
+    CameraSettings settings = new CameraSettings();
+    settings.setResultType(getResultType(call.getString("resultType")));
+    settings.setSaveToGallery(call.getBoolean("saveToGallery", CameraSettings.DEFAULT_SAVE_IMAGE_TO_GALLERY));
+    settings.setAllowEditing(call.getBoolean("allowEditing", false));
+    settings.setQuality(call.getInt("quality", CameraSettings.DEFAULT_QUALITY));
+    settings.setWidth(call.getInt("width", 0));
+    settings.setHeight(call.getInt("height", 0));
+    settings.setShouldResize(settings.getWidth() > 0 || settings.getHeight() > 0);
+    settings.setShouldCorrectOrientation(call.getBoolean("correctOrientation", CameraSettings.DEFAULT_CORRECT_ORIENTATION));
+    settings.setPreserveAspectRatio(call.getBoolean("preserveAspectRatio", false));
+    try {
+      settings.setSource(CameraSource.valueOf(call.getString("source", CameraSource.PROMPT.getSource())));
+    } catch (IllegalArgumentException ex) {
+      settings.setSource(CameraSource.PROMPT);
+    }
+    return settings;
+  }
+
+  private CameraResultType getResultType(String resultType) {
+    if (resultType == null) { return null; }
+    try {
+      return CameraResultType.valueOf(resultType.toUpperCase());
+    } catch (IllegalArgumentException ex) {
+      Logger.debug(getLogTag(), "Invalid result type \"" + resultType + "\", defaulting to base64");
+      return CameraResultType.BASE64;
+    }
+  }
+
+  public void openCamera(final PluginCall call) {
+    if (checkCameraPermissions(call)) {
+      Intent takePictureIntent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE);
+      if (takePictureIntent.resolveActivity(getActivity().getPackageManager()) != null) {
+        // If we will be saving the photo, send the target file along
+        try {
+          String appId = getAppId();
+          File photoFile = CameraUtils.createImageFile(getActivity());
+          imageFileSavePath = photoFile.getAbsolutePath();
+          // TODO: Verify provider config exists
+          imageFileUri = FileProvider.getUriForFile(getActivity(), appId + ".fileprovider", photoFile);
+          takePictureIntent.putExtra(MediaStore.EXTRA_OUTPUT, imageFileUri);
+        } catch (Exception ex) {
+          call.error(IMAGE_FILE_SAVE_ERROR, ex);
+          return;
+        }
+
+        startActivityForResult(call, takePictureIntent, REQUEST_IMAGE_CAPTURE);
+      } else {
+        call.error(NO_CAMERA_ACTIVITY_ERROR);
+      }
+    }
+  }
+
+  public void openPhotos(final PluginCall call) {
+    if (checkPhotosPermissions(call)) {
+      Intent intent = new Intent(Intent.ACTION_PICK);
+      intent.setType("image/*");
+      startActivityForResult(call, intent, REQUEST_IMAGE_PICK);
+    }
+  }
+
+  public void processCameraImage(PluginCall call) {
+    if(imageFileSavePath == null) {
+      call.error(IMAGE_PROCESS_NO_FILE_ERROR);
+      return;
+    }
+    // Load the image as a Bitmap
+    File f = new File(imageFileSavePath);
+    BitmapFactory.Options bmOptions = new BitmapFactory.Options();
+    Uri contentUri = Uri.fromFile(f);
+    Bitmap bitmap = BitmapFactory.decodeFile(imageFileSavePath, bmOptions);
+
+    if (bitmap == null) {
+      call.error("User cancelled photos app");
+      return;
+    }
+
+    returnResult(call, bitmap, contentUri);
+  }
+
+  public void processPickedImage(PluginCall call, Intent data) {
+    if (data == null) {
+      call.error("No image picked");
+      return;
+    }
+
+    Uri u = data.getData();
+
+    InputStream imageStream = null;
+
+    try {
+      imageStream = getActivity().getContentResolver().openInputStream(u);
+      Bitmap bitmap = BitmapFactory.decodeStream(imageStream);
+
+      if (bitmap == null) {
+        call.reject("Unable to process bitmap");
+        return;
+      }
+
+      returnResult(call, bitmap, u);
+    } catch (OutOfMemoryError err) {
+      call.error("Out of memory");
+    } catch (FileNotFoundException ex) {
+      call.error("No such image found", ex);
+    } finally {
+      if (imageStream != null) {
+        try {
+          imageStream.close();
+        } catch (IOException e) {
+          Logger.error(getLogTag(), UNABLE_TO_PROCESS_IMAGE, e);
+        }
+      }
+    }
+  }
+
+  /**
+   * Save the modified image we've created to a temporary location, so we can
+   * return a URI to it later
+   * @param bitmap
+   * @param contentUri
+   * @param is
+   * @return
+   * @throws IOException
+   */
+  private Uri saveTemporaryImage(Bitmap bitmap, Uri contentUri, InputStream is) throws IOException {
+    String filename = contentUri.getLastPathSegment();
+    if (!filename.contains(".jpg") && !filename.contains(".jpeg")) {
+      filename += "." + (new java.util.Date()).getTime() + ".jpeg";
+    }
+    File cacheDir = getActivity().getCacheDir();
+    File outFile = new File(cacheDir, filename);
+    FileOutputStream fos = new FileOutputStream(outFile);
+    byte[] buffer = new byte[1024];
+    int len;
+    while ((len = is.read(buffer)) != -1) {
+      fos.write(buffer, 0, len);
+    }
+    fos.close();
+    return Uri.fromFile(outFile);
+  }
+
+  /**
+   * After processing the image, return the final result back to the caller.
+   * @param call
+   * @param bitmap
+   * @param u
+   */
+  private void returnResult(PluginCall call, Bitmap bitmap, Uri u) {
+    try {
+      bitmap = prepareBitmap(bitmap, u);
+    } catch (IOException e) {
+      call.reject(UNABLE_TO_PROCESS_IMAGE);
+      return;
+    }
+
+    ExifWrapper exif = ImageUtils.getExifData(getContext(), bitmap, u);
+
+    // Compress the final image and prepare for output to client
+    ByteArrayOutputStream bitmapOutputStream = new ByteArrayOutputStream();
+    bitmap.compress(Bitmap.CompressFormat.JPEG, settings.getQuality(), bitmapOutputStream);
+
+    if (settings.isAllowEditing() && !isEdited) {
+      editImage(call, bitmap, u, bitmapOutputStream);
+      return;
+    }
+
+    boolean saveToGallery = call.getBoolean("saveToGallery", CameraSettings.DEFAULT_SAVE_IMAGE_TO_GALLERY);
+    if (saveToGallery && (imageEditedFileSavePath != null || imageFileSavePath != null)) {
+      try {
+        String fileToSavePath = imageEditedFileSavePath != null ? imageEditedFileSavePath : imageFileSavePath;
+        File fileToSave = new File(fileToSavePath);
+        MediaStore.Images.Media.insertImage(getActivity().getContentResolver(), fileToSavePath, fileToSave.getName(), "");
+      } catch (FileNotFoundException e) {
+        Logger.error(getLogTag(), IMAGE_GALLERY_SAVE_ERROR, e);
+      }
+    }
+
+    if (settings.getResultType() == CameraResultType.BASE64) {
+      returnBase64(call, exif, bitmapOutputStream);
+    } else if (settings.getResultType() == CameraResultType.URI) {
+      returnFileURI(call, exif, bitmap, u, bitmapOutputStream);
+    } else if (settings.getResultType() == CameraResultType.DATAURL) {
+      returnDataUrl(call, exif, bitmapOutputStream);
+    } else {
+      call.reject(INVALID_RESULT_TYPE_ERROR);
+    }
+
+    // Result returned, clear stored paths
+    imageFileSavePath = null;
+    imageFileUri = null;
+    imageEditedFileSavePath = null;
+  }
+
+  private void returnFileURI(PluginCall call, ExifWrapper exif, Bitmap bitmap, Uri u, ByteArrayOutputStream bitmapOutputStream) {
+    Uri newUri = getTempImage(bitmap, u, bitmapOutputStream);
+    if (newUri != null) {
+      JSObject ret = new JSObject();
+      ret.put("format", "jpeg");
+      ret.put("exif", exif.toJson());
+      ret.put("path", newUri.toString());
+      ret.put("webPath", FileUtils.getPortablePath(getContext(), bridge.getLocalUrl(), newUri));
+      call.resolve(ret);
+    } else {
+      call.reject(UNABLE_TO_PROCESS_IMAGE);
+    }
+  }
+
+  private Uri getTempImage(Bitmap bitmap, Uri u, ByteArrayOutputStream bitmapOutputStream) {
+    ByteArrayInputStream bis = null;
+    Uri newUri = null;
+    try {
+      bis = new ByteArrayInputStream(bitmapOutputStream.toByteArray());
+      newUri = saveTemporaryImage(bitmap, u, bis);
+    } catch (IOException ex) {
+    } finally {
+      if (bis != null) {
+        try {
+          bis.close();
+        } catch (IOException e) {
+          Logger.error(getLogTag(), UNABLE_TO_PROCESS_IMAGE, e);
+        }
+      }
+    }
+    return newUri;
+  }
+
+  /**
+   * Apply our standard processing of the bitmap, returning a new one and
+   * recycling the old one in the process
+   * @param bitmap
+   * @param imageUri
+   * @return
+   */
+  private Bitmap prepareBitmap(Bitmap bitmap, Uri imageUri) throws IOException {
+    if (settings.isShouldCorrectOrientation()) {
+      final Bitmap newBitmap = ImageUtils.correctOrientation(getContext(), bitmap, imageUri);
+      bitmap = replaceBitmap(bitmap, newBitmap);
+    }
+
+    if (settings.isShouldResize()) {
+      final Bitmap newBitmap = ImageUtils.resize(
+          bitmap,
+          settings.getWidth(),
+          settings.getHeight(),
+          settings.getPreserveAspectRatio()
+      );
+      bitmap = replaceBitmap(bitmap, newBitmap);
+    }
+    return bitmap;
+  }
+
+  private Bitmap replaceBitmap(Bitmap bitmap, final Bitmap newBitmap) {
+    if (bitmap != newBitmap) {
+      bitmap.recycle();
+    }
+    bitmap = newBitmap;
+    return bitmap;
+  }
+
+  private void returnDataUrl(PluginCall call, ExifWrapper exif, ByteArrayOutputStream bitmapOutputStream) {
+    byte[] byteArray = bitmapOutputStream.toByteArray();
+    String encoded = Base64.encodeToString(byteArray, Base64.NO_WRAP);
+
+    JSObject data = new JSObject();
+    data.put("format", "jpeg");
+    data.put("dataUrl", "data:image/jpeg;base64," + encoded);
+    data.put("exif", exif.toJson());
+    call.resolve(data);
+  }
+
+  private void returnBase64(PluginCall call, ExifWrapper exif, ByteArrayOutputStream bitmapOutputStream) {
+    byte[] byteArray = bitmapOutputStream.toByteArray();
+    String encoded = Base64.encodeToString(byteArray, Base64.NO_WRAP);
+
+    JSObject data = new JSObject();
+    data.put("format", "jpeg");
+    data.put("base64String", encoded);
+    data.put("exif", exif.toJson());
+    call.resolve(data);
+  }
+
+  @Override
+  protected void handleRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) {
+    super.handleRequestPermissionsResult(requestCode, permissions, grantResults);
+
+    Logger.debug(getLogTag(),"handling request perms result");
+
+    if (getSavedCall() == null) {
+      Logger.debug(getLogTag(),"No stored plugin call for permissions request result");
+      return;
+    }
+
+    PluginCall savedCall = getSavedCall();
+
+    for (int i = 0; i < grantResults.length; i++) {
+      int result = grantResults[i];
+      String perm = permissions[i];
+      if(result == PackageManager.PERMISSION_DENIED) {
+        Logger.debug(getLogTag(), "User denied camera permission: " + perm);
+        savedCall.error(PERMISSION_DENIED_ERROR);
+        return;
+      }
+    }
+
+    if (requestCode == REQUEST_IMAGE_CAPTURE) {
+      doShow(savedCall);
+    }
+  }
+
+  @Override
+  protected void handleOnActivityResult(int requestCode, int resultCode, Intent data) {
+    super.handleOnActivityResult(requestCode, resultCode, data);
+
+    PluginCall savedCall = getSavedCall();
+
+    if (savedCall == null) {
+      return;
+    }
+
+    settings = getSettings(savedCall);
+
+    if (requestCode == REQUEST_IMAGE_CAPTURE) {
+      processCameraImage(savedCall);
+    } else if (requestCode == REQUEST_IMAGE_PICK) {
+      processPickedImage(savedCall, data);
+    } else if (requestCode == REQUEST_IMAGE_EDIT && resultCode == Activity.RESULT_OK) {
+      isEdited = true;
+      processPickedImage(savedCall, data);
+    } else if (resultCode == Activity.RESULT_CANCELED && imageFileSavePath != null) {
+      imageEditedFileSavePath = null;
+      isEdited = true;
+      processCameraImage(savedCall);
+    }
+  }
+
+  private void editImage(PluginCall call, Bitmap bitmap, Uri uri, ByteArrayOutputStream bitmapOutputStream) {
+    Uri origPhotoUri = uri;
+    if (imageFileUri != null) {
+      origPhotoUri = imageFileUri;
+    }
+    try {
+      Intent editIntent = createEditIntent(origPhotoUri, false);
+      startActivityForResult(call, editIntent, REQUEST_IMAGE_EDIT);
+    } catch (SecurityException ex) {
+      Uri tempImage = getTempImage(bitmap, uri, bitmapOutputStream);
+      Intent editIntent = createEditIntent(tempImage, true);
+      if (editIntent != null) {
+        startActivityForResult(call, editIntent, REQUEST_IMAGE_EDIT);
+      } else {
+        call.error(IMAGE_EDIT_ERROR);
+      }
+    } catch (Exception ex) {
+      call.error(IMAGE_EDIT_ERROR, ex);
+    }
+  }
+
+  private Intent createEditIntent(Uri origPhotoUri, boolean expose) {
+    Uri editUri = origPhotoUri;
+    try {
+      if (expose) {
+        editUri = FileProvider.getUriForFile(getActivity(), getContext().getPackageName() + ".fileprovider", new File(origPhotoUri.getPath()));
+      }
+      Intent editIntent = new Intent(Intent.ACTION_EDIT);
+      editIntent.setDataAndType(editUri, "image/*");
+      File editedFile = CameraUtils.createImageFile(getActivity());
+      imageEditedFileSavePath = editedFile.getAbsolutePath();
+      Uri editedUri = Uri.fromFile(editedFile);
+      editIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
+      editIntent.addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
+      editIntent.putExtra(MediaStore.EXTRA_OUTPUT, editedUri);
+      return editIntent;
+    } catch (Exception ex) {
+      return null;
+    }
+  }
+
+  @Override
+  protected Bundle saveInstanceState() {
+    Bundle bundle = super.saveInstanceState();
+    bundle.putString("cameraImageFileSavePath", imageFileSavePath);
+    return bundle;
+  }
+
+  @Override
+  protected void restoreState(Bundle state) {
+    String storedImageFileSavePath = state.getString("cameraImageFileSavePath");
+    if (storedImageFileSavePath != null) {
+      imageFileSavePath = storedImageFileSavePath;
+    }
+  }
+
+}

+ 75 - 0
android/capacitor/src/main/java/com/getcapacitor/plugin/Clipboard.java

@@ -0,0 +1,75 @@
+package com.getcapacitor.plugin;
+
+import android.content.ClipData;
+import android.content.ClipDescription;
+import android.content.ClipboardManager;
+import android.content.Context;
+import com.getcapacitor.JSObject;
+import com.getcapacitor.Logger;
+import com.getcapacitor.NativePlugin;
+import com.getcapacitor.Plugin;
+import com.getcapacitor.PluginCall;
+import com.getcapacitor.PluginMethod;
+
+@NativePlugin()
+public class Clipboard extends Plugin {
+
+  @PluginMethod()
+  public void write(PluginCall call) {
+    String strVal = call.getString("string");
+    String imageVal = call.getString("image");
+    String urlVal = call.getString("url");
+    String label = call.getString("label");
+
+    Context c = getContext();
+    ClipboardManager clipboard = (ClipboardManager)
+        c.getSystemService(Context.CLIPBOARD_SERVICE);
+
+    ClipData data = null;
+    if(strVal != null) {
+      data = ClipData.newPlainText(label, strVal);
+    } else if(imageVal != null) {
+      data = ClipData.newPlainText(label, imageVal);
+    } else if(urlVal != null) {
+      data = ClipData.newPlainText(label, urlVal);
+    }
+
+    if(data != null) {
+      clipboard.setPrimaryClip(data);
+    }
+
+    call.success();
+  }
+
+  @PluginMethod()
+  public void read(PluginCall call) {
+    Context c = this.getContext();
+
+    ClipboardManager clipboard = (ClipboardManager)
+        c.getSystemService(Context.CLIPBOARD_SERVICE);
+
+    CharSequence value = null;
+
+    if (clipboard.hasPrimaryClip()) {
+      if(clipboard.getPrimaryClipDescription().hasMimeType(ClipDescription.MIMETYPE_TEXT_PLAIN)) {
+        Logger.debug(getLogTag(), "Got plaintxt");
+        ClipData.Item item = clipboard.getPrimaryClip().getItemAt(0);
+        value = item.getText();
+      } else {
+        Logger.debug(getLogTag(), "Not plaintext!");
+        ClipData.Item item = clipboard.getPrimaryClip().getItemAt(0);
+        value = item.coerceToText(this.getContext()).toString();
+      }
+    }
+
+    JSObject ret = new JSObject();
+    String type = "text/plain";
+    ret.put("value", value != null ? value : "");
+    if (value != null && value.toString().startsWith("data:")) {
+      type = value.toString().split(";")[0].split(":")[1];
+    }
+    ret.put("type", type);
+
+    call.success(ret);
+  }
+}

+ 154 - 0
android/capacitor/src/main/java/com/getcapacitor/plugin/Device.java

@@ -0,0 +1,154 @@
+package com.getcapacitor.plugin;
+
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.content.pm.PackageInfo;
+import android.content.pm.ApplicationInfo;
+import android.os.BatteryManager;
+import android.os.Environment;
+import android.os.StatFs;
+import android.provider.Settings;
+
+import com.getcapacitor.JSObject;
+import com.getcapacitor.NativePlugin;
+import com.getcapacitor.Plugin;
+import com.getcapacitor.PluginCall;
+import com.getcapacitor.PluginMethod;
+
+import java.util.Locale;
+
+
+@NativePlugin()
+public class Device extends Plugin {
+
+  @PluginMethod()
+  public void getInfo(PluginCall call) {
+    JSObject r = new JSObject();
+
+    r.put("memUsed", getMemUsed());
+    r.put("diskFree", getDiskFree());
+    r.put("diskTotal", getDiskTotal());
+    r.put("model", android.os.Build.MODEL);
+    r.put("operatingSystem", "android");
+    r.put("osVersion", android.os.Build.VERSION.RELEASE);
+    r.put("appVersion", getAppVersion());
+    r.put("appBuild", getAppBuild());
+    r.put("appId", getAppBundleId());
+    r.put("appName", getAppName());
+    r.put("platform", getPlatform());
+    r.put("manufacturer", android.os.Build.MANUFACTURER);
+    r.put("uuid", getUuid());
+    r.put("isVirtual", isVirtual());
+
+    call.success(r);
+  }
+
+  @PluginMethod()
+  public void getBatteryInfo(PluginCall call) {
+    JSObject r = new JSObject();
+
+    r.put("batteryLevel", getBatteryLevel());
+    r.put("isCharging", isCharging());
+
+    call.success(r);
+  }
+
+  @PluginMethod()
+  public void getLanguageCode(PluginCall call) {
+    JSObject ret = new JSObject();
+    ret.put("value", Locale.getDefault().getLanguage());
+    call.success(ret);
+  }
+
+  private long getMemUsed() {
+    final Runtime runtime = Runtime.getRuntime();
+    final long usedMem = (runtime.totalMemory() - runtime.freeMemory());
+    return usedMem;
+  }
+
+  private long getDiskFree() {
+    StatFs statFs = new StatFs(Environment.getRootDirectory().getAbsolutePath());
+    return statFs.getAvailableBlocksLong() * statFs.getBlockSizeLong();
+  }
+
+  private long getDiskTotal() {
+    StatFs statFs = new StatFs(Environment.getRootDirectory().getAbsolutePath());
+    return statFs.getBlockCountLong() * statFs.getBlockSizeLong();
+  }
+
+  private String getAppVersion() {
+    try {
+      PackageInfo pinfo = getContext().getPackageManager().getPackageInfo(getContext().getPackageName(), 0);
+      return pinfo.versionName;
+    } catch(Exception ex) {
+      return "";
+    }
+  }
+
+  private String getAppBuild() {
+    try {
+      PackageInfo pinfo = getContext().getPackageManager().getPackageInfo(getContext().getPackageName(), 0);
+      return Integer.toString(pinfo.versionCode);
+    } catch(Exception ex) {
+      return "";
+    }
+  }
+
+  private String getAppBundleId() {
+    try {
+      PackageInfo pinfo = getContext().getPackageManager().getPackageInfo(getContext().getPackageName(), 0);
+      return pinfo.packageName;
+    } catch(Exception ex) {
+      return "";
+    }
+  }
+
+  private String getAppName() {
+    try {
+      ApplicationInfo applicationInfo = getContext().getApplicationInfo();
+      int stringId = applicationInfo.labelRes;
+      return stringId == 0 ? applicationInfo.nonLocalizedLabel.toString() : getContext().getString(stringId);
+    } catch(Exception ex) {
+      return "";
+    }
+  }
+
+  private String getPlatform() {
+    return "android";
+  }
+
+  private String getUuid() {
+    return Settings.Secure.getString(this.bridge.getContext().getContentResolver(), android.provider.Settings.Secure.ANDROID_ID);
+  }
+
+  private float getBatteryLevel() {
+    IntentFilter ifilter = new IntentFilter(Intent.ACTION_BATTERY_CHANGED);
+    Intent batteryStatus = getContext().registerReceiver(null, ifilter);
+
+    int level = -1;
+    int scale = -1;
+
+    if (batteryStatus != null) {
+      level = batteryStatus.getIntExtra(BatteryManager.EXTRA_LEVEL, -1);
+      scale = batteryStatus.getIntExtra(BatteryManager.EXTRA_SCALE, -1);
+    }
+
+    return level / (float) scale;
+  }
+
+  private boolean isCharging() {
+    IntentFilter ifilter = new IntentFilter(Intent.ACTION_BATTERY_CHANGED);
+    Intent batteryStatus = getContext().registerReceiver(null, ifilter);
+
+    if (batteryStatus != null) {
+      int status = batteryStatus.getIntExtra(BatteryManager.EXTRA_STATUS, -1);
+      return status == BatteryManager.BATTERY_STATUS_CHARGING || status == BatteryManager.BATTERY_STATUS_FULL;
+    }
+    return false;
+  }
+
+  private boolean isVirtual() {
+    return android.os.Build.FINGERPRINT.contains("generic") || android.os.Build.PRODUCT.contains("sdk");
+  }
+
+}

+ 694 - 0
android/capacitor/src/main/java/com/getcapacitor/plugin/Filesystem.java

@@ -0,0 +1,694 @@
+package com.getcapacitor.plugin;
+
+import android.Manifest;
+import android.content.Context;
+import android.content.pm.PackageManager;
+import android.media.MediaScannerConnection;
+import android.net.Uri;
+import android.os.Environment;
+import android.util.Base64;
+import com.getcapacitor.JSArray;
+import com.getcapacitor.JSObject;
+import com.getcapacitor.Logger;
+import com.getcapacitor.NativePlugin;
+import com.getcapacitor.Plugin;
+import com.getcapacitor.PluginCall;
+import com.getcapacitor.PluginMethod;
+import com.getcapacitor.PluginRequestCodes;
+import org.json.JSONException;
+
+import java.io.BufferedWriter;
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileNotFoundException;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStreamWriter;
+import java.nio.channels.FileChannel;
+import java.nio.charset.Charset;
+import java.nio.charset.StandardCharsets;
+
+@NativePlugin(
+    requestCodes = {
+      PluginRequestCodes.FILESYSTEM_REQUEST_WRITE_FILE_PERMISSIONS,
+      PluginRequestCodes.FILESYSTEM_REQUEST_WRITE_FOLDER_PERMISSIONS,
+      PluginRequestCodes.FILESYSTEM_REQUEST_READ_FILE_PERMISSIONS,
+      PluginRequestCodes.FILESYSTEM_REQUEST_READ_FOLDER_PERMISSIONS,
+      PluginRequestCodes.FILESYSTEM_REQUEST_DELETE_FILE_PERMISSIONS,
+      PluginRequestCodes.FILESYSTEM_REQUEST_DELETE_FOLDER_PERMISSIONS,
+      PluginRequestCodes.FILESYSTEM_REQUEST_URI_PERMISSIONS,
+      PluginRequestCodes.FILESYSTEM_REQUEST_STAT_PERMISSIONS,
+      PluginRequestCodes.FILESYSTEM_REQUEST_RENAME_PERMISSIONS,
+      PluginRequestCodes.FILESYSTEM_REQUEST_COPY_PERMISSIONS,
+    },
+    permissions={
+      Manifest.permission.READ_EXTERNAL_STORAGE,
+      Manifest.permission.WRITE_EXTERNAL_STORAGE
+    },
+    permissionRequestCode = PluginRequestCodes.FILESYSTEM_REQUEST_ALL_PERMISSIONS
+)
+public class Filesystem extends Plugin {
+
+  private static final String PERMISSION_DENIED_ERROR = "Unable to do file operation, user denied permission request";
+
+  private Charset getEncoding(String encoding) {
+    if (encoding == null) {
+      return null;
+    }
+
+    switch(encoding) {
+      case "utf8":
+        return StandardCharsets.UTF_8;
+      case "utf16":
+        return StandardCharsets.UTF_16;
+      case "ascii":
+        return StandardCharsets.US_ASCII;
+    }
+    return null;
+  }
+
+  private File getDirectory(String directory) {
+    Context c = bridge.getContext();
+    switch(directory) {
+      case "DOCUMENTS":
+        return Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOCUMENTS);
+      case "DATA":
+        return c.getFilesDir();
+      case "CACHE":
+        return c.getCacheDir();
+      case "EXTERNAL":
+        return c.getExternalFilesDir(null);
+      case "EXTERNAL_STORAGE":
+        return Environment.getExternalStorageDirectory();
+    }
+    return null;
+  }
+
+  private File getFileObject(String path, String directory) {
+    if (directory == null) {
+      Uri u = Uri.parse(path);
+      if (u.getScheme() == null || u.getScheme().equals("file")) {
+        return new File(u.getPath());
+      }
+    }
+
+    File androidDirectory = this.getDirectory(directory);
+
+    if (androidDirectory == null) {
+      return null;
+    } else {
+      if(!androidDirectory.exists()) {
+        androidDirectory.mkdir();
+      }
+    }
+
+    return new File(androidDirectory, path);
+  }
+
+  private InputStream getInputStream(String path, String directory) throws IOException {
+    if (directory == null) {
+      Uri u = Uri.parse(path);
+      if (u.getScheme().equals("content")) {
+        return getContext().getContentResolver().openInputStream(u);
+      } else {
+        return new FileInputStream(new File(u.getPath()));
+      }
+    }
+
+    File androidDirectory = this.getDirectory(directory);
+
+    if (androidDirectory == null) {
+      throw new IOException("Directory not found");
+    }
+
+    return new FileInputStream(new File(androidDirectory, path));
+  }
+
+  private String readFileAsString(InputStream is, String encoding) throws IOException {
+    ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
+
+    byte[] buffer = new byte[1024];
+    int length = 0;
+
+    while ((length = is.read(buffer)) != -1) {
+      outputStream.write(buffer, 0, length);
+    };
+
+    return outputStream.toString(encoding);
+  }
+
+  private String readFileAsBase64EncodedData(InputStream is) throws IOException {
+    FileInputStream fileInputStreamReader = (FileInputStream) is;
+    ByteArrayOutputStream byteStream = new ByteArrayOutputStream();
+
+    byte[] buffer = new byte[1024];
+
+    int c;
+    while ((c = fileInputStreamReader.read(buffer)) != -1) {
+      byteStream.write(buffer, 0, c);
+    }
+    fileInputStreamReader.close();
+
+    return new String(Base64.encodeToString(byteStream.toByteArray(), Base64.NO_WRAP));
+  }
+
+  @PluginMethod()
+  public void readFile(PluginCall call) {
+    saveCall(call);
+    String file = call.getString("path");
+    String data = call.getString("data");
+    String directory = getDirectoryParameter(call);
+    String encoding = call.getString("encoding");
+
+    Charset charset = this.getEncoding(encoding);
+    if(encoding != null && charset == null) {
+      call.error("Unsupported encoding provided: " + encoding);
+      return;
+    }
+
+    if (!isPublicDirectory(directory)
+        || isStoragePermissionGranted(PluginRequestCodes.FILESYSTEM_REQUEST_READ_FILE_PERMISSIONS, Manifest.permission.READ_EXTERNAL_STORAGE)) {
+        try {
+          InputStream is = getInputStream(file, directory);
+          String dataStr;
+          if (charset != null) {
+            dataStr = readFileAsString(is, charset.name());
+          } else {
+            dataStr = readFileAsBase64EncodedData(is);
+          }
+
+          JSObject ret = new JSObject();
+          ret.putOpt("data", dataStr);
+          call.success(ret);
+        } catch (FileNotFoundException ex) {
+          call.error("File does not exist", ex);
+        } catch (IOException ex) {
+          call.error("Unable to read file", ex);
+        } catch(JSONException ex) {
+          call.error("Unable to return value for reading file", ex);
+        }
+    }
+  }
+
+  @PluginMethod()
+  public void writeFile(PluginCall call) {
+    saveCall(call);
+    String path = call.getString("path");
+    String data = call.getString("data");
+    Boolean recursive = call.getBoolean("recursive", false);
+
+    if (path == null) {
+      Logger.error(getLogTag(), "No path or filename retrieved from call", null);
+      call.error("NO_PATH");
+      return;
+    }
+
+    if (data == null) {
+      Logger.error(getLogTag(), "No data retrieved from call", null);
+      call.error("NO_DATA");
+      return;
+    }
+
+    String directory = getDirectoryParameter(call);
+    if (directory != null) {
+      if (!isPublicDirectory(directory)
+        || isStoragePermissionGranted(PluginRequestCodes.FILESYSTEM_REQUEST_WRITE_FILE_PERMISSIONS, Manifest.permission.WRITE_EXTERNAL_STORAGE)) {
+        // create directory because it might not exist
+        File androidDir = getDirectory(directory);
+        if (androidDir != null) {
+          if (androidDir.exists() || androidDir.mkdirs()) {
+            // path might include directories as well
+            File fileObject = new File(androidDir, path);
+            if (fileObject.getParentFile().exists() || (recursive && fileObject.getParentFile().mkdirs())) {
+              saveFile(call, fileObject, data);
+            } else {
+              call.error("Parent folder doesn't exist");
+            }
+          } else {
+            Logger.error(getLogTag(), "Not able to create '" + directory + "'!", null);
+            call.error("NOT_CREATED_DIR");
+          }
+        } else {
+          Logger.error(getLogTag(), "Directory ID '" + directory + "' is not supported by plugin", null);
+          call.error("INVALID_DIR");
+        }
+      }
+    } else {
+      // check if file://
+      Uri u = Uri.parse(path);
+      if ("file".equals(u.getScheme())) {
+        File fileObject = new File(u.getPath());
+        // do not know where the file is being store so checking the permission to be secure
+        // TODO to prevent permission checking we need a property from the call
+        if (isStoragePermissionGranted(PluginRequestCodes.FILESYSTEM_REQUEST_WRITE_FILE_PERMISSIONS, Manifest.permission.WRITE_EXTERNAL_STORAGE)) {
+          if (fileObject.getParentFile().exists() || (recursive && fileObject.getParentFile().mkdirs())) {
+            saveFile(call, fileObject, data);
+          } else {
+            call.error("Parent folder doesn't exist");
+          }
+        }
+      }
+    }
+  }
+
+  private void saveFile(PluginCall call, File file, String data) {
+    String encoding = call.getString("encoding");
+    boolean append = call.getBoolean("append", false);
+
+    Charset charset = this.getEncoding(encoding);
+    if (encoding != null && charset == null) {
+      call.error("Unsupported encoding provided: " + encoding);
+      return;
+    }
+
+    // if charset is not null assume its a plain text file the user wants to save
+    boolean success = false;
+    if (charset != null) {
+      try (BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(
+        new FileOutputStream(file, append), charset))) {
+        writer.write(data);
+        success = true;
+      } catch (IOException e) {
+        Logger.error(getLogTag(), "Creating text file '" + file.getPath() + "' with charset '" + charset + "' failed. Error: " + e.getMessage(), e);
+      }
+    } else {
+      //remove header from dataURL
+      if(data.indexOf(",") != -1) {
+        data = data.split(",")[1];
+      }
+      try (FileOutputStream fos = new FileOutputStream(file, append)) {
+        fos.write(Base64.decode(data, Base64.NO_WRAP));
+        success = true;
+      } catch (IOException e) {
+        Logger.error(getLogTag(), "Creating binary file '" + file.getPath() + "' failed. Error: " + e.getMessage(), e);
+      }
+    }
+
+    if (success) {
+      // update mediaStore index only if file was written to external storage
+      if (isPublicDirectory(getDirectoryParameter(call))) {
+        MediaScannerConnection.scanFile(getContext(), new String[] {file.getAbsolutePath()}, null, null);
+      }
+      Logger.debug(getLogTag(), "File '" + file.getAbsolutePath() + "' saved!");
+      JSObject result = new JSObject();
+      result.put("uri", Uri.fromFile(file).toString());
+      call.success(result);
+    } else {
+      call.error("FILE_NOTCREATED");
+    }
+  }
+
+  @PluginMethod()
+  public void appendFile(PluginCall call) {
+    try {
+      call.getData().putOpt("append", true);
+    } catch(JSONException ex) {}
+
+    this.writeFile(call);
+  }
+
+  @PluginMethod()
+  public void deleteFile(PluginCall call) {
+    saveCall(call);
+    String file = call.getString("path");
+    String directory = getDirectoryParameter(call);
+
+    File fileObject = getFileObject(file, directory);
+
+    if (!isPublicDirectory(directory)
+        || isStoragePermissionGranted(PluginRequestCodes.FILESYSTEM_REQUEST_DELETE_FILE_PERMISSIONS, Manifest.permission.WRITE_EXTERNAL_STORAGE)) {
+      if (!fileObject.exists()) {
+        call.error("File does not exist");
+        return;
+      }
+
+      boolean deleted = fileObject.delete();
+      if(deleted == false) {
+        call.error("Unable to delete file");
+      } else {
+        call.success();
+      }
+    }
+  }
+
+  @PluginMethod()
+  public void mkdir(PluginCall call) {
+    saveCall(call);
+    String path = call.getString("path");
+    String directory = getDirectoryParameter(call);
+    boolean recursive = call.getBoolean("recursive", false).booleanValue();
+
+    File fileObject = getFileObject(path, directory);
+
+    if (fileObject.exists()) {
+      call.error("Directory exists");
+      return;
+    }
+
+    if (!isPublicDirectory(directory)
+            || isStoragePermissionGranted(PluginRequestCodes.FILESYSTEM_REQUEST_WRITE_FOLDER_PERMISSIONS, Manifest.permission.WRITE_EXTERNAL_STORAGE)) {
+      boolean created = false;
+      if (recursive) {
+        created = fileObject.mkdirs();
+      } else {
+        created = fileObject.mkdir();
+      }
+      if(created == false) {
+        call.error("Unable to create directory, unknown reason");
+      } else {
+        call.success();
+      }
+    }
+  }
+
+  @PluginMethod()
+  public void rmdir(PluginCall call) {
+    saveCall(call);
+    String path = call.getString("path");
+    String directory = getDirectoryParameter(call);
+    Boolean recursive = call.getBoolean("recursive", false);
+
+    File fileObject = getFileObject(path, directory);
+
+    if (!isPublicDirectory(directory)
+        || isStoragePermissionGranted(PluginRequestCodes.FILESYSTEM_REQUEST_DELETE_FOLDER_PERMISSIONS, Manifest.permission.WRITE_EXTERNAL_STORAGE)) {
+      if (!fileObject.exists()) {
+        call.error("Directory does not exist");
+        return;
+      }
+
+      if (fileObject.isDirectory() && fileObject.listFiles().length != 0 && !recursive) {
+        call.error("Directory is not empty");
+        return;
+      }
+
+      boolean deleted = false;
+
+      try {
+        deleteRecursively(fileObject);
+        deleted = true;
+      } catch (IOException ignored) {
+      }
+
+      if(deleted == false) {
+        call.error("Unable to delete directory, unknown reason");
+      } else {
+        call.success();
+      }
+    }
+  }
+
+  @PluginMethod()
+  public void readdir(PluginCall call) {
+    saveCall(call);
+    String path = call.getString("path");
+    String directory = getDirectoryParameter(call);
+
+    File fileObject = getFileObject(path, directory);
+
+     if (!isPublicDirectory(directory)
+         || isStoragePermissionGranted(PluginRequestCodes.FILESYSTEM_REQUEST_READ_FOLDER_PERMISSIONS, Manifest.permission.READ_EXTERNAL_STORAGE)) {
+      if (fileObject != null && fileObject.exists()) {
+        String[] files = fileObject.list();
+        if (files != null) {
+          JSObject ret = new JSObject();
+          ret.put("files", JSArray.from(files));
+          call.success(ret);
+        } else {
+          call.error("Unable to read directory");
+        }
+      } else {
+      call.error("Directory does not exist");
+      }
+    }
+  }
+
+  @PluginMethod()
+  public void getUri(PluginCall call) {
+    saveCall(call);
+    String path = call.getString("path");
+    String directory = getDirectoryParameter(call);
+
+    File fileObject = getFileObject(path, directory);
+
+    if (!isPublicDirectory(directory)
+        || isStoragePermissionGranted(PluginRequestCodes.FILESYSTEM_REQUEST_URI_PERMISSIONS, Manifest.permission.READ_EXTERNAL_STORAGE)) {
+      JSObject data = new JSObject();
+      data.put("uri", Uri.fromFile(fileObject).toString());
+      call.success(data);
+    }
+  }
+
+  @PluginMethod()
+  public void stat(PluginCall call) {
+    saveCall(call);
+    String path = call.getString("path");
+    String directory = getDirectoryParameter(call);
+
+    File fileObject = getFileObject(path, directory);
+
+    if (!isPublicDirectory(directory)
+        || isStoragePermissionGranted(PluginRequestCodes.FILESYSTEM_REQUEST_STAT_PERMISSIONS, Manifest.permission.READ_EXTERNAL_STORAGE)) {
+      if (!fileObject.exists()) {
+        call.error("File does not exist");
+        return;
+      }
+
+      JSObject data = new JSObject();
+      data.put("type", fileObject.isDirectory() ? "directory" : "file");
+      data.put("size", fileObject.length());
+      data.put("ctime", null);
+      data.put("mtime", fileObject.lastModified());
+      data.put("uri", Uri.fromFile(fileObject).toString());
+      call.success(data);
+    }
+  }
+
+  /**
+   * Helper function to recursively delete a directory
+   *
+   * @param file The file or directory to recursively delete
+   * @throws IOException
+   */
+  private static void deleteRecursively(File file) throws IOException {
+    if (file.isFile()) {
+      file.delete();
+      return;
+    }
+
+    for (File f : file.listFiles()) {
+      deleteRecursively(f);
+    }
+
+    file.delete();
+  }
+
+  /**
+   * Helper function to recursively copy a directory structure (or just a file)
+   *
+   * @param src The source location
+   * @param dst The destination location
+   * @throws IOException
+   */
+  private static void copyRecursively(File src, File dst) throws IOException {
+    if (src.isDirectory()) {
+      dst.mkdir();
+
+      for (String file : src.list()) {
+        copyRecursively(new File(src, file), new File(dst, file));
+      }
+
+      return;
+    }
+
+    if (!dst.getParentFile().exists()) {
+      dst.getParentFile().mkdirs();
+    }
+
+    if (!dst.exists()) {
+      dst.createNewFile();
+    }
+
+    try (FileChannel source = new FileInputStream(src).getChannel(); FileChannel destination = new FileOutputStream(dst).getChannel()) {
+      destination.transferFrom(source, 0, source.size());
+    }
+  }
+
+  private void _copy(PluginCall call, boolean doRename) {
+    saveCall(call);
+
+    String from = call.getString("from");
+    String to = call.getString("to");
+    String directory = call.getString("directory");
+    String toDirectory = call.getString("toDirectory");
+
+    if (toDirectory == null) {
+      toDirectory = directory;
+    }
+
+    if (from == null || from.isEmpty() || to == null || to.isEmpty()) {
+      call.error("Both to and from must be provided");
+      return;
+    }
+
+    File fromObject = getFileObject(from, directory);
+    File toObject = getFileObject(to, toDirectory);
+
+    assert fromObject != null;
+    assert toObject != null;
+
+    if (toObject.equals(fromObject)) {
+      call.success();
+      return;
+    }
+
+    if (!fromObject.exists()) {
+      call.error("The source object does not exist");
+      return;
+    }
+
+    if (toObject.getParentFile().isFile()) {
+      call.error("The parent object of the destination is a file");
+      return;
+    }
+
+    if (!toObject.getParentFile().exists()) {
+      call.error("The parent object of the destination does not exist");
+      return;
+    }
+
+    if (isPublicDirectory(directory) || isPublicDirectory(toDirectory)) {
+      if (doRename) {
+        if (!isStoragePermissionGranted(PluginRequestCodes.FILESYSTEM_REQUEST_RENAME_PERMISSIONS, Manifest.permission.WRITE_EXTERNAL_STORAGE)) {
+          return;
+        }
+      } else {
+        if (!isStoragePermissionGranted(PluginRequestCodes.FILESYSTEM_REQUEST_COPY_PERMISSIONS, Manifest.permission.WRITE_EXTERNAL_STORAGE)) {
+          return;
+        }
+      }
+    }
+
+    if (toObject.isDirectory()) {
+      call.error("Cannot overwrite a directory");
+      return;
+    }
+
+    toObject.delete();
+
+    assert fromObject != null;
+
+    if (doRename) {
+      boolean modified = fromObject.renameTo(toObject);
+      if (!modified) {
+        call.error("Unable to rename, unknown reason");
+        return;
+      }
+    } else {
+      try {
+        copyRecursively(fromObject, toObject);
+      } catch (IOException e) {
+        call.error("Unable to perform action: " + e.getLocalizedMessage());
+        return;
+      }
+    }
+
+    call.success();
+  }
+
+  @PluginMethod()
+  public void rename(PluginCall call) {
+    this._copy(call, true);
+  }
+
+  @PluginMethod()
+  public void copy(PluginCall call) {
+    this._copy(call, false);
+  }
+
+  /**
+   * Checks the the given permission and requests them if they are not already granted.
+   * @param permissionRequestCode the request code see {@link PluginRequestCodes}
+   * @param permission the permission string
+   * @return Returns true if the permission is granted and false if it is denied.
+   */
+  private boolean isStoragePermissionGranted(int permissionRequestCode, String permission) {
+    if (hasPermission(permission)) {
+      Logger.verbose(getLogTag(),"Permission '" + permission + "' is granted");
+      return true;
+    } else {
+      Logger.verbose(getLogTag(),"Permission '" + permission + "' denied. Asking user for it.");
+      pluginRequestPermissions(new String[] {permission}, permissionRequestCode);
+      return false;
+    }
+  }
+
+  /**
+   * Reads the directory parameter from the plugin call
+   * @param call the plugin call
+   */
+  private String getDirectoryParameter(PluginCall call) {
+    return call.getString("directory");
+  }
+
+  /**
+   * True if the given directory string is a public storage directory, which is accessible by the user or other apps.
+   * @param directory the directory string.
+   */
+  private boolean isPublicDirectory(String directory) {
+    return "DOCUMENTS".equals(directory) || "EXTERNAL_STORAGE".equals(directory);
+  }
+
+  @Override
+  protected void handleRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) {
+    super.handleRequestPermissionsResult(requestCode, permissions, grantResults);
+
+    Logger.debug(getLogTag(),"handling request perms result");
+
+    if (getSavedCall() == null) {
+      Logger.debug(getLogTag(),"No stored plugin call for permissions request result");
+      return;
+    }
+
+    PluginCall savedCall = getSavedCall();
+
+    for (int i = 0; i < grantResults.length; i++) {
+      int result = grantResults[i];
+      String perm = permissions[i];
+      if(result == PackageManager.PERMISSION_DENIED) {
+        Logger.debug(getLogTag(), "User denied storage permission: " + perm);
+        savedCall.error(PERMISSION_DENIED_ERROR);
+        this.freeSavedCall();
+        return;
+      }
+    }
+
+    if (requestCode == PluginRequestCodes.FILESYSTEM_REQUEST_WRITE_FILE_PERMISSIONS) {
+      this.writeFile(savedCall);
+    } else if (requestCode == PluginRequestCodes.FILESYSTEM_REQUEST_WRITE_FOLDER_PERMISSIONS) {
+      this.mkdir(savedCall);
+    } else if (requestCode == PluginRequestCodes.FILESYSTEM_REQUEST_READ_FILE_PERMISSIONS) {
+      this.readFile(savedCall);
+    } else if (requestCode == PluginRequestCodes.FILESYSTEM_REQUEST_READ_FOLDER_PERMISSIONS) {
+      this.readdir(savedCall);
+    } else if (requestCode == PluginRequestCodes.FILESYSTEM_REQUEST_DELETE_FILE_PERMISSIONS) {
+      this.deleteFile(savedCall);
+    } else if (requestCode == PluginRequestCodes.FILESYSTEM_REQUEST_DELETE_FOLDER_PERMISSIONS) {
+      this.rmdir(savedCall);
+    } else if (requestCode == PluginRequestCodes.FILESYSTEM_REQUEST_URI_PERMISSIONS) {
+      this.getUri(savedCall);
+    } else if (requestCode == PluginRequestCodes.FILESYSTEM_REQUEST_STAT_PERMISSIONS) {
+      this.stat(savedCall);
+    } else if (requestCode == PluginRequestCodes.FILESYSTEM_REQUEST_RENAME_PERMISSIONS) {
+      this.rename(savedCall);
+    } else if (requestCode == PluginRequestCodes.FILESYSTEM_REQUEST_COPY_PERMISSIONS) {
+      this.copy(savedCall);
+    } else if (requestCode == PluginRequestCodes.FILESYSTEM_REQUEST_ALL_PERMISSIONS){
+      savedCall.resolve();
+    }
+    this.freeSavedCall();
+  }
+
+}

+ 194 - 0
android/capacitor/src/main/java/com/getcapacitor/plugin/Geolocation.java

@@ -0,0 +1,194 @@
+package com.getcapacitor.plugin;
+
+import android.Manifest;
+import android.content.Context;
+import android.content.pm.PackageManager;
+import android.location.Location;
+import android.location.LocationManager;
+import android.os.Build;
+
+import com.getcapacitor.JSObject;
+import com.getcapacitor.NativePlugin;
+import com.getcapacitor.Plugin;
+import com.getcapacitor.PluginCall;
+import com.getcapacitor.PluginMethod;
+import com.getcapacitor.PluginRequestCodes;
+
+import com.google.android.gms.location.FusedLocationProviderClient;
+import com.google.android.gms.location.LocationAvailability;
+import com.google.android.gms.location.LocationCallback;
+import com.google.android.gms.location.LocationRequest;
+import com.google.android.gms.location.LocationResult;
+import com.google.android.gms.location.LocationServices;
+
+import java.util.HashMap;
+import java.util.Map;
+
+
+@NativePlugin(
+    permissions={
+      Manifest.permission.ACCESS_COARSE_LOCATION,
+      Manifest.permission.ACCESS_FINE_LOCATION
+    },
+    permissionRequestCode = PluginRequestCodes.GEOLOCATION_REQUEST_PERMISSIONS
+)
+public class Geolocation extends Plugin {
+
+  private Map<String, PluginCall> watchingCalls = new HashMap<>();
+  private FusedLocationProviderClient fusedLocationClient;
+  private LocationCallback locationCallback;
+
+
+  @PluginMethod()
+  public void getCurrentPosition(PluginCall call) {
+    if (!hasRequiredPermissions()) {
+      saveCall(call);
+      pluginRequestAllPermissions();
+    } else {
+      sendLocation(call);
+    }
+  }
+
+  private void sendLocation(PluginCall call) {
+    requestLocationUpdates(call);
+  }
+
+  @PluginMethod(returnType=PluginMethod.RETURN_CALLBACK)
+  public void watchPosition(PluginCall call) {
+    call.save();
+    if (!hasRequiredPermissions()) {
+      saveCall(call);
+      pluginRequestAllPermissions();
+    } else {
+      startWatch(call);
+    }
+  }
+
+  @SuppressWarnings("MissingPermission")
+  private void startWatch(PluginCall call) {
+    requestLocationUpdates(call);
+    watchingCalls.put(call.getCallbackId(), call);
+  }
+
+  @SuppressWarnings("MissingPermission")
+  @PluginMethod()
+  public void clearWatch(PluginCall call) {
+    String callbackId = call.getString("id");
+    if (callbackId != null) {
+      PluginCall removed = watchingCalls.remove(callbackId);
+      if (removed != null) {
+        removed.release(bridge);
+      }
+    }
+    if (watchingCalls.size() == 0) {
+      clearLocationUpdates();
+    }
+    call.success();
+  }
+
+  /**
+   * Process a new location item and send it to any listening calls
+   * @param location
+   */
+  private void processLocation(Location location) {
+    for (Map.Entry<String, PluginCall> watch : watchingCalls.entrySet()) {
+      watch.getValue().success(getJSObjectForLocation(location));
+    }
+  }
+
+  @Override
+  protected void handleRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) {
+    super.handleRequestPermissionsResult(requestCode, permissions, grantResults);
+
+    PluginCall savedCall = getSavedCall();
+    if (savedCall == null) {
+      return;
+    }
+
+    for(int result : grantResults) {
+      if (result == PackageManager.PERMISSION_DENIED) {
+        savedCall.error("User denied location permission");
+        return;
+      }
+    }
+
+    if (savedCall.getMethodName().equals("getCurrentPosition")) {
+      sendLocation(savedCall);
+    } else if (savedCall.getMethodName().equals("watchPosition")) {
+      startWatch(savedCall);
+    } else {
+      savedCall.resolve();
+      savedCall.release(bridge);
+    }
+  }
+
+  private JSObject getJSObjectForLocation(Location location) {
+    JSObject ret = new JSObject();
+    JSObject coords = new JSObject();
+    ret.put("coords", coords);
+    ret.put("timestamp", location.getTime());
+    coords.put("latitude", location.getLatitude());
+    coords.put("longitude", location.getLongitude());
+    coords.put("accuracy", location.getAccuracy());
+    coords.put("altitude", location.getAltitude());
+    if (Build.VERSION.SDK_INT >= 26) {
+      coords.put("altitudeAccuracy", location.getVerticalAccuracyMeters());
+    }
+    coords.put("speed", location.getSpeed());
+    coords.put("heading", location.getBearing());
+    return ret;
+  }
+
+  @SuppressWarnings("MissingPermission")
+  private void requestLocationUpdates(final PluginCall call) {
+    clearLocationUpdates();
+    boolean enableHighAccuracy = call.getBoolean("enableHighAccuracy", false);
+    int timeout = call.getInt("timeout", 10000);
+    fusedLocationClient = LocationServices.getFusedLocationProviderClient(getContext());
+
+    LocationManager lm = (LocationManager)getContext().getSystemService(Context.LOCATION_SERVICE);
+    boolean networkEnabled = false;
+    try {
+      networkEnabled = lm.isProviderEnabled(LocationManager.NETWORK_PROVIDER);
+    } catch(Exception ex) {}
+    LocationRequest locationRequest = new LocationRequest();
+    locationRequest.setMaxWaitTime(timeout);
+    locationRequest.setInterval(10000);
+    locationRequest.setFastestInterval(5000);
+    int lowPriority = networkEnabled ? LocationRequest.PRIORITY_BALANCED_POWER_ACCURACY : LocationRequest.PRIORITY_LOW_POWER;
+    int priority = enableHighAccuracy ? LocationRequest.PRIORITY_HIGH_ACCURACY : lowPriority;
+    locationRequest.setPriority(priority);
+
+    locationCallback = new LocationCallback(){
+      @Override
+      public void onLocationResult(LocationResult locationResult) {
+        if (call.getMethodName().equals("getCurrentPosition")) {
+          clearLocationUpdates();
+        }
+        Location lastLocation = locationResult.getLastLocation();
+        if (lastLocation == null) {
+          call.error("location unavailable");
+        } else {
+          call.success(getJSObjectForLocation(lastLocation));
+        }
+      }
+      @Override
+      public void onLocationAvailability(LocationAvailability availability) {
+        if (!availability.isLocationAvailable()) {
+          call.error("location unavailable");
+          clearLocationUpdates();
+        }
+      }
+    };
+
+    fusedLocationClient.requestLocationUpdates(locationRequest, locationCallback, null);
+  }
+
+  private void clearLocationUpdates() {
+    if (locationCallback != null) {
+      fusedLocationClient.removeLocationUpdates(locationCallback);
+      locationCallback = null;
+    }
+  }
+
+}

+ 77 - 0
android/capacitor/src/main/java/com/getcapacitor/plugin/Haptics.java

@@ -0,0 +1,77 @@
+package com.getcapacitor.plugin;
+
+import android.Manifest;
+import android.content.Context;
+import android.os.Build;
+import android.os.VibrationEffect;
+import android.os.Vibrator;
+import android.view.HapticFeedbackConstants;
+
+import com.getcapacitor.NativePlugin;
+import com.getcapacitor.Plugin;
+import com.getcapacitor.PluginCall;
+import com.getcapacitor.PluginMethod;
+
+/**
+ * Haptic engine plugin, also handles vibration.
+ *
+ * Requires the android.permission.VIBRATE permission.
+ */
+@NativePlugin()
+public class Haptics extends Plugin {
+
+  boolean selectionStarted = false;
+
+  @PluginMethod()
+  @SuppressWarnings("MissingPermission")
+  public void vibrate(PluginCall call) {
+    Context c = this.getContext();
+    int duration = call.getInt("duration", 300);
+
+    if(!hasPermission(Manifest.permission.VIBRATE)) {
+      call.error("Can't vibrate: Missing VIBRATE permission in AndroidManifest.xml");
+      return;
+    }
+
+    if (Build.VERSION.SDK_INT >= 26) {
+      ((Vibrator) c.getSystemService(Context.VIBRATOR_SERVICE)).vibrate(VibrationEffect.createOneShot(duration, VibrationEffect.DEFAULT_AMPLITUDE));
+    } else {
+      vibratePre26(duration);
+    }
+
+    call.success();
+  }
+
+  @SuppressWarnings({"deprecation", "MissingPermission"})
+  private void vibratePre26(int duration) {
+    ((Vibrator) getContext().getSystemService(Context.VIBRATOR_SERVICE)).vibrate(duration);
+  }
+
+  @PluginMethod()
+  public void impact(PluginCall call) {
+    this.bridge.getWebView().performHapticFeedback(HapticFeedbackConstants.LONG_PRESS);
+    call.success();
+  }
+
+  @PluginMethod()
+  public void notification(PluginCall call) {
+    call.unimplemented();
+  }
+
+  @PluginMethod()
+  public void selectionStart(PluginCall call) {
+    this.selectionStarted = true;
+  }
+
+  @PluginMethod()
+  public void selectionChanged(PluginCall call) {
+    if (this.selectionStarted) {
+      this.bridge.getWebView().performHapticFeedback(HapticFeedbackConstants.CLOCK_TICK);
+    }
+  }
+
+  @PluginMethod()
+  public void selectionEnd(PluginCall call) {
+    this.selectionStarted = false;
+  }
+}

+ 156 - 0
android/capacitor/src/main/java/com/getcapacitor/plugin/Keyboard.java

@@ -0,0 +1,156 @@
+package com.getcapacitor.plugin;
+
+import android.content.Context;
+import android.graphics.Point;
+import android.graphics.Rect;
+import android.os.Build;
+import android.os.Handler;
+import android.util.DisplayMetrics;
+import android.view.Display;
+import android.view.View;
+import android.view.ViewTreeObserver;
+import android.view.WindowInsets;
+import android.view.inputmethod.InputMethodManager;
+
+import com.getcapacitor.JSObject;
+import com.getcapacitor.NativePlugin;
+import com.getcapacitor.Plugin;
+import com.getcapacitor.PluginCall;
+import com.getcapacitor.PluginMethod;
+
+/**
+ * Ported from https://github.com/ionic-team/cordova-plugin-ionic-keyboard/blob/master/src/android/IonicKeyboard.java
+ */
+@NativePlugin()
+public class Keyboard extends Plugin {
+  private ViewTreeObserver.OnGlobalLayoutListener list;
+  private View rootView;
+
+  private static final String EVENT_KB_WILL_SHOW = "keyboardWillShow";
+  private static final String EVENT_KB_DID_SHOW = "keyboardDidShow";
+  private static final String EVENT_KB_WILL_HIDE = "keyboardWillHide";
+  private static final String EVENT_KB_DID_HIDE = "keyboardDidHide";
+
+
+  public void load() {
+    execute(new Runnable() {
+      @Override
+      public void run() {
+        //calculate density-independent pixels (dp)
+        //http://developer.android.com/guide/practices/screens_support.html
+        DisplayMetrics dm = new DisplayMetrics();
+        getActivity().getWindowManager().getDefaultDisplay().getMetrics(dm);
+        final float density = dm.density;
+
+        //http://stackoverflow.com/a/4737265/1091751 detect if keyboard is showing
+        rootView = getActivity().getWindow().getDecorView().findViewById(android.R.id.content).getRootView();
+        list = new ViewTreeObserver.OnGlobalLayoutListener() {
+          int previousHeightDiff = 0;
+          @Override
+          public void onGlobalLayout() {
+            Rect r = new Rect();
+            //r will be populated with the coordinates of your view that area still visible.
+            rootView.getWindowVisibleDisplayFrame(r);
+
+            // cache properties for later use
+            int rootViewHeight = rootView.getRootView().getHeight();
+            int resultBottom = r.bottom;
+            int screenHeight;
+
+            if (Build.VERSION.SDK_INT >= 23) {
+              WindowInsets windowInsets = rootView.getRootWindowInsets();
+              int stableInsetBottom = windowInsets.getStableInsetBottom();
+              screenHeight = rootViewHeight;
+              resultBottom = resultBottom + stableInsetBottom;
+            } else {
+              // calculate screen height differently for android versions <23: Lollipop 5.x, Marshmallow 6.x
+              //http://stackoverflow.com/a/29257533/3642890 beware of nexus 5
+              Display display = getActivity().getWindowManager().getDefaultDisplay();
+              Point size = new Point();
+              display.getSize(size);
+              screenHeight = size.y;
+            }
+
+            int heightDiff = screenHeight - resultBottom;
+
+            int pixelHeightDiff = (int)(heightDiff / density);
+            if (pixelHeightDiff > 100 && pixelHeightDiff != previousHeightDiff) { // if more than 100 pixels, its probably a keyboard...
+              String data = "{ 'keyboardHeight': " + pixelHeightDiff + " }";
+              bridge.triggerWindowJSEvent(EVENT_KB_WILL_SHOW, data);
+              bridge.triggerWindowJSEvent(EVENT_KB_DID_SHOW, data);
+              JSObject kbData = new JSObject();
+              kbData.put("keyboardHeight", pixelHeightDiff);
+              notifyListeners(EVENT_KB_WILL_SHOW, kbData);
+              notifyListeners(EVENT_KB_DID_SHOW, kbData);
+            }
+            else if ( pixelHeightDiff != previousHeightDiff && ( previousHeightDiff - pixelHeightDiff ) > 100 ){
+              bridge.triggerWindowJSEvent(EVENT_KB_WILL_HIDE);
+              bridge.triggerWindowJSEvent(EVENT_KB_DID_HIDE);
+              JSObject kbData = new JSObject();
+              notifyListeners(EVENT_KB_WILL_HIDE, kbData);
+              notifyListeners(EVENT_KB_DID_HIDE, kbData);
+            }
+            previousHeightDiff = pixelHeightDiff;
+          }
+        };
+        rootView.getViewTreeObserver().addOnGlobalLayoutListener(list);
+      }
+    });
+  }
+
+  @PluginMethod()
+  public void show(final PluginCall call) {
+    execute(new Runnable() {
+      @Override
+      public void run() {
+        new Handler().postDelayed(new Runnable() {
+          @Override
+          public void run() {
+            ((InputMethodManager) getActivity().getSystemService(Context.INPUT_METHOD_SERVICE)).toggleSoftInput(0, InputMethodManager.HIDE_IMPLICIT_ONLY);
+            call.success(); // Thread-safe.
+          }
+        }, 350);
+      }
+    });
+  }
+
+  @PluginMethod()
+  public void hide(final PluginCall call) {
+    execute(new Runnable() {
+      @Override
+      public void run() {
+        //http://stackoverflow.com/a/7696791/1091751
+        InputMethodManager inputManager = (InputMethodManager) getActivity().getSystemService(Context.INPUT_METHOD_SERVICE);
+        View v = getActivity().getCurrentFocus();
+
+        if (v == null) {
+          call.error("Can't close keyboard, not currently focused");
+        } else {
+          inputManager.hideSoftInputFromWindow(v.getWindowToken(), InputMethodManager.HIDE_NOT_ALWAYS);
+          call.success(); // Thread-safe.
+        }
+      }
+    });
+  }
+
+  @PluginMethod()
+  public void setAccessoryBarVisible(PluginCall call) {
+    call.unimplemented();
+  }
+
+  @PluginMethod()
+  public void setStyle(PluginCall call) {
+    call.unimplemented();
+  }
+
+  @PluginMethod()
+  public void setResizeMode(PluginCall call) {
+    call.unimplemented();
+  }
+
+  @PluginMethod()
+  public void setScroll(PluginCall call) {
+        call.unimplemented();
+  }
+
+}

+ 142 - 0
android/capacitor/src/main/java/com/getcapacitor/plugin/LocalNotifications.java

@@ -0,0 +1,142 @@
+package com.getcapacitor.plugin;
+
+import android.content.Intent;
+
+import com.getcapacitor.JSArray;
+import com.getcapacitor.JSObject;
+import com.getcapacitor.NativePlugin;
+import com.getcapacitor.Plugin;
+import com.getcapacitor.PluginCall;
+import com.getcapacitor.PluginMethod;
+import com.getcapacitor.PluginRequestCodes;
+import com.getcapacitor.plugin.notification.LocalNotification;
+import com.getcapacitor.plugin.notification.LocalNotificationManager;
+import com.getcapacitor.plugin.notification.NotificationAction;
+import com.getcapacitor.plugin.notification.NotificationChannelManager;
+import com.getcapacitor.plugin.notification.NotificationStorage;
+
+import org.json.JSONArray;
+
+import java.util.List;
+import java.util.Map;
+
+
+/**
+ * Plugin for scheduling local notifications
+ * Plugins allows to create and trigger various types of notifications an specific times
+ * Please refer to individual documentation for reference
+ */
+@NativePlugin(requestCodes = PluginRequestCodes.NOTIFICATION_OPEN)
+public class LocalNotifications extends Plugin {
+  private LocalNotificationManager manager;
+  private NotificationStorage notificationStorage;
+  private NotificationChannelManager notificationChannelManager;
+
+  public LocalNotifications() {
+  }
+
+  @Override
+  public void load() {
+    super.load();
+    notificationStorage = new NotificationStorage(getContext());
+    manager = new LocalNotificationManager(notificationStorage, getActivity(), getContext(), this.bridge.getConfig());
+    manager.createNotificationChannel();
+    notificationChannelManager = new NotificationChannelManager(getActivity());
+  }
+
+  @Override
+  protected void handleOnNewIntent(Intent data) {
+    super.handleOnNewIntent(data);
+    if (!Intent.ACTION_MAIN.equals(data.getAction())) {
+      return;
+    }
+    JSObject dataJson = manager.handleNotificationActionPerformed(data, notificationStorage);
+    if (dataJson != null) {
+      notifyListeners("localNotificationActionPerformed", dataJson, true);
+    }
+  }
+
+  @Override
+  protected void handleOnActivityResult(int requestCode, int resultCode, Intent data) {
+    super.handleOnActivityResult(requestCode, resultCode, data);
+    this.handleOnNewIntent(data);
+  }
+
+  /**
+   * Schedule a notification call from JavaScript
+   * Creates local notification in system.
+   */
+  @PluginMethod()
+  public void schedule(PluginCall call) {
+    List<LocalNotification> localNotifications = LocalNotification.buildNotificationList(call);
+    if (localNotifications == null) {
+      return;
+    }
+    JSONArray ids = manager.schedule(call, localNotifications);
+    if (ids != null) {
+      notificationStorage.appendNotifications(localNotifications);
+      JSObject result = new JSObject();
+      JSArray jsArray = new JSArray();
+      for (int i=0; i < ids.length(); i++) {
+        try {
+          JSObject notification = new JSObject().put("id", ids.getString(i));
+          jsArray.put(notification);
+        } catch (Exception ex) {
+        }
+      }
+      result.put("notifications", jsArray);
+      call.success(result);
+    }
+  }
+
+  @PluginMethod()
+  public void requestPermission(PluginCall call) {
+    JSObject result = new JSObject();
+    result.put("granted", true);
+    call.success(result);
+  }
+
+  @PluginMethod()
+  public void cancel(PluginCall call) {
+    manager.cancel(call);
+  }
+
+  @PluginMethod()
+  public void getPending(PluginCall call) {
+    List<String> ids = notificationStorage.getSavedNotificationIds();
+    JSObject result = LocalNotification.buildLocalNotificationPendingList(ids);
+    call.success(result);
+  }
+
+  @PluginMethod()
+  public void registerActionTypes(PluginCall call) {
+    JSArray types = call.getArray("types");
+    Map<String, NotificationAction[]> typesArray = NotificationAction.buildTypes(types);
+    notificationStorage.writeActionGroup(typesArray);
+    call.success();
+  }
+
+  @PluginMethod()
+  public void areEnabled(PluginCall call) {
+    JSObject data = new JSObject();
+    data.put("value", manager.areNotificationsEnabled());
+    call.success(data);
+  }
+
+  @PluginMethod()
+  public void createChannel(PluginCall call) {
+    notificationChannelManager.createChannel(call);
+  }
+
+  @PluginMethod()
+  public void deleteChannel(PluginCall call) {
+    notificationChannelManager.deleteChannel(call);
+  }
+
+  @PluginMethod()
+  public void listChannels(PluginCall call) {
+    notificationChannelManager.listChannels(call);
+  }
+
+}
+

+ 142 - 0
android/capacitor/src/main/java/com/getcapacitor/plugin/Modals.java

@@ -0,0 +1,142 @@
+package com.getcapacitor.plugin;
+
+import android.app.Activity;
+
+import com.getcapacitor.Dialogs;
+import com.getcapacitor.JSArray;
+import com.getcapacitor.JSObject;
+import com.getcapacitor.NativePlugin;
+import com.getcapacitor.Plugin;
+import com.getcapacitor.PluginCall;
+import com.getcapacitor.PluginMethod;
+import com.getcapacitor.ui.ModalsBottomSheetDialogFragment;
+
+/**
+ * Common popup modals
+ */
+@NativePlugin()
+public class Modals extends Plugin {
+
+  @PluginMethod()
+  public void alert(final PluginCall call) {
+    final Activity c = this.getActivity();
+    final String title = call.getString("title");
+    final String message = call.getString("message");
+    final String buttonTitle = call.getString("buttonTitle", "OK");
+
+    if(title == null || message == null) {
+      call.error("Please provide a title or message for the alert");
+      return;
+    }
+
+    if (c.isFinishing()) {
+      call.error("App is finishing");
+      return;
+    }
+
+    Dialogs.alert(c, message, title, buttonTitle, new Dialogs.OnResultListener() {
+      @Override
+      public void onResult(boolean value, boolean didCancel, String inputValue) {
+        call.success();
+      }
+    });
+  }
+
+  @PluginMethod()
+  public void confirm(final PluginCall call) {
+    final Activity c = this.getActivity();
+    final String title = call.getString("title");
+    final String message = call.getString("message");
+    final String okButtonTitle = call.getString("okButtonTitle", "OK");
+    final String cancelButtonTitle = call.getString("cancelButtonTitle", "Cancel");
+
+    if(title == null || message == null) {
+      call.error("Please provide a title or message for the alert");
+      return;
+    }
+
+    if (c.isFinishing()) {
+      call.error("App is finishing");
+      return;
+    }
+
+    Dialogs.confirm(c, message, title, okButtonTitle, cancelButtonTitle, new Dialogs.OnResultListener() {
+      @Override
+      public void onResult(boolean value, boolean didCancel, String inputValue) {
+        JSObject ret = new JSObject();
+        ret.put("value", value);
+        call.success(ret);
+      }
+    });
+  }
+
+  @PluginMethod()
+  public void prompt(final PluginCall call) {
+    final Activity c = this.getActivity();
+    final String title = call.getString("title");
+    final String message = call.getString("message");
+    final String okButtonTitle = call.getString("okButtonTitle", "OK");
+    final String cancelButtonTitle = call.getString("cancelButtonTitle", "Cancel");
+    final String inputPlaceholder = call.getString("inputPlaceholder", "");
+    final String inputText = call.getString("inputText", "");
+
+    if(title == null || message == null) {
+      call.error("Please provide a title or message for the alert");
+      return;
+    }
+
+    if (c.isFinishing()) {
+      call.error("App is finishing");
+      return;
+    }
+
+    Dialogs.prompt(c, message, title, okButtonTitle, cancelButtonTitle, inputPlaceholder, inputText, new Dialogs.OnResultListener() {
+      @Override
+      public void onResult(boolean value, boolean didCancel, String inputValue) {
+        JSObject ret = new JSObject();
+        ret.put("cancelled", didCancel);
+        ret.put("value", inputValue == null ? "" : inputValue);
+        call.success(ret);
+      }
+    });
+  }
+
+
+  @PluginMethod()
+  public void showActions(final PluginCall call) {
+    String title = call.getString("title");
+    String message = call.getString("message", "");
+    JSArray options = call.getArray("options");
+
+    if (title == null) {
+      call.error("Must supply a title");
+      return;
+    }
+
+    if (options == null) {
+      call.error("Must supply options");
+      return;
+    }
+
+    if (getActivity().isFinishing()) {
+      call.error("App is finishing");
+      return;
+    }
+
+    final ModalsBottomSheetDialogFragment fragment = new ModalsBottomSheetDialogFragment();
+    fragment.setTitle(title);
+    fragment.setOptions(options);
+    fragment.setCancelable(false);
+    fragment.setOnSelectedListener(new ModalsBottomSheetDialogFragment.OnSelectedListener() {
+      @Override
+      public void onSelected(int index) {
+        JSObject ret = new JSObject();
+        ret.put("index", index);
+        call.success(ret);
+        fragment.dismiss();
+      }
+    });
+    fragment.show(getActivity().getSupportFragmentManager(), "capacitorModalsActionSheet");
+  }
+
+}

+ 123 - 0
android/capacitor/src/main/java/com/getcapacitor/plugin/Network.java

@@ -0,0 +1,123 @@
+package com.getcapacitor.plugin;
+
+import android.Manifest;
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.net.ConnectivityManager;
+import android.net.NetworkInfo;
+import com.getcapacitor.JSObject;
+import com.getcapacitor.Logger;
+import com.getcapacitor.NativePlugin;
+import com.getcapacitor.Plugin;
+import com.getcapacitor.PluginCall;
+import com.getcapacitor.PluginMethod;
+
+/**
+ * Simple Network status plugin.
+ *
+ * https://developer.android.com/training/monitoring-device-state/connectivity-monitoring.html
+ * https://developer.android.com/training/basics/network-ops/managing.html
+ */
+@NativePlugin(
+  permissions={
+    Manifest.permission.ACCESS_NETWORK_STATE
+  }
+)
+public class Network extends Plugin {
+  public static final String NETWORK_CHANGE_EVENT = "networkStatusChange";
+  private static final String PERMISSION_NOT_SET = Manifest.permission.ACCESS_NETWORK_STATE + " not set in AndroidManifest.xml";
+
+  private ConnectivityManager cm;
+  private BroadcastReceiver receiver;
+
+  /**
+   * Monitor for network status changes and fire our event.
+   */
+  @SuppressWarnings("MissingPermission")
+  public void load() {
+    cm = (ConnectivityManager)getContext().getSystemService(Context.CONNECTIVITY_SERVICE);
+
+    receiver = new BroadcastReceiver() {
+      @Override
+      public void onReceive(Context context, Intent intent) {
+        if (hasRequiredPermissions()) {
+          notifyListeners(NETWORK_CHANGE_EVENT, getStatusJSObject(cm.getActiveNetworkInfo()));
+        } else {
+          Logger.error(getLogTag(), PERMISSION_NOT_SET, null);
+        }
+      }
+    };
+  }
+
+  /**
+   * Get current network status information
+   * @param call
+   */
+  @SuppressWarnings("MissingPermission")
+  @PluginMethod()
+  public void getStatus(PluginCall call) {
+    if (hasRequiredPermissions()) {
+      ConnectivityManager cm =
+              (ConnectivityManager)getContext().getSystemService(Context.CONNECTIVITY_SERVICE);
+
+      NetworkInfo activeNetwork = cm.getActiveNetworkInfo();
+
+      call.success(getStatusJSObject(activeNetwork));
+    } else {
+      call.error(PERMISSION_NOT_SET);
+    }
+  }
+
+  /**
+   * Register the IntentReceiver on resume
+   */
+  @Override
+  protected void handleOnResume() {
+    IntentFilter filter = new IntentFilter(ConnectivityManager.CONNECTIVITY_ACTION);
+    getActivity().registerReceiver(receiver, filter);
+  }
+
+  /**
+   * Unregister the IntentReceiver on pause to avoid leaking it
+   */
+  @Override
+  protected void handleOnPause() {
+    getActivity().unregisterReceiver(receiver);
+  }
+
+
+  /**
+   * Transform a NetworkInfo object into our JSObject for returning to client
+   * @param info
+   * @return
+   */
+  private JSObject getStatusJSObject(NetworkInfo info) {
+    JSObject ret = new JSObject();
+    if (info == null) {
+      ret.put("connected", false);
+      ret.put("connectionType", "none");
+    } else {
+      ret.put("connected", info.isConnected());
+      ret.put("connectionType", getNormalizedTypeName(info));
+    }
+    return ret;
+  }
+
+  /**
+   * Convert the Android-specific naming for network types into our cross-platform type
+   * @param info
+   * @return
+   */
+  private String getNormalizedTypeName(NetworkInfo info) {
+    String typeName = info.getTypeName();
+    if (typeName.equals("WIFI")) {
+      return "wifi";
+    }
+    if (typeName.equals("MOBILE")) {
+      return "cellular";
+    }
+    return "none";
+  }
+}

+ 89 - 0
android/capacitor/src/main/java/com/getcapacitor/plugin/Permissions.java

@@ -0,0 +1,89 @@
+package com.getcapacitor.plugin;
+
+import android.Manifest;
+import android.content.pm.PackageManager;
+
+import androidx.core.app.NotificationManagerCompat;
+import androidx.core.content.ContextCompat;
+
+import com.getcapacitor.JSObject;
+import com.getcapacitor.NativePlugin;
+import com.getcapacitor.Plugin;
+import com.getcapacitor.PluginCall;
+import com.getcapacitor.PluginMethod;
+
+import org.json.JSONArray;
+
+@NativePlugin()
+public class Permissions extends Plugin {
+
+  @PluginMethod
+  public void query(PluginCall call) {
+    String name = call.getString("name");
+
+    switch (name) {
+      case "camera":
+        checkCamera(call);
+        break;
+      case "photos":
+        checkPhotos(call);
+        break;
+      case "geolocation":
+        checkGeo(call);
+        break;
+      case "notifications":
+        checkNotifications(call);
+        break;
+      case "clipboard-read":
+      case "clipboard-write":
+        checkClipboard(call);
+      case "microphone":
+        checkMicrophone(call);
+        break;
+      default:
+        call.reject("Unknown permission type");
+    }
+  }
+
+  private void checkPerm(String perm, PluginCall call) {
+    JSObject ret = new JSObject();
+    if (ContextCompat.checkSelfPermission(getContext(), perm) == PackageManager.PERMISSION_DENIED) {
+      ret.put("state", "denied");
+    } else if (ContextCompat.checkSelfPermission(getContext(), perm) == PackageManager.PERMISSION_GRANTED) {
+      ret.put("state", "granted");
+    } else {
+      ret.put("state", "prompt");
+    }
+    call.resolve(ret);
+  }
+
+  private void checkCamera(PluginCall call) {
+    checkPerm(Manifest.permission.CAMERA, call);
+  }
+
+  private void checkPhotos(PluginCall call) {
+    checkPerm(Manifest.permission.READ_EXTERNAL_STORAGE, call);
+  }
+
+  private void checkGeo(PluginCall call) {
+    checkPerm(Manifest.permission.ACCESS_COARSE_LOCATION, call);
+  }
+
+  private void checkNotifications(PluginCall call) {
+    boolean areEnabled = NotificationManagerCompat.from(getContext()).areNotificationsEnabled();
+    JSObject ret = new JSObject();
+    ret.put("state", areEnabled ? "granted" : "denied");
+    call.resolve(ret);
+  }
+
+  private void checkClipboard(PluginCall call) {
+    JSObject ret = new JSObject();
+    ret.put("state", "granted");
+    call.resolve(ret);
+  }
+
+  private void checkMicrophone(PluginCall call) {
+    checkPerm(Manifest.permission.RECORD_AUDIO, call);
+  }
+
+}

+ 30 - 0
android/capacitor/src/main/java/com/getcapacitor/plugin/Photos.java

@@ -0,0 +1,30 @@
+package com.getcapacitor.plugin;
+
+import com.getcapacitor.NativePlugin;
+import com.getcapacitor.Plugin;
+import com.getcapacitor.PluginCall;
+import com.getcapacitor.PluginMethod;
+
+@NativePlugin()
+public class Photos extends Plugin {
+
+  @PluginMethod()
+  public void getAlbums(PluginCall call) {
+    call.unimplemented();
+  }
+
+  @PluginMethod()
+  public void getPhotos(PluginCall call) {
+    call.unimplemented();
+  }
+
+  @PluginMethod()
+  public void createAlbum(PluginCall call) {
+    call.unimplemented();
+  }
+
+  @PluginMethod()
+  public void savePhoto(PluginCall call) {
+    call.unimplemented();
+  }
+}

+ 252 - 0
android/capacitor/src/main/java/com/getcapacitor/plugin/PushNotifications.java

@@ -0,0 +1,252 @@
+package com.getcapacitor.plugin;
+
+import android.app.Notification;
+import android.app.NotificationManager;
+import android.content.Context;
+import android.content.Intent;
+import android.os.Build;
+import android.os.Bundle;
+import android.service.notification.StatusBarNotification;
+import android.net.Uri;
+
+import com.getcapacitor.Bridge;
+import com.getcapacitor.JSArray;
+import com.getcapacitor.JSObject;
+import com.getcapacitor.NativePlugin;
+import com.getcapacitor.Plugin;
+import com.getcapacitor.PluginCall;
+import com.getcapacitor.PluginHandle;
+import com.getcapacitor.PluginMethod;
+import com.getcapacitor.plugin.notification.NotificationChannelManager;
+import com.google.android.gms.tasks.OnFailureListener;
+import com.google.android.gms.tasks.OnSuccessListener;
+import com.google.firebase.iid.FirebaseInstanceId;
+import com.google.firebase.iid.InstanceIdResult;
+import com.google.firebase.messaging.FirebaseMessaging;
+import com.google.firebase.messaging.RemoteMessage;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.util.ArrayList;
+import java.util.List;
+
+@NativePlugin()
+public class PushNotifications extends Plugin {
+
+  public static Bridge staticBridge = null;
+  public static RemoteMessage lastMessage = null;
+  public NotificationManager notificationManager;
+  private NotificationChannelManager notificationChannelManager;
+
+  private static final String EVENT_TOKEN_CHANGE = "registration";
+  private static final String EVENT_TOKEN_ERROR = "registrationError";
+
+  public void load() {
+    notificationManager = (NotificationManager)getActivity()
+            .getSystemService(Context.NOTIFICATION_SERVICE);
+    staticBridge = this.bridge;
+    if (lastMessage != null) {
+      fireNotification(lastMessage);
+      lastMessage = null;
+    }
+    notificationChannelManager = new NotificationChannelManager(getActivity(), notificationManager);
+  }
+
+  @Override
+  protected void handleOnNewIntent(Intent data) {
+    super.handleOnNewIntent(data);
+    Bundle bundle = data.getExtras();
+    if (bundle != null && bundle.containsKey("google.message_id")) {
+      JSObject notificationJson = new JSObject();
+      JSObject dataObject = new JSObject();
+      for (String key : bundle.keySet()) {
+        if (key.equals("google.message_id")) {
+          notificationJson.put("id", bundle.get(key));
+        } else {
+          Object value = bundle.get(key);
+          String valueStr = (value != null) ? value.toString() : null;
+          dataObject.put(key, valueStr);
+        }
+      }
+      notificationJson.put("data", dataObject);
+      JSObject actionJson = new JSObject();
+      actionJson.put("actionId", "tap");
+      actionJson.put("notification", notificationJson);
+      notifyListeners("pushNotificationActionPerformed", actionJson, true);
+    }
+  }
+
+  @PluginMethod()
+  public void register(PluginCall call) {
+    FirebaseMessaging.getInstance().setAutoInitEnabled(true);
+    FirebaseInstanceId.getInstance().getInstanceId().addOnSuccessListener(getActivity(), new OnSuccessListener<InstanceIdResult>() {
+      @Override
+      public void onSuccess(InstanceIdResult instanceIdResult) {
+        sendToken(instanceIdResult.getToken());
+      }
+    });
+    FirebaseInstanceId.getInstance().getInstanceId().addOnFailureListener(new OnFailureListener() {
+      public void onFailure(Exception e) {
+        sendError(e.getLocalizedMessage());
+      }
+    });
+    call.success();
+  }
+
+  @PluginMethod()
+  public void requestPermission(PluginCall call) {
+    JSObject result = new JSObject();
+    result.put("granted", true);
+    call.success(result);
+  }
+
+  @PluginMethod()
+  public void getDeliveredNotifications(PluginCall call) {
+    JSArray notifications = new JSArray();
+    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+      StatusBarNotification[] activeNotifications = notificationManager.getActiveNotifications();
+
+      for (StatusBarNotification notif : activeNotifications) {
+        JSObject jsNotif = new JSObject();
+
+        jsNotif.put("id", notif.getId());
+
+        Notification notification = notif.getNotification();
+        if (notification != null) {
+          jsNotif.put("title", notification.extras.getCharSequence(Notification.EXTRA_TITLE));
+          jsNotif.put("body", notification.extras.getCharSequence(Notification.EXTRA_TEXT));
+          jsNotif.put("group", notification.getGroup());
+          jsNotif.put("groupSummary", 0 != (notification.flags & Notification.FLAG_GROUP_SUMMARY));
+
+          JSObject extras = new JSObject();
+
+          for (String key : notification.extras.keySet()) {
+            extras.put(key, notification.extras.get(key));
+          }
+
+          jsNotif.put("data", extras);
+        }
+
+        notifications.put(jsNotif);
+      }
+    }
+
+    JSObject result = new JSObject();
+    result.put("notifications", notifications);
+    call.resolve(result);
+  }
+
+  @PluginMethod()
+  public void removeDeliveredNotifications(PluginCall call) {
+    JSArray notifications = call.getArray("notifications");
+
+    List<Integer> ids = new ArrayList<>();
+    try {
+      for (Object o : notifications.toList()) {
+        if (o instanceof JSONObject) {
+          JSObject notif = JSObject.fromJSONObject((JSONObject) o);
+          Integer id = notif.getInteger("id");
+          ids.add(id);
+        } else {
+          call.reject("Expected notifications to be a list of notification objects");
+        }
+      }
+    } catch (JSONException e) {
+      call.reject(e.getMessage());
+    }
+
+    for (int id : ids) {
+      notificationManager.cancel(id);
+    }
+
+    call.resolve();
+  }
+
+  @PluginMethod()
+  public void removeAllDeliveredNotifications(PluginCall call) {
+    notificationManager.cancelAll();
+    call.success();
+  }
+
+  @PluginMethod()
+  public void createChannel(PluginCall call) {
+    notificationChannelManager.createChannel(call);
+  }
+
+  @PluginMethod()
+  public void deleteChannel(PluginCall call) {
+    notificationChannelManager.deleteChannel(call);
+  }
+
+  @PluginMethod()
+  public void listChannels(PluginCall call) {
+    notificationChannelManager.listChannels(call);
+  }
+
+  public void sendToken(String token) {
+    JSObject data = new JSObject();
+    data.put("value", token);
+    notifyListeners(EVENT_TOKEN_CHANGE, data, true);
+  }
+
+  public void sendError(String error) {
+    JSObject data = new JSObject();
+    data.put("error", error);
+    notifyListeners(EVENT_TOKEN_ERROR, data, true);
+  }
+
+  public static void onNewToken(String newToken) {
+    PushNotifications pushPlugin = PushNotifications.getPushNotificationsInstance();
+    if (pushPlugin != null) {
+      pushPlugin.sendToken(newToken);
+    }
+  }
+
+  public static void sendRemoteMessage(RemoteMessage remoteMessage) {
+    PushNotifications pushPlugin = PushNotifications.getPushNotificationsInstance();
+    if (pushPlugin != null) {
+      pushPlugin.fireNotification(remoteMessage);
+    } else {
+      lastMessage = remoteMessage;
+    }
+  }
+
+  public void fireNotification(RemoteMessage remoteMessage) {
+    JSObject remoteMessageData = new JSObject();
+
+    JSObject data = new JSObject();
+    remoteMessageData.put("id", remoteMessage.getMessageId());
+    for (String key : remoteMessage.getData().keySet()) {
+      Object value = remoteMessage.getData().get(key);
+      data.put(key, value);
+    }
+    remoteMessageData.put("data", data);
+
+    RemoteMessage.Notification notification = remoteMessage.getNotification();
+    if (notification != null) {
+      remoteMessageData.put("title", notification.getTitle());
+      remoteMessageData.put("body", notification.getBody());
+      remoteMessageData.put("click_action", notification.getClickAction());
+
+      Uri link = notification.getLink();
+      if (link != null) {
+        remoteMessageData.put("link", link.toString());
+      }
+    }
+
+    notifyListeners("pushNotificationReceived", remoteMessageData, true);
+  }
+
+  public static PushNotifications getPushNotificationsInstance() {
+    if (staticBridge != null && staticBridge.getWebView() != null) {
+      PluginHandle handle = staticBridge.getPlugin("PushNotifications");
+      if (handle == null) {
+        return null;
+      }
+      return (PushNotifications) handle.getInstance();
+    }
+    return null;
+  }
+
+}

+ 87 - 0
android/capacitor/src/main/java/com/getcapacitor/plugin/Share.java

@@ -0,0 +1,87 @@
+package com.getcapacitor.plugin;
+
+import android.content.Intent;
+import android.net.Uri;
+import android.os.Build;
+import android.webkit.MimeTypeMap;
+
+import androidx.core.content.FileProvider;
+
+import com.getcapacitor.NativePlugin;
+import com.getcapacitor.Plugin;
+import com.getcapacitor.PluginCall;
+import com.getcapacitor.PluginMethod;
+
+import java.io.File;
+
+@NativePlugin()
+public class Share extends Plugin {
+
+  @PluginMethod()
+  public void share(PluginCall call) {
+    String title = call.getString("title", "");
+    String text = call.getString("text");
+    String url = call.getString("url");
+    String dialogTitle = call.getString("dialogTitle", "Share");
+
+    if (text == null && url == null) {
+      call.error("Must provide a URL or Message");
+      return;
+    }
+
+    if(url != null && !isFileUrl(url) && !isHttpUrl(url)) {
+      call.error("Unsupported url");
+      return;
+    }
+
+    Intent intent = new Intent(Intent.ACTION_SEND);
+
+    if (text != null) {
+      // If they supplied both fields, concat em
+      if (url != null && isHttpUrl(url)) text = text + " " + url;
+      intent.putExtra(Intent.EXTRA_TEXT, text);
+      intent.setTypeAndNormalize("text/plain");
+    }
+
+    if(url != null && isHttpUrl(url) && text == null) {
+      intent.putExtra(Intent.EXTRA_TEXT, url);
+      intent.setTypeAndNormalize("text/plain");
+    } else if (url != null && isFileUrl(url)) {
+      String type = getMimeType(url);
+      intent.setType(type);
+      Uri fileUrl = FileProvider.getUriForFile(getActivity(), getContext().getPackageName() + ".fileprovider", new File(Uri.parse(url).getPath()));
+      intent.putExtra(Intent.EXTRA_STREAM, fileUrl);
+      if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+        intent.setData(fileUrl);
+      }
+      intent.setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
+    }
+
+    if (title != null) {
+      intent.putExtra(Intent.EXTRA_SUBJECT, title);
+    }
+
+    Intent chooser = Intent.createChooser(intent, dialogTitle);
+    chooser.addCategory(Intent.CATEGORY_DEFAULT);
+
+    getActivity().startActivity(chooser);
+    call.success();
+  }
+
+  private String getMimeType(String url) {
+    String type = null;
+    String extension = MimeTypeMap.getFileExtensionFromUrl(url);
+    if (extension != null) {
+      type = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension);
+    }
+    return type;
+  }
+
+  private boolean isFileUrl(String url) {
+    return url.startsWith("file:");
+  }
+
+  private boolean isHttpUrl(String url) {
+    return url.startsWith("http");
+  }
+}

+ 37 - 0
android/capacitor/src/main/java/com/getcapacitor/plugin/SplashScreen.java

@@ -0,0 +1,37 @@
+package com.getcapacitor.plugin;
+
+import com.getcapacitor.NativePlugin;
+import com.getcapacitor.Plugin;
+import com.getcapacitor.PluginCall;
+import com.getcapacitor.PluginMethod;
+import com.getcapacitor.Splash;
+
+@NativePlugin()
+public class SplashScreen extends Plugin {
+  @PluginMethod()
+  public void show(final PluginCall call) {
+    int showDuration = call.getInt("showDuration", Splash.DEFAULT_SHOW_DURATION);
+    int fadeInDuration = call.getInt("fadeInDuration", Splash.DEFAULT_FADE_IN_DURATION);
+    int fadeOutDuration = call.getInt("fadeOutDuration", Splash.DEFAULT_FADE_OUT_DURATION);
+    boolean autoHide = call.getBoolean("autoHide", Splash.DEFAULT_AUTO_HIDE);
+
+    Splash.show(getActivity(), showDuration, fadeInDuration, fadeOutDuration, autoHide, new Splash.SplashListener() {
+      @Override
+      public void completed() {
+        call.success();
+      }
+
+      @Override
+      public void error() {
+        call.error("An error occurred while showing splash");
+      }
+    }, bridge.getConfig());
+  }
+
+  @PluginMethod()
+  public void hide(PluginCall call) {
+    int fadeDuration = call.getInt("fadeOutDuration", Splash.DEFAULT_FADE_OUT_DURATION);
+    Splash.hide(getContext(), fadeDuration);
+    call.success();
+  }
+}

+ 159 - 0
android/capacitor/src/main/java/com/getcapacitor/plugin/StatusBar.java

@@ -0,0 +1,159 @@
+package com.getcapacitor.plugin;
+
+import android.graphics.Color;
+import android.view.View;
+import android.view.Window;
+import android.view.WindowManager;
+
+import com.getcapacitor.JSObject;
+import com.getcapacitor.NativePlugin;
+import com.getcapacitor.Plugin;
+import com.getcapacitor.PluginCall;
+import com.getcapacitor.PluginMethod;
+
+@NativePlugin()
+public class StatusBar extends Plugin {
+
+  private int currentStatusbarColor;
+
+  public void load() {
+    // save initial color of the status bar
+    currentStatusbarColor = getActivity().getWindow().getStatusBarColor();
+  }
+
+  @PluginMethod()
+  public void setStyle(final PluginCall call) {
+    final String style = call.getString("style");
+    if (style == null) {
+      call.error("Style must be provided");
+      return;
+    }
+
+    getBridge().executeOnMainThread(new Runnable() {
+      @Override
+      public void run() {
+        Window window = getActivity().getWindow();
+        View decorView = window.getDecorView();
+
+        int visibilityFlags = decorView.getSystemUiVisibility();
+
+        if (style.equals("DARK")) {
+          decorView.setSystemUiVisibility(visibilityFlags & ~View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR);
+        } else {
+          decorView.setSystemUiVisibility(visibilityFlags | View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR);
+        }
+        call.success();
+      }
+    });
+  }
+
+  @PluginMethod()
+  public void setBackgroundColor(final PluginCall call) {
+    final String color = call.getString("color");
+    if (color == null) {
+      call.error("Color must be provided");
+      return;
+    }
+
+    getBridge().executeOnMainThread(new Runnable() {
+      @Override
+      public void run() {
+        Window window = getActivity().getWindow();
+        window.clearFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS);
+        window.addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS);
+        try {
+          final int parsedColor = Color.parseColor(color.toUpperCase());
+          window.setStatusBarColor(parsedColor);
+          // update the local color field as well
+          currentStatusbarColor = parsedColor;
+          call.success();
+        } catch (IllegalArgumentException ex) {
+          call.error("Invalid color provided. Must be a hex string (ex: #ff0000");
+        }
+      }
+    });
+  }
+
+  @PluginMethod()
+  public void hide(final PluginCall call) {
+    // Hide the status bar.
+    getBridge().executeOnMainThread(new Runnable() {
+      @Override
+      public void run() {
+        View decorView = getActivity().getWindow().getDecorView();
+        int uiOptions = decorView.getSystemUiVisibility();
+        uiOptions = uiOptions | View.SYSTEM_UI_FLAG_FULLSCREEN;
+        uiOptions = uiOptions & ~View.SYSTEM_UI_FLAG_VISIBLE;
+        decorView.setSystemUiVisibility(uiOptions);
+        call.success();
+      }
+    });
+  }
+
+  @PluginMethod()
+  public void show(final PluginCall call) {
+    // Show the status bar.
+    getBridge().executeOnMainThread(new Runnable() {
+      @Override
+      public void run() {
+        View decorView = getActivity().getWindow().getDecorView();
+        int uiOptions = decorView.getSystemUiVisibility();
+        uiOptions = uiOptions | View.SYSTEM_UI_FLAG_VISIBLE;
+        uiOptions = uiOptions & ~View.SYSTEM_UI_FLAG_FULLSCREEN;
+        decorView.setSystemUiVisibility(uiOptions);
+        call.success();
+      }
+    });
+  }
+
+  @PluginMethod()
+  public void getInfo(final PluginCall call) {
+    View decorView = getActivity().getWindow().getDecorView();
+    Window window = getActivity().getWindow();
+
+    String style;
+    if ((decorView.getSystemUiVisibility() & View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR) == View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR) {
+      style = "LIGHT";
+    } else {
+      style = "DARK";
+    }
+
+    JSObject data = new JSObject();
+    data.put("visible", (decorView.getSystemUiVisibility() & View.SYSTEM_UI_FLAG_FULLSCREEN) != View.SYSTEM_UI_FLAG_FULLSCREEN);
+    data.put("style", style);
+    data.put("color", String.format("#%06X", (0xFFFFFF & window.getStatusBarColor())));
+    data.put("overlays", (decorView.getSystemUiVisibility() & View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN) == View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN);
+    call.resolve(data);
+  }
+
+  @PluginMethod()
+  public void setOverlaysWebView(final PluginCall call) {
+    final Boolean overlays = call.getBoolean("overlay", true);
+    getBridge().executeOnMainThread(new Runnable() {
+      @Override
+      public void run() {
+        if (overlays) {
+          // Sets the layout to a fullscreen one that does not hide the actual status bar, so the webview is displayed behind it.
+          View decorView = getActivity().getWindow().getDecorView();
+          int uiOptions = decorView.getSystemUiVisibility();
+          uiOptions = uiOptions | View.SYSTEM_UI_FLAG_LAYOUT_STABLE | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN;
+          decorView.setSystemUiVisibility(uiOptions);
+          currentStatusbarColor = getActivity().getWindow().getStatusBarColor();
+          getActivity().getWindow().setStatusBarColor(Color.TRANSPARENT);
+
+          call.success();
+        } else {
+          // Sets the layout to a normal one that displays the webview below the status bar.
+          View decorView = getActivity().getWindow().getDecorView();
+          int uiOptions = decorView.getSystemUiVisibility();
+          uiOptions = uiOptions & ~View.SYSTEM_UI_FLAG_LAYOUT_STABLE & ~View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN;
+          decorView.setSystemUiVisibility(uiOptions);
+          // recover the previous color of the status bar
+          getActivity().getWindow().setStatusBarColor(currentStatusbarColor);
+
+          call.success();
+        }
+      }
+    });
+  }
+}

+ 92 - 0
android/capacitor/src/main/java/com/getcapacitor/plugin/Storage.java

@@ -0,0 +1,92 @@
+package com.getcapacitor.plugin;
+
+import android.app.Activity;
+import android.content.SharedPreferences;
+
+import com.getcapacitor.JSArray;
+import com.getcapacitor.JSObject;
+import com.getcapacitor.NativePlugin;
+import com.getcapacitor.Plugin;
+import com.getcapacitor.PluginCall;
+import com.getcapacitor.PluginMethod;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.util.Map;
+import java.util.Set;
+
+@NativePlugin()
+public class Storage extends Plugin {
+  private static final String PREFS_NAME = "CapacitorStorage";
+  private SharedPreferences prefs;
+  private SharedPreferences.Editor editor;
+
+  public void load() {
+    prefs = getContext().getSharedPreferences(PREFS_NAME, Activity.MODE_PRIVATE);
+    editor = prefs.edit();
+  }
+
+  @PluginMethod()
+  public void get(PluginCall call) {
+    String key = call.getString("key");
+    if (key == null) {
+      call.reject("Must provide key");
+      return;
+    }
+    String value = prefs.getString(key, null);
+
+    JSObject ret = new JSObject();
+    ret.put("value", value == null ? JSObject.NULL : value);
+    call.resolve(ret);
+  }
+
+  @PluginMethod()
+  public void set(PluginCall call) {
+    String key = call.getString("key");
+    if (key == null) {
+      call.reject("Must provide key");
+      return;
+    }
+    String value = call.getString("value");
+
+    editor.putString(key, value);
+    editor.apply();
+    call.resolve();
+  }
+
+  @PluginMethod()
+  public void remove(PluginCall call) {
+    String key = call.getString("key");
+    if (key == null) {
+      call.reject("Must provide key");
+      return;
+    }
+
+    editor.remove(key);
+    editor.apply();
+    call.resolve();
+  }
+
+  @PluginMethod()
+  public void keys(PluginCall call) {
+    Map<String, ?> values = prefs.getAll();
+    Set<String> keys = values.keySet();
+    String[] keyArray = keys.toArray(new String[keys.size()]);
+    JSObject ret = new JSObject();
+    try {
+      ret.put("keys", new JSArray(keyArray));
+    } catch (JSONException ex) {
+      call.reject("Unable to create key array.");
+      return;
+    }
+    call.resolve(ret);
+  }
+
+  @PluginMethod()
+  public void clear(PluginCall call) {
+    editor.clear();
+    editor.apply();
+    call.resolve();
+  }
+}

+ 42 - 0
android/capacitor/src/main/java/com/getcapacitor/plugin/Toast.java

@@ -0,0 +1,42 @@
+package com.getcapacitor.plugin;
+
+import android.view.Gravity;
+import com.getcapacitor.NativePlugin;
+import com.getcapacitor.Plugin;
+import com.getcapacitor.PluginCall;
+import com.getcapacitor.PluginMethod;
+
+@NativePlugin()
+public class Toast extends Plugin {
+
+  private static final int GRAVITY_TOP = Gravity.TOP|Gravity.CENTER_HORIZONTAL;
+  private static final int GRAVITY_CENTER = Gravity.CENTER_VERTICAL|Gravity.CENTER_HORIZONTAL;
+
+  @PluginMethod()
+  public void show(PluginCall call) {
+    CharSequence text = call.getString("text");
+    if(text == null) {
+      call.error("Must provide text");
+      return;
+    }
+
+    String durationType = call.getString("duration", "short");
+
+    int duration = android.widget.Toast.LENGTH_SHORT;
+    if("long".equals(durationType)) {
+      duration = android.widget.Toast.LENGTH_LONG;
+    }
+
+    android.widget.Toast toast = android.widget.Toast.makeText(getContext(), text, duration);
+
+    String position = call.getString("position", "bottom");
+    if("top".equals(position)) {
+      toast.setGravity(GRAVITY_TOP, 0, 40);
+    } else if("center".equals(position)) {
+      toast.setGravity(GRAVITY_CENTER, 0, 0);
+    }
+
+    toast.show();
+    call.success();
+  }
+}

+ 41 - 0
android/capacitor/src/main/java/com/getcapacitor/plugin/WebView.java

@@ -0,0 +1,41 @@
+package com.getcapacitor.plugin;
+
+import android.app.Activity;
+import android.content.SharedPreferences;
+
+import com.getcapacitor.JSObject;
+import com.getcapacitor.NativePlugin;
+import com.getcapacitor.Plugin;
+import com.getcapacitor.PluginCall;
+import com.getcapacitor.PluginMethod;
+
+@NativePlugin()
+public class WebView extends Plugin {
+  public static final String WEBVIEW_PREFS_NAME = "CapWebViewSettings";
+  public static final String CAP_SERVER_PATH = "serverBasePath";
+
+  @PluginMethod()
+  public void setServerBasePath(PluginCall call) {
+    String path = call.getString("path");
+    bridge.setServerBasePath(path);
+    call.success();
+  }
+
+  @PluginMethod()
+  public void getServerBasePath(PluginCall call) {
+    String path = bridge.getServerBasePath();
+    JSObject ret = new JSObject();
+    ret.put("path", path);
+    call.success(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.success();
+  }
+}

+ 72 - 0
android/capacitor/src/main/java/com/getcapacitor/plugin/background/BackgroundTask.java

@@ -0,0 +1,72 @@
+package com.getcapacitor.plugin.background;
+
+import android.app.IntentService;
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+
+import androidx.localbroadcastmanager.content.LocalBroadcastManager;
+
+import com.getcapacitor.Bridge;
+import com.getcapacitor.JSObject;
+import com.getcapacitor.NativePlugin;
+import com.getcapacitor.Plugin;
+import com.getcapacitor.PluginCall;
+import com.getcapacitor.PluginMethod;
+
+@NativePlugin()
+public class BackgroundTask extends Plugin {
+  public static String TASK_BROADCAST_ACTION = "com.getcapacitor.app.BACKGROUND_TASK_BROADCAST";
+
+
+  Intent serviceIntent = null;
+
+  private BroadcastReceiver taskReceiver;
+
+  public void load() {
+    IntentFilter intentFilter = new IntentFilter(TASK_BROADCAST_ACTION);
+
+    taskReceiver = new BroadcastReceiver() {
+      @Override
+      public void onReceive(Context context, Intent intent) {
+        String taskId = intent.getStringExtra("taskId");
+        // no-op for now
+        // callTaskCallback(taskId);
+      }
+    };
+
+    LocalBroadcastManager.getInstance(getContext()).registerReceiver(taskReceiver, intentFilter);
+  }
+
+  private void callTaskCallback(String taskId) {
+  }
+
+  @PluginMethod(returnType=PluginMethod.RETURN_CALLBACK)
+  public void beforeExit(PluginCall call) {
+    String taskId = "";
+
+    /*
+    serviceIntent = new Intent(getActivity(), BackgroundTaskService.class);
+    serviceIntent.putExtra("taskId", call.getCallbackId());
+    getActivity().startService(serviceIntent);
+    */
+
+    // No-op for now as Android has less strict requirements for background tasks
+
+    JSObject ret = new JSObject();
+    ret.put("taskId", call.getCallbackId());
+    call.success(ret);
+  }
+
+  @PluginMethod()
+  public void finish(PluginCall call) {
+    String taskId = call.getString("taskId");
+    if (taskId == null) {
+      call.error("Must provide taskId");
+      return;
+    }
+
+    call.success();
+  }
+}

+ 26 - 0
android/capacitor/src/main/java/com/getcapacitor/plugin/background/BackgroundTaskService.java

@@ -0,0 +1,26 @@
+package com.getcapacitor.plugin.background;
+
+import android.app.IntentService;
+import android.content.Intent;
+
+import androidx.localbroadcastmanager.content.LocalBroadcastManager;
+
+import com.getcapacitor.Logger;
+
+public class BackgroundTaskService extends IntentService {
+  public BackgroundTaskService() {
+    super("CapacitorBackgroundTaskService");
+  }
+
+  @Override
+  protected void onHandleIntent(Intent intent) {
+    // Gets data from the incoming Intent
+    String taskId = intent.getStringExtra("taskId");
+    Logger.debug("Doing background task: " + taskId);
+
+    Intent localIntent = new Intent(BackgroundTask.TASK_BROADCAST_ACTION)
+        .putExtra("taskId", taskId);
+    LocalBroadcastManager.getInstance(this).sendBroadcast(localIntent);
+    // Do work here, based on the contents of dataString
+  }
+}

+ 17 - 0
android/capacitor/src/main/java/com/getcapacitor/plugin/camera/CameraResultType.java

@@ -0,0 +1,17 @@
+package com.getcapacitor.plugin.camera;
+
+public enum CameraResultType {
+    BASE64("base64"),
+    URI("uri"),
+    DATAURL("dataUrl");
+
+    private String type;
+
+    CameraResultType(String type) {
+        this.type = type;
+    }
+
+    public String getType() {
+        return type;
+    }
+}

+ 95 - 0
android/capacitor/src/main/java/com/getcapacitor/plugin/camera/CameraSettings.java

@@ -0,0 +1,95 @@
+package com.getcapacitor.plugin.camera;
+
+public class CameraSettings {
+
+    public static final int DEFAULT_QUALITY = 90;
+    public static final boolean DEFAULT_SAVE_IMAGE_TO_GALLERY = false;
+    public static final boolean DEFAULT_CORRECT_ORIENTATION = true;
+
+    private CameraResultType resultType = CameraResultType.BASE64;
+    private int quality = DEFAULT_QUALITY;
+    private boolean shouldResize = false;
+    private boolean shouldCorrectOrientation = DEFAULT_CORRECT_ORIENTATION;
+    private boolean saveToGallery = DEFAULT_SAVE_IMAGE_TO_GALLERY;
+    private boolean allowEditing = false;
+    private int width = 0;
+    private int height = 0;
+    private CameraSource source = CameraSource.PROMPT;
+    private boolean preserveAspectRatio = false;
+
+    public CameraResultType getResultType() {
+        return resultType;
+    }
+
+    public void setResultType(CameraResultType resultType) {
+        this.resultType = resultType;
+    }
+
+    public int getQuality() {
+        return quality;
+    }
+
+    public void setQuality(int quality) {
+        this.quality = quality;
+    }
+
+    public boolean isShouldResize() {
+        return shouldResize;
+    }
+
+    public void setShouldResize(boolean shouldResize) {
+        this.shouldResize = shouldResize;
+    }
+
+    public boolean isShouldCorrectOrientation() {
+        return shouldCorrectOrientation;
+    }
+
+    public void setShouldCorrectOrientation(boolean shouldCorrectOrientation) {
+        this.shouldCorrectOrientation = shouldCorrectOrientation;
+    }
+
+    public boolean isSaveToGallery() {
+        return saveToGallery;
+    }
+
+    public void setSaveToGallery(boolean saveToGallery) {
+        this.saveToGallery = saveToGallery;
+    }
+
+    public boolean isAllowEditing() { return  allowEditing; }
+
+    public void setAllowEditing(boolean allowEditing) { this.allowEditing = allowEditing; }
+
+    public int getWidth() {
+        return width;
+    }
+
+    public void setWidth(int width) {
+        this.width = width;
+    }
+
+    public int getHeight() {
+        return height;
+    }
+
+    public void setHeight(int height) {
+        this.height = height;
+    }
+
+    public CameraSource getSource() {
+        return source;
+    }
+
+    public void setSource(CameraSource source) {
+        this.source = source;
+    }
+
+    public void setPreserveAspectRatio(boolean preserveAspectRatio) {
+        this.preserveAspectRatio = preserveAspectRatio;
+    }
+
+    public boolean getPreserveAspectRatio() {
+        return this.preserveAspectRatio;
+    }
+}

+ 17 - 0
android/capacitor/src/main/java/com/getcapacitor/plugin/camera/CameraSource.java

@@ -0,0 +1,17 @@
+package com.getcapacitor.plugin.camera;
+
+public enum CameraSource {
+    PROMPT("PROMPT"),
+    CAMERA("CAMERA"),
+    PHOTOS("PHOTOS");
+
+    private String source;
+
+    CameraSource(String source) {
+        this.source = source;
+    }
+
+    public String getSource() {
+        return this.source;
+    }
+}

+ 40 - 0
android/capacitor/src/main/java/com/getcapacitor/plugin/camera/CameraUtils.java

@@ -0,0 +1,40 @@
+package com.getcapacitor.plugin.camera;
+
+import android.app.Activity;
+import android.net.Uri;
+import android.os.Environment;
+
+import androidx.core.content.FileProvider;
+
+import com.getcapacitor.Logger;
+
+import java.io.File;
+import java.io.IOException;
+import java.text.SimpleDateFormat;
+import java.util.Date;
+
+public class CameraUtils {
+    public static Uri createImageFileUri(Activity activity, String appId) throws IOException{
+        File photoFile = CameraUtils.createImageFile(activity);
+        return FileProvider.getUriForFile(activity, appId + ".fileprovider", photoFile);
+    }
+
+    public static 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);
+
+        File image = File.createTempFile(
+                imageFileName,  /* prefix */
+                ".jpg",         /* suffix */
+                storageDir      /* directory */
+        );
+
+        return image;
+    }
+
+    protected static String getLogTag() {
+        return Logger.tags("CameraUtils");
+    }
+}

+ 154 - 0
android/capacitor/src/main/java/com/getcapacitor/plugin/camera/ExifWrapper.java

@@ -0,0 +1,154 @@
+package com.getcapacitor.plugin.camera;
+
+import androidx.exifinterface.media.ExifInterface;
+
+import static androidx.exifinterface.media.ExifInterface.*;
+
+import com.getcapacitor.JSObject;
+
+public class ExifWrapper {
+  private final ExifInterface exif;
+
+  public ExifWrapper(ExifInterface exif) {
+    this.exif = exif;
+  }
+
+  public JSObject toJson() {
+    JSObject ret = new JSObject();
+
+    if (this.exif == null) {
+      return ret;
+    }
+
+    // Commented fields are for API 24. Left in to save someone the wrist damage later
+
+    p(ret, TAG_APERTURE_VALUE);
+    /*
+    p(ret, TAG_ARTIST);
+    p(ret, TAG_BITS_PER_SAMPLE);
+    p(ret, TAG_BRIGHTNESS_VALUE);
+    p(ret, TAG_CFA_PATTERN);
+    p(ret, TAG_COLOR_SPACE);
+    p(ret, TAG_COMPONENTS_CONFIGURATION);
+    p(ret, TAG_COMPRESSED_BITS_PER_PIXEL);
+    p(ret, TAG_COMPRESSION);
+    p(ret, TAG_CONTRAST);
+    p(ret, TAG_COPYRIGHT);
+    */
+    p(ret, TAG_DATETIME);
+    /*
+    p(ret, TAG_DATETIME_DIGITIZED);
+    p(ret, TAG_DATETIME_ORIGINAL);
+    p(ret, TAG_DEFAULT_CROP_SIZE);
+    p(ret, TAG_DEVICE_SETTING_DESCRIPTION);
+    p(ret, TAG_DIGITAL_ZOOM_RATIO);
+    p(ret, TAG_DNG_VERSION);
+    p(ret, TAG_EXIF_VERSION);
+    p(ret, TAG_EXPOSURE_BIAS_VALUE);
+    p(ret, TAG_EXPOSURE_INDEX);
+    p(ret, TAG_EXIF_VERSION);
+    p(ret, TAG_EXPOSURE_MODE);
+    p(ret, TAG_EXPOSURE_PROGRAM);
+    */
+    p(ret, TAG_EXPOSURE_TIME);
+    // p(ret, TAG_F_NUMBER);
+    // p(ret, TAG_FILE_SOURCE);
+    p(ret, TAG_FLASH);
+    // p(ret, TAG_FLASH_ENERGY);
+    // p(ret, TAG_FLASHPIX_VERSION);
+    p(ret, TAG_FOCAL_LENGTH);
+    // p(ret, TAG_FOCAL_LENGTH_IN_35MM_FILM);
+    // p(ret, TAG_FOCAL_PLANE_RESOLUTION_UNIT);
+    p(ret, TAG_FOCAL_LENGTH);
+    // p(ret, TAG_GAIN_CONTROL);
+    p(ret, TAG_GPS_LATITUDE);
+    p(ret, TAG_GPS_LATITUDE_REF);
+    p(ret, TAG_GPS_LONGITUDE);
+    p(ret, TAG_GPS_LONGITUDE_REF);
+    p(ret, TAG_GPS_ALTITUDE);
+    p(ret, TAG_GPS_ALTITUDE_REF);
+    // p(ret, TAG_GPS_AREA_INFORMATION);
+    p(ret, TAG_GPS_DATESTAMP);
+    /*
+    API 24
+    p(ret, TAG_GPS_DEST_BEARING);
+    p(ret, TAG_GPS_DEST_BEARING_REF);
+    p(ret, TAG_GPS_DEST_DISTANCE_REF);
+    p(ret, TAG_GPS_DEST_DISTANCE_REF);
+    p(ret, TAG_GPS_DEST_LATITUDE);
+    p(ret, TAG_GPS_DEST_LATITUDE_REF);
+    p(ret, TAG_GPS_DEST_LONGITUDE);
+    p(ret, TAG_GPS_DEST_LONGITUDE_REF);
+    p(ret, TAG_GPS_DIFFERENTIAL);
+    p(ret, TAG_GPS_DOP);
+    p(ret, TAG_GPS_IMG_DIRECTION);
+    p(ret, TAG_GPS_IMG_DIRECTION_REF);
+    p(ret, TAG_GPS_MAP_DATUM);
+    p(ret, TAG_GPS_MEASURE_MODE);
+    */
+    p(ret, TAG_GPS_PROCESSING_METHOD);
+    /*
+    API 24
+    p(ret, TAG_GPS_SATELLITES);
+    p(ret, TAG_GPS_SPEED);
+    p(ret, TAG_GPS_SPEED_REF);
+    p(ret, TAG_GPS_STATUS);
+    */
+    p(ret, TAG_GPS_TIMESTAMP);
+    /*
+    API 24
+    p(ret, TAG_GPS_TRACK);
+    p(ret, TAG_GPS_TRACK_REF);
+    p(ret, TAG_GPS_VERSION_ID);
+    p(ret, TAG_IMAGE_DESCRIPTION);
+    */
+    p(ret, TAG_IMAGE_LENGTH);
+    // p(ret, TAG_IMAGE_UNIQUE_ID);
+    p(ret, TAG_IMAGE_WIDTH);
+    p(ret, TAG_ISO_SPEED);
+    /*
+    p(ret, TAG_INTEROPERABILITY_INDEX);
+    p(ret, TAG_ISO_SPEED_RATINGS);
+    p(ret, TAG_JPEG_INTERCHANGE_FORMAT);
+    p(ret, TAG_JPEG_INTERCHANGE_FORMAT_LENGTH);
+    p(ret, TAG_LIGHT_SOURCE);
+    */
+    p(ret, TAG_MAKE);
+    /*
+    p(ret, TAG_MAKER_NOTE);
+    p(ret, TAG_MAX_APERTURE_VALUE);
+    p(ret, TAG_METERING_MODE);
+    */
+    p(ret, TAG_MODEL);
+    /*
+    p(ret, TAG_NEW_SUBFILE_TYPE);
+    p(ret, TAG_OECF);
+    p(ret, TAG_ORF_ASPECT_FRAME);
+    p(ret, TAG_ORF_PREVIEW_IMAGE_LENGTH);
+    p(ret, TAG_ORF_PREVIEW_IMAGE_START);
+    */
+    p(ret, TAG_ORIENTATION);
+    /*
+    p(ret, TAG_ORF_THUMBNAIL_IMAGE);
+    p(ret, TAG_PHOTOMETRIC_INTERPRETATION);
+    p(ret, TAG_PIXEL_X_DIMENSION);
+    p(ret, TAG_PIXEL_Y_DIMENSION);
+    p(ret, TAG_PLANAR_CONFIGURATION);
+    p(ret, TAG_PRIMARY_CHROMATICITIES);
+    p(ret, TAG_REFERENCE_BLACK_WHITE);
+    p(ret, TAG_RELATED_SOUND_FILE);
+    p(ret, TAG_RESOLUTION_UNIT);
+    p(ret, TAG_ROWS_PER_STRIP);
+    p(ret, TAG_RW2_ISO);
+    p(ret, TAG_RW2_JPG_FROM_RAW);
+    */
+    p(ret, TAG_WHITE_BALANCE);
+
+    return ret;
+  }
+
+  public void p(JSObject o, String tag) {
+    String val = exif.getAttribute(tag);
+    o.put(tag, val);
+  }
+}

+ 185 - 0
android/capacitor/src/main/java/com/getcapacitor/plugin/camera/ImageUtils.java

@@ -0,0 +1,185 @@
+package com.getcapacitor.plugin.camera;
+
+import android.content.Context;
+import android.database.Cursor;
+import android.graphics.Bitmap;
+import android.graphics.Matrix;
+import androidx.exifinterface.media.ExifInterface;
+import android.net.Uri;
+import android.os.Build;
+import android.provider.MediaStore;
+
+import com.getcapacitor.FileUtils;
+import com.getcapacitor.Logger;
+
+import java.io.IOException;
+import java.io.InputStream;
+
+public class ImageUtils {
+
+  /**
+   * Resize an image to the given width and height.
+   * @param bitmap
+   * @param width
+   * @param height
+   * @return a new, scaled Bitmap
+   */
+  public static Bitmap resize(Bitmap bitmap, final int width, final int height) {
+    return ImageUtils.resize(bitmap, width, height, false);
+  }
+
+  /**
+   * Resize an image to the given width and height considering the preserveAspectRatio flag.
+   * @param bitmap
+   * @param width
+   * @param height
+   * @param preserveAspectRatio
+   * @return a new, scaled Bitmap
+   */
+  public static Bitmap resize(Bitmap bitmap, final int width, final int height, final boolean preserveAspectRatio) {
+    if (preserveAspectRatio) {
+        return ImageUtils.resizePreservingAspectRatio(bitmap, width, height);
+    }
+    return ImageUtils.resizeImageWithoutPreservingAspectRatio(bitmap, width, height);
+  }
+
+  /**
+   * Resize an image to the given width and height. Leave one dimension 0 to
+   * perform an aspect-ratio scale on the provided dimension.
+   * @param bitmap
+   * @param width
+   * @param height
+   * @return a new, scaled Bitmap
+   */
+  private static Bitmap resizeImageWithoutPreservingAspectRatio(Bitmap bitmap, final int width, final int height) {
+    float aspect = bitmap.getWidth() / (float) bitmap.getHeight();
+    if (width > 0 && height > 0) {
+      return Bitmap.createScaledBitmap(bitmap, width, height, false);
+    } else if (width > 0) {
+      return Bitmap.createScaledBitmap(bitmap, width, (int) (width * 1/aspect), false);
+    } else if (height > 0) {
+      return Bitmap.createScaledBitmap(bitmap, (int) (height * aspect), height, false);
+    }
+
+    return bitmap;
+  }
+
+  /**
+   * Resize an image to the given max width and max height. Constraint can be put
+   * on one dimension, or both. Resize will always preserve aspect ratio.
+   * @param bitmap
+   * @param desiredMaxWidth
+   * @param desiredMaxHeight
+   * @return a new, scaled Bitmap
+   */
+  private static Bitmap resizePreservingAspectRatio(Bitmap bitmap, final int desiredMaxWidth, final int desiredMaxHeight) {
+    int width = bitmap.getWidth();
+    int height = bitmap.getHeight();
+
+    // 0 is treated as 'no restriction'
+    int maxHeight = desiredMaxHeight == 0 ? height : desiredMaxHeight;
+    int maxWidth = desiredMaxWidth == 0 ? width : desiredMaxWidth;
+
+    // resize with preserved aspect ratio
+    float newWidth = Math.min(width, maxWidth);
+    float newHeight = (height * newWidth) / width;
+
+    if (newHeight > maxHeight) {
+        newWidth = (width * maxHeight) / height;
+        newHeight = maxHeight;
+    }
+    return Bitmap.createScaledBitmap(bitmap, Math.round(newWidth), Math.round(newHeight), false);
+  }
+
+  /**
+   * Transform an image with the given matrix
+   * @param bitmap
+   * @param matrix
+   * @return
+   */
+  private static Bitmap transform(final Bitmap bitmap, final Matrix matrix) {
+    return Bitmap.createBitmap(bitmap, 0, 0, bitmap.getWidth(), bitmap.getHeight(), matrix, true);
+  }
+
+  /**
+   * Correct the orientation of an image by reading its exif information and rotating
+   * the appropriate amount for portrait mode
+   * @param bitmap
+   * @param imageUri
+   * @return
+   */
+  public static Bitmap correctOrientation(final Context c, final Bitmap bitmap, final Uri imageUri) throws IOException {
+    if(Build.VERSION.SDK_INT < 24) {
+      return correctOrientationOlder(c, bitmap, imageUri);
+    } else {
+      final int orientation = getOrientation(c, imageUri);
+
+      if (orientation != 0) {
+        Matrix matrix = new Matrix();
+        matrix.postRotate(orientation);
+
+        return transform(bitmap, matrix);
+      } else {
+        return bitmap;
+      }
+    }
+  }
+
+  private static Bitmap correctOrientationOlder(final Context c, final Bitmap bitmap, final Uri imageUri) {
+    // TODO: To be tested on older phone using Android API < 24
+
+    String[] orientationColumn = { MediaStore.Images.Media.DATA, MediaStore.Images.Media.ORIENTATION };
+    Cursor cur = c.getContentResolver().query(imageUri, orientationColumn, null, null, null);
+    int orientation = -1;
+    if (cur != null && cur.moveToFirst()) {
+      orientation = cur.getInt(cur.getColumnIndex(orientationColumn[0]));
+    }
+    Matrix matrix = new Matrix();
+
+    if (orientation != -1) {
+      matrix.postRotate(orientation);
+    }
+
+    return transform(bitmap, matrix);
+  }
+
+  private static int getOrientation(final Context c, final Uri imageUri) throws IOException {
+    int result = 0;
+
+    InputStream iStream = null;
+
+    try {
+      iStream = c.getContentResolver().openInputStream(imageUri);
+      final ExifInterface exifInterface = new ExifInterface(iStream);
+
+      final int orientation = exifInterface.getAttributeInt(ExifInterface.TAG_ORIENTATION, 1);
+
+      if (orientation == ExifInterface.ORIENTATION_ROTATE_90) {
+        result = 90;
+      } else if (orientation == ExifInterface.ORIENTATION_ROTATE_180) {
+        result = 180;
+      } else if (orientation == ExifInterface.ORIENTATION_ROTATE_270) {
+        result = 270;
+      }
+    } finally {
+       if (iStream != null) {
+         iStream.close();
+       }
+    }
+
+    return result;
+  }
+
+  public static ExifWrapper getExifData(final Context c, final Bitmap bitmap, final Uri imageUri) {
+    try {
+      String fu = FileUtils.getFileUrlForUri(c, imageUri);
+      final ExifInterface exifInterface = new ExifInterface(fu);
+
+      return new ExifWrapper(exifInterface);
+    } catch (IOException ex) {
+      Logger.error("Error loading exif data from image", ex);
+    } finally {
+    }
+    return new ExifWrapper(null);
+  }
+}

+ 221 - 0
android/capacitor/src/main/java/com/getcapacitor/plugin/notification/DateMatch.java

@@ -0,0 +1,221 @@
+package com.getcapacitor.plugin.notification;
+
+
+import java.util.Calendar;
+import java.util.Date;
+
+/**
+ * Class that holds logic for on triggers
+ * (Specific time)
+ */
+public class DateMatch {
+
+  private final static String separator = " ";
+
+  private Integer year;
+  private Integer month;
+  private Integer day;
+  private Integer hour;
+  private Integer minute;
+
+  // Unit used to save the last used unit for a trigger.
+  // One of the Calendar constants values
+  private Integer unit = -1;
+
+  public DateMatch() {
+  }
+
+  public Integer getYear() {
+    return year;
+  }
+
+  public void setYear(Integer year) {
+    this.year = year;
+  }
+
+  public Integer getMonth() {
+    return month;
+  }
+
+  public void setMonth(Integer month) {
+    this.month = month;
+  }
+
+  public Integer getDay() {
+    return day;
+  }
+
+  public void setDay(Integer day) {
+    this.day = day;
+  }
+
+  public Integer getHour() {
+    return hour;
+  }
+
+  public void setHour(Integer hour) {
+    this.hour = hour;
+  }
+
+  public Integer getMinute() {
+    return minute;
+  }
+
+  public void setMinute(Integer minute) {
+    this.minute = minute;
+  }
+
+  /**
+   * Gets a calendar instance pointing to the specified date.
+   *
+   * @param date The date to point.
+   */
+  private Calendar buildCalendar(Date date) {
+    Calendar cal = Calendar.getInstance();
+    cal.setTime(date);
+    cal.set(Calendar.MILLISECOND, 0);
+    cal.set(Calendar.SECOND, 0);
+    return cal;
+  }
+
+  /**
+   * Calculates next trigger date for
+   *
+   * @param date base date used to calculate trigger
+   * @return next trigger timestamp
+   */
+  public long nextTrigger(Date date) {
+    Calendar current = buildCalendar(date);
+    Calendar next = buildNextTriggerTime(date);
+    return postponeTriggerIfNeeded(current, next);
+  }
+
+  /**
+   * Postpone trigger if first schedule matches the past
+   */
+  private long postponeTriggerIfNeeded(Calendar current, Calendar next) {
+    if (next.getTimeInMillis() <= current.getTimeInMillis() && unit != -1) {
+      Integer incrementUnit = -1;
+      if (unit == Calendar.YEAR || unit == Calendar.MONTH) {
+        incrementUnit = Calendar.YEAR;
+      } else if (unit == Calendar.DAY_OF_MONTH) {
+        incrementUnit = Calendar.MONTH;
+      } else if (unit == Calendar.HOUR_OF_DAY) {
+        incrementUnit = Calendar.DAY_OF_MONTH;
+      } else if (unit == Calendar.MINUTE) {
+        incrementUnit = Calendar.HOUR_OF_DAY;
+      }
+
+      if (incrementUnit != -1) {
+        next.set(incrementUnit, next.get(incrementUnit) + 1);
+      }
+    }
+    return next.getTimeInMillis();
+  }
+
+  private Calendar buildNextTriggerTime(Date date) {
+    Calendar next = buildCalendar(date);
+    if (year != null) {
+      next.set(Calendar.YEAR, year);
+      if (unit == -1) unit = Calendar.YEAR;
+    }
+    if (month != null) {
+      next.set(Calendar.MONTH, month);
+      if (unit == -1) unit = Calendar.MONTH;
+    }
+    if (day != null) {
+      next.set(Calendar.DAY_OF_MONTH, day);
+      if (unit == -1) unit = Calendar.DAY_OF_MONTH;
+    }
+    if (hour != null) {
+      next.set(Calendar.HOUR_OF_DAY, hour);
+      if (unit == -1) unit = Calendar.HOUR_OF_DAY;
+    }
+    if (minute != null) {
+      next.set(Calendar.MINUTE, minute);
+      if (unit == -1) unit = Calendar.MINUTE;
+    }
+    return next;
+  }
+
+  @Override
+  public String toString() {
+    return "DateMatch{" +
+            "year=" + year +
+            ", month=" + month +
+            ", day=" + day +
+            ", hour=" + hour +
+            ", minute=" + minute +
+            '}';
+  }
+
+  @Override
+  public boolean equals(Object o) {
+    if (this == o) return true;
+    if (o == null || getClass() != o.getClass()) return false;
+
+    DateMatch dateMatch = (DateMatch) o;
+
+    if (year != null ? !year.equals(dateMatch.year) : dateMatch.year != null) return false;
+    if (month != null ? !month.equals(dateMatch.month) : dateMatch.month != null) return false;
+    if (day != null ? !day.equals(dateMatch.day) : dateMatch.day != null) return false;
+    if (hour != null ? !hour.equals(dateMatch.hour) : dateMatch.hour != null) return false;
+    return minute != null ? minute.equals(dateMatch.minute) : dateMatch.minute == null;
+  }
+
+  @Override
+  public int hashCode() {
+    int result = year != null ? year.hashCode() : 0;
+    result = 31 * result + (month != null ? month.hashCode() : 0);
+    result = 31 * result + (day != null ? day.hashCode() : 0);
+    result = 31 * result + (hour != null ? hour.hashCode() : 0);
+    result = 31 * result + (minute != null ? minute.hashCode() : 0);
+    return result;
+  }
+
+  /**
+   * Transform DateMatch object to CronString
+   *
+   * @return
+   */
+  public String toMatchString() {
+    String matchString = year + separator + month + separator + day + separator + hour + separator + minute + separator + unit;
+    return matchString.replace("null", "*");
+  }
+
+  /**
+   * Create DateMatch object from stored string
+   *
+   * @param matchString
+   * @return
+   */
+  public static DateMatch fromMatchString(String matchString) {
+    DateMatch date = new DateMatch();
+    String[] split = matchString.split(separator);
+    if (split != null && split.length == 6) {
+      date.setYear(getValueFromCronElement(split[0]));
+      date.setMonth(getValueFromCronElement(split[1]));
+      date.setDay(getValueFromCronElement(split[2]));
+      date.setHour(getValueFromCronElement(split[3]));
+      date.setMinute(getValueFromCronElement(split[4]));
+      date.setUnit(getValueFromCronElement(split[5]));
+    }
+    return date;
+  }
+
+  public static Integer getValueFromCronElement(String token) {
+    try {
+      return Integer.parseInt(token);
+    } catch (NumberFormatException e) {
+      return null;
+    }
+  }
+
+  public Integer getUnit() {
+    return unit;
+  }
+
+  public void setUnit(Integer unit) {
+    this.unit = unit;
+  }
+}

+ 378 - 0
android/capacitor/src/main/java/com/getcapacitor/plugin/notification/LocalNotification.java

@@ -0,0 +1,378 @@
+package com.getcapacitor.plugin.notification;
+
+import android.content.ContentResolver;
+import android.content.Context;
+import android.net.Uri;
+
+import com.getcapacitor.Config;
+import com.getcapacitor.JSArray;
+import com.getcapacitor.JSObject;
+import com.getcapacitor.Logger;
+import com.getcapacitor.PluginCall;
+import com.getcapacitor.plugin.util.AssetUtil;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.text.ParseException;
+import java.util.ArrayList;
+import java.util.List;
+
+
+/**
+ * Local notification object mapped from json plugin
+ */
+public class LocalNotification {
+
+  private String title;
+  private String body;
+  private Integer id;
+  private String sound;
+  private String smallIcon;
+  private String iconColor;
+  private String actionTypeId;
+  private String group;
+  private boolean groupSummary;
+  private boolean ongoing;
+  private boolean autoCancel;
+  private JSObject extra;
+  private List<LocalNotificationAttachment> attachments;
+  private LocalNotificationSchedule schedule;
+  private String channelId;
+
+  private String source;
+
+  public String getTitle() {
+    return title;
+  }
+
+  public void setTitle(String title) {
+    this.title = title;
+  }
+
+  public String getBody() {
+    return body;
+  }
+
+  public void setBody(String body) {
+    this.body = body;
+  }
+
+
+  public LocalNotificationSchedule getSchedule() {
+    return schedule;
+  }
+
+  public void setSchedule(LocalNotificationSchedule schedule) {
+    this.schedule = schedule;
+  }
+
+  public String getSound(Context context, int defaultSound) {
+    String soundPath = null;
+    int resId = AssetUtil.RESOURCE_ID_ZERO_VALUE;
+    String name = AssetUtil.getResourceBaseName(sound);
+    if (name != null) {
+      resId = AssetUtil.getResourceID(context, name, "raw");
+    }
+    if (resId == AssetUtil.RESOURCE_ID_ZERO_VALUE) {
+      resId = defaultSound;
+    }
+    if(resId != AssetUtil.RESOURCE_ID_ZERO_VALUE){
+      soundPath = ContentResolver.SCHEME_ANDROID_RESOURCE + "://" + context.getPackageName() + "/" + resId;
+    }
+    return soundPath;
+  }
+
+  public void setSound(String sound) {
+    this.sound = sound;
+  }
+
+  public void setSmallIcon(String smallIcon) { this.smallIcon = AssetUtil.getResourceBaseName(smallIcon); }
+
+  public String getIconColor(String globalColor) {
+    // use the one defined local before trying for a globally defined color
+    if (iconColor != null) {
+      return iconColor;
+    } 
+
+    if (globalColor != null) {
+      return globalColor;
+    }
+
+    return null;
+  }
+
+  public void setIconColor(String iconColor) {
+    this.iconColor = iconColor;
+  }
+
+  public List<LocalNotificationAttachment> getAttachments() {
+    return attachments;
+  }
+
+  public void setAttachments(List<LocalNotificationAttachment> attachments) {
+    this.attachments = attachments;
+  }
+
+  public String getActionTypeId() {
+    return actionTypeId;
+  }
+
+  public void setActionTypeId(String actionTypeId) {
+    this.actionTypeId = actionTypeId;
+  }
+
+  public String getGroup() {
+    return group;
+  }
+
+  public void setGroup(String group) {
+    this.group = group;
+  }
+
+  public JSObject getExtra() {
+    return extra;
+  }
+
+  public void setExtra(JSObject extra) {
+    this.extra = extra;
+  }
+
+  public Integer getId() {
+    return id;
+  }
+
+  public void setId(Integer id) {
+    this.id = id;
+  }
+
+  public boolean isGroupSummary() {
+    return groupSummary;
+  }
+
+  public void setGroupSummary(boolean groupSummary) {
+    this.groupSummary = groupSummary;
+  }
+
+  public boolean isOngoing() {
+    return ongoing;
+  }
+
+  public void setOngoing(boolean ongoing) {
+    this.ongoing = ongoing;
+  }
+
+  public boolean isAutoCancel() {
+    return autoCancel;
+  }
+
+  public void setAutoCancel(boolean autoCancel) {
+    this.autoCancel = autoCancel;
+  }
+
+  public String getChannelId() {
+    return channelId;
+  }
+
+  public void setChannelId(String channelId) {
+    this.channelId = channelId;
+  }
+
+  /**
+   * Build list of the notifications from remote plugin call
+   */
+  public static List<LocalNotification> buildNotificationList(PluginCall call) {
+    JSArray notificationArray = call.getArray("notifications");
+    if (notificationArray == null) {
+      call.error("Must provide notifications array as notifications option");
+      return null;
+    }
+    List<LocalNotification> resultLocalNotifications = new ArrayList<>(notificationArray.length());
+    List<JSONObject> notificationsJson;
+    try {
+      notificationsJson = notificationArray.toList();
+    } catch (JSONException e) {
+      call.error("Provided notification format is invalid");
+      return null;
+    }
+
+    for (JSONObject jsonNotification : notificationsJson) {
+      JSObject notification = null;
+      try {
+        notification = JSObject.fromJSONObject(jsonNotification);
+      } catch (JSONException e) {
+        call.error("Invalid JSON object sent to NotificationPlugin", e);
+        return null;
+      }
+      
+      try {
+          LocalNotification activeLocalNotification = buildNotificationFromJSObject(notification);
+          resultLocalNotifications.add(activeLocalNotification);
+      } catch (ParseException e) {
+        call.error("Invalid date format sent to Notification plugin", e);
+        return null;
+      }
+    }
+    return resultLocalNotifications;
+  }
+
+  public static LocalNotification buildNotificationFromJSObject(JSObject jsonObject) throws ParseException {
+      LocalNotification localNotification = new LocalNotification();
+      localNotification.setSource(jsonObject.toString());
+      localNotification.setId(jsonObject.getInteger("id"));
+      localNotification.setBody(jsonObject.getString("body"));
+      localNotification.setActionTypeId(jsonObject.getString("actionTypeId"));
+      localNotification.setGroup(jsonObject.getString("group"));
+      localNotification.setSound(jsonObject.getString("sound"));
+      localNotification.setTitle(jsonObject.getString("title"));
+      localNotification.setSmallIcon(jsonObject.getString("smallIcon"));
+      localNotification.setIconColor(jsonObject.getString("iconColor"));
+      localNotification.setAttachments(LocalNotificationAttachment.getAttachments(jsonObject));
+      localNotification.setGroupSummary(jsonObject.getBoolean("groupSummary", false));
+      localNotification.setChannelId(jsonObject.getString("channelId"));
+      localNotification.setSchedule(new LocalNotificationSchedule(jsonObject));
+      localNotification.setExtra(jsonObject.getJSObject("extra"));
+      localNotification.setOngoing(jsonObject.getBoolean("ongoing", false));
+      localNotification.setAutoCancel(jsonObject.getBoolean("autoCancel", true));
+
+      return localNotification;
+  }
+
+  public static List<Integer> getLocalNotificationPendingList(PluginCall call) {
+    List<JSONObject> notifications = null;
+    try {
+      notifications = call.getArray("notifications").toList();
+    } catch (JSONException e) {
+    }
+    if (notifications == null || notifications.size() == 0) {
+      call.error("Must provide notifications array as notifications option");
+      return null;
+    }
+    List<Integer> notificationsList = new ArrayList<>(notifications.size());
+    for (JSONObject notificationToCancel : notifications) {
+      try {
+        notificationsList.add(notificationToCancel.getInt("id"));
+      } catch (JSONException e) {
+      }
+    }
+    return notificationsList;
+  }
+
+  public static JSObject buildLocalNotificationPendingList(List<String> ids) {
+    JSObject result = new JSObject();
+    JSArray jsArray = new JSArray();
+    for (String id : ids) {
+      JSObject notification = new JSObject();
+      notification.put("id", id);
+      jsArray.put(notification);
+    }
+    result.put("notifications", jsArray);
+    return result;
+  }
+
+  public int getSmallIcon(Context context, int defaultIcon) {
+    int resId = AssetUtil.RESOURCE_ID_ZERO_VALUE;
+
+    if(smallIcon != null){
+      resId = AssetUtil.getResourceID(context, smallIcon,"drawable");
+    }
+
+    if(resId == AssetUtil.RESOURCE_ID_ZERO_VALUE){
+      resId = defaultIcon;
+    }
+
+    return resId;
+  }
+
+
+
+  public boolean isScheduled() {
+    return this.schedule != null &&
+            (this.schedule.getOn() != null ||
+                    this.schedule.getAt() != null ||
+                    this.schedule.getEvery() != null);
+  }
+
+  @Override
+  public String toString() {
+    return "LocalNotification{" +
+            "title='" + title + '\'' +
+            ", body='" + body + '\'' +
+            ", id=" + id +
+            ", sound='" + sound + '\'' +
+            ", smallIcon='" + smallIcon + '\'' +
+            ", iconColor='" + iconColor + '\'' +
+            ", actionTypeId='" + actionTypeId + '\'' +
+            ", group='" + group + '\'' +
+            ", extra=" + extra +
+            ", attachments=" + attachments +
+            ", schedule=" + schedule +
+            ", groupSummary=" + groupSummary +
+            ", ongoing=" + ongoing +
+            ", autoCancel=" + autoCancel +
+            '}';
+  }
+
+  @Override
+  public boolean equals(Object o) {
+    if (this == o) return true;
+    if (o == null || getClass() != o.getClass()) return false;
+
+    LocalNotification that = (LocalNotification) o;
+
+    if (title != null ? !title.equals(that.title) : that.title != null) return false;
+    if (body != null ? !body.equals(that.body) : that.body != null) return false;
+    if (id != null ? !id.equals(that.id) : that.id != null) return false;
+    if (sound != null ? !sound.equals(that.sound) : that.sound != null) return false;
+    if (smallIcon != null ? !smallIcon.equals(that.smallIcon) : that.smallIcon != null) return false;
+    if (iconColor != null ? !iconColor.equals(that.iconColor) : that.iconColor != null) return false;
+    if (actionTypeId != null ? !actionTypeId.equals(that.actionTypeId) : that.actionTypeId != null)
+      return false;
+    if (group != null ? !group.equals(that.group) : that.group != null) return false;
+    if (extra != null ? !extra.equals(that.extra) : that.extra != null) return false;
+    if (attachments != null ? !attachments.equals(that.attachments) : that.attachments != null)
+      return false;
+    if (groupSummary != that.groupSummary) return false;
+    if( ongoing != that.ongoing ) return false;
+    if( autoCancel != that.autoCancel ) return false;
+    return schedule != null ? schedule.equals(that.schedule) : that.schedule == null;
+  }
+
+  @Override
+  public int hashCode() {
+    int result = title != null ? title.hashCode() : 0;
+    result = 31 * result + (body != null ? body.hashCode() : 0);
+    result = 31 * result + (id != null ? id.hashCode() : 0);
+    result = 31 * result + (sound != null ? sound.hashCode() : 0);
+    result = 31 * result + (smallIcon != null ? smallIcon.hashCode() : 0);
+    result = 31 * result + (iconColor != null ? iconColor.hashCode() : 0);
+    result = 31 * result + (actionTypeId != null ? actionTypeId.hashCode() : 0);
+    result = 31 * result + (group != null ? group.hashCode() : 0);
+    result = 31 * result + Boolean.hashCode(groupSummary);
+    result = 31 * result + Boolean.hashCode( ongoing );
+    result = 31 * result + Boolean.hashCode( autoCancel );
+    result = 31 * result + (extra != null ? extra.hashCode() : 0);
+    result = 31 * result + (attachments != null ? attachments.hashCode() : 0);
+    result = 31 * result + (schedule != null ? schedule.hashCode() : 0);
+    return result;
+  }
+
+
+  public void setExtraFromString(String extraFromString) {
+    try {
+      JSONObject jsonObject = new JSONObject(extraFromString);
+      this.extra = JSObject.fromJSONObject(jsonObject);
+    } catch (JSONException e) {
+      Logger.error(Logger.tags("LN"), "Cannot rebuild extra data", e);
+    }
+  }
+
+  public String getSource() {
+    return source;
+  }
+
+  public void setSource(String source) {
+    this.source = source;
+  }
+
+}

+ 75 - 0
android/capacitor/src/main/java/com/getcapacitor/plugin/notification/LocalNotificationAttachment.java

@@ -0,0 +1,75 @@
+package com.getcapacitor.plugin.notification;
+
+import com.getcapacitor.JSObject;
+
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public class LocalNotificationAttachment {
+  private String id;
+  private String url;
+  private JSONObject options;
+
+  public String getId() {
+    return id;
+  }
+
+  public void setId(String id) {
+    this.id = id;
+  }
+
+  public String getUrl() {
+    return url;
+  }
+
+  public void setUrl(String url) {
+    this.url = url;
+  }
+
+  public JSONObject getOptions() {
+    return options;
+  }
+
+  public void setOptions(JSONObject options) {
+    this.options = options;
+  }
+
+  public static List<LocalNotificationAttachment> getAttachments(JSObject notification) {
+    List<LocalNotificationAttachment> attachmentsList = new ArrayList<>();
+    JSONArray attachments = null;
+    try {
+      attachments = notification.getJSONArray("attachments");
+    } catch (Exception e) {
+    }
+    if (attachments != null) {
+      for (int i = 0; i < attachments.length(); i++) {
+        LocalNotificationAttachment newAttachment = new LocalNotificationAttachment();
+        JSONObject jsonObject = null;
+        try {
+          jsonObject = attachments.getJSONObject(i);
+        } catch (JSONException e) {
+        }
+        if (jsonObject != null) {
+          JSObject jsObject = null;
+          try {
+            jsObject = JSObject.fromJSONObject(jsonObject);
+          } catch (JSONException e) {
+          }
+          newAttachment.setId(jsObject.getString("id"));
+          newAttachment.setUrl(jsObject.getString("url"));
+          try {
+            newAttachment.setOptions(jsObject.getJSONObject("options"));
+          } catch (JSONException e) {
+          }
+          attachmentsList.add(newAttachment);
+        }
+      }
+    }
+
+    return attachmentsList;
+  }
+}

+ 424 - 0
android/capacitor/src/main/java/com/getcapacitor/plugin/notification/LocalNotificationManager.java

@@ -0,0 +1,424 @@
+package com.getcapacitor.plugin.notification;
+
+import android.app.Activity;
+import android.app.AlarmManager;
+import android.app.Notification;
+import android.app.NotificationChannel;
+import android.app.PendingIntent;
+import android.content.ContentResolver;
+import android.content.Context;
+import android.content.Intent;
+import android.graphics.Color;
+import android.media.AudioAttributes;
+import android.net.Uri;
+import android.os.Build;
+import android.os.Bundle;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.core.app.NotificationCompat;
+import androidx.core.app.NotificationManagerCompat;
+import androidx.core.app.RemoteInput;
+
+import com.getcapacitor.CapConfig;
+import com.getcapacitor.Config;
+import com.getcapacitor.JSObject;
+import com.getcapacitor.Logger;
+import com.getcapacitor.PluginCall;
+import com.getcapacitor.android.R;
+import com.getcapacitor.plugin.util.AssetUtil;
+
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.text.SimpleDateFormat;
+import java.util.Date;
+import java.util.List;
+
+
+/**
+ * Contains implementations for all notification actions
+ */
+public class LocalNotificationManager {
+
+  private static final String CONFIG_KEY_PREFIX = "plugins.LocalNotifications.";
+  private static int defaultSoundID = AssetUtil.RESOURCE_ID_ZERO_VALUE;
+  private static int defaultSmallIconID = AssetUtil.RESOURCE_ID_ZERO_VALUE;
+  // Action constants
+  public static final String NOTIFICATION_INTENT_KEY = "LocalNotificationId";
+  public static final String NOTIFICATION_OBJ_INTENT_KEY = "LocalNotficationObject";
+  public static final String ACTION_INTENT_KEY = "LocalNotificationUserAction";
+  public static final String NOTIFICATION_IS_REMOVABLE_KEY = "LocalNotificationRepeating";
+  public static final String REMOTE_INPUT_KEY = "LocalNotificationRemoteInput";
+
+  public static final String DEFAULT_NOTIFICATION_CHANNEL_ID = "default";
+  private static final String DEFAULT_PRESS_ACTION = "tap";
+
+  private Context context;
+  private Activity activity;
+  private NotificationStorage storage;
+  private CapConfig config;
+
+  public LocalNotificationManager(NotificationStorage notificationStorage, Activity activity, Context context, CapConfig config) {
+    storage = notificationStorage;
+    this.activity = activity;
+    this.context = context;
+    this.config = config;
+  }
+
+  /**
+   * Method extecuted when notification is launched by user from the notification bar.
+   */
+  public JSObject handleNotificationActionPerformed(Intent data, NotificationStorage notificationStorage) {
+    Logger.debug(Logger.tags("LN"), "LocalNotification received: " + data.getDataString());
+    int notificationId = data.getIntExtra(LocalNotificationManager.NOTIFICATION_INTENT_KEY, Integer.MIN_VALUE);
+    if (notificationId == Integer.MIN_VALUE) {
+      Logger.debug(Logger.tags("LN"), "Activity started without notification attached");
+      return null;
+    }
+    boolean isRemovable = data.getBooleanExtra(LocalNotificationManager.NOTIFICATION_IS_REMOVABLE_KEY, true);
+    if (isRemovable) {
+      notificationStorage.deleteNotification(Integer.toString(notificationId));
+    }
+    JSObject dataJson = new JSObject();
+
+    Bundle results = RemoteInput.getResultsFromIntent(data);
+    if (results != null) {
+      CharSequence input = results.getCharSequence(LocalNotificationManager.REMOTE_INPUT_KEY);
+      dataJson.put("inputValue", input.toString());
+    }
+    String menuAction = data.getStringExtra(LocalNotificationManager.ACTION_INTENT_KEY);
+
+    dismissVisibleNotification(notificationId);
+
+    dataJson.put("actionId", menuAction);
+    JSONObject request = null;
+    try {
+      String notificationJsonString = data.getStringExtra(LocalNotificationManager.NOTIFICATION_OBJ_INTENT_KEY);
+      if (notificationJsonString != null) {
+        request = new JSObject(notificationJsonString);
+      }
+    } catch (JSONException e) {
+    }
+    dataJson.put("notification", request);
+    return dataJson;
+  }
+
+  /**
+   * Create notification channel
+   */
+  public void createNotificationChannel() {
+    // Create the NotificationChannel, but only on API 26+ because
+    // the NotificationChannel class is new and not in the support library
+    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+      CharSequence name = "Default";
+      String description = "Default";
+      int importance = android.app.NotificationManager.IMPORTANCE_DEFAULT;
+      NotificationChannel channel = new NotificationChannel(DEFAULT_NOTIFICATION_CHANNEL_ID, name, importance);
+      channel.setDescription(description);
+      AudioAttributes audioAttributes = new AudioAttributes.Builder()
+              .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
+              .setUsage(AudioAttributes.USAGE_ALARM).build();
+      Uri soundUri = this.getDefaultSoundUrl(context);
+      if (soundUri != null) {
+        channel.setSound(soundUri, audioAttributes);
+      }
+      // Register the channel with the system; you can't change the importance
+      // or other notification behaviors after this
+      android.app.NotificationManager notificationManager = context.getSystemService(android.app.NotificationManager.class);
+      notificationManager.createNotificationChannel(channel);
+    }
+  }
+
+  @Nullable
+  public JSONArray schedule(PluginCall call, List<LocalNotification> localNotifications) {
+    JSONArray ids = new JSONArray();
+    NotificationManagerCompat notificationManager = NotificationManagerCompat.from(context);
+
+    boolean notificationsEnabled = notificationManager.areNotificationsEnabled();
+    if (!notificationsEnabled) {
+      if(call != null){
+        call.error("Notifications not enabled on this device");
+      }
+      return null;
+    }
+    for (LocalNotification localNotification : localNotifications) {
+      Integer id = localNotification.getId();
+      if (localNotification.getId() == null) {
+        if(call != null) {
+          call.error("LocalNotification missing identifier");
+        }
+        return null;
+      }
+      dismissVisibleNotification(id);
+      cancelTimerForNotification(id);
+      buildNotification(notificationManager, localNotification, call);
+      ids.put(id);
+    }
+    return ids;
+  }
+
+  // TODO Progressbar support
+  // TODO System categories (DO_NOT_DISTURB etc.)
+  // TODO control visibility by flag Notification.VISIBILITY_PRIVATE
+  // TODO Group notifications (setGroup, setGroupSummary, setNumber)
+  // TODO use NotificationCompat.MessagingStyle for latest API
+  // TODO expandable notification NotificationCompat.MessagingStyle
+  // TODO media style notification support NotificationCompat.MediaStyle
+  // TODO custom small/large icons
+  private void buildNotification(NotificationManagerCompat notificationManager, LocalNotification localNotification, PluginCall call) {
+    String channelId = DEFAULT_NOTIFICATION_CHANNEL_ID;
+    if (localNotification.getChannelId() != null) {
+      channelId = localNotification.getChannelId();
+    }
+    NotificationCompat.Builder mBuilder = new NotificationCompat.Builder(this.context, channelId)
+            .setContentTitle(localNotification.getTitle())
+            .setContentText(localNotification.getBody())
+            .setAutoCancel( localNotification.isAutoCancel( ) )
+            .setOngoing( localNotification.isOngoing( ) )
+            .setPriority(NotificationCompat.PRIORITY_DEFAULT)
+            .setGroupSummary(localNotification.isGroupSummary());
+
+
+    // support multiline text
+    mBuilder.setStyle(new NotificationCompat.BigTextStyle().bigText(localNotification.getBody()));
+
+    String sound = localNotification.getSound(context, getDefaultSound(context));
+    if (sound != null) {
+      Uri soundUri = Uri.parse(sound);
+      // Grant permission to use sound
+      context.grantUriPermission(
+              "com.android.systemui", soundUri,
+              Intent.FLAG_GRANT_READ_URI_PERMISSION);
+      mBuilder.setSound(soundUri);
+      mBuilder.setDefaults(Notification.DEFAULT_VIBRATE | Notification.DEFAULT_LIGHTS);
+    } else {
+      mBuilder.setDefaults(Notification.DEFAULT_ALL);
+    }
+
+
+    String group = localNotification.getGroup();
+    if (group != null) {
+      mBuilder.setGroup(group);
+    }
+
+    // make sure scheduled time is shown instead of display time
+    if (localNotification.isScheduled() && localNotification.getSchedule().getAt() != null) {
+      mBuilder.setWhen(localNotification.getSchedule().getAt().getTime())
+              .setShowWhen(true);
+    }
+
+    mBuilder.setVisibility(NotificationCompat.VISIBILITY_PRIVATE);
+    mBuilder.setOnlyAlertOnce(true);
+
+    mBuilder.setSmallIcon(localNotification.getSmallIcon(context, getDefaultSmallIcon(context)));
+
+    String iconColor = localNotification.getIconColor(config.getString(CONFIG_KEY_PREFIX + "iconColor"));
+    if (iconColor != null) {
+      try {
+        mBuilder.setColor(Color.parseColor(iconColor));
+      } catch (IllegalArgumentException ex) {
+        if(call != null) {
+            call.error("Invalid color provided. Must be a hex string (ex: #ff0000");
+        }
+        return;
+      }
+    }
+
+    createActionIntents(localNotification, mBuilder);
+    // notificationId is a unique int for each localNotification that you must define
+    Notification buildNotification = mBuilder.build();
+    if (localNotification.isScheduled()) {
+      triggerScheduledNotification(buildNotification, localNotification);
+    } else {
+      notificationManager.notify(localNotification.getId(), buildNotification);
+    }
+  }
+
+  // Create intents for open/dissmis actions
+  private void createActionIntents(LocalNotification localNotification, NotificationCompat.Builder mBuilder) {
+    // Open intent
+    Intent intent = buildIntent(localNotification, DEFAULT_PRESS_ACTION);
+
+    PendingIntent pendingIntent = PendingIntent.getActivity(context, localNotification.getId(), intent, PendingIntent.FLAG_CANCEL_CURRENT);
+    mBuilder.setContentIntent(pendingIntent);
+
+    // Build action types
+    String actionTypeId = localNotification.getActionTypeId();
+    if (actionTypeId != null) {
+      NotificationAction[] actionGroup = storage.getActionGroup(actionTypeId);
+      for (int i = 0; i < actionGroup.length; i++) {
+        NotificationAction notificationAction = actionGroup[i];
+        // TODO Add custom icons to actions
+        Intent actionIntent = buildIntent(localNotification, notificationAction.getId());
+        PendingIntent actionPendingIntent = PendingIntent.getActivity(context, localNotification.getId() + notificationAction.getId().hashCode(), actionIntent, PendingIntent.FLAG_CANCEL_CURRENT);
+        NotificationCompat.Action.Builder actionBuilder = new NotificationCompat.Action.Builder(R.drawable.ic_transparent, notificationAction.getTitle(), actionPendingIntent);
+        if (notificationAction.isInput()) {
+          RemoteInput remoteInput = new RemoteInput.Builder(REMOTE_INPUT_KEY)
+                  .setLabel(notificationAction.getTitle())
+                  .build();
+          actionBuilder.addRemoteInput(remoteInput);
+        }
+        mBuilder.addAction(actionBuilder.build());
+      }
+    }
+
+    // Dismiss intent
+    Intent dissmissIntent = new Intent(context, NotificationDismissReceiver.class);
+    dissmissIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK);
+    dissmissIntent.putExtra(NOTIFICATION_INTENT_KEY, localNotification.getId());
+    dissmissIntent.putExtra(ACTION_INTENT_KEY, "dismiss");
+    LocalNotificationSchedule schedule = localNotification.getSchedule();
+    dissmissIntent.putExtra(NOTIFICATION_IS_REMOVABLE_KEY, schedule == null || schedule.isRemovable());
+    PendingIntent deleteIntent = PendingIntent.getBroadcast(
+            context, localNotification.getId(), dissmissIntent, 0);
+    mBuilder.setDeleteIntent(deleteIntent);
+  }
+
+  @NonNull
+  private Intent buildIntent(LocalNotification localNotification, String action) {
+    Intent intent;
+    if (activity != null) {
+      intent = new Intent(context, activity.getClass());
+    } else {
+      String packageName = context.getPackageName();
+      intent = context.getPackageManager().getLaunchIntentForPackage(packageName);
+    }
+    intent.setAction(Intent.ACTION_MAIN);
+    intent.addCategory(Intent.CATEGORY_LAUNCHER);
+    intent.setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP | Intent.FLAG_ACTIVITY_CLEAR_TOP);
+    intent.putExtra(NOTIFICATION_INTENT_KEY, localNotification.getId());
+    intent.putExtra(ACTION_INTENT_KEY, action);
+    intent.putExtra(NOTIFICATION_OBJ_INTENT_KEY, localNotification.getSource());
+    LocalNotificationSchedule schedule = localNotification.getSchedule();
+    intent.putExtra(NOTIFICATION_IS_REMOVABLE_KEY, schedule == null || schedule.isRemovable());
+    return intent;
+  }
+
+  /**
+   * Build a notification trigger, such as triggering each N seconds, or
+   * on a certain date "shape" (such as every first of the month)
+   */
+  // TODO support different AlarmManager.RTC modes depending on priority
+  private void triggerScheduledNotification(Notification notification, LocalNotification request) {
+    AlarmManager alarmManager = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE);
+    LocalNotificationSchedule schedule = request.getSchedule();
+    Intent notificationIntent = new Intent(context, TimedNotificationPublisher.class);
+    notificationIntent.putExtra(NOTIFICATION_INTENT_KEY, request.getId());
+    notificationIntent.putExtra(TimedNotificationPublisher.NOTIFICATION_KEY, notification);
+    PendingIntent pendingIntent = PendingIntent.getBroadcast(context, request.getId(), notificationIntent, PendingIntent.FLAG_CANCEL_CURRENT);
+
+    // Schedule at specific time (with repeating support)
+    Date at = schedule.getAt();
+    if (at != null) {
+      if (at.getTime() < new Date().getTime()) {
+        Logger.error(Logger.tags("LN"), "Scheduled time must be *after* current time", null);
+        return;
+      }
+      if (schedule.isRepeating()) {
+        long interval = at.getTime() - new Date().getTime();
+        alarmManager.setRepeating(AlarmManager.RTC, at.getTime(), interval, pendingIntent);
+      } else {
+        alarmManager.setExact(AlarmManager.RTC, at.getTime(), pendingIntent);
+      }
+      return;
+    }
+
+    // Schedule at specific intervals
+    String every = schedule.getEvery();
+    if (every != null) {
+      Long everyInterval = schedule.getEveryInterval();
+      if (everyInterval != null) {
+        long startTime = new Date().getTime() + everyInterval;
+        alarmManager.setRepeating(AlarmManager.RTC, startTime, everyInterval, pendingIntent);
+      }
+      return;
+    }
+
+    // Cron like scheduler
+    DateMatch on = schedule.getOn();
+    if (on != null) {
+      long trigger = on.nextTrigger(new Date());
+      notificationIntent.putExtra(TimedNotificationPublisher.CRON_KEY, on.toMatchString());
+      pendingIntent = PendingIntent.getBroadcast(context, request.getId(), notificationIntent, PendingIntent.FLAG_CANCEL_CURRENT);
+      alarmManager.setExact(AlarmManager.RTC, trigger, pendingIntent);
+      SimpleDateFormat sdf = new SimpleDateFormat("yyyy/MM/dd HH:mm:ss");
+      Logger.debug(Logger.tags("LN"), "notification " + request.getId() + " will next fire at " + sdf.format(new Date(trigger)));
+    }
+  }
+
+  public void cancel(PluginCall call) {
+    List<Integer> notificationsToCancel = LocalNotification.getLocalNotificationPendingList(call);
+    if (notificationsToCancel != null) {
+      for (Integer id : notificationsToCancel) {
+        dismissVisibleNotification(id);
+        cancelTimerForNotification(id);
+        storage.deleteNotification(Integer.toString(id));
+      }
+    }
+    call.success();
+  }
+
+  private void cancelTimerForNotification(Integer notificationId) {
+    Intent intent = new Intent(context, TimedNotificationPublisher.class);
+    PendingIntent pi = PendingIntent.getBroadcast(
+            context, notificationId, intent, 0);
+    if (pi != null) {
+      AlarmManager alarmManager = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE);
+      alarmManager.cancel(pi);
+    }
+  }
+
+  private void dismissVisibleNotification(int notificationId) {
+    NotificationManagerCompat notificationManager = NotificationManagerCompat.from(this.context);
+    notificationManager.cancel(notificationId);
+  }
+
+  public boolean areNotificationsEnabled(){
+    NotificationManagerCompat notificationManager = NotificationManagerCompat.from(context);
+    return notificationManager.areNotificationsEnabled();
+  }
+
+  public Uri getDefaultSoundUrl(Context context){
+    int soundId = this.getDefaultSound(context);
+    if (soundId != AssetUtil.RESOURCE_ID_ZERO_VALUE) {
+      return Uri.parse(ContentResolver.SCHEME_ANDROID_RESOURCE + "://" + context.getPackageName() + "/" + soundId);
+    }
+    return null;
+  }
+
+  private int getDefaultSound(Context context){
+    if(defaultSoundID != AssetUtil.RESOURCE_ID_ZERO_VALUE) return defaultSoundID;
+
+    int resId = AssetUtil.RESOURCE_ID_ZERO_VALUE;
+    String soundConfigResourceName = config.getString(CONFIG_KEY_PREFIX + "sound");
+    soundConfigResourceName = AssetUtil.getResourceBaseName(soundConfigResourceName);
+
+    if(soundConfigResourceName != null){
+      resId = AssetUtil.getResourceID(context, soundConfigResourceName, "raw");
+    }
+
+    defaultSoundID = resId;
+    return resId;
+  }
+
+  private int getDefaultSmallIcon(Context context){
+    if(defaultSmallIconID != AssetUtil.RESOURCE_ID_ZERO_VALUE) return defaultSmallIconID;
+
+    int resId = AssetUtil.RESOURCE_ID_ZERO_VALUE;
+    String smallIconConfigResourceName = config.getString(CONFIG_KEY_PREFIX + "smallIcon");
+    smallIconConfigResourceName = AssetUtil.getResourceBaseName(smallIconConfigResourceName);
+
+    if(smallIconConfigResourceName != null){
+      resId = AssetUtil.getResourceID(context, smallIconConfigResourceName, "drawable");
+    }
+
+    if(resId == AssetUtil.RESOURCE_ID_ZERO_VALUE){
+      resId = android.R.drawable.ic_dialog_info;
+    }
+
+    defaultSmallIconID = resId;
+    return resId;
+  }
+}

+ 60 - 0
android/capacitor/src/main/java/com/getcapacitor/plugin/notification/LocalNotificationRestoreReceiver.java

@@ -0,0 +1,60 @@
+package com.getcapacitor.plugin.notification;
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.os.UserManager;
+
+import com.getcapacitor.CapConfig;
+
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.List;
+
+import static android.os.Build.VERSION.SDK_INT;
+
+public class LocalNotificationRestoreReceiver extends BroadcastReceiver {
+
+    @Override
+    public void onReceive(Context context, Intent intent) {
+        if (SDK_INT >= 24) {
+            UserManager um = context.getSystemService(UserManager.class);
+            if (um == null || !um.isUserUnlocked()) return;
+        }
+
+        NotificationStorage storage = new NotificationStorage(context);
+        List<String> ids = storage.getSavedNotificationIds();
+
+        ArrayList<LocalNotification> notifications = new ArrayList<>(ids.size());
+        ArrayList<LocalNotification> updatedNotifications = new ArrayList<>();
+        for (String id : ids) {
+            LocalNotification notification = storage.getSavedNotification(id);
+            if(notification == null) {
+                continue;
+            }
+
+            LocalNotificationSchedule schedule = notification.getSchedule();
+            if(schedule != null) {
+                Date at = schedule.getAt();
+                if(at != null && at.before(new Date())) {
+                    // modify the scheduled date in order to show notifications that would have been delivered while device was off.
+                    long newDateTime = new Date().getTime() + 15 * 1000;
+                    schedule.setAt(new Date(newDateTime));
+                    notification.setSchedule(schedule);
+                    updatedNotifications.add(notification);
+                }
+            }
+
+            notifications.add(notification);
+        }
+
+        if(updatedNotifications.size() > 0){
+            storage.appendNotifications(updatedNotifications);
+        }
+
+        CapConfig config = new CapConfig(context.getAssets(), null);
+        LocalNotificationManager localNotificationManager = new LocalNotificationManager(storage, null, context, config);
+
+        localNotificationManager.schedule(null, notifications);
+    }
+}

+ 164 - 0
android/capacitor/src/main/java/com/getcapacitor/plugin/notification/LocalNotificationSchedule.java

@@ -0,0 +1,164 @@
+package com.getcapacitor.plugin.notification;
+
+import android.text.format.DateUtils;
+
+import com.getcapacitor.JSObject;
+
+import java.text.ParseException;
+import java.text.SimpleDateFormat;
+import java.util.Date;
+import java.util.TimeZone;
+
+public class LocalNotificationSchedule {
+
+  public static String JS_DATE_FORMAT = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'";
+
+  private Date at;
+  private Boolean repeats;
+  private String every;
+  private Integer count;
+
+  private DateMatch on;
+
+
+  public LocalNotificationSchedule(JSObject jsonNotification) throws ParseException {
+    JSObject schedule = jsonNotification.getJSObject("schedule");
+    if (schedule != null) {
+      // Every specific unit of time (always constant)
+      buildEveryElement(schedule);
+      // Count of units of time from every to repeat on
+      buildCountElement(schedule);
+      // At specific moment of time (with repeating option)
+      buildAtElement(schedule);
+      // Build on - recurring times. For e.g. every 1st day of the month at 8:30.
+      buildOnElement(schedule);
+    }
+  }
+
+  public LocalNotificationSchedule() {
+  }
+
+  private void buildEveryElement(JSObject schedule) {
+    // 'year'|'month'|'two-weeks'|'week'|'day'|'hour'|'minute'|'second';
+    this.every = schedule.getString("every");
+  }
+
+  private void buildCountElement(JSObject schedule) {
+    this.count = schedule.getInteger("count", 1);
+  }
+
+  private void buildAtElement(JSObject schedule) throws ParseException {
+    this.repeats = schedule.getBool("repeats");
+    String dateString = schedule.getString("at");
+    if (dateString != null) {
+      SimpleDateFormat sdf = new SimpleDateFormat(JS_DATE_FORMAT);
+      sdf.setTimeZone(TimeZone.getTimeZone("UTC"));
+      this.at = sdf.parse(dateString);
+    }
+  }
+
+  private void buildOnElement(JSObject schedule) {
+    JSObject onJson = schedule.getJSObject("on");
+    if (onJson != null) {
+      this.on = new DateMatch();
+      on.setYear(onJson.getInteger("year"));
+      on.setMonth(onJson.getInteger("month"));
+      on.setDay(onJson.getInteger("day"));
+      on.setHour(onJson.getInteger("hour"));
+      on.setMinute(onJson.getInteger("minute"));
+    }
+  }
+
+  public DateMatch getOn() {
+    return on;
+  }
+
+  public void setOn(DateMatch on) {
+    this.on = on;
+  }
+
+  public Date getAt() {
+    return at;
+  }
+
+  public void setAt(Date at) {
+    this.at = at;
+  }
+
+  public Boolean getRepeats() {
+    return repeats;
+  }
+
+  public void setRepeats(Boolean repeats) {
+    this.repeats = repeats;
+  }
+
+  public String getEvery() {
+    return every;
+  }
+
+  public void setEvery(String every) {
+    this.every = every;
+  }
+
+  public int getCount() {
+    return count;
+  }
+
+  public void setCount(int count) {
+    this.count = count;
+  }
+
+  public boolean isRepeating() {
+    return Boolean.TRUE.equals(this.repeats);
+  }
+
+  public boolean isRemovable() {
+    if (every == null && on == null) {
+      if (at != null) {
+        return !isRepeating();
+      } else {
+        return true;
+      }
+    }
+    return false;
+  }
+
+  /**
+   * Get constant long value representing specific interval of time (weeks, days etc.)
+   */
+  public Long getEveryInterval() {
+    switch (every) {
+      case "year":
+        return count * DateUtils.YEAR_IN_MILLIS;
+      case "month":
+        // This case is just approximation as months have different number of days
+        return count * 30 * DateUtils.DAY_IN_MILLIS;
+      case "two-weeks":
+        return count * 2 * DateUtils.WEEK_IN_MILLIS;
+      case "week":
+        return count * DateUtils.WEEK_IN_MILLIS;
+      case "day":
+        return count * DateUtils.DAY_IN_MILLIS;
+      case "hour":
+        return count * DateUtils.HOUR_IN_MILLIS;
+      case "minute":
+        return count * DateUtils.MINUTE_IN_MILLIS;
+      case "second":
+        return count * DateUtils.SECOND_IN_MILLIS;
+      default:
+        return null;
+    }
+  }
+
+  /**
+   * Get next trigger time based on calendar and current time
+   *
+   * @param currentTime - current time that will be used to calculate next trigger
+   * @return millisecond trigger
+   */
+  public Long getNextOnSchedule(Date currentTime) {
+    return this.on.nextTrigger(currentTime);
+  }
+
+}

+ 88 - 0
android/capacitor/src/main/java/com/getcapacitor/plugin/notification/NotificationAction.java

@@ -0,0 +1,88 @@
+package com.getcapacitor.plugin.notification;
+
+import com.getcapacitor.JSArray;
+import com.getcapacitor.JSObject;
+
+import com.getcapacitor.Logger;
+
+import org.json.JSONArray;
+import org.json.JSONObject;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+
+/**
+ * Action types that will be registered for the notifications
+ */
+public class NotificationAction {
+
+  private String id;
+  private String title;
+  private Boolean input;
+
+  public NotificationAction() {
+
+  }
+
+  public NotificationAction(String id, String title, Boolean input) {
+    this.id = id;
+    this.title = title;
+    this.input = input;
+  }
+
+  public static Map<String, NotificationAction[]> buildTypes(JSArray types) {
+    Map<String, NotificationAction[]> actionTypeMap = new HashMap<>();
+    try {
+      List<JSONObject> objects = types.toList();
+      for (JSONObject obj : objects) {
+        JSObject jsObject = JSObject.fromJSONObject(obj);
+        String actionGroupId = jsObject.getString("id");
+        if (actionGroupId == null) {
+          return null;
+        }
+        JSONArray actions = jsObject.getJSONArray("actions");
+        if (actions != null) {
+          NotificationAction[] typesArray = new NotificationAction[actions.length()];
+          for (int i = 0; i < typesArray.length; i++) {
+            NotificationAction notificationAction = new NotificationAction();
+            JSObject action = JSObject.fromJSONObject(actions.getJSONObject(i));
+            notificationAction.setId(action.getString("id"));
+            notificationAction.setTitle(action.getString("title"));
+            notificationAction.setInput(action.getBool("input"));
+            typesArray[i] = notificationAction;
+          }
+          actionTypeMap.put(actionGroupId, typesArray);
+        }
+      }
+    } catch (Exception e) {
+      Logger.error(Logger.tags("LN"), "Error when building action types", e);
+    }
+    return actionTypeMap;
+  }
+
+  public String getId() {
+    return id;
+  }
+
+  public void setId(String id) {
+    this.id = id;
+  }
+
+  public String getTitle() {
+    return title;
+  }
+
+  public void setTitle(String title) {
+    this.title = title;
+  }
+
+  public boolean isInput() {
+    return Boolean.TRUE.equals(input);
+  }
+
+  public void setInput(Boolean input) {
+    this.input = input;
+  }
+}

+ 129 - 0
android/capacitor/src/main/java/com/getcapacitor/plugin/notification/NotificationChannelManager.java

@@ -0,0 +1,129 @@
+package com.getcapacitor.plugin.notification;
+
+import android.app.NotificationChannel;
+import android.app.NotificationManager;
+import android.content.ContentResolver;
+import android.content.Context;
+import android.graphics.Color;
+import android.media.AudioAttributes;
+import android.net.Uri;
+
+import androidx.core.app.NotificationCompat;
+
+import com.getcapacitor.JSArray;
+import com.getcapacitor.JSObject;
+import com.getcapacitor.Logger;
+import com.getcapacitor.PluginCall;
+
+import java.util.List;
+
+public class NotificationChannelManager {
+
+    private Context context;
+    private NotificationManager notificationManager;
+
+    public NotificationChannelManager(Context context) {
+        this.context = context;
+        this.notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
+    }
+
+    public NotificationChannelManager(Context context, NotificationManager manager) {
+        this.context = context;
+        this.notificationManager = manager;
+    }
+
+    private static String CHANNEL_ID = "id";
+    private static String CHANNEL_NAME = "name";
+    private static String CHANNEL_DESCRIPTION = "description";
+    private static String CHANNEL_IMPORTANCE = "importance";
+    private static String CHANNEL_VISIBILITY = "visibility";
+    private static String CHANNEL_SOUND = "sound";
+    private static String CHANNEL_VIBRATE = "vibration";
+    private static String CHANNEL_USE_LIGHTS = "lights";
+    private static String CHANNEL_LIGHT_COLOR = "lightColor";
+
+    public void createChannel(PluginCall call) {
+        if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) {
+            JSObject channel = new JSObject();
+            channel.put(CHANNEL_ID, call.getString(CHANNEL_ID));
+            channel.put(CHANNEL_NAME, call.getString(CHANNEL_NAME));
+            channel.put(CHANNEL_DESCRIPTION, call.getString(CHANNEL_DESCRIPTION, ""));
+            channel.put(CHANNEL_VISIBILITY, call.getInt(CHANNEL_VISIBILITY, NotificationCompat.VISIBILITY_PUBLIC));
+            channel.put(CHANNEL_IMPORTANCE, call.getInt(CHANNEL_IMPORTANCE));
+            channel.put(CHANNEL_SOUND, call.getString(CHANNEL_SOUND, null));
+            channel.put(CHANNEL_VIBRATE, call.getBoolean(CHANNEL_VIBRATE, false));
+            channel.put(CHANNEL_USE_LIGHTS, call.getBoolean(CHANNEL_USE_LIGHTS, false));
+            channel.put(CHANNEL_LIGHT_COLOR, call.getString(CHANNEL_LIGHT_COLOR, null));
+            createChannel(channel);
+            call.success();
+        } else {
+            call.unavailable();
+        }
+    }
+    public void createChannel(JSObject channel) {
+        if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) {
+            NotificationChannel notificationChannel = new NotificationChannel(channel.getString(CHANNEL_ID), channel.getString(CHANNEL_NAME), channel.getInteger(CHANNEL_IMPORTANCE));
+            notificationChannel.setDescription(channel.getString(CHANNEL_DESCRIPTION));
+            notificationChannel.setLockscreenVisibility(channel.getInteger(CHANNEL_VISIBILITY));
+            notificationChannel.enableVibration(channel.getBool(CHANNEL_VIBRATE));
+            notificationChannel.enableLights(channel.getBool(CHANNEL_USE_LIGHTS));
+            String lightColor = channel.getString(CHANNEL_LIGHT_COLOR);
+            if (lightColor != null) {
+                try {
+                    notificationChannel.setLightColor(Color.parseColor(lightColor));
+                } catch (IllegalArgumentException ex) {
+                    Logger.error(Logger.tags("NotificationChannel"), "Invalid color provided for light color.", null);
+                }
+            }
+            String sound = channel.getString(CHANNEL_SOUND, null);
+            if (sound != null && !sound.isEmpty()) {
+                if (sound.contains(".")) {
+                    sound = sound.substring(0, sound.lastIndexOf('.'));
+                }
+                AudioAttributes audioAttributes = new AudioAttributes.Builder()
+                        .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
+                        .setUsage(AudioAttributes.USAGE_NOTIFICATION).build();
+                Uri soundUri = Uri.parse(ContentResolver.SCHEME_ANDROID_RESOURCE + "://" + context.getPackageName() + "/raw/" + sound);
+                notificationChannel.setSound(soundUri, audioAttributes);
+            }
+            notificationManager.createNotificationChannel(notificationChannel);
+        }
+    }
+
+    public void deleteChannel(PluginCall call) {
+        if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) {
+            String channelId = call.getString("id");
+            notificationManager.deleteNotificationChannel(channelId);
+            call.success();
+        } else {
+            call.unavailable();
+        }
+    }
+
+    public void listChannels(PluginCall call) {
+        if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) {
+            List<NotificationChannel> notificationChannels = notificationManager.getNotificationChannels();
+            JSArray channels = new JSArray();
+            for (NotificationChannel notificationChannel : notificationChannels) {
+                JSObject channel = new JSObject();
+                channel.put(CHANNEL_ID, notificationChannel.getId());
+                channel.put(CHANNEL_NAME, notificationChannel.getName());
+                channel.put(CHANNEL_DESCRIPTION, notificationChannel.getDescription());
+                channel.put(CHANNEL_IMPORTANCE, notificationChannel.getImportance());
+                channel.put(CHANNEL_VISIBILITY, notificationChannel.getLockscreenVisibility());
+                channel.put(CHANNEL_SOUND, notificationChannel.getSound());
+                channel.put(CHANNEL_VIBRATE, notificationChannel.shouldVibrate());
+                channel.put(CHANNEL_USE_LIGHTS, notificationChannel.shouldShowLights());
+                channel.put(CHANNEL_LIGHT_COLOR, String.format("#%06X", (0xFFFFFF & notificationChannel.getLightColor())));
+                Logger.debug(Logger.tags("NotificationChannel"), "visibility " + notificationChannel.getLockscreenVisibility());
+                Logger.debug(Logger.tags("NotificationChannel"), "importance " + notificationChannel.getImportance());
+                channels.put(channel);
+            }
+            JSObject result = new JSObject();
+            result.put("channels", channels);
+            call.success(result);
+        } else {
+            call.unavailable();
+        }
+    }
+}

+ 29 - 0
android/capacitor/src/main/java/com/getcapacitor/plugin/notification/NotificationDismissReceiver.java

@@ -0,0 +1,29 @@
+package com.getcapacitor.plugin.notification;
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import com.getcapacitor.Logger;
+
+
+/**
+ * Receiver called when notification is dismissed by user
+ */
+public class NotificationDismissReceiver extends BroadcastReceiver {
+
+
+  @Override
+  public void onReceive(Context context, Intent intent) {
+    int intExtra = intent.getIntExtra(LocalNotificationManager.NOTIFICATION_INTENT_KEY, Integer.MIN_VALUE);
+    if (intExtra == Integer.MIN_VALUE) {
+      Logger.error(Logger.tags("LN"), "Invalid notification dismiss operation", null);
+      return;
+    }
+    boolean isRemovable = intent.getBooleanExtra(LocalNotificationManager.NOTIFICATION_IS_REMOVABLE_KEY, true);
+    if (isRemovable) {
+      NotificationStorage notificationStorage = new NotificationStorage(context);
+      notificationStorage.deleteNotification(Integer.toString(intExtra));
+    }
+
+  }
+}

+ 151 - 0
android/capacitor/src/main/java/com/getcapacitor/plugin/notification/NotificationStorage.java

@@ -0,0 +1,151 @@
+package com.getcapacitor.plugin.notification;
+
+import android.content.Context;
+import android.content.SharedPreferences;
+
+import com.getcapacitor.JSObject;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.text.ParseException;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Date;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * Class used to abstract storage for notification data
+ */
+public class NotificationStorage {
+
+  // Key for private preferences
+  private static final String NOTIFICATION_STORE_ID = "NOTIFICATION_STORE";
+
+  // Key used to save action types
+  private static final String ACTION_TYPES_ID = "ACTION_TYPE_STORE";
+
+  private static final String ID_KEY = "notificationIds";
+
+  private Context context;
+
+  public NotificationStorage(Context context) {
+    this.context = context;
+  }
+
+  /**
+   * Persist the id of currently scheduled notification
+   */
+  public void appendNotifications(List<LocalNotification> localNotifications) {
+    SharedPreferences storage = getStorage(NOTIFICATION_STORE_ID);
+    SharedPreferences.Editor editor = storage.edit();
+    for (LocalNotification request : localNotifications) {
+      String key = request.getId().toString();
+      editor.putString(key, request.getSource());
+    }
+    editor.apply();
+  }
+
+  public List<String> getSavedNotificationIds() {
+    SharedPreferences storage = getStorage(NOTIFICATION_STORE_ID);
+    Map<String, ?> all = storage.getAll();
+    if (all != null) {
+      return new ArrayList<>(all.keySet());
+    }
+    return new ArrayList<>();
+  }
+
+  public JSObject getSavedNotificationAsJSObject(String key) {
+    SharedPreferences storage = getStorage(NOTIFICATION_STORE_ID);
+    String notificationString = storage.getString(key, null);
+
+    if(notificationString == null){
+        return null;
+    }
+
+    JSObject jsNotification;
+    try {
+        jsNotification  = new JSObject(notificationString);
+    } catch (JSONException ex) {
+        return  null;
+    }
+
+    return jsNotification;
+  }
+
+  public LocalNotification getSavedNotification(String key) {
+    JSObject jsNotification = getSavedNotificationAsJSObject(key);
+    if(jsNotification == null) {
+        return null;
+    }
+
+    LocalNotification notification;
+    try {
+       notification = LocalNotification.buildNotificationFromJSObject(jsNotification);
+    } catch (ParseException ex) {
+       return  null;
+    }
+
+    return  notification;
+  }
+
+  /**
+   * Remove the stored notifications
+   */
+  public void deleteNotification(String id) {
+    SharedPreferences.Editor editor = getStorage(NOTIFICATION_STORE_ID).edit();
+    editor.remove(id);
+    editor.apply();
+  }
+
+  /**
+   * Shared private preferences for the application.
+   */
+  private SharedPreferences getStorage(String key) {
+    return context.getSharedPreferences(key, Context.MODE_PRIVATE);
+  }
+
+
+  /**
+   * Writes new action types (actions that being displayed in notification) to storage.
+   * Write will override previous data.
+   *
+   * @param typesMap - map with groupId and actionArray assigned to group
+   */
+  public void writeActionGroup(Map<String, NotificationAction[]> typesMap) {
+    Set<String> typesIds = typesMap.keySet();
+    for (String id : typesIds) {
+      SharedPreferences.Editor editor = getStorage(ACTION_TYPES_ID + id).edit();
+      editor.clear();
+      NotificationAction[] notificationActions = typesMap.get(id);
+      editor.putInt("count", notificationActions.length);
+      for (int i = 0; i < notificationActions.length; i++) {
+        editor.putString("id" + i, notificationActions[i].getId());
+        editor.putString("title" + i, notificationActions[i].getTitle());
+        editor.putBoolean("input" + i, notificationActions[i].isInput());
+      }
+      editor.apply();
+    }
+  }
+
+  /**
+   * Retrieve array of notification actions per ActionTypeId
+   *
+   * @param forId - id of the group
+   */
+  public NotificationAction[] getActionGroup(String forId) {
+    SharedPreferences storage = getStorage(ACTION_TYPES_ID + forId);
+    int count = storage.getInt("count", 0);
+    NotificationAction[] actions = new NotificationAction[count];
+    for (int i = 0; i < count; i++) {
+      String id = storage.getString("id" + i, "");
+      String title = storage.getString("title" + i, "");
+      Boolean input = storage.getBoolean("input" + i, false);
+      actions[i] = new NotificationAction(id, title, input);
+    }
+    return actions;
+  }
+
+}

+ 52 - 0
android/capacitor/src/main/java/com/getcapacitor/plugin/notification/TimedNotificationPublisher.java

@@ -0,0 +1,52 @@
+package com.getcapacitor.plugin.notification;
+
+import android.app.AlarmManager;
+import android.app.Notification;
+import android.app.NotificationManager;
+import android.app.PendingIntent;
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import com.getcapacitor.Logger;
+
+import java.text.SimpleDateFormat;
+import java.util.Date;
+
+/**
+ * Class used to create notification from timer event
+ * Note: Class is being registered in Android manifest as broadcast receiver
+ */
+public class TimedNotificationPublisher extends BroadcastReceiver {
+
+  public static String NOTIFICATION_KEY = "NotificationPublisher.notification";
+  public static String CRON_KEY = "NotificationPublisher.cron";
+
+  /**
+   * Restore and present notification
+   */
+  public void onReceive(Context context, Intent intent) {
+    NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
+    Notification notification = intent.getParcelableExtra(NOTIFICATION_KEY);
+    int id = intent.getIntExtra(LocalNotificationManager.NOTIFICATION_INTENT_KEY, Integer.MIN_VALUE);
+    if (id == Integer.MIN_VALUE) {
+      Logger.error(Logger.tags("LN"), "No valid id supplied", null);
+    }
+    notificationManager.notify(id, notification);
+    rescheduleNotificationIfNeeded(context, intent, id);
+  }
+
+  private void rescheduleNotificationIfNeeded(Context context, Intent intent, int id) {
+    String dateString = intent.getStringExtra(CRON_KEY);
+    if (dateString != null) {
+      DateMatch date = DateMatch.fromMatchString(dateString);
+      AlarmManager alarmManager = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE);
+      long trigger = date.nextTrigger(new Date());
+      Intent clone = (Intent) intent.clone();
+      PendingIntent pendingIntent = PendingIntent.getBroadcast(context, id, clone, PendingIntent.FLAG_CANCEL_CURRENT);
+      alarmManager.setExact(AlarmManager.RTC, trigger, pendingIntent);
+      SimpleDateFormat sdf = new SimpleDateFormat("yyyy/MM/dd HH:mm:ss");
+      Logger.debug(Logger.tags("LN"), "notification " + id + " will next fire at " + sdf.format(new Date(trigger)));
+    }
+  }
+  
+}

+ 367 - 0
android/capacitor/src/main/java/com/getcapacitor/plugin/util/AssetUtil.java

@@ -0,0 +1,367 @@
+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;
+    }
+
+}

+ 146 - 0
android/capacitor/src/main/java/com/getcapacitor/ui/ModalsBottomSheetDialogFragment.java

@@ -0,0 +1,146 @@
+package com.getcapacitor.ui;
+
+import android.annotation.SuppressLint;
+import android.app.Dialog;
+import android.content.DialogInterface;
+import android.graphics.Color;
+import android.view.View;
+import android.view.Window;
+import android.widget.LinearLayout;
+import android.widget.TextView;
+
+import androidx.annotation.NonNull;
+import androidx.coordinatorlayout.widget.CoordinatorLayout;
+
+import com.getcapacitor.Dialogs;
+import com.getcapacitor.JSArray;
+import com.getcapacitor.JSObject;
+import com.getcapacitor.Logger;
+import com.google.android.material.bottomsheet.BottomSheetBehavior;
+import com.google.android.material.bottomsheet.BottomSheetDialogFragment;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.util.List;
+
+public class ModalsBottomSheetDialogFragment extends BottomSheetDialogFragment {
+  public interface OnSelectedListener {
+    void onSelected(int index);
+  }
+
+  @Override
+  public void onCancel(DialogInterface dialog)
+  {
+    super.onCancel(dialog);
+    this.cancelListener.onCancel();
+  }
+
+  private String title;
+  private JSArray options;
+
+  private OnSelectedListener listener;
+  private Dialogs.OnCancelListener cancelListener;
+
+  public void setTitle(String title) {
+    this.title = title;
+  }
+  public void setOptions(JSArray options) {
+    this.options = options;
+  }
+
+  public void setOnSelectedListener(OnSelectedListener listener) {
+    this.listener = listener;
+  }
+
+  public void setOnCancelListener(Dialogs.OnCancelListener listener) {
+    this.cancelListener = listener;
+  }
+
+  private BottomSheetBehavior.BottomSheetCallback mBottomSheetBehaviorCallback = new BottomSheetBehavior.BottomSheetCallback() {
+
+    @Override
+    public void onStateChanged(@NonNull View bottomSheet, int newState) {
+      if (newState == BottomSheetBehavior.STATE_HIDDEN) {
+        dismiss();
+      }
+    }
+
+    @Override
+    public void onSlide(@NonNull View bottomSheet, float slideOffset) {
+    }
+  };
+
+  @Override
+  @SuppressLint("RestrictedApi")
+  public void setupDialog(Dialog dialog, int style) {
+    super.setupDialog(dialog, style);
+
+    if (options == null) {
+      return;
+    }
+
+    Window w = dialog.getWindow();
+
+    final float scale = getResources().getDisplayMetrics().density;
+
+    float layoutPaddingDp16 = 16.0f;
+    float layoutPaddingDp12  = 12.0f;
+    float layoutPaddingDp8  = 8.0f;
+    int layoutPaddingPx16 = (int) (layoutPaddingDp16 * scale + 0.5f);
+    int layoutPaddingPx12 = (int) (layoutPaddingDp12 * scale + 0.5f);
+    int layoutPaddingPx8 = (int) (layoutPaddingDp8 * scale + 0.5f);
+
+    CoordinatorLayout parentLayout = new CoordinatorLayout(getContext());
+
+    LinearLayout layout = new LinearLayout(getContext());
+    layout.setOrientation(LinearLayout.VERTICAL);
+    layout.setPadding(layoutPaddingPx16, layoutPaddingPx16, layoutPaddingPx16, layoutPaddingPx16);
+    TextView ttv = new TextView(getContext());
+    ttv.setTextColor(Color.parseColor("#757575"));
+    ttv.setPadding(layoutPaddingPx8, layoutPaddingPx8, layoutPaddingPx8, layoutPaddingPx8);
+    ttv.setText(title);
+    layout.addView(ttv);
+    try {
+      List<Object> optionsList = options.toList();
+      for (int i = 0; i < optionsList.size(); i++) {
+        final int optionIndex = i;
+        JSObject o = JSObject.fromJSONObject((JSONObject) optionsList.get(i));
+        String styleOption = o.getString("style", "DEFAULT");
+        String titleOption = o.getString("title", "");
+
+        TextView tv = new TextView(getContext());
+        tv.setTextColor(Color.parseColor("#000000"));
+        tv.setPadding(layoutPaddingPx12, layoutPaddingPx12, layoutPaddingPx12, layoutPaddingPx12);
+        //tv.setBackgroundColor(Color.parseColor("#80000000"));
+        tv.setText(titleOption);
+        tv.setOnClickListener(new View.OnClickListener() {
+          @Override
+          public void onClick(View view) {
+            Logger.debug("CliCKED: " + optionIndex);
+
+            if (listener != null) {
+              listener.onSelected(optionIndex);
+            }
+          }
+        });
+        layout.addView(tv);
+      }
+
+      parentLayout.addView(layout.getRootView());
+
+      dialog.setContentView(parentLayout.getRootView());
+
+      //dialog.getWindow().getDecorView().setBackgroundColor(Color.parseColor("#000000"));
+
+      CoordinatorLayout.LayoutParams params = (CoordinatorLayout.LayoutParams) ((View) parentLayout.getParent()).getLayoutParams();
+      CoordinatorLayout.Behavior behavior = params.getBehavior();
+
+      if (behavior != null && behavior instanceof BottomSheetBehavior) {
+        ((BottomSheetBehavior) behavior).setBottomSheetCallback(mBottomSheetBehaviorCallback);
+      }
+    } catch (JSONException ex) {
+      Logger.error("JSON error processing an option for showActions", ex);
+    }
+  }
+}

+ 25 - 0
android/capacitor/src/main/java/com/getcapacitor/ui/Toast.java

@@ -0,0 +1,25 @@
+package com.getcapacitor.ui;
+
+import android.content.Context;
+import android.os.Handler;
+import android.os.Looper;
+
+/**
+ * Toast utility. "Yeah toast" echos softly in the distance.
+ */
+public class Toast {
+
+  public static void show(Context c, String text) {
+    show(c, text, android.widget.Toast.LENGTH_LONG);
+  }
+
+  public static void show(final Context c, final String text, final int duration) {
+    new Handler(Looper.getMainLooper()).post(new Runnable() {
+      @Override
+      public void run() {
+        android.widget.Toast toast = android.widget.Toast.makeText(c, text, duration);
+        toast.show();
+      }
+    });
+  }
+}

+ 125 - 0
android/capacitor/src/main/java/com/getcapacitor/util/HostMask.java

@@ -0,0 +1,125 @@
+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<String> maskParts;
+
+        private Simple(List<String> maskParts) {
+            if(maskParts == null) {
+                throw new IllegalArgumentException("Mask parts can not be null");
+            }
+            this.maskParts = maskParts;
+        }
+
+        static Simple parse(String mask) {
+            List<String> parts = Util.splitAndReverse(mask);
+            return new Simple(parts);
+        }
+
+
+        @Override
+        public boolean matches(String host) {
+            if(host == null) {
+                return false;
+            }
+            List<String> 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<? extends HostMask> masks;
+
+        Any(List<? extends HostMask> 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<HostMask.Simple> 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<String> splitAndReverse(String string) {
+            if(string == null) {
+                throw new IllegalArgumentException("Can not split null argument");
+            }
+            List<String> parts = Arrays.asList(string.split("\\."));
+            Collections.reverse(parts);
+            return parts;
+        }
+    }
+}
+

+ 12 - 0
android/capacitor/src/main/res/drawable/ic_transparent.xml

@@ -0,0 +1,12 @@
+<?xml version="1.0" encoding="utf-8"?>
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="16dp"
+    android:height="16dp"
+    android:viewportHeight="108"
+    android:viewportWidth="108">
+
+    <path
+        android:width="1dp"
+        android:color="@android:color/transparent" />
+
+</vector>

+ 15 - 0
android/capacitor/src/main/res/layout/bridge_layout_main.xml

@@ -0,0 +1,15 @@
+<?xml version="1.0" encoding="utf-8"?>
+<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    xmlns:tools="http://schemas.android.com/tools"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent"
+    tools:context="com.getcapacitor.BridgeActivity"
+    >
+
+    <com.getcapacitor.CapacitorWebView
+        android:id="@+id/webview"
+        android:layout_width="fill_parent"
+        android:layout_height="fill_parent" />
+
+</RelativeLayout>

+ 13 - 0
android/capacitor/src/main/res/layout/fragment_bridge.xml

@@ -0,0 +1,13 @@
+<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:tools="http://schemas.android.com/tools"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent"
+    android:background="#F0FF1414"
+    tools:context="com.getcapacitor.BridgeFragment">
+
+  <com.getcapacitor.CapacitorWebView
+      android:id="@+id/webview"
+      android:layout_width="fill_parent"
+      android:layout_height="fill_parent" />
+
+</FrameLayout>

+ 6 - 0
android/capacitor/src/main/res/values/attrs.xml

@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+  <declare-styleable name="bridge_fragment">
+    <attr name="start_dir" format="string"/>
+  </declare-styleable>
+</resources>

+ 6 - 0
android/capacitor/src/main/res/values/colors.xml

@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+    <color name="colorPrimary">#3F51B5</color>
+    <color name="colorPrimaryDark">#303F9F</color>
+    <color name="colorAccent">#FF4081</color>
+</resources>

+ 7 - 0
android/capacitor/src/main/res/values/strings.xml

@@ -0,0 +1,7 @@
+<resources>
+    <string name="app_name">CapacitorAndroid</string>
+    <string name="ok">OK</string>
+    <string name="picture">Picture</string>
+    <string name="request_permission">Allow this app to take pictures</string>
+    <string name="camera_error">Unable to use camera</string>
+</resources>

+ 15 - 0
android/capacitor/src/main/res/values/styles.xml

@@ -0,0 +1,15 @@
+<resources>
+
+    <!-- Base application theme. -->
+    <style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
+        <!-- Customize your theme here. -->
+        <item name="colorPrimary">@color/colorPrimary</item>
+        <item name="colorPrimaryDark">@color/colorPrimaryDark</item>
+        <item name="colorAccent">@color/colorAccent</item>
+    </style>
+
+    <style name="AppTheme.NoActionBar" parent="Theme.AppCompat.NoActionBar">
+        <item name="windowActionBar">false</item>
+        <item name="windowNoTitle">true</item>
+    </style>
+</resources>

+ 17 - 0
android/capacitor/src/test/java/com/getcapacitor/ExampleUnitTest.java

@@ -0,0 +1,17 @@
+package com.getcapacitor;
+
+import org.junit.Test;
+
+import static org.junit.Assert.*;
+
+/**
+ * Example local unit test, which will execute on the development machine (host).
+ *
+ * @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
+ */
+public class ExampleUnitTest {
+    @Test
+    public void addition_isCorrect() throws Exception {
+        assertEquals(4, 2 + 2);
+    }
+}

+ 209 - 0
android/capacitor/src/test/java/com/getcapacitor/JSObjectTest.java

@@ -0,0 +1,209 @@
+package com.getcapacitor;
+
+import org.json.JSONException;
+import org.junit.Test;
+
+import static org.junit.Assert.*;
+
+public class JSObjectTest {
+
+    @Test
+    public void getStringReturnsNull_WhenJSObject_IsConstructed_WithNoInitialJSONObject() {
+        JSObject jsObject = new JSObject();
+
+        String actualValue = jsObject.getString("should be null");
+
+        assertNull(actualValue);
+    }
+
+    @Test
+    public void getStringReturnsExpectedValue_WhenJSObject_IsConstructed_WithAValidJSONObject() throws JSONException {
+        JSObject jsObject = new JSObject("{\"thisKeyExists\": \"this is the key value\"}");
+
+        String expectedValue = jsObject.getString("thisKeyExists");
+        String actualValue = "this is the key value";
+
+        assertEquals(expectedValue, actualValue);
+    }
+
+    @Test
+    public void getStringReturnsDefaultValue_WhenJSObject_IsConstructed_WithNoInitialJSONObject() throws JSONException {
+        JSObject jsObject = new JSObject();
+
+        String expectedValue = jsObject.getString("thisKeyDoesNotExist", "default value");
+        String actualValue = "default value";
+
+        assertEquals(expectedValue, actualValue);
+    }
+
+    @Test
+    public void getStringReturnsDefaultValue_WhenJSObject_IsConstructed_WithAValueAsInteger() throws JSONException {
+        JSObject jsObject = new JSObject("{\"thisKeyExists\": 1}");
+
+        String expectedValue = jsObject.getString("thisKeyExists", "default value");
+        String actualValue = "default value";
+
+        assertEquals(expectedValue, actualValue);
+    }
+
+    @Test
+    public void getIntegerReturnsNull_WhenJSObject_IsConstructed_WithNoInitialJSONObject() {
+        JSObject jsObject = new JSObject();
+
+        Integer actualValue = jsObject.getInteger("should be null");
+
+        assertNull(actualValue);
+    }
+
+    @Test
+    public void getIntegerReturnsExpectedValue_WhenJSObject_IsConstructed_WithAValidJSONObject() throws JSONException {
+        JSObject jsObject = new JSObject("{\"thisKeyExists\": 1}");
+
+        Integer expectedValue = jsObject.getInteger("thisKeyExists");
+        Integer actualValue = 1;
+
+        assertEquals(expectedValue, actualValue);
+    }
+
+    @Test
+    public void getIntegerReturnsDefaultValue_WhenJSObject_IsConstructed_WithNoInitialJSONObject() throws JSONException {
+        JSObject jsObject = new JSObject();
+
+        Integer expectedValue = jsObject.getInteger("thisKeyDoesNotExist", 1);
+        Integer actualValue = 1;
+
+        assertEquals(expectedValue, actualValue);
+    }
+
+    @Test
+    public void getStringReturnsDefaultValue_WhenJSObject_IsConstructed_WithAValueAsString() throws JSONException {
+        JSObject jsObject = new JSObject("{\"thisKeyExists\": \"not an integer\"}");
+
+        Integer expectedValue = jsObject.getInteger("thisKeyExists", 1);
+        Integer actualValue = 1;
+
+        assertEquals(expectedValue, actualValue);
+    }
+
+    @Test
+    public void getBoolReturnsNull_WhenJSObject_IsConstructed_WithNoInitialJSONObject() {
+        JSObject jsObject = new JSObject();
+
+        Boolean actualValue = jsObject.getBool("should be null");
+
+        assertNull(actualValue);
+    }
+
+    @Test
+    public void getBoolReturnsExpectedValue_WhenJSObject_IsConstructed_WithAValidJSONObject() throws JSONException {
+        JSObject jsObject = new JSObject("{\"thisKeyExists\": true}");
+
+        Boolean expectedValue = jsObject.getBool("thisKeyExists");
+        Boolean actualValue = true;
+
+        assertEquals(expectedValue, actualValue);
+    }
+
+    @Test
+    public void getBooleanReturnsDefaultValue_WhenJSObject_IsConstructed_WithNoInitialJSONObject() throws JSONException {
+        JSObject jsObject = new JSObject();
+
+        Boolean expectedValue = jsObject.getBoolean("thisKeyDoesNotExist", true);
+        Boolean actualValue = true;
+
+        assertEquals(expectedValue, actualValue);
+    }
+
+    @Test
+    public void getBooleanReturnsDefaultValue_WhenJSObject_IsConstructed_WithAValueAsString() throws JSONException {
+        JSObject jsObject = new JSObject("{\"thisKeyExists\": \"not an integer\"}");
+
+        Boolean expectedValue = jsObject.getBoolean("thisKeyExists", true);
+        Boolean actualValue = true;
+
+        assertEquals(expectedValue, actualValue);
+    }
+
+    @Test
+    public void getJSObjectReturnsNull_WhenJSObject_IsConstructed_WithNoInitialJSONObject() {
+        JSObject jsObject = new JSObject();
+
+        JSObject actualValue = jsObject.getJSObject("should be null");
+
+        assertNull(actualValue);
+    }
+
+    @Test
+    public void getJsObjectReturnsExpectedValue_WhenJSObject_IsConstructed_WithAValidJSONObject() throws JSONException {
+        JSObject jsObject = new JSObject("{\"thisKeyExists\": { \"innerObjectKey\": \"innerObjectValue\" }}");
+
+        String actualValue = jsObject.getJSObject("thisKeyExists").getString("innerObjectKey");
+        String expectedValue = "innerObjectValue";
+
+        assertEquals(expectedValue,  actualValue);
+    }
+
+    @Test
+    public void getJSObjectReturnsDefaultValue_WhenJSObject_IsConstructed_WithNoInitialJSONObject() throws JSONException {
+        JSObject jsObject = new JSObject();
+
+        String actualValue = jsObject.getJSObject("thisKeyExists", new JSObject("{\"thisKeyExists\": \"default string\"}")).getString("thisKeyExists");
+        String expectedValue = "default string";
+
+        assertEquals(expectedValue,  actualValue);
+    }
+
+    @Test
+    public void putBoolean_AddsValueToJSObject_UnderCorrectKey() {
+        JSObject jsObject = new JSObject();
+        jsObject.put("bool", true);
+
+        Boolean actualValue = jsObject.getBool("bool");
+
+        assertTrue(actualValue);
+    }
+
+    @Test
+    public void putInteger_AddsValueToJSObject_UnderCorrectKey() {
+        JSObject jsObject = new JSObject();
+        jsObject.put("integer", 1);
+
+        Integer expectedValue = 1;
+        Integer actualValue = jsObject.getInteger("integer");
+
+        assertEquals(actualValue, expectedValue);
+    }
+
+    @Test
+    public void putLong_AddsValueToJSObject_UnderCorrectKey() throws JSONException {
+        JSObject jsObject = new JSObject();
+        jsObject.put("long", 1l);
+
+        Long expectedValue = 1l;
+        Long actualValue = jsObject.getLong("long");
+
+        assertEquals(actualValue, expectedValue);
+    }
+
+    @Test
+    public void putDouble_AddsValueToJSObject_UnderCorrectKey() throws JSONException {
+        JSObject jsObject = new JSObject();
+        jsObject.put("double", 1d);
+
+        Double expectedValue = 1d;
+        Double actualValue = jsObject.getDouble("double");
+
+        assertEquals(actualValue, expectedValue);
+    }
+
+    @Test
+    public void putString_AddsValueToJSObject_UnderCorrectKey() throws JSONException {
+        JSObject jsObject = new JSObject();
+        jsObject.put("string", "test");
+
+        String expectedValue = "test";
+        String actualValue = jsObject.getString("string");
+
+        assertEquals(actualValue, expectedValue);
+    }
+}

+ 44 - 0
android/capacitor/src/test/java/com/getcapacitor/PluginMethodHandleTest.java

@@ -0,0 +1,44 @@
+package com.getcapacitor;
+
+import org.junit.Test;
+
+import java.lang.reflect.Method;
+
+import static org.junit.Assert.assertEquals;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.Mockito.mock;
+
+public class PluginMethodHandleTest {
+    @Test
+    public void getNameReturnsMethodName() {
+        PluginMethod pluginMethod = mock(PluginMethod.class);
+        Method mockMethod = mock(Method.class);
+
+        given(mockMethod.getName()).willReturn("methodName");
+        PluginMethodHandle pluginMethodHandle = new PluginMethodHandle(mockMethod, pluginMethod);
+
+        assertEquals(pluginMethodHandle.getName(), "methodName");
+    }
+
+    @Test
+    public void getMethodHandleReturnsMethodHandle() {
+        PluginMethod pluginMethod = mock(PluginMethod.class);
+        Method mockMethod = mock(Method.class);
+
+        given(pluginMethod.returnType()).willReturn("returnType");
+        PluginMethodHandle pluginMethodHandle = new PluginMethodHandle(mockMethod, pluginMethod);
+
+        assertEquals(pluginMethodHandle.getReturnType(), "returnType");
+    }
+
+
+    @Test
+    public void getMethodReturnsMethod() {
+        PluginMethod pluginMethod = mock(PluginMethod.class);
+        Method mockMethod = mock(Method.class);
+
+        PluginMethodHandle pluginMethodHandle = new PluginMethodHandle(mockMethod, pluginMethod);
+
+        assertEquals(pluginMethodHandle.getMethod(), mockMethod);
+    }
+}

+ 81 - 0
android/capacitor/src/test/java/com/getcapacitor/util/HostMaskTest.java

@@ -0,0 +1,81 @@
+package com.getcapacitor.util;
+
+import static org.junit.Assert.*;
+
+import org.junit.Test;
+
+import com.getcapacitor.util.HostMask.Util;
+
+public class HostMaskTest {
+
+    @Test
+    public void testParser() {
+        assertEquals(HostMask.Any.class, HostMask.Parser.parse("*,example.org,*.example.org".split(",")).getClass());
+        assertEquals(HostMask.Simple.class, HostMask.Parser.parse("*").getClass());
+        assertEquals(HostMask.Nothing.class, HostMask.Parser.parse((String) null).getClass());
+    }
+
+    @Test
+    public void testAny() {
+        HostMask mask = HostMask.Any.parse("*.example.org", "example.org");
+        assertFalse(mask.matches("org"));
+        assertTrue(mask.matches("example.org"));
+        assertTrue(mask.matches("www.example.org"));
+        assertFalse(mask.matches("imap.mail.example.org"));
+        assertFalse(mask.matches("another.org"));
+        assertFalse(mask.matches("www.another.org"));
+        assertFalse(mask.matches(null));
+    }
+
+    @Test
+    public void testAnyWildcard() {
+        HostMask mask = HostMask.Any.parse("*");
+        assertTrue(mask.matches("org"));
+        assertTrue(mask.matches("example.org"));
+        assertTrue(mask.matches("www.example.org"));
+        assertTrue(mask.matches("imap.mail.example.org"));
+        assertTrue(mask.matches("another.org"));
+        assertTrue(mask.matches("www.another.org"));
+        assertFalse(mask.matches(null));
+    }
+
+    @Test
+    public void testSimple() {
+        HostMask mask = HostMask.Simple.parse("*.org");
+        assertTrue(mask.matches("example.org"));
+        assertFalse(mask.matches("org"));
+        assertFalse(mask.matches("www.example.org"));
+        assertFalse("Null host never matches", mask.matches(null));
+    }
+
+    @Test
+    public void testSimpleExample1() {
+        HostMask mask = HostMask.Simple.parse("*.example.org");
+        assertFalse("Null host never matches", mask.matches("example.org"));
+    }
+
+    @Test
+    public void testSimpleExample2() {
+        HostMask mask = HostMask.Simple.parse("*");
+        assertTrue("Single star matches everything", mask.matches("example.org"));
+    }
+
+    @Test
+    public void test192168ForLocalTestingSakes() {
+        HostMask mask = HostMask.Simple.parse("192.168.*.*");
+        assertTrue("Matches 192.168.*.*", mask.matches("192.168.2.5"));
+        assertFalse("Matches NOT 192.168.*.*", mask.matches("192.66.2.5"));
+    }
+
+    @Test
+    public void testUtil() {
+        assertTrue("Everything matches *", Util.matches("*", "*"));
+        assertTrue("Everything matches *", Util.matches("*", "org"));
+        assertTrue(Util.matches("org", "org"));
+        assertTrue("Match is case insensitive", Util.matches("ORG", "org"));
+        assertFalse("Nothing matches null mask", Util.matches(null, "org"));
+        assertFalse("Nothing matches null mask", Util.matches(null, null));
+    }
+
+
+}