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:
parent
67e709b414
commit
84846b01a9
34 changed files with 992 additions and 508 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
93
ios/Gekidou/Sources/Gekidou/Logger.swift
Normal file
93
ios/Gekidou/Sources/Gekidou/Logger.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 []
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]] {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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] {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
2
package-lock.json
generated
|
|
@ -6,7 +6,7 @@
|
|||
"packages": {
|
||||
"": {
|
||||
"name": "mattermost-mobile",
|
||||
"version": "2.32.0",
|
||||
"version": "2.34.0",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache 2.0",
|
||||
"dependencies": {
|
||||
|
|
|
|||
Loading…
Reference in a new issue