In-Depth Guide to work with platform channels by integrating 3rd Party SDK: IOS

In-Depth Guide to work with platform channels by integrating 3rd Party SDK: IOS

In this article we will learn how method channels and event channels work. Also, how we will integrate 3rd party SDK in our Flutter app and use them to invoke native methods using method channels and stream data using event channels. In addition to that, we will structure the code and explore the prerequisites to start writing native code.

Platform Channels

Platform channels are used to communicate with the native side using event streams or method calls. This communication is set up by calling platform-specific APIs from Dart code, and it provides a way to share data among platform and dart sides. These APIs can be called in the language supported by the specific platform, e.g., Java or Kotlin for Android, Swift or Objective-C for IOS.

MethodChannel

They are used to invoke methods with or without arguments at the platform side. And on the platform side, we receive the method calls and send back a result. The method call is asynchronous. On Android, we use MethodChannel and on IOS we use FlutterMethodChannel for handling method calls.

EventChannel

They are used to stream data from platform to dart side. These stream requests are encoded into binary at the platform side and then we can receive these stream requests by subscribing to them. The stream data is then decoded into Dart.

Flutter.png

The above flow diagram explains the way our Dart code communicates with native code and then we use the Dart SDK as a plugin inside our Flutter app. From the Dart SDK, through method calls, we invoke platform-specific APIs and through the event channel, we get the binary encoded streamed data. Our IOS side uses FlutterMethodChannel to implement MethodChannel and FlutterEventChannel and FlutterStreamHandler to implement EventChannel.

Getting Started

Let's create our Flutter App. We will be using CometChatSDK v3 to demonstrate the working of Platform Channels.

flutter create --org com.example --template=plugin --platforms=android,ios -a java flutter_comet_chat_sdk

Here, we have created a flutter plugin with Java support for Android and Swift for IOS.

Project Structure

.
└── flutter_comet_chat_sdk/
    ├── android/
    │   ├── gradle
    │   ├── src/
    │   │   └── main/java/com/example/flutter_comet_chat_sdk/
    │   │       ├── FlutterCometChatSdkPlugin.java
    │   │       ├── EventChannelHelper.java
    │   │       ├── MethodChannelHandler.java
    │   │       └── Helper/
    │   └── build.gradle
    ├── example/
    │   ├── android
    │   ├── ios
    │   ├── lib/
    │   │   └── main.dart
    │   ├── test
    │   └── pubspec.yaml
    ├── ios/
    │   ├── Assets
    │   ├── Classes/
    │   │   ├── SwiftFlutterCometChatSdkPlugin.swift
    │   │   ├── FlutterCometChatSdkPlugin.m
    │   │   ├── FlutterCometChatSdkPlugin.h
    │   │   ├── EventChannelHandler.swift
    │   │   ├── Helpers/
    │   │   └── Listeners/
    │   └── flutter_comet_chat_sdk.podspec
    ├── lib/
    │   ├── src/
    │   │   ├── listener/
    │   │   ├── model/
    │   │   ├── comet_chat_dart.dart
    │   │   ├── comet_chat_constants.dart
    │   │   ├── comet_constants_keys.dart
    │   │   └── error_details.dart
    │   └── flutter_comet_chat_sdk.dart
    ├── test
    └── pubspec.yaml

We will use the android and ios folders in the root directory to write our Android and IOS native code respectively. Under the lib folder in the root directory, we will write Dart side implementation of method channels and event channels. In that file, we will subscribe to streams, add listeners, serialize data and add calls to invoke the method on the native side. Then under the example folder, we will test the Dart plugin methods.

Integrating 3rd party SDK with IOS

We are using CometChatSdk, so we will add that to our IOS file. So as per the docs we will add the SDK as a dependency and set the platform version to 11.0.

Under the file ios/flutter_comet_chat_sdk.podspec add:

. . .
s.dependency 'CometChatPro', '3.0.900'
s.platform = :ios, '11.0'
. . .

Or you can add your .framework project file in the ios folder and under the podspec file add the path to the framework file.

. . .
s.platform = :ios, '11.0'
s.preserve_paths = 'ProjectName.framework'
s.xcconfig = { 'OTHER_LDFLAGS' => '-framework ProjectName' }
s.vendored_frameworks = 'ProjectName.framework'
. .

Also, under the example/ios/Podfile add the platform version as 11.0 as the SDK supports versions equal or above that. To support running simulator with Comet Chat we need to exclude arm64 i386.

. . .
platform :ios, '11.0'
. . .
. . .

post_install do |installer|
  installer.pods_project.targets.each do |target|
    flutter_additional_ios_build_settings(target)
    target.build_configurations.each do |build_configuration|
      build_configuration.build_settings['EXCLUDED_ARCHS[sdk=iphonesimulator*]'] = 'arm64 i386'
    end
  end
end

Now, go to example/ios run pod install and then open the example/ios folder in Xcode and run the app once. We will use Xcode to run and write swift code. Sometimes the print statements mentioned in the swift code don't show in VS code terminal. So it's better to use Xcode to run and debug while working with IOS native side.

So, we have opened the ios folder under the example folder not the ios in the root directory. But the code for SDK needs to be written in the root ios/Classes folder only. We can access the folder under the Pods folder in Xcode. On the left side, in Xcode, we have Runner and Pods folder. If we go inside

Pods -> Development Pods -> flutter_comet_chat_sdk -> .. -> .. -> example -> iOS -> .symlinks -> plugins -> flutter_comet_chat_sdk -> iOS -> Classes ->

There we can find the Classes folder with the SwiftFlutterCometChatSdk.swift file. In this folder, we will implement our platform channel. Or for easy access, you can drag and drop the ios/Classes folder in the Xcode project and add it as a reference.

Now the setup is ready, we will use some of the Comet Chat SDK methods to see how we can implement our Platform channel.

Creating MethodChannel

We will define our FlutterMethodChannel inside SwiftFlutterCometChatSdk.swift file. To interact with the host platform and set up Platform channels we need to implement the interface FlutterPlugin. It provides a method to set up and register the plugin. Under the register method, we will set up the FlutterMethodChannel and FlutterEventChannels.

The register method provides FlutterPluginRegistrar which provides a method to add MethodChannel and set up communication between Dart and IOS. Also, the FlutterPluginRegistrar method messenger(), returns a FlutterBinaryMessenger which helps in asynchronous message passing between Dart and IOS using binary messages. These binary messages are used by the event channels to stream data.

The addMethodCallDelegate registers the MethodChannel to receive method calls from the Dart side.

Lastly, we have the handle method that is invoked when MethodChannel is defined and the native side is ready to receive method calls.

import Flutter
import UIKit
import CometChatPro

public class SwiftFlutterCometChatSdkPlugin: NSObject, FlutterPlugin {
    public override init() {
        super.init()
    }

  public static func register(with registrar: FlutterPluginRegistrar) {
      let instance = SwiftFlutterCometChatSdkPlugin()
      instance.setupChannel(registrar: registrar)
  }

  private func setupChannel(registrar: FlutterPluginRegistrar){
      let channel = FlutterMethodChannel(name: "plugins.flutter.io/comet_chat_dart", binaryMessenger: registrar.messenger())
      registrar.addMethodCallDelegate(self, channel: channel)
    }

  public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
      let args = call.arguments as? [String: Any]

      switch call.method {
      case "initialize":
          result("success")
      default:
          result(FlutterMethodNotImplemented)
      }
  }
}

Here we have created our MethodChannel with the name plugins.flutter.io/comet_chat_dart. The same name we will be using on the Dart side.

On the Dart side, we have the src folder and flutter_comet_chat_sdk.dart file. Under the flutter_comet_chat_sdk.dart we will define the exports for the plugin and In the file src/comet_chat_dart.dart we will set up our MethodChannel and EventChannel.

First, we will define the MethodChannel and then use the invoke method to call the native methods.

import 'dart:async';
import 'dart:convert';

/// Comet Chat utility package.
///
/// This is a preliminary API.
import 'package:flutter/services.dart';
import 'package:flutter_comet_chat_sdk/flutter_comet_chat_sdk.dart';

/// Entry point for the CometChat Dart.
class FlutterCometChatDart {
  static const MethodChannel channel =
      MethodChannel('plugins.flutter.io/comet_chat_dart');

  init() async {
    try {
      dynamic res = await FlutterCometChatDart.channel.invokeMethod('initialize');
      return res;
    } on PlatformException catch (e) {
      throw FlutterCometChatDart.convertException(e);
    }
  }

  static Exception convertException(PlatformException err) {
    return ErrorDetails(
        errorCode: err.code, errorDescription: err.message ?? "");
  }
}

This is how we are invoking the method init defined with the key initialize on the IOS side.

How to pass arguments from MethodChannels?

Let's see how we can pass function arguments from the Dart side to the native side. Also, we will send back the result from the native to the Dart side using MethodChannel.

So, on the native side, we will define a helper method Helpers/LoginLogoutHelper.swift to handle the authentication for Comet Chat. The initialize method has two arguments appId and appSettings. The appId is a String so we can directly share it through MethodChannel, but appSettings is an object of class AppSettings so for that we need to create it in the form of key-value pair and then we will map those key values into AppSettings class at Dart side.

The initialize method provides two callbacks based on success or error and we are returning the status based on the callback. Later, we will use this to pass as a FlutterResult.

import Foundation
import CometChatPro

public class LoginLogoutHelper {
    public init() {

    }

    public func initialize(appId: String?, appSettings:  Dictionary<String, Any>?) -> Any {
        let mySettings: AppSettings = Helper.getAppSettings(value: appSettings)
        var result: Any = "success";

        if let tAppId = appId {
            CometChat.init(appId: tAppId ,appSettings: mySettings, onSuccess: { (isSuccess) in
                      if (isSuccess) {
                          print("CometChat Pro SDK intialise successfully.")
                          result = true
                      }
                  }) { (error) in
                          print("CometChat Pro SDK failed intialise with error: \(error.errorDescription)")
                      result = error.errorDescription
                  }
        }
        return result
    }
}

Helpers/Helper.swift
Adding the method to get key-value data for AppSettings object.

import CometChatPro
import Foundation

public enum Helper {
    static func getAppSettings(value: [String: Any]?) -> AppSettings {
        if value != nil {
            let region = value?["region"] as? String
            let subscriptionType = value?["subscriptionType"] as? String
            let roles = value?["roles"] as? [String]

            if let tRegion = region,
               let tSubscriptionType = subscriptionType
            {
                var appSettings = AppSettings.AppSettingsBuilder().setRegion(region: tRegion).build()

                switch tSubscriptionType {
                case "ALL_USERS":
                    appSettings = AppSettings.AppSettingsBuilder().subscribePresenceForAllUsers().setRegion(region: tRegion).build()
                case "FRIENDS":
                    appSettings = AppSettings.AppSettingsBuilder().subscribePresenceForFriends().setRegion(region: tRegion).build()
                case "ROLES":
                    appSettings = AppSettings.AppSettingsBuilder().subcribePresenceForRoles(roles: roles ?? []).setRegion(region: tRegion).build()
                case "NONE":
                    appSettings = AppSettings.AppSettingsBuilder().setRegion(region: tRegion).build()
                default:
                    return appSettings
                }
                return appSettings
            }
        }
        return AppSettings.AppSettingsBuilder().subscribePresenceForAllUsers().setRegion(region: "US").build()
    }
}

Now, we will call the initialize method in our handle method. Also, we are mapping the arguments coming from the Dart side with their respective key values.

. . .
private var loginLogoutHelper: LoginLogoutHelper?

 public override init() {
        super.init()
        loginLogoutHelper = LoginLogoutHelper()
    }

. . .

public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
      let args = call.arguments as? [String: Any]

      switch call.method {
      case "initialize":
          let appId = args?["appId"] as? String
          let appSettings = args?["appSettings"] as? Dictionary<String, Any>

          let res = loginLogoutHelper?.initialize(appId: appId, appSettings: appSettings)
          result(res)
. . .
. . .

On the Dart side, we will define the initialize method, pass the required arguments, define the model classes and use the result passed by the method channel.

init(String appId, AppSettings appSettings,
      CallbackListener callbackListener) async {
    assert(appId != '');
    assert(appSettings.region != '');

    try {
      dynamic res = await FlutterCometChatDart.channel.invokeMethod(
          'initialize', {'appId': appId, 'appSettings': appSettings.toJson()});

      if (res == true) {
        callbackListener.onSuccess(true);
      } else {
        callbackListener.onError(res);
      }
    } on PlatformException catch (e) {
      throw FlutterCometChatDart.convertException(e);
    }
  }

For AppSettings class we need to define an AppSettingsBuilder class that will set the values that are under AppSettings class. Basically, the builder class will have set methods and a build method that will return the object of AppSettings class after setting the field values. Check the AppSettings code here.

Then we have a CallbackListener class that has methods onSuccess and onError and based on the response from invokeMethod we are calling the onSuccess or onError method.

class CallbackListener {
  Function onSuccess;
  Function onError;
  CallbackListener({required this.onSuccess, required this.onError});
}

So, now the MethodChannel is ready, we can use this method under our example flutter app.

 AppSettings appSettings = (AppSettingsBuilder()
          ..subscribePresenceForAllUsers()
          ..setRegion("US"))
        .build();

    _cometChat.init(
      Constant.appID,
      appSettings,
      CallbackListener(
        onSuccess: (dynamic value) {
          print("SUCCESSFULLY INITIALIZED $value");
        },
        onError: (dynamic error) {
          print("FAILED TO INITIALIZE $error");
        },
      ),
    );

Creating EventChannel

We will create an EventChannelHandler class that will handle our event channel methods. To implement this we will use FlutterStreamHandler. This FlutterStreamHandler exposes the event stream to the Dart side.

Now, we have to override two methods- onListen and onCancel. Under onListen method, we will set the FlutterEventSink with the passed event and in onCancel method, we will set the event sink to nil.

Also, there is an init method to initialize the FlutterEventChannel with the passed name and binary messenger. Then we will register the stream handler with the name on this channel using setStreamHandler method.

Since we are defining this as a generic class for all the EventChannels, we will define two more methods - success and error to set the value of the event sink using the object of EventChannelHandler class.

import Foundation
import Flutter

public class EventChannelHandler: NSObject, FlutterStreamHandler {
    private var eventSink: FlutterEventSink?

    public func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? {
        self.eventSink = events
        return nil
    }

    public func onCancel(withArguments arguments: Any?) -> FlutterError? {
        eventSink = nil
        return nil
    }

    public init(id: String, messenger: FlutterBinaryMessenger) {
        super.init()
        let eventChannel = FlutterEventChannel(name: id, binaryMessenger: messenger)
        eventChannel.setStreamHandler(self)
    }


    public func success(event: Any?) throws {
        if eventSink != nil {
            eventSink?(event)
        }
    }

    public func error(code: String, message: String?, details: Any? = nil) {
        if eventSink != nil {
            eventSink?(FlutterError(code: code, message: message, details: details))
        }
    }

}

How to transfer data through EventChannel?

Now, we will see how we can transfer data through streams. So, first, we will define an EventChannel and subscribe to the streams on the Dart side.

Create an object of EventChannelHandler class.

private var loginEventTrigger: EventChannelHandler?

Then under setupChannel method add the declaration for loginEventTrigger with the name as id and pass the binary messenger.

loginEventTrigger = EventChannelHandler(
          id: "plugins.flutter.io/login_event_trigger",
          messenger: registrar.messenger()
      )

To use this trigger we will add one more method from Comet Chat SDK, the login method. So under the handle method we will add one more case value, calling the selectLoginMethod defined in our LoginLogutHelper class.

case "login":
          let uid = args?["UID"] as? String
          let authKey = args?["authKey"] as? String
          let authToken = args?["authToken"] as? String

          loginLogoutHelper?.selectLoginMethod(authToken: authToken, authKey: authKey, UID: uid)
          result("success")

The selectLoginMethod will decide whether the user wants to login using authKey or authToken. (The authKey and authToken can be generated by creating an app on Comet Chat Pro website, use v3 version for creating the app as we are using that as reference in this project).

Here, I will use the login method with authToken to demonstrate the flow for transferring data through our event channel. For full code, you can refer to this repo.

We are calling the Comet Chat login method and it gives callbacks based on success or error. Under the onSuccess callback, we are getting the user. Now, we will convert this user in key-value type (Dictionary in case of Swift), then pass the Dictionary in string form. In the stream, we are passing the event with the loginSuccess or loginFailure key. On the Dart side we will use this key to get the user or error from the event stream.

private func loginWithAuthToken(tAuthToken: String) {
        if CometChat.getLoggedInUser() == nil {

          CometChat.login(authToken: tAuthToken , onSuccess: { (user) in
              do {
                  try self.eventChannelHandler?.success(event: ["loginSuccess": Helper.userToJson(user: user).jsonStringRepresentation])
              } catch {
                  self.eventChannelHandler?.error(code: "loginFailure", message: error.localizedDescription)
              }

          }) { (error) in
            print("Login failed with error: " + error.errorDescription);
              do {
                  try self.eventChannelHandler?.success(event: ["loginFailure" : error.errorDescription])
              } catch {
                  self.eventChannelHandler?.error(code: "loginFailure", message: error.localizedDescription)
              }
          }

        }
    }

Extension of jsonStringRepresentation to convert Dictionary to String.

extension Dictionary {
    var jsonStringRepresentation: String? {
        guard let theJSONData = try? JSONSerialization.data(withJSONObject: self,
                                                            options: [.prettyPrinted]) else {
            return nil
        }

        return String(data: theJSONData, encoding: .ascii)
    }
}

Defining EventChannel and StreamSubscription on the Dart side.

static const EventChannel loginEventTrigger =
      EventChannel('plugins.flutter.io/login_event_trigger');

late StreamSubscription _loginEventsubscription;

Subscribing to the event stream to receive data.

Stream<dynamic> loginEventTrigger() {
    return FlutterCometChatDart.loginEventTrigger.receiveBroadcastStream();
  }

Since this is a trigger stream so we will cancel the stream subscription once the event data is received. Method to cancel the subscription -

cancelSubscription(StreamSubscription streamSubscription) {
    return () {
      streamSubscription.cancel();
    };
  }

Now, in the login method, we will first do the method call then listen to the stream, and once we have used the listen() method to fetch the data we will cancel the subscription.

login(
      {String? authKey,
      String? uid,
      String? authToken,
      required CallbackListener callbackListener}) {
    assert((uid != '' && authKey != '') || (authToken != ''));

    _auth.login(authKey: authKey, uid: uid, authToken: authToken);

    _loginEventsubscription = _auth.loginEventTrigger().listen((event) {
      if (event.containsKey(CometConstantsKeys.LOGIN_SUCCESS)) {
        var data = jsonDecode(event[CometConstantsKeys.LOGIN_SUCCESS]);
        callbackListener.onSuccess(User.fromJson(data));
      }
      if (event.containsKey(CometConstantsKeys.LOGIN_FAILURE)) {
        callbackListener.onError(event);
      }
    });

    cancelSubscription(_loginEventsubscription);
  }

Here, we are using the same keys login_success and login_failure to get the data from the event stream. Then we have decoded the JSON String and serialized it with the User class. To get the object data on the Dart side we have added a fromJson method under the User class that will map all the keys in the JSON to the values of the User class.

 User.fromJson(Map<dynamic, dynamic> json) {
    avatar = json['avatar'];
    blockedByMe = json['blockedByMe'];
    credits = json['credits'];
    hasBlockedMe = json['hasBlockedMe'];
    lastActiveAt = json['lastActiveAt'];
    link = json['link'];
    metadata = json['metadata'];
    name = json['name'];
    role = json['role'];
    status = getUserStatus(json['status']);
    statusMessage = json['statusMessage'];
    uid = json['uid'];
  }

Now the method is ready we can use it in our example app.

 _cometChat.login(
                  uid: "superhero2",
                  authToken: Constant.authToken,
                  callbackListener: CallbackListener(
                    onSuccess: (User user) {
                      print("User $user");
                    },
                    onError: (e) {
                      print("Error $e");
                    },
                  ),
                );

Till now, we have defined and used MethodChannel, EventChannel. Let's look at another example for EventChannel by defining LoginListener.

According to the Comet Chat docs, LoginListener class needs to implement CometChatLoginDelegate and add CometChat.logindelegate = self in the init state of this class.

The LoginListener will then override 4 methods - onLoginSuccess, onLoginFailed, onLogoutSuccess, and onLogoutFailed. Similar to the login method here in the listener we will convert the user object into Dictionary then into String and pass it to the event channel success method or in case of error pass it to the error method with the right key.

public class LoginListener: CometChatLoginDelegate {
    private var eventChannelHandler: EventChannelHandler?

    public init(handler: EventChannelHandler?) {
        self.eventChannelHandler = handler
        CometChat.logindelegate = self
    }

    public func onLoginSuccess(user: User) {
        do {
            try eventChannelHandler?.success(
                event: ["loginSuccess":  Helper.userToJson(user: user).jsonStringRepresentation]
            )
        } catch {
            eventChannelHandler?.error(
                code: "loginFailure",
                message: error.localizedDescription
            )
        }
    }
. . .
. . .

Let's use this listener in our method call. First, declare the EventChannelHandler and LoginListener object.

private var loginEventListener: EventChannelHandler?
private var loginListener: LoginListener?

Then under setupChannel method declare the variable with the name used for this EventChannel and binary messenger.

 loginEventListener = EventChannelHandler(
          id: "plugins.flutter.io/login_event_listener",
          messenger: registrar.messenger()
      )

Under the handle method, we will define the add_login_listener method call and instantiate the LoginListener class.

 case "add_login_listeners":
          loginListener = LoginListener(handler: loginEventListener)
          result("success")

Coming on the Dart side, we will declare another event channel and subscribe to the stream of the event.

static const EventChannel loginEventListener =
      EventChannel("plugins.flutter.io/login_event_listener");

Subscribing to the event channel stream of login listener.

  Stream<dynamic> loginEventListener() {
    return FlutterCometChatDart.loginEventListener.receiveBroadcastStream();
  }

Now under the addLoginListener method, we will listen to the event stream and pass the data to the callback method. Here, we have used LoginListener as a callback which has 4 methods same as the LoginListener on the Swift side.

addLoginListener(String uniqueID, LoginListener loginListener) async {
    assert(uniqueID != '');
        try {
      await FlutterCometChatDart.channel
          .invokeMethod('add_login_listeners', {"uniqueId": uniqueID});

      _auth.loginEventListener().listen((event) {
        if (event.containsKey(CometConstantsKeys.LOGIN_FAILURE)) {
          loginListener.loginFailure(event);
        }
        if (event.containsKey(CometConstantsKeys.LOGIN_SUCCESS)) {
          var data = jsonDecode(event[CometConstantsKeys.LOGIN_SUCCESS]);
          loginListener.loginSuccess(User.fromJson(data));
        }
        if (event.containsKey(CometConstantsKeys.LOGOUT_FAILURE)) {
          loginListener.logoutFailure(event[CometConstantsKeys.LOGOUT_FAILURE]);
        }
        if (event.containsKey(CometConstantsKeys.LOGOUT_SUCCESS)) {
          loginListener.logoutSuccess(event[CometConstantsKeys.LOGOUT_SUCCESS]);
        }
      });
    } on PlatformException catch (e) {
      throw FlutterCometChatDart.convertException(e);
    }
  }

What's next?

In this tutorial, we learned how to set up Platform channels for IOS and implemented the 3rd party SDK. Then used the Dart plugin in our example app to access the native methods. In the next tutorial, we will implement the Platform channels and integrate the Comet Chat SDK for Android.

References