diff --git a/app/screens/settings/advanced/index.tsx b/app/screens/settings/advanced/index.tsx index 642067bb99..8a9a1a0d99 100644 --- a/app/screens/settings/advanced/index.tsx +++ b/app/screens/settings/advanced/index.tsx @@ -65,7 +65,7 @@ const AdvancedSettings = ({componentId}: AdvancedSettingsProps) => { style: 'destructive', onPress: async () => { await deleteFileCache(serverUrl); - await getAllCachedFiles(); + getAllCachedFiles(); }, }, ], diff --git a/app/utils/file/index.ts b/app/utils/file/index.ts index 8d228c8b8f..fdab1e8bfd 100644 --- a/app/utils/file/index.ts +++ b/app/utils/file/index.ts @@ -166,32 +166,41 @@ export async function deleteV1Data() { export async function deleteFileCache(serverUrl: string) { const serverDir = urlSafeBase64Encode(serverUrl); - deleteFileCacheByDir(serverDir); + return deleteFileCacheByDir(serverDir); } export async function deleteLegacyFileCache(serverUrl: string) { const serverDir = hashCode_DEPRECATED(serverUrl); - deleteFileCacheByDir(serverDir); + return deleteFileCacheByDir(serverDir); } export async function deleteFileCacheByDir(dir: string) { + if (Platform.OS === 'ios') { + const appGroupCacheDir = `${getIOSAppGroupDetails().appGroupSharedDirectory}/Library/Caches/${dir}`; + await deleteFilesInDir(appGroupCacheDir); + } + const cacheDir = `${FileSystem.CachesDirectoryPath}/${dir}`; - if (cacheDir) { - const cacheDirInfo = await FileSystem.exists(cacheDir); + await deleteFilesInDir(cacheDir); + + return true; +} + +async function deleteFilesInDir(directory: string) { + if (directory) { + const cacheDirInfo = await FileSystem.exists(directory); if (cacheDirInfo) { if (Platform.OS === 'ios') { - await FileSystem.unlink(cacheDir); - await FileSystem.mkdir(cacheDir); + await FileSystem.unlink(directory); + await FileSystem.mkdir(directory); } else { - const lstat = await FileSystem.readDir(cacheDir); + const lstat = await FileSystem.readDir(directory); lstat.forEach((stat: FileSystem.ReadDirItem) => { FileSystem.unlink(stat.path); }); } } } - - return true; } export function lookupMimeType(filename: string) { @@ -525,8 +534,18 @@ export const getAllFilesInCachesDirectory = async (serverUrl: string) => { try { const files: FileSystem.ReadDirItem[][] = []; - const directoryFiles = await FileSystem.readDir(`${FileSystem.CachesDirectoryPath}/${urlSafeBase64Encode(serverUrl)}`); - files.push(directoryFiles); + const promises = [FileSystem.readDir(`${FileSystem.CachesDirectoryPath}/${urlSafeBase64Encode(serverUrl)}`)]; + if (Platform.OS === 'ios') { + const cacheDir = `${getIOSAppGroupDetails().appGroupSharedDirectory}/Library/Caches/${urlSafeBase64Encode(serverUrl)}`; + promises.push(FileSystem.readDir(cacheDir)); + } + + const dirs = await Promise.allSettled(promises); + dirs.forEach((p) => { + if (p.status === 'fulfilled') { + files.push(p.value); + } + }); const flattenedFiles = files.flat(); const totalSize = flattenedFiles.reduce((acc, file) => acc + file.size, 0); diff --git a/ios/Gekidou/Sources/Gekidou/FileCache.swift b/ios/Gekidou/Sources/Gekidou/FileCache.swift new file mode 100644 index 0000000000..8e0cf05a76 --- /dev/null +++ b/ios/Gekidou/Sources/Gekidou/FileCache.swift @@ -0,0 +1,64 @@ +// +// FileCache.swift +// Gekidou +// +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +// + +import Foundation +import UIKit + +public class FileCache: NSObject { + private var cacheURL: URL? + @objc public static let `default` = FileCache() + + override private init() { + super.init() + let filemgr = FileManager.default + let appGroupId = Bundle.main.infoDictionary!["AppGroupIdentifier"] as! String + let containerUrl = filemgr.containerURL(forSecurityApplicationGroupIdentifier: appGroupId) + if let url = containerUrl, + let cacheURL = url.appendingPathComponent("Library", isDirectory: true) as URL? { + self.cacheURL = cacheURL.appendingPathComponent("Caches", isDirectory: true) + self.createDirectoryIfNeeded(directory: self.cacheURL) + } + } + + private func createDirectoryIfNeeded(directory: URL?) { + var isDirectory = ObjCBool(false) + + if let cachePath = directory?.path { + let exists = FileManager.default.fileExists(atPath: cachePath, isDirectory: &isDirectory) + + if !exists && !isDirectory.boolValue { + try? FileManager.default.createDirectory(atPath: cachePath, withIntermediateDirectories: true, attributes: nil) + } + } + } + + private func getUrlImageFor(serverUrl: String, userId: String) -> URL? { + guard let url = cacheURL else {return nil} + + let serverCacheURL = url.appendingPathComponent(serverUrl.toUrlSafeBase64Encode(), isDirectory: true) + createDirectoryIfNeeded(directory: serverCacheURL) + return serverCacheURL.appendingPathComponent(userId + ".png") + } + + public func getProfileImage(serverUrl: String, userId: String) -> UIImage? { + guard let url = getUrlImageFor(serverUrl: serverUrl, userId: userId) else { return nil } + return UIImage(contentsOfFile: url.path) + } + + public func saveProfileImage(serverUrl: String, userId: String, imageData: Data?) { + guard let data = imageData, + let url = getUrlImageFor(serverUrl: serverUrl, userId: userId) + else { return } + + do { + try data.write(to: url) + } catch let error { + print("Erro saving image. \(error)") + } + } +} diff --git a/ios/Gekidou/Sources/Gekidou/Networking/PushNotification.swift b/ios/Gekidou/Sources/Gekidou/Networking/PushNotification.swift index bddcddc955..596e17c101 100644 --- a/ios/Gekidou/Sources/Gekidou/Networking/PushNotification.swift +++ b/ios/Gekidou/Sources/Gekidou/Networking/PushNotification.swift @@ -1,6 +1,7 @@ import Foundation import UserNotifications import SQLite +import os.log public struct AckNotification: Codable { let type: String @@ -66,6 +67,14 @@ extension String { guard self.hasPrefix(prefix) else { return self } return String(self.dropFirst(prefix.count)) } + + func toUrlSafeBase64Encode() -> String { + return Data( + self.replacingOccurrences(of: "/\\+/g", with: "-", options: .regularExpression) + .replacingOccurrences(of: "/\\//g", with: "_", options: .regularExpression) + .utf8 + ).base64EncodedString() + } } extension Network { @@ -93,8 +102,18 @@ extension Network { let semaphore = DispatchSemaphore(value: 0) func processResponse(data: Data?, response: URLResponse?, error: Error?) { - if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 && error == nil { - imgData = data + if let httpResponse = response as? HTTPURLResponse { + if (httpResponse.statusCode == 200 && error == nil) { + imgData = data + FileCache.default.saveProfileImage(serverUrl: serverUrl, userId: senderId, imageData: data) + } else { + os_log( + OSLogType.default, + "Mattermost Notifications: Request for profile image failed with status %{public}@ and error %{public}@", + httpResponse.statusCode, + (error?.localizedDescription ?? "") + ) + } } semaphore.signal() } @@ -103,9 +122,14 @@ extension Network { let url = URL(string: overrideUrl) { request(url, withMethod: "GET", withServerUrl: "", completionHandler: processResponse) } else { - let endpoint = "/users/\(senderId)/image" - let url = buildApiUrl(serverUrl, endpoint) - request(url, withMethod: "GET", withServerUrl: serverUrl, completionHandler: processResponse) + if let image = FileCache.default.getProfileImage(serverUrl: serverUrl, userId: senderId) { + os_log(OSLogType.default, "Mattermost Notifications: cached image") + imgData = image.pngData() + semaphore.signal() + } else { + os_log(OSLogType.default, "Mattermost Notifications: image not cached") + fetchUserProfilePicture(userId: senderId, withServerUrl: serverUrl, completionHandler: processResponse) + } } semaphore.wait() diff --git a/ios/NotificationService/NotificationService.swift b/ios/NotificationService/NotificationService.swift index e18e40bc0c..0152366394 100644 --- a/ios/NotificationService/NotificationService.swift +++ b/ios/NotificationService/NotificationService.swift @@ -1,6 +1,7 @@ import Gekidou import UserNotifications import Intents +import os.log class NotificationService: UNNotificationServiceExtension { let preferences = Gekidou.Preferences.default @@ -24,13 +25,24 @@ class NotificationService: UNNotificationServiceExtension { let ackNotification = try? JSONDecoder().decode(AckNotification.self, from: jsonData) { fetchReceipt(ackNotification) } else { + os_log(OSLogType.default, "Mattermost Notifications: bestAttemptContent seems to be empty, will call sendMessageIntent") sendMessageIntent(notification: request.content) } } func processResponse(serverUrl: String, data: Data, bestAttemptContent: UNMutableNotificationContent) { bestAttemptContent.userInfo["server_url"] = serverUrl + os_log( + OSLogType.default, + "Mattermost Notifications: process receipt response for serverUrl %{public}@", + serverUrl + ) let json = try? JSONSerialization.jsonObject(with: data) as! [String: Any] + os_log( + OSLogType.default, + "Mattermost Notifications: parsed json response %{public}@", + String(describing: json != nil) + ) if let json = json { if let message = json["message"] as? String { bestAttemptContent.body = message @@ -49,10 +61,12 @@ class NotificationService: UNNotificationServiceExtension { if (preferences.object(forKey: "ApplicationIsForeground") as? String != "true") { Network.default.fetchAndStoreDataForPushNotification(bestAttemptContent, withContentHandler: {[weak self] notification in + os_log(OSLogType.default, "Mattermost Notifications: processed data for db. Will call sendMessageIntent") self?.sendMessageIntent(notification: bestAttemptContent) }) } else { bestAttemptContent.badge = Gekidou.Database.default.getTotalMentions() as NSNumber + os_log(OSLogType.default, "Mattermost Notifications: app in the foreground, no data processed. Will call sendMessageIntent") sendMessageIntent(notification: bestAttemptContent) } } @@ -60,7 +74,9 @@ class NotificationService: UNNotificationServiceExtension { override func 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. - if let bestAttemptContent = bestAttemptContent { + os_log(OSLogType.default, "Mattermost Notifications: service extension time expired") + if let bestAttemptContent = bestAttemptContent { + os_log(OSLogType.default, "Mattermost Notifications: calling sendMessageIntent before expiration") sendMessageIntent(notification: bestAttemptContent) } } @@ -71,68 +87,78 @@ class NotificationService: UNNotificationServiceExtension { let channelId = notification.userInfo["channel_id"] as! String let rootId = notification.userInfo.index(forKey: "root_id") != nil ? notification.userInfo["root_id"] as! String : "" let senderId = notification.userInfo["sender_id"] as? String ?? "" - let senderName = notification.userInfo["sender_name"] as? String ?? "" let channelName = notification.userInfo["channel_name"] as? String ?? "" let overrideIconUrl = notification.userInfo["override_icon_url"] as? String let serverUrl = notification.userInfo["server_url"] as? String ?? "" let message = (notification.userInfo["message"] as? String ?? "") - let avatarData = Network.default.fetchProfileImageSync(serverUrl, senderId: senderId, overrideIconUrl: overrideIconUrl) - - let handle = INPersonHandle(value: notification.userInfo["sender_id"] as? String, type: .unknown) - var avatar: INImage? - if let imgData = avatarData { - avatar = INImage(imageData: imgData) - } - let sender = INPerson(personHandle: handle, - nameComponents: nil, - displayName: channelName, - image: avatar, - contactIdentifier: nil, - customIdentifier: nil) - var conversationId = channelId - if isCRTEnabled && rootId != "" { - conversationId = rootId - } - - let intent = INSendMessageIntent(recipients: nil, - outgoingMessageType: .outgoingMessageText, - content: message, - speakableGroupName: nil, - conversationIdentifier: conversationId, - serviceName: nil, - sender: sender, - attachments: nil) - - let interaction = INInteraction(intent: intent, response: nil) - interaction.direction = .incoming - - interaction.donate { error in - if error != nil { - self.contentHandler?(notification) - return - } - - do { - let updatedContent = try notification.updating(from: intent) - self.contentHandler?(updatedContent) - } catch { - self.contentHandler?(notification) + if senderId != "" && serverUrl != "" { + os_log(OSLogType.default, "Mattermost Notifications: Fetching profile Image in server %{public}@ for sender %{public}@", serverUrl, senderId) + let avatarData = Network.default.fetchProfileImageSync(serverUrl, senderId: senderId, overrideIconUrl: overrideIconUrl) + if let imgData = avatarData, + let avatar = INImage(imageData: imgData) as INImage? { + os_log(OSLogType.default, "Mattermost Notifications: creating intent") + var conversationId = channelId + if isCRTEnabled && rootId != "" { + conversationId = rootId + } + + let handle = INPersonHandle(value: senderId, type: .unknown) + let sender = INPerson(personHandle: handle, + nameComponents: nil, + displayName: channelName, + image: avatar, + contactIdentifier: nil, + customIdentifier: nil) + + let intent = INSendMessageIntent(recipients: nil, + outgoingMessageType: .outgoingMessageText, + content: message, + speakableGroupName: nil, + conversationIdentifier: conversationId, + serviceName: nil, + sender: sender, + attachments: nil) + + let interaction = INInteraction(intent: intent, response: nil) + interaction.direction = .incoming + interaction.donate { error in + if error != nil { + self.contentHandler?(notification) + os_log(OSLogType.default, "Mattermost Notifications: sendMessageIntent intent error %{public}@", error! as CVarArg) + } + + do { + let updatedContent = try notification.updating(from: intent) + os_log(OSLogType.default, "Mattermost Notifications: present updated notification") + self.contentHandler?(updatedContent) + } catch { + os_log(OSLogType.default, "Mattermost Notifications: something failed updating the notification %{public}@", error as CVarArg) + self.contentHandler?(notification) + } + } } } } else { + os_log(OSLogType.default, "Mattermost Notifications: No intent created. will call contentHandler to present notification") self.contentHandler?(notification) } } func fetchReceipt(_ ackNotification: AckNotification) -> Void { if (self.retryIndex >= self.fibonacciBackoffsInSeconds.count) { + os_log(OSLogType.default, "Mattermost Notifications: max retries reached. Will call sendMessageIntent") sendMessageIntent(notification: bestAttemptContent!) return } Network.default.postNotificationReceipt(ackNotification) {data, response, error in if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode != 200 { + os_log( + OSLogType.default, + "Mattermost Notifications: notification receipt failed with status %{public}@. Will call sendMessageIntent", + httpResponse.statusCode + ) self.sendMessageIntent(notification: self.bestAttemptContent!) return } @@ -143,6 +169,11 @@ class NotificationService: UNNotificationServiceExtension { let backoffInSeconds = self.fibonacciBackoffsInSeconds[self.retryIndex] DispatchQueue.main.asyncAfter(deadline: .now() + backoffInSeconds, execute: { + os_log( + OSLogType.default, + "Mattermost Notifications: receipt retrieval failed. Retry %{public}@", + self.retryIndex + ) self.fetchReceipt(ackNotification) })