Crea un plugin de Cordova para Android
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:
- Preparar la configuración base del plugin
- Crear la interfaz de JavaScript
- Usar el plugin en una app de Cordova
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
:
- greeting
- countdownTimer
- writeFile
- bitcoinCurrentPrice
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:
- Usando <framework> en
plugin.xml
. - Usando <framework> en
plugin.xml
y un fichero Gradlebuild.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 enplatforms/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)
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
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;
}
// ...
}