Flutter+CallKitCallDirectory=Love


Привет!


В этом лонгриде я расскажу о том, как мы в Voximplant пришли к реализации собственного Flutter плагина для использования CallKit во Flutter приложении, и в итоге оказались первыми, кто сделал поддержку блокировки/определения номеров через Call Directory для Flutter.




Что такое CallKit


Apple CallKit – это фреймворк для интеграции звонков стороннего приложения в систему.


Если звонок из стороннего приложения отображается как нативный, то тут задействован CallKit. Если звонок из стороннего приложения отображается в списке звонков системного приложения Phone – тоже CallKit. Сторонние приложения, выступающие в качестве определителя номера – CallKit. Звонки из сторонних приложений, которые не могут пробиться через режим “Не беспокоить” – ну вы поняли.



CallKit предоставляет сторонним разработчикам системный UI для отображения звонков



А что с CallKit на Flutter?


CallKit является частью iOS SDK, во Flutter он не представлен, однако доступ к нему из Flutter возможен путём взаимодействия с нативным кодом. Для использования функциональности этого фреймворка потребуется подключить сторонний плагин, инкапсулирующий взаимодействие Flutter с iOS, или реализовывать всё самостоятельно, например, так:



Пример реализации CallKit сервиса для Flutter, где код iOS приложения (platform code) связывает приложение Flutter с системой



Готовые решения с CallKit на Flutter


Итак, нам потребовалось интегрировать наше Flutter приложение для VoIP звонков с системой. Первым делом мы рассмотрели большинство из существующих сторонних решений и на какое-то время воспользовались одним из них. Однако этот и остальные доступные варианты вели по пути наименьшего сопротивления, которому сопутствовали характерные проблемы.


Существующие плагины частично или полностью оборачивали CallKit API в собственный высокоуровневый API. Таким образом терялась гибкость, а некоторые возможности становились недоступными. Из-за собственной реализации архитектуры и интерфейсов такие плагины содержали свои баги. Документация хромала или отсутствовала, а авторы некоторых из них прекратили поддержку почти сразу, что особенно опасно на быстроразвивающемся Flutter.



Как мы пришли к созданию своего решения


Для простых сценариев на первое время это было приемлемо, однако, как только появлялся специфичный кейс, тут же появлялись неудобства. Приходилось изучать исходный код, чтобы выяснить, как именно этот плагин взаимодействует с CallKit. В конце концов могло обнаружиться, что реализовать требуемое вообще не выйдет из-за ограничений, накладываемых высокоуровневым API.


Мы задумались о том, чтобы реализовать своё решение с учетом этих недостатков.


Хотелось пойти по пути сохранения архитектуры и интерфейсов CallKit. Таким образом оставить пользователям всю гибкость, возможность использовать оригинальную документацию и оградить от потенциальных багов в собственной реализации.



Наша Реализация


Нам удалось перенести всё CallKit API на Dart с сохранением иерархии классов и механизмов взаимодействия с ними.



Наш плагин закрывает собой всю работу с платформой, при этом предоставляет идентичный интерфейс


Коммуникация между Flutter и iOS асинхронна, так что пришлось поломать голову с реализацией некоторых деталей. Основной сложностью был функционал, требующий синхронного взаимодействия с той или иной стороны.


Например, нативное CallKit API CXProviderDelegate.provider(_:execute:) требует синхронно возвращать Bool значение:


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

Этот метод вызывается каждый раз, когда нужно обработать новую транзакцию CXTransaction. Можно вернуть true, чтобы обработать транзакцию самостоятельно и уведомить об этом систему. Вернув false, получим дефолтное поведение, при котором для каждого CXAction, содержащегося в транзакции, будет вызван соответствующий метод обработчик в CXProviderDelegate.


Для переноса этого API в плагин требовалось объявить его в Dart коде так, чтобы пользователь мог управлять этим поведением, несмотря на асинхронный характер обмена данными между платформами. Возвращая в нативном коде значение true, мы смогли перенести управление транзакциями в Dart код, где выполняем ручную или автоматическую обработку CXTransaction в зависимости от значения, полученного от пользователя.


Проблемы с асинхронностью возникают и в нативной части. Например, есть iOS фреймворк PushKit, он не является частью CallKit, но часто они используются вместе, так что интеграция с ним была необходима. При получении VoIP пуша требуется немедленно уведомить CallKit о входящем звонке в нативном коде, в противном случае приложение упадет. Для обработки этого нюанса мы решили дать возможность репортить входящие звонки напрямую в CallKit из нативного кода без асинхронного “крюка” в виде Flutter. В итоге для этой интеграции реализовали несколько хелперов в нативной части плагина (доступны через FlutterCallkitPlugin iOS класс) и несколько на стороне Flutter (доступны через FCXPlugin Dart класс).


Дополнительные возможности плагина мы объявили в его собственном классе, чтобы отделить интерфейс плагина от интерфейса CallKit.

Как зарепортить входящий звонок напрямую в CallKit

При получении VoIP пуша вызывается один из методов PKPushRegistryDelegate.pushRegistry(_: didReceiveIncomingPushWith:). Здесь необходимо создать экземпляр CXProvider и вызвать reportNewIncomingCall для уведомления CallKit о звонке. Так как для дальнейшей работы со звонком необходим тот же экземпляр провайдера, мы добавили метод FlutterCallkitPlugin.reportNewIncomingCallWithUUID с нативной стороны плагина. При его вызове плагин сам зарепортит звонок в CXProvider, а так же вызовет FCXPlugin.didDisplayIncomingCall хендлер на стороне Dart для продолжения работы со звонком.


func pushRegistry(_ registry: PKPushRegistry,
                  didReceiveIncomingPushWith payload: PKPushPayload,
                  for type: PKPushType,
                  completion: @escaping () -> Void
) {
    // Достаем необходимые данные из пуша
    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"
    )
    
    // Репортим звонок в плагин, а он зарепортит его в CallKit
    FlutterCallkitPlugin.sharedInstance.reportNewIncomingCall(
        with: uuid,
        callUpdate: callUpdate,
        providerConfiguration: configuration,
        pushProcessingCompletion: completion
    )
}


Подводя итог: главной фишкой нашего плагина является то, что его использование на Flutter практически не отличается от использования нативного CallKit на iOS.


One more thing


Но оставалось ещё кое-что в Apple CallKit, что мы не реализовали у себя (и не реализовал никто в доступных сторонних решениях). Это поддержка Call Directory App Extension.



Что такое Call Directory


CallKit умеет блокировать и определять номера, доступ к этим возможностям для разработчиков открыт через специальное системное расширение – Call Directory. Подробнее про iOS app extensions можно почитать в App Extension Programming Guide.



Call Directory app extension позволяет блокировать и/или идентифицировать номера


Если вкратце, то это отдельный таргет iOS проекта, который запускается независимо от основного приложения по требованию системы.


Например, при получении входящего звонка iOS пытается определить или найти звонящего в списке заблокированных стандартными средствами. Если номер не был найден, система может запросить данные у доступных Call Directory расширений, чтобы так или иначе обработать звонок. В этот момент расширение должно эти номера “достать” из некого хранилища номеров. Само приложение может заполнять это хранилище номерами из своих баз в любое время. Таким образом, взаимодействия между расширением и приложением нет, обмен данными происходит через общее хранилище.



Пример архитектуры для реализации Call Directory


Примеры с передачей номеров в Call Directory уже есть на хабре: раз и два.


Подробнее про iOS App Extensions: App Extension Programming Guide.



Call Directory Extension на Flutter


Не так давно нам написал пользователь с запросом на добавление поддержки Call Directory. Начав изучать возможность реализации этой фичи, мы выяснили, что сделать Flutter API без необходимости написания пользователями нативного кода не выйдет. Проблема заключается в том, что, как было сказано выше, Call Directory работает в расширении. Оно запускается системой, работает очень короткое время и не зависит от приложения (и в том числе от Flutter). Таким образом, для поддержки этого функционала пользователю плагина так или иначе потребуется реализовать app extension и хранилище данных самостоятельно.



Пример работы с Call Directory во Flutter приложении



Принятое решение


Несмотря на сложности с нативным кодом, мы твёрдо решили сделать использование Call Directory максимально удобным для пользователей нашего фреймворка.


Проверив возможность работы такого расширения в связке с Flutter приложением, мы принялись за проектирование. Решение должно было сохранить все Call Directory Manager API, а также требовать от пользователя минимум написания нативного кода и быть удобным для взаимодействия через Flutter.


Так мы сделали версию 1.2.0 с поддержкой Call Directory Extension.



Как мы реализовывали Call Directory для Flutter


Итак, для реализации этого функционала требовалось учесть несколько аспектов:


  • Перенести интерфейс класса CXCallDirectoryManager (CallKit объект позволяющий управлять Call Directory)
  • Решить, что делать с app extension и хранилищем номеров для него
  • Создать удобный способ передачи данных из Dart в натив и обратно для управления списками номеров из Flutter приложения


Перенос интерфейсов CXCallDirectoryManager во Flutter


Код, приведенный в статье, был специально упрощен для облегчения восприятия, полную версию кода можно найти по ссылкам в конце статьи. Для реализации плагина мы использовали Objective-C, так как он был выбран основным в проекте ранее. Интерфейсы CallKit представлены на Swift для простоты.


Интерфейс


Первым делом посмотрим, что конкретно требуется перенести:


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
    )
}

Воссоздадим аналог CXCallDirectoryManager.EnabledStatus энама в Dart:


enum FCXCallDirectoryManagerEnabledStatus {
  unknown,
  disabled,
  enabled
}

Теперь можно объявить класс и методы. Необходимости в sharedInstance в нашем интерфейсе нет, так что сделаем обычный Dart класс со static методами:


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

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

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

Сохранение API важно, но так же важно учитывать платформенные и языковые code-style, чтобы использование интерфейса было понятно и удобно для пользователей плагина.


Для API в Dart мы использовали более короткое название без слов-связок (длинное название пришло из objective-C) и заменили completion блок на Future. Future является стандартным механизмом, используемым для получения результата выполнения асинхронных методов в Dart. Мы также возвращаем Future из большинства Dart методов плагина, потому что коммуникация с нативным кодом происходит асинхронно.


Было – getEnabledStatusForExtension(withIdentifier:completionHandler:)


Стало – Future getEnabledStatus(extensionIdentifier)




Реализация


Для коммуникации между Flutter и iOS будем использовать FlutterMethodChannel.


Подробнее про особенности работы этого канала связи можно почитать здесь.



On the Flutter side…


Создадим объект MethodChannel:


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


On the iOS side…


Первым делом iOS класс плагина нужно подписать на протокол FlutterPlugin, чтобы иметь возможность взаимодействовать с Flutter:


@interface FlutterCallkitPlugin : NSObject<FlutterPlugin>

@end

При инициализации плагина создадим FlutterMethodChannel с таким же идентификатором, что мы использовали выше:


+ (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];
}

Теперь можно использовать этот канал для вызова iOS методов из Flutter.



Рассмотрим подробно реализацию методов в Dart и нативной части плагина на примере getEnabledStatus.



On the Flutter side…


Реализация на Dart будет максимально проста и будет заключаться в вызове MethodChannel.invokeMethod с необходимыми аргументами, а также в обработке результата этого вызова.


Про MethodChannel

MethodChannel API позволяет асинхронно получить результат вызова из нативного кода посредством Future, но накладывает ограничения на передаваемые типы данных.




Итак, нам потребуется передать имя метода (его будем использовать в нативном коде для того, чтобы идентифицировать вызов) и аргумент extensionIdentifier в MethodChannel.invokeMethod, а затем преобразовать результат из простейшего типа int в FCXCallDirectoryManagerEnabledStatus. На случай ошибки в нативном коде следует обработать PlatformException.


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

  try {

    // Воспользуемся объектом MethodChannel для вызова
    // соответствующего метода в платформенном коде
    // с аргументом extensionIdentifier.
    int index = await _methodChannel.invokeMethod(
      'Plugin.getEnabledStatus',
      extensionIdentifier,
    );

    // Преобразуем результат в энам 
    // FCXCallDirectoryManagerEnabledStatus
    // и вернем его значение пользователю
    return FCXCallDirectoryManagerEnabledStatus.values[index];

  } on PlatformException catch (e) {

    // Если что-то пошло не так, обернем ошибку в собственный тип 
    // и отдадим пользователю
    throw FCXException(e.code, e.message);
  }
}

Обратите внимание на идентификатор метода который мы использовали:


Plugin.getEnabledStatus


Слово перед точкой используется, для определения модуля ответственного за тот или иной метод.


getEnabledStatus идентично названию метода во Flutter, а не в iOS (или Android).




On the iOS side…


Теперь переместимся в платформенный код и реализуем бэкенд для этого метода.


Вызовы через FlutterMethodChannel попадают в метод handleMethodCall:result:.


С помощью переданного ранее идентификатора можно определить, что за метод был вызван, достать из него аргументы и запустить выполнение логики. Больше пояснений в комментариях к коду:


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

    // Вызовы из Flutter можно идентифицировать по названию,
    // которое передается в `FlutterMethodCall.method` проперти
    if ([@"Plugin.getEnabledStatus" isEqualToString:call.method]) {

        // При передаче аргументов с помощью MethodChannel, 
        // они упаковываются в `FlutterMethodCall.arguments`
        // Извлечем extensionIdentifier, который 
        // мы передали сюда ранее из Flutter кода
        NSString *extensionIdentifier = call.arguments;

        if (isNull(extensionIdentifier)) {
            // Если аргументы не валидны, вернём ошибку через 
            // `result` обработчик
            // Ошибка должна быть упакована в `FlutterError`
            // Она вылетит в виде PlatformException в Dart коде
            result([FlutterError errorInvalidArguments:@"extensionIdentifier must not be null"]);
            return;
	}

        // Теперь, когда метод обнаружен,
        // а аргументы извлечены и провалидированы, 
        // можно реализовать саму логику

        // Для взаимодействия с этой функциональностью CallKit 
	// потребуется экземпляр CallDirectoryManager
        CXCallDirectoryManager *manager 
            = CXCallDirectoryManager.sharedInstance;

        // Вызываем метод CallDirectoryManager
        // с требуемой функциональностью
        // и ожидаем результата
        [manager 
            getEnabledStatusForExtensionWithIdentifier:extensionIdentifier
            completionHandler:^(CXCallDirectoryEnabledStatus status, 
                                           NSError * _Nullable error) {

            // completion с результатом вызова запустился, 
            // можем пробросить результат в Dart
            // предварительно сконвертировав его в подходящие типы, 
            // так как через MethodChannel можно передавать
            // лишь некоторые определенные типы данных.
            if (error) {

                // Ошибки передаются упакованные в `FlutterError`
                result([FlutterError errorFromCallKitError:error]);
            } else {

                // Номера передаются упакованные в `NSNumber`
                // Так как этот энам представлен значениями `NSInteger`, 
                // выполним требуемое преобразование
                result([self convertEnableStatusToNumber:enabledStatus]);
            }
	}];
    }
}


По аналогии реализуем оставшиеся два метода FCXCallDirectoryManager



On the Flutter side…


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

    // Задаем идентификатор, передаем аргумент 
    // и вызываем платформенный метод
    await _methodChannel.invokeMethod(
      'Plugin.reloadExtension',
      extensionIdentifier,
    );
  } on PlatformException catch (e) {
    throw FCXException(e.code, e.message);
  }
}

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

    // А этот метод не принимает аргументов 
    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"]);
    }
}


Готово, CallDirectoryManager реализован и может быть использован.


Подробнее про Platform-Flutter взаимодействие



App Extension и хранилище номеров


Так как из-за нахождения Call Directory в iOS расширении мы не сможем предоставить его реализацию с плагином, а работа с платформенным кодом обычно непривычна для Flutter разработчиков, не знакомых с нативной разработкой, постараемся по максимуму помочь им с помощью… Документации!


Реализуем полноценный пример app extension и хранилища и подключим их к example app нашего плагина.


В качестве простейшего варианта хранилища используем UserDefaults, которые обернем в propertyWrapper.


Примерно так выглядит интерфейс нашего хранилища:


// Доступ к хранилищу из iOS приложения
@UIApplicationMain
final class AppDelegate: FlutterAppDelegate {
    @UserDefault("blockedNumbers", defaultValue: [])
    private var blockedNumbers: [BlockableNumber]

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

// Доступ к хранилищу из 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?
}


Код имплементации хранилища:


UserDefaults


Код iOS приложения:


iOS App Delegate


Код iOS расширения:


iOS App Extension


Обратите внимание, что примеры хранилища и расширения – это не часть плагина, а часть example приложения, идущего в комплекте с ним.


Передача номеров из Flutter в iOS и обратно


Итак, app extension настроен и связан с хранилищем, необходимые методы CallDirectoryManager реализованы, осталась последняя деталь – научиться передавать номера из Flutter в платформенное хранилище или, наоборот, запрашивать номера оттуда.


Наиболее простым вариантом кажется взвалить передачу данных на пользователя плагина, тогда ему придется самостоятельно организовывать MethodChannel или использовать другие сторонние решения по управлению хранилищем. И, безусловно, кому-то это даже подойдет! :) А для остальных сделаем простое и удобное API, чтобы пробрасывать номера прямо через наш фреймворк. Этот функционал будем делать опциональным, чтобы не ограничивать тех, кому удобнее использовать свои способы передачи данных.



Интерфейс


Посмотрим, какие интерфейсы могут понадобиться:


  • Добавление блокируемых/идентифицируемых номеров в хранилище
  • Удаление блокируемых/идентифицируемых номеров из хранилища
  • Запрос блокируемых/идентифицируемых номеров из хранилища


On the Flutter side…


Для методов-хелперов мы ранее решили использовать классы плагина FCXPlugin (Flutter) и FlutterCallkitPlugin (iOS). Однако Call Directory является узкоспециализированным функционалом, который используется далеко не в каждом проекте. Поэтому хотелось вынести это в отдельный файл, но оставить доступ через объект класса FCXPlugin, для этого подойдет extension:


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…


Чтобы со стороны Flutter получить доступ к номерам, которые находятся в неком хранилище на стороне iOS, пользователю плагина нужно будет как-то связать свою базу номеров с плагином. Для этого дадим ему такой интерфейс:



@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


Для каждого типа взаимодействия с хранилищем предусмотрен свой обработчик, который будет вызываться нашим фреймворком каждый раз, когда с Flutter стороны был вызван соответствующий хелпер-метод.


Обработчики опциональны, что позволяет использовать лишь необходимый минимум или вовсе не брать эту функциональность, а воспользоваться собственным решением для передачи номеров.


Реализация


Теперь реализуем связь между объявленными методами-хелперами во Flutter и обработчиками в iOS.


Методов много, а работают они +- одинаково, поэтому будем рассматривать два из них с противоположным направлением движения данных.

Get identifiable numbers



On the Flutter side…


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

    // Вызываем платформенный метод и сохраняем результат
    List<dynamic> numbers = await _methodChannel.invokeMethod(
      'Plugin.getIdentifiablePhoneNumbers',
    );

    // Типизируем результат и возвращаем пользователю
    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) {
        // Проверяем существует-ли обработчик,
        // если нет – возвращаем ошибку
        result([FlutterError errorHandlerIsNotRegistered:@"getIdentifiablePhoneNumbers"]);
        return;
    }

    // Используя обработчик, запрашиваем номера у пользователя
    NSArray<FCXIdentifiablePhoneNumber *> *identifiableNumbers
        = self.getIdentifiablePhoneNumbers();

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

    // Оборачиваем каждый номер в словарь, 
    // чтобы иметь возможность передать их через MethodChannel 
    for (FCXIdentifiablePhoneNumber *identifiableNumber in identifiableNumbers) {
        NSMutableDictionary *dictionary 
            = [NSMutableDictionary dictionary];
        dictionary[@"number"] 
            = [NSNumber numberWithLongLong:identifiableNumber.number];
        dictionary[@"label"] 
            = identifiableNumber.label;
        [phoneNumbers addObject:dictionary];
    }

    // Отправляем номера во Flutter
    result(phoneNumbers);
}


Add identifiable numbers



On the Flutter side…


Future<void> addIdentifiablePhoneNumbers(
  List<FCXIdentifiablePhoneNumber> numbers,
) async {
  try {
    // Готовим номера для передачи через MethodChannel
    List<Map> arguments = numbers.map((f) => f._toMap()).toList();

    // Отправляем номера в нативный код
    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) {
        // Проверяем существует-ли обработчик,
        // если нет – возвращаем ошибку
        result([FlutterError errorHandlerIsNotRegistered:@"didAddIdentifiablePhoneNumbers"]);
        return;
    }

    // Достаем переданные в аргументах номера
    NSArray<NSDictionary *> *numbers = call.arguments;
    if (isNull(numbers)) {
        // Проверяем их валидность
        result([FlutterError errorInvalidArguments:@"numbers must not be null"]);
        return;
    }

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

    // Типизируем номера
    for (NSDictionary *obj in numbers) {
        NSNumber *number = obj[@"number"];
        __auto_type identifiableNumber
            = [[FCXIdentifiablePhoneNumber alloc] initWithNumber:number.longLongValue
                                                                                     label:obj[@"label"]];
        [identifiableNumbers addObject:identifiableNumber];
    }

    // Отдаём типизированные номера в обработчик пользователю
    self.didAddIdentifiablePhoneNumbers(identifiableNumbers);

    // Сообщаем во Flutter о завершении операции
    result(nil);
}


Остальные методы реализуются по аналогии, полный код:




Примеры использования


Теперь переместимся на сторону пользователя получившегося плагина и посмотрим, как он может воспользоваться нашими интерфейсами.



Reload extension


Метод reloadExtension(withIdentifier:completionHandler:) используется для перезагрузки расширения Call Directory. Это может потребоваться, например, после добавления новых номеров в хранилище, чтобы они попали в CallKit.


Использование идентично нативному CallKit API: обращаемся к FCXCallDirectoryManager и запрашиваем перезагрузку по заданному extensionIdentifier:


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

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


Get identified numbers



On the Flutter side…


Запрашиваем список идентифицируемых номеров через класс плагина из Flutter:


final FCXPlugin _plugin = FCXPlugin();

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


On the iOS side…


Добавляем обработчик getIdentifiablePhoneNumbers, который плагин использует для передачи заданных номеров во Flutter. Будем передавать в него номера из нашего хранилища identifiedNumbers:


private let callKitPlugin = FlutterCallkitPlugin.sharedInstance

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

// Добавляем обработчик событий запроса номеров
callKitPlugin.getIdentifiablePhoneNumbers = { [weak self] in
    guard let self = self else { return [] }

    // Возвращаем номера из хранилища в обработчик
    return self.identifiedNumbers.map {
        FCXIdentifiablePhoneNumber(number: $0.number, label: $0.label)
    }
}


Теперь номера из пользовательского хранилища будут попадать в обработчик, а из него через плагин во Flutter.



Add identified numbers



On the Flutter side…


Передаем номера, которые хотим идентифицировать, в объект плагина:


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…


Добавляем обработчик didAddIdentifiablePhoneNumbers, который плагин использует для уведомления платформенного кода о получении новых номеров из Flutter. В обработчике сохраняем полученные номера в хранилище номеров:


private let callKitPlugin = FlutterCallkitPlugin.sharedInstance

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

// Добавляем обработчик событий добавления номеров
callKitPlugin.didAddIdentifiablePhoneNumbers = { [weak self] numbers in
    guard let self = self else { return }

    // Сохраняем в хранилище номера, переданные плагином в обработчик
    self.identifiedNumbers.append(
        contentsOf: numbers.map {
            IdentifiableNumber(identifiableNumber: $0)
        }
    )

    // Номера в Call Directory обязательно должны быть отсортированы
    self.identifiedNumbers.sort()
}


Теперь номера из Flutter будут попадать в плагин, из него – в обработчик события, а оттуда – в пользовательское хранилище номеров. При следующей перезагрузке Call Directory расширения они станут доступны CallKit для идентификации звонков.


Полные примеры:




Итог


У нас получилось дать возможность использовать CallKit Call Directory из Flutter!


Детали платформенных коммуникаций по прежнему скрыты в недрах плагина, нативное API сохранено, а необходимая к написанию пользовательская iOS реализация хорошо задокументирована.


Теперь во Flutter можно относительно просто блокировать и/или определять номера с помощью нативного Call Directory.



Пример работы с Call Directory в Flutter приложении с использованием flutter_callkit_voximplant



Результаты:


  • Интерфейс CallDirectoryManager полностью перенесен
  • Добавлен простой способ передачи номеров из Flutter кода в iOS, оставлена возможность использовать собственные решения передачи данных
  • Архитектура решения описана в README с визуальными схемами для лучшего понимания
  • Добавлен полноценный работоспособный example app, использующий всю функциональность Call Directory, реализующий пример платформенных модулей (таких как iOS расширение и хранилище данных)


Полезные ссылки


Source код flutter_callkit на GitHub


Example app код на GitHub


Полная документация по использованию Call Directory с flutter_callkit


CallKit Framework Documentation by Apple


App Extension Programming Guide by Apple


Writing custom platform-specific code by Flutter

Комментарии (4)