24
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.
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
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:
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.
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.
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
)
}
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.
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.
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.
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.
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.
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.
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.
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)
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.
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.
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.
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.
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.
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);
}
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:
Now we move to the user side of the plugin and learn how our users can utilize the interfaces.
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);
}
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.
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:
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)
24