Capacitor plugin method types

Capacitor logo

Capacitor plugins make are responsible for making a connection between your typescript code with a native environment. In many cases a simple call to native environment may not be enough. Capacitor provide 3 types of plugin calls that can be used. In this post I will outline those and present them along some simple examples.

If you are interested in Cordova plugin development, you can check my previous post when I wrote how to create a Cordova plugin using Swift and Kotlin.

tldr;
You can jump directly to this repository and grab all the prepared examples.

Setup

(you can skip this step if you have a working project)

Before we start, we need to set up our project. The code examples will be using Ionic Angular for Ionic part, Swift for iOS and Kotlin for Android.

Since libraries may change, here are the environment details used when writing this post:

npx cap -V
4.5.0
ionic -v
6.20.8
npm -v
8.5.0

First create a project using:

ionic start 

Android setup

Capacitor project doesn’t come up with Kotlin enabled. Thankfully there are not many changes required to enable it. We just need to add few lines to module build.gradle (android/build.gradle) and project build.gradle (android/app/build.gradle)

In the module file, inside the dependencies sections add:

//android/build.gradle
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:1.8.10"

Then in the project file add:

//android/app/build.gradle
apply plugin: 'kotlin-android' // at top of the file

// inside dependencies section
implementation 'androidx.core:core-ktx:1.9.0'
implementation 'org.jetbrains.kotlin:kotlin-stdlib:1.7.22'

That’s it. You can now create Kotlin files in your Android project.

Now we prepare a plugin Kotlin class:

//CapMethodsPlugin.kt
package io.ionic.starter

import com.getcapacitor.Plugin
import com.getcapacitor.annotation.CapacitorPlugin


@CapacitorPlugin(name="CapMethodsPlugin")
class CapMethodsPlugin : Plugin() {

}

We also need to register our plugin in MainActivity:

//MainActivity.java
package io.ionic.starter;

import android.os.Bundle;

import com.getcapacitor.BridgeActivity;

public class MainActivity extends BridgeActivity {
  @Override
  public void onCreate(Bundle savedInstanceState) {
    registerPlugin(CapMethodsPlugin.class);

    super.onCreate(savedInstanceState);
  }
}

Voila! Android project is ready. We can now focus on extending its new methods.

iOS setup

Xcode doesn’t require as many setup as Android, but regardless if we will use Swift as a codebase, we still have to add an Objective-C file and a Bridging Header. We can think of your Objective-C file as a header (although it’s .m not .h) that declares plugin methods.

//CapMethodsPlugin.m
#import <Capacitor/Capacitor.h>

CAP_PLUGIN(CapMethodsPlugin, "CapMethodsPlugin")

and our plugin code class:

//CapMethodsPlugin.swift
import Capacitor

@objc(CapMethodsPlugin)
public class CapMethodsPlugin: CAPPlugin {
}

That’s it. Our project is ready to begin with.

Return None – Promise<void>

Method type with void promise is a simple call that you would like to send to the native environment without expecting any return. That could be opening an external app, share a dialog or just running some background process without a need to check on it later.

Let’s create an example in which we show a native alert dialog with a text passed from the app. First we create a service that will be responsible for calling the native environment:

import { Injectable } from '@angular/core';
import { Capacitor, registerPlugin } from '@capacitor/core';

const _pluginName: string = 'CapMethodsPlugin';

export interface CapMethodsPlugin {
  openAlert(text: { text: string }): Promise<void>;
}
const CapMethodsPlugin = registerPlugin<CapMethodsPlugin>(_pluginName);

@Injectable({
  providedIn: 'root',
})
export class CapMethodsPluginService {
  async openAlert(text: string): Promise<void> {
    if (Capacitor.isPluginAvailable(_pluginName)) {
      await CapMethodsPlugin.openAlert({
        text,
      });
    }
  }
}

Our service name CapMethodsPlugin should correspond with names in iOS and Android. When passing arguments and receiving data it’s always most convenient to use JSON based objects.
Most important part of this code is the CapsMethodsPlugin interface which describes methods that should be available in the native environments. This is the part that we want to extend if we want to expose more methods from native code. After an interface we have to call registerPlugin.

Note: You don’t have to support strong typing here and just use any, but that’s not best idea imho.

The service CapMethodsPluginService is not required, it’s just for convenience. I like to avoid getting unimplemented errors (if plugin works only in one env for example Apple Watch plugin could work only in iOS).

Setup a simple component with a button and a text input:

//home.component.ts
import { Component } from '@angular/core';
import { CapMethodsPluginService } from './services/cap-methods-plugin.service';

@Component({
  selector: 'app-home',
  templateUrl: 'home.page.html',
  styleUrls: ['home.page.scss'],
})
export class HomePage {
  alertText: string = 'Alert text';
  constructor(private capMethodsPluginService: CapMethodsPluginService) {}

  async openAlert() {
    await this.capMethodsPluginService.openAlert(this.alertText);
  }
}

and some astonishing UI:

<ion-header [translucent]="true">
  <ion-toolbar>
    <ion-title>
      Ionic Capacitor Method Examples
    </ion-title>
  </ion-toolbar>
</ion-header>

<ion-content [fullscreen]="true">
  <ion-header collapse="condense">
    <ion-toolbar>
      <ion-title size="large">Ionic Capacitor Method Examples</ion-title>
    </ion-toolbar>
  </ion-header>

  <div id="container">
    <h2>Void Method example</h2>
    <div class="row">
      <ion-input color="primary" [(ngModel)]="alertText"></ion-input>
      <ion-button (click)="openAlert()">Open alert</ion-button>
    </div>
  </div>
</ion-content>

First we will add an implementation in iOS. We need to let iOS know that the plugin supports a new method, therefore we add a CAP_PLUGIN_METHOD into our CapMethodsPlugin.m file

//CapMethodsPlugin.m
#import <Capacitor/Capacitor.h>

CAP_PLUGIN(CapMethodsPlugin, "CapMethodsPlugin",
//below is new method declaration
           CAP_PLUGIN_METHOD(openAlert, CAPPluginReturnNone);
)

now we can do an implementation inside CapMethodsPlugin.swift

//CapMethodsPlugin.swift
@objc func openAlert(_ call: CAPPluginCall) {
    let text = call.getString("text")
    DispatchQueue.main.async {
        let dialogMessage = UIAlertController(title: "My alert", message: text, preferredStyle: .alert)
        
        let okButton = UIAlertAction(title: "OK", style: .default, handler: { (action) -> Void in print("Closed")})
        
        dialogMessage.addAction(okButton)
        self.bridge?.viewController?.present(dialogMessage, animated: true)
    }
}

As you can see this code won’t have anything special to return. We simple get the property text from Ionic call, then we create an alert dialog and present it using native iOS environment.
Keep in mind that all methods that are exposed from the plugin must have an @objc attribute.

Now, when using Kotlin we don’t have to update any more headers like .m file in iOS. We just keep on extending our plugin class. This is a great advantage that I find towards Cordova as you don’t have all of the calls landing in a single method that ends up (in most cases) having a huge artificial routing based on switch of string. Now you can just keep on adding new methods and Capacitor will connect them by itself.

@PluginMethod(returnType = PluginMethod.RETURN_NONE)
fun openAlert(call: PluginCall) {
  var text = call.getString("text")
  val alertDialogBuilder = AlertDialog.Builder(this.bridge.context)
  alertDialogBuilder.setTitle("My alert")
  alertDialogBuilder.setMessage(text)
  alertDialogBuilder.setPositiveButton(android.R.string.ok) { dialog,               which -> }
  alertDialogBuilder.show()
}

Each capacitor method must have a @PluginMethod header and that’s basically all requirements. The PluginCall is similar to the one in iOS (CAPPluginCall) so you should expect similar methods between one and the other.

In both environments there’s a bridge property that let’s you access the app context and global UI contexts. Remember that working directly on UI should be always dispatched to main context.

Return value – Promise<T>

Next on our way is returning a value from the native environment. We can pass the arguments and expect some return or we can make a call without arguments and await the response.

This type of method would be usually used when there’s a need to get some device information like network configuration, memory stats or installed applications. Let’s add a method that will return a Bundle/Code version.

First we add a button, extend interface and add a call from Ionic app

// CapPluginMethods.ts

// inside CapMethodsPlugin interface 
getAppVersion(): Promise<{version: string}>;

// inside CapMethodsPluginService
async getAppVersion(): Promise<string | null> {
  if (Capacitor.isPluginAvailable(_pluginName)) {
    return (await CapMethodsPlugin.getAppVersion()).version;
  }
  return null;
}
<!-- home.compomennt.html-->
<h2>Value Method example</h2>
<div class="row">
  <ion-input color="primary" [(ngModel)]="alertText"></ion-input>
  <ion-button (click)="getAppVersion()">Get app version</ion-button>
</div>
// home.component.ts
async getAppVersion() {
  const version = await this.capMethodsPluginService.getAppVersion();
  alert(`Current version is: ${version}`);
}

First we get a version from iOS. We need to add a header inside CapMethodsPlugin.m

CAP_PLUGIN_METHOD(getAppVersion, CAPPluginReturnPromise);

notice that this time we changed the return type to CAPPluginReturnPromise. Followed by method header, we add implementation of it in swift file:

// CapMethodsPlugin.swift
@objc func getAppVersion(_ call: CAPPluginCall) {
      if let version = Bundle.main.infoDictionary?["CFBundleVersion"] as? String {
          call.resolve(["version": version])
      } else {
          call.resolve(["version": "Cannot get version"])
      }
  }

Use resolve method from call to pass the response. The data will be implicitly converted to the typescript object when grabbed at Ionic part.

Now the Kotlin example:

//CapMethodsPlugin.kt

@PluginMethod(returnType = PluginMethod.RETURN_PROMISE)
fun getAppVersion(call: PluginCall) {
  var version = BuildConfig.VERSION_CODE

  var result = JSObject()
  result.put("version", version)

  call.resolve(result)
}

As mentioned Capacitor framework has similar implementation on the Android side. The resolve method expects a JSObject to be passed which is also converted to typescript object by the framework and available in Ionic part of application.

Return callback – Promise<CallbackId>

Last type of call is a callback type. This one is the most advanced one. It can be used in two ways:

  1. One time return value – example could be a long running asynchronous operation. Make a request to the native environment and react when the processing is done.
  2. Multiple times return values – a type of subscription – make a request and keep on receiving data from the native environment. An example could be watching over memory usage in the device.

One time return value

We will start with a simpler version – a single one time return. As always, first we start from Ionic part. Extend interface -> add a button -> add a button action:

// cap-methods-plugin.service.ts

// a callback definition
export type ComputePluginCallback = (message: { result: number } | null, err?: any) => void;
export class ComputePluginParameters {
  constructor(public a: number, public b: number) { }
}
// inside CapMethodsPlugin interface
  computeAsync(params: ComputePluginParameters): Promise<CallbackID>;

// inside CapMethodsPluginService
async computeAsync(
  params: ComputePluginParameters,
  callback: ComputePluginCallback
): Promise<void> {
  if (Capacitor.isPluginAvailable(_pluginName)) {
    await CapMethodsPlugin.computeAsync(params, callback);
  }
}

There’s an obvious difference using callbacks. We need to create a callback type that will be later used by our new plugin method. The callback that we passed, will be invoked on the resolve call from native.

<!-- home.component.html -->
<h2>Async callback example</h2>
<p *ngIf="callBackStatus">
  Status: {{callBackStatus}}
</p>
<div class="row">
  <ion-input type="number" color="primary" [(ngModel)]="a"></ion-input>
  <ion-input type="number" color="primary" [(ngModel)]="b"></ion-input>
  <ion-button (click)="compute()">Compute</ion-button>
</div>
async compute() {
  await this.capMethodsPluginService.computeAsync(
    new ComputePluginParameters(this.a, this.b),
    (message) => {
      if (message && message.result) {
        alert(`Result is: ${message.result}`);
      }
      this.callBackStatus = 'Completed';
    }
  );
  this.callBackStatus = 'Invoked';
}

What we want to observe here is that when we make a call and the app continues to execute, then after some time the callback will be invoked and an alert will present results.

As usual we start from iOS and CapMethodsPlugin.m:

CAP_PLUGIN_METHOD(computeAsync, CAPPluginReturnCallback);

Note a different type of PluginMethod.

    @objc func computeAsync(_ call: CAPPluginCall) {
        DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
            let a = call.getInt("a")!
            let b = call.getInt("b")!
            let result = a+b
            call.resolve(["result" : result])
        }
    }

In this example we artificialy delay the response by 3 seconds. Then the heavy algorithms takes plac, sum up two numbers and send over the result. After that the callback is being executed.

Now the Kotlin code:

  @PluginMethod(returnType = PluginMethod.RETURN_CALLBACK)
  fun computeAsync(call: PluginCall) {
    Timer().schedule(3000) {
      var a = call.getInt("a") as Int
      var b = call.getInt("b") as Int
      var result = JSObject()
      result.put("result", a + b)
      call.resolve(result)
    }
  }

Kotlin code does exactly the same as the Swift one. Wait 3s and execute resolve. We can see that the app is running and doesn’t freeze when waiting for result.

Multiple times return values

Multiple times return values are much more complicated than the one time call. We need to make sure that:

  1. Capacitor knows that the call should not be closed
  2. The call needs to be terminated at some point manually

To not extend this post greatly, and focus on the main topic we would just get some counter running each second and sending over current value.

First let’s do the Ionic part. The definitions are similar to those in the one time callback. This time though we have to add two methods – second being a Promise<void>.

//cap-methods-plugin.service.ts

//callback definition
export type GetCounterPluginCallback = (
  message: { count: number } | null,
  err?: any
) => void;

 
 //inside interfaces
  subscribeToCounter(callback: GetCounterPluginCallback): Promise<CallbackID>;
  unsubscribeFromCounter(data: { callbackId: string }): Promise<void>;
  
 //method services
  async subscribeToCounter(
    callback: GetCounterPluginCallback
  ): Promise<CallbackID | null> {
    if (Capacitor.isPluginAvailable(_pluginName)) {
      return await CapMethodsPlugin.subscribeToCounter(callback);
    }
    return null;
  }

  async unsubscribeFromCounter(callbackId: string): Promise<void> {
    if (Capacitor.isPluginAvailable(_pluginName)) {
      return await CapMethodsPlugin.unsubscribeFromCounter({ callbackId });
    }
  }

Subscribe will be responsible for establishing contact with native and receiving results while unsubscribe will terminate the connection.

Let’s add some action buttons to UI:

<!-- home.component.html -->
<h2>Async multiplecallback example</h2>
<p *ngIf="multipleCallBackStatus">
  Response: {{multipleCallBackStatus}}
</p>
<div class="row">
  <ion-button (click)="subscribe()">Subscribe</ion-button>
  <ion-button *ngIf="callbackId" (click)="unsubscribe()">Unsubscribe</ion-button>
</div>
//home.component.ts
  callbackId: string | null = null;
  multipleCallBackStatus: string | null = null;
  
  
  async subscribe() {
    this.callbackId = await this.capMethodsPluginService.subscribeToCounter(
      (result) => {
        this.multipleCallBackStatus = result?.count.toString() ?? null;
        this.cd.detectChanges();
      }
    );
  }

  async unsubscribe() {
    if (this.callbackId) {
      await this.capMethodsPluginService.unsubscribeFromCounter(
        this.callbackId
      );
      this.callbackId = null;
    }
  }

We need to store callbackId once it’s created. The callbackId is used in unsubscribe method so the proper call will be closed.

Now we add iOS part starting as usual from CapMethodsPlugin.m:

CAP_PLUGIN_METHOD(subscribeToCounter, CAPPluginReturnCallback);           CAP_PLUGIN_METHOD(unsubscribeFromCounter, CAPPluginReturnNone);

and implementation CapMethodsPlugin.swift:

  var runCount = 0;
  var timer: Timer? = nil;
  
  @objc func subscribeToCounter(_ call: CAPPluginCall) {
      if self.timer == nil {
          call.keepAlive = true
          self.runCount = 0;
          DispatchQueue.main.async {
              self.timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true)
              { timer in
                  self.runCount += 1
                  call.resolve(["count": self.runCount])
              }
          }
      }
  }
  
  @objc func unsubscribeFromCounter(_ call: CAPPluginCall) {
      let callbackId = call.getString("callbackId")!
      if self.timer != nil {
          self.timer?.invalidate()
          self.timer = nil
      }
      self.bridge?.releaseCall(withID: callbackId)
  }

In the code above there is an important use of call.keepAlive = true, that automatically saves the callback so it won’t be terminated by Capacitor. You can also make a manual saveCall (it can be even used for single callback as well, when you won’t to delay it on purpose).

The other important part is releaseCall which terminates the connection. This is used for unsubscribe along terminating the current timer.

Now finally let’s look at Kotlin:

var timer: TimerTask? = null
var runCount: Int = 0

@PluginMethod(returnType = PluginMethod.RETURN_CALLBACK)
fun subscribeToCounter(call: PluginCall) {
  if (timer != null) {
    return;
  }

  call.setKeepAlive(true)
  runCount = 0

  timer = Timer().schedule(0, 1000) {
    runCount ++
    var result = JSObject()
    result.put("count", runCount)
    call.resolve(result)
  }
}

@PluginMethod(returnType = PluginMethod.RETURN_NONE)
fun unsubscribeFromCounter(call: PluginCall) {
  var callbackId = call.getString("callbackId");
  if (timer != null) {
    (timer as TimerTask).cancel()
    timer = null
  }
  bridge.releaseCall(callbackId)
}

All in all the Android part is doing the same. Initiate the counter, setting keepAlive and returning counter value every second. Another method is responsible for terminating the subscription. It may not be the best code example, but it serves it purpose 😉

Summary

In this post I wanted to give a brief overview of possible ways to connect Capacitor app with the native environment. Three different types of methods should be enough to create great apps with many features from simple communication with native environment to more complex subscription alike methods.

Probably in most cases Promise<T> would be enough, but sometimes you may want to add more of subscription types. I hope this post let’s you find it easier to use than it seems at the first glance.


Opublikowano

w

, , ,

przez

Komentarze

2 odpowiedzi na „Capacitor plugin method types”

  1. […] If you haven’t created any Capacitor Plugins yet, you can take a learn it from my previous post. […]

  2. […] If you’re having problems with adding Kotlin files to Capacitor project, check Android Setup in this post […]