Все «радости» CallKit или как мы делали определитель номера на iOS 10
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.
Эти ребята расскажут лучше:
- Документация;
- Видео с 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 игнорирует с октября, а второй находится в ооочень длинной очереди.
Так что в ближайшее время мы вряд ли сможем сделать продукт лучше для пользователя.
Как всё это в итоге работает:
- Пользователь качает город/города;
- Из города достаётся база номеров в нашем формате;
- Смотрим все базы, которые установлены у пользователя (мы храним их в общем 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 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. Их мы тоже можем доставить отдельным пакетом всем желающим.
В целом хотелось чего-то более стабильного и более дружественного к пользователю, чтобы даже моя мама сама смогла его включить. В любом случае, как минимум 20 000 пользователей включили экстеншен, а это реальная польза и ощущение, что всё было не зря.