talk-ios/NotificationServiceExtension/NotificationService.m

330 строки
19 KiB
Objective-C

/**
* SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
#import "NotificationService.h"
#import "NCAppBranding.h"
#import "NCDatabaseManager.h"
#import "NCIntentController.h"
#import "NCRoom.h"
#import "NCKeyChainController.h"
#import "NCNotification.h"
#import "NCPushNotification.h"
#import "NCPushNotificationsUtils.h"
#import "AFImageDownloader.h"
#import "NextcloudTalk-Swift.h"
#import <SDWebImage/SDWebImage.h>
typedef void (^CreateConversationNotificationCompletionBlock)(void);
@interface NotificationService ()
@property (nonatomic, strong) void (^contentHandler)(UNNotificationContent *contentToDeliver);
@property (nonatomic, strong) UNMutableNotificationContent *bestAttemptContent;
@property (nonatomic, strong) INSendMessageIntent *sendMessageIntent;
@end
@implementation NotificationService
- (void)didReceiveNotificationRequest:(UNNotificationRequest *)request withContentHandler:(void (^)(UNNotificationContent * _Nonnull))contentHandler {
self.contentHandler = contentHandler;
self.bestAttemptContent = [request.content mutableCopy];
self.sendMessageIntent = nil;
self.bestAttemptContent.title = @"";
self.bestAttemptContent.body = NSLocalizedString(@"You received a new notification", nil);
// Configure database
NSString *path = [[[[NSFileManager defaultManager] containerURLForSecurityApplicationGroupIdentifier:groupIdentifier] URLByAppendingPathComponent:kTalkDatabaseFolder] path];
NSURL *databaseURL = [[NSURL fileURLWithPath:path] URLByAppendingPathComponent:kTalkDatabaseFileName];
if ([[NSFileManager defaultManager] fileExistsAtPath:databaseURL.path]) {
@try {
NSError *error = nil;
// schemaVersionAtURL throws an exception when file is not readable
uint64_t currentSchemaVersion = [RLMRealm schemaVersionAtURL:databaseURL encryptionKey:nil error:&error];
if (error || currentSchemaVersion != kTalkDatabaseSchemaVersion) {
NSLog(@"Current schemaVersion is %llu app schemaVersion is %llu", currentSchemaVersion, kTalkDatabaseSchemaVersion);
NSLog(@"Database needs migration -> don't open database from extension");
self.contentHandler(self.bestAttemptContent);
return;
} else {
NSLog(@"Current schemaVersion is %llu app schemaVersion is %llu", currentSchemaVersion, kTalkDatabaseSchemaVersion);
}
}
@catch (NSException *exception) {
NSLog(@"Reading schemaVersion failed: %@", exception.reason);
self.contentHandler(self.bestAttemptContent);
return;
}
} else {
NSLog(@"Database does not exist -> main app needs to run before extension.");
self.contentHandler(self.bestAttemptContent);
return;
}
RLMRealmConfiguration *configuration = [RLMRealmConfiguration defaultConfiguration];
configuration.fileURL = databaseURL;
configuration.schemaVersion= kTalkDatabaseSchemaVersion;
configuration.objectClasses = @[TalkAccount.class, NCRoom.class, ServerCapabilities.class, FederatedCapabilities.class];
configuration.migrationBlock = ^(RLMMigration *migration, uint64_t oldSchemaVersion) {
// At the very minimum we need to update the version with an empty block to indicate that the schema has been upgraded (automatically) by Realm
};
[RLMRealmConfiguration setDefaultConfiguration:configuration];
// We don't want to use a memory cache in NSE, because we only have a total of 24MB before we get killed by the OS
SDImageCache.sharedImageCache.config.shouldCacheImagesInMemory = NO;
BOOL foundDecryptableMessage = NO;
// Decrypt message
NSString *message = [self.bestAttemptContent.userInfo objectForKey:@"subject"];
for (TalkAccount *talkAccount in [TalkAccount allObjects]) {
TalkAccount *account = [[TalkAccount alloc] initWithValue:talkAccount];
NSData *pushNotificationPrivateKey = [[NCKeyChainController sharedInstance] pushNotificationPrivateKeyForAccountId:account.accountId];
if (message && pushNotificationPrivateKey) {
@try {
NSString *decryptedMessage = [NCPushNotificationsUtils decryptPushNotification:message withDevicePrivateKey:pushNotificationPrivateKey];
if (decryptedMessage) {
NCPushNotification *pushNotification = [NCPushNotification pushNotificationFromDecryptedString:decryptedMessage withAccountId:account.accountId];
if (pushNotification.type == NCPushNotificationTypeAdminNotification) {
// Test notification send through "occ notification:test-push --talk <userid>"
// No need to increase the badge or query the server about it
self.bestAttemptContent.body = pushNotification.subject;
self.contentHandler(self.bestAttemptContent);
return;
}
foundDecryptableMessage = YES;
[[RLMRealm defaultRealm] transactionWithBlock:^{
NSPredicate *query = [NSPredicate predicateWithFormat:@"accountId = %@", account.accountId];
TalkAccount *managedAccount = [TalkAccount objectsWithPredicate:query].firstObject;
// Update unread notifications counter for push notification account
managedAccount.unreadBadgeNumber += 1;
managedAccount.unreadNotification = (managedAccount.active) ? NO : YES;
// Make sure we don't accidentally show a notification again, when we check for notifications in the background
if (managedAccount.lastNotificationId < pushNotification.notificationId) {
managedAccount.lastNotificationId = pushNotification.notificationId;
}
}];
// Get the total number of unread notifications
NSInteger unreadNotifications = 0;
for (TalkAccount *user in [TalkAccount allObjects]) {
unreadNotifications += user.unreadBadgeNumber;
}
self.bestAttemptContent.body = pushNotification.bodyForRemoteAlerts;
self.bestAttemptContent.threadIdentifier = pushNotification.roomToken;
self.bestAttemptContent.sound = [UNNotificationSound defaultSound];
self.bestAttemptContent.badge = @(unreadNotifications);
if (pushNotification.type == NCPushNotificationTypeChat) {
// Set category for chat messages to allow interactive notifications
self.bestAttemptContent.categoryIdentifier = @"CATEGORY_CHAT";
}
NSMutableDictionary *userInfo = [[NSMutableDictionary alloc] init];
[userInfo setObject:pushNotification.jsonString forKey:@"pushNotification"];
[userInfo setObject:pushNotification.accountId forKey:@"accountId"];
[userInfo setObject:@(pushNotification.notificationId) forKey:@"notificationId"];
self.bestAttemptContent.userInfo = userInfo;
// Create title and body structure if there is a new line in the subject
NSArray* components = [pushNotification.subject componentsSeparatedByString:@"\n"];
if (components.count > 1) {
NSString *title = [components objectAtIndex:0];
NSMutableArray *mutableComponents = [[NSMutableArray alloc] initWithArray:components];
[mutableComponents removeObjectAtIndex:0];
NSString *body = [mutableComponents componentsJoinedByString:@"\n"];
self.bestAttemptContent.title = title;
self.bestAttemptContent.body = body;
}
// Try to get the notification from the server
NSString *URLString = [NSString stringWithFormat:@"%@/ocs/v2.php/apps/notifications/api/v2/notifications/%ld", account.server, (long)pushNotification.notificationId];
NSURLSessionConfiguration *configuration = [NSURLSessionConfiguration defaultSessionConfiguration];
NSHTTPCookieStorage *cookieStorage = [NSHTTPCookieStorage sharedCookieStorageForGroupContainerIdentifier:account.accountId];
configuration.HTTPCookieStorage = cookieStorage;
NCAPISessionManager *apiSessionManager = [[NCAPISessionManager alloc] initWithConfiguration:configuration];
NSString *userTokenString = [NSString stringWithFormat:@"%@:%@", account.user, [[NCKeyChainController sharedInstance] tokenForAccountId:account.accountId]];
NSData *data = [userTokenString dataUsingEncoding:NSUTF8StringEncoding];
NSString *base64Encoded = [data base64EncodedStringWithOptions:0];
NSString *authorizationHeader = [[NSString alloc] initWithFormat:@"Basic %@", base64Encoded];
[apiSessionManager.requestSerializer setValue:authorizationHeader forHTTPHeaderField:@"Authorization"];
[apiSessionManager.requestSerializer setTimeoutInterval:25];
[apiSessionManager GET:URLString parameters:nil progress:nil success:^(NSURLSessionDataTask * _Nonnull task, id _Nullable responseObject) {
NSDictionary *notification = [[responseObject objectForKey:@"ocs"] objectForKey:@"data"];
NCNotification *serverNotification = [NCNotification notificationWithDictionary:notification];
if (!serverNotification) {
self.contentHandler(self.bestAttemptContent);
return;
}
// Add the serverNotification as userInfo as well -> this can later be used to access the actions directly
[userInfo setObject:notification forKey:@"serverNotification"];
self.bestAttemptContent.userInfo = userInfo;
if (serverNotification.notificationType == kNCNotificationTypeChat) {
NSAttributedString *attributedMessage = [[NSAttributedString alloc] initWithString:serverNotification.message];
NSAttributedString *markdownMessage = [SwiftMarkdownObjCBridge parseMarkdownWithMarkdownString:attributedMessage];
self.bestAttemptContent.title = serverNotification.chatMessageTitle;
self.bestAttemptContent.body = markdownMessage.string;
NSDictionary *fileDict = [serverNotification.messageRichParameters objectForKey:@"file"];
if (fileDict && [[fileDict objectForKey:@"preview-available"] boolValue]) {
// First try to create the conversation notification, and only afterwards try to retrieve the image preview
[self createConversationNotificationWithPushNotification:pushNotification withCompletionBlock:^{
NSString *fileId = [fileDict objectForKey:@"id"];
NSString *urlString = [NSString stringWithFormat:@"%@/index.php/core/preview?fileId=%@&x=-1&y=%ld&a=1&forceIcon=1", account.server, fileId, 512L];
AFImageDownloader *downloader = [[AFImageDownloader alloc]
initWithSessionManager:[NCImageSessionManager shared]
downloadPrioritization:AFImageDownloadPrioritizationFIFO
maximumActiveDownloads:1
imageCache:nil];
NSString *userAgent = [NSString stringWithFormat:@"Mozilla/5.0 (iOS) Nextcloud-Talk v%@",
[[[NSBundle mainBundle] infoDictionary] objectForKey:@"CFBundleShortVersionString"]];
NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:[NSURL URLWithString:urlString]];
[request setValue:authorizationHeader forHTTPHeaderField:@"Authorization"];
[request setValue:userAgent forHTTPHeaderField:@"User-Agent"];
[request setTimeoutInterval:25];
[downloader downloadImageForURLRequest:request success:^(NSURLRequest * _Nonnull request, NSHTTPURLResponse * _Nullable response, UIImage * _Nonnull image) {
UNNotificationAttachment *attachment = [self getNotificationAttachmentFromImage:image forAccountId:account.accountId];
if (attachment) {
self.bestAttemptContent.attachments = @[attachment];
}
[self showBestAttemptNotification];
} failure:^(NSURLRequest * _Nonnull request, NSHTTPURLResponse * _Nullable response, NSError * _Nonnull error) {
[self showBestAttemptNotification];
}];
}];
// Stop here because the downloader completion blocks will take care of creating the conversation notification
return;
}
} else if (serverNotification.notificationType == kNCNotificationTypeRecording) {
self.bestAttemptContent.categoryIdentifier = @"CATEGORY_RECORDING";
self.bestAttemptContent.title = serverNotification.subject;
self.bestAttemptContent.body = serverNotification.message;
} else if (serverNotification.notificationType == kNCNotificationTypeFederation) {
self.bestAttemptContent.categoryIdentifier = @"CATEGORY_FEDERATION";
self.bestAttemptContent.title = serverNotification.subject;
self.bestAttemptContent.body = serverNotification.message;
[[NCDatabaseManager sharedInstance] increasePendingFederationInvitationForAccountId:account.accountId];
}
[self createConversationNotificationWithPushNotificationAndShow:pushNotification];
} failure:^(NSURLSessionDataTask * _Nullable task, NSError * _Nonnull error) {
// Even if the server request fails, we should try to create a conversation notifications
[self createConversationNotificationWithPushNotificationAndShow:pushNotification];
}];
}
} @catch (NSException *exception) {
NSLog(@"An error ocurred decrypting the message. %@", exception);
continue;
}
}
}
if (!foundDecryptableMessage) {
// At this point we tried everything to decrypt the received message
// No need to wait for the extension timeout, nothing is happening anymore
self.contentHandler(self.bestAttemptContent);
}
}
- (void)createConversationNotificationWithPushNotification:(NCPushNotification *)pushNotification withCompletionBlock:(CreateConversationNotificationCompletionBlock)block {
// There's no reason to create a conversation notification, if we can't ever do something with it
if (!block) {
return;
}
NCRoom *room = [[NCDatabaseManager sharedInstance] roomWithToken:pushNotification.roomToken forAccountId:pushNotification.accountId];
if (room) {
[[NCIntentController sharedInstance] getInteractionForRoom:room withTitle:self.bestAttemptContent.title withCompletionBlock:^(INSendMessageIntent *sendMessageIntent) {
self.sendMessageIntent = sendMessageIntent;
block();
}];
return;
}
block();
}
- (void)createConversationNotificationWithPushNotificationAndShow:(NCPushNotification *)pushNotification
{
[self createConversationNotificationWithPushNotification:pushNotification withCompletionBlock:^{
[self showBestAttemptNotification];
}];
}
- (void)showBestAttemptNotification
{
// When we have a send message intent, we use it, otherwise we fall back to the non-conversation-notification one
if (self.sendMessageIntent) {
__block NSError *error;
self.contentHandler([self.bestAttemptContent contentByUpdatingWithProvider:self.sendMessageIntent error:&error]);
} else {
self.contentHandler(self.bestAttemptContent);
}
}
- (UNNotificationAttachment *)getNotificationAttachmentFromImage:(UIImage *)image forAccountId:(NSString *)accountId
{
NSString *encodedAccountId = [accountId stringByAddingPercentEncodingWithAllowedCharacters:NSCharacterSet.URLHostAllowedCharacterSet];
NSFileManager *fileManager = [NSFileManager defaultManager];
NSString *tempDirectoryPath = [NSTemporaryDirectory() stringByAppendingPathComponent:@"/download/"];
tempDirectoryPath = [tempDirectoryPath stringByAppendingPathComponent:encodedAccountId];
if (![fileManager fileExistsAtPath:tempDirectoryPath]) {
// Make sure our download directory exists
[fileManager createDirectoryAtPath:tempDirectoryPath withIntermediateDirectories:YES attributes:nil error:nil];
}
NSString *fileName = [NSString stringWithFormat:@"NotificationPreview_%@.jpg", [[NSUUID UUID] UUIDString]];
NSString *filePath = [tempDirectoryPath stringByAppendingPathComponent:fileName];
// Write the received image to the temporary directory and create the corresponding attachment object
if ([UIImageJPEGRepresentation(image, 1.0) writeToFile:filePath atomically:YES]) {
UNNotificationAttachment *attachment = [UNNotificationAttachment attachmentWithIdentifier:fileName URL:[NSURL fileURLWithPath:filePath] options:nil error:nil];
return attachment;
}
return nil;
}
- (void)serviceExtensionTimeWillExpire {
// Called just before the extension will be terminated by the system.
// Use this as an opportunity to deliver your "best attempt" at modified content, otherwise the original push payload will be used.
[self showBestAttemptNotification];
}
@end