
2ГИС давно хотел поделиться с пользователями айфонов своими знаниями о телефонных номерах компаний из справочника. Android-платформа давала такую возможность, а вот под iOS подходящего инструмента долго не было.
В июне мы ездили на WWDC 2016, и на одной из сессий ребята из Apple обмолвились, что наконец-то можно делать «gorgeous astonishment» — определитель номеров под iOS 10. Радости нашей не было предела, но до поры до времени: как Apple любит, фичу она предоставила с рядом ограничений.
Прототип
Первая «радость», с которой мы столкнулись — «богатая» документация, а именно:
→ CXCallDirectoryExtensionContext
@interface CXCallDirectoryExtensionContext : NSExtensionContext @property (nonatomic, weak, nullable) id<CXCallDirectoryExtensionContextDelegate> 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.

Эти ребята расскажут лучше:
Если коротко, 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 игнорирует с октября, а второй находится в ооочень длинной очереди.

Так что в ближайшее время мы вряд ли сможем сделать продукт лучше для пользователя.
Как всё это в итоге работает:
- Пользователь качает город/города;
- Из города достаётся база номеров в нашем формате;
- Смотрим все базы, которые установлены у пользователя (мы храним их в общем UserDefaults между экстеншном и основным приложением);
- У каждой базы есть хэш. Если хоть один хэш не совпал или появился новый, мы записываем все новые базы в общее хранилище и помечаем их как готовые к установке. Это нужно на случай, если пользователь не активировал экстеншн, а свернул приложение и включит его потом;
- Если экстеншн активен, перезагрузим его через:
[[CXCallDirectoryManager sharedInstance] reloadExtensionWithIdentifier:bundleID completionHandler:^(NSError * _Nullable error) {}];
- В самом экстеншне, когда он получает:
- (void)beginRequestWithExtensionContext:(CXCallDirectoryExtensionContext *)context
мы смотрим, есть ли базы, готовые к установке. Если есть, мы пробегаем через все и добавляем номера через:
[context addIdentificationEntryWithNextSequentialPhoneNumber:phone label:name];
- Помечаем базы как установленные;
- Повторяем процесс для каждого обновления;
В коде это выглядит примерно так:
- (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<RACSubscriber> 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<NSString *> *)regionCodes { ULogInfo(@"Reload dialer extension with tag: %@", regionCodes); NSString *bundleID = [DGSCallKitExtensionModel extensionBundleID]; return [RACSignal createSignal:^RACDisposable *(id<RACSubscriber> 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. Их мы тоже можем доставить отдельным пакетом всем желающим.

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