How we make Flutter work with CallKit Call Directory

Disclaimer: This will be a long read that my colleague wrote for Habr.ru and I decided to translate to share with you guys!

Stock up on snacks, make yourself comfortable, and let’s begin!

In this long read, I will tell you how we (at Voximplant) decided to create our own Flutter plugin to use CallKit in a Flutter app. And appeared to be the first who made call blocking & identification work for Flutter using Call Directory.

What is CallKit?

Apple CallKit is a framework used for integrating calls from 3rd party apps into the system.

If a call from a 3rd party app is displayed as native, it means that CallKit is used here. If a call from a 3rd party app is in the list of system application calls (Phone) – it is also CallKit. Third-party applications that act as a caller ID – CallKit. Calls from third-party apps that can't get through Do Not Disturb mode – well, you get the idea.

CallKit provides third-party developers with a system UI for displaying calls

What’s with CallKit on Flutter?

CallKit is a part of iOS SDK, but it can be accessed from Flutter by interacting with native code. To use the framework’s functionality, you need to connect a third-party plugin that encapsulates the Flutter interaction with iOS. Or you can implement everything yourself, for example, this way:

Ready-made CallKit Flutter solutions

So, we needed to integrate our Flutter application for VoIP calls with the system. First, we looked through most of the existing third-party solutions and chose one of them to use for a while. However, this and the rest of the available options had their problems.

Existing plugins partially or completely wrap the CallKit API in their own high-level API. Because of that flexibility is lost and some features are unavailable. Due to their implementation of architecture and interfaces, such plugins contain their bugs. The documentation is incomplete or absent, and the authors of some of them stopped supporting almost immediately, which is especially dangerous on the fast-growing Flutter.

How we came up with our solution

For simple scenarios, this worked at first, but a specific case gave us some trouble. We had to study the source code to find out how this particular plugin interacted with CallKit. In the end, we discovered that we wouldn't be able to implement what we wanted because of the high-level API limitations.

We thought about implementing our solution with those disadvantages in mind.

We wanted to preserve the architecture and interfaces of CallKit. This way we would give users all the flexibility and the ability to use the original documentation, and we could protect them from potential bugs in our implementation.

Our implementation

We managed to move the entire CallKit API to Dart, preserving the hierarchy of classes and mechanisms of interaction with them.

The communication between Flutter and iOS is asynchronous, so it took us a while to implement some of the details. The main difficulty was the functionality that required synchronous communication on one side or the other.

For example, the CXProviderDelegate.provider(_:execute:) native CallKit API requires synchronously returning a Bool value:

optional func provider(_ provider: CXProvider, 
    execute transaction: CXTransaction) -> Bool

This method is called every time a new CXTransaction needs to be processed. You can return true to process the transaction yourself and notify the system about it. If you return false (default behavior), the corresponding handler method in CXProviderDelegate is called for each CXAction contained in the transaction.

To use this API in the plugin, we needed to declare it in the Dart code so that the user could control this behavior despite the asynchronous nature of data exchange between the platforms. By returning true in native code, we managed to move transaction control to the Dart code, where we perform manual or automatic CXTransaction processing depending on the value received from the user.

Problems with asynchrony arise in the native part as well. For example, there is the PushKit iOS framework that is not part of CallKit but they are often used together, so it was necessary to integrate it. When you receive a VoIP push, you need to immediately notify CallKit of an incoming call in native code, otherwise the application will crash. To handle this we decided to allow reporting incoming calls directly to CallKit from native code without an asynchronous "hook" in the form of Flutter. As a result, for this integration, we implemented several helpers in the native part of the plugin (available via the FlutterCallkitPlugin iOS class) and several on the Flutter side (available via the FCXPlugin Dart class).

We declared additional features of the plugin in its class to separate the plugin interface from the CallKit interface.

How to report an incoming call directly to CallKit

When a VoIP push is received, one of the PKPushRegistryDelegate.pushRegistry(_: didReceiveIncomingPushWith:) methods is called. Here you need to create a CXProvider instance and call reportNewIncomingCall to notify CallKit of the call. Since the same provider instance is required to further handle the call, we added the FlutterCallkitPlugin.reportNewIncomingCallWithUUID method from the native side of the plugin. When the method is called, the plugin reports the call to the CXProvider and also executes FCXPlugin.didDisplayIncomingCall on the Dart side to continue working with the call.

func pushRegistry(_ registry: PKPushRegistry,
                  didReceiveIncomingPushWith payload: PKPushPayload,
                  for type: PKPushType,
                  completion: @escaping () -> Void
) {
    // Retrieve the necessary data from the push
    guard let uuidString = payload["UUID"] as? String,
        let uuid = UUID(uuidString: uuidString),
        let localizedName = payload["identifier"] as? String
    else {
        return
    }

    let callUpdate = CXCallUpdate()
    callUpdate.localizedCallerName = localizedName

    let configuration = CXProviderConfiguration(
        localizedName: "ExampleLocalizedName"
    )

    // Report the call to the plugin and it will report it to CallKit
    FlutterCallkitPlugin.sharedInstance.reportNewIncomingCall(
        with: uuid,
        callUpdate: callUpdate,
        providerConfiguration: configuration,
        pushProcessingCompletion: completion
    )
}

Enter fullscreen mode Exit fullscreen mode

To sum up: the main feature of our plugin is that using it on Flutter is almost the same as using the native CallKit on iOS.

One more thing

But there still is one thing about Apple CallKit that we haven't implemented (and no one has implemented in available third-party solutions). It's the Call Directory App Extension support.

Call Directory is

CallKit can block and identify calls. Developers can access these features using a special system extension – Call Directory. Read more about iOS app extensions in the App Extension Programming Guide.

In short, it is a separate iOS app target that runs independently of the main application at the request of the system.

For example, when receiving an incoming call, iOS tries to identify or find the caller in the list of blocked by standard means. If the number is not found, the system can request data from available Call Directory extensions to somehow handle the call. At this point, the extension has to "retrieve" these numbers from storage. The application itself can add numbers from its databases in there at any time. Thus there is no interaction between the extension and the application, the data is exchanged through the shared storage.

Read more about iOS App Extensions: App Extension Programming Guide.

Call Directory Extension in Flutter

Not so long ago a user asked if we can add Call Directory support. We started to study the possibility of implementing this feature and found out that it won’t be able to provide a Flutter API without making the users write native code. The problem is that the Call Directory works in the extension. It is launched by the system, runs for a very short time, and does not depend on the application (including Flutter). Thus, to support this functionality, the user of the plugin will need to create an app extension and data storage on his own eventually.

Decision we made

Despite the difficulties with native code, we were determined to make using Call Directory as convenient as possible for our framework's users.

Having tested the ability of such an extension to work with a Flutter app, we started designing it. The solution had to retain all the Call Directory Manager APIs, require the user to write a minimum of native code, and be easy to interact with via Flutter.

This is how we created version 1.2.0 with Call Directory Extension support.

How we implemented Call Directory for Flutter

To implement this functionality, we had to consider several aspects. We needed to:

  • Transfer the interface of CXCallDirectoryManager class (CallKit object that allows managing Call Directory);
  • Decide what to do with the app extension and its numbers storage;
  • Create a convenient way to transfer data from the Dart code to native code and back to manage the list of numbers from the Flutter app.

Transfer CXCallDirectoryManager interfaces to Flutter

The code presented in this article was simplified on purpose to make it easy to perceive. Find the full version of the code following the links at the end of the article. We used Objective-C to implement the plugin since it was chosen as the main language in our project earlier. The CallKit interfaces are written in Swift for simplicity.

Interface

First of all, let's see what exactly needs to be transferred:

extension CXCallDirectoryManager {  
    public enum EnabledStatus : Int {
        case unknown = 0
        case disabled = 1
        case enabled = 2
    }
}

open class CXCallDirectoryManager : NSObject {
    open class var sharedInstance: CXCallDirectoryManager { get }

    open func reloadExtension(
        withIdentifier identifier: String,
        completionHandler completion: ((Error?) -> Void)? = nil
    )

    open func getEnabledStatusForExtension(
        withIdentifier identifier: String,
        completionHandler completion: @escaping (CXCallDirectoryManager.EnabledStatus, Error?) -> Void
    )

    open func openSettings(
        completionHandler completion: ((Error?) -> Void)? = nil
    )
}

Let's recreate the equivalent of the CXCallDirectoryManager.EnabledStatus enum with Dart:

enum FCXCallDirectoryManagerEnabledStatus {
  unknown,
  disabled,
  enabled
}

Now you can declare the class and methods. There is no need for sharedInstance in our interface, so let's make a regular Dart class with static methods:

class FCXCallDirectoryManager {
  static Future<void> reloadExtension(String extensionIdentifier) async { }

  static Future<FCXCallDirectoryManagerEnabledStatus> getEnabledStatus(
    String extensionIdentifier,
  ) async { }

  static Future<void> openSettings() async { }
}

Preserving the API is important, but it is just as important to consider the platform and language code style so that the interface is clear and convenient for plugin users.
For the API in the Dart, we used a shorter name (the long name was from objective-C) and replaced the completion block with Future. Future is the standard mechanism used to get the result of asynchronous methods in Dart. We also return Future from most Dart plugin methods because communication with native code is asynchronous.
Before – getEnabledStatusForExtension(withIdentifier:completionHandler:)
After – Future getEnabledStatus(extensionIdentifier)

Implementation

To make communication between Flutter and iOS possible, we use FlutterMethodChannel.

Read more about the features of this communication channel here.

On the Flutter side

Create a MethodChannel object:

const MethodChannel _methodChannel =
  const MethodChannel('plugins.voximplant.com/flutter_callkit');

On the iOS side

The first thing we do is subscribe the iOS plugin class to the FlutterPlugin protocol to interact with Flutter:

@interface FlutterCallkitPlugin : NSObject<FlutterPlugin>

@end

When initializing the plugin, create a FlutterMethodChannel with the same identifier we used above:

+ (void)registerWithRegistrar:(NSObject<FlutterPluginRegistrar>*)registrar {
    FlutterMethodChannel *channel
        = [FlutterMethodChannel 
          methodChannelWithName:@"plugins.voximplant.com/flutter_callkit"
          binaryMessenger:[registrar messenger]];
    FlutterCallkitPlugin *instance 
        = [FlutterCallkitPlugin sharedPluginWithRegistrar:registrar];
    [registrar addMethodCallDelegate:instance channel:channel];
}

Now you can use this channel to call iOS methods from Flutter.

Let's take a closer look at the implementation of Dart methods and the native part of the plugin using the getEnabledStatus example.

On the Flutter side

The Dart implementation will be as simple as possible. We will call MethodChannel.invokeMethod with the necessary arguments and process the result of that call.

About MethodChannel MethodChannel API allows us to asynchronously get the result of a call from native code using Future but imposes restrictions on the data types that we pass.

We pass the method name (we'll use it in native code to identify the call) and the extensionIdentifier argument to MethodChannel.invokeMethod and then convert the result from the int type to FCXCallDirectoryManagerEnabledStatus. We should also handle PlatformException in case of an error in native code.

static Future<FCXCallDirectoryManagerEnabledStatus> getEnabledStatus(
  String extensionIdentifier,
) async {

  try {

    // Use MethodChannel with extensionIdentifier
    // as an argument to call the corresponding 
    // method in the platform code
    int index = await _methodChannel.invokeMethod(
      'Plugin.getEnabledStatus',
      extensionIdentifier,
    );

    // Convert the result to the
    // FCXCallDirectoryManagerEnabledStatus enum
    // and return its value to the user
    return FCXCallDirectoryManagerEnabledStatus.values[index];

  } on PlatformException catch (e) {

    // If we get an error, we pass it to FCXException
    // and then return it to the user in special type
    throw FCXException(e.code, e.message);
  }
}

Pay attention to the method identifier that we used:
Plugin.getEnabledStatus
The word before the dot is used to define the module responsible for a particular method.
getEnabledStatus is equal to the name of the method in Flutter, not in iOS (or Android).

On the iOS side

Now we move to the platform code and implement the backend for this method.

Calls through FlutterMethodChannel go straight to the handleMethodCall:result: method.

Using the previously passed identifier, we can determine what method was called, get the arguments from it and execute the main part of the code. Detailed information is in the comments here:

- (void)handleMethodCall:(FlutterMethodCall*)call
                  result:(FlutterResult)result {

    // Calls from Flutter can be initiated by name,
    // which is passed to `FlutterMethodCall.method` property
    if ([@"Plugin.getEnabledStatus" isEqualToString:call.method]) {

        // When passing arguments with MethodChannel, 
        // they are packed to `FlutterMethodCall.arguments`.
        // Extract extensionIdentifier, which we passed 
        // from the Flutter code earlier
        NSString *extensionIdentifier = call.arguments;

        if (isNull(extensionIdentifier)) {
            // If the arguments are invalid, return an error
            // using the `result` handler.
            // The error should be packed to `FlutterError`.
            // It’ll be thrown as PlatformException in the Dart code
            result([FlutterError errorInvalidArguments:@"extensionIdentifier must not be null"]);
            return;
    }

        // When the method is detected and the arguments
        // are extracted and validated,
        // we can write the logic

        // To interact with this CallKit functionality
    // we need the CallDirectoryManager instance
        CXCallDirectoryManager *manager 
            = CXCallDirectoryManager.sharedInstance;

        // Call the CallDirectoryManager method
        // and wait for the result
        [manager 
            getEnabledStatusForExtensionWithIdentifier:extensionIdentifier
            completionHandler:^(CXCallDirectoryEnabledStatus status, 
                                           NSError * _Nullable error) {

            // completion handler (containing the result of 
            // the CallDirectoryManager method) is executed, 
            // now we need to pass the result to Dart
            // But first we convert it to the в suitable type, 
            // because only certain data types can be passed 
            // through  MethodChannel
            if (error) {

                // Our errors are packed to `FlutterError`
                result([FlutterError errorFromCallKitError:error]);
            } else {

                // Numbers are packed to `NSNumber`
                // This enum is `NSInteger`, so we
                // make the required conversion
                result([self convertEnableStatusToNumber:enabledStatus]);
            }
    }];
    }
}

Implement the two remaining FCXCallDirectoryManager methods in the same way

On the Flutter side

static Future<void> reloadExtension(String extensionIdentifier) async {
  try {

    // Set an identifier, pass the argument, 
    // and call the platform method
    await _methodChannel.invokeMethod(
      'Plugin.reloadExtension',
      extensionIdentifier,
    );
  } on PlatformException catch (e) {
    throw FCXException(e.code, e.message);
  }
}

static Future<void> openSettings() async {
  try {

    // This method does not accept arguments
    await _methodChannel.invokeMethod(
      'Plugin.openSettings',
    );
  } on PlatformException catch (e) {
    throw FCXException(e.code, e.message);
  }
}

On the iOS side

if ([@"Plugin.reloadExtension" isEqualToString:call.method]) {
    NSString *extensionIdentifier = call.arguments;
    if (isNull(extensionIdentifier)) {
        result([FlutterError errorInvalidArguments:@"extensionIdentifier must not be null"]);
        return;
    }
    CXCallDirectoryManager *manager 
        = CXCallDirectoryManager.sharedInstance;
    [manager 
        reloadExtensionWithIdentifier:extensionIdentifier
        completionHandler:^(NSError * _Nullable error) {
        if (error) {
            result([FlutterError errorFromCallKitError:error]);
        } else {
            result(nil);
        }
    }];
}

if ([@"Plugin.openSettings" isEqualToString:call.method]) {
    if (@available(iOS 13.4, *)) {
        CXCallDirectoryManager *manager 
            = CXCallDirectoryManager.sharedInstance;
        [manager 
            openSettingsWithCompletionHandler:^(NSError * _Nullable error) {
            if (error) {
                result([FlutterError errorFromCallKitError:error]);
            } else {
                result(nil);
            }
        }];
    } else {
        result([FlutterError errorLowiOSVersionWithMinimal:@"13.4"]);
    }
}

That’s it! CallDirectoryManager is implemented and ready to be used.

App Extension and number storage

Since, due to the presence of the Call Directory in the iOS extension, we will not be able to provide its implementation with the plugin and working with the platform code is usually unfamiliar for Flutter developers, we will try to help them as much as possible providing ... Documentation!

Let's create a complete app extension and storage samples and connect them to the example app of our plugin.

As the simplest version of the storage, we will use UserDefaults, which we will wrap in propertyWrapper.

This is how the interface of our storage looks like:

// Access to the storage from the iOS app
@UIApplicationMain
final class AppDelegate: FlutterAppDelegate {
    @UserDefault("blockedNumbers", defaultValue: [])
    private var blockedNumbers: [BlockableNumber]

    @UserDefault("identifiedNumbers", defaultValue: [])
    private var identifiedNumbers: [IdentifiableNumber]
}

// Access to the storage from the app extension
final class CallDirectoryHandler: CXCallDirectoryProvider {
    @UserDefault("blockedNumbers", defaultValue: [])
    private var blockedNumbers: [BlockableNumber]

    @UserDefault("identifiedNumbers", defaultValue: [])
    private var identifiedNumbers: [IdentifiableNumber]

    @NullableUserDefault("lastUpdate")
    private var lastUpdate: Date?
}

Storage implementation code: UserDefaults

iOS app code: iOS App Delegate

iOS extension code: iOS App Extension

Note that the storage and extension samples are not part of the plugin, but rather part of the example application that comes with it.

Pass numbers from Flutter to iOS and vice versa

So, the app extension is configured and connected to the storage, the necessary methods of CallDirectoryManager are implemented, the last detail – to learn how to take numbers from Flutter and put them in the platform storage or, conversely, request them from the platform storage.

The easiest way as it may seem is to make the user of the plugin deal with the data. He'll have to set up his own MethodChannel or use other third-party storage management solutions. It will certainly suit some people! :) And for the rest, we will make a simple API to pass numbers directly through our framework. This functionality will be optional so we don’t limit those who are more comfortable using their ways of transferring data.

Interface

Let’s see which interfaces we need:

  • Add blocked/identifiable numbers to the storage
  • Delete blocked/identifiable numbers from the repository
  • Request blocked/identifiable numbers from the repository

On the Flutter side

We previously decided to use the FCXPlugin (Flutter) and FlutterCallkitPlugin (iOS) classes for the helpers. However, Call Directory is a highly specialized functionality that is not used in every project. That's why I want to put it in a separate file but leave the access through the FCXPlugin class object. The extension will do this work:

extension FCXPlugin_CallDirectoryExtension on FCXPlugin {

  Future<List<FCXCallDirectoryPhoneNumber>> getBlockedPhoneNumbers()
    async { }

  Future<void> addBlockedPhoneNumbers(
    List<FCXCallDirectoryPhoneNumber> numbers,
  ) async { }

  Future<void> removeBlockedPhoneNumbers(
    List<FCXCallDirectoryPhoneNumber> numbers,
  ) async { }

  Future<void> removeAllBlockedPhoneNumbers() async { }

  Future<List<FCXIdentifiablePhoneNumber>> getIdentifiablePhoneNumbers()
    async { }

  Future<void> addIdentifiablePhoneNumbers(
    List<FCXIdentifiablePhoneNumber> numbers,
  ) async { }

  Future<void> removeIdentifiablePhoneNumbers(
    List<FCXCallDirectoryPhoneNumber> numbers,
  ) async { }

  Future<void> removeAllIdentifiablePhoneNumbers() async { }
}

On the iOS side

To let Flutter access numbers that are in storage on the iOS side, the user of the plugin needs to somehow connect his database of numbers and the plugin. Let’s give him the interface to do that:

@interface FlutterCallkitPlugin : NSObject<FlutterPlugin>

@property(strong, nonatomic, nullable)
NSArray<FCXCallDirectoryPhoneNumber *> *(^getBlockedPhoneNumbers)(void);

@property(strong, nonatomic, nullable)
void(^didAddBlockedPhoneNumbers)(NSArray<FCXCallDirectoryPhoneNumber *> *numbers);

@property(strong, nonatomic, nullable)
void(^didRemoveBlockedPhoneNumbers)(NSArray<FCXCallDirectoryPhoneNumber *> *numbers);

@property(strong, nonatomic, nullable)
void(^didRemoveAllBlockedPhoneNumbers)(void);

@property(strong, nonatomic, nullable)
NSArray<FCXIdentifiablePhoneNumber *> *(^getIdentifiablePhoneNumbers)(void);

@property(strong, nonatomic, nullable)
void(^didAddIdentifiablePhoneNumbers)(NSArray<FCXIdentifiablePhoneNumber *> *numbers);

@property(strong, nonatomic, nullable)
void(^didRemoveIdentifiablePhoneNumbers)(NSArray<FCXCallDirectoryPhoneNumber *> *numbers);

@property(strong, nonatomic, nullable)
void(^didRemoveAllIdentifiablePhoneNumbers)(void);

@end

Each type of interaction with the repository has its handler. It is called by our framework every time the corresponding helper is called from the Flutter side.

Handlers are optional which allows you to use only some part of this functionality or use your own solution instead.

Implementation

Now let's implement the communication between the declared helper methods in Flutter and the handlers in iOS.

There are a lot of methods but they all work almost the same. That’s why we will focus on two of them, the ones with the opposite direction of data movement.

Get identifiable numbers

On the Flutter side

Future<List<FCXIdentifiablePhoneNumber>> getIdentifiablePhoneNumbers() async {
  try {

    // Call the platform method and save the result    List<dynamic> numbers = await _methodChannel.invokeMethod(
      'Plugin.getIdentifiablePhoneNumbers',
    );

    // Type the result and return it to the user
    return numbers
      .map(
        (f) => FCXIdentifiablePhoneNumber(f['number'], label: f['label']))
      .toList();

  } on PlatformException catch (e) {
    throw FCXException(e.code, e.message);
  }
}

On the iOS side

if ([@"Plugin.getIdentifiablePhoneNumbers" isEqualToString:call.method]) {
    if (!self.getIdentifiablePhoneNumbers) {
        // Check if the handler exists,
        // if not, return an error
        result([FlutterError errorHandlerIsNotRegistered:@"getIdentifiablePhoneNumbers"]);
        return;
    }

    // Using the handler, request numbers from a user
    NSArray<FCXIdentifiablePhoneNumber *> *identifiableNumbers
        = self.getIdentifiablePhoneNumbers();

    NSMutableArray<NSDictionary *> *phoneNumbers
        = [NSMutableArray arrayWithCapacity:identifiableNumbers.count];

    // Wrap each number in the dictionary type 
    // so we could pass them via MethodChannel 
    for (FCXIdentifiablePhoneNumber *identifiableNumber in identifiableNumbers) {
        NSMutableDictionary *dictionary 
            = [NSMutableDictionary dictionary];
        dictionary[@"number"] 
            = [NSNumber numberWithLongLong:identifiableNumber.number];
        dictionary[@"label"] 
            = identifiableNumber.label;
        [phoneNumbers addObject:dictionary];
    }

    // Pass the numbers to Flutter
    result(phoneNumbers);
}

Add identifiable numbers

On the Flutter side

Future<void> addIdentifiablePhoneNumbers(
  List<FCXIdentifiablePhoneNumber> numbers,
) async {
  try {
    // Prepare the numbers to be passed via MethodChannel
    List<Map> arguments = numbers.map((f) => f._toMap()).toList();

    // Pass the numbers to native code
    await _methodChannel.invokeMethod(
      'Plugin.addIdentifiablePhoneNumbers',
      arguments
    );

  } on PlatformException catch (e) {
    throw FCXException(e.code, e.message);
  }
}

On the iOS side

if ([@"Plugin.addIdentifiablePhoneNumbers" isEqualToString:call.method]) {
    if (!self.didAddIdentifiablePhoneNumbers) {
        // Check if the handler exists,
        // if not, return an error
        result([FlutterError errorHandlerIsNotRegistered:@"didAddIdentifiablePhoneNumbers"]);
        return;
    }

    // Get the numbers passed as arguments
    NSArray<NSDictionary *> *numbers = call.arguments;
    if (isNull(numbers)) {
        // Check if they’re valid
        result([FlutterError errorInvalidArguments:@"numbers must not be null"]);
        return;
    }

    NSMutableArray<FCXIdentifiablePhoneNumber *> *identifiableNumbers
        = [NSMutableArray array];

    // Type the numbers
    for (NSDictionary *obj in numbers) {
        NSNumber *number = obj[@"number"];
        __auto_type identifiableNumber
            = [[FCXIdentifiablePhoneNumber alloc] initWithNumber:number.longLongValue
                                                                                     label:obj[@"label"]];
        [identifiableNumbers addObject:identifiableNumber];
    }

    // Pass the typed numbers to the handler
    self.didAddIdentifiablePhoneNumbers(identifiableNumbers);

    // Tell Flutter about the end of operation
    result(nil);
}

The rest of the methods are implemented the same way, here is the full code for Flutter and iOS:

Usage samples

Now we move to the user side of the plugin and learn how our users can utilize the interfaces.

Reload extension

The reloadExtension(withIdentifier:completionHandler:) method is used to reload the Call Directory extension. You may need it, for example, after adding new numbers to the storage so that they get into CallKit.

Use it the same way you use native CallKit API: call FCXCallDirectoryManager and request reload by the given extensionIdentifier:

final String _extensionID =
  'com.voximplant.flutterCallkit.example.CallDirectoryExtension';

Future<void> reloadExtension() async {
  await FCXCallDirectoryManager.reloadExtension(_extensionID);
}

Get identified numbers

On the Flutter side

We request a list of identifiable numbers using our plugin class:

final FCXPlugin _plugin = FCXPlugin();

Future<List<FCXIdentifiablePhoneNumber>> getIdentifiedNumbers() async {
  return await _plugin.getIdentifiablePhoneNumbers();
}

On the iOS side

Add the getIdentifiablePhoneNumbers handler, which the plugin uses to pass the specified numbers to Flutter. We will use it to pass the numbers from our identifiedNumbers storage:

private let callKitPlugin = FlutterCallkitPlugin.sharedInstance

@UserDefault("identifiedNumbers", defaultValue: [])
private var identifiedNumbers: [IdentifiableNumber]

// Add a phone number request event handler
callKitPlugin.getIdentifiablePhoneNumbers = { [weak self] in
    guard let self = self else { return [] }

    // Return the numbers from the storage to the handler
    return self.identifiedNumbers.map {
        FCXIdentifiablePhoneNumber(number: $0.number, label: $0.label)
    }
}

Now the numbers from the user storage will go to the handler and then to Flutter.

Add identified numbers

On the Flutter side

We pass the numbers that we want to identify to the plugin object:

final FCXPlugin _plugin = FCXPlugin();

Future<void> addIdentifiedNumber(String number, String id) async {
  int num = int.parse(number);
  var phone = FCXIdentifiablePhoneNumber(num, label: id);
  await _plugin.addIdentifiablePhoneNumbers([phone]);
}

On the iOS side

Add the didAddIdentifiablePhoneNumbers handler, which the plugin uses to notify the platform code about receiving new numbers from Flutter. In the handler, we save the received numbers to the number storage:

private let callKitPlugin = FlutterCallkitPlugin.sharedInstance

@UserDefault("identifiedNumbers", defaultValue: [])
private var identifiedNumbers: [IdentifiableNumber]

// Add an event handler for adding numbers
callKitPlugin.didAddIdentifiablePhoneNumbers = { [weak self] numbers in
    guard let self = self else { return }

    // Save the numbers to the storage 
    self.identifiedNumbers.append(
        contentsOf: numbers.map {
            IdentifiableNumber(identifiableNumber: $0)
        }
    )

    // The numbers in Call Directory must be sorted
    self.identifiedNumbers.sort()
}

Now numbers from Flutter go to the plugin, then to the event handler, and eventually to the user's number storage. Next time you reload the Call Directory extension, they will be available for CallKit for call identification.

Full code samples:

To sum it up

We managed to make an opportunity to use the CallKit Call Directory from Flutter!

The details of platform communications are still hidden in the depths of the plugin, the native API is preserved, and the custom iOS implementation is well documented.

Now it is relatively easy to block and/or identify numbers using the native Call Directory in Flutter.

Results:

  • Completely moved CallDirectoryManager interface
  • Created an easy way to pass the numbers from the Flutter code to iOS, leaving the option to use your data transfer solutions
  • Described the architecture of the solution in the README with visual diagrams for better understanding
  • Added a full-fledged working example app that uses all Call Directory functionality and implements platform modules samples (iOS extension and data storage)

Useful links

24