Все «радости» CallKit или как мы делали определитель номера на iOS 10

d423c35d74e4460ca937c4f7d7591734.jpg

2ГИС давно хотел поделиться с пользователями айфонов своими знаниями о телефонных номерах компаний из справочника. Android-платформа давала такую возможность, а вот под iOS подходящего инструмента долго не было.

В июне мы ездили на WWDC 2016, и на одной из сессий ребята из Apple обмолвились, что наконец-то можно делать «gorgeous astonishment» — определитель номеров под iOS 10. Радости нашей не было предела, но до поры до времени: как Apple любит, фичу она предоставила с рядом ограничений.

Прототип


Первая «радость», с которой мы столкнулись — «богатая» документация, а именно:
→ CXCallDirectoryExtensionContext
    @interface CXCallDirectoryExtensionContext : NSExtensionContext
    @property (nonatomic, weak, nullable) id delegate;
    - (void)addBlockingEntryWithNextSequentialPhoneNumber:(CXCallDirectoryPhoneNumber)phoneNumber;
    - (void)addIdentificationEntryWithNextSequentialPhoneNumber:(CXCallDirectoryPhoneNumber)phoneNumber label:(NSString *)label;
    - (void)completeRequestWithCompletionHandler:(nullable void (^)(BOOL expired))completion;
    @end

→ CXCallDirectoryManager
    @interface CXCallDirectoryManager : NSObject
    @property (readonly, class) CXCallDirectoryManager *sharedInstance;
    - (void)reloadExtensionWithIdentifier:(NSString *)identifier completionHandler:(nullable void (^)(NSError *_Nullable error))completion;
    - (void)getEnabledStatusForExtensionWithIdentifier:(NSString *)identifier completionHandler:(void (^)(CXCallDirectoryEnabledStatus enabledStatus, NSError *_Nullable error))completion;
    @end

И всё. Ну что ж, могло быть хуже.

Из этого видим, что dialer под iOS — это расширение приложения, которое крутится отдельным процессом, его можно перегрузить и получить его статус. Похоже на то, что нам нужно.
В самом же экстеншне можно добавить номера в виде «телефон/имя» и добавить номера для блокировки.

Первый прототип был готов за 30 минут. Один личный телефон, зашитый в экстеншн, один тестовый телефон добавлен в блокировку, всё завелось с первого раза, радости не было предела. Будущее выглядело крайне радужным — мы уже представляли, как всё это попадёт в ближайший релиз на следующий день.

Пока не столкнулись со второй «радостью»: мы не можем включить dialer из основного приложения. Нужно отправить пользователя глубоко в настройки, что явно не идёт на повышение конверсии этой фичи.

Потом начали добавлять пачку номеров и выяснилась третья «радость»: все номера нужно записать в базу до того, как они будут определены (это как раз знаменитая безопасность Apple — чтобы мы не получали доступ к входящему callerID). А наша база — это около 4 000 000 номеров с подписью. То есть 140 Мб текстовой информации, или 40 Мб, если пожать по самой жести, и всё это нужно каким-то образом доставить в расширение.

Вооружившись этим знанием, мы приготовили данные в виде «телефон/имя» и начали пилить уже более реальный прототип.

База данных


Сначала решили тупо добавить все номера, и вновь неожиданность — номера должны быть добавлены не абы как, а в порядке возрастания: 01, 02, 911 и т.д. В противном случае экстеншн падает. В первой бете 8 xcode экстеншен падал вообще без ошибок.

Далее выяснилось, что мы ограничены 1 999 999 номерами. Да, именно 1 999 999, а не 2 000 000, что тоже не совсем равняется нашим 4 000 000 номеров. Хотели сначала сделать три расширения, наполниться каждое до 1 999 999 номеров и в ус не дуть. Потом решили разделить по регионам: Москва + Питер, остальная Россия, зарубежка. Но от этого решения отказались, потому что нужно было придумать более сложную доставку и делать фичу еще менее стабильной, и работа нескольких одновременно работающих расширений тоже не была стабильной. Да и заставлять пользователя включать все три расширения тоже не хотелось. В итоге решили оставить только номера установленных у пользователя городов.

Поначалу хотели доставлять данные через SQLite. Собрали простую базу в 100 000 номеров из Новосибирска, написали логику работы с базой, запустили демопроект, и… ничего. Ошибок нет, всё ок, а номера не определяются.

Покопав это дело, выяснили, что при попытке вытащить данные из SQLite в ascending order база создаёт кеш на 30 Мб и экстеншн падает по памяти. Покопав форумы Apple, поняли, что лучше не вылезать за 5 Мб оперативной памяти. В итоге при объединённой базе для Москвы, Питера и ещё пары городов нужно будет сильно усложнять запросы к базе, строить хорошо оптимизированные по памяти и скорости фетчи, и усложнять процесс тестирования. Делать все это было совсем некогда, неохота, к тому же моих компетенций в околобазаданных технологий явно не хватало.

Запилили свой тупой, как бревно, формат данных в виде битовой последовательности:

[uint16_t: Размер блока][unsigned long long int: Phone][String: Name]

и очень простой парсер без заморочек:
    @interface DGSPhonesDataReader : NSObject
    /**
     Текущее значение телефона, пока не позван next, будет 0
     */
    @property (nonatomic, assign, readonly) unsigned long long int phone;
    /**
     Текущее значение имени, пока не позван next, будет nil
     */
    @property (nonatomic, copy, readonly, nullable) NSString *name;
    
    - (instancetype)initWithFilePath:(NSString *)path;
    - (BOOL)next;
    
    @end

    #import "DGSPhonesDataReader.h"
    
    @interface DGSPhonesDataReader ()
    @property (nonatomic, strong, readonly) NSData *data;
    @property (nonatomic, assign) NSUInteger location;
    @property (nonatomic, assign, readwrite) unsigned long long int phone;
    @property (nonatomic, copy, readwrite, nullable) NSString *name;
    @end
    
    @implementation DGSPhonesDataReader
    
    - (instancetype)initWithFilePath:(NSString *)path
    {
            self = [super init];
            if (self == nil) return nil;
    
            NSError *error = nil;
            _data = [NSData dataWithContentsOfFile:path options:NSDataReadingMappedIfSafe error:&error];
            _location = 0;
            if (_data == nil)
            {
                    NSLog(@"DGSPhonesDataReader data create error: %@", error);
            }
            return self;
    }
    
    - (BOOL)next
    {
            uint16_t blockLength;
            [self.data getBytes:&blockLength range:NSMakeRange(self.location, sizeof(blockLength))];
            self.location += sizeof(blockLength);
    
            unsigned long long int phone;
            NSUInteger textLength = blockLength - sizeof(phone);
            [self.data getBytes:&phone range:NSMakeRange(self.location, sizeof(phone))];
            self.phone = phone;
            self.location += sizeof(phone);
    
            uint8_t buffer[textLength];
            [self.data getBytes:buffer range:NSMakeRange(self.location, textLength)];
            self.name = [[NSString alloc] initWithBytes:buffer length:textLength encoding:NSUTF8StringEncoding];
            self.location += textLength;
    
            return self.location < self.data.length;
    }
    @end


Да, по идее нужно использовать кеш, читать блоком по 8 Кб и всякие такие дела. Но такой алгоритм пробегает по базе в 2 000 000 номеров за 10 секунд в отдельном системном процессе, не затрагивая никак основное приложение, притом происходит это один раз за обновление, поэтому решили сильно не заморачиваться с оптимизацией.

Ура! Теперь мы умеем безопасно парсить номера телефонов из базы, спокойно укладываясь в лимит 5 Мб памяти. Но время идёт, а фича всё ещё не готова.

Доставка данных


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

Оказалось, что за нас уже всё придумали и есть замечательная штука App Groups, которая позволяет шарить данные между двумя приложениями от одного разработчика.

Можно положить в основном приложении файл по пути:

    + (NSString *)extensionDataPath
    {
            return [[[NSFileManager defaultManager] containerURLForSecurityApplicationGroupIdentifier:[self extensionGroupName]].path stringByAppendingPathComponent:@"Dialer"];
    }

, а в экстеншне достать его через:
    NSString *databasePath = [[DGSCallKitExtensionModel extensionDataPath] stringByAppendingPathComponent:manifest.databaseName];

Хоть проблем с доставкой не было никаких, и на том спасибо.

Дальше мы приготовили данные в нужном формате. Если не сильно углубляться, 500 Мб файл в формате .tsv нужно раскидать по 108 регионам, перегнать в бинарный формат, заархивировать и создать джобу на дженкинсе, чтобы не делать всё это руками и иметь готовую портянку данных для каждого релиза без особой боли. Короче, на это мы тоже потратили прилично времени — около 90% от всей разработки.

Встала задача доставить эти данные в телефон (вторые 90% разработки).

Сначала решили использовать технологию «On demand resources», а заодно и узнать, зачем нужна третья, вечно пустая вкладка в xcode — Resource Tags.

d54e6c853cc744e282edfc6ad5a93b2e.png

Эти ребята расскажут лучше:

  • Документация;
  • Видео с WWDC 2015;
  • Видео с WWDC 2016;

Если коротко, Resource Tags для нас — это просто манна небесная (а именно Download Only On Demand). Она позволяет пометить некоторые ресурсы приложения тэгами, указать их тип, и при заливке приложения в стор он не будет включать их в бинарь. Потом их можно докачать при помощи NSBundleResourceRequest и получить через [NSBundle mainBundle]. То есть вообще не нужно пинать другие команды, придумывать, как их хранить и как доставлять до пользователя. А Apple сам хранит все данные + предоставляет очень адекватное API для их получения. Что сулило быструю интеграцию хотя бы здесь.

Но не всё оказалось так радужно: в первом релизе эта технология показала себя крайне паршиво, и примерно 20% пользователей тупо не смогли ничего скачать. Покопав форумы Apple, выяснили, что не у нас одних такая проблема, а они очень давно её не чинят и никак на неё не реагируют.

Resource Tags пришлось выпилить и доставлять данные другим способом. В итоге вшили данные в базу обновления городов. Теперь вместе с обновлением города пользователи получают новые базы номеров.

Всё впереди


Худо-бедно dialer попал в AppStore, и тут нас ждала четвёртая «радость».

После успешной установки мы удаляли базы, так как зачем хранить то, что уже и так находится в памяти телефона. Оказалось, не всё так просто: если пользователь зайдёт в настройки, выключит и включит экстеншн, то вместо того, чтобы просто включиться, экстеншн идёт по полному сценарию обновления. My bad, мы это не учли, и все, кто так делал, теряли базы без возможности их обновления. В следующей версии мы это оперативно поправили и теперь оставляем данные в телефоне, пока они ещё актуальны.

Мы постоянно получаем жалобы, что определитель не работает, или вопросы, как его включить. Пока, как промежуточный вариант, сделали отдельный пункт про определитель в настройках 2ГИС.

С iOS 10.3 Apple подкинула ещё проблем: если обновиться до этой версии, то определитель пропадает в настройках до тех пор, пока пользователь либо не переустановит приложение, либо не накатит обновление. Экстеншн в целом ведёт себя нестабильно. Периодически (по непонятным причинам и законам) он выключается или вовсе пропадает из настроек при обновлении. Иногда, в процессе обновления номеров, система молча прибивает экстеншен с кодами ошибок:
→ CXErrorCodeCallDirectoryManagerErrorLoadingInterrupted;
→ CXErrorCodeCallDirectoryManagerErrorUnknown.

Ещё в октябре мы создали пару радаров в Apple с просьбой дать нам ручку, чтобы позволить пользователям включить dialer из самого приложения, и по поводу баги с 10.3. Первый тикет Apple игнорирует с октября, а второй находится в ооочень длинной очереди.

05c02cde813943b293b1f5f5a0937c06.png

Так что в ближайшее время мы вряд ли сможем сделать продукт лучше для пользователя.

Как всё это в итоге работает:

  1. Пользователь качает город/города;
  2. Из города достаётся база номеров в нашем формате;
  3. Смотрим все базы, которые установлены у пользователя (мы храним их в общем UserDefaults между экстеншном и основным приложением);
  4. У каждой базы есть хэш. Если хоть один хэш не совпал или появился новый, мы записываем все новые базы в общее хранилище и помечаем их как готовые к установке. Это нужно на случай, если пользователь не активировал экстеншн, а свернул приложение и включит его потом;
  5. Если экстеншн активен, перезагрузим его через:
      [[CXCallDirectoryManager sharedInstance] reloadExtensionWithIdentifier:bundleID completionHandler:^(NSError * _Nullable error) {}];
    
  6. В самом экстеншне, когда он получает:
        - (void)beginRequestWithExtensionContext:(CXCallDirectoryExtensionContext *)context
    
    мы смотрим, есть ли базы, готовые к установке. Если есть, мы пробегаем через все и добавляем номера через:
        [context addIdentificationEntryWithNextSequentialPhoneNumber:phone label:name];
    
  7. Помечаем базы как установленные;
  8. Повторяем процесс для каждого обновления;

В коде это выглядит примерно так:
    - (RACSignal *)reloadExtensionsIfNeeded
    {
            @weakify(self);
    
            if (![DGSCallKitFetchModel isExtensionAvailable] || self.manifests.count == 0) return [RACSignal empty];
    
            return [[[[[[[[[self fetchCanBeInstalledExtensionsRegionCodes]
                    filter:^BOOL(NSSet *regionCodes) {
                            return regionCodes.count > 0;
                    }]
                    deliverOn:[RACScheduler scheduler]]
                    flattenMap:^RACStream *(NSSet *regionCodes) {
                            @strongify(self);
    
                            return [RACSignal combineLatest:@[
                                    [self downloadDatabasesWithRegionCodesIfNeeded:regionCodes],
                                    [DGSCallKitFetchModel fetchExtensionEnabled]
                            ]];
                    }]
                    flattenMap:^RACStream *(RACTuple *t) {
                            @strongify(self);
    
                            RACTupleUnpack(NSSet *regionCodes, NSNumber *extensionEnabled) = t;
    
                            // Если дайлер не включен, то ничего не делаем
                            if (!extensionEnabled.boolValue) return [RACSignal empty];
    
                            // Если есть готовые базы, но они еще не установлены,
                            // то попробуем их установить в случае если пользователь разрешил дайлер в настройках,
                            // В остальных случаях не перезагружаем дайлер
                            if ([self shouldInstallDatabasesWithRegionCodes:regionCodes])
                            {
                                    return [RACSignal return:regionCodes];
                            }
                            else if ([self dialerEnabledWithRegionCodes:regionCodes])
                            {
                                    [self trackDialerInstalledEventWithRegionCodes:regionCodes];
                            }
                            return [RACSignal empty];
                    }]
                    flattenMap:^RACStream *(NSSet *regionCodes) {
                            @strongify(self);
    
                            return [self updateExtensionWithRegionCodes:regionCodes];
                    }]
                    doNext:^(NSSet *regionCodes) {
                            @strongify(self);
    
                            ULogInfo(@"Dialer extension installed with region codes: %@", regionCodes);
                            [self trackDialerInstalledEventWithRegionCodes:regionCodes];
                    }]
                    doError:^(NSError *error) {
                            @strongify(self);
    
                            ULogError(@"Dialer extension error: %@", error);
                            [self.analyticsSender trackEventWithCategory:kDGSCategoryDialer
                                                                                                      action:kDGSActionDialerFailed
                                                                                                       label:error.localizedDescription
                                                                                                       value:nil];
                    }]
                    doCompleted:^{
                            ULogInfo(@"Dialer extension reload completed signal");
                    }];
    }
    
    + (RACSignal *)fetchExtensionEnabled
    {
            NSString *bundleID = [DGSCallKitExtensionModel extensionBundleID];
            return [RACSignal createSignal:^RACDisposable *(id subscriber) {
                    [[CXCallDirectoryManager sharedInstance] getEnabledStatusForExtensionWithIdentifier:bundleID completionHandler:^(CXCallDirectoryEnabledStatus enabledStatus, NSError * _Nullable error) {
                            if (enabledStatus == CXCallDirectoryEnabledStatusEnabled)
                            {
                                    [subscriber sendNext:@YES];
                            }
                            else
                            {
                                    [subscriber sendNext:@NO];
                            }
                            [subscriber sendCompleted];
                    }];
                    return nil;
            }];
    }
    
    - (RACSignal *)updateExtensionWithRegionCodes:(NSSet *)regionCodes
    {
            ULogInfo(@"Reload dialer extension with tag: %@", regionCodes);
    
            NSString *bundleID = [DGSCallKitExtensionModel extensionBundleID];
            return [RACSignal createSignal:^RACDisposable *(id subscriber) {
                    [[CXCallDirectoryManager sharedInstance] reloadExtensionWithIdentifier:bundleID completionHandler:^(NSError * _Nullable error) {
                            if (error)
                            {
                                    [subscriber sendError:error];
                            }
                            else
                            {
                                    [subscriber sendNext:regionCodes];
                                    [subscriber sendCompleted];
                            }
                    }];
                    return nil;
            }];
    }


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

Если нет данных в готовом формате и их нужно доставлять и обновлять хитрым образом, тогда на интеграцию этой фичи уйдёт уйма времени, а из-за сложности включения этой фичи пользователи, е сожалению, не скажут вам «большое спасибо». В большинстве отзывов будет что-то вроде «у меня не работает», «я скачал приложение, а ничего не определяет» и всё в таком духе.

Вместо заключения


На данный момент фича завершена, в ближайшее время планов по её доработке нет. Но всё ещё хочется сделать выборку по самым определяемым номерам — где-то в районе 100 000 номеров — и зашить их сразу в экстеншн, чтобы пользователи сразу получили минимальный функционал без необходимости скачивать регионы. Ещё у нас есть довольно много данных о «токсичных» номерах: коллекторские агентства, различного рода опросы, разные финансовые пирамиды и другие неугодные номера, на которые пожаловались пользователи Dialer на Android. Их мы тоже можем доставить отдельным пакетом всем желающим.

978b0ec8d677493293c899e3e0f857cd.jpg

В целом хотелось чего-то более стабильного и более дружественного к пользователю, чтобы даже моя мама сама смогла его включить. В любом случае, как минимум 20 000 пользователей включили экстеншен, а это реальная польза и ощущение, что всё было не зря.

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

© Habrahabr.ru