Crea un plugin de Cordova para Android

18 de abril de 2022
Tabla de contenidos

Post principal

Este post forma parte de este tutorial:

Si no has leido ese otro post, es posible que necesites leerlo antes de continuar con este. Aprenderás a:

Puedes ver el código fuente de este tutorial en GitHub:
https://github.com/adrian-bueno/multiplatform-cordova-plugin-example

Inicio

Vamos a implementar en Java los 4 métodos que definimos en nuestra interfaz de JavaScript www/CordovaPluginExample.js:

Crea un nuevo directorio android dentro de src.

Después crea los ficheros CordovaPluginExample.java y WriteFileHelper.java dentro de android.

📁 cordova-plugin-example
└── 📁 src
    └── 📁 android
         ├── 📄 CordovaPluginExample.java
         └── 📄 WriteFileHelper.java

WriteFileHelper.java es una clase de ayuda para mantener el código de CordovaPluginExample.java más limpio.

Dependencias externas

Vamos a usar okhttp para obtener el precio actual del Bitcoin de https://api.coindesk.com/v1/bpi/currentprice.json . Podríamos hacer esto con JavaScript en el navegador, pero es un ejemplo sencillo con el que mostrar como usar dependencias externas.

Podemos añadir dependencias externas de 2 maneras:

  1. Usando <framework> en plugin.xml.
  2. Usando <framework> en plugin.xml y un fichero Gradle build.gradle.

<framework> en plugin.xml

Las dependencias que añadamos de esta forma, se descargaran de Maven Central.

plugin.xml

<platform name="android">
  
  ...

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

</platform>

<framework> y fichero Gradle

Para tener más control y poder usar otros repositorios, debemos usar un fichero Gradle. Crea el fichero build.gradle dentro de src/android.

📁 cordova-plugin-example
├── 📁 src
│   └── 📁 android
│        ├── 📄 build.gradle
│        ├── 📄 CordovaPluginExample.java
│        └── 📄 WriteFileHelper.java
└── 📄 plugin.xml

Para usar okhttp solo necesitamos escribir lo siguiente:

build.gradle

repositories {
  mavenCentral()
}

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

Puedes añadir cualquier otro repositorio que quieras:

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

Añade este fichero build.gradle como un <framework>:

plugin.xml

<platform name="android">
  
  ...

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

</platform>

Podemos usar todos los <framework> que queramos. Para este ejemplo solo necesitamos uno. Si decides usar un fichero de Gradle, puedes declarar todas tus dependencias en tu fichero build.gradle en vez de declarar varios <framework>. De esta manera estarán todas las dependencias declaradas en un solo lugar.

Editar AndroidManifest.xml

Podemos usar el tag <config-file> para añadir nuevas configuraciones y el tag <edit-config> para editar configuraciones existentes en AndroidManifest.xml.

Declarar permisos

Para el ejemplo de plugin que estamos construyendo, solo necesitamos especificar el permiso WRITE_EXTERNAL_STORAGE. Debemos añadir los permisos en la raíz del fichero AndroidManifest.xml, para ello usamos parent="/*".

<platform name="android">
  
  ...

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

</platform>

Podemos añadir cualquier código XML dentro de los tags <config-file>, no solo permisos.

Editar la actividad principal

Para este plugin no necesitamos editar ninguna configuración, pero os dejo un ejemplo por si lo necesitais hacer en algún momento:

<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>

Con esta configuración le decimos a Cordova que lea un tag <activity> con la propiedad android:name="MainActivity" dentro de los tags <manifest> > <application>. Como estamos usando mode="merge", hará una fusión entre la configuración existente y la nuestra (modifica con lo nuestro la configuración que coincida y añadirá la que no coincida).

Podemos comprobar si la configuración que hemos añadido funciona, comprobando el fichero AndroidManifest.xml generado por Cordova en platforms/android/app/src/main/AndroidManifest.xml .

Configuración final de plugin.xml

Esta es la configuración final para Android de nuestro plugin.xml:

<!-- 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>

El primer <config-file> se usa para editar el fichero res/xml/config.xml de cordova-android. Este fichero lo usa Cordova para registrar el plugin y su implementación Java en la aplicación de Android generada por Cordova. El fichero se encuentra en la siguiente ruta: platforms/android/app/src/main/res/xml/config.xml

<source-file> se usa para copiar el código fuente a la aplicación (platforms/android). En Android tenemos que poner el valor de target-dir como el nombre de paquete de los ficheros Java (package com.cordova.plugin.example;). Podemos copiar directorios también, en vez de fichero a fichero:

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

Estructura del código fuente de Android

📁 cordova-plugin-example
├── 📁 src
│   ├── 📁 android
│   │   ├── 📄 build.gradle
│   │   ├── 📄 CordovaPluginExample.java
│   │   └── 📄 WriteFileHelper.java
│   ├── 📁 ios
│   └── 📁 electron
├── 📁 www
│   └── 📄 CordovaPluginExample.js
├── 📄 .gitignore
├── 📄 package.json
├── 📄 plugin.xml
└── 📄 README.md

Código fuente en Java

CordovaPluginExample.java (Clase principal del plugin)

Abrir en GitHub

package com.cordova.plugin.example;

// imports...

public class CordovaPluginExample extends CordovaPlugin {

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

  // Este método es llamado con la inicialización de Cordova.
  // Usa este método para inicializar todo lo que necesites
  // más adelante. En este ejemplo, inicializamos el
  // cliente HTTP OkHttpClient y la clase de ayuda WriteFileHelper.
  @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);
  }

  // Este método es el que se ejecuta con las llamadas desde
  // la capa de JavaScript (www/CordovaPluginExample.js).
  // Un recordatorio de lo que pusimos en él:
  //
  // exports.greeting = function (successCallback, errorCallback, name) {
  //   exec(successCallback, errorCallback, PLUGIN_NAME, 'greeting', [name]);
  // };
  // 
  // Devuelve "true" si existe una implementación de esa acción.
  // Devuelve "false" en el caso contrario.
  @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;
  }

  // Este es el ejemplo más sencillo.
  // Devuelve el string "Hello {name}!"
  // o "Hello!" si no se recibe ningún nombre.
  //
  // callbackContext.success() puede devolver
  // cualquier valor primitivo, un JSONObject
  // o un 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);
  }

  // Devuelve un número cada segundo, desde el valor
  // del parámetro "seconds" hasta 0.
  //
  // Este ejemplo sirve para mostrar como devolver
  // más de un valor a lo largo del tiempo, como
  // un Observable.
  //
  // Con el Runnable estamos creando algo similar
  // a un setTimeout de JavaScript.
  //
  // Para devolver múltiples valores, necesitamos la 
  // clase PluginResult y el método callbackContext.sendPluginResult().
  // Para mantener la "conexión" abierta, tenemos que
  // asignar el valor "true" a la propiedad "keepCallback" de PluginResult
  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);
  }

  // Con este método podemos crear y escribir en un fichero en:
  //   - (Android 9-) El directorio Documents del teléfono
  //   - (Android 10+) El usuario puede elegir el directorio
  private void writeFile(final CallbackContext callbackContext, final JSONArray args) throws JSONException {
    writeFileHelper.writeFile(callbackContext, args);
  }

  // Para Android 10+, tenemos que capturar los resultados
  // del Intent de crear ficheros.
  @Override
  public void onActivityResult(int requestCode, int resultCode, Intent data) {
    if (requestCode == WriteFileHelper.CREATE_FILE_CODE) {
      writeFileHelper.onCreateFileActivityResult(resultCode, data);
    }
  }

  // Este ejemplo sirve solo para mostrar como usar
  // la dependencia que hemos añadido en el plugin.xml
  // con <framework>, en este caso 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

Abrir en GitHub

package com.cordova.plugin.example;

// imports...

public class WriteFileHelper {

  // Código usado con el Intent de crear ficheros
  public static Integer CREATE_FILE_CODE = 1;

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

  // Tenemos que pasar como parámetro la clase
  // principal del plugin para poder acceder
  // a la actividad principal de Android más adelante
  public WriteFileHelper(CordovaPlugin plugin) {
    this.plugin = plugin;
  }

  // Este es el método principal de esta clase de ayuda.
  // Tenemos implementaciones diferentes dependiendo
  // de si estamos en Android 9 o inferior o 
  // Android 10 o superior
  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 esta descontinuado (deprecated)
  // en las versiones nuevas de 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");
    }
  }

  // Para Android 10 y superior vamos a crear
  // ficheros usando los Intents de Android.
  // Vamos a crear el fichero con un Intent y
  // depués escribiremos en él.
  // 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);
    intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, "");

    // Tenemos que guardar el callbackContext
    // y el texto para usarlo en onCreateFileActivityResult()
    intentCallbackContext = callbackContext;
    intentText = text;

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

  // Con este método controlamos el resultado del Intent.
  // Obtenemos la URI del fichero creado, abrimos un
  // OutputStream y escribimos el texto que recibimos
  // por el método "writeFile"
  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

Para tareas pesadas o llamadas bloqueantes, se debería crear un nuevo hilo con 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;
  }

  // ...
}

Continua leyendo el post principal