NotificationService.m 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329
  1. /**
  2. * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
  3. * SPDX-License-Identifier: GPL-3.0-or-later
  4. */
  5. #import "NotificationService.h"
  6. #import "NCAppBranding.h"
  7. #import "NCDatabaseManager.h"
  8. #import "NCIntentController.h"
  9. #import "NCRoom.h"
  10. #import "NCKeyChainController.h"
  11. #import "NCNotification.h"
  12. #import "NCPushNotification.h"
  13. #import "NCPushNotificationsUtils.h"
  14. #import "AFImageDownloader.h"
  15. #import "NextcloudTalk-Swift.h"
  16. #import <SDWebImage/SDWebImage.h>
  17. typedef void (^CreateConversationNotificationCompletionBlock)(void);
  18. @interface NotificationService ()
  19. @property (nonatomic, strong) void (^contentHandler)(UNNotificationContent *contentToDeliver);
  20. @property (nonatomic, strong) UNMutableNotificationContent *bestAttemptContent;
  21. @property (nonatomic, strong) INSendMessageIntent *sendMessageIntent;
  22. @end
  23. @implementation NotificationService
  24. - (void)didReceiveNotificationRequest:(UNNotificationRequest *)request withContentHandler:(void (^)(UNNotificationContent * _Nonnull))contentHandler {
  25. self.contentHandler = contentHandler;
  26. self.bestAttemptContent = [request.content mutableCopy];
  27. self.sendMessageIntent = nil;
  28. self.bestAttemptContent.title = @"";
  29. self.bestAttemptContent.body = NSLocalizedString(@"You received a new notification", nil);
  30. // Configure database
  31. NSString *path = [[[[NSFileManager defaultManager] containerURLForSecurityApplicationGroupIdentifier:groupIdentifier] URLByAppendingPathComponent:kTalkDatabaseFolder] path];
  32. NSURL *databaseURL = [[NSURL fileURLWithPath:path] URLByAppendingPathComponent:kTalkDatabaseFileName];
  33. if ([[NSFileManager defaultManager] fileExistsAtPath:databaseURL.path]) {
  34. @try {
  35. NSError *error = nil;
  36. // schemaVersionAtURL throws an exception when file is not readable
  37. uint64_t currentSchemaVersion = [RLMRealm schemaVersionAtURL:databaseURL encryptionKey:nil error:&error];
  38. if (error || currentSchemaVersion != kTalkDatabaseSchemaVersion) {
  39. NSLog(@"Current schemaVersion is %llu app schemaVersion is %llu", currentSchemaVersion, kTalkDatabaseSchemaVersion);
  40. NSLog(@"Database needs migration -> don't open database from extension");
  41. self.contentHandler(self.bestAttemptContent);
  42. return;
  43. } else {
  44. NSLog(@"Current schemaVersion is %llu app schemaVersion is %llu", currentSchemaVersion, kTalkDatabaseSchemaVersion);
  45. }
  46. }
  47. @catch (NSException *exception) {
  48. NSLog(@"Reading schemaVersion failed: %@", exception.reason);
  49. self.contentHandler(self.bestAttemptContent);
  50. return;
  51. }
  52. } else {
  53. NSLog(@"Database does not exist -> main app needs to run before extension.");
  54. self.contentHandler(self.bestAttemptContent);
  55. return;
  56. }
  57. RLMRealmConfiguration *configuration = [RLMRealmConfiguration defaultConfiguration];
  58. configuration.fileURL = databaseURL;
  59. configuration.schemaVersion= kTalkDatabaseSchemaVersion;
  60. configuration.objectClasses = @[TalkAccount.class, NCRoom.class, ServerCapabilities.class, FederatedCapabilities.class];
  61. configuration.migrationBlock = ^(RLMMigration *migration, uint64_t oldSchemaVersion) {
  62. // 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
  63. };
  64. [RLMRealmConfiguration setDefaultConfiguration:configuration];
  65. // 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
  66. SDImageCache.sharedImageCache.config.shouldCacheImagesInMemory = NO;
  67. BOOL foundDecryptableMessage = NO;
  68. // Decrypt message
  69. NSString *message = [self.bestAttemptContent.userInfo objectForKey:@"subject"];
  70. for (TalkAccount *talkAccount in [TalkAccount allObjects]) {
  71. TalkAccount *account = [[TalkAccount alloc] initWithValue:talkAccount];
  72. NSData *pushNotificationPrivateKey = [[NCKeyChainController sharedInstance] pushNotificationPrivateKeyForAccountId:account.accountId];
  73. if (message && pushNotificationPrivateKey) {
  74. @try {
  75. NSString *decryptedMessage = [NCPushNotificationsUtils decryptPushNotification:message withDevicePrivateKey:pushNotificationPrivateKey];
  76. if (decryptedMessage) {
  77. NCPushNotification *pushNotification = [NCPushNotification pushNotificationFromDecryptedString:decryptedMessage withAccountId:account.accountId];
  78. if (pushNotification.type == NCPushNotificationTypeAdminNotification) {
  79. // Test notification send through "occ notification:test-push --talk <userid>"
  80. // No need to increase the badge or query the server about it
  81. self.bestAttemptContent.body = pushNotification.subject;
  82. self.contentHandler(self.bestAttemptContent);
  83. return;
  84. }
  85. foundDecryptableMessage = YES;
  86. [[RLMRealm defaultRealm] transactionWithBlock:^{
  87. NSPredicate *query = [NSPredicate predicateWithFormat:@"accountId = %@", account.accountId];
  88. TalkAccount *managedAccount = [TalkAccount objectsWithPredicate:query].firstObject;
  89. // Update unread notifications counter for push notification account
  90. managedAccount.unreadBadgeNumber += 1;
  91. managedAccount.unreadNotification = (managedAccount.active) ? NO : YES;
  92. // Make sure we don't accidentally show a notification again, when we check for notifications in the background
  93. if (managedAccount.lastNotificationId < pushNotification.notificationId) {
  94. managedAccount.lastNotificationId = pushNotification.notificationId;
  95. }
  96. }];
  97. // Get the total number of unread notifications
  98. NSInteger unreadNotifications = 0;
  99. for (TalkAccount *user in [TalkAccount allObjects]) {
  100. unreadNotifications += user.unreadBadgeNumber;
  101. }
  102. self.bestAttemptContent.body = pushNotification.bodyForRemoteAlerts;
  103. self.bestAttemptContent.threadIdentifier = pushNotification.roomToken;
  104. self.bestAttemptContent.sound = [UNNotificationSound defaultSound];
  105. self.bestAttemptContent.badge = @(unreadNotifications);
  106. if (pushNotification.type == NCPushNotificationTypeChat) {
  107. // Set category for chat messages to allow interactive notifications
  108. self.bestAttemptContent.categoryIdentifier = @"CATEGORY_CHAT";
  109. }
  110. NSMutableDictionary *userInfo = [[NSMutableDictionary alloc] init];
  111. [userInfo setObject:pushNotification.jsonString forKey:@"pushNotification"];
  112. [userInfo setObject:pushNotification.accountId forKey:@"accountId"];
  113. [userInfo setObject:@(pushNotification.notificationId) forKey:@"notificationId"];
  114. self.bestAttemptContent.userInfo = userInfo;
  115. // Create title and body structure if there is a new line in the subject
  116. NSArray* components = [pushNotification.subject componentsSeparatedByString:@"\n"];
  117. if (components.count > 1) {
  118. NSString *title = [components objectAtIndex:0];
  119. NSMutableArray *mutableComponents = [[NSMutableArray alloc] initWithArray:components];
  120. [mutableComponents removeObjectAtIndex:0];
  121. NSString *body = [mutableComponents componentsJoinedByString:@"\n"];
  122. self.bestAttemptContent.title = title;
  123. self.bestAttemptContent.body = body;
  124. }
  125. // Try to get the notification from the server
  126. NSString *URLString = [NSString stringWithFormat:@"%@/ocs/v2.php/apps/notifications/api/v2/notifications/%ld", account.server, (long)pushNotification.notificationId];
  127. NSURLSessionConfiguration *configuration = [NSURLSessionConfiguration defaultSessionConfiguration];
  128. NSHTTPCookieStorage *cookieStorage = [NSHTTPCookieStorage sharedCookieStorageForGroupContainerIdentifier:account.accountId];
  129. configuration.HTTPCookieStorage = cookieStorage;
  130. NCAPISessionManager *apiSessionManager = [[NCAPISessionManager alloc] initWithConfiguration:configuration];
  131. NSString *userTokenString = [NSString stringWithFormat:@"%@:%@", account.user, [[NCKeyChainController sharedInstance] tokenForAccountId:account.accountId]];
  132. NSData *data = [userTokenString dataUsingEncoding:NSUTF8StringEncoding];
  133. NSString *base64Encoded = [data base64EncodedStringWithOptions:0];
  134. NSString *authorizationHeader = [[NSString alloc] initWithFormat:@"Basic %@", base64Encoded];
  135. [apiSessionManager.requestSerializer setValue:authorizationHeader forHTTPHeaderField:@"Authorization"];
  136. [apiSessionManager.requestSerializer setTimeoutInterval:25];
  137. [apiSessionManager GET:URLString parameters:nil progress:nil success:^(NSURLSessionDataTask * _Nonnull task, id _Nullable responseObject) {
  138. NSDictionary *notification = [[responseObject objectForKey:@"ocs"] objectForKey:@"data"];
  139. NCNotification *serverNotification = [NCNotification notificationWithDictionary:notification];
  140. if (!serverNotification) {
  141. self.contentHandler(self.bestAttemptContent);
  142. return;
  143. }
  144. // Add the serverNotification as userInfo as well -> this can later be used to access the actions directly
  145. [userInfo setObject:notification forKey:@"serverNotification"];
  146. self.bestAttemptContent.userInfo = userInfo;
  147. if (serverNotification.notificationType == kNCNotificationTypeChat) {
  148. NSAttributedString *attributedMessage = [[NSAttributedString alloc] initWithString:serverNotification.message];
  149. NSAttributedString *markdownMessage = [SwiftMarkdownObjCBridge parseMarkdownWithMarkdownString:attributedMessage];
  150. self.bestAttemptContent.title = serverNotification.chatMessageTitle;
  151. self.bestAttemptContent.body = markdownMessage.string;
  152. NSDictionary *fileDict = [serverNotification.messageRichParameters objectForKey:@"file"];
  153. if (fileDict && [[fileDict objectForKey:@"preview-available"] boolValue]) {
  154. // First try to create the conversation notification, and only afterwards try to retrieve the image preview
  155. [self createConversationNotificationWithPushNotification:pushNotification withCompletionBlock:^{
  156. NSString *fileId = [fileDict objectForKey:@"id"];
  157. NSString *urlString = [NSString stringWithFormat:@"%@/index.php/core/preview?fileId=%@&x=-1&y=%ld&a=1&forceIcon=1", account.server, fileId, 512L];
  158. AFImageDownloader *downloader = [[AFImageDownloader alloc]
  159. initWithSessionManager:[NCImageSessionManager shared]
  160. downloadPrioritization:AFImageDownloadPrioritizationFIFO
  161. maximumActiveDownloads:1
  162. imageCache:nil];
  163. NSString *userAgent = [NSString stringWithFormat:@"Mozilla/5.0 (iOS) Nextcloud-Talk v%@",
  164. [[[NSBundle mainBundle] infoDictionary] objectForKey:@"CFBundleShortVersionString"]];
  165. NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:[NSURL URLWithString:urlString]];
  166. [request setValue:authorizationHeader forHTTPHeaderField:@"Authorization"];
  167. [request setValue:userAgent forHTTPHeaderField:@"User-Agent"];
  168. [request setTimeoutInterval:25];
  169. [downloader downloadImageForURLRequest:request success:^(NSURLRequest * _Nonnull request, NSHTTPURLResponse * _Nullable response, UIImage * _Nonnull image) {
  170. UNNotificationAttachment *attachment = [self getNotificationAttachmentFromImage:image forAccountId:account.accountId];
  171. if (attachment) {
  172. self.bestAttemptContent.attachments = @[attachment];
  173. }
  174. [self showBestAttemptNotification];
  175. } failure:^(NSURLRequest * _Nonnull request, NSHTTPURLResponse * _Nullable response, NSError * _Nonnull error) {
  176. [self showBestAttemptNotification];
  177. }];
  178. }];
  179. // Stop here because the downloader completion blocks will take care of creating the conversation notification
  180. return;
  181. }
  182. } else if (serverNotification.notificationType == kNCNotificationTypeRecording) {
  183. self.bestAttemptContent.categoryIdentifier = @"CATEGORY_RECORDING";
  184. self.bestAttemptContent.title = serverNotification.subject;
  185. self.bestAttemptContent.body = serverNotification.message;
  186. } else if (serverNotification.notificationType == kNCNotificationTypeFederation) {
  187. self.bestAttemptContent.categoryIdentifier = @"CATEGORY_FEDERATION";
  188. self.bestAttemptContent.title = serverNotification.subject;
  189. self.bestAttemptContent.body = serverNotification.message;
  190. [[NCDatabaseManager sharedInstance] increasePendingFederationInvitationForAccountId:account.accountId];
  191. }
  192. [self createConversationNotificationWithPushNotificationAndShow:pushNotification];
  193. } failure:^(NSURLSessionDataTask * _Nullable task, NSError * _Nonnull error) {
  194. // Even if the server request fails, we should try to create a conversation notifications
  195. [self createConversationNotificationWithPushNotificationAndShow:pushNotification];
  196. }];
  197. }
  198. } @catch (NSException *exception) {
  199. NSLog(@"An error ocurred decrypting the message. %@", exception);
  200. continue;
  201. }
  202. }
  203. }
  204. if (!foundDecryptableMessage) {
  205. // At this point we tried everything to decrypt the received message
  206. // No need to wait for the extension timeout, nothing is happening anymore
  207. self.contentHandler(self.bestAttemptContent);
  208. }
  209. }
  210. - (void)createConversationNotificationWithPushNotification:(NCPushNotification *)pushNotification withCompletionBlock:(CreateConversationNotificationCompletionBlock)block {
  211. // There's no reason to create a conversation notification, if we can't ever do something with it
  212. if (!block) {
  213. return;
  214. }
  215. NCRoom *room = [[NCDatabaseManager sharedInstance] roomWithToken:pushNotification.roomToken forAccountId:pushNotification.accountId];
  216. if (room) {
  217. [[NCIntentController sharedInstance] getInteractionForRoom:room withTitle:self.bestAttemptContent.title withCompletionBlock:^(INSendMessageIntent *sendMessageIntent) {
  218. self.sendMessageIntent = sendMessageIntent;
  219. block();
  220. }];
  221. return;
  222. }
  223. block();
  224. }
  225. - (void)createConversationNotificationWithPushNotificationAndShow:(NCPushNotification *)pushNotification
  226. {
  227. [self createConversationNotificationWithPushNotification:pushNotification withCompletionBlock:^{
  228. [self showBestAttemptNotification];
  229. }];
  230. }
  231. - (void)showBestAttemptNotification
  232. {
  233. // When we have a send message intent, we use it, otherwise we fall back to the non-conversation-notification one
  234. if (self.sendMessageIntent) {
  235. __block NSError *error;
  236. self.contentHandler([self.bestAttemptContent contentByUpdatingWithProvider:self.sendMessageIntent error:&error]);
  237. } else {
  238. self.contentHandler(self.bestAttemptContent);
  239. }
  240. }
  241. - (UNNotificationAttachment *)getNotificationAttachmentFromImage:(UIImage *)image forAccountId:(NSString *)accountId
  242. {
  243. NSString *encodedAccountId = [accountId stringByAddingPercentEncodingWithAllowedCharacters:NSCharacterSet.URLHostAllowedCharacterSet];
  244. NSFileManager *fileManager = [NSFileManager defaultManager];
  245. NSString *tempDirectoryPath = [NSTemporaryDirectory() stringByAppendingPathComponent:@"/download/"];
  246. tempDirectoryPath = [tempDirectoryPath stringByAppendingPathComponent:encodedAccountId];
  247. if (![fileManager fileExistsAtPath:tempDirectoryPath]) {
  248. // Make sure our download directory exists
  249. [fileManager createDirectoryAtPath:tempDirectoryPath withIntermediateDirectories:YES attributes:nil error:nil];
  250. }
  251. NSString *fileName = [NSString stringWithFormat:@"NotificationPreview_%@.jpg", [[NSUUID UUID] UUIDString]];
  252. NSString *filePath = [tempDirectoryPath stringByAppendingPathComponent:fileName];
  253. // Write the received image to the temporary directory and create the corresponding attachment object
  254. if ([UIImageJPEGRepresentation(image, 1.0) writeToFile:filePath atomically:YES]) {
  255. UNNotificationAttachment *attachment = [UNNotificationAttachment attachmentWithIdentifier:fileName URL:[NSURL fileURLWithPath:filePath] options:nil error:nil];
  256. return attachment;
  257. }
  258. return nil;
  259. }
  260. - (void)serviceExtensionTimeWillExpire {
  261. // Called just before the extension will be terminated by the system.
  262. // Use this as an opportunity to deliver your "best attempt" at modified content, otherwise the original push payload will be used.
  263. [self showBestAttemptNotification];
  264. }
  265. @end