Skip to main content

Implementing a Capacitor plugin

Capacitor plugins provide a practical approach to structured communication through a Portal. The Capacitor bridge is used under the hood in Portals, allowing Capacitor plugins to be used.

In this step, you will author a Capacitor plugin to log analytics.

Exploring the problem

Business sponsors of the Jobsync superapp would like to allow the web apps presented through Portals to log analytic events, with the following requirements:

  1. The ability to log navigation to a new screen shall exist.
  2. The ability to log specific actions taken in the app shall exist.
  3. Every analytic entry shall track the platform the log occurred on.

You could use Portal's pub/sub mechanism to satisfy the requirements, but authoring a Capacitor plugin to handle analytics provides a structured, OOP-based approach to communicate through a Portal without the need to manage subscriptions.

Defining the API contract

Capacitor plugins are bound to a shared API which platform developers (iOS/Android/web) implement. During runtime, a Capacitor plugin dynamically directs calls to the appropriate implementation.

info

Capacitor plugins perform platform-detection under the hood, making them a good abstraction for processes that require different implementations on different platforms.

Ionic recommends using TypeScript to define the API of a Capacitor plugin. This provides a central source of truth for the API across iOS and Android as well as providing type definitions for web code.

Based on the requirements above, the following interface is reasonable for the analytics plugin:


_10
interface AnalyticsPlugin {
_10
logAction(opts: { action: string, params?: any }): Promise<void>;
_10
logScreen(opts: { screen: string, params?: any }): Promise<void>;
_10
}

Notice that the interface doesn't address the requirement of tracking the running platform. This is an implementation detail that can be addressed when platform-specific code is written.

In Xcode, create a new Swift file in the Portals folder named AnalyticsPlugin.swift and populate the file with the following code:

Portals/AnalyticsPlugin.swift

_19
import Foundation
_19
import Capacitor
_19
_19
class AnalyticsPlugin: CAPInstancePlugin, CAPBridgedPlugin {
_19
let jsName = "Analytics"
_19
let identifier = "Analytics"
_19
let pluginMethods: [CAPPluginMethod] = [
_19
.init(name: "logAction", returnType: CAPPluginReturnPromise),
_19
.init(name: "logScreen", returnType: CAPPluginReturnPromise)
_19
]
_19
_19
@objc func logAction(_ call: CAPPluginCall) {
_19
print("AnalyticsPlugin: logAction")
_19
}
_19
_19
@objc func logScreen(_ call: CAPPluginCall) {
_19
print("AnalyticsPlugin: logScreen")
_19
}
_19
}

info

Refer to How To Define a Portal API for detailed information on authoring a Capacitor plugin.

Adding the plugin to a Portal

Capacitor plugins can be added to a Portal that has been initialized. Update the Portal defined in WebAppView, adding the AnalyticsPlugin to the Portal:

Portals/WebAppView.swift

_32
import SwiftUI
_32
import IonicPortals
_32
_32
struct WebAppView: View {
_32
@EnvironmentObject var credentialsManager: CredentialsManager
_32
@Environment(\.dismiss) var dismiss
_32
let metadata: WebAppMetadata
_32
_32
var body: some View {
_32
.adding(AnalyticsPlugin())

Build and run the Jobsync app and navigate to one of the features in the dashboard view. Switch from the 'Initial Context' tab to the 'Capacitor Plugins' tab.

Look at the list of plugins. Each Portal registers a few Capacitor plugins by default, such as Console and Portals (which provides web access to pub/sub). You'll also see that the analytics plugin has been added as Analytics, after the value provided for jsName.

Expand Analytics tap and tap logAction. You'll be taken to a detail view for the method where you can provide input as a JSON string in the 'Argument' field and a button allowing you to execute the method. Click 'Execute logAction' and the method will run, logging to Xcode's console.

In the next section, you'll learn how to access input provided to a method in a Capacitor plugin.

Validating plugin calls

Take a look at the signature of the logAction plugin method:


_10
@objc func logAction(_ call: CAPPluginCall)

CAPPluginCall is the call sent to the plugin method from the Capacitor bridge (which Portals uses under the hood). With it, you can access input data and either successfully resolve the call or reject the call and return an error.

Resolving or rejecting the call completes the asynchronous process initiated by the web code.

Since input data is available as part of the call, you can guard against bad input. Update logAction to reject any calls made to the plugin method that do not contain the action parameter:

Portals/AnalyticsPlugin.swift

_10
@objc func logAction(_ call: CAPPluginCall) {
_10
guard let action = call.getString("action") else {
_10
call.reject("Input option 'action' must be provided.")
_10
return
_10
}
_10
print("AnalyticsPlugin: logAction")
_10
}

Build, run, and navigate to the 'Capacitor Plugins' tab. Click logAction to get to the detail view, and then press 'Execute logAction' without providing any input. The detail view will print out the message supplied to call.reject(): "Input option 'action' must be provided.".

Using logAction as a guide, guard logScreen such that it rejects any calls made that do not supply screen as input.

Test logScreen and once complete, head to the next section to complete the implementation of the analytics plugin.

Completing the implementation

For the purpose of this training, logging analytic events consists of POSTing data to an HTTP endpoint.

Modify Portals/AnalyticsPlugin.swift and use NetworkManager to complete the implementation:

Portals/AnalyticsPlugin.swift

_25
import Foundation
_25
import Capacitor
_25
_25
class AnalyticsPlugin: CAPInstancePlugin, CAPBridgedPlugin {
_25
private var http: NetworkManager = NetworkManager()
_25
_25
let jsName = "Analytics"
_25
let identifier = "Analytics"
_25
let pluginMethods: [CAPPluginMethod] = [
_25
.init(name: "logAction", returnType: CAPPluginReturnPromise),
_25
.init(name: "logScreen", returnType: CAPPluginReturnPromise)
_25
]
_25
_25
@objc func logAction(_ call: CAPPluginCall) {
_25
guard let action = call.getString("action") else {
_25
call.reject("Input option 'action' must be provided.")
_25
return
_25
}
_25
print("AnalyticsPlugin: logAction")
_25
}
_25
_25
@objc func logScreen(_ call: CAPPluginCall) {
_25
print("AnalyticsPlugin: logScreen")
_25
}
_25
}

Start by adding a private instance of NetworkManager to the plugin class.

Portals/AnalyticsPlugin.swift

_36
import Foundation
_36
import Capacitor
_36
_36
struct AnalyticsInput: Encodable {
_36
let action: String?
_36
let screen: String?
_36
let params: String?
_36
let platform: String
_36
}
_36
_36
struct AnalyticsOutput: Decodable {
_36
let success: Bool
_36
}
_36
_36
class AnalyticsPlugin: CAPInstancePlugin, CAPBridgedPlugin {
_36
private var http: NetworkManager = NetworkManager()
_36
_36
let jsName = "Analytics"
_36
let identifier = "Analytics"
_36
let pluginMethods: [CAPPluginMethod] = [
_36
.init(name: "logAction", returnType: CAPPluginReturnPromise),
_36
.init(name: "logScreen", returnType: CAPPluginReturnPromise)
_36
]
_36
_36
@objc func logAction(_ call: CAPPluginCall) {
_36
guard let action = call.getString("action") else {
_36
call.reject("Input option 'action' must be provided.")
_36
return
_36
}
_36
print("AnalyticsPlugin: logAction")
_36
}
_36
_36
@objc func logScreen(_ call: CAPPluginCall) {
_36
print("AnalyticsPlugin: logScreen")
_36
}
_36
}

Add structs to encode the body of the request and to decode the response.

Portals/AnalyticsPlugin.swift

_40
import Foundation
_40
import Capacitor
_40
_40
struct AnalyticsInput: Encodable {
_40
let action: String?
_40
let screen: String?
_40
let params: String?
_40
let platform: String
_40
}
_40
_40
struct AnalyticsOutput: Decodable {
_40
let success: Bool
_40
}
_40
_40
class AnalyticsPlugin: CAPInstancePlugin, CAPBridgedPlugin {
_40
private var http: NetworkManager = NetworkManager()
_40
_40
let jsName = "Analytics"
_40
let identifier = "Analytics"
_40
let pluginMethods: [CAPPluginMethod] = [
_40
.init(name: "logAction", returnType: CAPPluginReturnPromise),
_40
.init(name: "logScreen", returnType: CAPPluginReturnPromise)
_40
]
_40
_40
@objc func logAction(_ call: CAPPluginCall) {
_40
guard let action = call.getString("action") else {
_40
call.reject("Input option 'action' must be provided.")
_40
return
_40
}
_40
_40
var params: String? = nil
_40
if let paramsObject = call.getObject("params") {
_40
params = String(data: try! JSONSerialization.data(withJSONObject: paramsObject, options: []), encoding: .utf8)
_40
}
_40
}
_40
_40
@objc func logScreen(_ call: CAPPluginCall) {
_40
print("AnalyticsPlugin: logScreen")
_40
}
_40
}

Additional parameters are optional and untyped. They can be stringified and added to the request should they exist.

Portals/AnalyticsPlugin.swift

_50
import Foundation
_50
import Capacitor
_50
_50
struct AnalyticsInput: Encodable {
_50
let action: String?
_50
let screen: String?
_50
let params: String?
_50
let platform: String
_50
}
_50
_50
struct AnalyticsOutput: Decodable {
_50
let success: Bool
_50
}
_50
_50
class AnalyticsPlugin: CAPInstancePlugin, CAPBridgedPlugin {
_50
private var http: NetworkManager = NetworkManager()
_50
_50
let jsName = "Analytics"
_50
let identifier = "Analytics"
_50
let pluginMethods: [CAPPluginMethod] = [
_50
.init(name: "logAction", returnType: CAPPluginReturnPromise),
_50
.init(name: "logScreen", returnType: CAPPluginReturnPromise)
_50
]
_50
_50
@objc func logAction(_ call: CAPPluginCall) {
_50
guard let action = call.getString("action") else {
_50
call.reject("Input option 'action' must be provided.")
_50
return
_50
}
_50
_50
var params: String? = nil
_50
if let paramsObject = call.getObject("params") {
_50
params = String(data: try! JSONSerialization.data(withJSONObject: paramsObject, options: []), encoding: .utf8)
_50
}
_50
_50
let input = AnalyticsInput(action: action, screen: nil, params: params, platform: "ios")
_50
http.post("/analytics", input: input, output: AnalyticsOutput.self) { result in
_50
switch result {
_50
case .success(let res):
_50
res.success ? call.resolve() : call.reject("Logging the analytic event failed.")
_50
case .failure:
_50
call.reject("Failed to connect to the analytics endpoint.")
_50
}
_50
}
_50
}
_50
_50
@objc func logScreen(_ call: CAPPluginCall) {
_50
print("AnalyticsPlugin: logScreen")
_50
}
_50
}

Make the network request. If it succeeds, call.resolve() will resolve the call made from the web code, otherwise call.reject() will throw an error to be handled by the web code.

Portals/AnalyticsPlugin.swift

_72
import Foundation
_72
import Capacitor
_72
_72
struct AnalyticsInput: Encodable {
_72
let action: String?
_72
let screen: String?
_72
let params: String?
_72
let platform: String
_72
}
_72
_72
struct AnalyticsOutput: Decodable {
_72
let success: Bool
_72
}
_72
_72
class AnalyticsPlugin: CAPInstancePlugin, CAPBridgedPlugin {
_72
private var http: NetworkManager = NetworkManager()
_72
_72
let jsName = "Analytics"
_72
let identifier = "Analytics"
_72
let pluginMethods: [CAPPluginMethod] = [
_72
.init(name: "logAction", returnType: CAPPluginReturnPromise),
_72
.init(name: "logScreen", returnType: CAPPluginReturnPromise)
_72
]
_72
_72
@objc func logAction(_ call: CAPPluginCall) {
_72
guard let action = call.getString("action") else {
_72
call.reject("Input option 'action' must be provided.")
_72
return
_72
}
_72
_72
var params: String? = nil
_72
if let paramsObject = call.getObject("params") {
_72
params = String(data: try! JSONSerialization.data(withJSONObject: paramsObject, options: []), encoding: .utf8)
_72
}
_72
_72
let input = AnalyticsInput(action: action, screen: nil, params: params, platform: "ios")
_72
http.post("/analytics", input: input, output: AnalyticsOutput.self) { result in
_72
switch result {
_72
case .success(let res):
_72
res.success ? call.resolve() : call.reject("Logging the analytic event failed.")
_72
case .failure:
_72
call.reject("Failed to connect to the analytics endpoint.")
_72
}
_72
}
_72
}
_72
_72
@objc func logScreen(_ call: CAPPluginCall) {
_72
print("AnalyticsPlugin: logScreen")
_72
}
_72
_72
private func stringify(_ json: Capacitor.JSObject?) -> String? {
_72
guard let json = json else { return nil }
_72
_72
do {
_72
let data = try JSONSerialization.data(withJSONObject: json, options: [])
_72
return String(data: data, encoding: .utf8)
_72
} catch {
_72
return nil
_72
}
_72
}
_72
_72
private func logEvent(_ input: AnalyticsInput, completion: @escaping (Bool) -> Void) {
_72
http.post("/analytics", input: input, output: AnalyticsOutput.self) { result in
_72
switch result {
_72
case .success(let res):
_72
completion(res.success)
_72
case .failure:
_72
completion(false)
_72
}
_72
}
_72
}
_72
}

logScreen also needs to stringify params and make the same network request. Refactor the code to add utility methods.

Portals/AnalyticsPlugin.swift

_64
import Foundation
_64
import Capacitor
_64
_64
struct AnalyticsInput: Encodable {
_64
let action: String?
_64
let screen: String?
_64
let params: String?
_64
let platform: String
_64
}
_64
_64
struct AnalyticsOutput: Decodable {
_64
let success: Bool
_64
}
_64
_64
class AnalyticsPlugin: CAPInstancePlugin, CAPBridgedPlugin {
_64
private var http: NetworkManager = NetworkManager()
_64
_64
let jsName = "Analytics"
_64
let identifier = "Analytics"
_64
let pluginMethods: [CAPPluginMethod] = [
_64
.init(name: "logAction", returnType: CAPPluginReturnPromise),
_64
.init(name: "logScreen", returnType: CAPPluginReturnPromise)
_64
]
_64
_64
@objc func logAction(_ call: CAPPluginCall) {
_64
guard let action = call.getString("action") else {
_64
call.reject("Input option 'action' must be provided.")
_64
return
_64
}
_64
_64
let params: String? = self.stringify(call.getObject("params"))
_64
let input = AnalyticsInput(action: action, screen: nil, params: params, platform: "ios")
_64
_64
self.logEvent(input) { success in
_64
success ? call.resolve() : call.reject("Something went wrong.")
_64
}
_64
}
_64
_64
@objc func logScreen(_ call: CAPPluginCall) {
_64
print("AnalyticsPlugin: logScreen")
_64
}
_64
_64
private func stringify(_ json: Capacitor.JSObject?) -> String? {
_64
guard let json = json else { return nil }
_64
_64
do {
_64
let data = try JSONSerialization.data(withJSONObject: json, options: [])
_64
return String(data: data, encoding: .utf8)
_64
} catch {
_64
return nil
_64
}
_64
}
_64
_64
private func logEvent(_ input: AnalyticsInput, completion: @escaping (Bool) -> Void) {
_64
http.post("/analytics", input: input, output: AnalyticsOutput.self) { result in
_64
switch result {
_64
case .success(let res):
_64
completion(res.success)
_64
case .failure:
_64
completion(false)
_64
}
_64
}
_64
}
_64
}

Update logAction to use the new utility methods.

Portals/AnalyticsPlugin.swift

_74
import Foundation
_74
import Capacitor
_74
_74
struct AnalyticsInput: Encodable {
_74
let action: String?
_74
let screen: String?
_74
let params: String?
_74
let platform: String
_74
}
_74
_74
struct AnalyticsOutput: Decodable {
_74
let success: Bool
_74
}
_74
_74
class AnalyticsPlugin: CAPInstancePlugin, CAPBridgedPlugin {
_74
private var http: NetworkManager = NetworkManager()
_74
_74
let jsName = "Analytics"
_74
let identifier = "Analytics"
_74
let pluginMethods: [CAPPluginMethod] = [
_74
.init(name: "logAction", returnType: CAPPluginReturnPromise),
_74
.init(name: "logScreen", returnType: CAPPluginReturnPromise)
_74
]
_74
_74
@objc func logAction(_ call: CAPPluginCall) {
_74
guard let action = call.getString("action") else {
_74
call.reject("Input option 'action' must be provided.")
_74
return
_74
}
_74
_74
let params: String? = self.stringify(call.getObject("params"))
_74
let input = AnalyticsInput(action: action, screen: nil, params: params, platform: "ios")
_74
_74
self.logEvent(input) { success in
_74
success ? call.resolve() : call.reject("Something went wrong.")
_74
}
_74
}
_74
_74
@objc func logScreen(_ call: CAPPluginCall) {
_74
guard let screen = call.getString("screen") else {
_74
call.reject("Input option 'screen' must be provided.")
_74
return
_74
}
_74
_74
let params: String? = self.stringify(call.getObject("params"))
_74
let input = AnalyticsInput(action: nil, screen: screen, params: params, platform: "ios")
_74
_74
self.logEvent(input) { success in
_74
success ? call.resolve() : call.reject("Something went wrong.")
_74
}
_74
}
_74
_74
private func stringify(_ json: Capacitor.JSObject?) -> String? {
_74
guard let json = json else { return nil }
_74
_74
do {
_74
let data = try JSONSerialization.data(withJSONObject: json, options: [])
_74
return String(data: data, encoding: .utf8)
_74
} catch {
_74
return nil
_74
}
_74
}
_74
_74
private func logEvent(_ input: AnalyticsInput, completion: @escaping (Bool) -> Void) {
_74
http.post("/analytics", input: input, output: AnalyticsOutput.self) { result in
_74
switch result {
_74
case .success(let res):
_74
completion(res.success)
_74
case .failure:
_74
completion(false)
_74
}
_74
}
_74
}
_74
}

Finally, implement the logScreen plugin method.

Start by adding a private instance of NetworkManager to the plugin class.

Add structs to encode the body of the request and to decode the response.

Additional parameters are optional and untyped. They can be stringified and added to the request should they exist.

Make the network request. If it succeeds, call.resolve() will resolve the call made from the web code, otherwise call.reject() will throw an error to be handled by the web code.

logScreen also needs to stringify params and make the same network request. Refactor the code to add utility methods.

Update logAction to use the new utility methods.

Finally, implement the logScreen plugin method.

Portals/AnalyticsPlugin.swift

_25
import Foundation
_25
import Capacitor
_25
_25
class AnalyticsPlugin: CAPInstancePlugin, CAPBridgedPlugin {
_25
private var http: NetworkManager = NetworkManager()
_25
_25
let jsName = "Analytics"
_25
let identifier = "Analytics"
_25
let pluginMethods: [CAPPluginMethod] = [
_25
.init(name: "logAction", returnType: CAPPluginReturnPromise),
_25
.init(name: "logScreen", returnType: CAPPluginReturnPromise)
_25
]
_25
_25
@objc func logAction(_ call: CAPPluginCall) {
_25
guard let action = call.getString("action") else {
_25
call.reject("Input option 'action' must be provided.")
_25
return
_25
}
_25
print("AnalyticsPlugin: logAction")
_25
}
_25
_25
@objc func logScreen(_ call: CAPPluginCall) {
_25
print("AnalyticsPlugin: logScreen")
_25
}
_25
}

Build, run, and navigate to the 'Capacitor Plugins' tab. Test out the method calls by providing the following input arguments:

  • logAction: { "action": "Submit time", "params": { "time": "600" } }
  • logScreen: { "screen": "Edit Expense View", "params": {"expenseId": "123" } }

If you inspect network traffic (optional), you will see network requests made to an analytics endpoint with a data payload containing platform: 'ios', confirming all requirements have been met.

What's next

Authoring Capacitor plugins, like the analytics plugins, creates structured, contract-bound communication between native mobile and web code. So far, you have been testing Portals using the sample web app downloaded from the Portals CLI. In the final step of this module, you will sync the finished web apps to complete the Jobsync superapp.