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 Android Studio, create a new Kotlin file in the portals folder named AnalyticsPlugin.kt and populate the file with the following code:

portals/AnalyticsPlugin.kt

_21
package io.ionic.cs.portals.jobsync.portals
_21
_21
import com.getcapacitor.Plugin
_21
import com.getcapacitor.PluginCall
_21
import com.getcapacitor.PluginMethod
_21
import com.getcapacitor.annotation.CapacitorPlugin
_21
_21
@CapacitorPlugin(name="Analytics")
_21
class AnalyticsPlugin: Plugin() {
_21
@PluginMethod()
_21
fun logAction(call: PluginCall) {
_21
println("AnalyticsPlugin: logAction")
_21
call.resolve()
_21
}
_21
_21
@PluginMethod()
_21
fun logScreen(call: PluginCall) {
_21
println("AnalyticsPlugin: logScreen")
_21
call.resolve()
_21
}
_21
}

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 WebAppScreen, adding the AnalyticsPlugin to the Portal:

portals/WebAppScreen.kt

_52
package io.ionic.cs.portals.jobsync.portals
_52
_52
import androidx.compose.foundation.layout.Arrangement
_52
import androidx.compose.foundation.layout.Column
_52
import androidx.compose.foundation.layout.fillMaxSize
_52
import androidx.compose.foundation.layout.padding
_52
import androidx.compose.material3.Scaffold
_52
import androidx.compose.runtime.Composable
_52
import androidx.compose.ui.Alignment
_52
.addPlugin(AnalyticsPlugin::class.java)

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 in the CapacitorPlugin decorator.

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 Android Studio's logcat.

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
@PluginMethod() fun logAction(call: PluginCall)

PluginCall 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.kt

_10
@PluginMethod()
_10
fun logAction(call: PluginCall) {
_10
val action = call.getString("action");
_10
if(action == null) {
_10
call.reject("Input option 'action' must be provided.")
_10
return
_10
}
_10
println("AnalyticsPlugin: logAction")
_10
call.resolve()
_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.kt and use the NetworkManager to complete the implementation:

portals/AnalyticsPlugin.kt

_42
package io.ionic.cs.portals.jobsync.portals
_42
_42
import com.getcapacitor.Plugin
_42
import com.getcapacitor.PluginCall
_42
import com.getcapacitor.PluginMethod
_42
import com.getcapacitor.annotation.CapacitorPlugin
_42
import androidx.annotation.Keep
_42
_42
@Keep
_42
data class AnalyticsBody(
_42
val action: String?,
_42
val screen: String?,
_42
val params: String?,
_42
val platform: String
_42
)
_42
@Keep
_42
data class AnalyticsResult(val success: Boolean)
_42
_42
@CapacitorPlugin(name = "Analytics")
_42
class AnalyticsPlugin: Plugin() {
_42
@PluginMethod()
_42
fun logAction(call: PluginCall) {
_42
val action = call.getString("action");
_42
if(action == null) {
_42
call.reject("Input option 'action' must be provided.")
_42
return
_42
}
_42
println("AnalyticsPlugin: logAction")
_42
call.resolve()
_42
}
_42
_42
@PluginMethod()
_42
fun logScreen(call: PluginCall) {
_42
val screen = call.getString("screen");
_42
if(screen == null) {
_42
call.reject("Input option 'screen' must be provided.")
_42
return
_42
}
_42
println("AnalyticsPlugin: logEvent")
_42
call.resolve()
_42
}
_42
}

Start by adding the request and response data types for calls made to the analytics endpoint.

portals/AnalyticsPlugin.kt

_54
package io.ionic.cs.portals.jobsync.portals
_54
_54
import com.getcapacitor.Plugin
_54
import com.getcapacitor.PluginCall
_54
import com.getcapacitor.PluginMethod
_54
import com.getcapacitor.annotation.CapacitorPlugin
_54
import androidx.annotation.Keep
_54
import io.ionic.cs.portals.jobsync.util.NetworkManager
_54
import retrofit2.http.Body
_54
import retrofit2.http.POST
_54
_54
@Keep
_54
data class AnalyticsBody(
_54
val action: String?,
_54
val screen: String?,
_54
val params: String?,
_54
val platform: String
_54
)
_54
@Keep
_54
data class AnalyticsResult(val success: Boolean)
_54
_54
interface AnalyticsAPIService {
_54
@POST("analytics")
_54
suspend fun analytics(@Body body: AnalyticsBody): AnalyticsResult
_54
}
_54
_54
@CapacitorPlugin(name = "Analytics")
_54
class AnalyticsPlugin: Plugin() {
_54
private val http: AnalyticsAPIService by lazy {
_54
NetworkManager.instance.create(AnalyticsAPIService::class.java)
_54
}
_54
_54
@PluginMethod()
_54
fun logAction(call: PluginCall) {
_54
val action = call.getString("action");
_54
if(action == null) {
_54
call.reject("Input option 'action' must be provided.")
_54
return
_54
}
_54
println("AnalyticsPlugin: logAction")
_54
call.resolve()
_54
}
_54
_54
@PluginMethod()
_54
fun logScreen(call: PluginCall) {
_54
val screen = call.getString("screen");
_54
if(screen == null) {
_54
call.reject("Input option 'screen' must be provided.")
_54
return
_54
}
_54
println("AnalyticsPlugin: logEvent")
_54
call.resolve()
_54
}
_54
}

Add a private instance of NetworkManager to the plugin class.

portals/AnalyticsPlugin.kt

_54
package io.ionic.cs.portals.jobsync.portals
_54
_54
import com.getcapacitor.Plugin
_54
import com.getcapacitor.PluginCall
_54
import com.getcapacitor.PluginMethod
_54
import com.getcapacitor.annotation.CapacitorPlugin
_54
import androidx.annotation.Keep
_54
import io.ionic.cs.portals.jobsync.util.NetworkManager
_54
import retrofit2.http.Body
_54
import retrofit2.http.POST
_54
_54
@Keep
_54
data class AnalyticsBody(
_54
val action: String?,
_54
val screen: String?,
_54
val params: String?,
_54
val platform: String
_54
)
_54
@Keep
_54
data class AnalyticsResult(val success: Boolean)
_54
_54
interface AnalyticsAPIService {
_54
@POST("analytics")
_54
suspend fun analytics(@Body body: AnalyticsBody): AnalyticsResult
_54
}
_54
_54
@CapacitorPlugin(name = "Analytics")
_54
class AnalyticsPlugin: Plugin() {
_54
private val http: AnalyticsAPIService by lazy {
_54
NetworkManager.instance.create(AnalyticsAPIService::class.java)
_54
}
_54
_54
@PluginMethod()
_54
fun logAction(call: PluginCall) {
_54
val action = call.getString("action");
_54
if(action == null) {
_54
call.reject("Input option 'action' must be provided.")
_54
return
_54
}
_54
val params = call.getObject("params")?.toString() ?: ""
_54
call.resolve()
_54
}
_54
_54
@PluginMethod()
_54
fun logScreen(call: PluginCall) {
_54
val screen = call.getString("screen");
_54
if(screen == null) {
_54
call.reject("Input option 'screen' must be provided.")
_54
return
_54
}
_54
println("AnalyticsPlugin: logEvent")
_54
call.resolve()
_54
}
_54
}

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

portals/AnalyticsPlugin.kt

_70
package io.ionic.cs.portals.jobsync.portals
_70
_70
import com.getcapacitor.Plugin
_70
import com.getcapacitor.PluginCall
_70
import com.getcapacitor.PluginMethod
_70
import com.getcapacitor.annotation.CapacitorPlugin
_70
import androidx.annotation.Keep
_70
import io.ionic.cs.portals.jobsync.util.NetworkManager
_70
import retrofit2.http.Body
_70
import retrofit2.http.POST
_70
import kotlinx.coroutines.CoroutineScope
_70
import kotlinx.coroutines.Dispatchers
_70
import kotlinx.coroutines.launch
_70
import kotlinx.coroutines.withContext
_70
_70
@Keep
_70
data class AnalyticsBody(
_70
val action: String?,
_70
val screen: String?,
_70
val params: String?,
_70
val platform: String
_70
)
_70
@Keep
_70
data class AnalyticsResult(val success: Boolean)
_70
_70
interface AnalyticsAPIService {
_70
@POST("analytics")
_70
suspend fun analytics(@Body body: AnalyticsBody): AnalyticsResult
_70
}
_70
_70
@CapacitorPlugin(name = "Analytics")
_70
class AnalyticsPlugin: Plugin() {
_70
private val http: AnalyticsAPIService by lazy {
_70
NetworkManager.instance.create(AnalyticsAPIService::class.java)
_70
}
_70
_70
@PluginMethod()
_70
fun logAction(call: PluginCall) {
_70
val action = call.getString("action");
_70
if(action == null) {
_70
call.reject("Input option 'action' must be provided.")
_70
return
_70
}
_70
val params = call.getObject("params")?.toString() ?: ""
_70
val body = AnalyticsBody(action, null, params, "android")
_70
CoroutineScope(Dispatchers.IO).launch {
_70
val result = runCatching { http.analytics(body) }
_70
withContext(Dispatchers.Main) {
_70
result.onSuccess {
_70
if(it.success) {
_70
call.resolve()
_70
} else {
_70
call.reject("Logging the analytic event failed.")
_70
}}
_70
.onFailure { call.reject("Failed to connect to the analytics endpoint.") }
_70
}
_70
}
_70
}
_70
_70
@PluginMethod()
_70
fun logScreen(call: PluginCall) {
_70
val screen = call.getString("screen");
_70
if(screen == null) {
_70
call.reject("Input option 'screen' must be provided.")
_70
return
_70
}
_70
println("AnalyticsPlugin: logEvent")
_70
call.resolve()
_70
}
_70
}

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

portals/AnalyticsPlugin.kt

_71
package io.ionic.cs.portals.jobsync.portals
_71
_71
import com.getcapacitor.Plugin
_71
import com.getcapacitor.PluginCall
_71
import com.getcapacitor.PluginMethod
_71
import com.getcapacitor.annotation.CapacitorPlugin
_71
import androidx.annotation.Keep
_71
import io.ionic.cs.portals.jobsync.util.NetworkManager
_71
import retrofit2.http.Body
_71
import retrofit2.http.POST
_71
import kotlinx.coroutines.CoroutineScope
_71
import kotlinx.coroutines.Dispatchers
_71
import kotlinx.coroutines.launch
_71
import kotlinx.coroutines.withContext
_71
_71
@Keep
_71
data class AnalyticsBody(
_71
val action: String?,
_71
val screen: String?,
_71
val params: String?,
_71
val platform: String
_71
)
_71
@Keep
_71
data class AnalyticsResult(val success: Boolean)
_71
_71
interface AnalyticsAPIService {
_71
@POST("analytics")
_71
suspend fun analytics(@Body body: AnalyticsBody): AnalyticsResult
_71
}
_71
_71
@CapacitorPlugin(name = "Analytics")
_71
class AnalyticsPlugin: Plugin() {
_71
private val http: AnalyticsAPIService by lazy {
_71
NetworkManager.instance.create(AnalyticsAPIService::class.java)
_71
}
_71
_71
@PluginMethod()
_71
fun logAction(call: PluginCall) {
_71
val action = call.getString("action");
_71
if(action == null) {
_71
call.reject("Input option 'action' must be provided.")
_71
return
_71
}
_71
val params = call.getObject("params")?.toString() ?: ""
_71
val body = AnalyticsBody(action, null, params, "android")
_71
logEvent(body) { success ->
_71
if(success) { call.resolve() } else { call.reject("Something went wrong.") }
_71
}
_71
}
_71
_71
@PluginMethod()
_71
fun logScreen(call: PluginCall) {
_71
val screen = call.getString("screen");
_71
if(screen == null) {
_71
call.reject("Input option 'screen' must be provided.")
_71
return
_71
}
_71
println("AnalyticsPlugin: logEvent")
_71
call.resolve()
_71
}
_71
_71
private fun logEvent(body: AnalyticsBody, completion: (Boolean) -> Unit) {
_71
CoroutineScope(Dispatchers.IO).launch {
_71
withContext(Dispatchers.Main) {
_71
val result = runCatching { http.analytics(body) }
_71
result.onSuccess { completion(it.success) }
_71
.onFailure { completion(false) }
_71
}
_71
}
_71
}
_71
}

logScreen needs to make the same network request. Refactor the code to add a utility method.

portals/AnalyticsPlugin.kt

_74
package io.ionic.cs.portals.jobsync.portals
_74
_74
import com.getcapacitor.Plugin
_74
import com.getcapacitor.PluginCall
_74
import com.getcapacitor.PluginMethod
_74
import com.getcapacitor.annotation.CapacitorPlugin
_74
import androidx.annotation.Keep
_74
import io.ionic.cs.portals.jobsync.util.NetworkManager
_74
import retrofit2.http.Body
_74
import retrofit2.http.POST
_74
import kotlinx.coroutines.CoroutineScope
_74
import kotlinx.coroutines.Dispatchers
_74
import kotlinx.coroutines.launch
_74
import kotlinx.coroutines.withContext
_74
_74
@Keep
_74
data class AnalyticsBody(
_74
val action: String?,
_74
val screen: String?,
_74
val params: String?,
_74
val platform: String
_74
)
_74
@Keep
_74
data class AnalyticsResult(val success: Boolean)
_74
_74
interface AnalyticsAPIService {
_74
@POST("analytics")
_74
suspend fun analytics(@Body body: AnalyticsBody): AnalyticsResult
_74
}
_74
_74
@CapacitorPlugin(name = "Analytics")
_74
class AnalyticsPlugin: Plugin() {
_74
private val http: AnalyticsAPIService by lazy {
_74
NetworkManager.instance.create(AnalyticsAPIService::class.java)
_74
}
_74
_74
@PluginMethod()
_74
fun logAction(call: PluginCall) {
_74
val action = call.getString("action");
_74
if(action == null) {
_74
call.reject("Input option 'action' must be provided.")
_74
return
_74
}
_74
val params = call.getObject("params")?.toString() ?: ""
_74
val body = AnalyticsBody(action, null, params, "android")
_74
logEvent(body) { success ->
_74
if(success) { call.resolve() } else { call.reject("Something went wrong.") }
_74
}
_74
}
_74
_74
@PluginMethod()
_74
fun logScreen(call: PluginCall) {
_74
val screen = call.getString("screen");
_74
if(screen == null) {
_74
call.reject("Input option 'screen' must be provided.")
_74
return
_74
}
_74
val params = call.getObject("params")?.toString() ?: ""
_74
val body = AnalyticsBody(null, screen, params, "android")
_74
logEvent(body) { success ->
_74
if(success) { call.resolve() } else { call.reject("Something went wrong.") }
_74
}
_74
}
_74
_74
private fun logEvent(body: AnalyticsBody, completion: (Boolean) -> Unit) {
_74
CoroutineScope(Dispatchers.IO).launch {
_74
withContext(Dispatchers.Main) {
_74
val result = runCatching { http.analytics(body) }
_74
result.onSuccess { completion(it.success) }
_74
.onFailure { completion(false) }
_74
}
_74
}
_74
}
_74
}

Finally, implement the logScreen plugin method.

Start by adding the request and response data types for calls made to the analytics endpoint.

Add a private instance of NetworkManager to the plugin class.

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 thrown an error to be handled by the web code.

logScreen needs to make the same network request. Refactor the code to add a utility method.

Finally, implement the logScreen plugin method.

portals/AnalyticsPlugin.kt

_42
package io.ionic.cs.portals.jobsync.portals
_42
_42
import com.getcapacitor.Plugin
_42
import com.getcapacitor.PluginCall
_42
import com.getcapacitor.PluginMethod
_42
import com.getcapacitor.annotation.CapacitorPlugin
_42
import androidx.annotation.Keep
_42
_42
@Keep
_42
data class AnalyticsBody(
_42
val action: String?,
_42
val screen: String?,
_42
val params: String?,
_42
val platform: String
_42
)
_42
@Keep
_42
data class AnalyticsResult(val success: Boolean)
_42
_42
@CapacitorPlugin(name = "Analytics")
_42
class AnalyticsPlugin: Plugin() {
_42
@PluginMethod()
_42
fun logAction(call: PluginCall) {
_42
val action = call.getString("action");
_42
if(action == null) {
_42
call.reject("Input option 'action' must be provided.")
_42
return
_42
}
_42
println("AnalyticsPlugin: logAction")
_42
call.resolve()
_42
}
_42
_42
@PluginMethod()
_42
fun logScreen(call: PluginCall) {
_42
val screen = call.getString("screen");
_42
if(screen == null) {
_42
call.reject("Input option 'screen' must be provided.")
_42
return
_42
}
_42
println("AnalyticsPlugin: logEvent")
_42
call.resolve()
_42
}
_42
}

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: 'android', 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.