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>
This commit is contained in:
Elias Nahum 2025-11-01 19:19:31 +08:00 committed by GitHub
parent 67e709b414
commit 84846b01a9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
34 changed files with 992 additions and 508 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<NotificationClaims>(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
}

View file

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

View file

@ -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 []
}
}

View file

@ -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<String>("id")
let userId = Expression<String>("user_id")
let postId = Expression<String>("post_id")
let postIdCol = Expression<String>("post_id")
let emojiName = Expression<String>("emoji_name")
let createAt = Expression<Double>("create_at")
let statusCol = Expression<String>("_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<String>("id")
let postIdCol = Expression<String>("post_id")
let name = Expression<String>("name")
let ext = Expression<String>("extension")
let size = Expression<Double>("size")
@ -310,82 +338,111 @@ extension Database {
let localPath = Expression<String?>("local_path")
let imageThumbnail = Expression<String?>("image_thumbnail")
let statusCol = Expression<String>("_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<String>("id")
let name = Expression<String>("name")
let statusCol = Expression<String>("_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]] {

View file

@ -59,10 +59,17 @@ extension Database {
if let db = try? getDatabaseForServer(serverUrl) {
let idCol = Expression<String>("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
}

View file

@ -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<String>("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
}

View file

@ -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<String>("url")
let identifier = Expression<String>("identifier")
let lastActiveAt = Expression<Int64>("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<String>("url")
let identifier = Expression<String>("identifier")
let lastActiveAt = Expression<Int64>("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<String>("url")
let identifier = Expression<String>("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<String>("url")
let identifier = Expression<String>("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: Codable>() -> [T] {

View file

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

View file

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

View file

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

View file

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

2
package-lock.json generated
View file

@ -6,7 +6,7 @@
"packages": {
"": {
"name": "mattermost-mobile",
"version": "2.32.0",
"version": "2.34.0",
"hasInstallScript": true,
"license": "Apache 2.0",
"dependencies": {