Crea la capa de Ionic Native para un plugin de Cordova

18 de abril de 2022

En este post aprenderemos como crear la capa de @awesome-cordova-plugins para hacer más sencillo el uso de nuestro plugin de Cordova. Esta simplificación se consigue mapeando las funciones de callback del plugin de Cordova a Promesas y Observables.

@awesome-cordova-plugins se llamaba antes @ionic-native.
El proyecto ha cambiado solo el nombre, la funcionalidad continua igual.

Este post es la continuación del siguiente tutorial:

Por lo que la creación de esta capa se basará en el plugin que construimos en ese tutorial.

Puedes encontrar el código fuente de este tutorial en GitHub:
https://github.com/adrian-bueno/multiplatform-cordova-plugin-example
(en packages > awesome-cordova-plugins-example).

Empezaremos primero definiendo como abordar la creación de esta capa dependiendo de si nuestro plugin será público o privado. Después veremos como se escribe esta capa usando TypeScript.

Mi plugin será público

Si tu plugin será público, simplemente crea un fork del repositorio oficial de @awesome-cordova-plugins. Luego clona tu fork:

git clone https://github.com/<tu-nombre-de-usuario>/awesome-cordova-plugins.git

Una vez lo hayas clonado, crea un nuevo directorio con el nombre de tu plugin dentro de src/@awesome-cordova-plugins/plugins.

Después crea un fichero index.ts dentro de este nuevo directorio. Veremos que escribir en él, en la sección: Código de la capa.

Una vez que tengas el código listo, crea un pull request al repositorio oficial. Cuando sea aceptado, tu capa estará disponible en npm con el nombre de paquete @awesome-cordova-plugins/<nombre-del-directorio-de-tu-plugin>.

Mi plugin será privado

Para construir un plugin privado tenemos 2 opciones:

  1. Creamos un fork del repositorio oficial. Borramos todos los directorios dentro de src/@awesome-cordova-plugins/plugins y creamos nuevos directorios para todas nuestras capas de plugins. Luego subimos los paquetes generados a nuestro repositorio privado de npm.

  2. (Esta opción es un preferencia personal) Creamos un fork del repositorio oficial y adaptamos el código para que solo contenga una capa para un plugin. Además de borrar todos los ficheros que no consideremos necesarios.

Da igual la opción que elijamos, pues con ambas opciones tendremos que ir actualizando el código de los scripts de construcción con el código del repositorio oficial.

Os voy a mostrar como he adaptado el repositorio oficial para que solo contenga una capa en vez de varias y solo genere un paquete.

También podeis ahorraros leer esto y usar directamente mi código como base para el vuestro. Ver en GitHub.

Primero, clona el repositorio oficial y cambiale el nombre:

git clone --depth=1 https://github.com/danielsogl/awesome-cordova-plugins.git awesome-cordova-plugins-example

Borra los siguientes directorios y ficheros:

📁 .git
📁 .github
📁 .husky
📁 docs
📁 scripts
 ├── 📁 docs
 ├── 📁 tasks
 │    └── 📄 publish.ts
 ├── 📁 templates
 └── 📄 logger.ts **
📁 src
 └── 📁 @awesome-cordova-plugins
📄 .editorconfig **
📄 .eslintignore **
📄 .eslintrc **
📄 .gitbook.yml
📄 .prettierignore **
📄 CHANGELOG.md **
📄 CODE_OF_CONDUCT.md
📄 DEVELOPER.md
📄 gulpfile.js
📄 prettier.config.js **
📄 LICENSE **
📄 renovate.json
📄 tsconfig.core.json

Puedes mantener los ficheros marcados con **, pero adaptalos a tus necesidades y gustos. Si borras el fichero logger.ts tendrás que usar console.log en los ficheros que usen el logger.

Borra el directorio .git y crea un nuevo repositorio.

Borra la propiedad paths del fichero tsconfig.json y edita el valor de la propiedad include:

{
  "compilerOptions": {
    "baseUrl": ".",
    "declaration": true,
    "stripInternal": true,
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true,
    "noImplicitAny": true,
    "module": "es2015",
    "moduleResolution": "node",
    "outDir": "./dist",
    "rootDir": "src",
    "target": "es5",
    "skipLibCheck": true,
    "lib": ["es2017", "dom"],
    "inlineSources": true,
    "inlineSourceMap": true
  },
  "include": ["src/**/*.ts"], <---
  "angularCompilerOptions": {
    "genDir": "aot"
  }
}

Borra también paths del fichero scripts/tsconfig.json:

{
  "compilerOptions": {
    "module": "commonjs",
    "target": "es5",
    "moduleResolution": "node",
    "noImplicitAny": false,
    "lib": ["es6"]
  },
  "exclude": ["node_modules"]
}

Haz limpieza en el package.json e instala @awesome-cordova-plugins/core como una dependencia de desarrollo. Cambia también el nombre y la versión.

{
  "name": "awesome-cordova-plugins-example",
  "version": "1.0.0",
  "description": "Native plugin wrapper for cordova-plugin-example",
  "scripts": {
    "build:esm": "ts-node -P scripts/tsconfig.json scripts/tasks/build-esm",
    "build:es5": "ts-node -P scripts/tsconfig.json scripts/tasks/build-es5",
    "build:ngx": "ts-node -P scripts/tsconfig.json scripts/tasks/build-ngx",
    "build": "npm run build:esm && npm run build:ngx && npm run build:es5",
    "prebuild": "rimraf -rf dist"
  },
  "peerDependencies": {
    "@awesome-cordova-plugins/core": "^5.1.0",
    "rxjs": "^5.5.0 || ^6.5.0"
  },
  "devDependencies": {
    "@angular/common": "11.2.14",
    "@angular/compiler": "11.2.14",
    "@angular/compiler-cli": "11.2.14",
    "@angular/core": "11.2.14",
    "@awesome-cordova-plugins/core": "^5.41.0",
    "@types/cordova": "0.0.34",
    "@types/fs-extra": "9.0.13",
    "@types/lodash": "4.14.181",
    "@types/node": "16.11.26",
    "@types/rimraf": "3.0.2",
    "@types/webpack": "5.28.0",
    "fs-extra": "10.0.1",
    "lodash": "4.17.21",
    "rimraf": "3.0.2",
    "rollup": "2.70.1",
    "rxjs": "6.6.7",
    "terser-webpack-plugin": "5.3.1",
    "ts-node": "10.7.0",
    "typescript": "4.1.6",
    "unminified-webpack-plugin": "3.0.0",
    "webpack": "5.71.0",
    "winston": "3.7.2",
    "zone.js": "0.11.5"
  }
}

Lo siguiente que tenemos que hacer, es reemplazar en todos los ficheros TypeScript de la carpeta scripts:

También debemos cambiar el valor de la variable PLUGIN_PATHS de scrits/build/helpers.ts:

export const PLUGIN_PATHS = [join(ROOT, 'src', 'index.ts')];

Y borrar transpileNgxCore() de scripts/tasks/build-ngx.ts, ya que hemos borrado el paquete core de src.

Por último, crea un nuevo fichero TypeScript dentro de scripts/tasks con el nombre build-packagejson.ts:

import { readFileSync, writeFileSync } from 'fs-extra';
import path = require('path');
import { ROOT } from '../build/helpers';

const packageJson = JSON.parse(readFileSync(path.join(ROOT, "package.json")).toString());

const newPackageJson = {
    name: packageJson.name,
    version: packageJson.version,
    module: packageJson.module,
    main: packageJson.main,
    peerDependencies: packageJson.peerDependencies
}

writeFileSync(path.join(ROOT, "dist/package.json"), JSON.stringify(newPackageJson, null, 4));

Este script crea un nuevo package.json dentro del directorio dist. Copia los datos del package.json de la raíz y ‘borra’ los datos que no son necesarios. Añade las propiedades que necesites a la variable newPackageJson.

Actualiza el script build del package.json de la raíz.

{
  "build:packagejson": "ts-node -P scripts/tsconfig.json scripts/tasks/build-packagejson",
  "build": "npm run build:esm && npm run build:ngx && npm run build:es5 && npm run build:packagejson",
}

Antes de ejecutar este script, tenemos que escribir el código de nuestra capa en src/index.ts.

Código de la capa

index.ts (Open on GitHub)

import { Injectable } from '@angular/core';
import { Cordova, Plugin, AwesomeCordovaNativePlugin } from '@awesome-cordova-plugins/core';
import { Observable } from 'rxjs';


// Vamos a definir primero 2 interfaces para
// el objeto con el precio de Bitcoin
// Por ahora no sé si se pueden definir
// interfaces en ficheros separados.
// He tenido problemas al hacerlo, por lo menos
// con el script 'build-ngx'.

export interface BPI {
  code: string;
  description: string;
  rate: string;
  rate_float: number;
  symbol: string;
}

export interface BitcoinCurrentPriceResponse {
  bpi: {
    EUR: BPI;
    GBP: BPI;
    USD: BPI;
  },
  chartName: string;
  disclaimer: string;
  time: {
    updated: string;
    updatedISO: string;
    updateduk: string;
  }
}

// Necesitamos 2 decoradores, @Plugin e @Injectable
//
// Para las propiedades de @Plugin, recuerda
// lo que escribimos en el fichero plugin.xml:
//
// <!-- www -->
// <js-module name="CordovaPluginExample" src="www/CordovaPluginExample.js">
//   <clobbers target="cordova.plugins.CordovaPluginExample" />
// </js-module>
//
// pluginName == js-module.name
// pluginRef == clobbers.target

@Plugin({
  pluginName: 'CordovaPluginExample',
  plugin: 'cordova-plugin-example',
  pluginRef: 'cordova.plugins.CordovaPluginExample',
  repo: 'https://github.com/adrian-bueno/multiplatform-cordova-plugin-example',
  platforms: ['Android', 'iOS', 'Electron']
})
@Injectable()
export class AwesomeCordovaPluginExample extends AwesomeCordovaNativePlugin {

  // Usa el decorador @Cordova en cada método.
  // Como en nuestro fichero JavaScript del plugin (www/CordovaPluginExample.js)
  // pusimos primero las funciones de callback
  // y después los parametros de entrada,
  // ahora tenemos que declarar la propiedad
  // 'callbackOrder' como 'reverse'.

  @Cordova({
    callbackOrder: 'reverse'
  })
  greeting(name?: string): Promise<string> { return null; }

  // Si un método puede devolver más de un valor
  // a lo largo del tiempo, declara la propiedad
  // 'observable' como 'true'.

  @Cordova({
    observable: true,
    callbackOrder: 'reverse'
  })
  countdownTimer(seconds?: number): Observable<number> { return null; }

  // No tenemos que añadir ninguna lógica dentro
  // de los métodos, simplemente devuelve null.

  @Cordova({
    callbackOrder: 'reverse'
  })
  writeFile(fileName: string, text: string): Promise<string> { return null; }

  @Cordova({
    callbackOrder: 'reverse'
  })
  bitcoinCurrentPrice(): Promise<BitcoinCurrentPriceResponse> { return null; }

}

Lee la documentación oficial para ver más opciones y detalles de @Plugin y @Cordova.

Prueba la capa en una app de Cordova

Esta capa funciona con:

Lee la documentación oficial para más detalles y ejemplos de código.

Voy a usar Angular (+ Cordova) para probar el código.

# Instala el CLI de Angular
npm i -g @angular/cli

# Crea una app de Angular
ng new <app-name>
cd <app-name>

Cambia el destino de compilación de Angular a www y añade ./ como baseHref en el fichero angular.json:

{
...

"architect": {
        "build": {
          "builder": "@angular-devkit/build-angular:browser",
          "options": {
            "baseHref": "./",  <---
            "outputPath": "www",  <---
            "index": "src/index.html",
            "main": "src/main.ts",
...
}

Copia el fichero config.xml de la app que creamos en el tutorial principal. Puedes copiarlo de mi repositorio de GitHub.

Instala el CLI de Cordova y las plataformas de Cordova que vayamos a usar:

# Instala el CLI de Cordova
npm i -D cordova

# Crea el directorio www para
# que Cordova no se queje de que 
# la app no es una app de Cordova
# cuando instalemos las plataformas
mkdir www

# Añade la plataforma Android
npx cordova platform add android

# Añade la plataforma iOS (solo si estás usando macOS)
npx cordova platform add ios

# Añade la plataforma Electron
npx cordova platform add electron@3.0.0
# O si quieres usar mi fork de cordova-electron
# para tener la funcionalidad 'keepCallback'
npx cordova platform add https://github.com/adrian-bueno/cordova-electron#feature/keep-callback

Añade www, plugins y platforms al fichero .gitignore.

Instala @awesome-cordova-plugins/core:

npm i @awesome-cordova-plugins/core

Instala la capa que hemos creado:

npm i <path-to-wrapper>/awesome-cordova-plugins-example/dist

Instala el plugin:

npx cordova plugin add <path-to-plugin>/cordova-plugin-example

Consejo: Mueve todas las dependencies a devDependencies (package.json) si vas a construir una app de Electron. Vas a reducir el tamaño de la app generada. Las dependencias de Angular solo son necesarias para compilar la app y no se usan mientras la app se está ejecutando.

Añade el script cordova.js al final del index.html:

<script src="cordova.js"></script>

Borra el código por defecto de app.component.html y app.component.ts.

Añade la clase de la capa a la lista de providers en app.module.ts:

// En Angular debemos importar la ruta /ngx
import { AwesomeCordovaPluginExample } from 'awesome-cordova-plugins-example/ngx';

@NgModule({
  // ...
  
  providers: [AwesomeCordovaPluginExample],
  
  // ...
})
export class AppModule { }

Si tu plugin es público, su nombre de paquete será @awesome-cordova-plugins/example en vez de awesome-cordova-plugins-example.

Ahora vamos a crear unos botones en AppComponent, como hicimos en el tutorial principal.

Empezamos con app.component.ts:

import { Component } from '@angular/core';
import { AwesomeCordovaPluginExample } from 'awesome-cordova-plugins-example/ngx'

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.scss']
})
export class AppComponent {

  constructor(private awesomeCordovaPluginExample: AwesomeCordovaPluginExample) {}

  greeting(name?: string) {
    this.awesomeCordovaPluginExample.greeting(name)
      .then(res => console.log(res))
      .catch(error => console.error(error));
  }

  countdown(seconds: number) {
    this.awesomeCordovaPluginExample.countdownTimer(seconds)
      .subscribe(sec => console.log(sec));
  }

  writeFile(fileName: string, text: string) {
    this.awesomeCordovaPluginExample.writeFile(fileName, text)
      .then(res => console.log(res))
      .catch(error => console.error(error));
  }

  bitcoinCurrentPrice() {
    this.awesomeCordovaPluginExample.bitcoinCurrentPrice()
      .then(res => console.log(res))
      .catch(error => console.error(error));
  }

}

app.component.html:

<button (click)="greeting('World!')">
  Greeting "Hello World!"
</button>

<button (click)="greeting()">
  Greeting "Hello!"
</button>

<hr>

<button (click)="countdown(12)">
  Countdown (12)
</button>

<hr>

<button (click)="writeFile('cordova-plugin-example.txt', 'Hello there 👋')">
  Write file
</button>

<hr>

<button (click)="bitcoinCurrentPrice()">
  Bitcoin current price
</button>

Nuestra app de prueba esta lista. Ahora podemos compilarla y ejecutarla en una de las plataformas de Cordova.

npm run build && npx cordova run android

npm run build && npx cordova run ios

npm run build && npx cordova run electron

Nota: No se puede ejecutar cordova-electron@3.0.0 con Node.js 16, usa la versión 14.

Deberías ver los resultados del plugin en la consola al pulsar los botones. Si no sabes como ver los logs, puedes leer como hacerlo en el tutorial principal.