From 84846b01a90bd36166aa691ebeb4ab5d72dc4295 Mon Sep 17 00:00:00 2001 From: Elias Nahum Date: Sat, 1 Nov 2025 19:19:31 +0800 Subject: [PATCH] MM-66173 fixes crash when receiving notifications (#9243) * MM-66173 fixes crash when receiving notifications * Update ios/Gekidou/Sources/Gekidou/PushNotification/PushNotification+Signature.swift Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update ios/Gekidou/Sources/Gekidou/PushNotification/PushNotification+Signature.swift Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update ios/Gekidou/Sources/Gekidou/DataTypes/ChannelMember.swift Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../Cache/ImageCache+InsertRemove.swift | 10 +- .../Sources/Gekidou/DataTypes/Category.swift | 12 +- .../Sources/Gekidou/DataTypes/Channel.swift | 2 +- .../Gekidou/DataTypes/ChannelMember.swift | 8 +- .../Sources/Gekidou/DataTypes/Post.swift | 6 +- .../Gekidou/DataTypes/PostThread.swift | 2 +- .../Sources/Gekidou/DataTypes/Team.swift | 2 +- .../Gekidou/DataTypes/TeamMember.swift | 4 +- .../Sources/Gekidou/DataTypes/User.swift | 4 +- ios/Gekidou/Sources/Gekidou/Keychain.swift | 45 +-- ios/Gekidou/Sources/Gekidou/Logger.swift | 93 ++++++ .../Gekidou/Networking/Network+Category.swift | 5 +- .../Gekidou/Networking/Network+Channel.swift | 43 ++- .../Gekidou/Networking/Network+Delegate.swift | 13 +- .../Gekidou/Networking/Network+Posts.swift | 37 ++- .../Gekidou/Networking/Network+Team.swift | 30 +- .../Gekidou/Networking/Network+Thread.swift | 5 +- .../Gekidou/Networking/Network+Users.swift | 26 +- .../Sources/Gekidou/Networking/Network.swift | 79 +++-- .../PushNotification/AckNotification.swift | 25 +- .../PushNotification+FetchData.swift | 107 +++++-- .../PushNotification+ProfileImage.swift | 28 +- .../PushNotification+Signature.swift | 109 +++---- .../PushNotification/PushNotification.swift | 136 +++++---- .../Gekidou/Storage/Database+Channels.swift | 45 +-- .../Gekidou/Storage/Database+Posts.swift | 185 +++++++---- .../Gekidou/Storage/Database+Team.swift | 11 +- .../Gekidou/Storage/Database+Users.swift | 10 +- .../Sources/Gekidou/Storage/Database.swift | 86 ++++-- ios/GekidouWrapper.swift | 19 ++ ios/Mattermost/AppDelegate.mm | 18 +- .../Helpers/NotificationHelper.swift | 6 +- .../NotificationService.swift | 287 ++++++++++++------ package-lock.json | 2 +- 34 files changed, 992 insertions(+), 508 deletions(-) create mode 100644 ios/Gekidou/Sources/Gekidou/Logger.swift diff --git a/ios/Gekidou/Sources/Gekidou/Cache/ImageCache+InsertRemove.swift b/ios/Gekidou/Sources/Gekidou/Cache/ImageCache+InsertRemove.swift index ee06088a0..be9f6c567 100644 --- a/ios/Gekidou/Sources/Gekidou/Cache/ImageCache+InsertRemove.swift +++ b/ios/Gekidou/Sources/Gekidou/Cache/ImageCache+InsertRemove.swift @@ -2,22 +2,24 @@ import Foundation extension ImageCache { public func removeAllImages() { + lock.lock(); defer { lock.unlock() } imageCache.removeAllObjects() keysCache.removeAllObjects() } - + public func insertImage(_ data: Data?, for userId: String, updatedAt: Double, forServer serverUrl: String ) { guard let data = data else { - return removeImage(for: userId, forServer: serverUrl) + removeImage(for: userId, forServer: serverUrl) + return } - + lock.lock(); defer { lock.unlock() } let cacheKey = "\(serverUrl)-\(userId)" as NSString let imageKey = "\(cacheKey)-\(updatedAt)" as NSString imageCache.setObject(NSData(data: data), forKey: imageKey as NSString, cost: data.count) keysCache.setObject(imageKey, forKey: cacheKey) } - + public func removeImage(for userId: String, forServer serverUrl: String) { lock.lock(); defer { lock.unlock() } let cacheKey = "\(serverUrl)-\(userId)" as NSString diff --git a/ios/Gekidou/Sources/Gekidou/DataTypes/Category.swift b/ios/Gekidou/Sources/Gekidou/DataTypes/Category.swift index 151116afe..f97e78668 100644 --- a/ios/Gekidou/Sources/Gekidou/DataTypes/Category.swift +++ b/ios/Gekidou/Sources/Gekidou/DataTypes/Category.swift @@ -44,9 +44,9 @@ public struct Category: Codable { public init(from decoder: Decoder) throws { let values = try decoder.container(keyedBy: CategoryKeys.self) - id = try values.decode(String.self, forKey: .id) - teamId = try values.decode(String.self, forKey: .teamId) - userId = try values.decode(String.self, forKey: .userId) + id = values.decodeIfPresent(forKey: .id, defaultValue: "") + teamId = values.decodeIfPresent(forKey: .teamId, defaultValue: "") + userId = values.decodeIfPresent(forKey: .userId, defaultValue: "") channelIds = values.decodeIfPresent(forKey: .channelIds, defaultValue: [String]()) collapsed = false displayName = values.decodeIfPresent(forKey: .displayName, defaultValue: "") @@ -86,9 +86,9 @@ public struct CategoryChannel: Codable { public init(from decoder: Decoder) throws { let values = try decoder.container(keyedBy: CategoryChannelKeys.self) - id = try values.decode(String.self, forKey: .id) - channelId = try values.decode(String.self, forKey: .channelId) - categoryId = try values.decode(String.self, forKey: .categoryId) + id = values.decodeIfPresent(forKey: .id, defaultValue: "") + channelId = values.decodeIfPresent(forKey: .channelId, defaultValue: "") + categoryId = values.decodeIfPresent(forKey: .categoryId, defaultValue: "") sortOrder = values.decodeIfPresent(forKey: .sortOrder, defaultValue: 0) } diff --git a/ios/Gekidou/Sources/Gekidou/DataTypes/Channel.swift b/ios/Gekidou/Sources/Gekidou/DataTypes/Channel.swift index 8329a9b36..b0e3fb80b 100644 --- a/ios/Gekidou/Sources/Gekidou/DataTypes/Channel.swift +++ b/ios/Gekidou/Sources/Gekidou/DataTypes/Channel.swift @@ -49,7 +49,7 @@ public struct Channel: Codable { public init(from decoder: Decoder) throws { let values = try decoder.container(keyedBy: ChannelKeys.self) - id = try values.decode(String.self, forKey: .id) + id = values.decodeIfPresent(forKey: .id, defaultValue: "") creatorId = values.decodeIfPresent(forKey: .creatorId, defaultValue: "") createAt = values.decodeIfPresent(forKey: .createAt, defaultValue: 0) deleteAt = values.decodeIfPresent(forKey: .deleteAt, defaultValue: 0) diff --git a/ios/Gekidou/Sources/Gekidou/DataTypes/ChannelMember.swift b/ios/Gekidou/Sources/Gekidou/DataTypes/ChannelMember.swift index cbfc46ef8..8d0965aa4 100644 --- a/ios/Gekidou/Sources/Gekidou/DataTypes/ChannelMember.swift +++ b/ios/Gekidou/Sources/Gekidou/DataTypes/ChannelMember.swift @@ -41,8 +41,8 @@ public struct ChannelMember: Codable { public init(from decoder: Decoder) throws { let values = try decoder.container(keyedBy: ChannelMemberKeys.self) - id = try values.decode(String.self, forKey: .id) - userId = try values.decode(String.self, forKey: .userId) + id = values.decodeIfPresent(forKey: .id, defaultValue: "") + userId = values.decodeIfPresent(forKey: .userId, defaultValue: "") explicitRoles = values.decodeIfPresent(forKey: .explicitRoles, defaultValue: "") lastUpdateAt = values.decodeIfPresent(forKey: .lastUpdateAt, defaultValue: 0) lastViewedAt = values.decodeIfPresent(forKey: .lastViewedAt, defaultValue: 0) @@ -50,8 +50,8 @@ public struct ChannelMember: Codable { mentionCountRoot = values.decodeIfPresent(forKey: .mentionCountRoot, defaultValue: 0) msgCount = values.decodeIfPresent(forKey: .msgCount, defaultValue: 0) msgCountRoot = values.decodeIfPresent(forKey: .msgCountRoot, defaultValue: 0) - let propsData = try values.decode([String:Any].self, forKey: .notifyProps) - notifyProps = Database.default.json(from: propsData) ?? "{}" + let propsDict = try? values.decode([String:Any].self, forKey: .notifyProps) + notifyProps = Database.default.json(from: propsDict) ?? "{}" roles = values.decodeIfPresent(forKey: .roles, defaultValue: "") schemeAdmin = values.decodeIfPresent(forKey: .schemeAdmin, defaultValue: false) schemeGuest = values.decodeIfPresent(forKey: .schemeGuest, defaultValue: false) diff --git a/ios/Gekidou/Sources/Gekidou/DataTypes/Post.swift b/ios/Gekidou/Sources/Gekidou/DataTypes/Post.swift index b96e8c987..115fddf61 100644 --- a/ios/Gekidou/Sources/Gekidou/DataTypes/Post.swift +++ b/ios/Gekidou/Sources/Gekidou/DataTypes/Post.swift @@ -47,9 +47,9 @@ public struct Post: Codable { public init(from decoder: Decoder) throws { let values = try decoder.container(keyedBy: PostKeys.self) prevPostId = "" - id = try values.decode(String.self, forKey: .id) - channelId = try values.decode(String.self, forKey: .channelId) - userId = try values.decode(String.self, forKey: .userId) + id = values.decodeIfPresent(forKey: .id, defaultValue: "") + channelId = values.decodeIfPresent(forKey: .channelId, defaultValue: "") + userId = values.decodeIfPresent(forKey: .userId, defaultValue: "") createAt = values.decodeIfPresent(forKey: .createAt, defaultValue: 0) updateAt = values.decodeIfPresent(forKey: .updateAt, defaultValue: 0) deleteAt = values.decodeIfPresent(forKey: .deleteAt, defaultValue: 0) diff --git a/ios/Gekidou/Sources/Gekidou/DataTypes/PostThread.swift b/ios/Gekidou/Sources/Gekidou/DataTypes/PostThread.swift index cb87cb477..14fec9670 100644 --- a/ios/Gekidou/Sources/Gekidou/DataTypes/PostThread.swift +++ b/ios/Gekidou/Sources/Gekidou/DataTypes/PostThread.swift @@ -25,7 +25,7 @@ public struct PostThread: Codable { public init(from decoder: Decoder) throws { let values = try decoder.container(keyedBy: PostThreadKeys.self) - id = try values.decode(String.self, forKey: .id) + id = values.decodeIfPresent(forKey: .id, defaultValue: "") post = values.decodeIfPresent(forKey: .post, defaultValue: nil) participants = values.decodeIfPresent(forKey: .participants, defaultValue: [User]()) lastReplyAt = values.decodeIfPresent(forKey: .lastReplyAt, defaultValue: 0) diff --git a/ios/Gekidou/Sources/Gekidou/DataTypes/Team.swift b/ios/Gekidou/Sources/Gekidou/DataTypes/Team.swift index 6d656637f..df5a8d868 100644 --- a/ios/Gekidou/Sources/Gekidou/DataTypes/Team.swift +++ b/ios/Gekidou/Sources/Gekidou/DataTypes/Team.swift @@ -44,7 +44,7 @@ public struct Team: Codable { public init(from decoder: Decoder) throws { let values = try decoder.container(keyedBy: TeamKeys.self) - id = try values.decode(String.self, forKey: .id) + id = values.decodeIfPresent(forKey: .id, defaultValue: "") allowOpenInvite = values.decodeIfPresent(forKey: .allowOpenInvite, defaultValue: true) allowedDomains = values.decodeIfPresent(forKey: .allowedDomains, defaultValue: "") cloudLimitsArchived = values.decodeIfPresent(forKey: .cloudLimitsArchived, defaultValue: false) diff --git a/ios/Gekidou/Sources/Gekidou/DataTypes/TeamMember.swift b/ios/Gekidou/Sources/Gekidou/DataTypes/TeamMember.swift index 132cc7e6e..0819c16bf 100644 --- a/ios/Gekidou/Sources/Gekidou/DataTypes/TeamMember.swift +++ b/ios/Gekidou/Sources/Gekidou/DataTypes/TeamMember.swift @@ -21,8 +21,8 @@ public struct TeamMember: Codable { public init(from decoder: Decoder) throws { let values = try decoder.container(keyedBy: TeamMemberKeys.self) - id = try values.decode(String.self, forKey: .id) - userId = try values.decode(String.self, forKey: .userId) + id = values.decodeIfPresent(forKey: .id, defaultValue: "") + userId = values.decodeIfPresent(forKey: .userId, defaultValue: "") explicitRoles = values.decodeIfPresent(forKey: .explicitRoles, defaultValue: "") roles = values.decodeIfPresent(forKey: .roles, defaultValue: "") schemeAdmin = values.decodeIfPresent(forKey: .schemeAdmin, defaultValue: false) diff --git a/ios/Gekidou/Sources/Gekidou/DataTypes/User.swift b/ios/Gekidou/Sources/Gekidou/DataTypes/User.swift index 9b85bdfbf..1f1f8b839 100644 --- a/ios/Gekidou/Sources/Gekidou/DataTypes/User.swift +++ b/ios/Gekidou/Sources/Gekidou/DataTypes/User.swift @@ -36,8 +36,8 @@ public struct User: Codable, Hashable { public init(from decoder: Decoder) throws { let values = try decoder.container(keyedBy: UserKeys.self) - id = try values.decode(String.self, forKey: .id) - username = try values.decode(String.self, forKey: .username) + id = values.decodeIfPresent(forKey: .id, defaultValue: "") + username = values.decodeIfPresent(forKey: .username, defaultValue: "") authService = values.decodeIfPresent(forKey: .authService, defaultValue: "") updateAt = values.decodeIfPresent(forKey: .updateAt, defaultValue: 0) deleteAt = values.decodeIfPresent(forKey: .deleteAt, defaultValue: 0) diff --git a/ios/Gekidou/Sources/Gekidou/Keychain.swift b/ios/Gekidou/Sources/Gekidou/Keychain.swift index 8642d246a..de2292d7e 100644 --- a/ios/Gekidou/Sources/Gekidou/Keychain.swift +++ b/ios/Gekidou/Sources/Gekidou/Keychain.swift @@ -57,27 +57,30 @@ public class Keychain: NSObject { public func getClientIdentityAndCertificate(for host: String) throws -> (SecIdentity, SecCertificate)? { let query = try buildIdentityQuery(for: host) - var result: AnyObject? - let identityStatus = SecItemCopyMatching(query as CFDictionary, &result) - guard identityStatus == errSecSuccess else { - if identityStatus == errSecItemNotFound { + var item: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &item) + guard status == errSecSuccess else { + if status == errSecItemNotFound { throw KeychainError.IdentityNotFound } - - throw KeychainError.FailedSecItemCopyMatching(identityStatus) + throw KeychainError.FailedSecItemCopyMatching(status) } - - let identity = result as! SecIdentity + + guard let obj = item, CFGetTypeID(obj) == SecIdentityGetTypeID() else { + throw KeychainError.IdentityNotFound + } + let identity = obj as! SecIdentity // safe after the type-ID check + var certificate: SecCertificate? - let certificateStatus = SecIdentityCopyCertificate(identity, &certificate) - guard certificateStatus == errSecSuccess else { - throw KeychainError.FailedSecIdentityCopyCertificate(certificateStatus) + let certStatus = SecIdentityCopyCertificate(identity, &certificate) + guard certStatus == errSecSuccess else { + throw KeychainError.FailedSecIdentityCopyCertificate(certStatus) } - guard certificate != nil else { + guard let cert = certificate else { throw KeychainError.CertificateForIdentityNotFound } - return (identity, certificate!) + return (identity, cert) } @objc public func getCredentialsObjc(for serverUrl: String) -> NSDictionary? { @@ -103,9 +106,9 @@ public class Keychain: NSObject { var result: AnyObject? let status = SecItemCopyMatching(attributes as CFDictionary, &result) - let data = result as? Data - if status == errSecSuccess && data != nil { - let token = String(data: data!, encoding: .utf8) + if status == errSecSuccess, + let data = result as? Data, + let token = String(data: data, encoding: .utf8) { return token } @@ -119,9 +122,9 @@ public class Keychain: NSObject { var result: AnyObject? let status = SecItemCopyMatching(attributes as CFDictionary, &result) - let data = result as? Data - if status == errSecSuccess && data != nil { - let preauthSecret = String(data: data!, encoding: .utf8) + if status == errSecSuccess, + let data = result as? Data, + let preauthSecret = String(data: data, encoding: .utf8) { return preauthSecret } @@ -152,7 +155,7 @@ public class Keychain: NSObject { kSecAttrServer: serverUrlData ] - if let accessGroup = Bundle.main.object(forInfoDictionaryKey: "AppGroupIdentifier") as! String? { + if let accessGroup = Bundle.main.object(forInfoDictionaryKey: "AppGroupIdentifier") as? String { attributes[kSecAttrAccessGroup] = accessGroup } @@ -174,7 +177,7 @@ public class Keychain: NSObject { kSecAttrAccount: accountData ] - if let accessGroup = Bundle.main.object(forInfoDictionaryKey: "AppGroupIdentifier") as! String? { + if let accessGroup = Bundle.main.object(forInfoDictionaryKey: "AppGroupIdentifier") as? String { attributes[kSecAttrAccessGroup] = accessGroup } diff --git a/ios/Gekidou/Sources/Gekidou/Logger.swift b/ios/Gekidou/Sources/Gekidou/Logger.swift new file mode 100644 index 000000000..0888661b8 --- /dev/null +++ b/ios/Gekidou/Sources/Gekidou/Logger.swift @@ -0,0 +1,93 @@ +// +// Logger.swift +// +// Unified logging system that supports both os_log (fallback) and TurboLog (when injected) +// + +import Foundation +import OSLog + +/// Log level enum for Gekidou logger +public enum GekidouLogLevel { + case debug + case info + case warning + case error + + /// Maps to OSLogType for os_log fallback + var osLogType: OSLogType { + switch self { + case .debug: + return .debug + case .info: + return .info + case .warning: + return .default + case .error: + return .error + } + } +} + +/// Type alias for log handler closure +/// - Parameters: +/// - level: The log level +/// - message: The formatted log message (already processed with arguments) +public typealias GekidouLogHandler = (GekidouLogLevel, String) -> Void + +/// Unified logger for Gekidou library +/// Supports injection of external logger (e.g., TurboLog) while falling back to os_log +public class GekidouLogger { + public static let shared = GekidouLogger() + + private var logHandler: GekidouLogHandler? + private let lock = NSLock() + private let osLog = OSLog(subsystem: "com.mattermost.Gekidou", category: "default") + + private init() { + } + + /// Sets an external log handler (e.g., TurboLog) + /// - Parameter handler: Closure that handles logging with external system + public func setLogHandler(_ handler: @escaping GekidouLogHandler) { + lock.lock() + defer { lock.unlock() } + logHandler = handler + } + + /// Logs a message with the specified level + /// - Parameters: + /// - level: The log level + /// - message: The message to log + /// - args: Optional arguments for string formatting + public func log(_ level: GekidouLogLevel, _ message: String, _ args: CVarArg...) { + lock.lock() + let handler = logHandler + lock.unlock() + + if let handler = handler { + // Use external logger (TurboLog) + // Format the message by replacing %{public}@ and %d with standard format specifiers + if args.isEmpty { + handler(level, message) + } else { + let cleanMessage = message + .replacingOccurrences(of: "%{public}@", with: "%@") + .replacingOccurrences(of: "%{public}d", with: "%d") + let formattedMessage = String(format: cleanMessage, arguments: args) + handler(level, formattedMessage) + } + } else { + // Fallback to os_log + let formattedMessage: String + if args.isEmpty { + formattedMessage = message + } else { + // Convert CVarArg to format string arguments + // Note: os_log expects format strings, but we need to handle them carefully + formattedMessage = String(format: message.replacingOccurrences(of: "%{public}@", with: "%@"), arguments: args) + } + os_log("%{public}@", log: osLog, type: level.osLogType, formattedMessage) + } + } +} diff --git a/ios/Gekidou/Sources/Gekidou/Networking/Network+Category.swift b/ios/Gekidou/Sources/Gekidou/Networking/Network+Category.swift index ebb0f1e66..c7097d81d 100644 --- a/ios/Gekidou/Sources/Gekidou/Networking/Network+Category.swift +++ b/ios/Gekidou/Sources/Gekidou/Networking/Network+Category.swift @@ -5,7 +5,10 @@ public typealias CategoriesHandler = (_ categoriesWithOrder: CategoriesWithOrder extension Network { public func fetchCategories(withTeamId teamId: String, forServerUrl serverUrl: String, completionHandler: @escaping CategoriesHandler) { var categoriesWithOrder: CategoriesWithOrder? - let channelUrl = buildApiUrl(serverUrl, "/users/me/teams/\(teamId)/channels/categories") + guard let channelUrl = buildApiUrl(serverUrl, "/users/me/teams/\(teamId)/channels/categories") else { + completionHandler(nil) + return + } request(channelUrl, usingMethod: "GET", forServerUrl: serverUrl) { data, response, error in if let data = data { categoriesWithOrder = try? JSONDecoder().decode(CategoriesWithOrder.self, from: data) diff --git a/ios/Gekidou/Sources/Gekidou/Networking/Network+Channel.swift b/ios/Gekidou/Sources/Gekidou/Networking/Network+Channel.swift index f1fc7b4b0..01237e428 100644 --- a/ios/Gekidou/Sources/Gekidou/Networking/Network+Channel.swift +++ b/ios/Gekidou/Sources/Gekidou/Networking/Network+Channel.swift @@ -9,7 +9,10 @@ extension Network { var profiles: [User]? = nil group.enter() - let channelUrl = buildApiUrl(serverUrl, "/channels/\(channelId)") + guard let channelUrl = buildApiUrl(serverUrl, "/channels/\(channelId)") else { + completionHandler(nil, nil, nil) + return + } request(channelUrl, usingMethod: "GET", forServerUrl: serverUrl) { data, response, error in if let data = data { tempChannel = try? JSONDecoder().decode(Channel.self, from: data) @@ -18,7 +21,10 @@ extension Network { } group.enter() - let myChannelUrl = buildApiUrl(serverUrl, "/channels/\(channelId)/members/me") + guard let myChannelUrl = buildApiUrl(serverUrl, "/channels/\(channelId)/members/me") else { + completionHandler(nil, nil, nil) + return + } request(myChannelUrl, usingMethod: "GET", forServerUrl: serverUrl) { data, response, error in if let data = data { myChannel = try? JSONDecoder().decode(ChannelMember.self, from: data) @@ -26,33 +32,42 @@ extension Network { group.leave() } - group.notify(queue: .main) { + // Use background queue for notification extension - no UI updates needed + group.notify(queue: DispatchQueue.global(qos: .default)) { if let tempChannel = tempChannel, (tempChannel.type == "D" || tempChannel.type == "G") && !Database.default.queryChannelExists(withId: channelId, forServerUrl: serverUrl) { let displayNameSetting = Database.default.getTeammateDisplayNameSetting(serverUrl) Network.default.fetchProfiles(inChannelId: channelId, forServerUrl: serverUrl) {[weak self] data, response, error in - if let data = data, - let currentUserId = try? Database.default.queryCurrentUserId(serverUrl), - let users = try? JSONDecoder().decode([User].self, from: data) { + guard let self = self else { return } + guard let data = data else { + if let error = error { + GekidouLogger.shared.log(.error, "Gekidou Network: Failed to fetch profiles for channel %{public}@ - %{public}@", channelId, String(describing: error)) + } + completionHandler(channel, myChannel, profiles) + return + } + + do { + let currentUserId = try Database.default.queryCurrentUserId(serverUrl) + let users = try JSONDecoder().decode([User].self, from: data) if !users.isEmpty { profiles = users.filter{ $0.id != currentUserId} if tempChannel.type == "D", let profiles = profiles, - let user = profiles.first, - let displayName = self?.displayUsername(user, displayNameSetting) { + let user = profiles.first { var chan = tempChannel - chan.displayName = displayName + chan.displayName = self.displayUsername(user, displayNameSetting) channel = chan } else if let profiles = profiles { let locale = Database.default.getCurrentUserLocale(serverUrl) - if let displayName = self?.displayGroupMessageName(profiles, locale: locale, displayNameSetting: displayNameSetting) { - var chan = tempChannel - chan.displayName = displayName - channel = chan - } + var chan = tempChannel + chan.displayName = self.displayGroupMessageName(profiles, locale: locale, displayNameSetting: displayNameSetting) + channel = chan } } + } catch { + GekidouLogger.shared.log(.error, "Gekidou Network: Failed to decode profiles or query userId for channel %{public}@ on server %{public}@ - %{public}@", channelId, serverUrl, String(describing: error)) } completionHandler(channel, myChannel, profiles) } diff --git a/ios/Gekidou/Sources/Gekidou/Networking/Network+Delegate.swift b/ios/Gekidou/Sources/Gekidou/Networking/Network+Delegate.swift index e69ee25aa..b61d9b9f8 100644 --- a/ios/Gekidou/Sources/Gekidou/Networking/Network+Delegate.swift +++ b/ios/Gekidou/Sources/Gekidou/Networking/Network+Delegate.swift @@ -1,5 +1,4 @@ import Foundation -import os.log extension Network: URLSessionDelegate, URLSessionTaskDelegate { typealias ChallengeEvaluation = (disposition: URLSession.AuthChallengeDisposition, credential: URLCredential?, error: NetworkError?) @@ -21,11 +20,7 @@ extension Network: URLSessionDelegate, URLSessionTaskDelegate { } if let error = evaluation.error { - os_log("Gekidou: %{public}@", - log: .default, - type: .error, - error.localizedDescription - ) + GekidouLogger.shared.log(.error, "Gekidou Network: %{public}@", error.localizedDescription) } completionHandler(evaluation.disposition, evaluation.credential) @@ -66,11 +61,7 @@ extension Network: URLSessionDelegate, URLSessionTaskDelegate { return (.useCredential, URLCredential(trust: trust), nil) } catch { - os_log("Gekidou: %{public}@", - log: .default, - type: .error, - error.localizedDescription - ) + GekidouLogger.shared.log(.error, "Gekidou Network: %{public}@", error.localizedDescription) return (.cancelAuthenticationChallenge, nil, error as? NetworkError) } } diff --git a/ios/Gekidou/Sources/Gekidou/Networking/Network+Posts.swift b/ios/Gekidou/Sources/Gekidou/Networking/Network+Posts.swift index 5001db4d7..6d63f083b 100644 --- a/ios/Gekidou/Sources/Gekidou/Networking/Network+Posts.swift +++ b/ios/Gekidou/Sources/Gekidou/Networking/Network+Posts.swift @@ -21,7 +21,10 @@ extension Network { let data = try JSONSerialization.data(withJSONObject: json, options: .prettyPrinted) let headers = ["Content-Type": "application/json; charset=utf-8"] let endpoint = "/posts" - let url = buildApiUrl(serverUrl, endpoint) + guard let url = buildApiUrl(serverUrl, endpoint) else { + completionHandler(nil, nil, NSError(domain: "GekidouNetwork", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid server URL"])) + return + } request( url, withMethod: "POST", @@ -32,7 +35,8 @@ extension Network { ) } } catch { - + GekidouLogger.shared.log(.error, "Gekidou Network: Failed to serialize post JSON for server %{public}@ - %{public}@", serverUrl, String(describing: error)) + completionHandler(nil, nil, error) } } @@ -46,25 +50,36 @@ extension Network { if receivingThreads { let since = rootId.isEmpty ? nil : Database.default.queryLastPostInThread(withRootId: rootId, forServerUrl: serverUrl) - let queryParams = since == nil ? "?perPage=60&fromCreateAt=0&direction=up" : "?fromCreateAt=\(Int64(since!))&direction=down" + let queryParams: String + if let sinceValue = since { + queryParams = "?fromCreateAt=\(Int64(sinceValue))&direction=down" + } else { + queryParams = "?perPage=60&fromCreateAt=0&direction=up" + } endpoint = "/posts/\(rootId)/thread\(queryParams)\(additionalParams)" } else { let since = Database.default.queryPostsSinceForChannel(withId: channelId, forServerUrl: serverUrl) - let queryParams = since == nil ? "?page=0&per_page=60" : "?since=\(Int64(since!))" + let queryParams: String + if let sinceValue = since { + queryParams = "?since=\(Int64(sinceValue))" + } else { + queryParams = "?page=0&per_page=60" + } endpoint = "/channels/\(channelId)/posts\(queryParams)\(additionalParams)" } - - let url = buildApiUrl(serverUrl, endpoint) + + guard let url = buildApiUrl(serverUrl, endpoint) else { + completionHandler(nil, nil, nil) + return + } request(url, usingMethod: "GET", forServerUrl: serverUrl) {data, response, error in if let data = data { postResponse = try? JSONDecoder().decode(PostResponse.self, from: data) } - DispatchQueue.main.async { - self.processPostsFetched(postResponse, andAlreadyLoadedProfilesIds: alreadyLoadedUserIds, - usingCRT: isCRTEnabled, forServerUrl: serverUrl, - completionHandler: completionHandler) - } + self.processPostsFetched(postResponse, andAlreadyLoadedProfilesIds: alreadyLoadedUserIds, + usingCRT: isCRTEnabled, forServerUrl: serverUrl, + completionHandler: completionHandler) } } diff --git a/ios/Gekidou/Sources/Gekidou/Networking/Network+Team.swift b/ios/Gekidou/Sources/Gekidou/Networking/Network+Team.swift index 51aabfbee..6b0a24fa8 100644 --- a/ios/Gekidou/Sources/Gekidou/Networking/Network+Team.swift +++ b/ios/Gekidou/Sources/Gekidou/Networking/Network+Team.swift @@ -5,11 +5,17 @@ extension Network { let group = DispatchGroup() var team: Team? = nil var myTeam: TeamMember? = nil - + var shouldCallCompletion = true + if !Database.default.queryTeamExists(withId: teamId, forServerUrl: serverUrl) { group.enter() - - let url = buildApiUrl(serverUrl, "/teams/\(teamId)") + + guard let url = buildApiUrl(serverUrl, "/teams/\(teamId)") else { + group.leave() + shouldCallCompletion = false + completionHandler(nil, nil) + return + } request(url, usingMethod: "GET", forServerUrl: serverUrl) { data, response, error in if let data = data { team = try? JSONDecoder().decode(Team.self, from: data) @@ -17,10 +23,15 @@ extension Network { group.leave() } } - + if !Database.default.queryMyTeamExists(withId: teamId, forServerUrl: serverUrl) { group.enter() - let url = buildApiUrl(serverUrl, "/teams/\(teamId)/members/me") + guard let url = buildApiUrl(serverUrl, "/teams/\(teamId)/members/me") else { + group.leave() + shouldCallCompletion = false + completionHandler(nil, nil) + return + } request(url, usingMethod: "GET", forServerUrl: serverUrl) { data, response, error in if let data = data { myTeam = try? JSONDecoder().decode(TeamMember.self, from: data) @@ -28,9 +39,12 @@ extension Network { group.leave() } } - - group.notify(queue: .main) { - completionHandler(team, myTeam) + + if shouldCallCompletion { + // Use background queue for notification extension (not .main) + group.notify(queue: DispatchQueue.global(qos: .default)) { + completionHandler(team, myTeam) + } } } } diff --git a/ios/Gekidou/Sources/Gekidou/Networking/Network+Thread.swift b/ios/Gekidou/Sources/Gekidou/Networking/Network+Thread.swift index 08eb79ddf..1a578df7b 100644 --- a/ios/Gekidou/Sources/Gekidou/Networking/Network+Thread.swift +++ b/ios/Gekidou/Sources/Gekidou/Networking/Network+Thread.swift @@ -11,7 +11,10 @@ extension Network { completionHandler(nil) return } - let url = buildApiUrl(serverUrl, "/users/\(currentUserId)/teams/\(threadTeamId)/threads/\(threadId)") + guard let url = buildApiUrl(serverUrl, "/users/\(currentUserId)/teams/\(threadTeamId)/threads/\(threadId)") else { + completionHandler(nil) + return + } request(url, usingMethod: "GET", forServerUrl: serverUrl) {data, response, error in if let data = data { thread = try? JSONDecoder().decode(PostThread.self, from: data) diff --git a/ios/Gekidou/Sources/Gekidou/Networking/Network+Users.swift b/ios/Gekidou/Sources/Gekidou/Networking/Network+Users.swift index 4d7f0b96f..66b264653 100644 --- a/ios/Gekidou/Sources/Gekidou/Networking/Network+Users.swift +++ b/ios/Gekidou/Sources/Gekidou/Networking/Network+Users.swift @@ -49,30 +49,42 @@ extension Network { public func fetchUsers(byIds userIds: [String], forServerUrl serverUrl: String, completionHandler: @escaping ResponseHandler) { let endpoint = "/users/ids" - let url = buildApiUrl(serverUrl, endpoint) + guard let url = buildApiUrl(serverUrl, endpoint) else { + completionHandler(nil, nil, nil) + return + } let data = try? JSONSerialization.data(withJSONObject: userIds, options: []) - + return request(url, withMethod: "POST", withBody: data, andHeaders: nil, forServerUrl: serverUrl, completionHandler: completionHandler) } public func fetchUsers(byUsernames usernames: [String], forServerUrl serverUrl: String, completionHandler: @escaping ResponseHandler) { let endpoint = "/users/usernames" - let url = buildApiUrl(serverUrl, endpoint) + guard let url = buildApiUrl(serverUrl, endpoint) else { + completionHandler(nil, nil, nil) + return + } let data = try? JSONSerialization.data(withJSONObject: usernames, options: []) - + return request(url, withMethod: "POST", withBody: data, andHeaders: nil, forServerUrl: serverUrl, completionHandler: completionHandler) } public func fetchProfiles(inChannelId channelId: String, forServerUrl serverUrl: String, completionHandler: @escaping ResponseHandler) { let endpoint = "/users?in_channel=\(channelId)&page=0&per_page=8&sort=" - let url = buildApiUrl(serverUrl, endpoint) + guard let url = buildApiUrl(serverUrl, endpoint) else { + completionHandler(nil, nil, nil) + return + } request(url, usingMethod: "GET", forServerUrl: serverUrl, completionHandler: completionHandler) } public func fetchUserProfilePicture(userId: String, lastUpdateAt: Double, forServerUrl serverUrl: String, completionHandler: @escaping ResponseHandler) { let endpoint = "/users/\(userId)/image?lastPictureUpdate=\(Int64(lastUpdateAt))" - let url = buildApiUrl(serverUrl, endpoint) - + guard let url = buildApiUrl(serverUrl, endpoint) else { + completionHandler(nil, nil, nil) + return + } + return request(url, usingMethod: "GET", forServerUrl: serverUrl, completionHandler: completionHandler) } } diff --git a/ios/Gekidou/Sources/Gekidou/Networking/Network.swift b/ios/Gekidou/Sources/Gekidou/Networking/Network.swift index a59aaedd6..b642e0e0c 100644 --- a/ios/Gekidou/Sources/Gekidou/Networking/Network.swift +++ b/ios/Gekidou/Sources/Gekidou/Networking/Network.swift @@ -6,7 +6,6 @@ // import Foundation -import os.log public typealias ResponseHandler = (_ data: Data?, _ response: URLResponse?, _ error: Error?) -> Void @@ -16,26 +15,61 @@ public class Network: NSObject { internal let urlVersion = "/api/v4" internal var certificates: [String: [SecCertificate]] = [:] + // Track active network tasks for potential cancellation + private var activeTasks = NSMutableSet() + private let tasksLock = NSLock() + @objc public static let `default` = Network() - + override private init() { super.init() - + loadPinnedCertificates() - + queue.maxConcurrentOperationCount = 1 - + let config = URLSessionConfiguration.default config.httpAdditionalHeaders = ["X-Requested-With": "XMLHttpRequest"] config.allowsCellularAccess = true config.httpMaximumConnectionsPerHost = 10 - + // Reduced timeouts for notification extension (iOS terminates extension at 30s) + config.timeoutIntervalForRequest = 15.0 // 15 second timeout per request + config.timeoutIntervalForResource = 20.0 // 20 second total timeout (well under 30s limit) + self.session = URLSession.init( configuration: config, delegate: self, delegateQueue: nil ) } + + // Cancel all active network requests (useful for extension termination) + @objc public func cancelAllRequests() { + tasksLock.lock() + defer { tasksLock.unlock() } + + for task in activeTasks { + if let urlTask = task as? URLSessionTask { + urlTask.cancel() + } + } + activeTasks.removeAllObjects() + } + + private func trackTask(_ task: URLSessionTask) { + tasksLock.lock() + defer { tasksLock.unlock() } + activeTasks.add(task) + } + + private func untrackTask(withIdentifier identifier: Int) { + tasksLock.lock() + defer { tasksLock.unlock() } + if let tasks = activeTasks.allObjects as? [URLSessionTask], + let taskToRemove = tasks.first(where: { $0.taskIdentifier == identifier }) { + activeTasks.remove(taskToRemove) + } + } internal func loadPinnedCertificates() { guard let certsPath = Bundle.app.resourceURL?.appendingPathComponent("certs") else { @@ -55,25 +89,16 @@ public class Network: NSObject { } else { certificates[domain] = [certificate] } - os_log("Gekidou: loaded certificate %{public}@ for domain %{public}@", - log: .default, - type: .info, - cert.lastPathComponent, domain - ) + GekidouLogger.shared.log(.info, "Gekidou: loaded certificate %{public}@ for domain %{public}@", cert.lastPathComponent, domain) } } } catch { - os_log( - "Gekidou: Error loading pinned certificates -- %{public}@", - log: .default, - type: .error, - String(describing: error) - ) + GekidouLogger.shared.log(.error, "Gekidou: Error loading pinned certificates -- %{public}@", String(describing: error)) } } - internal func buildApiUrl(_ serverUrl: String, _ endpoint: String) -> URL { - return URL(string: "\(serverUrl)\(urlVersion)\(endpoint)")! + internal func buildApiUrl(_ serverUrl: String, _ endpoint: String) -> URL? { + return URL(string: "\(serverUrl)\(urlVersion)\(endpoint)") } internal func responseOK(_ response: URLResponse?) -> Bool { @@ -111,12 +136,22 @@ public class Network: NSObject { } internal func request(_ url: URL, withMethod method: String, withBody body: Data?, andHeaders headers: [String:String]?, forServerUrl serverUrl: String, completionHandler: @escaping ResponseHandler) { + guard let session = session else { + GekidouLogger.shared.log(.error, "Gekidou Network: URLSession is nil, cannot make request") + completionHandler(nil, nil, NSError(domain: "GekidouNetwork", code: -1, userInfo: [NSLocalizedDescriptionKey: "URLSession not initialized"])) + return + } + let urlRequest = buildURLRequest(for: url, usingMethod: method, withBody: body, andHeaders: headers, forServerUrl: serverUrl) - - let task = session!.dataTask(with: urlRequest) { data, response, error in + + var taskId = 0 + let task = session.dataTask(with: urlRequest) { [weak self] data, response, error in + defer { self?.untrackTask(withIdentifier: taskId) } completionHandler(data, response, error) } - + taskId = task.taskIdentifier + trackTask(task) task.resume() + } } diff --git a/ios/Gekidou/Sources/Gekidou/PushNotification/AckNotification.swift b/ios/Gekidou/Sources/Gekidou/PushNotification/AckNotification.swift index edaadf996..c0036c7d8 100644 --- a/ios/Gekidou/Sources/Gekidou/PushNotification/AckNotification.swift +++ b/ios/Gekidou/Sources/Gekidou/PushNotification/AckNotification.swift @@ -38,11 +38,30 @@ public struct AckNotification: Codable { isIdLoaded = false } receivedAt = Date().millisecondsSince1970 - + if let decodedIdentifier = try? container.decode(String.self, forKey: .server_id) { - serverUrl = try Database.default.getServerUrlForServer(decodedIdentifier) + if let url = try? Database.default.getServerUrlForServer(decodedIdentifier) { + serverUrl = url + } else { + GekidouLogger.shared.log(.error, "Gekidou AckNotification: Failed to get server URL for server ID %{public}@", decodedIdentifier) + throw DecodingError.dataCorruptedError( + forKey: .server_id, + in: container, + debugDescription: "Failed to retrieve server URL for server ID: \(decodedIdentifier)" + ) + } } else { - serverUrl = try Database.default.getOnlyServerUrl() + if let url = try? Database.default.getOnlyServerUrl() { + serverUrl = url + } else { + GekidouLogger.shared.log(.error, "Gekidou AckNotification: Failed to get only server URL - no servers configured") + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Failed to retrieve server URL - no servers configured" + ) + ) + } } } diff --git a/ios/Gekidou/Sources/Gekidou/PushNotification/PushNotification+FetchData.swift b/ios/Gekidou/Sources/Gekidou/PushNotification/PushNotification+FetchData.swift index 0a9a33035..b01b9bc51 100644 --- a/ios/Gekidou/Sources/Gekidou/PushNotification/PushNotification+FetchData.swift +++ b/ios/Gekidou/Sources/Gekidou/PushNotification/PushNotification+FetchData.swift @@ -1,5 +1,4 @@ import Foundation -import os.log import SQLite import UserNotifications @@ -33,9 +32,9 @@ public struct PushNotificationData: Encodable { } extension PushNotification { - public func fetchDataForPushNotification(_ notification: [AnyHashable:Any], withContentHandler contentHander: @escaping ((_ data: PushNotificationData) -> Void)) { + public func fetchDataForPushNotification(_ notification: [AnyHashable:Any], withContentHandler contentHandler: @escaping ((_ data: PushNotificationData) -> Void)) { let operation = BlockOperation { - os_log(OSLogType.default, "Mattermost Notifications: Fetch notification data.") + GekidouLogger.shared.log(.info, "Gekidou PushNotification: Fetch notification data.") let fetchGroup = DispatchGroup() let teamId = notification["team_id"] as? String ?? "" @@ -102,7 +101,19 @@ extension PushNotification { fetchGroup.leave() } - fetchGroup.notify(queue: DispatchQueue.main) { + // Use background queue for notification extension - no UI updates needed + // Add timeout to prevent extension termination (iOS kills notification extension at 30s) + DispatchQueue.global(qos: .default).async { + let timeout: DispatchTime = .now() + .seconds(18) // 18 second timeout (leaves 12s buffer) + let result = fetchGroup.wait(timeout: timeout) + + if result == .timedOut { + GekidouLogger.shared.log(.error, "Gekidou PushNotification: Network fetch timeout after 18 seconds for server %{public}@", serverUrl) + // Return partial data even on timeout + contentHandler(notificationData) + return + } + if isCRTEnabled && !rootId.isEmpty { Network.default.fetchThread(byId: rootId, belongingToTeamId: teamId, forServerUrl: serverUrl) { thread in if let thread = thread { @@ -123,10 +134,10 @@ extension PushNotification { notificationData.threads?[index] = copy } } - contentHander(notificationData); + contentHandler(notificationData); } } else { - contentHander(notificationData); + contentHandler(notificationData); } } } @@ -147,31 +158,59 @@ extension PushNotification { let teamId = notification.userInfo["team_id"] as? String ?? "" fetchDataForPushNotification(notification.userInfo) { data in - if let db = try? Database.default.getDatabaseForServer(serverUrl) { - try? db.transaction { + guard let db = try? Database.default.getDatabaseForServer(serverUrl) else { + GekidouLogger.shared.log(.error, "Gekidou PushNotification: Failed to get database for server %{public}@", serverUrl) + notification.badge = Gekidou.Database.default.getTotalMentions() as NSNumber + contentHandler(notification) + return + } + + do { + try db.transaction { let receivingThreads = isCRTEnabled && !rootId.isEmpty + if let team = data.team { - try? Database.default.insertTeam(db, team) + do { + try Database.default.insertTeam(db, team) + } catch { + GekidouLogger.shared.log(.error, "Gekidou PushNotification: Failed to insert team %{public}@ - %{public}@", team.id, String(describing: error)) + } } - + if let myTeam = data.myTeam { - try? Database.default.insertMyTeam(db, myTeam) + do { + try Database.default.insertMyTeam(db, myTeam) + } catch { + GekidouLogger.shared.log(.error, "Gekidou PushNotification: Failed to insert myTeam %{public}@ - %{public}@", myTeam.id, String(describing: error)) + } } - + if let categories = data.categories { - try? Database.default.insertCategoriesWithChannels(db, categories.categories) + do { + try Database.default.insertCategoriesWithChannels(db, categories.categories) + } catch { + GekidouLogger.shared.log(.error, "Gekidou PushNotification: Failed to insert categories - %{public}@", String(describing: error)) + } } - + if let categoryChannels = data.categoryChannels, !categoryChannels.isEmpty { - try? Database.default.insertChannelToDefaultCategory(db, categoryChannels) + do { + try Database.default.insertChannelToDefaultCategory(db, categoryChannels) + } catch { + GekidouLogger.shared.log(.error, "Gekidou PushNotification: Failed to insert category channels - %{public}@", String(describing: error)) + } } - + if let channel = data.channel, !Database.default.queryChannelExists(withId: channel.id, forServerUrl: serverUrl) { - try? Database.default.insertChannel(db, channel) + do { + try Database.default.insertChannel(db, channel) + } catch { + GekidouLogger.shared.log(.error, "Gekidou PushNotification: Failed to insert channel %{public}@ - %{public}@", channel.id, String(describing: error)) + } } - + if var myChannel = data.myChannel { var lastFetchedAt: Double = 0 if let postResponse = data.posts, !receivingThreads { @@ -186,21 +225,39 @@ extension PushNotification { myChannel.internalMsgCount = channel.totalMsgCount - myChannel.msgCount myChannel.internalMsgCountRoot = channel.totalMsgCountRoot - myChannel.msgCountRoot } - try? Database.default.insertOrUpdateMyChannel(db, myChannel, isCRTEnabled, lastFetchedAt, lastPostAt) + do { + try Database.default.insertOrUpdateMyChannel(db, myChannel, isCRTEnabled, lastFetchedAt, lastPostAt) + } catch { + GekidouLogger.shared.log(.error, "Gekidou PushNotification: Failed to insert/update myChannel %{public}@ - %{public}@", myChannel.id, String(describing: error)) + } } - + if let posts = data.posts { - try? Database.default.handlePostData(db, posts, channelId, receivingThreads) + do { + try Database.default.handlePostData(db, posts, channelId, receivingThreads) + } catch { + GekidouLogger.shared.log(.error, "Gekidou PushNotification: Failed to handle post data for channel %{public}@ - %{public}@", channelId, String(describing: error)) + } } - + if let threads = data.threads { - try? Database.default.handleThreads(db, threads, forTeamId: teamId) + do { + try Database.default.handleThreads(db, threads, forTeamId: teamId) + } catch { + GekidouLogger.shared.log(.error, "Gekidou PushNotification: Failed to handle threads for team %{public}@ - %{public}@", teamId, String(describing: error)) + } } - + if !data.users.isEmpty { - try? Database.default.insertUsers(db, data.users) + do { + try Database.default.insertUsers(db, data.users) + } catch { + GekidouLogger.shared.log(.error, "Gekidou PushNotification: Failed to insert %d users - %{public}@", data.users.count, String(describing: error)) + } } } + } catch { + GekidouLogger.shared.log(.error, "Gekidou PushNotification: Database transaction failed for server %{public}@ - %{public}@", serverUrl, String(describing: error)) } notification.badge = Gekidou.Database.default.getTotalMentions() as NSNumber diff --git a/ios/Gekidou/Sources/Gekidou/PushNotification/PushNotification+ProfileImage.swift b/ios/Gekidou/Sources/Gekidou/PushNotification/PushNotification+ProfileImage.swift index 2842ba9f9..0962eaca5 100644 --- a/ios/Gekidou/Sources/Gekidou/PushNotification/PushNotification+ProfileImage.swift +++ b/ios/Gekidou/Sources/Gekidou/PushNotification/PushNotification+ProfileImage.swift @@ -1,5 +1,4 @@ import Foundation -import os.log extension PushNotification { public func fetchProfileImageSync(_ serverUrl: String, senderId: String, overrideIconUrl: String?, completionHandler: @escaping (_ data: Data?) -> Void) { @@ -12,14 +11,12 @@ extension PushNotification { ImageCache.default.insertImage(data, for: senderId, updatedAt: updatedAt, forServer: serverUrl) completionHandler(data) } else { - os_log( - OSLogType.default, - "Mattermost Notifications: Request for profile image failed with status %{public}@ and error %{public}@", - String(statusCode), - errorMessage - ) + GekidouLogger.shared.log(.info, "Gekidou PushNotification: Request for profile image failed with status %{public}@ and error %{public}@", String(statusCode), errorMessage) completionHandler(nil) } + } else { + GekidouLogger.shared.log(.error, "Gekidou PushNotification: Network response error - response is not HTTPURLResponse: %{public}@", error?.localizedDescription ?? "unknown error") + completionHandler(nil) } } @@ -31,16 +28,17 @@ extension PushNotification { updatedAt = lastUpdateAt } if let image = ImageCache.default.image(for: senderId, updatedAt: updatedAt, forServer: serverUrl) { - os_log(OSLogType.default, "Mattermost Notifications: cached image") + GekidouLogger.shared.log(.info, "Gekidou PushNotification: cached image") completionHandler(image) - } else { - ImageCache.default.removeImage(for: senderId, forServer: serverUrl) - os_log(OSLogType.default, "Mattermost Notifications: image not cached") - Network.default.fetchUserProfilePicture( - userId: senderId, lastUpdateAt: updatedAt, - forServerUrl: serverUrl, completionHandler: processResponse - ) + return } + + ImageCache.default.removeImage(for: senderId, forServer: serverUrl) + GekidouLogger.shared.log(.info, "Gekidou PushNotification: image not cached") + Network.default.fetchUserProfilePicture( + userId: senderId, lastUpdateAt: updatedAt, + forServerUrl: serverUrl, completionHandler: processResponse + ) } } } diff --git a/ios/Gekidou/Sources/Gekidou/PushNotification/PushNotification+Signature.swift b/ios/Gekidou/Sources/Gekidou/PushNotification/PushNotification+Signature.swift index 0fe1da18c..56e4d4b07 100644 --- a/ios/Gekidou/Sources/Gekidou/PushNotification/PushNotification+Signature.swift +++ b/ios/Gekidou/Sources/Gekidou/PushNotification/PushNotification+Signature.swift @@ -1,6 +1,5 @@ import Foundation import UserNotifications -import os.log import SwiftJWT struct NotificationClaims : Claims { @@ -16,64 +15,51 @@ extension PushNotification { guard let signature = userInfo["signature"] as? String else { // Backward compatibility with old push proxies - os_log( - OSLogType.default, - "Mattermost Notifications: Signature verification: No signature in the notification" - ) + GekidouLogger.shared.log(.info, "Gekidou PushNotification: Signature verification: No signature in the notification") return true } - + guard let serverId = userInfo["server_id"] as? String else { - os_log( - OSLogType.default, - "Mattermost Notifications: Signature verification: No server_id in the notification" - ) + GekidouLogger.shared.log(.info, "Gekidou PushNotification: Signature verification: No server_id in the notification") return false } - + guard let serverUrl = try? Database.default.getServerUrlForServer(serverId) else { - os_log( - OSLogType.default, - "Mattermost Notifications: Signature verification: No server_url for server_id" - ) + GekidouLogger.shared.log(.info, "Gekidou PushNotification: Signature verification: No server_url for server_id") return false } if signature == "NO_SIGNATURE" { guard let version = Database.default.getConfig(serverUrl, "Version") else { - os_log( - OSLogType.default, - "Mattermost Notifications: Signature verification: No server version" - ) + GekidouLogger.shared.log(.info, "Gekidou PushNotification: Signature verification: No server version") return false } - + let parts = version.components(separatedBy: "."); - if (parts.count < 3) { - os_log( - OSLogType.default, - "Mattermost Notifications: Signature verification: Invalid server version" - ) + guard parts.count >= 3 + else { + GekidouLogger.shared.log(.info, "Gekidou PushNotification: Signature verification: Invalid server version format") return false } guard let major = Int(parts[0]), let minor = Int(parts[1]), let patch = Int(parts[2]) else { - os_log( - OSLogType.default, - "Mattermost Notifications: Signature verification: Invalid server version" - ) + GekidouLogger.shared.log(.info, "Gekidou PushNotification: Signature verification: Invalid server version") return false } let versionTargets = [[9,8,0], [9,7,3], [9,6,3], [9,5,5], [8,1,14]] var rejected = false for (index, versionTarget) in versionTargets.enumerated() { - let first = index == 0; + let first = index == 0 + guard versionTarget.count >= 3 else { + GekidouLogger.shared.log(.error, "Gekidou PushNotification: Invalid versionTarget format at index %{public}d: expected 3 elements, got %{public}d", index, versionTarget.count) + continue + } let majorTarget = versionTarget[0] let minorTarget = versionTarget[1] let patchTarget = versionTarget[2] @@ -114,10 +100,7 @@ extension PushNotification { } if (rejected) { - os_log( - OSLogType.default, - "Mattermost Notifications: Signature verification: Server version should send signature" - ) + GekidouLogger.shared.log(.info, "Gekidou PushNotification: Signature verification: Server version should send signature") return false; } @@ -127,10 +110,7 @@ extension PushNotification { guard let signingKey = Database.default.getConfig(serverUrl, "AsymmetricSigningPublicKey") else { - os_log( - OSLogType.default, - "Mattermost Notifications: Signature verification: No signing key" - ) + GekidouLogger.shared.log(.info, "Gekidou PushNotification: Signature verification: No signing key") return false } @@ -139,64 +119,49 @@ extension PushNotification { \(signingKey) -----END PUBLIC KEY----- """ - let jwtVerifier = JWTVerifier.es256(publicKey: keyPEM.data(using: .utf8)!) + guard let keyPEMData = keyPEM.data(using: .utf8) else { + GekidouLogger.shared.log(.info, "Gekidou PushNotification: Signature verification: Failed to encode key PEM as UTF-8") + return false + } + + let jwtVerifier = JWTVerifier.es256(publicKey: keyPEMData) guard let newJWT = try? JWT(jwtString: signature, verifier: jwtVerifier) else { - os_log( - OSLogType.default, - "Mattermost Notifications: Signature verification: Cannot verify the signature" - ) + GekidouLogger.shared.log(.info, "Gekidou PushNotification: Signature verification: Cannot verify the signature") return false } - + guard let ackId = userInfo["ack_id"] as? String else { - os_log( - OSLogType.default, - "Mattermost Notifications: Signature verification: No ack_id in the notification" - ) + GekidouLogger.shared.log(.info, "Gekidou PushNotification: Signature verification: No ack_id in the notification") return false } - + if (ackId != newJWT.claims.ack_id) { - os_log( - OSLogType.default, - "Mattermost Notifications: Signature verification: ackId is different" - ) + GekidouLogger.shared.log(.info, "Gekidou PushNotification: Signature verification: ackId is different") return false } - + guard let storedDeviceToken = Database.default.getDeviceToken() else { - os_log( - OSLogType.default, - "Mattermost Notifications: Signature verification: No device token" - ) + GekidouLogger.shared.log(.info, "Gekidou PushNotification: Signature verification: No device token") return false } - + let tokenParts = storedDeviceToken.components(separatedBy: ":") - if (tokenParts.count != 2) { - os_log( - OSLogType.default, - "Mattermost Notifications: Signature verification: Wrong stored device token format" - ) + guard tokenParts.count == 2 else { + GekidouLogger.shared.log(.info, "Gekidou PushNotification: Signature verification: Wrong stored device token format (expected 2 parts, got %{public}d)", tokenParts.count) return false } + let deviceToken = tokenParts[1].dropLast(1) if (deviceToken.isEmpty) { - os_log( - OSLogType.default, - "Mattermost Notifications: Signature verification: Empty stored device token" - ) + GekidouLogger.shared.log(.info, "Gekidou PushNotification: Signature verification: Empty stored device token") return false } if (deviceToken != newJWT.claims.device_id) { - os_log( - OSLogType.default, - "Mattermost Notifications: Signature verification: Device token is different" - ) + GekidouLogger.shared.log(.info, "Gekidou PushNotification: Signature verification: Device token is different") return false } diff --git a/ios/Gekidou/Sources/Gekidou/PushNotification/PushNotification.swift b/ios/Gekidou/Sources/Gekidou/PushNotification/PushNotification.swift index e91c65e70..205e0441b 100644 --- a/ios/Gekidou/Sources/Gekidou/PushNotification/PushNotification.swift +++ b/ios/Gekidou/Sources/Gekidou/PushNotification/PushNotification.swift @@ -1,16 +1,38 @@ import Foundation import UserNotifications -import os.log public class PushNotification: NSObject { @objc public static let `default` = PushNotification() let fibonacciBackoffsInSeconds = [1.0, 2.0, 3.0, 5.0, 8.0] - private var retryIndex = 0 + // Track retry count per notification (keyed by ack_id) instead of globally + private var retryIndices: [String: Int] = [:] + private let retryLock = NSLock() let queue = OperationQueue() - + public override init() { queue.maxConcurrentOperationCount = 1 } + + // Thread-safe retry index access per notification + private func getAndIncrementRetryIndex(for ackId: String) -> Int { + retryLock.lock() + defer { retryLock.unlock() } + let current = retryIndices[ackId] ?? 0 + retryIndices[ackId] = current + 1 + return current + } + + private func getCurrentRetryIndex(for ackId: String) -> Int { + retryLock.lock() + defer { retryLock.unlock() } + return retryIndices[ackId] ?? 0 + } + + private func clearRetryIndex(for ackId: String) { + retryLock.lock() + defer { retryLock.unlock() } + retryIndices.removeValue(forKey: ackId) + } @objc public func postNotificationReceipt(_ userInfo: [AnyHashable:Any]) { let notification = UNMutableNotificationContent() @@ -19,21 +41,19 @@ public class PushNotification: NSObject { } public func postNotificationReceipt(_ notification: UNMutableNotificationContent, completionHandler: @escaping (_ notification: UNMutableNotificationContent?) -> Void) { - if let jsonData = try? JSONSerialization.data(withJSONObject: notification.userInfo), - let ackNotification = try? JSONDecoder().decode(AckNotification.self, from: jsonData) { + guard let jsonData = try? JSONSerialization.data(withJSONObject: notification.userInfo) else { + GekidouLogger.shared.log(.error, "Gekidou PushNotification: Failed to serialize notification userInfo to JSON") + completionHandler(nil) + return + } + + do { + let ackNotification = try JSONDecoder().decode(AckNotification.self, from: jsonData) postNotificationReceiptWithRetry(ackNotification) { data in - os_log( - OSLogType.default, - "Mattermost Notifications: process receipt response for serverUrl %{public}@", - ackNotification.serverUrl - ) - + GekidouLogger.shared.log(.info, "Gekidou PushNotification: process receipt response for serverUrl %{public}@", ackNotification.serverUrl) + guard let data = data else { - os_log( - OSLogType.default, - "Mattermost Notifications: process receipt response for serverUrl %{public}@ does not contain data", - ackNotification.serverUrl - ) + GekidouLogger.shared.log(.info, "Gekidou PushNotification: process receipt response for serverUrl %{public}@ does not contain data", ackNotification.serverUrl) completionHandler(nil) return } @@ -53,64 +73,75 @@ public class PushNotification: NSObject { } completionHandler(notification) } - return + } catch { + GekidouLogger.shared.log(.error, "Gekidou PushNotification: Failed to decode AckNotification - %{public}@", String(describing: error)) + completionHandler(nil) } - os_log(OSLogType.default, "Mattermost Notifications: Could not parse ACK notification") - completionHandler(nil) } private func postNotificationReceiptWithRetry(_ ackNotification: AckNotification, completionHandler: @escaping (_ data: Data?) -> Void) { - if (self.retryIndex >= self.fibonacciBackoffsInSeconds.count) { - os_log(OSLogType.default, "Mattermost Notifications: max retries reached. Will call sendMessageIntent") + let ackId = ackNotification.id + let currentRetryIndex = getCurrentRetryIndex(for: ackId) + if (currentRetryIndex >= self.fibonacciBackoffsInSeconds.count) { + GekidouLogger.shared.log(.info, "Gekidou PushNotification: max retries reached for notification %{public}@", ackId) + clearRetryIndex(for: ackId) completionHandler(nil) return } - + do { let jsonData = try JSONEncoder().encode(ackNotification) let headers = ["Content-Type": "application/json; charset=utf-8"] let endpoint = "/notifications/ack" - let url = Network.default.buildApiUrl(ackNotification.serverUrl, endpoint) + guard let url = Network.default.buildApiUrl(ackNotification.serverUrl, endpoint) else { + GekidouLogger.shared.log(.error, "Gekidou PushNotification: Failed to build API URL for server %{public}@", ackNotification.serverUrl) + completionHandler(nil) + return + } Network.default.request( url, withMethod: "POST", withBody: jsonData, andHeaders: headers, forServerUrl: ackNotification.serverUrl) {[weak self] data, response, error in - if error != nil && ackNotification.isIdLoaded, - let nsError = error as? NSError, - let fibonacciBackoffsInSeconds = self?.fibonacciBackoffsInSeconds, - let retryIndex = self?.retryIndex, - fibonacciBackoffsInSeconds.count > retryIndex { - if nsError.code == NSURLErrorCancelled { - completionHandler(nil) - return - } - let backoffInSeconds = fibonacciBackoffsInSeconds[retryIndex] - self?.retryIndex += 1 - - DispatchQueue.main.asyncAfter(deadline: .now() + backoffInSeconds, execute: {[weak self] in - os_log( - OSLogType.default, - "Mattermost Notifications: receipt retrieval failed. Retry %{public}@", - String(retryIndex) - ) - self?.postNotificationReceiptWithRetry(ackNotification, completionHandler: completionHandler) - }) - + guard let self = self else { + completionHandler(nil) return } - + + if error != nil && ackNotification.isIdLoaded, + let nsError = error as? NSError, + nsError.code != NSURLErrorCancelled { + let retryIndex = self.getAndIncrementRetryIndex(for: ackNotification.id) + if retryIndex < self.fibonacciBackoffsInSeconds.count { + let backoffInSeconds = self.fibonacciBackoffsInSeconds[retryIndex] + + DispatchQueue.global(qos: .default).asyncAfter(deadline: .now() + backoffInSeconds, execute: {[weak self] in + GekidouLogger.shared.log(.info, "Gekidou PushNotification: receipt retrieval failed for %{public}@. Retry %{public}@", ackNotification.id, String(retryIndex)) + self?.postNotificationReceiptWithRetry(ackNotification, completionHandler: completionHandler) + }) + return + } else { + self.clearRetryIndex(for: ackNotification.id) + } + } else { + self.clearRetryIndex(for: ackNotification.id) + } + completionHandler(data) } } catch { - os_log(OSLogType.default, "Mattermost Notifications: receipt failed %{public}@", error.localizedDescription) + GekidouLogger.shared.log(.info, "Gekidou PushNotification: receipt failed %{public}@", error.localizedDescription) completionHandler(nil) } } private func parseIdLoadedNotification(_ data: Data?) -> [AnyHashable:Any]? { - if let data = data, - let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] - { - os_log(OSLogType.default, "Mattermost Notifications: parsed json response") + guard let data = data else { return nil } + + do { + guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { + GekidouLogger.shared.log(.error, "Gekidou PushNotification: Parsed JSON is not a dictionary") + return nil + } + GekidouLogger.shared.log(.info, "Gekidou PushNotification: parsed json response") var userInfo = [AnyHashable:Any]() let userInfoKeys = ["channel_name", "team_id", "sender_id", "sender_name", "root_id", "override_username", "override_icon_url", "from_webhook", "message"] for key in userInfoKeys { @@ -120,8 +151,9 @@ public class PushNotification: NSObject { } return userInfo + } catch { + GekidouLogger.shared.log(.error, "Gekidou PushNotification: Failed to parse notification JSON - %{public}@", String(describing: error)) + return nil } - - return nil } } diff --git a/ios/Gekidou/Sources/Gekidou/Storage/Database+Channels.swift b/ios/Gekidou/Sources/Gekidou/Storage/Database+Channels.swift index a5a276a2f..d2b0639dd 100644 --- a/ios/Gekidou/Sources/Gekidou/Storage/Database+Channels.swift +++ b/ios/Gekidou/Sources/Gekidou/Storage/Database+Channels.swift @@ -33,7 +33,7 @@ extension Database { INNER JOIN Team t ON c.team_id=t.id """ let stmt = try db.prepare(stmtString) - let count = try stmt.scalar() as! Int64 + let count = (try stmt.scalar() as? Int64) ?? 0 return count > 0 } catch { return false @@ -66,24 +66,26 @@ extension Database { let channelId = try queryCurrentChannelId(serverUrl) guard let currentUserId = try? queryCurrentUserId(serverUrl) else {return nil} let db = try getDatabaseForServer(serverUrl) - + let stmtString = """ SELECT DISTINCT c.*, t.display_name AS team_display_name, \ my.last_viewed_at, COUNT(DISTINCT cm.user_id) AS member_count, u.delete_at > 0 as deactivated \ FROM Channel c \ INNER JOIN MyChannel my ON c.id=my.id \ LEFT JOIN (\ - SELECT *, ROW_NUMBER() OVER (PARTITION by user_id order by user_id ) as Ranking FROM ChannelMembership WHERE user_id != '\(currentUserId)' \ + SELECT *, ROW_NUMBER() OVER (PARTITION by user_id order by user_id ) as Ranking FROM ChannelMembership WHERE user_id != ? \ ) cm on cm.channel_id=c.id \ LEFT JOIN User u ON cm.user_id=u.id \ LEFT JOIN Team t ON t.id = c.team_id \ - WHERE c.id = '\(channelId)' AND c.delete_at = 0 + WHERE c.id = ? AND c.delete_at = 0 GROUP BY c.id, my.last_viewed_at """ - - let results: [T] = try db.prepareRowIterator(stmtString).map { try $0.decode()} + + let bindings: [String] = [currentUserId, channelId] + let results: [T] = try db.prepareRowIterator(stmtString, bindings: bindings).map { try $0.decode()} return results.first } catch { + GekidouLogger.shared.log(.error, "Gekidou Database: Failed to get current channel with team for server %{public}@ - %{public}@", serverUrl, String(describing: error)) return nil } } @@ -92,14 +94,14 @@ extension Database { do { let db = try getDatabaseForServer(serverUrl) guard let currentUserId = try? queryCurrentUserId(serverUrl) else {return []} - + let stmtString = """ SELECT DISTINCT c.*, t.display_name AS team_display_name, \ my.last_viewed_at, COUNT(DISTINCT cm.user_id) AS member_count, u.delete_at > 0 as deactivated \ FROM Channel c \ INNER JOIN MyChannel my ON c.id=my.id \ LEFT JOIN (\ - SELECT *, ROW_NUMBER() OVER (PARTITION by user_id order by user_id ) as Ranking FROM ChannelMembership WHERE user_id != '\(currentUserId)' \ + SELECT *, ROW_NUMBER() OVER (PARTITION by user_id order by user_id ) as Ranking FROM ChannelMembership WHERE user_id != ? \ ) cm on cm.channel_id=c.id \ LEFT JOIN User u ON cm.user_id=u.id \ LEFT JOIN Team t ON t.id = c.team_id \ @@ -108,12 +110,14 @@ extension Database { ORDER BY my.last_viewed_at DESC \ LIMIT 20 """ - - let results: [T] = try db.prepareRowIterator(stmtString) + + let bindings: [String] = [currentUserId] + let results: [T] = try db.prepareRowIterator(stmtString, bindings: bindings) .map { try $0.decode()} - + return results } catch { + GekidouLogger.shared.log(.error, "Gekidou Database: Failed to get channels with team for server %{public}@ - %{public}@", serverUrl, String(describing: error)) return [] } } @@ -161,49 +165,50 @@ extension Database { do { guard let currentUserId = try? queryCurrentUserId(serverUrl) else {return []} let db = try getDatabaseForServer(serverUrl) - + let onlyDMs = term.starts(with: "@") ? "AND c.type = 'D'" : "" var username: String = "" var displayName: String = "" let searchTerm = term.removePrefix("@").lowercased() - var bindings: [String] = [] + var bindings: [String] = [currentUserId, currentUserId] // First two bindings for currentUserId in subquery and JOIN if matchStart { username = "u.username LIKE ?" bindings.append("\(searchTerm)%") // binding for username - + displayName = "c.display_name LIKE ?" bindings.append("\(searchTerm)%") // binding for displayname } else { username = "u.username LIKE ? AND u.username NOT LIKE ?" bindings.append("%\(searchTerm)%") bindings.append("\(searchTerm)%") - + displayName = "c.display_name LIKE ? AND c.display_name NOT LIKE ?" bindings.append("%\(searchTerm)%") bindings.append("\(searchTerm)%") } - + let stmtString = """ SELECT DISTINCT c.*, "" AS team_display_name, \ my.last_viewed_at, COUNT(DISTINCT cm.user_id) AS member_count, u.delete_at > 0 as deactivated \ FROM Channel c \ INNER JOIN MyChannel my ON c.id=my.id \ LEFT JOIN (\ - SELECT *, ROW_NUMBER() OVER (PARTITION by user_id order by user_id ) as Ranking FROM ChannelMembership WHERE user_id != '\(currentUserId)' \ + SELECT *, ROW_NUMBER() OVER (PARTITION by user_id order by user_id ) as Ranking FROM ChannelMembership WHERE user_id != ? \ ) cm on cm.channel_id=c.id \ - LEFT JOIN User u ON u.id=cm.user_id AND (CASE WHEN c.type = 'D' THEN cm.user_id != '\(currentUserId)' ELSE 1 END) + LEFT JOIN User u ON u.id=cm.user_id AND (CASE WHEN c.type = 'D' THEN cm.user_id != ? ELSE 1 END) WHERE c.delete_at = 0 AND c.team_id = '' AND \(displayName) \(onlyDMs) \ OR CASE WHEN c.type = 'G' THEN 0 ELSE \(username) END \ GROUP BY c.id, my.last_viewed_at \ ORDER BY CASE c.type WHEN 'D' THEN 0 ELSE 1 END ASC, my.last_viewed_at DESC \ LIMIT 20 """ - + let results: [T] = try db.prepareRowIterator(stmtString, bindings: bindings) .map{ try $0.decode()} - + return results } catch { + GekidouLogger.shared.log(.error, "Gekidou Database: Failed to search direct channels for server %{public}@ with term %{public}@ - %{public}@", serverUrl, term, String(describing: error)) return [] } } diff --git a/ios/Gekidou/Sources/Gekidou/Storage/Database+Posts.swift b/ios/Gekidou/Sources/Gekidou/Storage/Database+Posts.swift index 4d3b024b2..cd98c2119 100644 --- a/ios/Gekidou/Sources/Gekidou/Storage/Database+Posts.swift +++ b/ios/Gekidou/Sources/Gekidou/Storage/Database+Posts.swift @@ -110,9 +110,10 @@ extension Database { let sortedAndNotDeletedPosts = sortedChainedPosts.filter({$0.deleteAt == 0}) if (!receivingThreads) { - if !sortedAndNotDeletedPosts.isEmpty { - let earliest = sortedAndNotDeletedPosts.first!.createAt - let latest = sortedAndNotDeletedPosts.last!.createAt + if let first = sortedAndNotDeletedPosts.first, + let last = sortedAndNotDeletedPosts.last { + let earliest = first.createAt + let latest = last.createAt try handlePostsInChannel(db, channelId, earliest, latest) } } @@ -294,13 +295,40 @@ extension Database { return postsSetters } - - private func createPostMetadataSetters(from post: Post) -> MetadataSetters { + + private func createReactionSetters(from reactions: [Any], postId: String) -> [[Setter]] { let id = Expression("id") let userId = Expression("user_id") - let postId = Expression("post_id") + let postIdCol = Expression("post_id") let emojiName = Expression("emoji_name") let createAt = Expression("create_at") + let statusCol = Expression("_status") + + var reactionSetters = [[Setter]]() + for reaction in reactions { + if let r = reaction as? [String: Any], + let userIdValue = r["user_id"] as? String, + let postIdValue = r["post_id"] as? String, + let emojiNameValue = r["emoji_name"] as? String, + let createAtValue = r["create_at"] as? Double { + var reactionSetter = [Setter]() + reactionSetter.append(id <- generateId()) + reactionSetter.append(userId <- userIdValue) + reactionSetter.append(postIdCol <- postIdValue) + reactionSetter.append(emojiName <- emojiNameValue) + reactionSetter.append(createAt <- createAtValue) + reactionSetter.append(statusCol <- "created") + reactionSetters.append(reactionSetter) + } else { + GekidouLogger.shared.log(.warning, "Gekidou Database: Skipping malformed reaction in post %{public}@", postId) + } + } + return reactionSetters + } + + private func createFileSetters(from files: [Any], postId: String) -> [[Setter]] { + let id = Expression("id") + let postIdCol = Expression("post_id") let name = Expression("name") let ext = Expression("extension") let size = Expression("size") @@ -310,82 +338,111 @@ extension Database { let localPath = Expression("local_path") let imageThumbnail = Expression("image_thumbnail") let statusCol = Expression("_status") - + + var fileSetters = [[Setter]]() + for file in files { + if let f = file as? [String: Any], + let idValue = f["id"] as? String, + let postIdValue = f["post_id"] as? String, + let nameValue = f["name"] as? String, + let extValue = f["extension"] as? String, + let sizeValue = f["size"] as? Double, + let mimeTypeValue = f["mime_type"] as? String { + var fileSetter = [Setter]() + fileSetter.append(id <- idValue) + fileSetter.append(postIdCol <- postIdValue) + fileSetter.append(name <- nameValue) + fileSetter.append(ext <- extValue) + fileSetter.append(size <- sizeValue) + fileSetter.append(mimeType <- mimeTypeValue) + fileSetter.append(width <- (f["width"] as? Double ?? 0)) + fileSetter.append(height <- (f["height"] as? Double ?? 0)) + fileSetter.append(localPath <- "") + fileSetter.append(imageThumbnail <- (f["mini_preview"] as? String ?? "")) + fileSetter.append(statusCol <- "created") + fileSetters.append(fileSetter) + } else { + GekidouLogger.shared.log(.warning, "Gekidou Database: Skipping malformed file in post %{public}@", postId) + } + } + return fileSetters + } + + private func createEmojiSetters(from emojis: [Any], postId: String) -> [[Setter]] { + let id = Expression("id") + let name = Expression("name") + let statusCol = Expression("_status") + + var emojiSetters = [[Setter]]() + for emoji in emojis { + if let e = emoji as? [String: Any], + let idValue = e["id"] as? String, + let nameValue = e["name"] as? String { + var emojiSetter = [Setter]() + emojiSetter.append(id <- idValue) + emojiSetter.append(name <- nameValue) + emojiSetter.append(statusCol <- "created") + emojiSetters.append(emojiSetter) + } else { + GekidouLogger.shared.log(.warning, "Gekidou Database: Skipping malformed emoji in post %{public}@", postId) + } + } + return emojiSetters + } + + private func createPostMetadataSetters(from post: Post) -> MetadataSetters { var metadataString = "{}" var reactionSetters = [[Setter]]() var fileSetters = [[Setter]]() var emojiSetters = [[Setter]]() - - let json = try? JSONSerialization.jsonObject(with: post.metadata.data(using: .utf8)!, options: []) - if var metadata = json as? [String: Any] { - // Reaction setters - if let reactions = metadata["reactions"] as? [Any] { - for reaction in reactions { - if let r = reaction as? [String: Any] { - var reactionSetter = [Setter]() - reactionSetter.append(id <- generateId()) - reactionSetter.append(userId <- r["user_id"] as! String) - reactionSetter.append(postId <- r["post_id"] as! String) - reactionSetter.append(emojiName <- r["emoji_name"] as! String) - reactionSetter.append(createAt <- r["create_at"] as! Double) - reactionSetter.append(statusCol <- "created") - reactionSetters.append(reactionSetter) - } - } + guard let metadataData = post.metadata.data(using: .utf8) else { + GekidouLogger.shared.log(.error, "Gekidou Database: Failed to encode post metadata as UTF-8 for post %{public}@", post.id) + return MetadataSetters(metadata: metadataString, reactionSetters: reactionSetters, fileSetters: fileSetters, emojiSetters: emojiSetters) + } + + do { + guard let json = try JSONSerialization.jsonObject(with: metadataData, options: []) as? [String: Any] else { + GekidouLogger.shared.log(.error, "Gekidou Database: Failed to decode post metadata JSON as dictionary for post %{public}@", post.id) + return MetadataSetters(metadata: metadataString, reactionSetters: reactionSetters, fileSetters: fileSetters, emojiSetters: emojiSetters) + } + + var metadata = json + + // Process reactions + if let reactions = metadata["reactions"] as? [Any] { + reactionSetters = createReactionSetters(from: reactions, postId: post.id) metadata.removeValue(forKey: "reactions") } - // File setters + // Process files if let files = metadata["files"] as? [Any] { - for file in files { - if let f = file as? [String: Any] { - var fileSetter = [Setter]() - fileSetter.append(id <- f["id"] as! String) - fileSetter.append(postId <- f["post_id"] as! String) - fileSetter.append(name <- f["name"] as! String) - fileSetter.append(ext <- f["extension"] as! String) - fileSetter.append(size <- f["size"] as! Double) - fileSetter.append(mimeType <- f["mime_type"] as! String) - fileSetter.append(width <- (f["width"] as? Double ?? 0)) - fileSetter.append(height <- (f["height"] as? Double ?? 0)) - fileSetter.append(localPath <- "") - fileSetter.append(imageThumbnail <- (f["mini_preview"] as? String ?? "")) - fileSetter.append(statusCol <- "created") - - fileSetters.append(fileSetter) - } - } - + fileSetters = createFileSetters(from: files, postId: post.id) metadata.removeValue(forKey: "files") } - // Emoji setters + // Process emojis if let emojis = metadata["emojis"] as? [Any] { - for emoji in emojis { - if let e = emoji as? [String: Any] { - var emojiSetter = [Setter]() - emojiSetter.append(id <- e["id"] as! String) - emojiSetter.append(name <- e["name"] as! String) - emojiSetter.append(statusCol <- "created") - - emojiSetters.append(emojiSetter) - } - } - + emojiSetters = createEmojiSetters(from: emojis, postId: post.id) metadata.removeValue(forKey: "emojis") } - // Remaining Metadata - - let dataJSON = try! JSONSerialization.data(withJSONObject: metadata, options: []) - metadataString = String(data: dataJSON, encoding: String.Encoding.utf8)! + // Serialize remaining metadata + do { + let dataJSON = try JSONSerialization.data(withJSONObject: metadata, options: []) + if let metadataStringValue = String(data: dataJSON, encoding: String.Encoding.utf8) { + metadataString = metadataStringValue + } else { + GekidouLogger.shared.log(.error, "Gekidou Database: Failed to encode remaining metadata as UTF-8 string for post %{public}@", post.id) + } + } catch { + GekidouLogger.shared.log(.error, "Gekidou Database: Failed to serialize remaining metadata to JSON for post %{public}@ - %{public}@", post.id, String(describing: error)) + } + } catch { + GekidouLogger.shared.log(.error, "Gekidou Database: Failed to parse post metadata JSON for post %{public}@ - %{public}@", post.id, String(describing: error)) } - return MetadataSetters(metadata: metadataString, - reactionSetters: reactionSetters, - fileSetters: fileSetters, - emojiSetters: emojiSetters) + return MetadataSetters(metadata: metadataString, reactionSetters: reactionSetters, fileSetters: fileSetters, emojiSetters: emojiSetters) } private func createPostsInThreadSetters(_ db: Connection, from posts: [Post]) throws -> [[Setter]] { diff --git a/ios/Gekidou/Sources/Gekidou/Storage/Database+Team.swift b/ios/Gekidou/Sources/Gekidou/Storage/Database+Team.swift index 4221872a0..04e0405a1 100644 --- a/ios/Gekidou/Sources/Gekidou/Storage/Database+Team.swift +++ b/ios/Gekidou/Sources/Gekidou/Storage/Database+Team.swift @@ -59,10 +59,17 @@ extension Database { if let db = try? getDatabaseForServer(serverUrl) { let idCol = Expression("id") if let myTeams = try? db.prepare(myTeamTable.select(idCol)) { - return myTeams.map { try! $0.get(idCol) } + return myTeams.compactMap { row in + do { + return try row.get(idCol) + } catch { + GekidouLogger.shared.log(.error, "Gekidou Database: Failed to get team ID from row for server %{public}@ - %{public}@", serverUrl, String(describing: error)) + return nil + } + } } } - + return nil } diff --git a/ios/Gekidou/Sources/Gekidou/Storage/Database+Users.swift b/ios/Gekidou/Sources/Gekidou/Storage/Database+Users.swift index 056714c34..46448352c 100644 --- a/ios/Gekidou/Sources/Gekidou/Storage/Database+Users.swift +++ b/ios/Gekidou/Sources/Gekidou/Storage/Database+Users.swift @@ -63,13 +63,15 @@ extension Database { var updateAt: Double? do { let db = try getDatabaseForServer(serverUrl) - - let stmtString = "SELECT * FROM User WHERE id='\(userId)'" - - let results: [User] = try db.prepareRowIterator(stmtString).map {try $0.decode()} + + let idCol = Expression("id") + let query = userTable.where(idCol == userId) + + let results: [User] = try db.prepare(query).map {try $0.decode()} updateAt = results.first?.lastPictureUpdate } catch { + GekidouLogger.shared.log(.error, "Gekidou Database: Failed to get user last picture update for userId %{public}@, server %{public}@ - %{public}@", userId, serverUrl, String(describing: error)) return nil } diff --git a/ios/Gekidou/Sources/Gekidou/Storage/Database.swift b/ios/Gekidou/Sources/Gekidou/Storage/Database.swift index 8e5314a86..29a31622f 100644 --- a/ios/Gekidou/Sources/Gekidou/Storage/Database.swift +++ b/ios/Gekidou/Sources/Gekidou/Storage/Database.swift @@ -72,11 +72,23 @@ public class Database: NSObject { @objc public static let `default` = Database() override private init() { - let appGroupId = Bundle.main.object(forInfoDictionaryKey: "AppGroupIdentifier") as! String - let sharedDirectory = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroupId)! + guard let appGroupId = Bundle.main.object(forInfoDictionaryKey: "AppGroupIdentifier") as? String else { + GekidouLogger.shared.log(.error, "Gekidou Database: AppGroupIdentifier missing from Info.plist - database operations will fail") + DEFAULT_DB_PATH = "" + super.init() + return + } + + guard let sharedDirectory = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroupId) else { + GekidouLogger.shared.log(.error, "Gekidou Database: Failed to get shared container for app group %{public}@ - database operations will fail", appGroupId) + DEFAULT_DB_PATH = "" + super.init() + return + } + let databaseUrl = sharedDirectory.appendingPathComponent("databases/\(DEFAULT_DB_NAME)") - DEFAULT_DB_PATH = databaseUrl.path + super.init() } @objc public func getOnlyServerUrlObjc() -> String { @@ -93,42 +105,50 @@ public class Database: NSObject { } public func getOnlyServerUrl() throws -> String { - let db = try Connection(DEFAULT_DB_PATH) - let url = Expression("url") - let identifier = Expression("identifier") - let lastActiveAt = Expression("last_active_at") - let query = serversTable.select(url).filter(lastActiveAt > 0 && identifier != "") - - var serverUrl: String? - for result in try db.prepare(query) { - if (serverUrl != nil) { - throw DatabaseError.MultipleServers + do { + let db = try Connection(DEFAULT_DB_PATH) + let url = Expression("url") + let identifier = Expression("identifier") + let lastActiveAt = Expression("last_active_at") + let query = serversTable.select(url).filter(lastActiveAt > 0 && identifier != "") + + var serverUrl: String? + for result in try db.prepare(query) { + if (serverUrl != nil) { + throw DatabaseError.MultipleServers + } + + serverUrl = try result.get(url) } - - serverUrl = try result.get(url) + + if let serverUrl = serverUrl { + return serverUrl + } + + throw DatabaseError.NoResults(query.expression.description) + } catch { + GekidouLogger.shared.log(.error, "Gekidou Database: Failed to get only server URL from %{public}@ - %{public}@", DEFAULT_DB_PATH, String(describing: error)) + throw error } - - if (serverUrl != nil) { - return serverUrl! - } - - throw DatabaseError.NoResults(query.expression.description) } - + public func getServerUrlForServer(_ id: String) throws -> String { - let db = try Connection(DEFAULT_DB_PATH) - let url = Expression("url") - let identifier = Expression("identifier") - let query = serversTable.select(url).filter(identifier == id) - - if let server = try db.pluck(query) { - let serverUrl: String? = try server.get(url) - if (serverUrl != nil) { - return serverUrl! + do { + let db = try Connection(DEFAULT_DB_PATH) + let url = Expression("url") + let identifier = Expression("identifier") + let query = serversTable.select(url).filter(identifier == id) + + if let server = try db.pluck(query), + let serverUrl = try? server.get(url) { + return serverUrl } + + throw DatabaseError.NoResults(query.expression.description) + } catch { + GekidouLogger.shared.log(.error, "Gekidou Database: Failed to get server URL for server %{public}@ from %{public}@ - %{public}@", id, DEFAULT_DB_PATH, String(describing: error)) + throw error } - - throw DatabaseError.NoResults(query.expression.description) } public func getAllActiveDatabases() -> [T] { diff --git a/ios/GekidouWrapper.swift b/ios/GekidouWrapper.swift index 91292a422..49764d8ce 100644 --- a/ios/GekidouWrapper.swift +++ b/ios/GekidouWrapper.swift @@ -9,6 +9,7 @@ import Foundation import Gekidou import react_native_emm +import TurboLogIOSNative @objc class GekidouWrapper: NSObject { @objc public static let `default` = GekidouWrapper() @@ -17,6 +18,24 @@ import react_native_emm ScreenCaptureManager.startTrackingScreens() } + @objc func configureTurboLogForGekidou() { + GekidouLogger.shared.setLogHandler { level, message in + let turboLevel: TurboLogIOSNative.TurboLogLevel + switch level { + case .debug: + turboLevel = .debug + case .info: + turboLevel = .info + case .warning: + turboLevel = .warning + case .error: + turboLevel = .error + } + + TurboLogIOSNative.TurboLogger.write(level: turboLevel, message: message) + } + } + @objc func postNotificationReceipt(_ userInfo: [AnyHashable:Any]) { PushNotification.default.postNotificationReceipt(userInfo) } diff --git a/ios/Mattermost/AppDelegate.mm b/ios/Mattermost/AppDelegate.mm index 16aefed9f..bd255c14d 100644 --- a/ios/Mattermost/AppDelegate.mm +++ b/ios/Mattermost/AppDelegate.mm @@ -38,6 +38,10 @@ NSString* const NOTIFICATION_TEST_ACTION = @"test"; NSLog(@"Failed to configure TurboLog: %@", error.localizedDescription); } [TurboLog writeWithLogLevel:TurboLogLevelInfo message:@[@"Configured turbolog"]]; + + // Configure Gekidou to use TurboLog via wrapper + [[GekidouWrapper default] configureTurboLogForGekidou]; + OrientationManager.shared.delegate = self; // Clear keychain on first run in case of reinstallation @@ -111,11 +115,21 @@ NSString* const NOTIFICATION_TEST_ACTION = @"test"; if (state != UIApplicationStateActive) { [[GekidouWrapper default] fetchDataForPushNotification:userInfo withContentHandler:^(NSData * _Nullable data) { NSMutableDictionary *notification = [userInfo mutableCopy]; - NSError *jsonError; + if (notification == nil) { + [TurboLog writeWithLogLevel:TurboLogLevelError message:@[@"Mattermost AppDelegate: Failed to copy userInfo dictionary"]]; + completionHandler(UIBackgroundFetchResultFailed); + return; + } + if (data != nil) { + NSError *jsonError = nil; id json = [NSJSONSerialization JSONObjectWithData:data options:NULL error:&jsonError]; - if (!jsonError) { + if (jsonError) { + [TurboLog writeWithLogLevel:TurboLogLevelError message:@[@"Mattermost AppDelegate: JSON serialization error", jsonError.localizedDescription]]; + } else if (json != nil) { [notification setObject:json forKey:@"data"]; + } else { + [TurboLog writeWithLogLevel:TurboLogLevelWarning message:@[@"Mattermost AppDelegate: JSON serialization returned nil without error"]]; } } [RNNotifications didReceiveBackgroundNotification:notification withCompletionHandler:completionHandler]; diff --git a/ios/Mattermost/Helpers/NotificationHelper.swift b/ios/Mattermost/Helpers/NotificationHelper.swift index 58ca9f9fb..b2264029b 100644 --- a/ios/Mattermost/Helpers/NotificationHelper.swift +++ b/ios/Mattermost/Helpers/NotificationHelper.swift @@ -21,9 +21,9 @@ import UIKit guard let serverUrl = try? Gekidou.Database.default.getServerUrlForServer(serverId) else { return } - if !skipThreadNotification && channelId != nil { - removeChannelNotifications(serverUrl: serverUrl, channelId: channelId!) - try? Gekidou.Database.default.resetMyChannelMentions(serverUrl, channelId!) + if !skipThreadNotification, let channelId = channelId { + removeChannelNotifications(serverUrl: serverUrl, channelId: channelId) + try? Gekidou.Database.default.resetMyChannelMentions(serverUrl, channelId) } else if !rootId.isEmpty { removeThreadNotifications(serverUrl: serverUrl, threadId: rootId) try? Gekidou.Database.default.resetThreadMentions(serverUrl, rootId) diff --git a/ios/NotificationService/NotificationService.swift b/ios/NotificationService/NotificationService.swift index 2ecedef18..32dd57652 100644 --- a/ios/NotificationService/NotificationService.swift +++ b/ios/NotificationService/NotificationService.swift @@ -5,53 +5,134 @@ import os.log import TurboLogIOSNative class NotificationService: UNNotificationServiceExtension { - var contentHandler: ((UNNotificationContent) -> Void)? - var bestAttemptContent: UNMutableNotificationContent? - + + // Thread-safe wrapper to ensure contentHandler is called exactly once + // This prevents crashes from multiple contentHandler invocations which terminates the extension + private class ContentHandlerSafeWrapper { + private var handler: ((UNNotificationContent) -> Void)? + private let lock = NSLock() + private var hasBeenCalled = false + + init(_ handler: @escaping (UNNotificationContent) -> Void) { + self.handler = handler + } + + func callOnce(with content: UNNotificationContent) { + lock.lock() + defer { lock.unlock() } + + guard !hasBeenCalled, let handler = handler else { + return + } + + hasBeenCalled = true + handler(content) + self.handler = nil + } + } + + private var contentHandlerWrapper: ContentHandlerSafeWrapper? + private var isLoggerConfigured = false + + // Thread-safe access to bestAttemptContent + private let contentQueue = DispatchQueue(label: "com.mattermost.notification.content", attributes: .concurrent) + private var _bestAttemptContent: UNMutableNotificationContent? + + var bestAttemptContent: UNMutableNotificationContent? { + get { + return contentQueue.sync { _bestAttemptContent } + } + set { + contentQueue.sync(flags: .barrier) { [weak self] in + self?._bestAttemptContent = newValue + } + } + } + override init() { super.init() initSentryAppExt() + + // Safely configure TurboLogger without force unwrapping + guard let appGroupId = Bundle.main.object(forInfoDictionaryKey: "AppGroupIdentifier") as? String else { + os_log(OSLogType.error, "NotificationService init: AppGroupIdentifier missing from Info.plist") + return + } + + guard let containerUrl = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroupId) else { + os_log(OSLogType.error, "NotificationService init: Failed to get container URL for app group: %{public}@", appGroupId) + return + } + do { - let appGroupId = Bundle.main.object(forInfoDictionaryKey: "AppGroupIdentifier") as! String - let containerUrl = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroupId) - try TurboLogIOSNative.TurboLogger.configure(dailyRolling: false, maximumFileSize: 1024*1024, maximumNumberOfFiles: 2, logsDirectory: containerUrl!.appendingPathComponent("Logs").path, logsFilename: "MMLogs") + try TurboLogIOSNative.TurboLogger.configure( + dailyRolling: false, + maximumFileSize: 1024*1024, + maximumNumberOfFiles: 2, + logsDirectory: containerUrl.appendingPathComponent("Logs").path, + logsFilename: "MMLogs" + ) + isLoggerConfigured = true + + // Configure Gekidou to use TurboLogger + Gekidou.GekidouLogger.shared.setLogHandler { level, message in + let turboLevel: TurboLogIOSNative.TurboLogLevel + switch level { + case .debug: + turboLevel = .debug + case .info: + turboLevel = .info + case .warning: + turboLevel = .warning + case .error: + turboLevel = .error + } + + // Message is already formatted by GekidouLogger + TurboLogIOSNative.TurboLogger.write(level: turboLevel, message: message) + } } catch { - os_log(OSLogType.default, "Failed to configure TurboLogger: %{public}@", String(describing: error)) + os_log(OSLogType.error, "NotificationService init: Failed to configure TurboLogger: %{public}@", String(describing: error)) } } override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) { - self.contentHandler = contentHandler + // Create thread-safe wrapper for contentHandler + self.contentHandlerWrapper = ContentHandlerSafeWrapper(contentHandler) - TurboLogIOSNative.TurboLogger.write(level: .info, message: "Mattermost Notifications: received notification") + Gekidou.GekidouLogger.shared.log(.info, "NotificationService didReceive: received notification") bestAttemptContent = (request.content.mutableCopy() as? UNMutableNotificationContent) if let bestAttemptContent = bestAttemptContent { PushNotification.default.postNotificationReceipt(bestAttemptContent, completionHandler: {[weak self] notification in + guard let self = self else { return } + if let notification = notification { - self?.bestAttemptContent = notification + self.bestAttemptContent = notification if (!PushNotification.default.verifySignatureFromNotification(notification)) { - TurboLogIOSNative.TurboLogger.write(level: .info, message: "Mattermost Notifications: signature not verified. Will call sendInvalidNotificationIntent") - self?.sendInvalidNotificationIntent() + Gekidou.GekidouLogger.shared.log(.info, "NotificationService didReceive: signature not verified. Will call sendInvalidNotificationIntent") + self.sendInvalidNotificationIntent() return } if (Gekidou.Preferences.default.object(forKey: "ApplicationIsRunning") as? String != "true") { - PushNotification.default.fetchAndStoreDataForPushNotification(bestAttemptContent, withContentHandler: {notification in - TurboLogIOSNative.TurboLogger.write(level: .info, message: "Mattermost Notifications: processed data for db. Will call sendMessageIntent") - self?.sendMessageIntent() + Gekidou.GekidouLogger.shared.log(.debug, "NotificationService didReceive: app not in use, processing data to store in db") + PushNotification.default.fetchAndStoreDataForPushNotification(notification, withContentHandler: {[weak self] notification in + guard let self = self else { return } + Gekidou.GekidouLogger.shared.log(.info, "NotificationService didReceive: processed data for db. Will call sendMessageIntent") + self.sendMessageIntent() }) } else { - bestAttemptContent.badge = nil - TurboLogIOSNative.TurboLogger.write(level: .info, message: "Mattermost Notifications: app in use, no data processed. Will call sendMessageIntent") - self?.sendMessageIntent() + notification.badge = nil + Gekidou.GekidouLogger.shared.log(.info, "NotificationService didReceive: app in use, no data processed. Will call sendMessageIntent") + self.sendMessageIntent() } return } - - TurboLogIOSNative.TurboLogger.write(level: .info, message: "Mattermost Notifications: notification receipt seems to be empty, will call sendMessageIntent") - self?.sendMessageIntent() + + Gekidou.GekidouLogger.shared.log(.info, "NotificationService didReceive: notification receipt seems to be empty, will call sendMessageIntent") + self.sendMessageIntent() }) } else { - TurboLogIOSNative.TurboLogger.write(level: .info, message: "Mattermost Notifications: bestAttemptContent seems to be empty, will call sendMessageIntent") + Gekidou.GekidouLogger.shared.log(.info, "NotificationService didReceive: bestAttemptContent seems to be empty, will call sendMessageIntent") sendMessageIntent() } } @@ -59,9 +140,16 @@ 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. - TurboLogIOSNative.TurboLogger.write(level: .info, message: "Mattermost Notifications: service extension time expired") - TurboLogIOSNative.TurboLogger.write(level: .info, message: "Mattermost Notifications: calling sendMessageIntent before expiration") - sendMessageIntent() + Gekidou.GekidouLogger.shared.log(.info, "NotificationService serviceExtensionTimeWillExpire: service extension time expired") + + // Cancel all pending network requests to prevent callbacks after termination + Network.default.cancelAllRequests() + + // Deliver the best content we have, even if incomplete + if let bestAttemptContent = bestAttemptContent { + Gekidou.GekidouLogger.shared.log(.info, "NotificationService serviceExtensionTimeWillExpire: delivering best attempt content before expiration") + contentHandlerWrapper?.callOnce(with: bestAttemptContent) + } } private func sendMessageIntent() { @@ -72,13 +160,13 @@ class NotificationService: UNNotificationServiceExtension { guard let serverUrl = notification.userInfo["server_url"] as? String else { - TurboLogIOSNative.TurboLogger.write(level: .info, message: "Mattermost Notifications: No intent created. will call contentHandler to present notification") - self.contentHandler?(notification) + Gekidou.GekidouLogger.shared.log(.info, "NotificationService sendMessageIntent: No intent created. will call contentHandler to present notification") + contentHandlerWrapper?.callOnce(with: notification) return } let overrideIconUrl = notification.userInfo["override_icon_url"] as? String - TurboLogIOSNative.TurboLogger.write(level: .info, message: "Mattermost Notifications: Fetching profile Image in server", serverUrl, "for sender", senderId ?? overrideUsername ?? "no sender is set") + Gekidou.GekidouLogger.shared.log(.info, "NotificationService sendMessageIntent: Fetching profile Image in server", serverUrl, "for sender", senderId ?? overrideUsername ?? "no sender is set") if senderId != nil || overrideIconUrl != nil { PushNotification.default.fetchProfileImageSync(serverUrl, senderId: senderId ?? "", overrideIconUrl: overrideIconUrl) {[weak self] data in self?.sendMessageIntentCompletion(data) @@ -86,47 +174,63 @@ class NotificationService: UNNotificationServiceExtension { } else { self.sendMessageIntentCompletion(nil) } + } else { + // iOS < 15.0, deliver notification without intent + contentHandlerWrapper?.callOnce(with: notification) } } + // Helper method to donate INIntent (assumes already on main thread) + @available(iOSApplicationExtension 15.0, *) + private func donateIntent(_ intent: INSendMessageIntent, with notification: UNNotificationContent) { + let interaction = INInteraction(intent: intent, response: nil) + interaction.direction = .incoming + interaction.donate { [weak self] error in + guard let self = self else { return } + + if let error = error { + Gekidou.GekidouLogger.shared.log(.error, "NotificationService donateIntent: intent donation error: \(String(describing: error))") + self.contentHandlerWrapper?.callOnce(with: notification) + return + } + + do { + let updatedContent = try notification.updating(from: intent) + Gekidou.GekidouLogger.shared.log(.info, "NotificationService donateIntent: present updated notification") + self.contentHandlerWrapper?.callOnce(with: updatedContent) + } catch { + Gekidou.GekidouLogger.shared.log(.error, "NotificationService donateIntent: failed updating notification from intent: \(String(describing: error))") + self.contentHandlerWrapper?.callOnce(with: notification) + } + } + } + private func sendInvalidNotificationIntent() { guard let notification = bestAttemptContent else { return } - TurboLogIOSNative.TurboLogger.write(level: .info, message: "Mattermost Notifications: creating invalid intent") - + Gekidou.GekidouLogger.shared.log(.info, "NotificationService sendInvalidNotificationIntent: creating invalid intent") + bestAttemptContent?.body = NSLocalizedString( "native.ios.notifications.not_verified", value: "We could not verify this notification with the server", comment: "") bestAttemptContent?.userInfo.updateValue("false", forKey: "verified") - + if #available(iOSApplicationExtension 15.0, *) { - let intent = INSendMessageIntent(recipients: nil, - outgoingMessageType: .outgoingMessageText, - content: "We could not verify this notification with the server", - speakableGroupName: nil, - conversationIdentifier: "NOT_VERIFIED", - serviceName: nil, - sender: nil, - attachments: nil) - - let interaction = INInteraction(intent: intent, response: nil) - interaction.direction = .incoming - interaction.donate { error in - if error != nil { - self.contentHandler?(notification) - TurboLogIOSNative.TurboLogger.write(level: .info, message: "Mattermost Notifications: sendMessageIntent intent error", error! as CVarArg) - } - - do { - let updatedContent = try notification.updating(from: intent) - TurboLogIOSNative.TurboLogger.write(level: .info, message: "Mattermost Notifications: present updated notification") - self.contentHandler?(updatedContent) - } catch { - TurboLogIOSNative.TurboLogger.write(level: .info, message: "Mattermost Notifications: something failed updating the notification", error as CVarArg) - self.contentHandler?(notification) - } + // Create INIntent on main thread for thread safety + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + + let intent = INSendMessageIntent(recipients: nil, + outgoingMessageType: .outgoingMessageText, + content: "We could not verify this notification with the server", + speakableGroupName: nil, + conversationIdentifier: "NOT_VERIFIED", + serviceName: nil, + sender: nil, + attachments: nil) + self.donateIntent(intent, with: notification) } } else { - self.contentHandler?(notification) + contentHandlerWrapper?.callOnce(with: notification) } } @@ -135,7 +239,7 @@ class NotificationService: UNNotificationServiceExtension { if #available(iOSApplicationExtension 15.0, *), let imgData = avatarData, let channelId = notification.userInfo["channel_id"] as? String { - TurboLogIOSNative.TurboLogger.write(level: .info, message: "Mattermost Notifications: creating intent") + Gekidou.GekidouLogger.shared.log(.info, "NotificationService sendMessageIntentCompletion: creating intent") let isCRTEnabled = notification.userInfo["is_crt_enabled"] as? Bool ?? false let rootId = notification.userInfo["root_id"] as? String ?? "" @@ -145,13 +249,12 @@ class NotificationService: UNNotificationServiceExtension { let overrideUsername = notification.userInfo["override_username"] as? String let senderId = notification.userInfo["sender_id"] as? String let senderIdentifier = overrideUsername ?? senderId - let avatar = INImage(imageData: imgData) as INImage? var conversationId = channelId if isCRTEnabled && !rootId.isEmpty { conversationId = rootId } - + if channelName == nil && message == "", let senderName = senderName, let body = bestAttemptContent?.body { @@ -159,42 +262,42 @@ class NotificationService: UNNotificationServiceExtension { bestAttemptContent?.body = message } - let handle = INPersonHandle(value: senderIdentifier, type: .unknown) - let sender = INPerson(personHandle: handle, - nameComponents: nil, - displayName: channelName ?? senderName, - 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) - TurboLogIOSNative.TurboLogger.write(level: .info, message: "Mattermost Notifications: sendMessageIntent intent error", error! as CVarArg) + // INImage must be created on main thread per Apple documentation + // This prevents crashes when interacting with the Intents framework + DispatchQueue.main.async { [weak self] in + guard let self = self else { + return } - - do { - let updatedContent = try notification.updating(from: intent) - TurboLogIOSNative.TurboLogger.write(level: .info, message: "Mattermost Notifications: present updated notification") - self.contentHandler?(updatedContent) - } catch { - TurboLogIOSNative.TurboLogger.write(level: .info, message: "Mattermost Notifications: something failed updating the notification", error as CVarArg) - self.contentHandler?(notification) + + guard let updatedNotification = self.bestAttemptContent else { + self.contentHandlerWrapper?.callOnce(with: notification) + return } + + // Create INImage on main thread + let avatar = INImage(imageData: imgData) as INImage? + + let handle = INPersonHandle(value: senderIdentifier, type: .unknown) + let sender = INPerson(personHandle: handle, + nameComponents: nil, + displayName: channelName ?? senderName, + 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) + + self.donateIntent(intent, with: updatedNotification) } } else { - self.contentHandler?(notification) + contentHandlerWrapper?.callOnce(with: notification) } } } diff --git a/package-lock.json b/package-lock.json index 91e5eb0c0..2b9632083 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,7 +6,7 @@ "packages": { "": { "name": "mattermost-mobile", - "version": "2.32.0", + "version": "2.34.0", "hasInstallScript": true, "license": "Apache 2.0", "dependencies": {