Build a Cordova plugin for iOS

April 18, 2022
Table of contents

Main post

This post forms part of this tutorial:

You might want to check that post first before continuing with this one. You will learn how to:

You could find the source code of this tutorial on GitHub:
https://github.com/adrian-bueno/multiplatform-cordova-plugin-example.

Start

We are going to implement in Swift the 4 methods defined in our JavaScript interface www/CordovaPluginExample.js:

Create a new directory ios inside src.

Then create the files CordovaPluginExample.swift and CallbackHelper.swift inside ios.

πŸ“ cordova-plugin-example
└── πŸ“ src
    └── πŸ“ ios
         β”œβ”€β”€ πŸ“„ CordovaPluginExample.swift
         └── πŸ“„ CallbackHelper.swift

CallbackHelper.swift is a helper class to keep our code in CordovaPluginExample.swift cleaner.

External dependencies

We are going to use Alomofire to obtain Bitcoin’s current price from https://api.coindesk.com/v1/bpi/currentprice.json . We could do this in JavaScript, but it’s a simple and easy example on how to use external dependencies.

To add an external dependency with the latests Cordova versions, we use podspec:

<platform name="ios">

  ...

  <podspec>
    <config>
      <!-- CocoaPods repositories -->
      <source url="https://github.com/CocoaPods/Specs.git"/>
    </config>
    <pods use-frameworks="true">
      <pod name="Alamofire" spec="~> 5.5" />

      <!-- Or install using a Git repository -->
      <!-- <pod name="Alamofire" options=":git => 'https://github.com/Alamofire/Alamofire.git', :tag => '5.5.0'" /> -->
      <!-- <pod name="Alamofire" git="https://github.com/Alamofire/Alamofire.git" tag="5.5.0" /> -->
    </pods>
  </podspec>

  ...

</platform>

You must have CocoaPods installed:

sudo gem install cocoapods

If you have an error downloading a pod version, try to update your local pods data:

pod repo update

Native dependencies

We could also use iOS native dependencies. We use the <framework> tag for that:

<platform name="ios">

  ...

  <framework src="Speech.framework"/>

  ...

</platform>

Use another Cordova plugin as a dependency

To add Swift support to our app, we need the plugin cordova-plugin-add-swift-support. Just add it as a dependency in plugin.xml:

<platform name="ios">

  ...

  <dependency id="cordova-plugin-add-swift-support" version="2.0.2"/>

  ...

</platform>

If you don’t want to add this dependency in plugin.xml, you could also add it in your Cordova app’s config.xml.

Edit Info.plist

We can use <config-file> tag to add new configurations and <edit-config> tag to edit existing configurations in Info.plist.

Declare permissions

Fot this tutorial we need to set to true the properties UIFileSharingEnabled and LSSupportsOpeningDocumentsInPlace to be able to view our app Documents directory on the Files app.

<platform name="ios">
  
  ...

  <config-file target="*-Info.plist" parent="UIFileSharingEnabled">
    <true/>
  </config-file>

  <config-file target="*-Info.plist" parent="LSSupportsOpeningDocumentsInPlace">
    <true/>
  </config-file>
  
  ...

</platform>

We don’t need any special permission for this plugin. If you have to add a usage description for a special permission, you could do it like this:

<platform name="ios">
  
  ...

  <config-file target="*-Info.plist" parent="NSMicrophoneUsageDescription">
    <string>Explaing here why you need to use the microphone.</string>
  </config-file>
  
  ...

</platform>

Final plugin.xml configuration

For this plugin example, this is our final plugin.xml configuration for iOS:

<!-- ios -->
<platform name="ios">
  <config-file parent="/*" target="config.xml">
    <feature name="CordovaPluginExample">
      <param name="ios-package" value="CordovaPluginExample"/>
    </feature>
  </config-file>

  <config-file target="*-Info.plist" parent="UIFileSharingEnabled">
    <true/>
  </config-file>

  <config-file target="*-Info.plist" parent="LSSupportsOpeningDocumentsInPlace">
    <true/>
  </config-file>

  <source-file src="src/ios/CordovaPluginExample.swift"/>
  <source-file src="src/ios/CallbackHelper.swift"/>

  <podspec>
    <config>
      <source url="https://github.com/CocoaPods/Specs.git"/>
    </config>
    <pods use-frameworks="true">
      <pod name="Alamofire" spec="~> 5.5" />
    </pods>
  </podspec>

  <dependency id="cordova-plugin-add-swift-support" version="2.0.2"/>
</platform>

The first <config-file> is used to edit a cordova-ios related file used to register our plugin main class in Cordova.

<source-file> is used to copy our source files to our cordova app. We could copy directories too:

<source-file src="src/ios/<directory-name>" target="src/"/> />

iOS source code structure

πŸ“ cordova-plugin-example
β”œβ”€β”€ πŸ“ src
β”‚   β”œβ”€β”€ πŸ“ android
β”‚   β”œβ”€β”€ πŸ“ ios
β”‚   β”‚   β”œβ”€β”€ πŸ“„ CordovaPluginExample.swift
β”‚   β”‚   └── πŸ“„ CallbackHelper.swift
β”‚   └── πŸ“ electron
β”œβ”€β”€ πŸ“ www
β”‚   └── πŸ“„ CordovaPluginExample.js
β”œβ”€β”€ πŸ“„ .gitignore
β”œβ”€β”€ πŸ“„ package.json
β”œβ”€β”€ πŸ“„ plugin.xml
└── πŸ“„ README.md

Swift source code

CordovaPluginExample.swift (Main plugin class)

Open on GitHub

import Foundation
import Alamofire


@objc(CordovaPluginExample) class CordovaPluginExample : CDVPlugin {

  var callbackHelper: CallbackHelper?

  func log(_ message: String) {
    NSLog("[CordovaPluginExample] %@", message)
  }

  // This method will be called on Cordova initialization.
  // Use it to initialize everything you may need later.
  // In this example, we will initialize our CallbackHelper class.
  override func pluginInitialize() {
    log("Initializing Cordova plugin example");
    super.pluginInitialize()
    callbackHelper = CallbackHelper(self.commandDelegate!)
  }

  // This is our simplest example.
  // Returns the string "Hello {name}!"
  // or "Hello!" if a name is not received.
  @objc(greeting:) func greeting(command: CDVInvokedUrlCommand) {
    let name = command.arguments[0] as? String ?? ""
    let greeting = name.isEmpty ? "Hello!" : "Hello \(name)"
    callbackHelper!.sendString(command, greeting)
  }

  // Returns a number every second, from "seconds" parameter value to 0.
  //
  // With this example we will see how can we return
  // multiple values over time, like an Observable.
  // 
  // To return multiple values and keep the "connection"
  // open, just set the parameter "keepCallback" of
  // CallbackHelper methods to true
  @objc(countdownTimer:) func countdownTimer(command: CDVInvokedUrlCommand) {
    let seconds = command.arguments[0] as? Int ?? 10
    var secondsLeft = seconds > 0 ? seconds : 10
    let timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { (timer) in
      let keepCallback = secondsLeft > 0
      self.callbackHelper!.sendNumber(command, secondsLeft, keepCallback)
      if (keepCallback) {
        secondsLeft -= 1
      } else {
        timer.invalidate()
      }
    }
    timer.fire()
  }

  // With this method we can create and write
  // a file in our app's Documents directory.
  @objc(writeFile:) func writeFile(command: CDVInvokedUrlCommand) {
    let fileName = command.arguments[0] as? String;
    let text = command.arguments[1] as? String;

    if (fileName == nil || text == nil) {
      callbackHelper!.sendError(command, "BAD_ARGS")
      return
    }

    do {
      let documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
      let fileUrl = documentsDirectory.appendingPathComponent(fileName!)
      log("Writing file at path: " + fileUrl.absoluteString)
      try text!.write(to: fileUrl, atomically: false, encoding: .utf8)
      callbackHelper!.sendEmpty(command)
    } catch {
      print(error)
      callbackHelper!.sendError(command, "COULD_NOT_CREATE_FILE")
    }
  }

  // This example is just to show how can we use
  // the external dependencies we declared previously,
  // in this case, Alomofire
  @objc(bitcoinCurrentPrice:) func bitcoinCurrentPrice(command: CDVInvokedUrlCommand) {
    let url = "https://api.coindesk.com/v1/bpi/currentprice.json"
    AF.request(url).responseString { response in
      switch response.result {
      case .success:
        let json = (try? JSONSerialization.jsonObject(with: response.data!, options: [])) as? [String:AnyObject]
        self.callbackHelper!.sendJson(command, json)
      case let .failure(error):
        print(error)
        self.callbackHelper!.sendError(command, "REQUEST_ERROR")
      }
    }
  }

}

In iOS we don’t need a method execute like in Android. We just have to use the same method names as we defined in www/CordovaPluginExample.js.

CallbackHelper.swift

Open on GitHub

import Foundation


class CallbackHelper {

  let commandDelegate: CDVCommandDelegate

  // We need the CDVCommandDelegate from the main class
  init(_ commandDelegate: CDVCommandDelegate) {
    self.commandDelegate = commandDelegate
  }

  // This is the main method of this helper class.
  // It receives as parameters the received command,
  // the pluginResult we want to return, and the
  // keepCallback option.
  // By setting setKeepCallbackAs(true) we can 
  // return multiple values over time for the same command.
  // Use setKeepCallbackAs(false) if you don't
  // want to return more values.
  private func send(_ command: CDVInvokedUrlCommand, _ pluginResult: CDVPluginResult, _ keepCallback: Bool = false) {
    pluginResult.setKeepCallbackAs(keepCallback)
    self.commandDelegate.send(
      pluginResult,
      callbackId: command.callbackId
    )
  }

  // Returns an error result with a message
  func sendError(_ command: CDVInvokedUrlCommand, _ message: String) {
    let pluginResult = CDVPluginResult(
      status: CDVCommandStatus_ERROR,
      messageAs: message
    )
    self.send(command, pluginResult!)
  }

  // Returns an OK response with no value
  func sendEmpty(_ command: CDVInvokedUrlCommand, _ keepCallback: Bool = false) {
    let pluginResult = CDVPluginResult(status: CDVCommandStatus_OK)
    self.send(command, pluginResult!, keepCallback)
  }

  // Returns an OK response with a string value
  func sendString(_ command: CDVInvokedUrlCommand, _ string: String, _ keepCallback: Bool = false) {
    let pluginResult = CDVPluginResult(
      status: CDVCommandStatus_OK,
      messageAs: string
    )
    self.send(command, pluginResult!, keepCallback)
  }

  // Returns an OK response with a number value
  func sendNumber(_ command: CDVInvokedUrlCommand, _ number: Int, _ keepCallback: Bool = false) {
    let pluginResult = CDVPluginResult(
      status: CDVCommandStatus_OK,
      messageAs: number
    )
    self.send(command, pluginResult!, keepCallback)
  }

  // Returns an OK response with a JSON object
  func sendJson(_ command: CDVInvokedUrlCommand, _ json: [String:AnyObject]?, _ keepCallback: Bool = false) {
    let pluginResult = CDVPluginResult(
      status: CDVCommandStatus_OK,
      messageAs: json
    )
    self.send(command, pluginResult!, keepCallback)
  }

}

Threading

For heavy tasks or blocking calls, you should create a new thread with commandDelegate.run.

@objc(heavyTask:) func heavyTask(command: CDVInvokedUrlCommand) {
  self.commandDelegate.run {
    // your code
  }
}

Continue reading the main post

Useful references