Привет, Хабр!
При разработке приложения мы столкнулись с проблемой правильной обработки Push (т.н. пушей) уведомлений на стороне клиента. Перед разбором логики и тонкостей настройки пуш-уведомлений предлагаю определиться, в каких случаях возникает необходимость в изменении пушей?
Типичные кейсы для применения изменяемых пушей:
Аналитика получения пуш-нотификации;
Подгрузка картинки в пуш;
Кастомное изменение счетчика;
Локализации текстов;
Другие изменения тела пуша/тайтлов.
Теперь, разобравшись, для чего могут понадобиться навыки обработки пуш-уведомлений, мы можем перейти к более к детальному разбору данной технологии. И начнем с механизма Push notification extension.
Ниже расскажу про метод его создания и подключения, сертификаты и возможности для пушей, приведу подробные примеры кода с пояснениями. Также поделюсь некоторыми тонкостями. Например, про логирование информации о получении пуша с применением опции keychain sharing, загрузку картинки в пуш-уведомление и изменение счетчика пушей.
Push Notification Extension
В iOS для обработки пуш-уведомлений существует отдельный механизм под названием Push Notificaions Extenstion. Сам по себе extension является отдельным процессом, который упаковывается в ту же самую ipa, что и основное приложение, и устанавливается на устройство вместе с приложением. Жизненный цикл Push Notification Extension не зависит от жизненного цикла самого приложения. А в случае получения пуш-нотификации от приложения, сама операционная система вызывает его, передавая данные пуш-нотификации, и позволяет эту пуш-нотификацию изменять. Она вызывается, только если в пуше есть поле mutable-content: 1
.
Создание экстеншена потребует написание нативного кода, и может одинаково использоваться как в React Native, так и в нативных приложениях, поэтому большая часть листингов будет именно на objC/swift.
Необходимо помнить, что при получении пуша экстеншену выделяется ограниченное вычислительное время, соответственно, проводить тяжелые и большие операции тут не представляется возможным. К тому же, если вычислительное время закончилось, а данные для обработки не были получены, то приходится обрабатывать ситуацию. В таких случаях можно показать, например, частично подгруженные данные, или оставить оригинальное тело пуша как фоллбек.
Подключение и создание notification extension
В нашем xcode проекте создаем File -> New -> Notification Service Extension
. По аналогии с обычным приложением указываем product name
(для конечного пользователя оно нигде не будет отображаться), язык, команду, в которую его создаем (должна быть одинаковой с командой, чьими сертификатами подписывается основное приложение), и самое главное, на что следует обратить внимание — поле Embedded in Application
— тут всегда указываем родительское приложение.
Сертификаты и Capabilities приложения – следующие шаги для настройки
Сертификаты
Экстеншен имеет отдельный bundle identifier, то есть для него создается свой application id в developer.apple.com. Поэтому необходимо иметь отдельные провижн профайлы для dev, adhoc, release. Для экстеншн также создается отдельный info.plist, и entitlements-файлы.
Capabilities
При переходе на этот шаг надо создать Appgroup, а затем в основном приложении и экстеншене включить для них общую группу. Это поможет передавать данные между ними. Если вы используете безопасное хранилище keychain, например для хранения логина/пароля, и вам необходимо при получении пуш-уведомлений делать запросы с авторизацией (POST запрос для логирования информации о пуше, или загрузку картинки), то необходимо включить опцию keychain sharing
— это поможет получить доступ к кейчейну из обоих мест.
Теперь можно непосредственно приступить к написанию кода:
- (void)didReceiveNotificationRequest:(UNNotificationRequest *)request withContentHandler:(void (^)(UNNotificationContent * _Nonnull))contentHandler {- (void)serviceExtensionTimeWillExpire {
Сам экстеншн состоит из двух функций. Первая используется непосредственно для модификации тела пуша.
Создаем копию содержимого пуша (self.bestAttemptContent = [request.content mutableCopy];)
, изменяем ее как необходимо, и после изменений вызываем self.contentHandler
с этим аргументом, что вызовет отображение пуш нотификации.
Приведу ниже примеры использования:
Простые — модификация тайта, счетчика и аналогичных полей.
self.bestAttemptContent.badge = @2;
self.bestAttemptContent.title = @"My modified title";
self.contentHandler(self.bestAttemptContent);
Аналогично этому примеру можно изменять и остальные поля.
Для чего это нужно?
Таким образом можно подставлять нужные локализационные тексты, для этого в теле пуша у Apple предусмотрено поле text-loc. Оно используется для хранения ключа локализации. Затем получать по ключу нужный текст в зависимости от локали девайса и выставлять его в тайтл/сабтайтл.
Еще один пример использования — логирование информации о получении пуша (например, для целей аналитики).
Предположим, что логирование — это персонализированный запрос, который надо сделать от определенного юзера/токена. Токен храниться в shared keychain (его создание я описал выше в статье), а считать токен можно как в примере листинга ниже.
- (NSString *)updateToken {
NSString *authenticationPrompt = @"Authenticate to retrieve secret";
NSDictionary *query = @{
(__bridge NSString *)kSecClass: (__bridge id)(kSecClassGenericPassword),
(__bridge NSString *)kSecAttrService: service,
(__bridge NSString *)kSecReturnAttributes: (__bridge id)kCFBooleanTrue,
(__bridge NSString *)kSecReturnData: (__bridge id)kCFBooleanTrue,
(__bridge NSString *)kSecMatchLimit: (__bridge NSString *)kSecMatchLimitOne,
(__bridge NSString *)kSecUseOperationPrompt: authenticationPrompt
};
// Look up service in the keychain
NSDictionary *found = nil;
CFTypeRef foundTypeRef = NULL;
OSStatus osStatus = SecItemCopyMatching((__bridge CFDictionaryRef) query, (CFTypeRef*)&foundTypeRef);
if (osStatus == noErr) {
found = (__bridge NSDictionary*)(foundTypeRef);
if (found) {
NSString* token = [[NSString alloc] initWithData:[found objectForKey:(__bridge id)(kSecValueData)] encoding:NSUTF8StringEncoding];
return token;
}
Return null;
}
}
Дальше можно использовать как библиотеки для создания API запросов, так и написать запрос с помощью стандартного URLRrequest:
NSMutableURLRequest *request = [[NSMutableURLRequest alloc] initWithURL:[NSURL URLWithString:[NSString stringWithFormat:@"https://example/logs/push-notification-received"]]];
NSDictionary *body = @{@"notification-payload":payload};
NSData *data = [NSJSONSerialization dataWithJSONObject:body options:kNilOptions error:nil];
[request setHTTPMethod:@"POST"];
[request setValue:@"application/json" forHTTPHeaderField:@"Content-Type"];
[request setValue:[NSString stringWithFormat:@"Token token=\"%@\"",token] forHTTPHeaderField:@"Authorization"];
[request setHTTPBody:data];
[[[NSURLSession sharedSession] dataTaskWithRequest:request] resume];
Следующая типичная задача — загрузка картинки в пуш нотификацию.
Отображение картинок бывает в двух видах: на заблокированном экране в списке нотификаций, полноэкранно развернуто. Размер может отличаться, поэтому необходим баланс между качеством и скоростью загрузки.
Пример загрузки картинки:
NSString *urlString = [request.content userInfo][@"image"];
if (!urlString) {
self.contentHandler(self.bestAttemptContent);
return;
}
NSURL *fileUrl = [NSURL URLWithString:[appApiUrl stringByAppendingString:urlString]];
NSURLSessionDownloadTask *task = [[NSURLSession sharedSession] downloadTaskWithURL:fileUrl completionHandler:^(NSURL * _Nullable location, NSURLResponse * _Nullable response, NSError * _Nullable error) {
if (response && location) {
NSString *fileType = [self determineType:response.MIMEType];
NSString *fileName = [location.lastPathComponent stringByAppendingString:fileType];
NSString *tmpDirectory = NSTemporaryDirectory();
NSString *tmpFile = [[@"file://" stringByAppendingString:tmpDirectory] stringByAppendingString:fileName];
NSURL *tempUrl = [NSURL URLWithString:tmpFile];
if (tempUrl) {
[[NSFileManager defaultManager] moveItemAtURL:location toURL:tempUrl error:&error];
if (error == nil) {
UNNotificationAttachment *attachment = [UNNotificationAttachment attachmentWithIdentifier:attachmentIdentifier URL:tempUrl options:NULL error:NULL];
if (attachment) {
self.bestAttemptContent.attachments = @[attachment];
}
}
}
}
self.contentHandler(self.bestAttemptContent);
}];
[task resume];
- (NSString *)determineType:(NSString *)contentType {
if ([contentType isEqualToString:@"image/jpeg"]) {
return @".jpg";
}
if ([contentType isEqualToString:@"image/gif"]) {
return @".gif";
}
if ([contentType isEqualToString:@"image/png"]) {
return @".png";
}
return @".tmp";
}
Также, мы можем указать на кастомный экран запуска приложения, если приложение запускается из этого пуша. Для этого есть поле launchImageName.
Иногда возникает необходимость получить доступ к данным, которые сохраняются самим приложением. Для этого можно использовать UserDefaults с кастомным suite, если приложения объединены в одну группу (App group).
Запись в UserDefaults:
NSDictionary *userInfo = [request.content userInfo];
NSNumber *customData = userInfo[@customData"];
NSUserDefaults *sharedDefaults = [[NSUserDefaults alloc] initWithSuiteName:@"group.com.ios.example"];
[sharedDefaults setObject: customData forKey:@”customDataKey”];
[sharedDefaults synchronize];
Считывание из UserDefaults:
NSObject* objectForKey = [sharedDefaults objectForKey:@”customDataKey”];
Существуют различные вспомогательные функции для хранения соответствующих типов данных:
- (void)setInteger:(NSInteger)value forKey:(NSString *)defaultName;
- (void)setFloat:(float)value forKey:(NSString *)defaultName;
- (void)setDouble:(double)value forKey:(NSString *)defaultName;
- (void)setBool:(BOOL)value forKey:(NSString *)defaultName;
- (void)setURL:(nullable NSURL *)url forKey:(NSString *)defaultName API_AVAILABLE(macos(10.6), ios(4.0), watchos(2.0), tvos(9.0));
И на считывание:
- (NSInteger)integerForKey:(NSString *)defaultName;
- (float)floatForKey:(NSString *)defaultName;
- (double)doubleForKey:(NSString *)defaultName;
- (nullable NSData *)dataForKey:(NSString *)defaultName;
- (nullable NSString *)stringForKey:(NSString *)defaultName;
- (nullable NSArray *)arrayForKey:(NSString *)defaultName;
- (nullable id)objectForKey:(NSString *)defaultName;
Обратите внимание, что для считывания добавилась возможность получить строку, массив, данные (date) по ключу. Это просто обертка над objectForKey, с дополнительным приведением к указанному типу. Сохраняем эти значения (массив, строку и.т.д.) через обычный setObject.
В рамках js окружения, в react-native приложениях работу с UserDefaults упростить npm пакеты может react-native-default-preference. Тут важно обратить внимание на то, что из коробки он работает только вместе со скоупом UserDefaults standartDefaults, который не расшаривается между приложением и экстеншеном. Но в них есть возможность указания используемого suite. Поэтому, если данная функция для нас необходима, то мы можем делиться данными между приложением и расширением.
import UserDefaults from 'react-native-default-preference';
UserDefaults.setName(“group.com.ios.example”);
const customData = await UserDefaults.get(@”customDataKey”)
Для чего необходимо счетчик пушей?
Например, ваше приложение получает пуши от двух независимых серверов, у каждого из которых свой счетчик badge, а приложение должно их суммировать.
Следующая важная функция:
- (void)serviceExtensionTimeWillExpire {
Вызывается системой, когда вычислительное время, выделенное для работы экстеншена, завершается. Это последний шанс показать измененные данные. Тут стоит прекратить все операции загрузки или запросы, и прописать те данные, которые есть на данный момент. В противном случае пуш нотификация отобразиться с исходными данными из пуша.