Build a Cordova plugin for Android

April 18, 2022
Table of contents

Main post

This post forms part of this tutorial:

You might want to check that post first before continuing with this one. You will learn how to:

You could find the source code of this tutorial on GitHub:
https://github.com/adrian-bueno/multiplatform-cordova-plugin-example

Start

We are going to implement in Java the 4 methods defined in our JavaScript interface www/CordovaPluginExample.js:

Create a new directory android inside src.

Then create the files CordovaPluginExample.java and WriteFileHelper.java inside android.

πŸ“ cordova-plugin-example
└── πŸ“ src
    └── πŸ“ android
         β”œβ”€β”€ πŸ“„ CordovaPluginExample.java
         └── πŸ“„ WriteFileHelper.java

WriteFileHelper.java is a helper class to keep our code in CordovaPluginExample.java cleaner.

External dependencies

We are going to use okhttp to obtain Bitcoin’s current price from https://api.coindesk.com/v1/bpi/currentprice.json . We could do this with JavaScript in the browser, but it’s a simple and easy example on how to use external dependencies.

We can add external dependencies in 2 ways:

  1. Using <framework> in plugin.xml.
  2. Using <framework> in plugin.xml and a Gradle file build.gradle.

<framework> in plugin.xml

Dependencies declared by this way, will be downloaded from Maven Central.

plugin.xml

<platform name="android">
  
  ...

  <framework src="com.squareup.okhttp3:okhttp:4.9.0" />
  
  ...

</platform>

<framework> and a Gradle file

If you want more control and use other repositories, we need a Gradle file. Create the file build.gradle inside src/android.

πŸ“ cordova-plugin-example
β”œβ”€β”€ πŸ“ src
β”‚   └── πŸ“ android
β”‚        β”œβ”€β”€ πŸ“„ build.gradle
β”‚        β”œβ”€β”€ πŸ“„ CordovaPluginExample.java
β”‚        └── πŸ“„ WriteFileHelper.java
└── πŸ“„ plugin.xml

For okhttp we only need to write this:

build.gradle

repositories {
  mavenCentral()
}

dependencies {
  implementation("com.squareup.okhttp3:okhttp:4.9.0")
}

You can add any other repository that you need:

repositories {
  mavenCentral()
  jcenter()
  maven { url "https://jitpack.io" }
  ...
}

Add this build.gradle file as a <framework>:

plugin.xml

<platform name="android">
  
  ...

  <framework src="src/android/build.gradle" custom="true" type="gradleReference" />
  
  ...

</platform>

You can declare multiple <framework>. For this example we only need one. If you decide to use a Gradle file, you could declare all your dependencies inside build.gradle instead of using multiple <framework>. That way you will have all your dependencies at one place.

Edit AndroidManifest.xml

We can use <config-file> tag to add new configurations and <edit-config> tag to edit existing configurations in AndroidManifest.xml.

Declare permissions

For this plugin example, we only need the WRITE_EXTERNAL_STORAGE permission. We have to add the permission at the root of the manifest, we use parent="/*" for that.

<platform name="android">
  
  ...

  <config-file parent="/*" target="AndroidManifest.xml">
    <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
  </config-file>
  
  ...

</platform>

You can add any XML inside the <config-file> tags, not just permissions.

Edit main activity

We don’t need to edit any configuration for this plugin, but I leave you an example:

<platform name="android">
  
  ...

  <edit-config file="AndroidManifest.xml" target="/manifest/application/activity[@android:name='MainActivity']" mode="merge">
    <activity android:name="MainActivity" android:label="@string/app_name">
      <intent-filter>
        <action android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED"/>
      </intent-filter>
    </activity>
  </edit-config>
  
  ...

</platform>

With this configuration we tell Cordova to read a tag <activity> with the property android:name="MainActivity" inside the tags <manifest> > <application>. Since we are using mode="merge", it will merge existing configuration with our configuration (modify matches and add the configuration without matches).

You could check if your XML configuration works, by checking the AndroidManifest.xml generated by your Cordova app at platforms/android/app/src/main/AndroidManifest.xml .

Final plugin.xml configuration

For this plugin example, this is our final plugin.xml configuration for Android:

<!-- android -->
<platform name="android">

  <config-file parent="/*" target="res/xml/config.xml">
    <feature name="CordovaPluginExample">
      <param name="android-package" value="com.cordova.plugin.example.CordovaPluginExample" />
    </feature>
  </config-file>

  <config-file parent="/*" target="AndroidManifest.xml">
    <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
  </config-file>

  <source-file src="src/android/CordovaPluginExample.java" target-dir="src/com/cordova/plugin/example" />
  <source-file src="src/android/WriteFileHelper.java" target-dir="src/com/cordova/plugin/example" />

  <framework src="com.squareup.okhttp3:okhttp:4.9.0" />
  <!-- <framework src="src/android/build.gradle" custom="true" type="gradleReference" /> -->

</platform>

The first <config-file> is used to edit a cordova-android related file (res/xml/config.xml) used to register our plugin main class in Cordova. You could find this file in your Cordova app, at the path platforms/android/app/src/main/res/xml/config.xml

<source-file> is used to copy the source files to the app (platforms/android). We will use target-dir as our Java files package name (package com.cordova.plugin.example;). We could copy directories too:

<source-file src="src/android/<directory-name>" target-dir="src/com/cordova/plugin/example/<directory-name>" />

Android source code structure

πŸ“ cordova-plugin-example
β”œβ”€β”€ πŸ“ src
β”‚   β”œβ”€β”€ πŸ“ android
β”‚   β”‚   β”œβ”€β”€ πŸ“„ build.gradle
β”‚   β”‚   β”œβ”€β”€ πŸ“„ CordovaPluginExample.java
β”‚   β”‚   └── πŸ“„ WriteFileHelper.java
β”‚   β”œβ”€β”€ πŸ“ ios
β”‚   └── πŸ“ electron
β”œβ”€β”€ πŸ“ www
β”‚   └── πŸ“„ CordovaPluginExample.js
β”œβ”€β”€ πŸ“„ .gitignore
β”œβ”€β”€ πŸ“„ package.json
β”œβ”€β”€ πŸ“„ plugin.xml
└── πŸ“„ README.md

Java source code

CordovaPluginExample.java (Main plugin class)

Open on GitHub

package com.cordova.plugin.example;

// imports...

public class CordovaPluginExample extends CordovaPlugin {

  private final String TAG = "CordovaPluginExample";
  private OkHttpClient http;
  private WriteFileHelper writeFileHelper;

  // This method will be called on Cordova initialization.
  // Use it to initialize everything you may need later.
  // In this example, we initialize the HTTP client OkHttpClient
  // and our WriteFileHelper class.
  @Override
  public void initialize(CordovaInterface cordova, CordovaWebView webView) {
    Log.d(TAG, "Initializing Cordova plugin example");
    super.initialize(cordova, webView);
    http = new OkHttpClient();
    writeFileHelper = new WriteFileHelper(this);
  }

  // This method will be called by the JavaScript interface.
  // Remember what we wrote on it (www/CordovaPluginExample.js)
  // 
  // Example:
  // exports.greeting = function (successCallback, errorCallback, name) {
  //   exec(successCallback, errorCallback, PLUGIN_NAME, 'greeting', [name]);
  // };
  // 
  // Return "true" if you have an implementation for the received "action"
  // Return "false" otherwise
  @Override
  public boolean execute(String action, JSONArray args, CallbackContext callbackContext) throws JSONException {
    if (action.equals("greeting")) {
      greeting(callbackContext, args);
      return true;
    }
    if (action.equals("countdownTimer")) {
      countdownTimer(callbackContext, args);
      return true;
    }
    if (action.equals("writeFile")) {
      writeFile(callbackContext, args);
      return true;
    }
    if (action.equals("bitcoinCurrentPrice")) {
      bitcoinCurrentPrice(callbackContext);
      return true;
    }
    return false;
  }

  // This is our simplest example.
  // Returns the string "Hello {name}!"
  // or "Hello!" if a name is not received.
  //
  // callbackContext.success() can return any primitive
  // value or a JSONObject or JSONArray.
  private void greeting(final CallbackContext callbackContext, final JSONArray args) throws JSONException {
    String response = (args.isNull(0))
      ? "Hello!"
      : "Hello " + args.getString(0) + "!";
    callbackContext.success(response);
  }

  // Returns a number every second, from "seconds" parameter value to 0.
  //
  // With this example we will see how can we return
  // multiple values over time, like an Observable.
  //
  // With the Runnable class we are creating
  // something similar to the setTimeout
  // JavaScript function.
  // 
  // To return multiple values, we need the PluginResult class
  // and the method callbackContext.sendPluginResult().
  // To keep the "connection" open, just set the property
  // "keepCallback" of PluginResult to true
  private void countdownTimer(final CallbackContext callbackContext, final JSONArray args) throws JSONException {
    final Integer seconds = (!args.isNull(0) && args.getInt(0) > 0)
      ? args.getInt(0)
      : 10;

    Handler handler = new Handler(Looper.getMainLooper());

    Runnable runnable = new Runnable() {
      Integer secondsLeft = seconds;

      @Override
      public void run() {
        boolean keepCallback = secondsLeft > 0;
        final PluginResult result = new PluginResult(PluginResult.Status.OK, secondsLeft);
        result.setKeepCallback(keepCallback);
        callbackContext.sendPluginResult(result);
        if (keepCallback) {
          secondsLeft--;
          handler.postDelayed(this, 1000);
        } else {
          handler.removeCallbacks(this);
        }
      }

    };

    handler.postDelayed(runnable, 0);
  }

  // With this method we can create and write a file in:
  //   - (Android 9-) Phone Documents directory
  //   - (Android 10+) User can choose the directory
  private void writeFile(final CallbackContext callbackContext, final JSONArray args) throws JSONException {
    writeFileHelper.writeFile(callbackContext, args);
  }

  // We will listen to Intent results and handle
  // the "create file" intent result (Android 10+ only)
  @Override
  public void onActivityResult(int requestCode, int resultCode, Intent data) {
    if (requestCode == WriteFileHelper.CREATE_FILE_CODE) {
      writeFileHelper.onCreateFileActivityResult(resultCode, data);
    }
  }

  // This example is just to show how can we use
  // the external dependency we declared previously
  // in the plugin.xml file with <framework>,
  // in this case, OkHttpClient
  private void bitcoinCurrentPrice(final CallbackContext callbackContext) {
    Request request = new Request.Builder()
      .url("https://api.coindesk.com/v1/bpi/currentprice.json")
      .build();

    try (Response response = http.newCall(request).execute()) {
      JSONObject jsonObject = new JSONObject(response.body().string());
      callbackContext.success(jsonObject);
    } catch (IOException | JSONException e) {
      Log.e(TAG, e.getMessage());
      e.printStackTrace();
      callbackContext.error("REQUEST_ERROR");
    }
  }

}

WriteFileHelper.java

Open on GitHub

package com.cordova.plugin.example;

// imports...

public class WriteFileHelper {

  // Code used with the create file Intent
  public static Integer CREATE_FILE_CODE = 1;

  private CordovaPlugin plugin;
  private CallbackContext intentCallbackContext;
  private String intentText;

  // We need to pass as a parameter our main class to be
  // able to access the main Android Activity later on
  public WriteFileHelper(CordovaPlugin plugin) {
    this.plugin = plugin;
  }

  // This is the main method of this helper class
  // We will have different implementations for
  // Android 9 and lower and Android 10 and higher
  public void writeFile(final CallbackContext callbackContext, final JSONArray args) throws JSONException {
    if (args.isNull(0) || args.isNull(1)) {
      callbackContext.error("BAD_ARGS");
      return;
    }

    String fileName = args.getString(0);
    String text = args.getString(1);

    if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.Q) {
      writeFileAndroid9AndLower(callbackContext, fileName, text);
    } else {
      writeFileAndroid10AndHigher(callbackContext, fileName, text);
    }
  }

  // getExternalStoragePublicDirectory is deprecated in newer versions of Android
  // https://developer.android.com/reference/android/os/Environment#getExternalStoragePublicDirectory(java.lang.String)
  private void writeFileAndroid9AndLower(
    final CallbackContext callbackContext,
    final String fileName,
    final String text
  ) {
    try {
      File dir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOCUMENTS);

      if (!dir.exists()) {
        dir.mkdirs();
      }

      File file = new File(dir, fileName);

      if (!file.exists()) {
        if (!file.createNewFile()) {
          throw new IOException("Error creating file");
        }
      }

      FileWriter fileWriter = new FileWriter(file);
      fileWriter.append(text);
      callbackContext.success();
    } catch (IOException e) {
      callbackContext.error("WRITE_ERROR");
    }
  }

  // For Android 10 and higher, we are going to create
  // files using Android Intents.
  // We will create the file with an Intent, and after
  // the file is created we will write on it.
  // https://developer.android.com/training/data-storage/shared/documents-files#create-file
  @TargetApi(Build.VERSION_CODES.O)
  private void writeFileAndroid10AndHigher(
    final CallbackContext callbackContext,
    final String fileName,
    final String text
  ) {
    Intent intent = new Intent(Intent.ACTION_CREATE_DOCUMENT);
    intent.addCategory(Intent.CATEGORY_OPENABLE);
    intent.setType("text/plain");
    intent.putExtra(Intent.EXTRA_TITLE, fileName);
    intent.putExtra("text", text);

    // Optionally, specify a URI for the directory that should be opened by
    // the system's file picker when your app creates the document.
    intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, "");

    // We need to save the callbackContext and text
    // to use it in onCreateFileActivityResult()
    intentCallbackContext = callbackContext;
    intentText = text;

    plugin.cordova.setActivityResultCallback(plugin);
    plugin.cordova.getActivity().startActivityForResult(intent, WriteFileHelper.CREATE_FILE_CODE);
  }

  // This method handles the Intent result.
  // It obtains the created file URI, open an OutputStream
  // and write the text passed as parameter in the writeFile method
  public void onCreateFileActivityResult(int resultCode, Intent data) {
    if (resultCode == Activity.RESULT_OK) {
      try {
        Uri uri = data.getData();
        ContentResolver contentResolver = plugin.cordova.getActivity().getContentResolver();
        OutputStream outputStream = contentResolver.openOutputStream(uri);
        outputStream.write(intentText.getBytes(StandardCharsets.UTF_8));
        outputStream.close();
        intentCallbackContext.success();
      } catch (FileNotFoundException e) {
        e.printStackTrace();
        intentCallbackContext.error("FILE_NOT_FOUND");
      } catch (IOException e) {
        e.printStackTrace();
        intentCallbackContext.error("CLOSE_IO_EXCEPTION");
      }
    } else {
      intentCallbackContext.error("INTENT_ERROR");
    }
    intentCallbackContext = null;
    intentText = null;
  }

}

Threading

For heavy tasks or blocking calls, you should create a new thread with getThreadPool().

@Override
public boolean execute(String action, JSONArray args, CallbackContext callbackContext) {

  // ...

  if (action.equals("heavyTask")) {
    cordova.getThreadPool().execute(new Runnable() {
      public void run() {
        heavyTask(callbackContext, args);
      }
    });
    return true;
  }

  // ...
}

Continue reading the main post