Cache push notification profile image and add logs (#6932)

* Cache push notification profile image and add logs

* Fix indent
This commit is contained in:
Elias Nahum
2023-01-05 12:31:52 +02:00
committed by GitHub
parent dbe565319d
commit 001a6699fb
5 changed files with 198 additions and 60 deletions

View File

@@ -65,7 +65,7 @@ const AdvancedSettings = ({componentId}: AdvancedSettingsProps) => {
style: 'destructive',
onPress: async () => {
await deleteFileCache(serverUrl);
await getAllCachedFiles();
getAllCachedFiles();
},
},
],

View File

@@ -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);

View File

@@ -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)")
}
}
}

View File

@@ -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()

View File

@@ -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)
})